103 Commits

Author SHA1 Message Date
Devin Zuczek cc43d57172 Reapply "the client uploading something with 2 as the type expects it to have a /data/ prefix (holotar for example it seems)"
This reverts commit 6b7acc9435.
2026-08-05 17:58:11 -04:00
Devin Zuczek 8b804eaa33 add CDN /data/ endpoint 2026-08-05 17:54:07 -04:00
Devin Zuczek 6b7acc9435 Revert "the client uploading something with 2 as the type expects it to have a /data/ prefix (holotar for example it seems)"
This reverts commit 93a46871de.
2026-08-05 17:53:42 -04:00
Devin Zuczek 93a46871de the client uploading something with 2 as the type expects it to have a /data/ prefix (holotar for example it seems) 2026-08-05 17:27:27 -04:00
Devin Zuczek 4111bc49aa limit slideshow to 10 by default 2026-08-05 17:01:45 -04:00
Devin Zuczek f6561f1ec9 adjust "top" window 2026-08-05 15:49:59 -04:00
Devin Zuczek bc96a6245b hot inventions 2026-08-05 15:45:48 -04:00
Devin Zuczek a73dec7c13 extract published field for convenience 2026-08-05 15:39:25 -04:00
Devin Zuczek ae3bef4cc4 real featured inventions 2026-08-05 15:30:50 -04:00
Devin Zuczek 1f615bab4f fix incorrect balance push notification 2026-08-05 15:27:13 -04:00
Devin Zuczek a986d012f5 invention purchase, at least, how I think they should work 2026-08-05 14:58:55 -04:00
Devin Zuczek b82a5e1dc0 add some basic validation 2026-08-05 14:00:31 -04:00
Devin Zuczek 6bfd4d9e50 validation in some areas, maybe move this to schema later 2026-08-05 12:08:04 -04:00
Devin Zuczek 079c889ccb remove proxies and use native services w/cors 2026-08-05 11:13:15 -04:00
Devin Zuczek 9f4ce07aca track owned inventions 2026-08-04 22:33:47 -04:00
Devin Zuczek dfb1e9ab21 put back stubbed meta auth for sideloaded apks 2026-08-04 21:13:33 -04:00
Devin Zuczek 1d08ed8296 fix too many accounts error because the worker was behind a Cloudflare call, add username change 2026-08-04 21:04:00 -04:00
Devin Zuczek f3e2ab422c add friendly message 2026-08-04 20:10:16 -04:00
Devin Zuczek aa304dbede (wip) attendees 2026-08-04 18:54:40 -04:00
Devin Zuczek 10eb89ac12 (wip) events 2026-08-04 18:44:11 -04:00
Devin Zuczek 65611c15d8 fix custom loading screens, add private endpoint for match into instance 2026-08-04 17:50:21 -04:00
Devin Zuczek d6a0e3e6a6 add a few admin endpoints for matching into instances 2026-08-04 17:49:53 -04:00
Devin Zuczek 7d300fa836 update shape of room instance browser 2026-08-04 17:19:03 -04:00
Devin Zuczek 73bb7c4609 implement room ban 2026-08-04 15:41:49 -04:00
Devin Zuczek dbc6d15ef5 fix issue where polaroids come in with a different URL, pull from other bucket 2026-08-04 14:08:39 -04:00
Devin Zuczek db003d54ef add a non working banlist 2026-08-04 13:26:12 -04:00
Devin Zuczek 05b56e698e add room invites 2026-08-04 12:45:11 -04:00
Devin Zuczek dee7497fe5 add mutual friends endpoint 2026-08-04 12:26:44 -04:00
Devin Zuczek 12f6d7ab61 add warning table (maybe this is just a notification, later) 2026-08-04 12:06:09 -04:00
Devin Zuczek 8d1539de03 add player reports 2026-08-04 11:42:53 -04:00
Devin Zuczek 7e0c26a100 update quest invite 2026-08-04 09:47:16 -04:00
Devin Zuczek f94877347c free invention purchase 2026-08-04 02:01:51 -04:00
Devin Zuczek 23bc159c28 apps
fix birthday which causes various issues
2026-08-04 01:19:51 -04:00
devin 70df3cb6cb Add build status badge to README
Added a badge for the main branch build status.
2026-08-04 00:01:47 -04:00
Devin Zuczek 03b1c59f0d fix 2 test failures due to chat type notification 2026-08-03 22:56:04 -04:00
Devin Zuczek d298977790 drop old platform ID column 2026-08-03 22:48:57 -04:00
devin 821bf54b9b Update deployment requirements and instructions
Emphasize the importance of prerequisites for RecFlare deployment and clarify usage of `just` for installation.
2026-08-03 21:56:42 -04:00
Devin Zuczek 108b061019 increase cardinality of platform accounts 2026-08-03 20:32:23 -04:00
Devin Zuczek af2a2a0683 fix a couple room routes 2026-08-03 19:43:38 -04:00
devin 339a91735b Add account signup and turnstile, subroom perms (#24)
* turnstile

* require turnstile

* homepage refresh

* implemented rooms visited endpoint for friends

* update default profile pic

* add a meta download button

* add subroom permissions

* enable signup
2026-08-03 15:20:10 -04:00
Devin Zuczek a46f6db9d7 meta platform login 2026-08-02 01:57:01 -04:00
Devin Zuczek b3f1d04823 add invention blob hash which does not seem to help 2026-08-01 12:15:56 -04:00
Devin Zuczek 55cb769de9 add github workflow to test 2026-07-31 14:03:35 -04:00
Devin Zuczek 4c6b9679e4 fix room save shape 2026-07-28 23:49:32 -04:00
Devin Zuczek 6a910b27bd update auth for subroom paths 2026-07-28 23:28:25 -04:00
Devin Zuczek 9c8bf7087c fixup autopublish 2026-07-28 23:13:35 -04:00
Devin Zuczek d5ccad51d3 fixed room and subroom saving, add room saves 2026-07-28 20:29:20 -04:00
Devin Zuczek a28b9b4561 add subroom accessibility endpoint 2026-07-28 17:22:15 -04:00
Devin Zuczek 8c773da137 www stuff 2026-07-27 18:46:37 -04:00
Devin Zuczek d5e3d3946e blur bg image 2026-07-24 23:12:15 -04:00
Devin Zuczek beed8451d4 cute 2026-07-24 23:07:53 -04:00
Devin Zuczek 273d62ed80 new www 2026-07-24 23:01:03 -04:00
Devin Zuczek a5136d2bfa cdn openapi 2026-07-24 21:48:10 -04:00
Devin Zuczek 460839458c rooms openapi 2026-07-24 21:32:43 -04:00
Devin Zuczek 27c45792b8 beta subrooms (saving not working) 2026-07-24 21:00:42 -04:00
Devin Zuczek 568717bb53 migrate subrooms to own storage 2026-07-24 16:08:30 -04:00
Devin Zuczek 5ed9e765a5 clean up match code, remove old endpoints 2026-07-24 14:19:08 -04:00
Devin Zuczek cfc29cb175 update presence on drop 2026-07-23 22:19:25 -04:00
Devin Zuczek 82f011c0b0 fix invites 2026-07-23 18:09:16 -04:00
Devin Zuczek f39db8a15e fix chat and notifications 2026-07-23 18:09:03 -04:00
Devin Zuczek 95386c5de3 add club invite 2026-07-23 15:50:56 -04:00
Devin Zuczek a400de4a8b fix issue where avatar store buying broke 2026-07-23 14:35:21 -04:00
Devin Zuczek bceb8f6a4f remove items that do not exist 2026-07-23 14:34:22 -04:00
Devin Zuczek 725540a95b add playerevents endpoint, whatever that is 2026-07-23 14:28:32 -04:00
Devin Zuczek 2f6a40279d add version check for 20230414 client 2026-07-23 12:23:30 -04:00
Devin Zuczek 7ab171b646 v4 avatars 2026-07-23 02:19:17 -04:00
Devin Zuczek 23b78104e8 updating api docs 2026-07-22 11:43:30 -04:00
Devin Zuczek 68b98665b2 update api docs 2026-07-22 10:12:50 -04:00
Devin Zuczek 881663a4fe placeholder for community board 2026-07-22 10:12:50 -04:00
Devin Zuczek 7ca7aa0666 tweaks to chat, but not working right 2026-07-22 10:12:50 -04:00
devin 539e6150b0 Add DorkNet and update postman collection reference
Updated README to include additional project links.
2026-07-22 00:45:05 -04:00
Devin Zuczek df618af435 docs for api 2026-07-21 21:42:14 -04:00
Devin Zuczek ec324558a0 apps/notify some notify fixes 2026-07-21 19:32:38 -04:00
Devin Zuczek 40c38d7a18 add max rooms and clubs per account 2026-07-21 16:49:57 -04:00
Devin Zuczek c853cc1c6f clean up memory 2026-07-21 16:31:59 -04:00
Devin Zuczek 5df06ea168 fix club images 2026-07-21 16:28:53 -04:00
Devin Zuczek c80a25bd24 add club delete (club images not quite working) 2026-07-21 16:11:31 -04:00
Devin Zuczek 2a043d0042 Revert "store images as int"
This reverts commit 56f7695c71.
2026-07-21 15:57:57 -04:00
Devin Zuczek 56f7695c71 store images as int 2026-07-21 15:52:28 -04:00
Devin Zuczek 430825d72e club photos 2026-07-21 13:27:02 -04:00
Devin Zuczek 7f497fcee0 fix #19 clubhouses 2026-07-21 12:59:47 -04:00
Devin Zuczek d56fe3a276 oops update platform here too 2026-07-21 12:15:07 -04:00
Devin Zuczek dd3bcf6868 clean up auth and platform types 2026-07-21 12:14:10 -04:00
Devin Zuczek 9cb1135a12 add overview to api docs 2026-07-21 11:10:37 -04:00
Devin Zuczek cfde2cebf4 add equipment update 2026-07-21 01:52:37 -04:00
Devin Zuczek 086441f6f5 misc cleanup 2026-07-21 01:20:20 -04:00
Devin Zuczek 41fa8b9979 equipment 2026-07-21 01:20:03 -04:00
Devin Zuczek 0f301e5788 relationship notifications (maybe) 2026-07-21 01:19:37 -04:00
Devin Zuczek 08cf44991d chat, little buggy 2026-07-21 01:17:20 -04:00
Devin Zuczek 64bfc9f851 fake route for oculus 2026-07-20 21:38:40 -04:00
Devin Zuczek 21f7eff384 remove unused endpoint - think it was for 2018, this moved to playersettings 2026-07-20 19:21:41 -04:00
Devin Zuczek 019e7ade01 fixup docs 2026-07-20 19:08:41 -04:00
Devin Zuczek 7429ba4536 experimental api docs 2026-07-20 18:41:00 -04:00
Devin Zuczek 30bb6a131c tweaks to monoservice to return ns doc 2026-07-20 18:23:31 -04:00
Devin Zuczek 9ab4199bd5 tweak for returning ns 2026-07-20 18:15:20 -04:00
Devin Zuczek aebd4ca630 use path prefixes to make it easy 2026-07-20 18:10:39 -04:00
Devin Zuczek 86edf0ba66 update lockfile 2026-07-20 17:59:48 -04:00
Devin Zuczek 9eb9476270 econ docs 2026-07-20 17:59:37 -04:00
Devin Zuczek a4ddef2cf7 match docs, tweak for monowrapper 2026-07-20 17:59:00 -04:00
Devin Zuczek 52cd808e0a experimental monolithic service 2026-07-20 17:57:08 -04:00
Devin Zuczek 4cef0bdf5b accounts documentation 2026-07-20 16:20:54 -04:00
Devin Zuczek 355b459dc9 beta openapi docs 2026-07-20 15:33:08 -04:00
Devin Zuczek 3319a5d91a room delete endpoint :( 2026-07-20 11:37:59 -04:00
173 changed files with 49365 additions and 4598 deletions
+21
View File
@@ -52,7 +52,28 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
# RECFLARE_MAX_ACCOUNTS_PER_IP=3
# How many rooms one account may create (`rooms`) and how many clubs (`clubs`).
# Enforced on creation only — lowering either never touches what players already have,
# it just stops new ones. Set either to 0 to turn that cap off.
# ...ROOMS counts rooms the account created, minus their auto-provisioned dorm.
# ...CLUBS counts clubs the account created (subscription clubs don't count).
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
# RECFLARE_MAX_CLUBS_PER_ACCOUNT=10
# 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
+59
View File
@@ -55,6 +55,65 @@
- Explicit function return types are optional
</code-style>
<client-contract-notes>
Response shapes the Rec Room client depends on. These were found by watching the live
client, not by reading a spec: when one is wrong the client renders nothing or hangs
rather than erroring, so tests won't catch a regression. Don't "clean up" an
inconsistency here without checking the client first.
- Player image lists (`api`: `/api/images/v5|v4/player/:id`, `/api/images/v3/feed/player/:id`)
must use the `toImagesPlayer` projection — `Id``SavedImageId`, `Type`
`SavedImageType`, no `TaggedPlayerIds`. Serving the raw `SavedImage` renders blank
thumbnails.
- The room photo feed (`api`: `/api/images/v4/room/:roomId`) serves the raw `SavedImage`
and displays correctly. It is deliberately NOT projected — do not unify these two.
- A club's `AdditionalImages` (`clubs`) is an array of whole `SavedImage` records, not
image names — a bare string array fails the client's parser ("expected '{'"). The list
is packed: removing an image shifts the rest up, never leaving a blank slot.
- A room's `LoadScreens` (`rooms`: `PUT /rooms/:id/loadscreen`) is an array — the
client's parser wants one — but the client renders only the FIRST entry and only ever
posts one. So the endpoint REPLACES the list rather than appending: an appended screen
sits unreachable behind the old one and setting a load screen looks like it did
nothing. Keep the array shape for eventual multi-screen support.
- Endpoints the client re-renders from must return the updated entity, not
`{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old
clubhouse on screen until it answered the full details envelope.
- Every subroom mutation (`rooms`: create, delete, `/subrooms/:sid/clone`,
`/subrooms/:sid/accessibility`, `/subrooms/:sid/publish_save`) answers
`{ success, error, value }` with the whole updated ROOM — the client re-renders the room
from `value`. Notably `value` is the room even for `clone`, whose product is a new
SUBROOM; only the room-level `POST /rooms/:id/clone` returns the thing it created.
- The room save (`rooms`: `POST /subrooms/:sid/data`) is the ONE exception to that shape:
`value` is `{ room, subRoomDataSave }`, and `error` is NULL rather than `""`. The
`subRoomDataSave` is camelCase with a different field set from the PascalCase
`CurrentSave` embedded in the room (no persistence/OM/UGC versions, no moderation state,
no asset arrays; but `unityAsset`/`unityAssetHash`). Don't unify the two projections.
- A subroom's saved scene loads from `CurrentSave.DataBlob` (`rooms`: `GET /rooms/:id`),
NOT the flat `DataBlob` on the subroom — a subroom with no `CurrentSave` silently loads
nothing. The key must be present (null before the first publish); read it via
`subRoomDataBlob()` so `match`/`auth` instance payloads resolve it the same way.
- A room save (`rooms`: `POST …/subrooms/:sid/data`) publishes only when the body says
`AutoPublish: true`; otherwise it STAGES onto `StagedSubRoomDataSaveId` and leaves
`CurrentSave` alone, so players keep loading the last published version until the owner
posts `…/subrooms/:sid/publish_save` with `subRoomDataSaveId=<id>`. DORMS always
publish: no publish step exists in the client for them. Saves live in the
`subroom_save` table with globally-unique ids (a bare id has to resolve —
`StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so
`…/saves` is real history and `publish_save` doubles as restore-a-save. `…/saves` is
auth-gated and CREATOR-only (not co-owners) — it lists unpublished staged saves. There
is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path.
- 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.
- Accessibility is sent as the `RoomAccessibility` enum NAME on
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
(Private, Public, Unlisted, Dev_only, Dev_Unlisted); parse via `parseAccessibility`,
which accepts either form.
</client-contract-notes>
<critical-notes>
- TypeScript configs MUST use fully qualified paths: `@repo/typescript-config/base.json` not `./base.json`
- Do NOT add 'WebWorker' to TypeScript config - types are in worker-configuration.d.ts or @cloudflare/workers-types
+83 -6
View File
@@ -43,10 +43,12 @@ services but would require small code changes.
## Prerequisites
- node (modern)
- pnpm
- bun
- jq/awk/sed
**You must have all these requirements or RecFlare deployment will fail!**
- node 24 (https://nodejs.org)
- pnpm (install with `npm install -g pnpm`)
- bun (https://bun.sh)
- jq/awk/sed (on Windows try `winget jq` etc.)
- A Cloudflare account with a zone (domain) you control, for deploying.
Cloudflare's free plan is good enough for testing (100k worker requests/day) but the
@@ -65,6 +67,8 @@ We use [Just](https://github.com/casey/just) for convenience. This will install
just install
```
You do not have to use `just` but you will have to run things manually with `pnpm`/`bun`.
**Configure your custom domain:**
Create a new .env file from the template:
@@ -77,11 +81,11 @@ Edit `.env` and set `RECFLARE_DOMAIN` to your domain (or declare it with `export
(Optional) - per-app subdomain overrides come from
`RECFLARE_SUBDOMAINS` (a JSON object, e.g. `'{"playersettings":"settings"}'`). This would be used
if you wanted to merge two services together.
if you wanted to merge two services together e.g. send `datacollection` calls to `api`.
**Create the storage resources:**
The workers bind Cloudflare storage primitive. Create them once against your
The workers bind Cloudflare storage primitives. Create them once against your
Cloudflare account, then record the IDs in `.env`. The committed `wrangler.jsonc`
files carry `"local"` placeholders; the real IDs are spliced in at deploy time, so
nothing in version control needs editing. Authenticate wrangler first
@@ -104,6 +108,24 @@ binds it so tokens signed by `auth` verify everywhere. Record its id in `.env` a
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
```
The same store also holds `META_APP_SECRET`, the app secret from your app's page in
the Meta developer dashboard (developers.meta.com). Only the `auth` worker binds it,
and only to authenticate itself to Meta when validating a headset login's nonce —
unlike Steam's ticket, which verifies offline, a Meta login cannot be checked without
it. Create it too:
```bash
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
```
> ⚠️ Both secrets must **exist** in the store or `just deploy` fails on the `auth`
> worker — a binding to a missing secret is a deploy error. If you have no Meta app,
> create `META_APP_SECRET` with any placeholder value: Meta sign-ins then fail with a
> 500 ("Meta platform verification is not configured") and nothing else is affected.
> Steam and password sign-ins are unaffected either way. Put the real value in later
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
> the secret per request.
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
```bash
@@ -199,6 +221,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
+3 -1
View File
@@ -2,6 +2,8 @@
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
![example branch parameter](https://github.com/djdevin/recflare/actions/workflows/test.yml/badge.svg?branch=main)
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
Cloudflare Workers. It implements the network services the Rec Room client talks
to — accounts, auth, rooms, matchmaking, economy, chat, notifications, and more —
@@ -96,6 +98,6 @@ missing pieces. Again, another reason to come together on one project and
stop gatekeeping.
Unfortunately, they were all leaked code except for
[CannedNet](https://github.com/CannedNet/CannedNet) and jordanparki7's postman
[CannedNet](https://github.com/CannedNet/CannedNet), [DorkNet](https://github.com/DorkSquadRR/DorkNet), and jordanparki7's postman
collection of RecNet APIs which is gone for some reason. So I will not list the
leaks publicly.
+91 -29
View File
@@ -1,37 +1,99 @@
# accounts
Accounts Worker served on the `accounts` subdomain. A Hono app for accounts.
Database queries are stubbed for now — no real bindings yet.
Accounts Worker served on the `accounts` subdomain (`accounts.recflare.net`) — a Hono
app for account reads, profile mutations and lookups. Accounts live in the shared
`recflare` D1 database, whose `account` schema and migrations are owned by the `auth`
worker; this worker binds it read/write.
## Behavior
## Routes
- **Auth-gated routes** validate the Bearer JWT issued by the `auth` worker
(same dev secret, see `src/jwt.ts`) and 401 when it's missing/invalid.
- **DB-backed reads** return synthesized default accounts. Every column gets a
fallback (`Player{id}`, `DefaultProfileImage.jpg`, etc.), so the stubs return
those defaults rather than 404ing on a missing row.
- **DB-backed writes** (`create`, the `PUT /account/me/*` mutations) accept the
request and ack without persisting. `create` mints a random account id and
returns it wrapped in the RecNet result envelope `{ success, value }`.
| Method | Path | Auth | Description |
| ------ | ------------------------------ | ---- | ------------------------------------------------ |
| GET | `/` | | Health check |
| GET | `/account/me` | ✓ | The caller's own account (private self DTO) |
| GET | `/account/search?name=` | | Prefix-search accounts by username |
| GET | `/account/bulk?id=1&id=2,3` | | Look up many accounts by id |
| GET | `/account/:id` | | A single public account |
| GET | `/account/:id/bio` | | A player's bio |
| POST | `/account/create` | | Create an account → `{ success, value }` |
| GET | `/parentalcontrol/me` | ✓ | The caller's parental-control flags |
| GET | `/accountprivacysettings/:id` | | An account's privacy settings |
| PUT | `/account/me/displayname` | ✓ | Set display name |
| PUT | `/account/me/username` | ✓ | Change username (unique + change remaining) |
| POST | `/account/me/email` | ✓ | Set email |
| POST | `/account/me/phone` | ✓ | Set phone number |
| PUT | `/account/me/identityflags` | ✓ | Set identity flags bitmask |
| PUT | `/account/me/personalpronouns` | ✓ | Set personal pronouns (posted as `pronounFlags`) |
| PUT | `/account/me/bio` | ✓ | Set bio |
| PUT | `/account/me/profileimage` | ✓ | Set avatar object key |
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
## Endpoints
Auth-gated routes validate the Bearer JWT issued by the `auth` worker and return an
empty-body 401 when it's missing or invalid.
- `GET /` — health check
- `GET /account/me` — authed self account (`SelfAccount`)
- `GET /account/bulk?id=1&id=2` — accounts for the requested ids
- `GET /account/:id` — single account
- `GET /account/:id/bio` — player bio
- `POST /account/create` — create an account → `{ success, value }`
- `GET /parentalcontrol/me` — authed parental-control flags
- `PUT /account/me/displayname` — authed, body `displayName`
- `PUT /account/me/username` — authed, body `username`
- `PUT /account/me/bio` — authed, body `bio`
- `PUT /account/me/profileimage` — authed, body `imageName`
## API documentation
## TODO before production
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
alongside each handler, with the schemas in `src/openapi.ts`.
- Wire a DB binding (D1/DO) for `Accounts`, `CachedLogins`, `PlayerBios`,
`Rooms`/`SubRooms` (the dorm room created on signup).
- Make reads 404 on missing rows once real data exists.
- Persist the `PUT /account/me/*` mutations.
- Move the JWT secret to a shared secret binding (shared with `auth`).
**The spec is descriptive, not enforced.** Nothing validates requests against it — same
rationale as the `auth` worker: this serves a protocol reverse-engineered from the Rec
Room client, the handlers are lenient (form fields are read as
`typeof value === 'string' ? value : ''`), and reads fall back to a synthesized default
account rather than 404. Read a "required" field as _the client always sends it_, not
_the server rejects it if absent_.
A test asserts that every route the worker serves appears in the spec, so adding a
route without documenting it fails rather than silently shipping an incomplete spec.
## Account shapes
Two DTOs, both camelCase:
- **Public** (`toAccountDto`) — returned for any account. Excludes private fields.
- **Self** (`toSelfAccountDto`, the `/account/me` shape) — the public DTO plus
owner-only `email`, `birthday` and `availableUsernameChanges`.
Two client-deserializer quirks are load-bearing and deliberate:
- `juniorState` / `parentAccountId` are **omitted entirely** when unset — emitting
`null` makes the client's enum parser throw. `email` / `birthday` aren't enums, so
they're kept as `null`.
- `GET /accountprivacysettings/:id` never returns a bare `{}` — that fails the client's
deserializer ("Deserialization returned null"), so the id is echoed back with recent
history reported visible. Nothing stores per-player privacy yet.
## Missing rows fall back to defaults
Account reads (`/account/me`, `/account/:id`, `/account/bulk`) never 404 on an unknown
id — they synthesize a default account (`defaultAccount`) so every requested id is
present in the response. `bulk` in particular guarantees one entry per requested id.
## Notifications
Profile mutations persist to the account row and then push through the shared
notifications hub (a single global Durable Object owned by the `notify` worker): the
owner receives `SelfAccountUpdate` + `AccountUpdate`, and every connected client
receives an `AccountUpdate` broadcast. Hub failures are logged and swallowed — the
write has already committed, so a hub hiccup must not fail the request.
This matters most for the mutations whose HTTP response carries no account body
(`personalpronouns`, `identityflags`): the client only learns the new value from the
pushed update, and since those fields are in the _public_ DTO, every other client needs
the broadcast too. `email` and `phone` are private, so they persist without a push.
## Bindings
| Binding | Type | Notes |
| ---------------------------- | -------------- | ------------------------------------------------------------ |
| `DB` | D1 | Shared `recflare` database; `account` schema owned by `auth` |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
This worker has no migrations of its own — the `account` table is created and migrated
by `auth` (`apps/auth/migrations/`).
## Known gaps
- `POST /account/create` parses `platformId` but doesn't yet persist it, and doesn't
create the dorm Room/SubRoom a new account should get.
+6 -1
View File
@@ -18,8 +18,13 @@
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+530 -159
View File
@@ -1,4 +1,5 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler, validator } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
@@ -10,9 +11,32 @@ import {
searchAccounts,
updateAccount,
} from '@repo/domain'
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
AccountDto,
BioRequest,
BioResponse,
CreateAccountRequest,
CreateAccountResult,
DisplayNameRequest,
EmailRequest,
form,
HealthResponse,
IdentityFlagsRequest,
json,
ParentalControl,
PhoneRequest,
PrivacySettings,
ProfileImageRequest,
PronounsRequest,
SelfAccountDto,
SuccessResponse,
UsernameRequest,
UsernameResult,
} from './openapi'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
import type { App } from './context'
@@ -21,9 +45,10 @@ import type { App } from './context'
* Account reads/writes are backed by the shared `accounts` table in D1 (schema
* owned by the `auth` worker). Accounts not in the table fall back to a
* synthesized default (every column has a fallback anyway). Profile mutations
* still accept-and-ack (marked `TODO`).
* persist to the account row and push an AccountUpdate through the notifications
* hub (see `pushAccountUpdate`).
*
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
*/
/**
@@ -44,12 +69,18 @@ function unauthorized(c: Context<App>) {
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.
* Username-change result envelope: `{ success, error, value }`. On success `value` is
* the updated account; on a refusal `error` carries the message and `value` is an empty
* string.
*
* A refusal is a 400. The body shape is unchanged — anything reading `error` still
* works — but it used to come back at HTTP 200, which meant a caller keying off the
* status read every refusal as a success. That envelope-at-200 was the reference's
* (`RecNet`) convention and is kept by `POST /account/create`; here it was traded for a
* status a client can actually branch on.
*/
function usernameResult(c: Context<App>, error = '', value: unknown = '') {
return c.json({ success: error === '', error, value })
return c.json({ success: error === '', error, value }, error === '' ? 200 : 400)
}
/** Read a single string field from a form-urlencoded / multipart body. */
@@ -87,7 +118,9 @@ function toSelfAccountDto(account: Account) {
return {
...toAccountDto(account),
email: account.email ?? null,
birthday: null,
// @todo he game client needs this to be set. I forget how birthdays were set, so for now
// everyone can be old.
birthday: '1904-01-01T00:00:00.000Z',
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
}
}
@@ -96,7 +129,7 @@ function toSelfAccountDto(account: Account) {
const HUB_INSTANCE = 'global'
/**
* Push the notifications that follow an account mutation, mirroring the C#/Go
* Push the notifications that follow an account mutation, mirroring the reference
* hub behavior: the owner receives `SelfAccountUpdate` and `AccountUpdate`, and
* every connected client receives an `AccountUpdate` broadcast. Hub failures are
* logged and swallowed — the account write has already committed, so a hub
@@ -117,6 +150,12 @@ async function pushAccountUpdate(c: Context<App>, account: Account): Promise<voi
}
}
/** The empty-body 401 every auth-gated route returns; reused across their specs. */
const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** Bearer-JWT security requirement, for the auth-gated routes. */
const AUTHED = [{ bearerAuth: [] }]
const app = new Hono<App>()
.use(
'*',
@@ -128,207 +167,539 @@ const app = new Hono<App>()
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
// Root health check.
.get('/', (c) => c.json({ service: 'accounts', status: 'ok' }))
.get(
'/',
describeRoute({
tags: ['Meta'],
summary: 'Health check',
responses: { 200: json(HealthResponse, 'Service is up') },
}),
(c) => c.json({ service: 'accounts', status: 'ok' })
)
// ---- Self account --------------------------------------------------------
.get('/account/me', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// Load the stored account, falling back to a synthesized default.
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
return c.json(toSelfAccountDto(account))
})
.get(
'/account/me',
describeRoute({
tags: ['Self'],
summary: 'The callers own account',
description: [
'The private self DTO, including owner-only fields (email, remaining username',
'changes). An account with no stored row falls back to a synthesized default.',
].join(' '),
security: AUTHED,
responses: {
200: json(SelfAccountDto, 'The callers account'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// Load the stored account, falling back to a synthesized default.
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
return c.json(toSelfAccountDto(account))
}
)
// ---- Search --------------------------------------------------------------
// Prefix-search accounts by username (`?name=`). Returns a bare array of public
// account DTOs, ordered alphabetically. Registered before `/account/:id` so the
// static `search` path wins over the param route.
.get('/account/search', async (c) => {
const name = c.req.query('name') ?? ''
const accounts = await searchAccounts(c.env.DB, name)
return c.json(accounts.map(toAccountDto))
})
.get(
'/account/search',
describeRoute({
tags: ['Lookup'],
summary: 'Prefix-search accounts by username',
description: 'Case-insensitive prefix match on username, ordered alphabetically.',
parameters: [
{
name: 'name',
in: 'query',
required: false,
description: 'Username prefix; empty matches nothing meaningful',
schema: { type: 'string' },
},
],
responses: { 200: json(AccountDto.array(), 'Matching public accounts') },
}),
async (c) => {
const name = c.req.query('name') ?? ''
const accounts = await searchAccounts(c.env.DB, name)
return c.json(accounts.map(toAccountDto))
}
)
// ---- Bulk / single lookup ------------------------------------------------
// Register the static `bulk` path before the `/account/:id` param route.
.get('/account/bulk', async (c) => {
// Reads repeated `id` query params; also accept a comma-separated list.
const ids =
c.req
.queries('id')
?.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n)) ?? []
// Resolve stored accounts, synthesizing a default for any id not in the DB
// so every requested id is present in the response.
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
})
.get(
'/account/bulk',
describeRoute({
tags: ['Lookup'],
summary: 'Look up many accounts by id',
description: [
'Accepts repeated `id` query params and/or comma-separated lists. Every requested',
'id appears in the response — ids with no stored row get a synthesized default.',
].join(' '),
parameters: [
{
name: 'id',
in: 'query',
required: false,
description: 'Repeatable; each value may be a comma-separated list of ids',
schema: { type: 'array', items: { type: 'string' } },
},
],
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
}),
async (c) => {
// Reads repeated `id` query params; also accept a comma-separated list.
const ids =
c.req
.queries('id')
?.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n)) ?? []
// Resolve stored accounts, synthesizing a default for any id not in the DB
// so every requested id is present in the response.
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
}
)
.get('/account/:id/bio', async (c) => {
const accountId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(accountId)) return c.body(null, 400)
// Bio is stored on the account JSON (set via PUT /account/me/bio).
const account = await getAccount(c.env.DB, accountId)
return c.json({ accountId, bio: account?.bio ?? '' })
})
.get(
'/account/:id/bio',
describeRoute({
tags: ['Lookup'],
summary: 'A players bio',
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: 'Account id; non-numeric is 400',
schema: { type: 'string' },
},
],
responses: {
200: json(BioResponse, 'The bio (empty string when unset)'),
400: { description: 'Non-numeric id (empty body)' },
},
}),
async (c) => {
const accountId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(accountId)) return c.body(null, 400)
// Bio is stored on the account JSON (set via PUT /account/me/bio).
const account = await getAccount(c.env.DB, accountId)
return c.json({ accountId, bio: account?.bio ?? '' })
}
)
.get('/account/:id', async (c) => {
const accountId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(accountId)) return c.body(null, 400)
// Load the stored account, falling back to a synthesized default.
return c.json(
toAccountDto((await getAccount(c.env.DB, accountId)) ?? defaultAccount(accountId))
)
})
.get(
'/account/:id',
describeRoute({
tags: ['Lookup'],
summary: 'A single public account',
description: 'An id with no stored row falls back to a synthesized default account.',
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: 'Account id; non-numeric is 400',
schema: { type: 'string' },
},
],
responses: {
200: json(AccountDto, 'The public account'),
400: { description: 'Non-numeric id (empty body)' },
},
}),
async (c) => {
const accountId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(accountId)) return c.body(null, 400)
// Load the stored account, falling back to a synthesized default.
return c.json(
toAccountDto((await getAccount(c.env.DB, accountId)) ?? defaultAccount(accountId))
)
}
)
// ---- Create --------------------------------------------------------------
.post('/account/create', async (c) => {
// Parsed for fidelity; unused until there's a DB to persist CachedLogins.
const platform = await formField(c, 'platform')
await formField(c, 'platformId')
.post(
'/account/create',
describeRoute({
tags: ['Self'],
summary: 'Create an account',
description: [
'Mints a new account with an auto-assigned random username (players dont choose',
'one initially). Not auth-gated. `platformId` is parsed but not yet persisted.',
].join(' '),
requestBody: form(CreateAccountRequest, 'Platform fields'),
responses: { 200: json(CreateAccountResult, 'The created account, in a result envelope') },
}),
async (c) => {
// Parsed for fidelity; unused until there's a DB to persist CachedLogins.
const platform = await formField(c, 'platform')
await formField(c, 'platformId')
// Persist a new account with an auto-assigned random username (players
// don't choose one initially).
const platforms = Number.parseInt(platform, 10)
const account = await createAccount(c.env.DB, {
platforms: Number.isNaN(platforms) ? 0 : platforms,
})
// TODO: also create a dorm Room/SubRoom for the new account.
return c.json({ success: true, value: toAccountDto(account) })
})
// Persist a new account with an auto-assigned random username (players
// don't choose one initially).
const platforms = Number.parseInt(platform, 10)
const account = await createAccount(c.env.DB, {
platforms: Number.isNaN(platforms) ? 0 : platforms,
})
// TODO: also create a dorm Room/SubRoom for the new account.
return c.json({ success: true, value: toAccountDto(account) })
}
)
// ---- Parental control ----------------------------------------------------
.get('/parentalcontrol/me', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ accountId: id, disallowInAppPurchases: false })
})
.get(
'/parentalcontrol/me',
describeRoute({
tags: ['Self'],
summary: 'The callers parental-control flags',
description: 'Nothing stores parental controls yet; purchases are always allowed.',
security: AUTHED,
responses: {
200: json(ParentalControl, 'Parental-control flags'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ accountId: id, disallowInAppPurchases: false })
}
)
// Privacy settings for an account. A bare `{}` fails the client's deserializer
// ("Deserialization returned null") — it needs the fields, so echo the id back and
// report recent history as visible. Nothing stores per-player privacy yet.
.get('/accountprivacysettings/:id{[0-9]+}', (c) =>
c.json({
accountId: Number.parseInt(c.req.param('id'), 10),
isRecentHistoryVisible: true,
})
.get(
'/accountprivacysettings/:id{[0-9]+}',
describeRoute({
tags: ['Lookup'],
summary: 'An accounts privacy settings',
description: [
'Nothing stores per-player privacy yet; the id is echoed and recent history is',
'reported visible (a bare `{}` fails the clients deserializer).',
].join(' '),
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: 'Account id (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: { 200: json(PrivacySettings, 'Privacy settings') },
}),
(c) =>
c.json({
accountId: Number.parseInt(c.req.param('id'), 10),
isRecentHistoryVisible: true,
})
)
// ---- Profile mutations ---------------------------------------------------
// Set the player's display name (persisted on the account row).
.put('/account/me/displayname', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const displayName = (await formField(c, 'displayName')).trim()
if (displayName === '') return c.body(null, 400)
const account = await updateAccount(c.env.DB, id, { displayName })
await pushAccountUpdate(c, account)
return c.json({ success: true })
})
.put(
'/account/me/displayname',
describeRoute({
tags: ['Profile'],
summary: 'Set display name',
description: 'Persisted and broadcast via an AccountUpdate notification.',
security: AUTHED,
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
// An EMPTY 400, which is what this route already answered for an empty name: it
// acks with a bare SuccessResponse and has never sent the client a body on
// failure, so enforcing the schema doesn't change what a refusal looks like.
validator('form', DisplayNameRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { displayName } = c.req.valid('form')
const account = await updateAccount(c.env.DB, id, { displayName })
await pushAccountUpdate(c, account)
return c.json({ success: true })
}
)
// Change the caller's username. Rejects a name already taken by another account,
// and requires the account to have username changes remaining. On success the
// new name is persisted and the remaining-changes counter is decremented.
.put('/account/me/username', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.put(
'/account/me/username',
describeRoute({
tags: ['Profile'],
summary: 'Change username',
description: [
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
'account and requires a remaining change; on success the name is persisted and',
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
'(see the UsernameResult envelope).',
].join(' '),
security: AUTHED,
responses: {
200: json(UsernameResult, 'The updated account, in the result envelope'),
400: json(UsernameResult, 'Refused — `error` carries the reason, `value` is ""'),
401: UNAUTHORIZED_RESPONSE,
},
}),
// Shape is checked before the handler runs, so a rejected name costs no D1 read and
// — the part that matters — can never spend one of the account's rationed changes.
// The message is relayed rather than zod's issue array: `nameRejection` writes the
// sentence the player reads, and nothing can render an array of issues.
// `c` is annotated so the hook's context matches this app's bindings, and `error` is
// Standard Schema's flat issue list rather than a zod error object.
validator('form', UsernameRequest, (r, c: Context<App>) =>
r.success
? undefined
: usernameResult(c, r.error[0]?.message ?? 'That username cannot be used.')
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const username = (await formField(c, 'username')).trim()
if (username === '') return usernameResult(c, 'You must enter a username.')
const { username } = c.req.valid('form')
// Duplicate check first (case-insensitive); keeping your own name is allowed.
const existing = await getAccountByUsername(c.env.DB, username)
if (existing && existing.accountId !== id) {
return usernameResult(c, 'That username is already taken.')
// Duplicate check first (case-insensitive); keeping your own name is allowed.
const existing = await getAccountByUsername(c.env.DB, username)
if (existing && existing.accountId !== id) {
return usernameResult(c, 'That username is already taken.')
}
// Then require a remaining change.
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
const remaining = account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES
if (remaining <= 0) {
return usernameResult(c, 'You have no username changes remaining.')
}
const updated = await updateAccount(c.env.DB, id, {
username,
availableUsernameChanges: remaining - 1,
})
await pushAccountUpdate(c, updated)
return usernameResult(c, '', toAccountDto(updated))
}
// Then require a remaining change.
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
const remaining = account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES
if (remaining <= 0) {
return usernameResult(c, 'You have no username changes remaining.')
}
const updated = await updateAccount(c.env.DB, id, {
username,
availableUsernameChanges: remaining - 1,
})
await pushAccountUpdate(c, updated)
return usernameResult(c, '', toAccountDto(updated))
})
)
// Set the player's email (persisted on the account row; surfaced by /account/me).
.post('/account/me/email', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const email = (await formField(c, 'email')).trim()
if (!email.includes('@')) return c.body(null, 400)
await updateAccount(c.env.DB, id, { email })
return c.json({ success: true })
})
.post(
'/account/me/email',
describeRoute({
tags: ['Profile'],
summary: 'Set email',
description: 'Persisted; surfaced only by `/account/me`. Not broadcast.',
security: AUTHED,
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Not a syntactically valid address (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
validator('form', EmailRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { email } = c.req.valid('form')
await updateAccount(c.env.DB, id, { email })
return c.json({ success: true })
}
)
// Set the player's phone (persisted on the account row).
.post('/account/me/phone', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const phone = (await formField(c, 'phone')).trim()
if (phone === '') return c.body(null, 400)
await updateAccount(c.env.DB, id, { phone })
return c.json({ success: true })
})
.post(
'/account/me/phone',
describeRoute({
tags: ['Profile'],
summary: 'Set phone number',
description: 'Persisted on the account row. Not broadcast.',
security: AUTHED,
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty phone (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
validator('form', PhoneRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { phone } = c.req.valid('form')
await updateAccount(c.env.DB, id, { phone })
return c.json({ success: true })
}
)
// Set the player's identityFlags bitmask (persisted; surfaced by /account/me).
// `identityFlags` is part of the public account DTO, so the update has to be pushed
// — see the note on personalpronouns below.
.put('/account/me/identityflags', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const identityFlags = Number.parseInt((await formField(c, 'identityFlags')).trim(), 10)
if (Number.isNaN(identityFlags)) return c.body(null, 400)
const account = await updateAccount(c.env.DB, id, { identityFlags })
await pushAccountUpdate(c, account)
return c.json({ success: true })
})
.put(
'/account/me/identityflags',
describeRoute({
tags: ['Profile'],
summary: 'Set identity flags',
description: [
'`identityFlags` bitmask. In the public DTO, so the update is broadcast via',
'AccountUpdate.',
].join(' '),
security: AUTHED,
requestBody: form(IdentityFlagsRequest, 'The identityFlags bitmask'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Non-numeric identityFlags (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const identityFlags = Number.parseInt((await formField(c, 'identityFlags')).trim(), 10)
if (Number.isNaN(identityFlags)) return c.body(null, 400)
const account = await updateAccount(c.env.DB, id, { identityFlags })
await pushAccountUpdate(c, account)
return c.json({ success: true })
}
)
// Set the player's personalPronouns (posted as `pronounFlags`; persisted).
// The response body carries no account, so the client only learns the new value from
// the `SelfAccountUpdate`/`AccountUpdate` the hub pushes — without it the player's own
// UI (and every other client, since personalPronouns is in the public DTO) keeps
// showing the old pronouns until something else refetches the account.
.put('/account/me/personalpronouns', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const personalPronouns = Number.parseInt((await formField(c, 'pronounFlags')).trim(), 10)
if (Number.isNaN(personalPronouns)) return c.body(null, 400)
const account = await updateAccount(c.env.DB, id, { personalPronouns })
await pushAccountUpdate(c, account)
return c.json({ success: true })
})
.put(
'/account/me/personalpronouns',
describeRoute({
tags: ['Profile'],
summary: 'Set personal pronouns',
description: [
'Posted as `pronounFlags`. The response carries no account, so the client learns',
'the new value only from the broadcast AccountUpdate.',
].join(' '),
security: AUTHED,
requestBody: form(PronounsRequest, 'The pronounFlags bitmask'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Non-numeric pronounFlags (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const personalPronouns = Number.parseInt((await formField(c, 'pronounFlags')).trim(), 10)
if (Number.isNaN(personalPronouns)) return c.body(null, 400)
const account = await updateAccount(c.env.DB, id, { personalPronouns })
await pushAccountUpdate(c, account)
return c.json({ success: true })
}
)
.put('/account/me/bio', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const bio = await formField(c, 'bio')
const account = await updateAccount(c.env.DB, id, { bio })
await pushAccountUpdate(c, account)
return c.json({ success: true })
})
.put(
'/account/me/bio',
describeRoute({
tags: ['Profile'],
summary: 'Set bio',
description: 'Free text up to 255 characters; empty is allowed. Persisted and broadcast.',
security: AUTHED,
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Bio over 255 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
// Refused rather than truncated: silently storing half a sentence reads as data loss.
validator('form', BioRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { bio } = c.req.valid('form')
const account = await updateAccount(c.env.DB, id, { bio })
await pushAccountUpdate(c, account)
return c.json({ success: true })
}
)
.put('/account/me/profileimage', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const imageName = await formField(c, 'imageName')
if (!imageName) return c.body(null, 400)
// Persist the new avatar key on the account row and fire the AccountUpdate
// websocket (the new profileImage rides along in the DTO payload).
const account = await updateAccount(c.env.DB, id, { profileImage: imageName })
await pushAccountUpdate(c, account)
return c.json({ success: true })
})
.put(
'/account/me/profileimage',
describeRoute({
tags: ['Profile'],
summary: 'Set profile image',
description: 'Persists the avatar object key and broadcasts it in the AccountUpdate payload.',
security: AUTHED,
requestBody: form(ProfileImageRequest, 'The avatar object key'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty imageName (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const imageName = await formField(c, 'imageName')
if (!imageName) return c.body(null, 400)
// Persist the new avatar key on the account row and fire the AccountUpdate
// websocket (the new profileImage rides along in the DTO payload).
const account = await updateAccount(c.env.DB, id, { profileImage: imageName })
await pushAccountUpdate(c, account)
return c.json({ success: true })
}
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare accounts',
version: '1.0.0',
description: [
'Account reads, profile mutations and lookups for recflare, a private-server',
'reimplementation of the Rec Room backend. Accounts live in the shared `recflare`',
'D1 database, whose `account` schema is owned by the `auth` worker.',
].join('\n'),
},
servers: [{ url: 'https://accounts.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+205
View File
@@ -0,0 +1,205 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import {
isValidBio,
isValidEmail,
MAX_DISPLAY_NAME_LENGTH,
MAX_USERNAME_LENGTH,
nameRejection,
} from '@repo/domain'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the accounts worker.
*
* Most of these are DESCRIPTIVE ONLY: they are passed to `describeRoute` to generate the
* spec, and the handler stays lenient. That is deliberate — the Rec Room client is the
* real consumer, form fields are read as `typeof value === 'string' ? value : ''`, and
* missing or malformed input falls through to a graceful path (or a synthesized default
* account) rather than a hard error. A schema that rejected what the client actually
* sends would break the game, not protect it.
*
* The EXCEPTION is the profile mutations a player types into a box — displayName,
* username, email, phone, bio. Those carry real rules (see `@repo/domain`), and each is
* wired into `hono-openapi`'s `validator()` per route, with tests, exactly as the older
* version of this note prescribed. Wiring one up means the schema both validates the
* request and generates the spec, so a limit can't be changed in one and not the other —
* which is precisely how the documented email limit came to disagree with the real one.
*
* A validated route drops `requestBody: form(...)` from its `describeRoute`: the
* validator registers the body itself, and declaring it twice would emit it twice.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
/**
* Emit a zod schema as a form request body. `describeRoute`'s `requestBody` takes a
* plain OpenAPI schema (not a `resolver()`), so convert here. zod's `$schema` key and
* `additionalProperties: false` are dropped — these handlers read the fields they know
* and ignore the rest, so claiming a closed object would misreport them as stricter
* than they are. The client posts both urlencoded and multipart, hence the wildcard.
*/
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return {
description,
content: {
// zod's JSONSchema type is far wider than OpenAPI's SchemaObject; cast at the
// boundary (the emitted value is valid OpenAPI 3.1).
'application/x-www-form-urlencoded': { schema: jsonSchema as OpenAPIV3_1.SchemaObject },
'multipart/form-data': { schema: jsonSchema as OpenAPIV3_1.SchemaObject },
},
}
}
/**
* The public account DTO (`toAccountDto`) — the camelCase shape returned for any
* account, with private fields (email, birthday) excluded. Fields the client parses
* as enums are numbers here.
*/
export const AccountDto = z.object({
accountId: z.int(),
username: z.string(),
displayName: z.string(),
profileImage: z.string().describe('Avatar object key'),
isJunior: z.boolean(),
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
personalPronouns: z.int().describe('Pronoun flags bitmask'),
identityFlags: z.int().describe('Identity flags bitmask'),
createdAt: z.iso.datetime(),
})
/**
* The private self DTO (`toSelfAccountDto`, the `/account/me` shape) — the public DTO
* plus owner-only fields. `juniorState`/`parentAccountId` are omitted entirely when
* unset (emitting `null` makes the client's enum parser throw); `email`/`birthday` are
* kept as nullable since they aren't enums.
*/
export const SelfAccountDto = AccountDto.extend({
email: z.string().nullable(),
birthday: z.null().describe('Always null — birthday is not stored'),
availableUsernameChanges: z.int().describe('Remaining username changes'),
})
/** Player bio, from `GET /account/:id/bio`. */
export const BioResponse = z.object({
accountId: z.int(),
bio: z.string().describe('"" when unset'),
})
/** A bare `{ success: true }` ack, returned by most profile mutations. */
export const SuccessResponse = z.object({ success: z.literal(true) })
/** The RecNet result envelope `{ success, value }` used by create + username change. */
export function envelope(value: z.ZodType) {
return z.object({
success: z.boolean(),
value,
error: z.string().optional().describe('Present (with success:false) on failure'),
})
}
/**
* The username-change envelope. Always HTTP 200 even on failure: `success:false` with
* a message in `error` and `value` an empty string; on success `value` is the updated
* public account.
*/
export const UsernameResult = envelope(z.union([AccountDto, z.literal('')])).describe(
'value is the updated account on success, "" on failure'
)
/** `POST /account/create` response. */
export const CreateAccountResult = envelope(AccountDto)
/** `GET /parentalcontrol/me` response. */
export const ParentalControl = z.object({ accountId: z.int(), disallowInAppPurchases: z.boolean() })
/**
* `GET /accountprivacysettings/:id` response. A bare `{}` fails the client's
* deserializer, so the id is echoed back and recent history reported visible; nothing
* stores per-player privacy yet.
*/
export const PrivacySettings = z.object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() })
/** Root health check. */
export const HealthResponse = z.object({ service: z.literal('accounts'), status: z.literal('ok') })
// ---- Request bodies --------------------------------------------------------
/** `POST /account/create` form body. Both fields are parsed but not yet persisted. */
export const CreateAccountRequest = z.object({
platform: z.string().optional().describe('PlatformType integer string; defaults to 0'),
platformId: z.string().optional().describe('Parsed for fidelity; currently unused'),
})
/**
* Single-string form bodies, one per profile mutation.
*
* These are ENFORCED, not just described: each is handed to hono-openapi's `validator`,
* so the same schema both validates the request and generates the spec. Before this they
* were documentation only, and the real rule lived in the handler — which meant every
* limit had to be edited in two places and nothing caught them disagreeing.
*
* The rules themselves come from `@repo/domain` so `rooms` and `clubs` can't drift from
* `accounts`; `superRefine` is used where the message matters, because `nameRejection`
* writes the player-facing sentence and there's no reason to write it twice.
*/
/** Zod check that defers to the shared name rule, message and all. */
const nameCheck = (label: string, max: number) =>
z.string()
.trim()
.superRefine((value, ctx) => {
const rejection = nameRejection(value, label, max)
if (rejection !== null) ctx.addIssue({ code: 'custom', message: rejection })
})
export const DisplayNameRequest = z.object({
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
.min(1)
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'),
})
export const UsernameRequest = z.object({
username: nameCheck('username', MAX_USERNAME_LENGTH)
.min(1, 'You must enter a username.')
.describe(
'Trimmed; letters and digits only, max 50. Must be unique and changes must remain'
),
})
export const EmailRequest = z.object({
email: z
.string()
.trim()
.refine(isValidEmail, 'That email address looks wrong.')
.describe('A syntactically valid address (RFC 5321/5322, so at most 254); otherwise 400'),
})
export const PhoneRequest = z.object({
// No shape rule on purpose: the client sends E.164 (`+15552223333`), which the name
// rule above would reject outright by eating the leading `+`.
phone: z.string().trim().min(1).describe('Trimmed; empty is rejected (400)'),
})
export const IdentityFlagsRequest = z.object({
identityFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
})
export const PronounsRequest = z.object({
pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
})
export const BioRequest = z.object({
// Not trimmed — a bio is free text, and leading whitespace is the player's business.
bio: z.string().refine(isValidBio).describe('Free text, max 255; empty is allowed'),
})
export const ProfileImageRequest = z.object({
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
})
+225 -3
View File
@@ -220,8 +220,9 @@ describe('auth-gated endpoints', () => {
...form({ username: 'Coach' }),
headers: { ...(await bearer('893')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
// Business errors are HTTP 200 with the { success, error, value } envelope.
expect(res.status).toBe(200)
// A refusal is a 400 carrying the same { success, error, value } envelope. It used
// to be HTTP 200, which read as a success to anything branching on the status.
expect(res.status).toBe(400)
const body = (await res.json()) as { success: boolean; error: string; value: string }
expect(body.success).toBe(false)
expect(body.error).toMatch(/already taken/i)
@@ -260,7 +261,7 @@ describe('auth-gated endpoints', () => {
...form({ username: 'coachy' }),
headers,
})
expect(blocked.status).toBe(200)
expect(blocked.status).toBe(400)
const blockedBody = (await blocked.json()) as { success: boolean; error: string }
expect(blockedBody.success).toBe(false)
expect(blockedBody.error).toMatch(/no username changes/i)
@@ -412,4 +413,225 @@ describe('auth-gated endpoints', () => {
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('888') })
expect(((await me.json()) as { email: string }).email).toBe('ners@recroom.com')
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'GET /',
'GET /account/bulk',
'GET /account/me',
'GET /account/search',
'GET /account/{id}',
'GET /account/{id}/bio',
'GET /accountprivacysettings/{id}',
'GET /parentalcontrol/me',
'POST /account/create',
'POST /account/me/email',
'POST /account/me/phone',
'PUT /account/me/bio',
'PUT /account/me/displayname',
'PUT /account/me/identityflags',
'PUT /account/me/personalpronouns',
'PUT /account/me/profileimage',
'PUT /account/me/username',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
// hono-openapi registers a validated form body under `multipart/form-data` only, and
// its `media` option can't say otherwise (a precedence bug — see `withCleanSpec`). The
// real callers post `application/x-www-form-urlencoded`, so a spec that named only
// multipart would tell an integrator to send the one thing nothing here sends.
test('GET /openapi.json documents both form content types on validated routes', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
const spec = (await res.json()) as {
paths: Record<string, Record<string, { requestBody?: { content: Record<string, unknown> } }>>
}
for (const [path, method] of [
['/account/me/email', 'post'],
['/account/me/username', 'put'],
['/account/me/displayname', 'put'],
['/account/me/bio', 'put'],
['/account/me/phone', 'post'],
] as const) {
const content = spec.paths[path]?.[method]?.requestBody?.content ?? {}
expect(Object.keys(content).sort(), path).toEqual([
'application/x-www-form-urlencoded',
'multipart/form-data',
])
}
})
})
// The names a player chooses are alphanumeric and length-capped, by the same rule the
// `rooms` worker applies (see `nameRejection` in @repo/domain). The three limits come
// from the client's own input boxes rather than a round number, so anything stored is
// something the game can render and re-edit.
//
// Server-generated names go around this deliberately — the seeded "Rec Room" account
// above has a space in its display name, and dorms are called `@<username>'s Dorm`. The
// check belongs at the request handler, not in the db helpers.
describe('name, email and bio validation', () => {
const authed = async (sub: string) => ({
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
})
test('PUT /account/me/username refuses anything but letters and digits, max 50', async () => {
const headers = await authed('8801')
for (const username of ['has space', 'under_score', 'punct!', 'café', 'a'.repeat(51)]) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username }),
headers,
})
// Refused by the SCHEMA (see openapi.ts `UsernameRequest`) before the handler
// runs — but still in this route's envelope, because the hook puts it there.
expect(res.status, username).toBe(400)
const body = (await res.json()) as { success: boolean; error: string; value: string }
expect(body.success, username).toBe(false)
expect(body.error).toMatch(/letters and numbers|at most 50 characters/)
expect(body.value).toBe('')
}
// The rationed change must NOT be spent by a refusal: an account starts with one,
// and burning it on a typo would leave the player stuck with a name they never had.
const me = (await (
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('8801') })
).json()) as { availableUsernameChanges: number }
expect(me.availableUsernameChanges).toBe(1)
// 50 is the client's own cap, so a name that long has to be accepted.
const ok = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'a'.repeat(50) }),
headers,
})
expect(((await ok.json()) as { success: boolean }).success).toBe(true)
})
test('PUT /account/me/displayname refuses anything but letters and digits, max 15', async () => {
const headers = await authed('8802')
for (const displayName of ['has space', 'punct!', 'a'.repeat(16)]) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
// An empty 400, matching what this route already answers for an empty name —
// it acks with a bare `{ success: true }` and has never sent the client a body
// on failure.
expect(res.status, displayName).toBe(400)
}
// 15 is the client's box, so it must fit.
const ok = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName: 'a'.repeat(15) }),
headers,
})
expect(ok.status).toBe(200)
})
// Syntax comes from the `isemail` package rather than a pattern written here — this is
// a contact address nothing is ever sent to in order to prove it, so a hand-rolled
// regex only buys more edge cases to get wrong. It enforces the RFC's own
// 254-character maximum, which is why there's no separate length check.
test('POST /account/me/email requires a syntactically valid address', async () => {
const headers = await authed('8803')
const bad = [
'nope', // no @ at all — what this route used to be the only check for
'@example.com', // nothing to deliver to
'someone@', // no domain
'someone@example.', // empty last label
'two words@example.com', // whitespace
`${'a'.repeat(250)}@example.com`, // past the RFC's 254
]
for (const email of bad) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
...form({ email }),
method: 'POST',
headers,
})
expect(res.status, email).toBe(400)
}
// `someone@localhost` is in the ACCEPTED list on purpose: it's valid per the RFC,
// and an undeliverable address costs nothing here.
for (const email of [
'someone@example.com',
'first.last+tag@mail.example.co.uk',
'someone@localhost',
]) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
...form({ email }),
method: 'POST',
headers,
})
expect(res.status, email).toBe(200)
}
})
test('PUT /account/me/bio caps the stored text at 255 characters', async () => {
const headers = await authed('8804')
const ok = await exports.default.fetch(`${ORIGIN}/account/me/bio`, {
...form({ bio: 'b'.repeat(255) }),
headers,
})
expect(ok.status).toBe(200)
// Refused rather than truncated — storing half a sentence reads as data loss.
const tooLong = await exports.default.fetch(`${ORIGIN}/account/me/bio`, {
...form({ bio: 'b'.repeat(256) }),
headers,
})
expect(tooLong.status).toBe(400)
// The refusal changed nothing: the 255-character bio is still what's stored.
const me = await exports.default.fetch(`${ORIGIN}/account/8804/bio`)
expect(((await me.json()) as { bio: string }).bio).toBe('b'.repeat(255))
})
})
// Phone is deliberately NOT held to the name rule above: the client sends E.164
// (`+15552223333`), so a letters-and-digits check would reject every real number by
// eating the leading `+`. Pinned here because this route sits between two that DID just
// get stricter, and the obvious next "cleanup" is to make it match them.
test('POST /account/me/phone stores an E.164 number exactly as the client sends it', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me/phone`, {
...form({ phone: '+15552223333' }),
method: 'POST',
headers: { ...(await bearer('8805')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
// Read from the row: phone is stored but not surfaced by any DTO, so there's no
// endpoint to check it through.
const row = await env.DB.prepare(
"SELECT json_extract(data, '$.phone') AS phone FROM account WHERE json_extract(data, '$.accountId') = 8805"
).first<{ phone: string }>()
// Verbatim — no normalising, no stripping of the +.
expect(row?.phone).toBe('+15552223333')
})
+23
View File
@@ -0,0 +1,23 @@
-- Player-report storage. Like the relationship table (and unlike the JSON-blob
-- tables in this shared database), a report is genuinely columnar, so it gets a
-- normal relational table. Owned by the `api` worker; generated from
-- src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- One row per submitted report; nothing updates or dedupes them, so the table is
-- an append-only log of what players sent. `reporter_player_id` comes from the
-- caller's bearer token, everything else from the form body.
CREATE TABLE IF NOT EXISTS report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reporter_player_id INTEGER NOT NULL,
reported_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
details TEXT,
height_reporter REAL,
height_reported REAL,
room_id INTEGER,
room_instance_type TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id);
CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id);
+21
View File
@@ -0,0 +1,21 @@
-- Moderator-issued player warnings. The counterpart to the `report` table (0004):
-- reports are what players submit, warnings are what a moderator hands down. Also
-- columnar rather than a JSON blob, and likewise append-only. Owned by the `api`
-- worker; generated from src/warnings-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `moderator_player_id` is the acting moderator, taken from the caller's bearer
-- token (the endpoint is gated on the `moderator` role); everything else comes
-- from the form body. `display_reason` is what the warned player is shown,
-- `moderator_note` is internal.
CREATE TABLE IF NOT EXISTS warning (
id INTEGER PRIMARY KEY AUTOINCREMENT,
moderator_player_id INTEGER NOT NULL,
warned_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
display_reason TEXT,
moderator_note TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id);
CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id);
+25
View File
@@ -0,0 +1,25 @@
-- Player-event storage (scheduled events: a room, a window of time, and the
-- settings the event runs under). Like the image/invention/rooms/accounts tables
-- in this shared database, an event is a single JSON blob in the `data` column,
-- with queryable fields exposed as SQLite generated (virtual) columns extracted
-- from that JSON. Owned by the `api` worker; generated from src/events-db.ts
-- (SCHEMA_DDL) — keep in sync.
--
-- The stored blob IS the DTO: every read endpoint serves it verbatim, so the
-- PascalCase field set matches Rec Room's `PlayerEvent` exactly. `start_time` /
-- `end_time` extract ISO-8601 UTC strings, which compare lexicographically — the
-- browse query filters finished events in SQL on that.
CREATE TABLE IF NOT EXISTS event (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id);
CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id);
CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id);
CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time);
@@ -0,0 +1,23 @@
-- Player-event RSVPs: one row per player per event, recording how they answered
-- (`POST /api/playerevents/v1/respond`). Unlike the `event` table next to it, this
-- one is genuinely columnar — like the relationship/report tables — so it's a
-- normal relational table rather than a JSON blob. Owned by the `api` worker;
-- generated from src/events-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go. Only Going
-- counts toward the event's `AttendeeCount`, which is recomputed from this table on
-- every response. A decline is recorded rather than deleted, so the client can show
-- a player their own answer and changing your mind is an UPDATE (the composite
-- primary key is what makes the upsert a replace).
--
-- An event's creator gets a Going row at create time — that's why a fresh event's
-- AttendeeCount is 1.
CREATE TABLE IF NOT EXISTS event_attendee (
event_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
status INTEGER NOT NULL,
responded_at TEXT NOT NULL,
PRIMARY KEY (event_id, player_id)
);
CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id);
@@ -0,0 +1,21 @@
-- Break the two visibility flags out of the invention JSON blob into queryable
-- generated columns, the same way 0003 did for `IsFeatured`. `IsPublished` and
-- `HideFromPlayer` are always tested together — every feed, the search/browse list and
-- the per-room list ask for "published and not hidden" — so they move together.
-- Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
--
-- SQLite allows ALTER TABLE ADD COLUMN only for VIRTUAL generated columns (a STORED one
-- would need rewriting existing rows), which is what we want anyway: the value stays
-- derived from `data`, so nothing can drift out of sync with it. json_extract of a JSON
-- `true` is 1, so both columns read 1/0 — and NULL for a blob missing the key, which is
-- neither 1 nor 0 and so fails both filters exactly as the json_extract predicates it
-- replaces did. This is a rename, not a behaviour change.
--
-- No index: both columns are booleans that are overwhelmingly one value (nearly every
-- invention is published and not hidden), so an index on them would be read past rather
-- than used. The selective one is idx_invention_featured, added in 0003, which stays.
ALTER TABLE invention
ADD COLUMN is_published INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsPublished')) VIRTUAL;
ALTER TABLE invention
ADD COLUMN hide_from_player INTEGER GENERATED ALWAYS AS (json_extract(data, '$.HideFromPlayer')) VIRTUAL;
+6 -1
View File
@@ -19,8 +19,13 @@
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+52 -1
View File
@@ -1,10 +1,12 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { avatarRoutes } from './routes/avatar'
import { configRoutes } from './routes/config'
import { eventRoutes } from './routes/events'
import { gameplayRoutes } from './routes/gameplay'
import { imageRoutes } from './routes/images'
import { inventoryRoutes } from './routes/inventory'
@@ -38,6 +40,14 @@ const app = new Hono<App>({ strict: false })
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -47,9 +57,50 @@ const app = new Hono<App>({ strict: false })
.route('/', progressionRoutes)
.route('/', avatarRoutes)
.route('/', gameplayRoutes)
.route('/', eventRoutes)
.route('/', moderationRoutes)
.route('/', inventoryRoutes)
.route('/', roomRoutes)
.route('/', imageRoutes)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare api',
version: '1.0.0',
description: [
'The catch-all Game API for recflare, a private-server reimplementation of the Rec',
'Room backend: everything the client calls that has not been split out into its own',
'worker yet. Today that is config, the friend graph, inventions, saved photos,',
'player events, reputation and the assorted sinks the client hits while loading.',
'Relationships, inventions, images and player events are D1-backed; several',
'endpoints are still stubs, noted per route.',
'',
'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,',
'equipment, consumables and objectives on `econ`) are already served there — the',
'client calls that host and the copy here is a stub, which each route says.',
].join('\n'),
},
servers: [{ url: 'https://api.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+3
View File
@@ -21,6 +21,9 @@ export type Env = SharedHonoEnv & {
// Image bucket (shared with the `img` worker, which serves objects back by
// key). Uploaded saved images are written here.
IMAGES: R2Bucket
// Shared CDN bucket (owned by the `cdn` worker, written by `storage`). Read
// here only to hash an invention's uploaded data blob under `invention/`.
CDN_ASSETS: R2Bucket
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
// push RelationshipChanged notifications when a player's relationship changes.
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
+614
View File
@@ -0,0 +1,614 @@
/**
* 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)`,
]
/**
* 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. */
export interface EventAttendeeRow {
event_id: number
player_id: number
status: number
responded_at: string
}
/**
* 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
}
/** 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`.
*/
export function toEventNotification(event: PlayerEvent): 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')
}
/**
* 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 {
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
}
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 {
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)),
])
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
}
/** 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 * 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. Backs a future guest list. */
export async function getEventAttendees(
db: D1Database,
eventId: number
): Promise<EventAttendeeRow[]> {
const { results } = await db
.prepare('SELECT * 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)
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. `query` is matched
* case-insensitively against the name and description, term by term; an empty query
* browses everything upcoming. Paginated via skip/take, soonest first.
*
* 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[]> {
// end_time is a generated column of an ISO-8601 UTC string, so it compares
// lexicographically — the filter stays in SQL.
const { results } = await db
.prepare('SELECT data FROM event WHERE end_time >= ?1')
.bind(eventTime(Date.now()))
.all<EventRow>()
let events = results.map((r) => JSON.parse(r.data) as PlayerEvent)
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
for (const term of terms) {
events = events.filter(
(e) => e.Name.toLowerCase().includes(term) || e.Description.toLowerCase().includes(term)
)
}
return events.sort(bySoonest).slice(skip, skip + take)
}
+14 -5
View File
@@ -1,4 +1,4 @@
import { validateAndGetAccountId } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import type { Context } from 'hono'
import type { App } from './context'
@@ -12,6 +12,16 @@ export async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* The `role` claim from a Bearer token — the operator-granted roles the auth worker
* stamps from the account's flags (a plain player's token is just `['gameClient']`).
* `null` when the request carries no valid token, which callers treat as a 401; an
* empty array means a valid token with no roles. Shaped to mirror {@link authedId}.
*/
export async function authedRoles(c: Context<App>): Promise<string[] | null> {
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Results.Unauthorized() equivalent — 401 with empty body. */
export function unauthorized(c: Context<App>) {
return c.body(null, 401)
@@ -28,14 +38,13 @@ export async function parseFormIds(c: Context<App>): Promise<number[]> {
.filter((n) => !Number.isNaN(n))
}
/** Read integer ids from repeated/comma-separated `id` query params. The 2023
* client passes these to the bulk GET endpoints (e.g. `?id=1&id=2`). */
/** Read integer ids from repeated `id` query params. The 2023 client passes these to
* the bulk GET endpoints as one value per id (`?id=1&id=2`), never comma-separated. */
export function queryIds(c: Context<App>): number[] {
return (
c.req
.queries('id')
?.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
?.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n)) ?? []
)
}
+60 -11
View File
@@ -37,10 +37,10 @@ export const SCHEMA_DDL: string[] = [
]
/**
* Saved-image categories from the C# `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.
* 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,
@@ -118,12 +118,16 @@ export async function createImage(db: D1Database, input: NewImage): Promise<Save
*/
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')
.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")
.prepare(
"UPDATE image SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1"
)
.bind(savedImageId, count)
.run()
return count
@@ -223,9 +227,7 @@ export async function getImagesByRoom(
if (filter > 0) images = images.filter((img) => img.Type === filter)
images.sort(
sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst
)
images.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
return images.slice(skip, skip + take)
}
@@ -257,8 +259,55 @@ export async function getImagesByPlayer(
.slice(skip, skip + take)
}
/** Default number of recent images the slideshow feed returns. */
export const SLIDESHOW_LIMIT = 130
/**
* 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,
}
}
/** How many recent images the slideshow feed returns when the caller doesn't say. */
export const SLIDESHOW_LIMIT = 10
/**
* The most a caller can ask the slideshow feed for. The endpoint is public and
* unauthenticated, so the cap is what keeps an arbitrary `take` from turning into a
* scan of the whole image table plus the two batched joins behind it.
*/
export const SLIDESHOW_MAX_LIMIT = 100
/** The slideshow projection of an image — creator username + room name joined in. */
export interface SlideshowImage {
+145 -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,32 @@ 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
)
}
/**
* 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 +340,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 +414,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 +637,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 +659,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()
}
+678
View File
@@ -0,0 +1,678 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the api worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/match/econ workers: a reverse-engineered protocol,
* lenient handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** The empty-body 401 the auth-gated routes return. */
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/** An integer path parameter (ids are constrained to `[0-9]+` by the route pattern). */
export function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'path', required: true, description, schema: { type: 'integer' } }
}
/** A string path parameter. */
export function stringParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'path', required: true, description, schema: { type: 'string' } }
}
/** An optional string query parameter. */
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
}
/** An optional integer query parameter (`skip` / `take` / `sort` / `filter`). */
export function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'integer' } }
}
/** The `skip`/`take` pair every paginated feed accepts. */
export function pageParams(defaultTake: number): OpenAPIV3_1.ParameterObject[] {
return [
intQuery('skip', 'How many entries to skip (default 0)'),
intQuery('take', `How many entries to return (default ${defaultTake})`),
]
}
// ---- Loose shapes ----------------------------------------------------------
// Several routes serve opaque static config blobs (the game configs, the charades word
// list) or empty-list stubs. Modelling every field adds noise without value, so these
// use deliberately loose schemas.
/** An opaque JSON object (a static config blob, a stub, …). */
export const JsonObject = z.record(z.string(), z.unknown())
/** An opaque JSON array (a static list served verbatim, or an empty-list stub). */
export const JsonArray = z.array(z.unknown())
/** A bare JSON boolean — several routes answer `true`/`false` with no envelope. */
export const BareBoolean = z.boolean()
/** A bare JSON string (`POST /api/sanitize/v1` echoes one back). */
export const BareString = z.string()
/** The `{ error }` body the 400 / 403 branches return. */
export const ErrorResponse = z.object({ error: z.string() })
/**
* The `{ success, error }` envelope the report / warning writes and the message send
* answer with — `error` is an empty string on success, never null, and the rejected
* branches use the same shape so there is only one thing to parse.
*/
export const SuccessErrorEnvelope = z.object({
success: z.boolean(),
error: z.string().describe('Empty string when the call succeeded'),
})
// ---- Config ----------------------------------------------------------------
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
export const AmplitudeConfig = z.object({
AmplitudeKey: z.string(),
StatSigKey: z.string(),
RudderStackKey: z.string(),
UseRudderStack: z.boolean(),
})
/** `GET /api/config/v1/azurespeech` — speech-to-text config; `Enabled` is false here. */
export const AzureSpeechConfig = z.object({
Key: z.string(),
Region: z.string(),
Enabled: z.boolean(),
})
/** `GET /api/config/v1/backtrace` — the client's crash-reporter budget and filters. */
export const BacktraceConfig = z.object({
ReportBudget: z.int(),
FilterType: z.int(),
SampleRate: z.int(),
LogLineCount: z.int(),
CaptureNativeCrashes: z.int(),
AMRThresholdMS: z.int(),
MessageCount: z.int(),
MessageRegex: z.string(),
VersionRegex: z.string(),
})
/**
* `GET /api/config/v2` — the big client config blob (a static asset), with
* `ShareBaseUrl` derived from the deploy-time base domain.
*/
export const ApiConfigV2 = JsonObject.describe(
'The static client config, plus a ShareBaseUrl templated from the deploy domain'
)
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build matches GAME_VERSION. */
export const VersionCheck = z.object({
VersionStatus: z.int().describe('0 = current, 1 = client on a different build'),
UpdateNotificationStage: z.int(),
IsVersionIslanded: z.boolean(),
IsCrossPlayDisabled: z.boolean(),
})
// ---- Social ----------------------------------------------------------------
/**
* The per-player relationship projection (`RelationshipResponse`). `PlayerID` is the
* OTHER player; the type and flags are taken from the caller's own side of the row, so
* the two players in a pair see different projections of it.
*/
export const RelationshipDto = z.object({
PlayerID: z.int().describe('The other player in the pair'),
RelationshipType: z
.int()
.describe('0 = none, 1 = friend request sent, 2 = friend request received, 3 = friend'),
Favorited: z.int().describe('0/1 — the callers own flag'),
Ignored: z.int().describe('0/1 — the callers own flag'),
Muted: z.int().describe('0/1 — the callers own flag'),
})
/**
* `POST /api/messages/v2/send` form body — a message sent to another player. Everything
* is a string on the wire (it's form-encoded). The sender is NOT in the body — it's
* taken from the bearer token.
*/
export const SendMessageRequest = z.object({
ToPlayerId: z.string().describe('Account id of the recipient'),
Type: z
.string()
.optional()
.describe('The Message-model type, e.g. `10`. Passed through unmapped; defaults to 0'),
Data: z.string().optional().describe('The message payload; often empty'),
})
/** The `{ Success, Message }` ack the flag toggles answer with. */
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
/**
* One entry of `GET /api/relationships/mutualfriends` — a friend both players share.
* A trimmed account card, not a relationship: no relationship type or flags.
*/
export const MutualFriendDto = z.object({
AccountId: z.int(),
Username: z.string(),
DisplayName: z.string(),
ProfileImage: z.string().describe('The image name; an empty string when the account has none'),
})
// ---- Progression -----------------------------------------------------------
/**
* A player's reputation (cheer counters). Nobody has earned cheers yet, so every
* counter is 0 and everyone has their full credit. `SelectedCheer` is an int (0 = none),
* not null, and `IsCheerful` is true — the client reads it to decide whether the player
* may hand out cheers at all.
*/
export const ReputationDto = z.object({
AccountId: z.int(),
IsCheerful: z.boolean(),
Noteriety: z.int(),
SelectedCheer: z.int().describe('0 = none selected'),
CheerCredit: z.int(),
CheerGeneral: z.int(),
CheerHelpful: z.int(),
CheerCreative: z.int(),
CheerGreatHost: z.int(),
CheerSportsman: z.int(),
SubscriberCount: z.int(),
SubscribedCount: z.int(),
})
/** A player's level/XP (`/api/players/v1/progression/:id`). */
export const ProgressionDto = z.object({
PlayerId: z.int(),
Level: z.int(),
XP: z.int(),
})
/** The `Ids` form body the bulk POST endpoints take. */
export const BulkIdsRequest = z.object({
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
})
// ---- Inventions ------------------------------------------------------------
/** One version of an invention — carries the blob name the client downloads. */
export const InventionVersionDto = z.object({
InventionId: z.int(),
ReplicationId: z.string(),
VersionNumber: z.int(),
BlobName: z.string().describe('The `.inv` key in the storage workers bucket'),
BlobHash: z
.string()
.nullable()
.describe('Base64 SHA-256 of the blob; null when it was never uploaded'),
InstantiationCost: z.int(),
LightsCost: z.int(),
ChipsCost: z.int(),
CloudVariablesCost: z.int(),
AICost: z.int(),
})
/** A tag on an invention. `Type` 0 = custom (creator-submitted), 2 = auto-derived. */
export const InventionTagDto = z.object({
Tag: z.string(),
Type: z.int().describe('0 = custom, 2 = auto'),
})
/** A stored invention record (the reference's `RRInvention`). */
export const InventionDto = z.object({
InventionId: z.int(),
ReplicationId: z.string(),
CreatorPlayerId: z.int(),
Name: z.string(),
Description: z.string(),
ImageName: z.string(),
CurrentVersionNumber: z.int(),
CurrentVersion: InventionVersionDto,
Accessibility: z.int(),
IsPublished: z.boolean().describe('Unpublished inventions are visible only to their creator'),
IsFeatured: z.boolean(),
ModifiedAt: z.string(),
CreatedAt: z.string(),
FirstPublishedAt: z.string().nullable(),
CreationRoomId: z.int(),
NumPlayersHaveUsedInRoom: z.int(),
NumDownloads: z.int(),
CheerCount: z.int(),
CreatorPermission: z.int(),
GeneralPermission: z.int().describe('What other players may do with it once published'),
IsAGInvention: z.boolean(),
IsCertifiedInvention: z.boolean(),
Price: z.int(),
AllowTrial: z.boolean(),
HideFromPlayer: z.boolean(),
ReferencedInventions: z.array(z.int()),
Tags: z
.array(InventionTagDto)
.optional()
.describe('Unset on save — the real RRInvention carries no Tags field'),
})
/** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */
export const InventionSaveResult = z.object({
Status: z.int().describe('0 = success'),
Invention: InventionDto,
InventionVersion: InventionVersionDto,
})
/** The tag filter chips on a browse screen, derived from the tags actually in use. */
export const TagFilters = z.object({
PinnedFilters: z.array(z.string()),
PopularFilters: z.array(z.string()),
TrendingFilters: z
.array(z.string())
.nullable()
.describe('Null — needs recent-activity data we dont keep'),
})
/** `GET /api/inventions/v1/details` — an invention's detail card is just its tags. */
export const InventionDetails = z.object({ Tags: z.array(InventionTagDto) })
/** `GET /api/inventions/v1/personaldetails/:id` — the caller's own relation to it. */
export const InventionPersonalDetails = z.object({
IsCheering: z.boolean().describe('Always false — nothing can cheer an invention yet'),
})
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
export const SetTagsRequest = z.object({
InventionId: z.int(),
AutoTags: z
.array(z.string())
.optional()
.describe('Client-derived tags (Type 2); each at most 15 letters once lowercased'),
CustomTags: z
.array(z.string())
.optional()
.describe('Creator-submitted tags (Type 0); each at most 15 letters once lowercased'),
})
/** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */
export const SetTagsResponse = z.object({
Result: z.int().describe('0 = success'),
Tags: z.array(z.string()).describe('Auto tags first, then custom'),
})
/** `POST /api/inventions/v1/updateprice` JSON body. */
export const UpdatePriceRequest = z.object({
InventionId: z.int(),
Price: z.int().describe('Must be >= 0'),
})
/** `POST /api/inventions/v6/save` JSON body — camelCase, unlike the read shapes. */
export const SaveInventionRequest = z.object({
inventionDataFilename: z
.string()
.describe('The blob uploaded through the storage worker; the one required field'),
name: z
.string()
.optional()
.describe('324 chars: letters, digits, spaces, dashes, colons. Omitted/blank ⇒ “Untitled”'),
description: z
.string()
.optional()
.describe('At most 512 chars. Omitted/blank ⇒ “No description yet”'),
imageName: z.string().optional(),
instantiationCost: z.int().optional(),
lightsCost: z.int().optional(),
chipsCost: z.int().optional(),
cloudVariablesCost: z.int().optional(),
aiCost: z.int().optional(),
creationRoomId: z.int().optional(),
referencedInventions: z.array(z.int()).optional(),
})
// ---- Avatar / custom avatar items ------------------------------------------
/** `POST /api/avatar/v2/gifts/generate` — a generated gift box (always a token gift). */
export const GeneratedGift = z.object({
Id: z.int().describe('Always 0 — gifts generated here are not persisted'),
FromPlayerId: z.int(),
ConsumableItemDesc: z.string(),
AvatarItemDesc: z.string(),
FriendlyName: z.string(),
AvatarItemType: z.int(),
EquipmentPrefabName: z.string(),
EquipmentModificationGuid: z.string(),
CurrencyType: z.int(),
Currency: z.int().describe('A random token amount'),
Xp: z.int(),
Level: z.int(),
Platform: z.int(),
PlatformsToSpawnOn: z.int(),
BalanceType: z.int(),
GiftContext: z.int(),
GiftRarity: z.int(),
Message: z.string(),
})
/** `POST /api/avatar/v2/gifts/generate` form body. */
export const GenerateGiftRequest = z.object({
GiftContext: z.string().optional().describe('Where the gift was earned'),
Message: z.string().optional(),
Xp: z.string().optional(),
})
/** A paginated custom-avatar-item page (no storage yet, so always empty). */
export const CustomAvatarItemsPage = z.object({
Results: JsonArray,
TotalResults: z.int(),
})
/** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */
export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.null() })
// ---- Gameplay --------------------------------------------------------------
/** `POST /api/sanitize/v1` JSON body — the text to clean. */
export const SanitizeRequest = z.object({ Value: z.string() })
/** `POST /api/sanitize/v1/isPure` — whether the text is clean (always true here). */
export const IsPureResponse = z.object({ IsPure: z.boolean() })
/** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */
export const KeepsakeConfig = z.object({
KeepsakeFeatureEnabled: z.boolean(),
KeepsakeRoomLimit: z.int(),
SocialXpBoostEnabled: z.boolean(),
})
/**
* A scheduled player event (Rec Room's `PlayerEvent`) — the record every read endpoint
* serves verbatim. The `State` / `Accessibility` / `*Permissions` ints are stored and
* echoed as the client sends them; their enums aren't reversed yet.
*/
export const PlayerEventDto = z.object({
PlayerEventId: z.int(),
CreatorPlayerId: z.int(),
ImageName: z.string().nullable().describe('Banner image; null until one is uploaded'),
RoomId: z.int(),
SubRoomId: z.int().nullable().describe('Null when the event doesnt pin a subroom'),
ClubId: z.int().nullable().describe('Null when the event isnt a clubs'),
Name: z.string(),
Description: z.string(),
StartTime: z.string().describe('ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`)'),
EndTime: z.string().describe('ISO 8601 UTC, seconds precision'),
AttendeeCount: z.int().describe('Starts at 1 — the creator attends their own event'),
State: z.int().describe('0 = scheduled'),
Accessibility: z.int(),
IsMultiInstance: z.boolean(),
SupportMultiInstanceRoomChat: z.boolean(),
DefaultBroadcastPermissions: z.int(),
CanRequestBroadcastPermissions: z.int(),
})
/** The `{ Result, TagModifyResult, PlayerEvent }` envelope the event writes answer with. */
export const PlayerEventResultDto = z.object({
Result: z.int().describe('0 = success'),
TagModifyResult: z
.null()
.describe('Always null — the write carries no tag edit, as no event tags are stored'),
PlayerEvent: PlayerEventDto,
})
/**
* The JSON body of an event create / update. Every field is optional: create defaults
* what's missing, update leaves anything absent at its stored value. The fields may be
* posted at the top level or nested under `PlayerEvent` — the client posts back the
* same envelope it read — and both forms are accepted. `PlayerEventId`,
* `CreatorPlayerId` and `AttendeeCount` are ignored if present: the id is assigned
* here, the creator comes from the bearer token, and RSVPs aren't set by hand.
*/
export const PlayerEventRequest = PlayerEventDto.partial().extend({
PlayerEvent: z
.unknown()
.optional()
.describe('The events fields, if nested rather than posted at the top level'),
})
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
export const PlayerEventRespondRequest = z.object({
PlayerEventId: z.int(),
Type: z.int().describe('0 Going, 1 Interested, 2 Cant go'),
})
/** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */
export const PlayerEventsAll = z.object({
Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'),
Responses: JsonArray.describe(
'Events the caller RSVPd to — always empty; RSVPs are stored, but this fields ' +
'entry shape has not been observed yet'
),
})
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
export const PlayerEventsPage = z.object({
ContinuationToken: z.string().describe('Empty = no next page'),
Events: JsonArray,
})
/** `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.
*/
export const ModerationBlockDetails = z.object({
ReportCategory: z.int().describe('-1 = no category (0 is a real one)'),
Duration: z.int(),
GameSessionId: z.int(),
IsBan: z.boolean(),
IsHostKick: z.boolean(),
IsVoiceModAutoban: z.boolean(),
Message: z.string().nullable(),
PlayerIdReporter: z.int().nullable(),
TimeoutStartedAt: z.string().nullable(),
})
/**
* `POST /api/PlayerReporting/v3/create` form body — a player report. Everything is a
* string on the wire (it's form-encoded); only `PlayerIdReported` is required. The
* reporter is NOT in the body — it's taken from the bearer token.
*/
export const CreateReportRequest = z.object({
PlayerIdReported: z.string().describe('Account id of the player being reported'),
ReportCategory: z
.string()
.optional()
.describe('The reason picked in the report UI, e.g. `100`. Stored verbatim; unmapped'),
Details: z.string().optional().describe('The free-text description the reporter typed'),
HeightReporter: z
.string()
.optional()
.describe('Reporters player height in metres at report time, e.g. `1.64`'),
HeightReported: z.string().optional().describe('Reported players height in metres'),
RoomId: z.string().optional().describe('Room the report was raised in, if any'),
RoomInstanceType: z
.string()
.optional()
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
})
/**
* `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything
* is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
* moderator is NOT in the body — it's taken from the bearer token.
*/
export const CreateWarningRequest = z.object({
WarnedPlayerId: z.string().describe('Account id of the player being warned'),
ReportCategory: z
.string()
.optional()
.describe('The reason category, e.g. `101`. Stored verbatim; unmapped'),
DisplayReason: z
.string()
.optional()
.describe('What the warned player is shown, e.g. `Sexual gestures`'),
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
})
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
export const DeviceIdRequest = z.object({
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
newDeviceId: z.string().optional(),
platform: z.string().optional(),
})
// ---- Rooms -----------------------------------------------------------------
/** `GET /api/quickPlay/v1/getandclear` — a pending quick-play action; all null = none. */
export const QuickPlayResponse = z.object({
RoomName: z.string().nullable(),
ActionCode: z.string().nullable(),
TargetPlayerId: z.int().nullable(),
})
/** `POST /api/rooms/v1/verifyRole` form body. */
export const VerifyRoleRequest = z.object({
roomId: z.string(),
role: z.string().describe('The minimum role level required'),
context: z.string().optional().describe('e.g. MakerPen — accepted and ignored'),
})
// ---- Images ----------------------------------------------------------------
/**
* A stored image record. Note the room photo feed (`/api/images/v4/room/:roomId`)
* serves this shape raw, while the player lists serve the `ImagesPlayer` projection
* below — deliberately different, see the client-contract notes in CLAUDE.md.
*/
export const SavedImageDto = z.object({
Id: z.int(),
Type: z.int().describe('SavedImageType: 1 = share camera, 3 = room, 4 = profile, …'),
Accessibility: z.int(),
AccessibilityLocked: z.boolean(),
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
Description: z.string().nullable(),
PlayerId: z.int(),
TaggedPlayerIds: z.array(z.int()),
RoomId: z.int().nullable(),
PlayerEventId: z.int().nullable(),
CreatedAt: z.string(),
CheerCount: z.int(),
CommentCount: z.int(),
})
/**
* The client's `ImagesPlayer` projection — the same record with `Id` → `SavedImageId`,
* `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed
* MUST serve this: the raw SavedImage renders blank thumbnails.
*/
export const ImagesPlayerDto = z.object({
SavedImageId: z.int(),
SavedImageType: z.int(),
Accessibility: z.int(),
AccessibilityLocked: z.boolean(),
CheerCount: z.int(),
CommentCount: z.int(),
CreatedAt: z.string(),
Description: z.string().nullable(),
ImageName: z.string(),
PlayerEventId: z.int().nullable(),
PlayerId: z.int(),
RoomId: z.int().nullable(),
})
/** One entry in the anonymous slideshow feed, joined to its creator and room. */
export const SlideshowImageDto = z.object({
SavedImageId: z.int(),
ImageName: z.string(),
Username: z.string(),
RoomName: z.string().nullable(),
RoomId: z.int().nullable(),
SavedImageType: z.int(),
PlayerEventId: z.int().nullable(),
Accessibility: z.int(),
PlayerIds: z.array(z.int()),
})
/** `GET /api/images/v1/slideshow` — the feed plus a short cache hint. */
export const SlideshowResponse = z.object({
Images: z.array(SlideshowImageDto),
ValidTill: z.string().describe('ISO timestamp ~2 minutes out; the client refreshes against it'),
})
/** `POST /api/images/v4/uploadsaved` multipart body. */
export const UploadImageRequest = z.object({
image: z.string().describe('The image file (`file` is accepted too)'),
imgMeta: z
.string()
.optional()
.describe(
'A JSON `SavedImageMetaDTO`: { playerIds, savedImageType, roomId, playerEventId, accessibility, description }'
),
})
/** `POST /api/images/v4/uploadsaved` — the stored bucket key. */
export const UploadImageResponse = z.object({
ImageName: z.string().describe('The bucket key; the img worker serves the object by it'),
})
/** `DELETE /api/images/v1/deletesaved` JSON body. */
export const DeleteImageRequest = z.object({ ImageName: z.string() })
/** `POST /api/images/v1/cheer` JSON body. */
export const CheerImageRequest = z.object({
SavedImageId: z.int(),
Cheer: z.boolean().describe('True to cheer, false to un-cheer'),
})
/** The bare `{ success: true }` ack the image writes answer with. */
export const SuccessResponse = z.object({ success: z.boolean() })
/** One entry of `GET /api/images/v5/cheered/bulk`, one per requested id, in order. */
export const CheeredEntry = z.object({
SavedImageId: z.int(),
IsCheered: z.boolean(),
})
+155 -42
View File
@@ -14,7 +14,7 @@
* workers' migrations that share the database).
*/
/** Relationship state from the perspective of the player asking (mirror of the C# enum). */
/** Relationship state from the perspective of the player asking (mirrors the reference). */
export enum RelationshipType {
None = 0,
FriendRequestSent = 1,
@@ -53,7 +53,7 @@ interface RelationshipRow {
target_muted: number
}
/** The per-player relationship projection returned to the client (the C# RelationshipResponse). */
/** The per-player relationship projection returned to the client (RelationshipResponse). */
export interface RelationshipResponse {
Favorited: number
Ignored: number
@@ -62,6 +62,34 @@ export interface RelationshipResponse {
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
@@ -85,6 +113,16 @@ function toResponse(row: RelationshipRow, playerId: number): RelationshipRespons
}
}
/** 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
@@ -98,7 +136,10 @@ async function findPair(db: D1Database, a: number, b: number): Promise<Relations
/**
* All of a player's relationships, projected from that player's point of view.
* `None` rows are omitted (a removed friend leaves no relationship to report).
*
* `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,
@@ -111,9 +152,49 @@ export async function getRelationshipsForPlayer(
)
.bind(playerId)
.all<RelationshipRow>()
return results
.filter((row) => row.relationship_type !== RelationshipType.None)
.map((row) => toResponse(row, playerId))
return results.map((row) => toResponse(row, playerId))
}
/**
* The ids of everyone a player is actually friends with — `Friend` rows only, from
* either side of the pair (the row records one direction, the friendship is mutual).
* Pending requests and `None` rows are excluded, unlike
* {@link getRelationshipsForPlayer}, which reports the whole graph.
*/
export async function getFriendIds(db: D1Database, playerId: number): Promise<number[]> {
const { results } = await db
.prepare(
`SELECT CASE WHEN requester_id = ?1 THEN target_id ELSE requester_id END AS id
FROM relationship
WHERE relationship_type = ?2 AND (requester_id = ?1 OR target_id = ?1)`
)
.bind(playerId, RelationshipType.Friend)
.all<{ id: number }>()
return results.map((r) => r.id)
}
/** How many mutual friends the mutual-friends lookup will return at most. */
export const MUTUAL_FRIENDS_LIMIT = 100
/**
* The ids two players are both friends with — the intersection of their friend lists,
* ascending and capped at {@link MUTUAL_FRIENDS_LIMIT}. Each player's friends are a
* small set, so the intersection is done in memory rather than as a SQL INTERSECT.
*/
export async function getMutualFriendIds(
db: D1Database,
playerId: number,
otherId: number
): Promise<number[]> {
const [mine, theirs] = await Promise.all([
getFriendIds(db, playerId),
getFriendIds(db, otherId),
])
const ours = new Set(theirs)
return mine
.filter((id) => ours.has(id))
.sort((a, b) => a - b)
.slice(0, MUTUAL_FRIENDS_LIMIT)
}
/**
@@ -121,14 +202,14 @@ export async function getRelationshipsForPlayer(
* 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
* relationship from `requesterId`'s point of view.
* 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<RelationshipResponse> {
): Promise<RelationshipRow> {
const existing = await findPair(db, requesterId, targetId)
if (!existing) {
await db
@@ -138,7 +219,17 @@ async function upsertPair(
)
.bind(requesterId, targetId, type)
.run()
return { PlayerID: targetId, RelationshipType: type, Favorited: 0, Ignored: 0, Muted: 0 }
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
@@ -175,93 +266,117 @@ async function upsertPair(
)
.run()
return {
PlayerID: targetId,
RelationshipType: type,
Favorited: reqFlags.favorited,
Ignored: reqFlags.ignored,
Muted: reqFlags.muted,
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 is left unchanged.
* Returns the relationship from the requester's point of view.
* (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<RelationshipResponse> {
): Promise<RelationshipChange> {
const existing = await findPair(db, requesterId, targetId)
if (existing) {
if (existing.relationship_type === RelationshipType.Friend) {
return toResponse(existing, requesterId)
// 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
) {
return upsertPair(db, requesterId, targetId, RelationshipType.Friend)
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
return toChange(row, requesterId, targetId, true)
}
}
return upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
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 unchanged. Returns the relationship from the
* accepter's point of view.
* 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<RelationshipResponse> {
): Promise<RelationshipChange> {
const existing = await findPair(db, accepterId, otherId)
if (
existing &&
existing.requester_id === otherId &&
existing.relationship_type === RelationshipType.FriendRequestSent
) {
// upsertPair projects for the requester (otherId); the accepter is the target,
// so re-project the written row from the accepter's point of view.
await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
const updated = await findPair(db, accepterId, otherId)
if (updated) return toResponse(updated, accepterId)
const row = await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
return toChange(row, accepterId, otherId, true)
}
return existing
? toResponse(existing, accepterId)
: { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 }
? toChange(existing, accepterId, otherId, false)
: { self: noneResponse(otherId), other: noneResponse(accepterId), changed: false }
}
/**
* Directly make `requesterId` and `targetId` friends (no pending request step).
* Returns the relationship from the requester's point of view.
*/
export async function addFriend(
db: D1Database,
requesterId: number,
targetId: number
): Promise<RelationshipResponse> {
return upsertPair(db, requesterId, targetId, RelationshipType.Friend)
): 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). Deletes the row entirely so neither side reports a relationship.
* 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, a: number, b: number): Promise<void> {
export async function removeFriend(
db: D1Database,
playerId: number,
otherId: number
): Promise<RelationshipChange> {
await db
.prepare(
`DELETE FROM relationship
`UPDATE relationship SET relationship_type = ?3
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
)
.bind(a, b)
.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. */
@@ -308,7 +423,5 @@ export async function setRelationshipFlag(
}
const updated = await findPair(db, playerId, otherId)
return updated
? toResponse(updated, playerId)
: { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 }
return updated ? toResponse(updated, playerId) : noneResponse(otherId)
}
+103
View File
@@ -0,0 +1,103 @@
/**
* 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: nothing updates or dedupes a
* report, so the table is a log of exactly what players submitted.
*
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
* applied under its own `migrations_table` so it doesn't clash with the other
* workers' migrations that share the database).
*
* Nothing acts on the rows yet — `/api/PlayerReporting/v1/moderationBlockDetails`
* still answers "not blocked" unconditionally; this is the record that a future
* moderation flow would read.
*/
/** Schema DDL (mirror of migrations/0004_report.sql, sans seed rows). */
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
)`,
`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)`,
]
/** 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
}
/**
* 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
}
/** 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
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
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()
)
.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
}
+653 -208
View File
@@ -1,16 +1,23 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
inventionDescriptionRejection,
inventionNameRejection,
inventionTagRejection,
} from '@repo/domain'
import { authedId, unauthorized } from '../http'
import {
createInvention,
getFeaturedInventions,
getInventionById,
getInventionsByCreator,
getInventionsByIds,
getInventionsByRoom,
getInventionTagFilters,
getInventionTags,
getInventionVersion,
getMyInventions,
getTopInventions,
parsePermissionLevel,
publishInvention,
@@ -20,6 +27,34 @@ import {
toSaveResult,
updateInvention,
} from '../inventions-db'
import {
AUTHED,
BareBoolean,
CustomAvatarItemsPage,
ErrorResponse,
form,
GeneratedGift,
GenerateGiftRequest,
idParam,
intQuery,
InventionDetails,
InventionDto,
InventionPersonalDetails,
InventionSaveResult,
InventionVersionDto,
json,
JsonArray,
jsonBody,
pageParams,
SaveInventionRequest,
SetTagsRequest,
SetTagsResponse,
stringQuery,
SuccessValueEnvelope,
TagFilters,
UNAUTHORIZED_RESPONSE,
UpdatePriceRequest,
} from '../openapi'
import type { Context } from 'hono'
import type { App } from '../context'
@@ -52,129 +87,295 @@ async function creatorsInvention(
// gift-box consume live in the `econ` worker, which the client calls on the econ host
// — not here. Only the gift `generate` action remains on this worker.
export const avatarRoutes = new Hono<App>({ strict: false })
.post('/api/avatar/v2/gifts/generate', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.post(
'/api/avatar/v2/gifts/generate',
describeRoute({
tags: ['Avatar'],
summary: 'Generate a gift box',
description:
'Mint the gift box a player earned (levelling up, a room reward). With no ' +
'EarnableRewards catalog wired up this always falls back to a token gift of a ' +
'random amount, and the box is not persisted — its `Id` is 0 and it cannot be ' +
'opened through the `econ` workers consume endpoint.',
security: AUTHED,
requestBody: form(GenerateGiftRequest, 'Where the gift was earned'),
responses: {
200: json(GeneratedGift, 'The generated (unpersisted) gift'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const giftContext =
typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0
const message = typeof body.Message === 'string' ? body.Message : ''
const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const giftContext =
typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0
const message = typeof body.Message === 'string' ? body.Message : ''
const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0
// No EarnableRewards binding → always fall back to a token gift.
const tokenAmounts = [10, 25, 50, 100, 250, 500]
const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)]
// No EarnableRewards binding → always fall back to a token gift.
const tokenAmounts = [10, 25, 50, 100, 250, 500]
const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)]
return c.json({
Id: 0, // TODO: real id once gifts are persisted
FromPlayerId: 1,
ConsumableItemDesc: '',
AvatarItemDesc: '',
FriendlyName: '',
AvatarItemType: 0,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
CurrencyType: 2,
Currency: currency,
Xp: xp,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: 0,
GiftContext: giftContext,
GiftRarity: 20,
Message: message,
})
})
return c.json({
Id: 0, // TODO: real id once gifts are persisted
FromPlayerId: 1,
ConsumableItemDesc: '',
AvatarItemDesc: '',
FriendlyName: '',
AvatarItemType: 0,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
CurrencyType: 2,
Currency: currency,
Xp: xp,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: 0,
GiftContext: giftContext,
GiftRarity: 20,
Message: message,
})
}
)
// Custom avatar item gates — real Rec Room client endpoints with no backing
// implementation yet; we enable them. Flip to `false` to disable the
// corresponding flow. `isCreationAllowedForAccount` wraps its answer in the
// success/value envelope; the other two return a bare JSON boolean.
.get('/api/customAvatarItems/v1/isCreationAllowedForAccount', (c) =>
c.json({ success: true, value: null })
.get(
'/api/customAvatarItems/v1/isCreationAllowedForAccount',
describeRoute({
tags: ['Avatar'],
summary: 'May this account create custom items?',
description:
'A feature gate with no backing implementation — we answer yes. Note this one ' +
'wraps its answer in the `{ success, value }` envelope while the two gates below ' +
'return a bare boolean.',
responses: { 200: json(SuccessValueEnvelope, 'Allowed') },
}),
(c) => c.json({ success: true, value: null })
)
.get(
'/api/customAvatarItems/v1/isCreationEnabled',
describeRoute({
tags: ['Avatar'],
summary: 'Is custom-item creation enabled?',
description: 'A server-wide feature gate. Enabled; flip to `false` to disable the flow.',
responses: { 200: json(BareBoolean, 'A bare `true`') },
}),
(c) => c.json(true)
)
.get(
'/api/customAvatarItems/v1/isRenderingEnabled',
describeRoute({
tags: ['Avatar'],
summary: 'Is custom-item rendering enabled?',
description: 'A server-wide feature gate. Enabled; flip to `false` to disable the flow.',
responses: { 200: json(BareBoolean, 'A bare `true`') },
}),
(c) => c.json(true)
)
.get('/api/customAvatarItems/v1/isCreationEnabled', (c) => c.json(true))
.get('/api/customAvatarItems/v1/isRenderingEnabled', (c) => c.json(true))
// The featured custom-avatar-item feed. No curated items yet → an empty list.
.get('/api/customAvatarItems/v1/featured', (c) => c.json([]))
.get(
'/api/customAvatarItems/v1/featured',
describeRoute({
tags: ['Avatar'],
summary: 'Featured custom avatar items',
description: 'The curated feed. Nothing is curated yet, so it is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
// The "hot" (trending) custom-avatar-item feed. No items yet → an empty list.
.get('/api/customAvatarItems/v1/hot', (c) => c.json([]))
.get(
'/api/customAvatarItems/v1/hot',
describeRoute({
tags: ['Avatar'],
summary: 'Trending custom avatar items',
description: 'The “hot” feed. No custom items exist yet, so it is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
// Custom avatar items created by a given account. No storage yet → an empty
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
.get('/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}', (c) =>
c.json({ Results: [], TotalResults: 0 })
.get(
'/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}',
describeRoute({
tags: ['Avatar'],
summary: 'A creators custom avatar items',
description:
'The items an account has authored. Nothing stores custom items yet, so this is an ' +
'empty page — in the same shape as the `econ` workers `customAvatarItems/v1/owned`.',
parameters: [idParam('accountId', 'Creator account id')],
responses: { 200: json(CustomAvatarItemsPage, 'An empty page') },
}),
(c) => c.json({ Results: [], TotalResults: 0 })
)
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
// or 404 when there's no such invention.
.get('/api/inventions/v1', async (c) => {
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
const invention = await getInventionById(c.env.DB, inventionId)
return invention ? c.json(invention) : c.notFound()
})
.get(
'/api/inventions/v1',
describeRoute({
tags: ['Inventions'],
summary: 'One invention by id',
description: 'The stored `RRInvention`. Public — an unpublished invention is served too.',
parameters: [intQuery('inventionId', 'Invention id; required')],
responses: {
200: json(InventionDto, 'The invention'),
400: json(ErrorResponse, 'Missing or non-numeric inventionId'),
404: { description: 'No such invention' },
},
}),
async (c) => {
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
const invention = await getInventionById(c.env.DB, inventionId)
return invention ? c.json(invention) : c.notFound()
}
)
// The tag filter chips on the invention browse screen. Derived from the tags in
// use on published inventions — most popular first, top few pinned. Public.
.get('/api/inventions/v1/tagfilters', async (c) => c.json(await getInventionTagFilters(c.env.DB)))
.get(
'/api/inventions/v1/tagfilters',
describeRoute({
tags: ['Inventions'],
summary: 'Invention browse filter chips',
description:
'The filter chips on the invention browse screen, derived from the tags actually in ' +
'use on published inventions — most popular first, the top few pinned. ' +
'`TrendingFilters` is null: that needs recent-activity data we do not keep, and the ' +
'client treats null as absent.',
responses: { 200: json(TagFilters, 'The chips in use') },
}),
async (c) => c.json(await getInventionTagFilters(c.env.DB))
)
// A batch of inventions by id (`?id=1&id=2`, and each `id` may itself be a
// comma-separated list). Unknown ids are dropped rather than 404ing, and an empty
// request is an empty list. Auth is optional and only widens what you see: an
// unpublished invention comes back only to its creator. Bare array.
.get('/api/inventions/v2/batch', async (c) => {
const ids = c.req
.queries('id')
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((id) => !Number.isNaN(id))
if (ids === undefined || ids.length === 0) return c.json([])
.get(
'/api/inventions/v2/batch',
describeRoute({
tags: ['Inventions'],
summary: 'Inventions by id, in bulk',
description:
'Look up several inventions at once. Unknown ids are dropped rather than 404ing, ' +
'and an empty request is an empty list. Auth is optional and only widens what you ' +
'see: an unpublished invention comes back only to its creator.',
parameters: [intQuery('id', 'Repeatable; each value may be a comma-separated list of ids')],
responses: { 200: json(InventionDto.array(), 'The inventions the caller may see') },
}),
async (c) => {
const ids = 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 playerId = await authedId(c)
const inventions = await getInventionsByIds(c.env.DB, ids)
return c.json(
inventions.filter(
(i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId)
const playerId = await authedId(c)
const inventions = await getInventionsByIds(c.env.DB, ids)
return c.json(
inventions.filter(
(i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId)
)
)
)
})
}
)
// A room's inventions (`?id=76`) — published inventions created in that room,
// newest first. Paginated via skip/take (take defaults to 100). Bare array.
.get('/api/inventions/v1/room', async (c) => {
const roomId = Number.parseInt(c.req.query('id') ?? '', 10)
if (Number.isNaN(roomId)) return c.json({ error: 'id is required' }, 400)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getInventionsByRoom(c.env.DB, roomId, skip, take))
})
.get(
'/api/inventions/v1/room',
describeRoute({
tags: ['Inventions'],
summary: 'A rooms inventions',
description: 'Published inventions created in that room, newest first.',
parameters: [intQuery('id', 'Room id; required'), ...pageParams(100)],
responses: {
200: json(InventionDto.array(), 'The rooms inventions'),
400: json(ErrorResponse, 'Missing or non-numeric id'),
},
}),
async (c) => {
const roomId = Number.parseInt(c.req.query('id') ?? '', 10)
if (Number.isNaN(roomId)) return c.json({ error: 'id is required' }, 400)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getInventionsByRoom(c.env.DB, roomId, skip, take))
}
)
// The signed-in player's own relationship to an invention (`/personaldetails/2`)
// — just whether they're cheering it. We store no cheers (nothing can cheer an
// invention yet), so this is always false; it stays a 200 for signed-out callers
// too, since the client only reads the flag.
.get('/api/inventions/v1/personaldetails/:inventionId{[0-9]+}', (c) =>
c.json({ IsCheering: false })
.get(
'/api/inventions/v1/personaldetails/:inventionId{[0-9]+}',
describeRoute({
tags: ['Inventions'],
summary: 'The callers own relation to an invention',
description:
'Just whether the caller is cheering it. We store no cheers, so it is always false ' +
'— and this stays a 200 for signed-out callers too, since the client only reads the ' +
'flag.',
parameters: [idParam('inventionId', 'Invention id')],
responses: { 200: json(InventionPersonalDetails, 'Always not cheering') },
}),
(c) => c.json({ IsCheering: false })
)
// A single version of an invention (`?inventionId=…&version=…`) — the bare
// RRInventionVersion, which carries the blob name the client downloads. Public.
// Only the current version exists (nothing writes version history yet), so any
// other version number 404s rather than naming a blob that isn't there.
.get('/api/inventions/v1/version', async (c) => {
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
// RRInventionVersion, which carries the blob name the client downloads and the
// SHA-256 of that blob. Public. Only the current version exists (nothing writes
// version history yet), so any other version number 404s rather than naming a
// blob that isn't there.
.get(
'/api/inventions/v1/version',
describeRoute({
tags: ['Inventions'],
summary: 'One version of an invention',
description:
'The bare `RRInventionVersion`, which carries the blob name the client downloads ' +
'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
'never uploaded). Only the current version exists — nothing writes version ' +
'history yet — so any other version number 404s rather than naming a blob that ' +
'is not there.',
parameters: [
intQuery('inventionId', 'Invention id; required'),
intQuery('version', 'Version number; required'),
],
responses: {
200: json(InventionVersionDto, 'The version'),
400: json(ErrorResponse, 'Missing inventionId or version'),
404: { description: 'No such invention, or not the current version' },
},
}),
async (c) => {
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
const version = await getInventionVersion(c.env.DB, inventionId, versionNumber)
return version === null ? c.notFound() : c.json(version)
})
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
@@ -183,67 +384,150 @@ export const avatarRoutes = new Hono<App>({ strict: false })
// empty `description` clears it, but an empty `name`/`imageName` is ignored
// rather than blanking the invention. Publishing and pricing are separate
// endpoints. Auth-gated, creator only; answers the save envelope.
.get('/api/inventions/v1/update', async (c) => {
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
if ('response' in gate) return gate.response
.get(
'/api/inventions/v1/update',
describeRoute({
tags: ['Inventions'],
summary: 'Edit an inventions 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. A supplied name/description must satisfy the same rules `v6/save` ' +
'enforces. Publishing and pricing are separate endpoints.',
security: AUTHED,
parameters: [
intQuery('inventionId', 'Invention id; required'),
stringQuery('name', '324 chars, letters/digits/spaces/dashes/colons; empty is ignored'),
stringQuery('description', 'Max 512 chars; present-but-empty clears it'),
stringQuery('imageName', 'New thumbnail; empty is ignored'),
stringQuery('allowTrial', '`true`/`1` to allow trials'),
stringQuery('permission', 'A name like `useonly`, or the raw permission number'),
],
responses: {
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
400: json(ErrorResponse, 'A supplied name or description breaks its rule'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers invention'),
404: { description: 'No such invention' },
},
}),
async (c) => {
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
if ('response' in gate) return gate.response
// Query params arrive as strings; only the ones actually present are applied.
const nonEmpty = (name: string): string | undefined => {
const v = c.req.query(name)?.trim()
return v === undefined || v === '' ? undefined : v
// Query params arrive as strings; only the ones actually present are applied.
const nonEmpty = (name: string): string | undefined => {
const v = c.req.query(name)?.trim()
return v === undefined || v === '' ? undefined : v
}
const allowTrial = c.req.query('allowTrial')
const permission = c.req.query('permission')
// Only a name that's actually being changed is checked — an absent or empty one
// keeps the stored name, which was already validated when it was set.
const name = nonEmpty('name')
const nameRejection = name === undefined ? null : inventionNameRejection(name)
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
// The description is checked on presence, not emptiness: empty is how a creator
// clears it, and the length rule accepts that.
const description = c.req.query('description')
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
name,
// Present-but-empty clears the description, so this checks presence.
description,
imageName: nonEmpty('imageName'),
allowTrial:
allowTrial === undefined
? undefined
: allowTrial.toLowerCase() === 'true' || allowTrial === '1',
generalPermission: permission === undefined ? undefined : parsePermissionLevel(permission),
})
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
}
const allowTrial = c.req.query('allowTrial')
const permission = c.req.query('permission')
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
name: nonEmpty('name'),
// Present-but-empty clears the description, so this checks presence.
description: c.req.query('description'),
imageName: nonEmpty('imageName'),
allowTrial:
allowTrial === undefined
? undefined
: allowTrial.toLowerCase() === 'true' || allowTrial === '1',
generalPermission: permission === undefined ? undefined : parsePermissionLevel(permission),
})
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
})
)
// Publish an invention — this is what puts it into search and the feeds. Sets the
// permission other players get (`permissionLevel`, defaulting to UseOnly) and its
// `price`. Auth-gated, creator only; answers the save envelope.
.get('/api/inventions/v3/publish', async (c) => {
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
if ('response' in gate) return gate.response
.get(
'/api/inventions/v3/publish',
describeRoute({
tags: ['Inventions'],
summary: 'Publish an invention',
description:
'What puts an invention into search and the feeds. Sets the permission other ' +
'players get (defaulting to UseOnly) and its price. Another GET that writes.',
security: AUTHED,
parameters: [
intQuery('inventionId', 'Invention id; required'),
stringQuery('permissionLevel', 'A name like `useonly`, or the raw number'),
intQuery('price', 'Price in tokens; negative is ignored'),
],
responses: {
200: json(InventionSaveResult, 'The published invention, in the save envelope'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers invention'),
404: { description: 'No such invention' },
},
}),
async (c) => {
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
if ('response' in gate) return gate.response
const permissionLevel = c.req.query('permissionLevel')
const price = Number.parseInt(c.req.query('price') ?? '', 10)
const permissionLevel = c.req.query('permissionLevel')
const price = Number.parseInt(c.req.query('price') ?? '', 10)
const published = await publishInvention(
c.env.DB,
gate.invention.InventionId,
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
Number.isNaN(price) || price < 0 ? undefined : price
)
return published === null ? c.notFound() : c.json(toSaveResult(published))
})
const published = await publishInvention(
c.env.DB,
gate.invention.InventionId,
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
Number.isNaN(price) || price < 0 ? undefined : price
)
return published === null ? c.notFound() : c.json(toSaveResult(published))
}
)
// Set an invention's price. Unlike update/publish this one POSTs a JSON body.
// Auth-gated, creator only; answers the save envelope.
.post('/api/inventions/v1/updateprice', async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
.post(
'/api/inventions/v1/updateprice',
describeRoute({
tags: ['Inventions'],
summary: 'Set an inventions price',
description:
'Unlike update/publish, this one POSTs a JSON body. Creator only; a negative price ' +
'is rejected.',
security: AUTHED,
requestBody: jsonBody(UpdatePriceRequest, 'The invention and its new price'),
responses: {
200: json(InventionSaveResult, 'The repriced invention, in the save envelope'),
400: json(ErrorResponse, 'Unparseable body, or a price below 0'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers invention'),
404: { description: 'No such invention' },
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
const gate = await creatorsInvention(c, inventionId)
if ('response' in gate) return gate.response
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
const gate = await creatorsInvention(c, inventionId)
if ('response' in gate) return gate.response
const price = typeof body.Price === 'number' ? body.Price : Number.NaN
if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400)
const price = typeof body.Price === 'number' ? body.Price : Number.NaN
if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400)
const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
})
const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
}
)
// Replace an invention's tags. `CustomTags` are the creator's own (Type 0),
// `AutoTags` the ones the client derives from the invention (Type 2); both lists
@@ -251,69 +535,191 @@ export const avatarRoutes = new Hono<App>({ strict: false })
// invention. Answers `{ Result, Tags }` — `Result` 0 is success, and `Tags` is the
// flat list of tag *names* (auto first, then custom); the typed `{ Tag, Type }`
// objects are what `v1/details` serves.
.post('/api/inventions/v1/settags', async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
.post(
'/api/inventions/v1/settags',
describeRoute({
tags: ['Inventions'],
summary: 'Replace an inventions tags',
description:
'`CustomTags` are the creators own (Type 0), `AutoTags` the ones the client ' +
'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' +
'only.\n\n' +
'Every tag in either list must be at most 15 letters (az once lowercased); one ' +
'that isnt fails the whole call, so no tag is ever silently dropped.\n\n' +
'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' +
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
security: AUTHED,
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
responses: {
200: json(SetTagsResponse, 'The resulting tag names'),
400: json(ErrorResponse, 'Unparseable body, or a tag that breaks the rule'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers invention'),
404: { description: 'No such invention' },
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
const gate = await creatorsInvention(c, inventionId)
if ('response' in gate) return gate.response
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
const gate = await creatorsInvention(c, inventionId)
if ('response' in gate) return gate.response
const strings = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
const strings = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
const tags = await setInventionTags(
c.env.DB,
gate.invention.InventionId,
strings(body.AutoTags),
strings(body.CustomTags)
)
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
})
const autoTags = strings(body.AutoTags)
const customTags = strings(body.CustomTags)
// Both lists are held to the tag rule, and one bad tag fails the whole call rather
// than being dropped — a silently missing tag looks to the creator like a tag that
// saved. Checked against the normalized form `setInventionTags` will store, so the
// rejection quotes the tag as it would have been stored, not as it was typed.
// Blanks are skipped, not rejected: the store already drops them, and the client
// pads its list with empties.
for (const raw of [...autoTags, ...customTags]) {
const tag = raw.trim().toLowerCase()
if (tag === '') continue
const rejection = inventionTagRejection(tag)
if (rejection !== null) {
return c.json({ error: `${rejection} (“${tag}”)` }, 400)
}
}
const tags = await setInventionTags(
c.env.DB,
gate.invention.InventionId,
autoTags,
customTags
)
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
}
)
// An invention's detail card (`?inventionId=…`) — just its tags, as `{ Tags }`.
// Untagged inventions report an empty list. 404s on unknown ids.
.get('/api/inventions/v1/details', async (c) => {
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
const tags = await getInventionTags(c.env.DB, inventionId)
return tags === null ? c.notFound() : c.json({ Tags: tags })
})
.get(
'/api/inventions/v1/details',
describeRoute({
tags: ['Inventions'],
summary: 'An inventions detail card',
description:
'Which in practice is just its tags, as typed `{ Tag, Type }` objects. An untagged ' +
'invention reports an empty list.',
parameters: [intQuery('inventionId', 'Invention id; required')],
responses: {
200: json(InventionDetails, 'The inventions tags'),
400: json(ErrorResponse, 'Missing or non-numeric inventionId'),
404: { description: 'No such invention' },
},
}),
async (c) => {
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
const tags = await getInventionTags(c.env.DB, inventionId)
return tags === null ? c.notFound() : c.json({ Tags: tags })
}
)
// The "top today" invention feed — published inventions ranked by engagement
// (lifetime, not per-day: we keep no daily counters). Paginated via skip/take
// (take defaults to 50, as the client asks for). Bare array.
.get('/api/inventions/v1/toptoday', async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
return c.json(await getTopInventions(c.env.DB, skip, take))
})
// The "top today" invention feed — the inventions most acquired in the last 24 hours,
// counted from the purchase rows the `econ` worker writes. A real day window, so an
// empty list is a quiet day rather than a bug. Paginated via skip/take (take defaults
// to 50, as the client asks for). Bare array.
.get(
'/api/inventions/v1/toptoday',
describeRoute({
tags: ['Inventions'],
summary: 'The “top today” feed',
description:
'Published inventions ranked by how many players acquired them in the last 24 ' +
'hours, counted from the purchase records — free grants included, one per ' +
'player per invention. Genuinely a window: an invention nobody has picked up ' +
'since yesterday falls off, and a day with no acquisitions at all serves an ' +
'empty list. It trails the clock rather than resetting at midnight.',
parameters: pageParams(50),
responses: { 200: json(InventionDto.array(), 'The top inventions') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
return c.json(await getTopInventions(c.env.DB, skip, take))
}
)
// The featured invention feed — curated (`IsFeatured`) inventions, falling back
// to the top feed while nothing is curated. Bare array, like toptoday.
.get('/api/inventions/v1/featured', async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
return c.json(await getFeaturedInventions(c.env.DB, skip, take))
})
// The featured invention feed — the curated (`IsFeatured`) inventions and nothing
// else, newest first. Empty until someone flags one. Bare array, like toptoday.
.get(
'/api/inventions/v1/featured',
describeRoute({
tags: ['Inventions'],
summary: 'The featured feed',
description:
'Curated (`IsFeatured`) inventions, newest first — published and non-hidden only. ' +
'Serves an empty list while nothing is flagged rather than standing in the top ' +
'feed: the client presents these as hand-picked, so a fallback would be a lie.',
parameters: pageParams(50),
responses: { 200: json(InventionDto.array(), 'The featured inventions') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
return c.json(await getFeaturedInventions(c.env.DB, skip, take))
}
)
// Invention search/browse: published inventions matching `value` (matched against
// name + description; absent → browse everything published), newest first.
// Paginated via skip/take (take defaults to 100). Returns a bare array.
.get('/api/inventions/v2/search', async (c) => {
const value = c.req.query('value') ?? ''
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await searchInventions(c.env.DB, value, skip, take))
})
.get(
'/api/inventions/v2/search',
describeRoute({
tags: ['Inventions'],
summary: 'Search / browse inventions',
description:
'Published inventions matching `value` (matched against name and description), ' +
'newest first. An absent `value` browses everything published — that is the ' +
'browse screens initial request.',
parameters: [
stringQuery('value', 'Search text; absent browses everything'),
...pageParams(100),
],
responses: { 200: json(InventionDto.array(), 'The matching inventions') },
}),
async (c) => {
const value = c.req.query('value') ?? ''
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await searchInventions(c.env.DB, value, skip, take))
}
)
// The signed-in player's saved inventions ("my inventions"), newest first.
// Auth-gated; returns a bare array (empty when the player has saved none).
.get('/api/inventions/v2/mine', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getInventionsByCreator(c.env.DB, id))
})
// The signed-in player's invention shelf ("my inventions"), newest first — the ones
// they created AND the ones they bought (`inventory_invention`, written by the `econ`
// worker's buyInvention). A bought invention stays on the shelf whatever happens to it
// afterwards: unpublished or hidden since, the buyer paid for it.
// Auth-gated; returns a bare array (empty when the player has neither).
.get(
'/api/inventions/v2/mine',
describeRoute({
tags: ['Inventions'],
summary: 'The callers own inventions',
description:
'“My inventions”, newest first — the ones the caller created plus the ones they ' +
'bought. Includes unpublished ones, which nobody else can see, and keeps a bought ' +
'invention listed even if it has since been unpublished or hidden. Not paginated.',
security: AUTHED,
responses: {
200: json(InventionDto.array(), 'The callers inventions'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getMyInventions(c.env.DB, id))
}
)
// Save an invention's metadata. The data file itself is uploaded separately
// through the `storage` worker and referenced here by `inventionDataFilename` —
@@ -321,36 +727,75 @@ export const avatarRoutes = new Hono<App>({ strict: false })
// omitted name/description is defaulted rather than rejected. Auth-gated; returns
// the `{ Status, Invention, InventionVersion }` envelope the client expects (the
// invention carries its assigned inventionId).
.post('/api/inventions/v6/save', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.post(
'/api/inventions/v6/save',
describeRoute({
tags: ['Inventions'],
summary: 'Save a new invention',
description:
'Records an inventions metadata. The data file itself is uploaded separately ' +
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
'one required field, since an invention with no data blob is unusable. An omitted ' +
'name/description is defaulted rather than rejected; a supplied one must be 324 ' +
'characters of letters, digits, spaces, dashes and colons (name) or at most 512 ' +
'characters (description).\n\n' +
'A freshly saved invention is private: it shows up only in the creators own list ' +
'until they call `v3/publish`.',
security: AUTHED,
requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'),
responses: {
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
400: json(
ErrorResponse,
'Unparseable body, no inventionDataFilename, or an invalid name/description'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
if (!inventionDataFilename) {
return c.json({ error: 'inventionDataFilename is required' }, 400)
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
if (!inventionDataFilename) {
return c.json({ error: 'inventionDataFilename is required' }, 400)
}
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
// "No description yet"), so only a supplied one is held to the rules — otherwise
// saving an unnamed invention would fail the 3-character minimum on a name the
// player never typed.
const name = str(body.name)?.trim()
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
const description = str(body.description)
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
creatorPlayerId: id,
inventionDataFilename,
name,
description,
imageName: str(body.imageName),
instantiationCost: num(body.instantiationCost),
lightsCost: num(body.lightsCost),
chipsCost: num(body.chipsCost),
cloudVariablesCost: num(body.cloudVariablesCost),
aiCost: num(body.aiCost),
creationRoomId: num(body.creationRoomId),
referencedInventions: Array.isArray(body.referencedInventions)
? body.referencedInventions.filter((v): v is number => typeof v === 'number')
: undefined,
})
return c.json(toSaveResult(invention))
}
const invention = await createInvention(c.env.DB, {
creatorPlayerId: id,
inventionDataFilename,
name: str(body.name),
description: str(body.description),
imageName: str(body.imageName),
instantiationCost: num(body.instantiationCost),
lightsCost: num(body.lightsCost),
chipsCost: num(body.chipsCost),
cloudVariablesCost: num(body.cloudVariablesCost),
aiCost: num(body.aiCost),
creationRoomId: num(body.creationRoomId),
referencedInventions: Array.isArray(body.referencedInventions)
? body.referencedInventions.filter((v): v is number => typeof v === 'number')
: undefined,
})
return c.json(toSaveResult(invention))
})
)
+120 -37
View File
@@ -1,56 +1,139 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { GAME_VERSION } from '@repo/domain'
import apiConfigV2 from '../../static/api-config-v2.json'
import gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
import {
AmplitudeConfig,
ApiConfigV2,
AzureSpeechConfig,
BacktraceConfig,
json,
JsonObject,
VersionCheck,
} from '../openapi'
import type { App } from '../context'
// ---- Config / version ------------------------------------------------------
export const configRoutes = new Hono<App>({ strict: false })
.get('/api/config/v1/amplitude', (c) =>
c.json({
AmplitudeKey: 'a',
StatSigKey: 'a',
RudderStackKey: 'a',
UseRudderStack: false,
})
.get(
'/api/config/v1/amplitude',
describeRoute({
tags: ['Config'],
summary: 'Analytics keys',
description:
'The Amplitude / StatSig / RudderStack keys the client initialises its analytics ' +
'with. This server collects nothing, so the keys are placeholders and RudderStack ' +
'is off — but the client needs the object to finish loading.',
responses: { 200: json(AmplitudeConfig, 'Placeholder analytics keys') },
}),
(c) =>
c.json({
AmplitudeKey: 'a',
StatSigKey: 'a',
RudderStackKey: 'a',
UseRudderStack: false,
})
)
.get('/api/config/v1/azurespeech', (c) =>
c.json({
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
Region: 'eastus',
Enabled: false,
})
.get(
'/api/config/v1/azurespeech',
describeRoute({
tags: ['Config'],
summary: 'Speech-to-text config',
description:
'Azure Speech credentials for the clients voice transcription. `Enabled` is false ' +
'here, so the key and region are never used.',
responses: { 200: json(AzureSpeechConfig, 'Speech config, disabled') },
}),
(c) =>
c.json({
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
Region: 'eastus',
Enabled: false,
})
)
.get('/api/config/v1/backtrace', (c) =>
c.json({
ReportBudget: 125,
FilterType: 0,
SampleRate: 1,
LogLineCount: 50,
CaptureNativeCrashes: 1,
AMRThresholdMS: 0,
MessageCount: 1000,
MessageRegex:
"^.*$",
VersionRegex: '.*',
})
.get(
'/api/config/v1/backtrace',
describeRoute({
tags: ['Config'],
summary: 'Crash reporter config',
description:
'Budget, sampling and log-capture settings for the clients Backtrace crash ' +
'reporter. Nothing on this server receives the reports.',
responses: { 200: json(BacktraceConfig, 'Crash reporter settings') },
}),
(c) =>
c.json({
ReportBudget: 125,
FilterType: 0,
SampleRate: 1,
LogLineCount: 50,
CaptureNativeCrashes: 1,
AMRThresholdMS: 0,
MessageCount: 1000,
MessageRegex: '^.*$',
VersionRegex: '.*',
})
)
// ShareBaseUrl is derived from the deploy-time base domain; the rest of the
// config is static.
.get('/api/config/v2', (c) =>
c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` })
.get(
'/api/config/v2',
describeRoute({
tags: ['Config'],
summary: 'The main client config blob',
description:
'The large feature-switch / endpoint config the client reads at startup. Served ' +
'from a static asset, except `ShareBaseUrl`, which is templated from the ' +
'deploy-time base domain so share links point at this deployment.',
responses: { 200: json(ApiConfigV2, 'The client config') },
}),
(c) => c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` })
)
.get('/api/versioncheck/v4', (c) =>
c.json({
VersionStatus: 0,
UpdateNotificationStage: 0,
IsVersionIslanded: false,
IsCrossPlayDisabled: false,
})
.get(
'/api/versioncheck/v4',
describeRoute({
tags: ['Config'],
summary: 'Client version check',
description:
'Whether the client build is current. Compares the clients `?v=` build against ' +
'our target `GAME_VERSION`: `VersionStatus` is 0 when they match, 1 when the ' +
'client is on a different build.',
responses: { 200: json(VersionCheck, 'Version status') },
}),
(c) =>
c.json({
VersionStatus: c.req.query('v') === GAME_VERSION ? 0 : 1,
UpdateNotificationStage: 0,
IsVersionIslanded: false,
IsCrossPlayDisabled: false,
})
)
.get(
'/api/gameconfigs/v1/all',
describeRoute({
tags: ['Config'],
summary: 'Per-game configuration',
description: 'An opaque static catalog of per-game settings, served verbatim.',
responses: { 200: json(JsonObject, 'The game config catalog') },
}),
(c) => c.json(gameConfigsV1All)
)
.get('/api/gameconfigs/v1/all', (c) => c.json(gameConfigsV1All))
// Voice chat config. The client fetches it to set up voice.
// No reference shape, so return an empty object until the client needs fields.
.get('/voice/config', (c) => c.json({}))
.get(
'/voice/config',
describeRoute({
tags: ['Config'],
summary: 'Voice chat config',
description:
'Fetched by the client while setting up voice. We have no reference shape for it, ' +
'so it stays an empty object until the client is observed needing a field.',
responses: { 200: json(JsonObject, 'An empty object') },
}),
(c) => c.json({})
)
+413
View File
@@ -0,0 +1,413 @@
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,
getEventById,
getEventsByClubs,
getEventsByCreator,
getEventsByIds,
getLiveEvents,
isEventResponseType,
eventInputRejection,
parseEventBody,
searchEvents,
setEventResponse,
toEventNotification,
toEventResult,
updateEvent,
} from '../events-db'
import { authedId, queryIds, unauthorized } from '../http'
import {
AUTHED,
idParam,
intQuery,
json,
jsonBody,
pageParams,
PlayerEventDto,
PlayerEventRequest,
PlayerEventRespondRequest,
PlayerEventResultDto,
PlayerEventsAll,
PlayerEventsPage,
stringQuery,
TagFilters,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import type { Context } from 'hono'
import type { App } from '../context'
import type { 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): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
event.CreatorPlayerId,
NotificationType.PlayerEventCreated,
{ ...toEventNotification(event) }
)
} catch (err) {
logger.error('failed to push PlayerEventCreated notification', {
playerEventId: event.PlayerEventId,
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 })
.get(
'/api/playerevents/v1/all',
describeRoute({
tags: ['Events'],
summary: 'The callers player events',
description:
'Events the player created and events they have RSVPd to. `Created` is served ' +
'from the event table, soonest first.\n\n' +
'`Responses` is still always empty. RSVPs ARE stored now (see ' +
'`/api/playerevents/v1/respond` and the `event_attendee` table) — what isnt known ' +
'is the shape this field wants: whether an entry is a bare event like `Created`, ' +
'or the event plus the answer, which is the useful thing to render. Serving the ' +
'wrong one renders nothing rather than erroring, so it stays empty until a real ' +
'response is observed.',
security: AUTHED,
responses: {
200: json(PlayerEventsAll, 'The callers created events, and an empty RSVP list'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ Created: await getEventsByCreator(c.env.DB, id), Responses: [] })
}
)
// The tag filter chips on the player-events browse screen. Static: these are the
// categories the client offers when creating an event, so the list doesn't depend on
// what's stored. `TrendingFilters` is null even in the reference — it needs
// recent-activity data we don't keep, and the client renders no trending row for null.
.get(
'/api/playerevents/v1/tagfilters',
describeRoute({
tags: ['Events'],
summary: 'Player-event filter chips',
description:
'The filter chips on the player-events browse screen — the event categories the ' +
'client offers. Static: the same set regardless of what is stored. ' +
'`TrendingFilters` is null even in the reference (it needs recent-activity data), ' +
'and the client renders no trending row for null.',
security: AUTHED,
responses: { 200: json(TagFilters, 'The filter chips'), 401: UNAUTHORIZED_RESPONSE },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
PinnedFilters: [
'workshops',
'celebration',
'game',
'meetup',
'performance',
'coop',
'grandopening',
'class',
'competition',
],
PopularFilters: [
'workshops',
'celebration',
'class',
'coop',
'competition',
'game',
'grandopening',
'meetup',
'performance',
],
TrendingFilters: null,
})
}
)
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
// page. A bare array: the client deserializes this one as a list, and chokes on the
// `{ ContinuationToken, Events }` envelope the single-club form uses.
.get(
'/api/playerevents/v1/clubs',
describeRoute({
tags: ['Events'],
summary: 'Player events across several clubs',
description:
'The events shelf for a set of clubs (`?id=1&id=2`), soonest first. This form ' +
'returns a BARE ARRAY — the client deserializes it as a list and chokes on the ' +
'paged envelope the single-club form below uses. Do not unify the two. No ids ' +
'means an empty shelf, not every event.',
parameters: [intQuery('id', 'Repeatable club id')],
responses: { 200: json(PlayerEventDto.array(), 'The clubs events') },
}),
async (c) => c.json(await getEventsByClubs(c.env.DB, queryIds(c)))
)
// The same feed for a single club (`/club/1`) — the form the reference serves,
// which *does* wrap the events with a paging cursor (empty = no next page).
.get(
'/api/playerevents/v1/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'Player events for one club',
description:
'The same feed for a single club — and this form DOES wrap the events with a ' +
'paging cursor, matching the reference. The cursor is always empty: a clubs event ' +
'list is small enough to serve in one page.',
parameters: [idParam('clubId', 'Club id')],
responses: { 200: json(PlayerEventsPage, 'The clubs events, in a single page') },
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const events = await getEventsByClubs(c.env.DB, [clubId])
return c.json({ ContinuationToken: '', Events: events })
}
)
// Live player-event search (the "happening now" browse query) — events that have
// started and not yet finished. A bare array, like the multi-club feed.
.get(
'/api/playerevents/v1/searchlive',
describeRoute({
tags: ['Events'],
summary: 'Live player events',
description:
'The "happening now" row on the player-events browse screen: events that have ' +
'started and not yet ended, soonest first. A bare array.',
responses: { 200: json(PlayerEventDto.array(), 'The events running right now') },
}),
async (c) => c.json(await getLiveEvents(c.env.DB))
)
// Event search — the browse query. Text is matched term by term against name and
// description; finished events are left out (this backs a browse screen).
.get(
'/api/playerevents/v1/search',
describeRoute({
tags: ['Events'],
summary: 'Search player events',
description:
'The browse query on the player-events screen. `query` is matched ' +
'case-insensitively against the event name and description, term by term; an empty ' +
'query browses everything upcoming. 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 text; every term must match the name or description'),
...pageParams(50),
],
responses: { 200: json(PlayerEventDto.array(), 'The matching events') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
return c.json(await searchEvents(c.env.DB, c.req.query('query') ?? '', skip, take))
}
)
// Bulk fetch (`?id=1&id=2`) — the events behind a list of ids the client already
// holds. Answers in the order asked for; ids with no event are skipped.
.get(
'/api/playerevents/v1/bulk',
describeRoute({
tags: ['Events'],
summary: 'Several player events by id',
description:
'The events behind a list of ids the client already holds (`?id=1&id=2`). Answers ' +
'in the order the ids were asked for — the client renders them in request order — ' +
'and skips ids with no event rather than leaving a hole, so the result may be ' +
'shorter than the request. A bare array.',
parameters: [intQuery('id', 'Repeatable event id')],
responses: { 200: json(PlayerEventDto.array(), 'The events that exist, in request order') },
}),
async (c) => c.json(await getEventsByIds(c.env.DB, queryIds(c)))
)
// RSVP. One row per player per event, so responding again replaces the previous
// answer rather than stacking up. Note this is the v1 path while create/update are
// v2 — that's how the client calls them.
.post(
'/api/playerevents/v1/respond',
describeRoute({
tags: ['Events'],
summary: 'Answer a player event',
description:
'Records how the caller is answering an event — `Type` is 0 Going, 1 Interested, ' +
'2 Cant go. Responding again replaces the previous answer; there is one row per ' +
'player per event, and a decline is recorded rather than deleted so the client can ' +
'show a player what they said.\n\n' +
'Only Going counts toward the events `AttendeeCount`, which is recomputed from ' +
'the RSVP table on every response. Anyone may respond, the creator included — ' +
'they are already Going from create, and nothing stops them declining their own ' +
'event. Answers the same `{ Result, TagModifyResult, PlayerEvent }` envelope the ' +
'v2 writes do, carrying the event with its updated count, so the client can ' +
're-render from the response.\n\n' +
'A body with no usable `PlayerEventId`, or a `Type` outside 02, is a 400; an ' +
'unknown event is a 404.',
security: AUTHED,
requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'),
responses: {
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'),
400: { description: 'Missing `PlayerEventId` or an unknown `Type` (empty body)' },
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req
.json<{ PlayerEventId?: unknown; Type?: unknown }>()
.catch(() => ({}) as { PlayerEventId?: unknown; Type?: unknown })
const eventId = Number(body.PlayerEventId)
const type = Number(body.Type)
// Both are rejected rather than defaulted: an unrecognized answer stored as
// Going would silently inflate the count.
if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400)
const updated = await setEventResponse(c.env.DB, eventId, id, type)
return updated === null ? c.body(null, 404) : c.json(toEventResult(updated))
}
)
// Create. The creator comes from the bearer token, never the body — posting someone
// else's `CreatorPlayerId` doesn't make it theirs.
.post(
'/api/playerevents/v2',
describeRoute({
tags: ['Events'],
summary: 'Create a player event',
description:
'Schedules a new event. The creator is taken from the bearer token, never the ' +
'body; the id is assigned here. Lenient about the rest, like the other writes ' +
'here — a missing name becomes “Untitled Event” and a missing time window becomes ' +
'an hour from now, rather than an error the client cant render.\n\n' +
'`State` starts at 0, and the creator is recorded as Going in the RSVP table — ' +
'which is what makes `AttendeeCount` start at 1, since that count is derived from ' +
'the table. Answers the `{ Result, TagModifyResult, PlayerEvent }` envelope — NOT ' +
'the bare event the read endpoints serve.\n\n' +
'Also pushes a `PlayerEventCreated` (80) hub notification to the creator, carrying ' +
'the event in its camelCase notification projection. A hub failure is logged and ' +
'swallowed — the event is already stored by then.',
security: AUTHED,
requestBody: jsonBody(PlayerEventRequest, 'The event to schedule'),
responses: {
200: json(PlayerEventResultDto, 'The created event'),
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.json<unknown>().catch(() => ({}))
const input = parseEventBody(body)
// The one thing this route isn't lenient about. Everything else here defaults a
// missing or unusable field, but a name or description past the stored length
// can't be defaulted into something sensible — and truncating a player's event
// description silently is worse than refusing it.
if (eventInputRejection(input) !== null) return c.body(null, 400)
const event = await createEvent(c.env.DB, id, input)
await notifyEventCreated(c, event)
return c.json(toEventResult(event))
}
)
// Update. Creator-only, and a partial body only changes what it carries.
.post(
'/api/playerevents/v2/:eventId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'Update a player event',
description:
'Edits an event the caller created. Only the fields the body carries change; ' +
'everything else keeps its stored value, so a partial post cant blank out the ' +
'rest of the event. A posted `null` on `ImageName` / `SubRoomId` / `ClubId` does ' +
'clear it.\n\n' +
'The id, the creator and the attendee count are not editable: ownership doesnt ' +
'transfer and RSVPs arent set by hand. Creator only — anyone else gets 403, and ' +
'an unknown event is 404. Answers the same envelope as create.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: jsonBody(PlayerEventRequest, 'The fields to change'),
responses: {
200: json(PlayerEventResultDto, 'The updated event'),
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const existing = await getEventById(c.env.DB, eventId)
if (existing === null) return c.body(null, 404)
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
const body = await c.req.json<unknown>().catch(() => ({}))
const input = parseEventBody(body)
if (eventInputRejection(input) !== null) return c.body(null, 400)
const updated = await updateEvent(c.env.DB, eventId, input)
// updateEvent only returns null when the row vanished, which the read above rules out.
return c.json(toEventResult(updated!))
}
)
// 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.',
parameters: [idParam('eventId', 'Event id')],
responses: {
200: json(PlayerEventDto, 'The event'),
404: { description: 'No such event (empty body)' },
},
}),
async (c) => {
const event = await getEventById(c.env.DB, Number.parseInt(c.req.param('eventId'), 10))
return event === null ? c.body(null, 404) : c.json(event)
}
)
+138 -34
View File
@@ -1,6 +1,21 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import charadesWords from '../../static/charades.json'
import communityBoard from '../../static/community-board.json'
import {
BareString,
idParam,
IsPureResponse,
json,
JsonArray,
jsonBody,
JsonObject,
KeepsakeConfig,
SanitizeRequest,
stringParam,
SubscriptionResponse,
} from '../openapi'
import type { App } from '../context'
@@ -9,54 +24,143 @@ import type { App } from '../context'
export const gameplayRoutes = new Hono<App>({ strict: false })
// Text sanitization (display names, room names, chat). `v1` echoes the input
// value back; `isPure` reports the text is clean.
.post('/api/sanitize/v1', async (c) => {
const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown })
return c.json(typeof body.Value === 'string' ? body.Value : '')
})
.post('/api/sanitize/v1/isPure', (c) => c.json({ IsPure: true }))
.post(
'/api/sanitize/v1',
describeRoute({
tags: ['Gameplay'],
summary: 'Sanitize a string',
description:
'Runs display names, room names and chat through the profanity filter. There is ' +
'no filter here — the input `Value` is echoed back verbatim as a bare JSON string ' +
'(an empty string if the body has no `Value`).',
requestBody: jsonBody(SanitizeRequest, 'The text to clean'),
responses: { 200: json(BareString, 'The input text, unchanged (a bare JSON string)') },
}),
async (c) => {
const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown })
return c.json(typeof body.Value === 'string' ? body.Value : '')
}
)
.post(
'/api/sanitize/v1/isPure',
describeRoute({
tags: ['Gameplay'],
summary: 'Whether a string is clean',
description: 'The yes/no form of the filter. Always `true` — nothing is filtered here.',
requestBody: jsonBody(SanitizeRequest, 'The text to check'),
responses: { 200: json(IsPureResponse, 'Always pure') },
}),
(c) => c.json({ IsPure: true })
)
// ---- Activities -----------------------------------------------------------
// Word bank for the Charades activity. The client requests the list by
// activity name (`.../words/Charades`); other activities have no data yet.
.get('/api/activities/charades/v1/words/:activity', (c) => c.json(charadesWords))
.get(
'/api/activities/charades/v1/words/:activity',
describeRoute({
tags: ['Gameplay'],
summary: 'An activitys word bank',
description:
'The words the Charades activity draws from. The client asks by activity name ' +
'(`.../words/Charades`); the name is not matched on, so every activity gets the ' +
'charades list — no other activity has data yet.',
parameters: [stringParam('activity', 'Activity name, e.g. `Charades`. Not matched on.')],
responses: { 200: json(JsonArray, 'The word list') },
}),
(c) => c.json(charadesWords)
)
// Keepsakes (room mementos). Stubbed empty.
.get('/api/keepsakes/globalconfig', (c) =>
c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false })
.get(
'/api/keepsakes/globalconfig',
describeRoute({
tags: ['Gameplay'],
summary: 'Keepsake feature switches',
description:
'Whether keepsakes (room mementos) are on and how many a room may hold. The ' +
'feature reports as enabled, but nothing stores keepsakes yet.',
responses: { 200: json(KeepsakeConfig, 'The keepsake config') },
}),
(c) =>
c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false })
)
.get(
'/api/keepsakes/rooms/:roomId',
describeRoute({
tags: ['Gameplay'],
summary: 'A rooms keepsakes',
description:
'No keepsake storage yet. Answers 204 with no body rather than an empty list — ' +
'that is what the reference does, and the client treats a body here as data.',
parameters: [idParam('roomId', 'Room id')],
responses: { 204: { description: 'No keepsakes (empty body)' } },
}),
(c) => c.body(null, 204)
)
.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') },
}),
(c) => c.json([])
)
.get('/api/keepsakes/rooms/:roomId', (c) => c.body(null, 204))
.get('/api/keepsakes/categories', (c) => c.json([]))
// ---- Objectives / events / rewards ---------------------------------------
// Objectives live on the `econ` host (`updateobjective` / `myprogress`), which is
// where the client calls them — they are not served here.
.get('/api/communityboard/v2/current', (c) => c.json({})) // TODO: hydrate from JSON/communityboard.json
.get('/api/playerevents/v1/all', (c) => c.json({ Created: [], Responses: [] }))
// The tag filter chips on the player-events browse screen. Derived from the tags in
// use across events — we store no events, so there are no chips to offer.
// `TrendingFilters` is null even in the reference (it needs recent-activity data).
.get('/api/playerevents/v1/tagfilters', (c) =>
c.json({ PinnedFilters: [], PopularFilters: [], TrendingFilters: null })
.get(
'/api/communityboard/v2/current',
describeRoute({
tags: ['Gameplay'],
summary: 'The current community board',
description:
'The rotating community board on the home screen — featured player, featured room ' +
'group, announcement and image strips. Served verbatim from a static blob.',
responses: { 200: json(JsonObject, 'The community board') },
}),
(c) => c.json(communityBoard)
)
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
// page. A bare array: the client deserializes this one as a list, and chokes on the
// `{ ContinuationToken, Events }` envelope the single-club form uses. No
// player-event storage yet, so the feed is empty.
.get('/api/playerevents/v1/clubs', (c) => c.json([]))
// The same feed for a single club (`/club/1`) — the form the reference serves,
// which *does* wrap the events with a paging cursor (empty = no next page).
.get('/api/playerevents/v1/club/:clubId{[0-9]+}', (c) =>
c.json({ ContinuationToken: '', Events: [] })
)
.get('/api/announcement/v1/get', (c) => c.json([])) // TODO: hydrate from JSON/announcements.json
// Player events live in their own controller (routes/events.ts) — they're D1-backed
// now, unlike the stubs around them here.
.get(
'/api/announcement/v1/get',
describeRoute({
tags: ['Gameplay'],
summary: 'Announcements',
description: 'The announcement banners on the home screen. Not hydrated yet.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
) // TODO: hydrate from JSON/announcements.json
// GameSight attribution/analytics event sink. Accept and ack without persisting.
.post('/api/gamesight/event', (c) => c.body(null, 200))
.post(
'/api/gamesight/event',
describeRoute({
tags: ['Gameplay'],
summary: 'Analytics event sink',
description:
'The clients GameSight attribution/analytics events. Accepted and dropped — ' +
'nothing is persisted. Answers 200 with an empty body.',
responses: { 200: { description: 'Accepted (empty body)' } },
}),
(c) => c.body(null, 200)
)
// ---- Subscription ---------------------------------------------------------
.post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) =>
c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
.post(
'/api/CampusCard/v1/UpdateAndGetSubscription',
describeRoute({
tags: ['Gameplay'],
summary: 'The callers 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 })
)
+372 -148
View File
@@ -1,5 +1,7 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { authedId, unauthorized } from '../http'
import {
createImage,
deleteImage,
@@ -11,8 +13,32 @@ import {
getSlideshowImages,
SavedImageType,
setImageCheer,
SLIDESHOW_LIMIT,
SLIDESHOW_MAX_LIMIT,
toImagesPlayer,
} from '../images-db'
import { authedId, unauthorized } from '../http'
import {
AUTHED,
CheeredEntry,
CheerImageRequest,
DeleteImageRequest,
ErrorResponse,
form,
idParam,
ImagesPlayerDto,
intQuery,
json,
JsonArray,
jsonBody,
pageParams,
SavedImageDto,
SlideshowResponse,
stringQuery,
SuccessResponse,
UNAUTHORIZED_RESPONSE,
UploadImageRequest,
UploadImageResponse,
} from '../openapi'
import type { App } from '../context'
@@ -28,191 +54,389 @@ const typeFolder: Record<number, string> = {
// ---- Images ----------------------------------------------------------------
export const imageRoutes = new Hono<App>({ strict: false })
.get('/api/images/v2/named', (c) => c.json([])) // TODO: hydrate from JSON/namedimages.json
.post('/api/images/v4/uploadsaved', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.get(
'/api/images/v2/named',
describeRoute({
tags: ['Images'],
summary: 'Named images',
description:
'The named-image catalog (UI art the client looks up by name). Not hydrated yet.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
) // TODO: hydrate from JSON/namedimages.json
.post(
'/api/images/v4/uploadsaved',
describeRoute({
tags: ['Images'],
summary: 'Upload a saved image',
description:
'Stores a photo in the shared image bucket under a random key, foldered by image ' +
'type and upload date (e.g. `sharecamera/2026-06-15/…`) so the bucket stays ' +
'browsable. The returned `ImageName` is that key — the `img` worker serves the ' +
'object back by it, slashes and all.\n\n' +
'The `imgMeta` multipart field is a JSON `SavedImageMetaDTO` describing the upload; ' +
'malformed JSON is tolerated and the image is still stored, just untyped. A ' +
'`savedImageType` of 4 (profile thumbnail) additionally becomes the accounts ' +
'avatar, persisted on the account row.',
security: AUTHED,
requestBody: form(UploadImageRequest, 'The image file plus its metadata'),
responses: {
200: json(UploadImageResponse, 'The stored bucket key'),
400: json(ErrorResponse, 'No file in the request'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
// The client posts the file as `image`; accept `file` too for safety.
const candidate = body.image ?? body.file
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
const file = candidate
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
// The client posts the file as `image`; accept `file` too for safety.
const candidate = body.image ?? body.file
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
const file = candidate
// `imgMeta` is a JSON blob describing the upload (the C# `SavedImageMetaDTO`),
// posted as a multipart field. It carries the metadata we record on the image
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
let meta: Record<string, unknown> = {}
if (typeof body.imgMeta === 'string') {
try {
const parsed = JSON.parse(body.imgMeta)
if (parsed && typeof parsed === 'object') meta = parsed as Record<string, unknown>
} catch {
// Malformed imgMeta — treat as an untyped upload (still stored).
// `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`),
// posted as a multipart field. It carries the metadata we record on the image
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
let meta: Record<string, unknown> = {}
if (typeof body.imgMeta === 'string') {
try {
const parsed = JSON.parse(body.imgMeta)
if (parsed && typeof parsed === 'object') meta = parsed as Record<string, unknown>
} catch {
// Malformed imgMeta — treat as an untyped upload (still stored).
}
}
// imgMeta shape: {playerIds, savedImageType, roomId, playerEventId, accessibility}.
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const savedImageType = num(meta.savedImageType) ?? SavedImageType.None
// roomId / playerEventId use 0 or -1 as "none" — store null in that case.
const roomId = num(meta.roomId)
const playerEventId = num(meta.playerEventId)
const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
const dot = file.name.lastIndexOf('.')
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''
const extension = valid.includes(ext) ? ext : '.jpg'
// Store the upload in the shared image bucket under a random key, foldered by
// the image type and then the upload date (e.g. `sharecamera/2026-06-15/`) so
// the bucket stays browsable over time. The `img` worker serves it back by that
// key (slashes and all), which is the returned ImageName.
const typePrefix = (typeFolder[savedImageType] ?? typeFolder[SavedImageType.None]) + '/'
const datePrefix = new Date().toISOString().slice(0, 10) + '/'
const name = typePrefix + datePrefix + crypto.randomUUID() + extension
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type || 'image/jpeg' },
})
// A profile thumbnail becomes the account's avatar — persist it on the
// account row (a JSON blob in the shared accounts table) so it sticks.
if (savedImageType === SavedImageType.ProfileThumbnail) {
await c.env.DB.prepare(
"UPDATE account SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1"
)
.bind(id, name)
.run()
}
// Record the image metadata (the `image` table the img worker owns), pulling
// the fields the client provided in imgMeta.
await createImage(c.env.DB, {
imageName: name,
playerId: id,
type: savedImageType,
accessibility: num(meta.accessibility),
roomId: roomId !== undefined && roomId > 0 ? roomId : null,
description: typeof meta.description === 'string' ? meta.description : null,
taggedPlayerIds: Array.isArray(meta.playerIds)
? meta.playerIds.filter((v): v is number => typeof v === 'number')
: undefined,
playerEventId: playerEventId !== undefined && playerEventId > 0 ? playerEventId : null,
})
return c.json({ ImageName: name })
}
// imgMeta shape: {playerIds, savedImageType, roomId, playerEventId, accessibility}.
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const savedImageType = num(meta.savedImageType) ?? SavedImageType.None
// roomId / playerEventId use 0 or -1 as "none" — store null in that case.
const roomId = num(meta.roomId)
const playerEventId = num(meta.playerEventId)
const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
const dot = file.name.lastIndexOf('.')
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''
const extension = valid.includes(ext) ? ext : '.jpg'
// Store the upload in the shared image bucket under a random key, foldered by
// the image type and then the upload date (e.g. `sharecamera/2026-06-15/`) so
// the bucket stays browsable over time. The `img` worker serves it back by that
// key (slashes and all), which is the returned ImageName.
const typePrefix = (typeFolder[savedImageType] ?? typeFolder[SavedImageType.None]) + '/'
const datePrefix = new Date().toISOString().slice(0, 10) + '/'
const name = typePrefix + datePrefix + crypto.randomUUID() + extension
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type || 'image/jpeg' },
})
// A profile thumbnail becomes the account's avatar — persist it on the
// account row (a JSON blob in the shared accounts table) so it sticks.
if (savedImageType === SavedImageType.ProfileThumbnail) {
await c.env.DB.prepare(
"UPDATE account SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1"
)
.bind(id, name)
.run()
}
// Record the image metadata (the `image` table the img worker owns), pulling
// the fields the client provided in imgMeta.
await createImage(c.env.DB, {
imageName: name,
playerId: id,
type: savedImageType,
accessibility: num(meta.accessibility),
roomId: roomId !== undefined && roomId > 0 ? roomId : null,
description: typeof meta.description === 'string' ? meta.description : null,
taggedPlayerIds: Array.isArray(meta.playerIds)
? meta.playerIds.filter((v): v is number => typeof v === 'number')
: undefined,
playerEventId: playerEventId !== undefined && playerEventId > 0 ? playerEventId : null,
})
return c.json({ ImageName: name })
})
)
// Delete one of the caller's saved images ({ ImageName }). Auth-gated. Looks the
// image up by name, refuses unless the caller took it (PlayerId), then removes the
// metadata row (and its cheers) and the object from R2. 404 for an unknown image,
// 403 for someone else's.
.delete('/api/images/v1/deletesaved', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.delete(
'/api/images/v1/deletesaved',
describeRoute({
tags: ['Images'],
summary: 'Delete one of the callers photos',
description:
'Looks the image up by name and refuses unless the caller took it, then removes ' +
'the metadata row (and its cheers) and the object from the bucket. The metadata ' +
'goes first; the R2 delete is idempotent, so a missing object is fine.',
security: AUTHED,
requestBody: jsonBody(DeleteImageRequest, 'The image to delete'),
responses: {
200: json(SuccessResponse, 'Deleted'),
400: json(ErrorResponse, 'No ImageName given'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers image'),
404: { description: 'No image by that name' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as { ImageName?: unknown } | null
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
const body = (await c.req.json().catch(() => null)) as { ImageName?: unknown } | null
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
const image = await getImageByName(c.env.DB, imageName)
if (!image) return c.notFound()
if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403)
const image = await getImageByName(c.env.DB, imageName)
if (!image) return c.notFound()
if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403)
// Drop the metadata (and cheers) first, then the object. An R2 delete is
// idempotent, so a missing object is fine.
await deleteImage(c.env.DB, image)
await c.env.IMAGES.delete(imageName)
// Drop the metadata (and cheers) first, then the object. An R2 delete is
// idempotent, so a missing object is fine.
await deleteImage(c.env.DB, image)
await c.env.IMAGES.delete(imageName)
return c.json({ success: true })
})
return c.json({ success: true })
}
)
// A room's photo feed — the public images taken in that room. `sort` orders the
// feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType
// (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array.
.get('/api/images/v4/room/:roomId{[0-9]+}', async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
const filter = Number.parseInt(c.req.query('filter') ?? '0', 10) || 0
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take))
})
.get(
'/api/images/v4/room/:roomId{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'A rooms photo feed',
description:
'The public images taken in that room.\n\n' +
'This feed serves the RAW `SavedImage` record — unlike the player photo lists ' +
'below, which must serve the `ImagesPlayer` projection. The inconsistency is real ' +
'and load-bearing: both render correctly as they are, and unifying them breaks one ' +
'of them.',
parameters: [
idParam('roomId', 'Room id'),
intQuery('sort', '1 = most cheered; anything else = newest first'),
intQuery('filter', 'Narrow by SavedImageType; 0 = all'),
...pageParams(100),
],
responses: { 200: json(SavedImageDto.array(), 'The rooms photos') },
}),
async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
const filter = Number.parseInt(c.req.query('filter') ?? '0', 10) || 0
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take))
}
)
// A player's photos — the public images that player has taken, newest first.
// Paginated via skip/take (take defaults to 100). Returns a bare array.
.get('/api/images/v4/player/:playerId{[0-9]+}', async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getImagesByPlayer(c.env.DB, playerId, 0, skip, take))
})
// Paginated via skip/take (take defaults to 100). Returns a bare array of the
// client's ImagesPlayer projection (SavedImageId/SavedImageType, not Id/Type).
.get(
'/api/images/v4/player/:playerId{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'A players photos',
description:
'The public images that player has taken, newest first. Serves the clients ' +
'`ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) ' +
'— the raw `SavedImage` renders blank thumbnails here.',
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
responses: { 200: json(ImagesPlayerDto.array(), 'The players photos') },
}),
async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take)
return c.json(images.map(toImagesPlayer))
}
)
// A player's photos with a sort option. `sort` orders the list (1 = most
// cheered, else newest). Paginated via skip/take (take defaults to 100). Bare array.
.get('/api/images/v5/player/:playerId{[0-9]+}', async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getImagesByPlayer(c.env.DB, playerId, sort, skip, take))
})
.get(
'/api/images/v5/player/:playerId{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'A players photos, sortable',
description: 'v4 plus a `sort` option. Same `ImagesPlayer` projection — see the note on v4.',
parameters: [
idParam('playerId', 'Account id'),
intQuery('sort', '1 = most cheered; anything else = newest first'),
...pageParams(100),
],
responses: { 200: json(ImagesPlayerDto.array(), 'The players photos') },
}),
async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take)
return c.json(images.map(toImagesPlayer))
}
)
// A player's photo feed — the public images they took plus ones they're tagged
// in, newest first. Paginated via skip/take (take defaults to 100). Bare array.
.get('/api/images/v3/feed/player/:playerId{[0-9]+}', async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take))
})
// in, newest first. Paginated via skip/take (take defaults to 100). Bare array of
// the same ImagesPlayer projection the player photo lists use.
.get(
'/api/images/v3/feed/player/:playerId{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'A players photo feed',
description:
'The public images they took PLUS the ones they are tagged in, newest first — the ' +
'photo tab on a profile. Same `ImagesPlayer` projection as the player photo lists.',
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
responses: { 200: json(ImagesPlayerDto.array(), 'The players feed') },
}),
async (c) => {
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
const images = await getPlayerFeed(c.env.DB, playerId, skip, take)
return c.json(images.map(toImagesPlayer))
}
)
// Global slideshow feed — the most recent publicly-listable ShareCamera photos
// (Accessibility 0 or 1, Type 1) across all rooms, newest first, each joined to its
// creator's username and room name. Public (no auth): it only surfaces already-public
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
.get('/api/images/v1/slideshow', async (c) => {
const Images = await getSlideshowImages(c.env.DB)
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
return c.json({ Images, ValidTill })
})
// Serves 10 by default and never more than SLIDESHOW_MAX_LIMIT (100): it's public and
// unauthenticated, so an unclamped `take` would let anyone ask for the whole image
// table — and the callers that rotate one photo at a time (the website's hero) don't
// want a long feed anyway.
.get(
'/api/images/v1/slideshow',
describeRoute({
tags: ['Images'],
summary: 'The global slideshow feed',
description:
'The most recent publicly-listable ShareCamera photos across all rooms, newest ' +
'first, each joined to its creators username and room name.\n\n' +
'Deliberately public — it surfaces only already-public images and backs the ' +
'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' +
'client refreshes against.',
parameters: [
intQuery(
'take',
`How many photos to return (default ${SLIDESHOW_LIMIT}, capped at ${SLIDESHOW_MAX_LIMIT})`
),
],
responses: { 200: json(SlideshowResponse, 'The feed plus its cache hint') },
}),
async (c) => {
// Junk, zero and negative takes fall back to the default rather than 400ing or
// serving an empty stage — the caller is a homepage, and no photos reads as the
// server being down.
const asked = Number.parseInt(c.req.query('take') ?? '', 10)
const take = asked > 0 ? Math.min(asked, SLIDESHOW_MAX_LIMIT) : SLIDESHOW_LIMIT
const Images = await getSlideshowImages(c.env.DB, take)
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
return c.json({ Images, ValidTill })
}
)
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
// there's no metadata row for that name.
.get('/api/images/v6', async (c) => {
const name = c.req.query('name') ?? ''
if (name === '') return c.json({ error: 'name is required' }, 400)
const image = await getImageByName(c.env.DB, name)
return image ? c.json(image) : c.notFound()
})
.get(
'/api/images/v6',
describeRoute({
tags: ['Images'],
summary: 'Image metadata by filename',
description:
'The stored `SavedImage` record for a bucket key. 404s when the object exists but ' +
'has no metadata row.',
parameters: [stringQuery('name', 'The image name (bucket key); required')],
responses: {
200: json(SavedImageDto, 'The image record'),
400: json(ErrorResponse, 'No name given'),
404: { description: 'No metadata for that name' },
},
}),
async (c) => {
const name = c.req.query('name') ?? ''
if (name === '') return c.json({ error: 'name is required' }, 400)
const image = await getImageByName(c.env.DB, name)
return image ? c.json(image) : c.notFound()
}
)
// Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Persists the
// caller's cheer to `image_interaction` and resyncs the image's CheerCount.
.post('/api/images/v1/cheer', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as {
SavedImageId?: number
Cheer?: boolean
} | null
if (body && typeof body.SavedImageId === 'number') {
await setImageCheer(c.env.DB, id, body.SavedImageId, body.Cheer === true)
.post(
'/api/images/v1/cheer',
describeRoute({
tags: ['Images'],
summary: 'Cheer or un-cheer a photo',
description:
'Persists the callers cheer and resyncs the images `CheerCount`. A body naming no ' +
'`SavedImageId` is accepted and ignored — the ack is the same either way.',
security: AUTHED,
requestBody: jsonBody(CheerImageRequest, 'The image and the new cheer state'),
responses: {
200: json(SuccessResponse, 'Recorded'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as {
SavedImageId?: number
Cheer?: boolean
} | null
if (body && typeof body.SavedImageId === 'number') {
await setImageCheer(c.env.DB, id, body.SavedImageId, body.Cheer === true)
}
return c.json({ success: true })
}
return c.json({ success: true })
})
)
// Whether the caller has cheered each of the given saved-image ids (`?id=55&id=54`,
// and each `id` may itself be a comma-separated list). Auth-gated. Returns one
// `{ SavedImageId, IsCheered }` per requested id, in order.
.get('/api/images/v5/cheered/bulk', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const ids =
c.req
.queries('id')
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((imageId) => !Number.isNaN(imageId)) ?? []
const cheered = await getCheeredImageIds(c.env.DB, id, ids)
return c.json(ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) })))
})
.get(
'/api/images/v5/cheered/bulk',
describeRoute({
tags: ['Images'],
summary: 'Which photos the caller has cheered',
description:
'One `{ SavedImageId, IsCheered }` per requested id, in request order — the client ' +
'fills in the cheer buttons on a photo grid from this.',
security: AUTHED,
parameters: [
intQuery('id', 'Repeatable; each value may be a comma-separated list of image ids'),
],
responses: {
200: json(CheeredEntry.array(), 'One entry per requested id, in order'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const ids =
c.req
.queries('id')
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((imageId) => !Number.isNaN(imageId)) ?? []
const cheered = await getCheeredImageIds(c.env.DB, id, ids)
return c.json(
ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) }))
)
}
)
+38 -6
View File
@@ -1,14 +1,46 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { authedId, unauthorized } from '../http'
import { AUTHED, json, JsonArray, UNAUTHORIZED_RESPONSE } from '../openapi'
import type { App } from '../context'
// ---- Inventory -------------------------------------------------------------
// The equipment/consumables the client actually reads are served by the `econ` worker,
// on the econ host. These are the same paths on this host, kept as stubs because some
// client builds probe them here first.
export const inventoryRoutes = new Hono<App>({ strict: false })
.get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
.get('/api/consumables/v2/getUnlocked', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([]) // TODO: query ConsumableItems
})
.get(
'/api/equipment/v2/getUnlocked',
describeRoute({
tags: ['Inventory'],
summary: 'Unlocked equipment',
description:
'A stub on this host — the real inventory lives in the `econ` worker, which serves ' +
'this same path with the players equipment. Always an empty list here, and ' +
'unlike the econ route it does not require a token.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
.get(
'/api/consumables/v2/getUnlocked',
describeRoute({
tags: ['Inventory'],
summary: 'Unlocked consumables',
description:
'A stub on this host — the real consumables live in the `econ` worker. Auth-gated ' +
'even so, then always an empty list.',
security: AUTHED,
responses: {
200: json(JsonArray, 'An empty list'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([]) // TODO: query ConsumableItems
}
)
+239 -15
View File
@@ -1,28 +1,231 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { authedId, authedRoles, unauthorized } from '../http'
import {
AUTHED,
BareBoolean,
CreateReportRequest,
CreateWarningRequest,
DeviceIdRequest,
form,
json,
JsonArray,
ModerationBlockDetails,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import { createReport } from '../reports-db'
import { createWarning } from '../warnings-db'
import type { Context } from 'hono'
import type { App } from '../context'
/**
* Roles allowed to hand down a warning the operator-granted elevated roles the auth
* worker stamps from an account's isModerator/isDeveloper flags (see the admin CLI's
* `grant-moderator` / `grant-developer`). Same set the `notify` / `www` workers gate
* their admin surfaces on: a warning is a moderation action, but staff hold both.
*/
const MODERATOR_ROLES = new Set(['moderator', 'developer'])
/**
* Read one field of a submitted form. The client posts these form-encoded, but the
* same names also arrive as a query string on some builds, so both are accepted.
*/
function formField(
body: Record<string, unknown>,
c: Context<App>,
name: string
): string | undefined {
const raw = body[name]
if (typeof raw === 'string' && raw !== '') return raw
return c.req.query(name) || undefined
}
/** Parse a field as an integer, or null when absent / not a number. */
const asInt = (v: string | undefined): number | null => {
if (v === undefined) return null
const n = Number.parseInt(v, 10)
return Number.isNaN(n) ? null : n
}
/** Parse a field as a float (the reported heights), or null when absent / not a number. */
const asFloat = (v: string | undefined): number | null => {
if (v === undefined) return null
const n = Number.parseFloat(v)
return Number.isNaN(n) ? null : n
}
// ---- Player reporting ------------------------------------------------------
export const moderationRoutes = new Hono<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
// ban storage yet, so this is always the "not blocked" answer. `ReportCategory` is
// -1 (no category) rather than 0, which is a real category; `Message` is null, not
// an empty string — the client distinguishes "no message" from a blank one.
.get('/api/PlayerReporting/v1/moderationBlockDetails', (c) =>
c.json({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
.get(
'/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.',
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
}),
(c) =>
c.json({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
)
.get(
'/api/PlayerReporting/v1/voteToKickReasons',
describeRoute({
tags: ['Moderation'],
summary: 'Vote-to-kick reasons',
description:
'The reasons offered when starting a vote-to-kick. Not hydrated yet, so the list ' +
'is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
) // TODO: hydrate from JSON/vtkreasons.json
.post(
'/api/PlayerReporting/v1/hile',
describeRoute({
tags: ['Moderation'],
summary: 'Report submission sink',
description:
'A player report. Nothing stores reports, so this accepts whatever it is sent and ' +
'answers a bare `false`.',
responses: { 200: json(BareBoolean, 'A bare JSON `false`') },
}),
(c) => c.json(false)
)
// The report the client actually submits. Auth-gated: the reporter is taken from
// the bearer token rather than the body, so a report can't be filed as someone else.
.post(
'/api/PlayerReporting/v3/create',
describeRoute({
tags: ['Moderation'],
summary: 'Submit a player report',
description:
'Records a player report in the `report` table — an append-only log; nothing ' +
'dedupes or acts on the rows yet, and `moderationBlockDetails` still answers ' +
'“not blocked” unconditionally.\n\n' +
'The reporter is the caller (from the bearer token), NOT a body field. Only ' +
'`PlayerIdReported` is required; the client omits whatever it has no value for ' +
'(a report raised outside a room carries no `RoomId`), and those are stored as ' +
'NULL. `ReportCategory` and `RoomInstanceType` are stored verbatim — neither ' +
'enum is mapped here. A `RoomId` of 0 or below means “no room”.\n\n' +
'Answers the real services `{ success, error }` envelope, where `error` is an ' +
'empty string rather than null. The rejected branch uses the same envelope so ' +
'the client only ever parses one shape.',
security: AUTHED,
requestBody: form(CreateReportRequest, 'The report'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No `PlayerIdReported` in the request'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const reportedPlayerId = asInt(formField(body, c, 'PlayerIdReported'))
if (reportedPlayerId === null) {
return c.json({ success: false, error: 'PlayerIdReported is required' }, 400)
}
// 0 / -1 are the client's "no room" values — store null rather than a bogus id.
const roomId = asInt(formField(body, c, 'RoomId'))
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId,
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
details: formField(body, c, 'Details') ?? null,
heightReporter: asFloat(formField(body, c, 'HeightReporter')),
heightReported: asFloat(formField(body, c, 'HeightReported')),
roomId: roomId !== null && roomId > 0 ? roomId : null,
roomInstanceType: formField(body, c, 'RoomInstanceType') ?? null,
})
return c.json({ success: true, error: '' })
}
)
// A warning handed down by a moderator — the staff-side counterpart to a report.
// Gated on the `moderator` role in the token, not just a valid one.
.post(
'/api/playerwarnings',
describeRoute({
tags: ['Moderation'],
summary: 'Issue a player warning',
description:
'Records a moderator-issued warning in the `warning` table — an append-only log ' +
'like `report`; nothing dispatches the warning to the player or acts on the rows ' +
'yet.\n\n' +
'**Staff only.** The token must carry the `moderator` or `developer` role (granted ' +
'per account by the operator, see the admin CLIs `grant-moderator` / ' +
'`grant-developer`); a valid token with neither gets a 403. The acting moderator ' +
'is the caller, NOT a body field.\n\n' +
'Only `WarnedPlayerId` is required; the rest are stored as NULL when absent. ' +
'`ReportCategory` is stored verbatim — the enum is not mapped here. ' +
'`DisplayReason` is what the warned player would be shown; `ModeratorNote` is ' +
'internal and never surfaced to them.\n\n' +
'Answers the same `{ success, error }` envelope as the report write, with `error` ' +
'an empty string rather than null — including on the rejected branches, so there ' +
'is only one shape to parse.',
security: AUTHED,
requestBody: form(CreateWarningRequest, 'The warning'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No `WarnedPlayerId` in the request'),
401: UNAUTHORIZED_RESPONSE,
403: json(SuccessErrorEnvelope, 'A valid token with neither staff role'),
},
}),
async (c) => {
const moderatorId = await authedId(c)
if (moderatorId === null) return unauthorized(c)
const roles = await authedRoles(c)
if (!roles?.some((role) => MODERATOR_ROLES.has(role))) {
return c.json({ success: false, error: 'Forbidden' }, 403)
}
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const warnedPlayerId = asInt(formField(body, c, 'WarnedPlayerId'))
if (warnedPlayerId === null) {
return c.json({ success: false, error: 'WarnedPlayerId is required' }, 400)
}
await createWarning(c.env.DB, {
moderatorPlayerId: moderatorId,
warnedPlayerId,
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
displayReason: formField(body, c, 'DisplayReason') ?? null,
moderatorNote: formField(body, c, 'ModeratorNote') ?? null,
})
return c.json({ success: true, error: '' })
}
)
.get('/api/PlayerReporting/v1/voteToKickReasons', (c) => c.json([])) // TODO: hydrate from JSON/vtkreasons.json
.post('/api/PlayerReporting/v1/hile', (c) => c.json(false))
// The client reporting its device id (form-encoded `oldDeviceId`, `newDeviceId`,
// `platform`), rotating from the id it thinks we hold to the current one. Carries no
@@ -34,4 +237,25 @@ export const moderationRoutes = new Hono<App>({ strict: false })
// https://github.com/djdevin/recnet-plugin we disable the device ID check to enable
// account creation. Nothing in the logs, client just hangs, who knows what it is
// waiting for.
.post('/api/PlayerReporting/v1/deviceId', (c) => c.json([]));
.post(
'/api/PlayerReporting/v1/deviceId',
describeRoute({
tags: ['Moderation'],
summary: 'Device id rotation (known broken)',
description:
'The client reporting its device id, rotating from the one it thinks we hold to ' +
'the current one. It carries no bearer token and fires *before* account creation, ' +
'so there is no caller to attribute the id to and nothing to store it against — ' +
'we accept it and drop it.\n\n' +
'**Known broken.** No response shape found so far keeps the client happy: it ' +
'hangs during account creation with nothing in the logs. The real service answers ' +
'a `{ success, error }` envelope; we currently answer an empty array, which does ' +
'not help either. The workaround is to disable the device-id check client-side ' +
'(see [recnet-plugin](https://github.com/djdevin/recnet-plugin)).',
requestBody: form(DeviceIdRequest, 'The id rotation'),
responses: {
200: json(JsonArray, 'An empty array — see the note above; this is not the real shape'),
},
}),
(c) => c.json([])
)
+146 -26
View File
@@ -1,6 +1,17 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { parseFormIds, queryIds } from '../http'
import {
BulkIdsRequest,
form,
idParam,
intQuery,
json,
JsonArray,
ProgressionDto,
ReputationDto,
} from '../openapi'
import type { App } from '../context'
@@ -27,39 +38,148 @@ function defaultReputation(id: number) {
}
}
/**
* The repeated `id` query param the 2023 client uses on the bulk GET forms each value
* may itself be a comma-separated list, so `?id=1,2&id=3` is three ids.
*/
const BULK_ID_QUERY = [
intQuery('id', 'Repeated once per account id (`?id=1&id=2`); not comma-separated'),
]
/** The `Ids` form body the bulk POST forms take. */
const BULK_ID_BODY = form(BulkIdsRequest, 'The account ids to look up')
// ---- Reputation / progression ----------------------------------------------
export const progressionRoutes = new Hono<App>({ strict: false })
.get('/api/playerReputation/v1/:id', (c) =>
c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
.get(
'/api/playerReputation/v1/:id',
describeRoute({
tags: ['Progression'],
summary: 'A players reputation',
description:
'The cheer counters shown on a players profile. No cheers are stored yet, so ' +
'every player gets the same all-zero record with full cheer credit.',
parameters: [idParam('id', 'Account id')],
responses: { 200: json(ReputationDto, 'The players reputation') },
}),
(c) => c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
)
.get(
'/api/players/v1/progression/:id',
describeRoute({
tags: ['Progression'],
summary: 'A players level and XP',
description: 'Nothing awards XP yet, so everyone is level 1 with 0 XP.',
parameters: [idParam('id', 'Account id')],
responses: { 200: json(ProgressionDto, 'The players progression') },
}),
(c) => {
const id = Number.parseInt(c.req.param('id'), 10)
return c.json({ PlayerId: id, Level: 1, XP: 0 })
}
)
.post(
'/api/playerReputation/v1/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Reputations in bulk (v1)',
description:
'The older bulk form, superseded by v2. It answers an empty list rather than ' +
'synthesizing defaults — the client only uses v2.',
requestBody: BULK_ID_BODY,
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
.get('/api/players/v1/progression/:id', (c) => {
const id = Number.parseInt(c.req.param('id'), 10)
return c.json({ PlayerId: id, Level: 1, XP: 0 })
})
.post('/api/playerReputation/v1/bulk', (c) => c.json([])) // TODO: hydrate from JSON/bulkprogression.json
// Synthesize a default reputation per requested id (the intended behavior;
// the DB-less fallback reads a static JSON file instead).
.post('/api/playerReputation/v2/bulk', async (c) => {
const ids = await parseFormIds(c)
return c.json(ids.map(defaultReputation))
})
.post(
'/api/playerReputation/v2/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Reputations in bulk',
description:
'One default reputation per requested id, in request order. Ids that name no ' +
'account still get a record — the client renders a profile card from it.',
requestBody: BULK_ID_BODY,
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
}),
async (c) => {
const ids = await parseFormIds(c)
return c.json(ids.map(defaultReputation))
}
)
// The 2023 client calls this as a GET with repeated `id` query params.
.get('/api/playerReputation/v2/bulk', (c) => c.json(queryIds(c).map(defaultReputation)))
.post('/api/players/v1/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
.get(
'/api/playerReputation/v2/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Reputations in bulk (GET form)',
description:
'What the 2023 client sends: the same bulk lookup with the ids as repeated query ' +
'params instead of a form body.',
parameters: BULK_ID_QUERY,
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
}),
(c) => c.json(queryIds(c).map(defaultReputation))
)
.post(
'/api/players/v1/progression/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Progressions in bulk (v1)',
description: 'No progression is stored yet, so this is an empty list.',
requestBody: BULK_ID_BODY,
responses: { 200: json(JsonArray, 'An empty list') },
}),
async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
}
)
// v2 is identical to v1 — same form-id parse + PlayerProgressions query.
.post('/api/players/v2/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
.post(
'/api/players/v2/progression/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Progressions in bulk (v2)',
description: 'Identical to v1 — same ids in, same empty list out.',
requestBody: BULK_ID_BODY,
responses: { 200: json(JsonArray, 'An empty list') },
}),
async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
}
)
// The 2023 client calls this as a GET with repeated `id` query params.
// Return a default progression per requested id.
.get('/api/players/v2/progression/bulk', (c) =>
c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
.get(
'/api/players/v2/progression/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Progressions in bulk (GET form)',
description:
'What the 2023 client sends. Unlike the POST forms this one does answer — a ' +
'default level-1 progression per requested id, in request order.',
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 })))
)
.post(
'/api/v1/progression/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Progressions in bulk (unversioned path)',
description:
'An older unversioned path some client builds still call. Same empty answer as ' +
'the versioned POST forms.',
requestBody: BULK_ID_BODY,
responses: { 200: json(JsonArray, 'An empty list') },
}),
async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
}
)
.post('/api/v1/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
+129 -50
View File
@@ -1,42 +1,101 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { getRoomById } from '@repo/domain'
import { authedId } from '../http'
import {
AUTHED,
BareBoolean,
form,
json,
JsonArray,
QuickPlayResponse,
TagFilters,
VerifyRoleRequest,
} from '../openapi'
import type { App } from '../context'
// ---- Room keys / quick play / rooms ----------------------------------------
export const roomRoutes = new Hono<App>({ strict: false })
.get('/api/roomkeys/v1/mine', (c) => c.json([]))
.get('/api/roomkeys/v1/room', (c) => c.json([]))
.get('/api/quickPlay/v1/getandclear', (c) =>
c.json({ RoomName: null, ActionCode: null, TargetPlayerId: null })
.get(
'/api/roomkeys/v1/mine',
describeRoute({
tags: ['Rooms'],
summary: 'The callers room keys',
description: 'Nothing issues room keys yet, so this is an empty list.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
.get(
'/api/roomkeys/v1/room',
describeRoute({
tags: ['Rooms'],
summary: 'A rooms keys',
description: 'Nothing issues room keys yet, so this is an empty list.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
.get(
'/api/quickPlay/v1/getandclear',
describeRoute({
tags: ['Rooms'],
summary: 'Take the pending quick-play action',
description:
'A read-and-clear of whatever quick-play action is queued for the caller (joining ' +
'a friend, an invite deep link). Nothing queues one yet, so all three fields are ' +
'null — which the client reads as “nothing to do”.',
responses: { 200: json(QuickPlayResponse, 'All null — no pending action') },
}),
(c) => c.json({ RoomName: null, ActionCode: null, TargetPlayerId: null })
)
// Room search filters. The client deserializes this into an object (not an
// array) — shape from the 2025 reference.
.get('/api/rooms/v1/filters', (c) =>
c.json({
PinnedFilters: [
'recroomoriginal',
'community',
'featured',
'quest',
'pvp',
'hangout',
'game',
'art',
'store',
'tutorial',
'fandom',
'performance',
'action',
'horror',
],
PopularFilters: ['pvp', 'quest', 'game', 'hangout', 'art'],
TrendingFilters: ['roleplay', 'nomp', 'rp', 'casual', 'fun', 'action', 'military', 'sports'],
})
.get(
'/api/rooms/v1/filters',
describeRoute({
tags: ['Rooms'],
summary: 'Room browse filter chips',
description:
'The filter chips on the room browse screen. Static, taken from the 2025 ' +
'reference. The client deserializes this as an OBJECT, not an array — and unlike ' +
'the invention/event filters, `TrendingFilters` here is a real list.',
responses: { 200: json(TagFilters, 'The filter chips') },
}),
(c) =>
c.json({
PinnedFilters: [
'recroomoriginal',
'community',
'featured',
'quest',
'pvp',
'hangout',
'game',
'art',
'store',
'tutorial',
'fandom',
'performance',
'action',
'horror',
],
PopularFilters: ['pvp', 'quest', 'game', 'hangout', 'art'],
TrendingFilters: [
'roleplay',
'nomp',
'rp',
'casual',
'fun',
'action',
'military',
'sports',
],
})
)
// Verify the caller holds at least `role` in a room. Params come from the form
@@ -44,29 +103,49 @@ export const roomRoutes = new Hono<App>({ strict: false })
// room creator always passes; otherwise the caller needs a Roles entry with
// `Role >= role`. Any failure (no token, unknown room, insufficient role) is
// `false`. The `context` field (e.g. MakerPen) is accepted and ignored.
.post('/api/rooms/v1/verifyRole', async (c) => {
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const param = (name: string): string => {
const form = body[name]
if (typeof form === 'string' && form !== '') return form
return c.req.query(name) ?? ''
.post(
'/api/rooms/v1/verifyRole',
describeRoute({
tags: ['Rooms'],
summary: 'Verify the callers role in a room',
description:
'Whether the caller holds at least `role` in the room — the gate the client checks ' +
'before letting someone into the Maker Pen. The rooms creator always passes; ' +
'anyone else needs a `Roles` entry at that level or higher.\n\n' +
'Answers a bare `true`/`false`, and every failure is `false` rather than an error ' +
'status: no token, an unknown room, and an insufficient role are indistinguishable ' +
'to the client. Params are read from the form body, falling back to the query ' +
'string. Room data is read from the shared rooms database (owned by the `rooms` ' +
'worker).',
security: AUTHED,
requestBody: form(VerifyRoleRequest, 'The room and the role level to check'),
responses: { 200: json(BareBoolean, 'Whether the caller holds the role') },
}),
async (c) => {
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const param = (name: string): string => {
// (named `fromBody` rather than `form` — the openapi helper owns that name here)
const fromBody = body[name]
if (typeof fromBody === 'string' && fromBody !== '') return fromBody
return c.req.query(name) ?? ''
}
const roomId = Number.parseInt(param('roomId'), 10)
const role = Number.parseInt(param('role'), 10)
const accountId = await authedId(c)
if (accountId === null || Number.isNaN(roomId)) return c.json(false)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return c.json(false)
// The creator always passes.
if (room.CreatorAccountId === accountId) return c.json(true)
// Otherwise the caller needs a room role at least as high as requested.
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Record<string, unknown>>) : []
const hasRole = roles.some(
(r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0)
)
return c.json(hasRole)
}
const roomId = Number.parseInt(param('roomId'), 10)
const role = Number.parseInt(param('role'), 10)
const accountId = await authedId(c)
if (accountId === null || Number.isNaN(roomId)) return c.json(false)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return c.json(false)
// The creator always passes.
if (room.CreatorAccountId === accountId) return c.json(true)
// Otherwise the caller needs a room role at least as high as requested.
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Record<string, unknown>>) : []
const hasRole = roles.some(
(r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0)
)
return c.json(hasRole)
})
)
+449 -99
View File
@@ -1,12 +1,33 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { getAccountsByIds } 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,
MutualFriendDto,
RelationshipDto,
SendMessageRequest,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import {
acceptFriendRequest,
addFriend,
getMutualFriendIds,
getRelationshipsForPlayer,
MUTUAL_FRIENDS_LIMIT,
removeFriend,
sendFriendRequest,
setRelationshipFlag,
@@ -14,20 +35,60 @@ import {
import type { Context } from 'hono'
import type { App } from '../context'
import type { RelationshipFlag } from '../relationships-db'
import type {
RelationshipChange,
RelationshipFlag,
RelationshipResponse,
} from '../relationships-db'
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/** NotificationType.RelationshipChanged (see apps/notify/src/notification-types.ts). */
const RELATIONSHIP_CHANGED = 1
/**
* Push a `RelationshipChanged` notification carrying `rel` to one player. Hub failures are
* logged and swallowed the DB write has already committed, so a hub hiccup must not fail
* the request.
*/
async function notifyRelationship(
c: Context<App>,
playerId: number,
rel: RelationshipResponse
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
NotificationType.RelationshipChanged,
{ ...rel }
)
} catch (err) {
logger.error('failed to push RelationshipChanged notification', {
playerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Apply a per-player relationship flag toggle (favorited/ignored/muted) and hand the
* result to the client the way the Go server does: the resulting relationship rides a
* `RelationshipChanged` hub notification to the caller, and the HTTP body is just the
* `{ Success, Message }` ack. Hub failures are logged and swallowed the DB write has
* already committed, so a hub hiccup must not fail the request.
* Notify both players of a friend-graph change, each with the relationship projected from
* their own point of view the target of a request sees `FriendRequestReceived` where the
* sender sees `Sent`, so the two payloads differ. A no-op mutation notifies nobody.
*/
async function notifyBoth(
c: Context<App>,
playerId: number,
otherId: number,
change: RelationshipChange
): Promise<void> {
if (!change.changed) return
await notifyRelationship(c, playerId, change.self)
await notifyRelationship(c, otherId, change.other)
}
/**
* Apply a per-player relationship flag toggle (favorited/ignored/muted). The flags are
* private to the caller's own side of the row, so only the caller is notified. The
* resulting relationship rides the notification and the HTTP body is just the
* `{ Success, Message }` ack.
*/
async function applyFlag(
c: Context<App>,
@@ -37,18 +98,7 @@ async function applyFlag(
value: boolean
): Promise<Response> {
const rel = await setRelationshipFlag(c.env.DB, playerId, otherId, flag, value)
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
RELATIONSHIP_CHANGED,
{ ...rel }
)
} catch (err) {
logger.error('failed to push RelationshipChanged notification', {
playerId,
error: err instanceof Error ? err.message : String(err),
})
}
await notifyRelationship(c, playerId, rel)
return c.json({ Success: true, Message: '' })
}
@@ -78,55 +128,293 @@ async function targetPlayerId(c: Context<App>): Promise<number | null> {
return null
}
/**
* How every relationship mutation names its target. The handler is liberal it also
* accepts `PlayerId`/`playerId`/`Id` from a JSON or form body but the client sends the
* query param, so that's what the spec documents.
*/
const TARGET_PARAMS = [
intQuery('id', 'The other player. The client uses this form.'),
intQuery('playerId', 'Accepted as an alias for `id`'),
]
/**
* A `describeRoute` spec for one of the four friend-graph mutations. These change state
* both players can see, so each also pushes a RelationshipChanged notification to both
* sides; the HTTP body is the caller's own projection.
*/
function friendMutation(summary: string, description: string) {
return describeRoute({
tags: ['Social'],
summary,
description,
security: AUTHED,
parameters: TARGET_PARAMS,
responses: {
200: json(RelationshipDto, 'The relationship, from the callers point of view'),
400: json(ErrorResponse, 'No target id, or the caller targeting themselves'),
401: UNAUTHORIZED_RESPONSE,
},
})
}
/**
* A `describeRoute` spec for a per-side flag toggle (favorite / ignore / mute and their
* inverses). The write lands on the caller's own side of the row, so only the caller is
* notified and the resulting relationship rides that notification, not the response,
* which is just the ack.
*/
function flagToggle(summary: string, description: string) {
return describeRoute({
tags: ['Social'],
summary,
description,
security: AUTHED,
parameters: TARGET_PARAMS,
responses: {
200: json(AckResponse, 'The ack; the relationship arrives over the notification hub'),
400: json(ErrorResponse, 'No target id, or the caller targeting themselves'),
401: UNAUTHORIZED_RESPONSE,
},
})
}
// ---- Social ----------------------------------------------------------------
export const socialRoutes = new Hono<App>({ strict: false })
// The authed player's relationships, projected from their point of view — a bare
// array of RelationshipResponse. Auth-gated.
.get('/api/relationships/v2/get', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getRelationshipsForPlayer(c.env.DB, id))
})
.get(
'/api/relationships/v2/get',
describeRoute({
tags: ['Social'],
summary: 'The callers relationships',
description:
'Every relationship the signed-in player has, projected from their point of view — ' +
'a bare array. `None` rows are included: that is how an unfriending, or an ' +
'ignore/mute of someone you were never friends with, is recorded, and they still ' +
'carry the callers favorited/ignored/muted flags.',
security: AUTHED,
responses: {
200: json(RelationshipDto.array(), 'The callers relationships'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getRelationshipsForPlayer(c.env.DB, id))
}
)
// The friends the caller and another player have in common. Unlike the other
// relationship routes this answers account cards, not relationships — it's what the
// client shows on someone else's profile.
.get(
'/api/relationships/mutualfriends',
describeRoute({
tags: ['Social'],
summary: 'Friends in common with another player',
description:
'The accounts the caller and `id` are both friends with — a bare array, ascending ' +
`by account id and capped at ${MUTUAL_FRIENDS_LIMIT}. Only real friendships count; ` +
'pending requests on either side are ignored.\n\n' +
'Answers an empty array rather than an error for the degenerate cases: no target ' +
'id, an id of 0 or below, or the caller asking for mutuals with themselves. ' +
'Mutual ids with no account row are dropped, so the list can be shorter than the ' +
'intersection.\n\n' +
'Each entry is a trimmed account card. `ProfileImage` is an empty string, never ' +
'null, when the account has no image.',
security: AUTHED,
parameters: [intQuery('id', 'The other player')],
responses: {
200: json(MutualFriendDto.array(), 'The shared friends; empty when there are none'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const raw = c.req.query('id')
const otherId = raw === undefined ? Number.NaN : Number.parseInt(raw, 10)
// Nothing to intersect: no/garbage id, a non-positive one, or the caller
// themselves. An empty list, not an error — this feeds a profile panel.
if (Number.isNaN(otherId) || otherId <= 0 || otherId === id) return c.json([])
const mutualIds = await getMutualFriendIds(c.env.DB, id, otherId)
const accounts = await getAccountsByIds(c.env.DB, mutualIds)
return c.json(
accounts
.map((a) => ({
AccountId: a.accountId,
Username: a.username,
DisplayName: a.displayName,
ProfileImage: a.profileImage ?? '',
}))
// getAccountsByIds doesn't promise an order; keep the ascending one.
.sort((a, b) => a.AccountId - b.AccountId)
)
}
)
// A message from one player to another — the "invite me!" style prompts the client
// sends. Nothing is stored: the message IS the notification, pushed to the
// recipient's hub connection (and queued by the hub if they're offline).
.post(
'/api/messages/v2/send',
describeRoute({
tags: ['Social'],
summary: 'Send a message to another player',
description:
'Pushes a `MessageReceived` notification to `ToPlayerId` carrying the message — ' +
'the same frame the Coach broadcast sends (see the `notify` workers ' +
'`coachMessageAll`), except `FromPlayerId` is the caller rather than the Coach ' +
'account and it goes to one player. The hub queues it when the recipient is ' +
'offline, so it arrives on their next connect.\n\n' +
'Nothing is persisted here — there is no message store, the notification is the ' +
'whole delivery. The sender is the caller (from the bearer token), NOT a body ' +
'field. `Type` is a Message-model type (a different enum from `NotificationType`) ' +
'passed through unmapped, defaulting to 0; `Data` is the payload and is commonly ' +
'empty.\n\n' +
'Answers the same `{ success, error }` envelope as the report / warning writes, ' +
'`error` an empty string on success. A hub failure is reported honestly as a 500 ' +
'with `success: false` — with no store behind it, a swallowed error would be a ' +
'silently dropped message.',
security: AUTHED,
requestBody: form(SendMessageRequest, 'The message'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No `ToPlayerId` in the request'),
401: UNAUTHORIZED_RESPONSE,
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
},
}),
async (c) => {
const fromPlayerId = await authedId(c)
if (fromPlayerId === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const toPlayerId = Number.parseInt(str(body.ToPlayerId) ?? '', 10)
if (Number.isNaN(toPlayerId)) {
return c.json({ success: false, error: 'ToPlayerId is required' }, 400)
}
// The Message the notification carries. Mirrors the coach message's shape with
// a real sender and recipient; `Data` stays a string, empty included (the hub
// drops only null/undefined from the frame).
const message = {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0,
Data: str(body.Data) ?? '',
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toPlayerId,
NotificationType.MessageReceived,
message
)
} catch (err) {
logger.error('failed to push MessageReceived notification', {
toPlayerId,
error: err instanceof Error ? err.message : String(err),
})
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
}
return c.json({ success: true, error: '' })
}
)
// Send a friend request to another player (the target arrives as `?id=`). The
// client calls this as a GET; the mutations accept GET or POST (the Go handlers
// matched any method). Auth-gated. Returns the resulting relationship from the
// caller's point of view.
.on(['GET', 'POST'], '/api/relationships/v2/sendfriendrequest', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return c.json(await sendFriendRequest(c.env.DB, id, target))
})
//
// The four friend-graph mutations below change state both players can see, so each
// notifies BOTH sides with their own projection (see notifyBoth) on top of the HTTP
// response. A no-op — re-sending an outstanding request, accepting nothing pending —
// notifies nobody.
.on(
['GET', 'POST'],
'/api/relationships/v2/sendfriendrequest',
friendMutation(
'Send a friend request',
'Offer friendship to another player. Re-sending an outstanding request is a no-op ' +
'and notifies nobody.'
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
const change = await sendFriendRequest(c.env.DB, id, target)
await notifyBoth(c, id, target, change)
return c.json(change.self)
}
)
// Accept a pending friend request from another player (`?id=`). Auth-gated.
.on(['GET', 'POST'], '/api/relationships/v2/acceptfriendrequest', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return c.json(await acceptFriendRequest(c.env.DB, id, target))
})
.on(
['GET', 'POST'],
'/api/relationships/v2/acceptfriendrequest',
friendMutation(
'Accept a friend request',
'Turn a pending incoming request into a friendship. Accepting nothing pending is a ' +
'no-op and notifies nobody.'
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
const change = await acceptFriendRequest(c.env.DB, id, target)
await notifyBoth(c, id, target, change)
return c.json(change.self)
}
)
// Remove a friend / cancel a request / decline a request (`?id=`). Auth-gated.
.on(['GET', 'POST'], '/api/relationships/v2/removefriend', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
await removeFriend(c.env.DB, id, target)
return c.json({ success: true })
})
// Remove a friend / cancel a request / decline a request (`?id=`). The row is kept as
// a None relationship so the per-side flags survive (see removeFriend). Auth-gated.
.on(
['GET', 'POST'],
'/api/relationships/v2/removefriend',
friendMutation(
'Unfriend, or cancel/decline a request',
'All three are the same operation. The row is kept as a `None` relationship so the ' +
'per-side favorited/ignored/muted flags survive.'
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
const change = await removeFriend(c.env.DB, id, target)
await notifyBoth(c, id, target, change)
return c.json(change.self)
}
)
// Directly add another player as a friend, no pending-request step (`?id=`). Auth-gated.
.on(['GET', 'POST'], '/api/relationships/v2/addfriend', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return c.json(await addFriend(c.env.DB, id, target))
})
.on(
['GET', 'POST'],
'/api/relationships/v2/addfriend',
friendMutation(
'Befriend directly',
'Become friends with no pending-request step. Already being friends is a no-op.'
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
const change = await addFriend(c.env.DB, id, target)
await notifyBoth(c, id, target, change)
return c.json(change.self)
}
)
// Ignore / mute another player, and their inverses unignore / unmute (target
// arrives as `PlayerId` in the POST body). These set a per-player flag on the
@@ -135,54 +423,116 @@ export const socialRoutes = new Hono<App>({ strict: false })
// friended. The un- variants just clear the same flag. Auth-gated. The resulting
// relationship is delivered via a RelationshipChanged hub notification (see
// applyFlag); the HTTP body is just the { Success, Message } ack.
.on(['GET', 'POST'], '/api/relationships/v1/ignore', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'ignored', true)
})
.on(['GET', 'POST'], '/api/relationships/v1/unignore', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'ignored', false)
})
.on(['GET', 'POST'], '/api/relationships/v1/mute', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'muted', true)
})
.on(['GET', 'POST'], '/api/relationships/v1/unmute', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'muted', false)
})
.on(
['GET', 'POST'],
'/api/relationships/v1/ignore',
flagToggle(
'Ignore a player',
'Sets the callers `ignored` flag. Ignoring someone you have no relationship with ' +
'creates a bare (`None`) row to hold the flag.'
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'ignored', true)
}
)
.on(
['GET', 'POST'],
'/api/relationships/v1/unignore',
flagToggle('Stop ignoring a player', 'Clears the callers `ignored` flag.'),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'ignored', false)
}
)
.on(
['GET', 'POST'],
'/api/relationships/v1/mute',
flagToggle(
'Mute a player',
'Sets the callers `muted` flag. Like ignore, this works on a player you have no ' +
'relationship with.'
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'muted', true)
}
)
.on(
['GET', 'POST'],
'/api/relationships/v1/unmute',
flagToggle('Unmute a player', 'Clears the callers `muted` flag.'),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'muted', false)
}
)
// Favorite / unfavorite another player (the client calls these as a GET with the
// target in `?id=`). Same per-side flag mechanics as ignore/mute above: the write
// lands on the *caller's* side of the row, and favoriting someone you have no
// relationship with creates a bare (None) row. Auth-gated. Result rides a
// RelationshipChanged notification; the body is the { Success, Message } ack.
.on(['GET', 'POST'], '/api/relationships/v1/favorite', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'favorited', true)
})
.on(['GET', 'POST'], '/api/relationships/v1/unfavorite', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'favorited', false)
})
.on(
['GET', 'POST'],
'/api/relationships/v1/favorite',
flagToggle(
'Favorite a player',
'Sets the callers `favorited` flag — what pins a player to the top of their friends ' +
'list. Works on a player you have no relationship with.'
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'favorited', true)
}
)
.on(
['GET', 'POST'],
'/api/relationships/v1/unfavorite',
flagToggle('Unfavorite a player', 'Clears the callers `favorited` flag.'),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await targetPlayerId(c)
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
return applyFlag(c, id, target, 'favorited', false)
}
)
.get('/api/messages/v2/get', (c) => c.json([]))
.get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([]))
.get(
'/api/messages/v2/get',
describeRoute({
tags: ['Social'],
summary: 'Direct messages',
description: 'There is no message store yet, so this is always an empty list.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
.get(
'/api/messages/v1/favoriteFriendOnlineStatus',
describeRoute({
tags: ['Social'],
summary: 'Online status of favorited friends',
description:
'Presence for the callers favorited friends. Presence lives in the `match` ' +
'worker and is not joined in here yet, so this is an empty list.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
+1421 -68
View File
@@ -2,13 +2,31 @@ import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import {
GAME_VERSION,
grantInvention,
INVENTORY_INVENTION_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import '../../api.app'
import {
countGoing,
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
getEventAttendees,
getEventResponse,
} from '../../events-db'
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
import { getReportsAgainst, SCHEMA_DDL as REPORTS_SCHEMA_DDL } from '../../reports-db'
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
import type { Env } from '../../context'
import type { PlayerEvent, PlayerEventResult } from '../../events-db'
import type { SavedImage } from '../../images-db'
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
@@ -42,16 +60,13 @@ const TEST_ROOMS = [
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
)`
).run()
const insert = env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r))))
// The rooms worker's schema (room + interaction) — reading a room aggregates its
// cheer/favorite Stats from `interaction`, so both tables have to be here.
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Subrooms live in their own table now; getRoomById hydrates from it, so create it and
// split each seeded room's subrooms into it (mirrors the rooms worker's 0007 migration).
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const r of TEST_ROOMS) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
// Accounts table (matching the auth worker's migration) — uploadsaved records
// profile thumbnails on the account row. Seed the account the test token (sub
@@ -77,6 +92,18 @@ beforeAll(async () => {
// 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()
// 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()
// Warnings table (owned by the api worker) — moderator-issued warnings land here.
for (const stmt of WARNINGS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Player events table (owned by the api worker) — scheduled events live here.
for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
@@ -90,10 +117,13 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub = '42'): Promise<Record<string, string>> {
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
// off, the token carries none, which is what a plain player's looks like to the
// role-gated routes.
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -106,6 +136,12 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}
/** Base64 SHA-256 — the form an invention version's `BlobHash` takes. */
async function base64Sha256(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
}
describe('public endpoints', () => {
test('GET /api/config/v1/amplitude', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
@@ -135,11 +171,16 @@ describe('public endpoints', () => {
expect(body).toMatchObject({ ReportBudget: 125, VersionRegex: '.*' })
})
test('GET /api/versioncheck/v4', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4`)
test('GET /api/versioncheck/v4 reports current for the matching build', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${GAME_VERSION}`)
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
})
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
})
test('GET /api/relationships/v2/get returns empty array for a player with none', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, {
headers: await bearer('99999'),
@@ -179,17 +220,6 @@ describe('public endpoints', () => {
expect(reps.map((r) => r.AccountId)).toEqual([1, 2])
})
test('GET /api/playerevents/v1/tagfilters returns empty filter chips', async () => {
// No player-event storage → no tags in use → no chips. Trending is null.
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/tagfilters`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
PinnedFilters: [],
PopularFilters: [],
TrendingFilters: null,
})
})
test('GET /api/activities/charades/v1/words/Charades returns the word bank', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/activities/charades/v1/words/Charades`)
expect(res.status).toBe(200)
@@ -199,19 +229,6 @@ describe('public endpoints', () => {
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
})
test('GET /api/playerevents/v1/clubs returns an empty event list', async () => {
// The client deserializes this as a bare array — an envelope here fails with
// "expected:'[', actual:'{'". No player-event storage yet → empty.
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/clubs?id=1&id=2`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
// The single-club form does wrap its events with a paging cursor.
const one = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/club/1`)
expect(one.status).toBe(200)
expect(await one.json()).toEqual({ ContinuationToken: '', Events: [] })
})
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
@@ -244,7 +261,7 @@ describe('public endpoints', () => {
}),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
expect(await res.json()).toEqual([])
})
test('POST /api/playerReputation/v2/bulk returns a reputation per id', async () => {
@@ -409,7 +426,7 @@ describe('public endpoints', () => {
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Already .inv', inventionDataFilename: '2026-07-12/x.inv' }),
body: JSON.stringify({ name: 'Already Suffixed', inventionDataFilename: '2026-07-12/x.inv' }),
})
expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe(
'2026-07-12/x.inv'
@@ -441,6 +458,49 @@ describe('public endpoints', () => {
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
})
test('GET /api/inventions/v2/mine lists bought inventions alongside the callers own', async () => {
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
// exactly this row) and also creates one of their own.
const save = async (sub: string, name: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: `${name}.inv` }),
})
expect(res.status).toBe(200)
return ((await res.json()) as InventionSaveResult).Invention
}
const mine = async (sub: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer(sub),
})
expect(res.status).toBe(200)
return (await res.json()) as SavedInvention[]
}
const bought = await save('6100', 'bought-invention')
const own = await save('6101', 'own-invention')
await grantInvention(env.DB, 6101, bought.InventionId)
// Newest first, whichever set it came from: 6101 saved theirs after buying.
const list = await mine('6101')
expect(list.map((i) => i.InventionId)).toEqual([own.InventionId, bought.InventionId])
// A bought invention is still the creator's — it is listed, not re-attributed.
expect(list.find((i) => i.InventionId === bought.InventionId)?.CreatorPlayerId).toBe(6100)
// It is unpublished (a fresh save is), and stays on the buyer's shelf regardless.
expect(list.find((i) => i.InventionId === bought.InventionId)?.IsPublished).toBe(false)
// The seller's own list is unaffected by the sale.
expect((await mine('6100')).map((i) => i.InventionId)).toEqual([bought.InventionId])
// An ownership row pointing at an invention that no longer exists just drops out.
await grantInvention(env.DB, 6101, 999_888)
expect((await mine('6101')).map((i) => i.InventionId)).toEqual([
own.InventionId,
bought.InventionId,
])
})
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -472,6 +532,51 @@ describe('public endpoints', () => {
})
})
test('POST /api/inventions/v6/save enforces the name and description rules', async () => {
const save = async (fields: Record<string, unknown>): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6262')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', ...fields }),
})
// A name is 324 characters of letters, digits, spaces, dashes and colons.
expect((await save({ name: 'ab' })).status).toBe(400)
expect((await save({ name: 'a'.repeat(25) })).status).toBe(400)
expect((await save({ name: 'Rocket!' })).status).toBe(400)
expect((await save({ name: 'Café Lamp' })).status).toBe(400)
const ok = await save({ name: 'Rocket Sofa-Bed 2' })
expect(ok.status).toBe(200)
expect(((await ok.json()) as InventionSaveResult).Invention.Name).toBe('Rocket Sofa-Bed 2')
// The rejection carries the player-facing sentence, not a code.
const short = await save({ name: 'ab' })
expect((await short.json()) as { error: string }).toEqual({
error: 'Invention names must be at least 3 characters.',
})
// A description is prose: any characters, at most 512 of them.
expect((await save({ name: 'Long Winded', description: 'x'.repeat(513) })).status).toBe(400)
expect((await save({ name: 'Long Winded', description: 'x'.repeat(512) })).status).toBe(200)
expect((await save({ name: 'Punctuated', description: 'Yes! Its 100% good.' })).status).toBe(
200
)
})
test('POST /api/inventions/v6/save accepts the clients auto-generated timestamp name', async () => {
// The real client names an unnamed invention after the moment it was saved
// (`071126 13:10:50`, captured from a live save), so the colon is in the allowed name
// charset on purpose. Dropping it from the pattern would 400 every unnamed save the
// game makes — this test is what would catch that.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6363')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', name: '071126 13:10:50' }),
})
expect(res.status).toBe(200)
expect(((await res.json()) as InventionSaveResult).Invention.Name).toBe('071126 13:10:50')
})
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
expect(res.status).toBe(404)
@@ -551,6 +656,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)
@@ -750,6 +888,12 @@ describe('public endpoints', () => {
})
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
// The data file is uploaded (via the storage worker) before the metadata save,
// so the version carries its hash from the start. No sha256 recorded on this
// object — the api worker digests the blob itself in that case.
const data = new Uint8Array([1, 2, 3, 4])
await env.CDN_ASSETS.put('invention/2026-07-12/lamp.inv', data)
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
@@ -761,7 +905,8 @@ describe('public endpoints', () => {
})
const { Invention } = (await save.json()) as InventionSaveResult
// The bare RRInventionVersion — the blob name is what the client downloads.
// The bare RRInventionVersion — the blob name is what the client downloads,
// BlobHash the base64 SHA-256 of what it will download.
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
)
@@ -770,6 +915,7 @@ describe('public endpoints', () => {
InventionId: Invention.InventionId,
VersionNumber: 1,
BlobName: '2026-07-12/lamp.inv',
BlobHash: await base64Sha256(data),
InstantiationCost: 42,
})
@@ -792,6 +938,44 @@ describe('public endpoints', () => {
expect(noId.status).toBe(400)
})
test('BlobHash is null until the blob exists, then backfilled onto the invention', async () => {
// Saved before the upload landed: nothing to hash, so the field stays null
// rather than carrying a hash of something the client can't download.
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('7474')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Late Lamp', inventionDataFilename: '2026-07-12/late.inv' }),
})
const { Invention, InventionVersion } = (await save.json()) as InventionSaveResult
expect(InventionVersion.BlobHash).toBeNull()
const version = async (): Promise<Record<string, unknown>> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
)
return (await res.json()) as Record<string, unknown>
}
expect((await version()).BlobHash).toBeNull()
// Once the blob is there the hash resolves — here from the checksum recorded at
// upload time (what the storage worker puts), not by digesting the body.
const data = new Uint8Array([9, 8, 7])
await env.CDN_ASSETS.put('invention/2026-07-12/late.inv', data, {
sha256: await crypto.subtle.digest('SHA-256', data),
})
const hash = await base64Sha256(data)
expect((await version()).BlobHash).toBe(hash)
// And it's kept, so the other invention endpoints serve it too — without the
// read counting as an edit (ModifiedAt is untouched).
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${Invention.InventionId}`
)
const stored = (await details.json()) as SavedInvention
expect(stored.CurrentVersion.BlobHash).toBe(hash)
expect(stored.ModifiedAt).toBe(Invention.ModifiedAt)
})
test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -833,6 +1017,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)
@@ -944,12 +1138,15 @@ describe('public endpoints', () => {
const ids = async (res: Response): Promise<number[]> =>
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
// Nothing is flagged IsFeatured yet → featured falls back to the top feed.
const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
const beforeFeatured = await ids(
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`)
// Both feeds start EMPTY, for different reasons: nothing is flagged IsFeatured, and
// the only inventions acquired so far in this file are an unpublished one and an id
// with no invention row — neither of which a public feed may show.
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))).toEqual(
[]
)
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))).toEqual(
[]
)
expect(beforeFeatured).toEqual(beforeTop)
const feedInvention = (
id: number,
@@ -987,19 +1184,42 @@ describe('public endpoints', () => {
.run()
}
// Top: engagement-ranked, so the biggest download counts lead.
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
expect(top.slice(0, 3)).toEqual([202, 203, 201])
expect(top).not.toContain(204)
expect(top).not.toContain(205)
// Recent acquisitions, which is what "top today" now counts: 201 picked up by three
// players, 203 by one. 204/205 are acquired too — an unpublished and a hidden
// invention can still be owned — and must not surface in a public feed.
for (const accountId of [7001, 7002, 7003]) await grantInvention(env.DB, accountId, 201)
await grantInvention(env.DB, 7001, 203)
await grantInvention(env.DB, 7001, 204)
await grantInvention(env.DB, 7002, 205)
// 202 was acquired 25 hours ago, just past the trailing 24-hour window, so it is out —
// the feed really does forget, rather than accumulating every acquisition ever.
await env.DB.prepare(
'INSERT INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)'
)
.bind(7004, 202, new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString())
.run()
// Featured: only the flagged, visible inventions — newest first.
// Top: most acquisitions in the window first. Download counts no longer rank anything —
// 202 has the biggest of them and is absent entirely.
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
expect(top).toEqual([201, 203])
// Featured: only the flagged, visible inventions — newest first. 201 is published but
// unflagged, so it stays out however popular it is.
const featured = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))
expect(featured).toEqual([203, 202])
// skip/take paginate the top feed.
// skip/take paginate both feeds.
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
expect(await ids(page)).toEqual([203])
// Pagination happens after the visibility filter, so the hidden/unpublished
// acquisitions don't leave holes in a page.
const firstPage = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?take=1`)
expect(await ids(firstPage)).toEqual([201])
const featuredPage = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/featured?skip=1&take=1`
)
expect(await ids(featuredPage)).toEqual([202])
})
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
@@ -1031,6 +1251,179 @@ 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' })
})
})
describe('player warnings', () => {
const MOD = ['gameClient', 'moderator']
const issue = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/playerwarnings`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
test('POST /api/playerwarnings records the warning', async () => {
const res = await issue(
{
WarnedPlayerId: '205',
ReportCategory: '101',
DisplayReason: 'Sexual gestures',
ModeratorNote: 'dfg',
},
await bearer('42', MOD)
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
const [row] = await getWarningsAgainst(env.DB, 205)
expect(row).toMatchObject({
// The moderator is the token's subject, not a body field.
moderator_player_id: 42,
warned_player_id: 205,
report_category: 101,
display_reason: 'Sexual gestures',
moderator_note: 'dfg',
})
expect(row?.created_at).toBeTruthy()
})
test('POST /api/playerwarnings stores absent fields as null', async () => {
const res = await issue({ WarnedPlayerId: '206' }, await bearer('42', MOD))
expect(res.status).toBe(200)
const [row] = await getWarningsAgainst(env.DB, 206)
expect(row).toMatchObject({
warned_player_id: 206,
report_category: 0,
display_reason: null,
moderator_note: null,
})
})
// Append-only, like reports: warning the same player twice is two rows.
test('POST /api/playerwarnings appends rather than dedupes', async () => {
await issue({ WarnedPlayerId: '207', ModeratorNote: 'first' }, await bearer('42', MOD))
await issue({ WarnedPlayerId: '207', ModeratorNote: 'second' }, await bearer('42', MOD))
const rows = await getWarningsAgainst(env.DB, 207)
expect(rows).toHaveLength(2)
// Newest first.
expect(rows.map((r) => r.moderator_note)).toEqual(['second', 'first'])
})
test('POST /api/playerwarnings 401s without a bearer token', async () => {
const res = await issue({ WarnedPlayerId: '205' })
expect(res.status).toBe(401)
})
// A valid token is not enough — a plain player's carries neither staff role.
// Nothing is written on the rejected branch.
test('POST /api/playerwarnings 403s without a staff role', async () => {
for (const roles of [undefined, ['gameClient']]) {
const res = await issue({ WarnedPlayerId: '208' }, await bearer('42', roles))
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
}
expect(await getWarningsAgainst(env.DB, 208)).toHaveLength(0)
})
// `developer` gets in as well as `moderator` — staff hold both.
test('POST /api/playerwarnings accepts the developer role', async () => {
const res = await issue(
{ WarnedPlayerId: '209' },
await bearer('42', ['gameClient', 'developer'])
)
expect(res.status).toBe(200)
expect(await getWarningsAgainst(env.DB, 209)).toHaveLength(1)
})
test('POST /api/playerwarnings 400s without a warned player', async () => {
const res = await issue({ ModeratorNote: 'dfg' }, await bearer('42', MOD))
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ success: false, error: 'WarnedPlayerId is required' })
})
})
describe('rooms', () => {
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
const verify = async (fields: Record<string, string>, sub?: string): Promise<boolean> => {
@@ -1138,9 +1531,33 @@ describe('images', () => {
})
})
// The feed is public and unauthenticated, so `take` is clamped rather than trusted:
// without the cap a single anonymous request could pull the whole image table through
// the two joins behind it.
test('GET /api/images/v1/slideshow serves 10 by default and caps take at 100', async () => {
// 120 public ShareCamera photos — more than both the default and the cap.
for (let i = 0; i < 120; i++) {
await createImage(env.DB, { imageName: `bulkslide${i}.jpg`, playerId: 42 })
}
const feed = async (query: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow${query}`)
expect(res.status).toBe(200)
return ((await res.json()) as { Images: unknown[] }).Images.length
}
expect(await feed('')).toBe(10)
expect(await feed('?take=25')).toBe(25)
expect(await feed('?take=500')).toBe(100)
// Junk and non-positive takes fall back rather than erroring or emptying the stage.
expect(await feed('?take=0')).toBe(10)
expect(await feed('?take=-5')).toBe(10)
expect(await feed('?take=lots')).toBe(10)
})
test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
// Seed an image to cheer.
const img = await createImage(env.DB, { imageName: 'cheerme.jpg', playerId: 700 })
// Its own player id: 700's photos are asserted on exactly in the player-list test.
const img = await createImage(env.DB, { imageName: 'cheerme.jpg', playerId: 7001 })
const cheerBody = JSON.stringify({ SavedImageId: img.Id, Cheer: true })
// No token → 401.
@@ -1465,23 +1882,47 @@ describe('images', () => {
seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }),
])
// The lists serve the client's ImagesPlayer projection: the id and type are
// SavedImageId/SavedImageType, and TaggedPlayerIds isn't part of it.
type ImagesPlayer = { SavedImageId: number; SavedImageType: number; ImageName: string }
// v4/player → only photos 700 *took*, public, newest first.
const mine = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`)
).json()) as SavedImage[]
expect(mine.map((i) => i.Id)).toEqual([202, 201])
).json()) as ImagesPlayer[]
expect(mine.map((i) => i.SavedImageId)).toEqual([202, 201])
expect(mine[0]).toEqual({
Accessibility: 1,
AccessibilityLocked: false,
CheerCount: 0,
CommentCount: 0,
CreatedAt: '2026-04-01T00:00:00.000Z',
Description: null,
ImageName: 'p202.jpg',
PlayerEventId: null,
PlayerId: 700,
RoomId: null,
SavedImageId: 202,
SavedImageType: 1,
})
// take paginates.
const one = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700?take=1`)
).json()) as SavedImage[]
expect(one.map((i) => i.Id)).toEqual([202])
).json()) as ImagesPlayer[]
expect(one.map((i) => i.SavedImageId)).toEqual([202])
// v5/player is the same list with a sort option (0 = newest first).
const sorted = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v5/player/700?sort=0`)
).json()) as ImagesPlayer[]
expect(sorted.map((i) => i.SavedImageId)).toEqual([202, 201])
// v3/feed/player → photos taken *or* tagged in, newest first (204 is newest).
const feed = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700?take=100`)
).json()) as SavedImage[]
expect(feed.map((i) => i.Id)).toEqual([204, 202, 201])
).json()) as ImagesPlayer[]
expect(feed.map((i) => i.SavedImageId)).toEqual([204, 202, 201])
// A player with no photos → empty array on both.
expect(
@@ -1517,6 +1958,24 @@ describe('relationships', () => {
// the relationship detail rides a RelationshipChanged hub notification instead.
const ACK = { Success: true, Message: '' }
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
type Notification = {
playerId: number
notificationType: number
data: { PlayerID: number; RelationshipType: number; Favorited: number; Ignored: number }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
/** Drop everything the hub stub has recorded so far. */
async function resetNotifications() {
await hub().fetch('http://do/all', { method: 'DELETE' })
}
/** Every notification pushed since the last reset, in order. */
async function sentNotifications(): Promise<Notification[]> {
return (await (await hub().fetch('http://do/all')).json()) as Notification[]
}
// POST a flag mutation the real client way (form body `PlayerId=<id>`), returning
// the parsed ack body.
async function ackFlag(path: string, sub: string, playerId: number) {
@@ -1529,9 +1988,8 @@ describe('relationships', () => {
).json()) as { Success: boolean; Message: string }
}
// A player's own-side flags read straight from the relationship row. The None row a
// flag can create for an otherwise-unrelated pair isn't reported by v2/get, so the
// flag effect is verified here instead of through the (now ack-only) response.
// A player's own-side flags read straight from the relationship row — the flag
// endpoints return only an ack, so the effect is verified against the row itself.
async function ownFlags(playerId: number, otherId: number) {
const row = (await env.DB.prepare(
`SELECT requester_id, requester_favorited, requester_ignored, requester_muted,
@@ -1597,10 +2055,27 @@ describe('relationships', () => {
{ PlayerID: 500, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
])
// 500 removes → neither side has a relationship.
// 500 removes → both sides drop to None. The row is kept (that's where the
// per-side flags live), so v2/get still reports the pair, now as None (0).
expect((await mutate('/api/relationships/v2/removefriend', '500', 501)).status).toBe(200)
expect(await relationships('500')).toEqual([])
expect(await relationships('501')).toEqual([])
expect(await relationships('500')).toEqual([
{ PlayerID: 501, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 },
])
expect(await relationships('501')).toEqual([
{ PlayerID: 500, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 },
])
})
test('removefriend keeps the callers ignore flag', async () => {
// 760 befriends 761 then ignores them; dropping the friendship must not
// un-ignore them (the flag lives on the row the removal downgrades to None).
await mutate('/api/relationships/v2/addfriend', '760', 761)
await ackFlag('/api/relationships/v1/ignore', '760', 761)
await mutate('/api/relationships/v2/removefriend', '760', 761)
expect(await ownFlags(760, 761)).toMatchObject({ Ignored: 1 })
expect(await relationships('760')).toEqual([
{ PlayerID: 761, RelationshipType: 0, Favorited: 0, Ignored: 1, Muted: 0 },
])
})
test('addfriend makes them friends directly', async () => {
@@ -1689,8 +2164,10 @@ describe('relationships', () => {
// Mirrors ignore/mute: a bare None row is created with the caller's side flagged.
expect(await (await mutate('/api/relationships/v1/favorite', '730', 731)).json()).toEqual(ACK)
expect(await ownFlags(730, 731)).toMatchObject({ Favorited: 1 })
// A None row is not reported as a relationship by v2/get.
expect(await relationships('730')).toEqual([])
// The bare None row is reported by v2/get — it carries the flag.
expect(await relationships('730')).toEqual([
{ PlayerID: 731, RelationshipType: 0, Favorited: 1, Ignored: 0, Muted: 0 },
])
})
test('a self-targeted favorite is rejected', async () => {
@@ -1711,4 +2188,880 @@ describe('relationships', () => {
expect(last.notificationType).toBe(1) // NotificationType.RelationshipChanged
expect(last.data).toMatchObject({ PlayerID: 751, Favorited: 1, RelationshipType: 0 })
})
test('sendfriendrequest notifies both players with their own projection', async () => {
await resetNotifications()
await mutate('/api/relationships/v2/sendfriendrequest', '770', 771)
// Both sides hear about it, each seeing the other player and their own side's
// type: the sender Sent (1), the recipient Received (2).
expect(await sentNotifications()).toEqual([
{
playerId: 770,
notificationType: 1,
data: { PlayerID: 771, RelationshipType: 1, Favorited: 0, Ignored: 0, Muted: 0 },
},
{
playerId: 771,
notificationType: 1,
data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 },
},
])
})
test('accepting notifies both players as Friend', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '780', 781)
await resetNotifications()
await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780)
const sent = await sentNotifications()
expect(sent).toHaveLength(2)
// Friend (3) is symmetric, so both sides see the same type, each pointing at the other.
expect(sent).toEqual(
expect.arrayContaining([
expect.objectContaining({
playerId: 780,
data: expect.objectContaining({ PlayerID: 781, RelationshipType: 3 }),
}),
expect.objectContaining({
playerId: 781,
data: expect.objectContaining({ PlayerID: 780, RelationshipType: 3 }),
}),
])
)
})
test('removefriend notifies both players with None', async () => {
await mutate('/api/relationships/v2/addfriend', '790', 791)
await resetNotifications()
await mutate('/api/relationships/v2/removefriend', '790', 791)
const sent = await sentNotifications()
expect(sent).toHaveLength(2)
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([790, 791])
for (const n of sent) expect(n.data.RelationshipType).toBe(0)
})
test('a no-op friend request notifies nobody', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '810', 811)
await resetNotifications()
// Re-sending an already-outstanding request writes nothing, so nothing is pushed.
await mutate('/api/relationships/v2/sendfriendrequest', '810', 811)
expect(await sentNotifications()).toEqual([])
// Likewise accepting something that isn't pending (810 has no request to accept).
await mutate('/api/relationships/v2/acceptfriendrequest', '810', 811)
expect(await sentNotifications()).toEqual([])
})
test('crossing requests notify both players as Friend', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '820', 821)
await resetNotifications()
// 821's request crosses 820's → an immediate friendship, both sides told.
await mutate('/api/relationships/v2/sendfriendrequest', '821', 820)
const sent = await sentNotifications()
expect(sent).toHaveLength(2)
for (const n of sent) expect(n.data.RelationshipType).toBe(3)
})
})
describe('messages', () => {
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
type Sent = {
playerId: number
notificationType: number
data: { FromPlayerId: number; ToPlayerId: number; Type: number; Data: string }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const pushed = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
const send = async (fields: Record<string, string>, headers?: Record<string, string>) => {
await hub().fetch('http://do/all', { method: 'DELETE' })
return exports.default.fetch(`${ORIGIN}/api/messages/v2/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
}
// NotificationType.MessageReceived — the same frame the Coach broadcast uses.
const MESSAGE_RECEIVED = 2
test('POST /api/messages/v2/send pushes MessageReceived to the recipient', async () => {
const res = await send({ ToPlayerId: '2', Type: '10', Data: '' }, await bearer('42'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
expect(await pushed()).toEqual([
{
// Delivered to the recipient, not the sender.
playerId: 2,
notificationType: MESSAGE_RECEIVED,
// FromPlayerId is the token's subject, not a body field.
data: { FromPlayerId: 42, ToPlayerId: 2, Type: 10, Data: '' },
},
])
})
test('POST /api/messages/v2/send defaults Type and Data when omitted', async () => {
const res = await send({ ToPlayerId: '2' }, await bearer('42'))
expect(res.status).toBe(200)
expect((await pushed())[0]?.data).toEqual({
FromPlayerId: 42,
ToPlayerId: 2,
Type: 0,
Data: '',
})
})
test('POST /api/messages/v2/send 400s without a recipient, pushing nothing', async () => {
const res = await send({ Type: '10' }, await bearer('42'))
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ success: false, error: 'ToPlayerId is required' })
expect(await pushed()).toEqual([])
})
test('POST /api/messages/v2/send is auth-gated', async () => {
const res = await send({ ToPlayerId: '2' })
expect(res.status).toBe(401)
expect(await pushed()).toEqual([])
})
})
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. */
const at = (offsetMs: number): string =>
new Date(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/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/searchlive serves what is running right now', async () => {
const res = await get('/api/playerevents/v1/searchlive')
expect(res.status).toBe(200)
const ids = ((await res.json()) as PlayerEvent[]).map((e) => e.PlayerEventId)
expect(ids).toContain(liveEvent.PlayerEventId)
// Started in an hour / finished already — neither is live.
expect(ids).not.toContain(upcoming.PlayerEventId)
expect(ids).not.toContain(pastEvent.PlayerEventId)
})
test('GET /api/playerevents/v1/clubs is a bare array; /club/:id is a paged envelope', async () => {
// The client deserializes the multi-club form as a list — an envelope here fails
// with "expected:'[', actual:'{'". Do not unify the two.
const many = await get('/api/playerevents/v1/clubs?id=7&id=8')
expect(many.status).toBe(200)
const events = (await many.json()) as PlayerEvent[]
expect(events.map((e) => e.PlayerEventId)).toEqual([
liveEvent.PlayerEventId, // started an hour ago — soonest first
clubEvent.PlayerEventId,
])
// The single-club form does wrap its events with a paging cursor.
const one = await get('/api/playerevents/v1/club/7')
expect(one.status).toBe(200)
expect(await one.json()).toEqual({ ContinuationToken: '', Events: events })
// A club with no events, and the no-ids case.
expect(await (await get('/api/playerevents/v1/club/8')).json()).toEqual({
ContinuationToken: '',
Events: [],
})
expect(await (await get('/api/playerevents/v1/clubs')).json()).toEqual([])
})
test('GET /api/playerevents/v1/all lists the callers own events, auth-gated', async () => {
expect((await get('/api/playerevents/v1/all')).status).toBe(401)
const mine = (await (await get('/api/playerevents/v1/all', '42')).json()) as {
Created: PlayerEvent[]
Responses: unknown[]
}
const ids = mine.Created.map((e) => e.PlayerEventId)
expect(ids).toContain(upcoming.PlayerEventId)
// 43 created that one, not 42.
expect(ids).not.toContain(liveEvent.PlayerEventId)
// Finished events stay in the creator's own list — only the browse queries drop them.
expect(ids).toContain(pastEvent.PlayerEventId)
// Nothing records an RSVP yet.
expect(mine.Responses).toEqual([])
const theirs = (await (await get('/api/playerevents/v1/all', '43')).json()) as {
Created: PlayerEvent[]
}
expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId])
})
test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => {
const respond = async (body: unknown, sub = '42'): Promise<Response> =>
post('/api/playerevents/v1/respond', body, sub)
const event = await create({ RoomId: 3, Name: 'RSVP Test', StartTime: at(HOUR) })
const id = event.PlayerEventId
// The creator is Going from create, which is where the initial 1 comes from.
expect(event.AttendeeCount).toBe(1)
expect(await countGoing(env.DB, id)).toBe(1)
// 43 says Going → 2 attendees, and the envelope carries the updated event.
const res = await respond({ PlayerEventId: id, Type: 0 }, '43')
expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
expect(body.PlayerEvent.AttendeeCount).toBe(2)
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({
event_id: id,
player_id: 43,
status: 0,
})
// Changing the answer REPLACES it — one row per player, not a second RSVP.
const changed = await respond({ PlayerEventId: id, Type: 2 }, '43')
expect(((await changed.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({ player_id: 43, status: 2 })
expect((await getEventAttendees(env.DB, id)).map((a) => a.player_id)).toEqual([42, 43])
// Interested is a maybe — recorded, but not counted.
await respond({ PlayerEventId: id, Type: 1 }, '43')
expect(await countGoing(env.DB, id)).toBe(1)
// And the count sticks on the stored event, not just the response.
const fetched = (await (await get(`/api/playerevents/v1/${id}`)).json()) as PlayerEvent
expect(fetched.AttendeeCount).toBe(1)
})
test('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/v2/:eventId edits only what the body carries, creator-only', async () => {
const event = await create({
RoomId: 5,
SubRoomId: 6,
ClubId: 9,
Name: 'Original',
Description: 'Original description',
StartTime: at(5 * HOUR),
EndTime: at(6 * HOUR),
})
const path = `/api/playerevents/v2/${event.PlayerEventId}`
expect(
(await exports.default.fetch(`${ORIGIN}${path}`, { method: 'POST', body: '{}' })).status
).toBe(401)
// 43 didn't create it.
expect((await post(path, { Name: 'Hijacked' }, '43')).status).toBe(403)
expect((await post('/api/playerevents/v2/999999', { Name: 'Nope' })).status).toBe(404)
const res = await post(path, { Name: 'Renamed' })
expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
// Only the name moved; a partial post can't blank out the rest.
expect(body.PlayerEvent).toEqual({ ...event, Name: 'Renamed' })
// And it stuck.
expect(await (await get(`/api/playerevents/v1/${event.PlayerEventId}`)).json()).toEqual(
body.PlayerEvent
)
})
test('POST /api/playerevents/v2/:eventId clears a nullable id when the body sends null', async () => {
const event = await create({ RoomId: 5, SubRoomId: 6, ClubId: 9, Name: 'Clearable' })
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
// Nested form again, and an explicit null — absent leaves the value alone,
// null genuinely clears it.
PlayerEvent: { ClubId: null, ImageName: null },
})
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
expect(updated.ClubId).toBeNull()
expect(updated.ImageName).toBeNull()
expect(updated.SubRoomId).toBe(6)
})
test('POST /api/playerevents/v2/:eventId cannot move ownership or the attendee count', async () => {
const event = await create({ RoomId: 5, Name: 'Fixed' })
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
PlayerEventId: 424242,
CreatorPlayerId: 43,
AttendeeCount: 500,
})
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
expect(updated.PlayerEventId).toBe(event.PlayerEventId)
expect(updated.CreatorPlayerId).toBe(42)
expect(updated.AttendeeCount).toBe(1)
})
})
describe('openapi', () => {
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
// `.on(['GET','POST'], …)` relationship routes contribute both methods.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'DELETE /api/images/v1/deletesaved',
'GET /api/PlayerReporting/v1/moderationBlockDetails',
'GET /api/PlayerReporting/v1/voteToKickReasons',
'GET /api/activities/charades/v1/words/{activity}',
'GET /api/announcement/v1/get',
'GET /api/communityboard/v2/current',
'GET /api/config/v1/amplitude',
'GET /api/config/v1/azurespeech',
'GET /api/config/v1/backtrace',
'GET /api/config/v2',
'GET /api/consumables/v2/getUnlocked',
'GET /api/customAvatarItems/v1/featured',
'GET /api/customAvatarItems/v1/hot',
'GET /api/customAvatarItems/v1/isCreationAllowedForAccount',
'GET /api/customAvatarItems/v1/isCreationEnabled',
'GET /api/customAvatarItems/v1/isRenderingEnabled',
'GET /api/customAvatarItems/v2/fromCreator/{accountId}',
'GET /api/equipment/v2/getUnlocked',
'GET /api/gameconfigs/v1/all',
'GET /api/images/v1/slideshow',
'GET /api/images/v2/named',
'GET /api/images/v3/feed/player/{playerId}',
'GET /api/images/v4/player/{playerId}',
'GET /api/images/v4/room/{roomId}',
'GET /api/images/v5/cheered/bulk',
'GET /api/images/v5/player/{playerId}',
'GET /api/images/v6',
'GET /api/inventions/v1',
'GET /api/inventions/v1/details',
'GET /api/inventions/v1/featured',
'GET /api/inventions/v1/personaldetails/{inventionId}',
'GET /api/inventions/v1/room',
'GET /api/inventions/v1/tagfilters',
'GET /api/inventions/v1/toptoday',
'GET /api/inventions/v1/update',
'GET /api/inventions/v1/version',
'GET /api/inventions/v2/batch',
'GET /api/inventions/v2/mine',
'GET /api/inventions/v2/search',
'GET /api/inventions/v3/publish',
'GET /api/keepsakes/categories',
'GET /api/keepsakes/globalconfig',
'GET /api/keepsakes/rooms/{roomId}',
'GET /api/messages/v1/favoriteFriendOnlineStatus',
'GET /api/messages/v2/get',
'GET /api/playerReputation/v1/{id}',
'GET /api/playerReputation/v2/bulk',
'GET /api/playerevents/v1/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/players/v1/progression/{id}',
'GET /api/players/v2/progression/bulk',
'GET /api/quickPlay/v1/getandclear',
'GET /api/relationships/mutualfriends',
'GET /api/relationships/v1/favorite',
'GET /api/relationships/v1/ignore',
'GET /api/relationships/v1/mute',
'GET /api/relationships/v1/unfavorite',
'GET /api/relationships/v1/unignore',
'GET /api/relationships/v1/unmute',
'GET /api/relationships/v2/acceptfriendrequest',
'GET /api/relationships/v2/addfriend',
'GET /api/relationships/v2/get',
'GET /api/relationships/v2/removefriend',
'GET /api/relationships/v2/sendfriendrequest',
'GET /api/roomkeys/v1/mine',
'GET /api/roomkeys/v1/room',
'GET /api/rooms/v1/filters',
'GET /api/versioncheck/v4',
'GET /voice/config',
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
'POST /api/PlayerReporting/v1/deviceId',
'POST /api/PlayerReporting/v1/hile',
'POST /api/PlayerReporting/v3/create',
'POST /api/avatar/v2/gifts/generate',
'POST /api/gamesight/event',
'POST /api/images/v1/cheer',
'POST /api/images/v4/uploadsaved',
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v6/save',
'POST /api/messages/v2/send',
'POST /api/playerReputation/v1/bulk',
'POST /api/playerReputation/v2/bulk',
'POST /api/playerevents/v1/respond',
'POST /api/playerevents/v2',
'POST /api/playerevents/v2/{eventId}',
'POST /api/players/v1/progression/bulk',
'POST /api/players/v2/progression/bulk',
'POST /api/playerwarnings',
'POST /api/relationships/v1/favorite',
'POST /api/relationships/v1/ignore',
'POST /api/relationships/v1/mute',
'POST /api/relationships/v1/unfavorite',
'POST /api/relationships/v1/unignore',
'POST /api/relationships/v1/unmute',
'POST /api/relationships/v2/acceptfriendrequest',
'POST /api/relationships/v2/addfriend',
'POST /api/relationships/v2/removefriend',
'POST /api/relationships/v2/sendfriendrequest',
'POST /api/rooms/v1/verifyRole',
'POST /api/sanitize/v1',
'POST /api/sanitize/v1/isPure',
'POST /api/v1/progression/bulk',
])
// Every operation carries a summary — an undescribed one renders as a bare path.
for (const [path, ops] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(ops)) {
expect(op.summary, `${method.toUpperCase()} ${path} has no summary`).toBeTruthy()
}
}
})
// Schemas are inlined rather than $ref'd into components: a `.meta({ id })`'d schema
// used in a response emits a $ref this hono-openapi + zod v4 setup does not always
// hoist, leaving a dangling reference that breaks the docs UI.
test('the spec has no $refs', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
const raw = await res.text()
expect(raw.match(/\$ref/g)).toBeNull()
})
// `z.int()` carries the safe-integer range as its bounds, which Scalar would
// otherwise show as the example value for every integer field (-9007199254740991).
// withCleanSpec() supplies a placeholder instead; this guards the wrapper staying
// wired up.
test('integer fields carry a placeholder example', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
const raw = await res.text()
const integers = raw.match(/"type":"integer"/g) ?? []
expect(integers.length).toBeGreaterThan(0)
expect(raw.match(/"example":12345/g)?.length).toBe(integers.length)
})
})
+87
View File
@@ -0,0 +1,87 @@
/**
* Moderator-issued player warnings on the shared `recflare` D1 database.
*
* The counterpart to the `report` table (see reports-db.ts): a report is what a
* player submits, a warning is what a moderator hands down. Same shape of storage
* columnar rather than a JSON blob, append-only, nothing dedupes or acts on the
* rows yet.
*
* The `api` worker owns this schema/migration (migrations/0005_warning.sql,
* applied under its own `migrations_table` so it doesn't clash with the other
* workers' migrations that share the database).
*/
/** Schema DDL (mirror of migrations/0005_warning.sql, sans seed rows). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS warning (
id INTEGER PRIMARY KEY AUTOINCREMENT,
moderator_player_id INTEGER NOT NULL,
warned_player_id INTEGER NOT NULL,
report_category INTEGER NOT NULL DEFAULT 0,
display_reason TEXT,
moderator_note TEXT,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id)`,
]
/** A stored warning row (snake_case columns, one row per warning issued). */
export interface WarningRow {
id: number
/** The moderator who issued it, from their bearer token. */
moderator_player_id: number
warned_player_id: number
report_category: number
/** What the warned player is shown, e.g. `Sexual gestures`. */
display_reason: string | null
/** Internal note — never surfaced to the warned player. */
moderator_note: string | null
created_at: string
}
/**
* A warning as issued everything but the moderator (which comes from the bearer
* token) and the timestamp. Only the warned player is required; the rest are
* optional and stored as NULL when absent.
*/
export interface NewWarning {
moderatorPlayerId: number
warnedPlayerId: number
reportCategory?: number
displayReason?: string | null
moderatorNote?: string | null
}
/** Record an issued warning, returning the stored row (with its assigned id). */
export async function createWarning(db: D1Database, input: NewWarning): Promise<WarningRow> {
const row = await db
.prepare(
`INSERT INTO warning (
moderator_player_id, warned_player_id, report_category,
display_reason, moderator_note, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
RETURNING *`
)
.bind(
input.moderatorPlayerId,
input.warnedPlayerId,
input.reportCategory ?? 0,
input.displayReason ?? null,
input.moderatorNote ?? null,
new Date().toISOString()
)
.first<WarningRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
// from having to handle an impossible null.
return row!
}
/** Every warning issued against a player, newest first. Backs a future moderation view. */
export async function getWarningsAgainst(db: D1Database, playerId: number): Promise<WarningRow[]> {
const { results } = await db
.prepare('SELECT * FROM warning WHERE warned_player_id = ?1 ORDER BY id DESC')
.bind(playerId)
.all<WarningRow>()
return results
}
+32
View File
@@ -0,0 +1,32 @@
{
"FeaturedPlayer": {
"Id": 1,
"TitleOverride": "",
"UrlOverride": null
},
"FeaturedRoomGroup": {
"FeaturedRoomGroupId": 2,
"Name": "Featured Rooms",
"Rooms": [
]
},
"CurrentAnnouncement": {
"Message": "Server powered by RecFlare",
"MoreInfoUrl": "https://recflare.net"
},
"InstagramImages": [
{
"ImageName": "test.jpg",
"ImageUrl": "https://github.com/djdevin/recflare"
}
],
"Videos": [
{
"BlobName": "something.mp4",
"Title": "test",
"Description": "test description",
"ThumbnailBlobName": "something.jpg",
"SourceUrl": "https://www.youtube.com"
}
]
}
+14 -4
View File
@@ -21,17 +21,27 @@ export default defineConfig({
compatibilityDate: '2026-06-16',
compatibilityFlags: ['nodejs_compat'],
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
// notifyPlayer records its last call so tests can assert the notification
// the worker pushed (type + payload); GET the DO to read it back.
// notifyPlayer records every call so tests can assert the notifications the
// worker pushed (type + payload). GET the DO for the most recent one,
// GET /all for the whole list (friend-graph changes notify both players),
// DELETE to reset it between assertions.
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
sent = []
async notifyPlayer(playerId, notificationType, data) {
this.last = { playerId, notificationType, data }
this.sent.push({ playerId, notificationType, data })
return { delivered: 0, queued: true }
}
async broadcast() { return { delivered: 0 } }
async fetch() { return Response.json(this.last ?? null) }
async fetch(request) {
if (request.method === 'DELETE') {
this.sent = []
return new Response(null, { status: 204 })
}
if (new URL(request.url).pathname === '/all') return Response.json(this.sent)
return Response.json(this.sent.at(-1) ?? null)
}
}
export default { fetch() { return new Response('ok') } }
`,
+7 -1
View File
@@ -19,11 +19,17 @@
}
],
// Image bucket shared with the `img` worker (which serves objects back by key).
// Saved-image uploads are written here.
// Saved-image uploads are written here. The `recflare-cdn` bucket (owned by the
// `cdn` worker, written by `storage`) is bound read-only alongside it, to hash an
// invention's uploaded data blob for its `BlobHash`.
"r2_buckets": [
{
"binding": "IMAGES",
"bucket_name": "recflare-img"
},
{
"binding": "CDN_ASSETS",
"bucket_name": "recflare-cdn"
}
],
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
+148 -25
View File
@@ -1,26 +1,150 @@
# auth
Auth Worker served on the `auth` subdomain. A Hono app handling authentication.
Binding-dependent behavior (database queries) is stubbed for now — no real
KV/D1/DO bindings yet.
Auth Worker served on the `auth` subdomain (`auth.recflare.net`) — a Hono app that
authenticates players and issues the JWTs every other worker verifies.
## Routes
| Method | Path | Description |
| ------ | ------------------------------------------ | ---------------------------------- |
| GET | `/eac/challenge` | EAC challenge, served as text |
| GET | `/cachedlogin/forplatformid/:platform/:id` | Cached logins (stubbed → `[]`) |
| POST | `/connect/token` | OAuth token endpoint, issues a JWT |
| GET | `/role/developer/:id` | Developer role lookup (TODO) |
| Method | Path | Description |
| ------ | ------------------------------------------ | ------------------------------------------------------ |
| GET | `/eac/challenge` | EAC handshake; a constant, JSON-quoted, as text |
| GET | `/cachedlogin/forplatformid/:platform/:id` | Accounts linked to a platform id, for the login screen |
| POST | `/cachedlogin/forplatformids` | Bulk cached-login lookup (friends resolution) |
| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token |
| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) |
| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean |
| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean |
| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) |
## API documentation
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
alongside each handler, with the schemas in `src/openapi.ts`.
**The spec is descriptive, not enforced.** Nothing validates requests against it. That
is deliberate: this worker serves a protocol reverse-engineered from the Rec Room
client, and the handlers are intentionally lenient — every field is read as
`typeof body.x === 'string' ? body.x : ''`, and missing or malformed input generally
falls through to a graceful path rather than a 400. Which parts of that tolerance the
client actually depends on isn't fully known, so enforcing a schema would risk
rejecting requests that work today. Read a "required" field as _the client always
sends it_, not _the server rejects it if absent_.
A test asserts that every route the worker serves appears in the spec, so adding a
route without documenting it fails rather than silently shipping an incomplete spec.
## Grants
`POST /connect/token` selects behavior from `grant_type`:
- **`create_account`** — mints an account with an auto-assigned random username and
places the player in the Orientation room (RoomId 13), which the client enters
without matchmaking. A posted `password` becomes the login credential.
- **`cached_login`** — logs into an already-linked account using platform ownership as
the credential; no password. The posted `account_id` must be linked to exactly the
identity `platform_auth` proves.
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
30-day TTL; platform and platform id come from what was stored at issue time.
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
the account by `username` or numeric `account_id` and requires the matching password
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
all, which is what closes id/username-only takeover. When it also carries a verifying
`platform_auth`, that identity is **linked** to the account (see below).
Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `role`
claim, so developer/moderator powers refresh on every login and every refresh grant.
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
### Verifiable platforms: Steam and Meta
Only an identity we can _prove_ is ever bound to an account, so any grant that
authenticates _by platform identity_ (`cached_login`, and `create_account` when it
asserts a platform) must be a platform we can verify. Two are:
- **Steam (`0`)**`src/steam-ticket.ts` parses the `platform_auth` ticket and checks
Steam's signature against Steam's system public key. Verified **offline**: no
publisher Web API key, no network call. The SteamID64 the ticket carries replaces the
client-supplied `platform_id`.
- **Meta / Oculus (`1`)**`src/meta-nonce.ts` posts the nonce in `platform_auth` to
`graph.oculus.com/user_nonce_validate`, authenticated as the app with
`META_APP_SECRET`. Meta's nonce proves nothing by itself; validation is what binds it
to a user id, so here the posted `platform_id` is an _input_ to the check and a
spoofed one fails. This means an outbound request on every Meta login, and no Meta
login at all without the app secret — an unset `META_APP_SECRET` answers 500 rather
than falling back to trusting the client.
Everything else is refused. Whichever platform, the identity that gets bound or linked
is the verified one, never the raw `platform_id` field.
### One account, many platform identities
An account can be reached from several platform identities — a player's PC and their
headset both open the same account, with no password after the first time. The links
live in the `platform_account` table (`src/platform-db.ts`, migration 0007), one row per
(platform, platform id, account).
That table is the **one source of truth** for both halves of a cached login: the picker
(`/cachedlogin/forplatformid`) lists the accounts an identity links to, and the
`cached_login` grant asks it whether the account it was handed is linked to the identity
just proven. They used to be two separate checks over the account blob's single
`platformId`, which could disagree — the client would be offered an account that then
answered "no linked account" forever.
A second device is linked by **logging in with a password there**: the client posts its
`platform_auth` alongside the password, and a proof that verifies becomes a link. Only a
verified identity is ever linked, since a link is a password-free way into the account.
A proof that doesn't verify never fails the login — it just leaves that device without a
cached login.
The account blob keeps `platform`/`platformId` as the account's **primary** identity
(the first one linked). It feeds the account DTO and a refreshed token's claims, and
nothing authorizes off it. It is no longer indexed: migration 0008 drops the
`account.platform_id` generated column that 0004 added, since leaving a queryable copy
of one identity per account invites exactly the picker/grant disagreement above. Look
identities up in `platform_account`.
## Signup caps
`create_account` is capped on two independent arms, per verified platform identity and
per signup IP. The platform arm can't be spoofed or reset by changing networks; the IP
arm is coarse and will produce false positives behind NAT, shared campus and mobile
networks. Both default to 3.
The platform arm also caps **linking**, or it wouldn't be a cap: an identity at the
limit could otherwise have accounts created for it with a password and link its way into
all of them. Hitting it never fails a password login — the account just doesn't get a
cached login on that device.
Override per environment via the root `.env` (`RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID`,
`RECFLARE_MAX_ACCOUNTS_PER_IP`), injected at deploy time so tuning them never means
editing a versioned file. Setting an arm to `0` disables it — worth reaching for on a
small private server, or when a shared network is being locked out.
## Bindings
| Binding | Type | Notes |
| -------------------- | ------------- | ----------------------------------------------------------------------------------------------- |
| `DB` | D1 | Shared `recflare` database; this worker owns `account`, `refresh_tokens` and `platform_account` |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
| `META_APP_SECRET` | Secrets Store | Meta app secret; only used to validate a login nonce |
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
table, so they stay independent of the `rooms` worker's migrations on the same
database. Run them with `pnpm -F auth migrate`.
## Signing key
Tokens are signed HS256 with the `JWT_SECRET` binding (see `src/jwt.ts`), resolved
at request time via `await c.env.JWT_SECRET.get()`. The key lives in a single shared
**Cloudflare Secrets Store** that every worker binds (so `auth`-signed tokens verify
in `rooms`, `api`, `match`, etc.). The store id is kept out of source in the root
`.env` as `RECFLARE_SECRETS_STORE` and spliced into `wrangler.jsonc`'s `"local"`
`store_id` placeholder at deploy time (see `packages/tools/bin/run-wrangler-deploy`).
Tokens are signed HS256 with the `JWT_SECRET` binding (see `@repo/jwt`), resolved at
request time via `await c.env.JWT_SECRET.get()`. The key lives in a single shared
**Cloudflare Secrets Store** that every worker binds, so `auth`-signed tokens verify in
`rooms`, `api`, `match`, etc. The store id is kept out of source in the root `.env` as
`RECFLARE_SECRETS_STORE` and spliced into `wrangler.jsonc`'s `"local"` `store_id`
placeholder at deploy time (see `packages/tools/bin/run-wrangler-deploy`).
If the secret resolves empty, the worker refuses to issue a token at all rather than
sign one with an empty key — every worker validates against that same key, so an
empty-key token would be forgeable by anyone.
One-time setup (needs Cloudflare auth):
@@ -30,20 +154,19 @@ wrangler secrets-store store create recflare --scopes workers
# Set the shared signing key (prompted for the value)
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
# Set the Meta app secret. Required for the deploy to succeed even with no Meta app —
# a binding to a missing secret is a deploy error. Any placeholder will do; Meta
# sign-ins then answer 500 until it holds the real value.
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
```
For local `wrangler dev`, seed a local value (omit `--remote`) so `.get()` resolves:
For local `wrangler dev`, seed local values (omit `--remote`) so `.get()` resolves:
```sh
wrangler secrets-store secret create local --name JWT_SECRET --value <dev-key> --scopes workers
wrangler secrets-store secret create local --name META_APP_SECRET --value <app-secret> --scopes workers
```
Rotating the store value invalidates all existing tokens (clients re-authenticate).
## Notes / TODO
- `/eac/challenge` content is inlined in `src/auth.app.ts` (Workers have no
filesystem) — replace `EAC_CHALLENGE` with the real challenge text.
- `/cachedlogin/...` and the `RoomInstance` cleanup in `/connect/token` need a DB
binding to be implemented.
- `/role/developer/:id` is a stub (`// TODO: implement`).
Rotating the signing key invalidates all existing tokens (clients re-authenticate).
The Meta secret is read per request, so updating it takes effect without a redeploy.
@@ -0,0 +1,8 @@
-- Drop the platform identity from refresh_tokens. A refreshed access token now takes
-- `platform`/`platform_id` from the account, which is where the bound identity lives —
-- the copy stored at issue time was redundant, and went stale if the account's
-- identity changed mid-session. Kept in sync with REFRESH_SCHEMA_DDL in
-- src/refresh-db.ts.
ALTER TABLE refresh_tokens DROP COLUMN platform;
ALTER TABLE refresh_tokens DROP COLUMN platform_id;
@@ -0,0 +1,35 @@
-- Let one account be linked to MORE THAN ONE platform identity, so a player with a
-- PC and a headset gets a cached login on both. The account blob's single
-- `platformId`/`platform` pair could only hold one, so logging in on the second
-- device meant a password every time.
--
-- Links move into their own table, which becomes the one source of truth for both
-- halves of a cached login (the picker and the `cached_login` grant). The blob fields
-- stay as the account's *primary* identity — the first one linked — for the account
-- DTO and the refresh grant's claims; nothing authorizes off them any more. Kept in
-- sync with PLATFORM_SCHEMA_DDL in src/platform-db.ts.
CREATE TABLE IF NOT EXISTS platform_account (
account_id INTEGER NOT NULL,
platform INTEGER NOT NULL,
platform_id TEXT NOT NULL,
linked_at TEXT NOT NULL,
PRIMARY KEY (platform, platform_id, account_id)
);
CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id);
CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id);
-- Backfill every identity already bound to an account. `platform` is COALESCEd to 0
-- because nothing ever defaulted that field: an account can carry a platformId with no
-- platform recorded, and back when Steam was the only verifiable platform an unset one
-- *was* Steam. Without the COALESCE those accounts would lose their cached login at
-- deploy. Mirrored as PLATFORM_BACKFILL_SQL in src/platform-db.ts, which is what the
-- tests run.
INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
SELECT
account_id,
COALESCE(json_extract(data, '$.platform'), 0),
platform_id,
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
FROM account
WHERE platform_id IS NOT NULL AND platform_id <> '';
@@ -0,0 +1,23 @@
-- Drop the `platform_id` generated column added by 0004. Nothing reads it any more:
-- 0007 moved every account ↔ identity link into `platform_account`, which is now the
-- one source of truth for the login picker and the `cached_login` grant. The column's
-- last reader was 0007's own backfill, which has already run.
--
-- Leaving it would leave a SECOND, stale answer to "which account does this identity
-- open?" — it only ever holds the account's primary identity, so an account reachable
-- from a PC and a headset appears here under one of them. That is exactly the split
-- that used to have the picker offer an account the grant then refused.
--
-- The underlying `platformId` in the JSON blob STAYS: it is the account's primary
-- identity, and feeds the account DTO and a refreshed token's claims. This drops the
-- generated column and its index only — a virtual column stores nothing, so no account
-- data is rewritten or lost. The index has to go first; SQLite refuses to drop an
-- indexed column. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts.
--
-- Safe to run before or after the deploy that ships it: no worker queries this column,
-- so the currently-deployed code doesn't notice it go. (`PLATFORM_BACKFILL_SQL` in
-- src/platform-db.ts still names it in 0007's text — that statement has run and won't
-- run again; the exported copy selects the blob instead so tests keep working.)
DROP INDEX IF EXISTS idx_accounts_platform_id;
ALTER TABLE account DROP COLUMN platform_id;
+6 -1
View File
@@ -19,8 +19,13 @@
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+799 -342
View File
@@ -1,49 +1,86 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
countAccountsByPlatformId,
countAccountsBySignupIp,
createAccount,
GAME_VERSION,
getAccount,
getAccountByUsername,
getAccountsByPlatformId,
getAccountsByIds,
getPasswordHash,
getRoomById,
hashPassword,
RoomInstanceType,
setLastLoginTime,
setLoginContext,
setPasswordHash,
setPresence,
subRoomDataBlob,
updateAccount,
verifyPassword,
} from '@repo/domain'
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { intVar, logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
import { verifyMetaNonce } from './meta-nonce'
import {
CachedLogin,
ChangePasswordRequest,
ChangePasswordResponse,
FakeCachedLogin,
form,
json,
OAuthError,
PlatformIdsRequest,
PlatformType,
roleLookup,
TokenRequest,
TokenResponse,
} from './openapi'
import {
countAccountsForPlatformIdentity,
getLinksForPlatformId,
getLinksForPlatformIdentity,
isPlatformIdentityLinked,
linkPlatformIdentity,
} from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
import type { App } from './context'
import type { PlatformLink } from './platform-db'
/** OAuth scopes granted by `/connect/token`. */
const TOKEN_SCOPE =
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
/** Platform-type enum names by value, used for the token's `platform` claim. */
const PLATFORM_TYPES: Record<number, string> = {
[-1]: 'All',
0: 'Steam',
1: 'Oculus',
2: 'PlayStation',
3: 'Xbox',
4: 'RecNet',
5: 'IOS',
6: 'GooglePlay',
7: 'Standalone',
8: 'Pico',
}
/**
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
* build has no Meta SDK to ask, so it has nothing real to report and every sideloaded
* headset reports this same value. Two things follow, and both are enforced below:
* - it is never verifiable (`verifyPlatformProof` refuses it outright), and
* - it is therefore never LINKED to an account. A link is a password-free way in, so
* one link on a shared id would open that account to every sideloaded build.
* It exists only to get such a client onto the username/password login screen.
*/
const SIDELOAD_PLATFORM_ID = '1'
/**
* The canned entry served for the one Oculus cached-login lookup below the sideloaded
* APK's way onto the password login screen. Not backed by a link, an account or a
* platform proof, hence `requirePassword: true`.
*/
const FAKE_OCULUS_CACHED_LOGIN = {
platform: PlatformType.Oculus,
platformId: SIDELOAD_PLATFORM_ID,
accountId: 1,
lastLoginTime: '2026-07-19T17:13:29.225Z',
requirePassword: true,
} as const
/**
* Signup caps, enforced on create_account only (never on login an existing account
@@ -90,12 +127,11 @@ async function placeNewPlayerInOrientation(
accountId: number,
deviceClass: number
): Promise<void> {
const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
.bind(ORIENTATION_ROOM_ID)
.first<{ data: string }>()
if (!row) return
// getRoomById hydrates the room's SubRooms from the subroom table (they no longer
// live in the room blob), so the Orientation scene resolves the same way match does.
const room = await getRoomById(env.DB, ORIENTATION_ROOM_ID)
if (!room) return
const room = JSON.parse(row.data) as Record<string, unknown>
const subRooms = room.SubRooms
const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as
Record<string, unknown> | undefined
@@ -108,7 +144,7 @@ async function placeNewPlayerInOrientation(
subRoomId: num(sub?.SubRoomId, 1),
roomInstanceType: RoomInstanceType.Public,
location: str(sub?.UnitySceneId),
dataBlob: str(sub?.DataBlob),
dataBlob: subRoomDataBlob(sub),
eventId: 0,
clubId: 0,
roomCode: '',
@@ -129,7 +165,7 @@ async function placeNewPlayerInOrientation(
deviceClass,
vrMovementMode: 1,
platform: 0,
appVersion: '20230302',
appVersion: GAME_VERSION,
})
}
@@ -154,48 +190,169 @@ function accountRoles(account: Pick<Account, 'isDeveloper' | 'isModerator'> | nu
/**
* The platform an account's `platformId` belongs to. Nothing defaults the `platform`
* field (see defaultAccount), so an account can carry a platform identity with no
* platform recorded and Steam is the only platform whose identity we can prove, so
* an unset one *is* Steam.
* platform recorded and until Meta verification landed Steam was the only identity
* we could prove, so an unset one *is* Steam. Every account bound since records its
* platform explicitly; this default only covers those older rows.
*/
function accountPlatform(account: Pick<Account, 'platform'>): number {
return account.platform ?? 0
}
/**
* Whether an account is the one linked to a given platform identity the single
* check behind both the cached-login picker and the `cached_login` grant. It lives in
* one place on purpose: if the picker offers an account the grant then rejects, the
* client is handed an `account_id` it can never log into ("no linked account for this
* platform identity" on every attempt).
*
* `platformId` must be the *proven* identity (the SteamID64 from a verified
* platform_auth ticket), never the client-supplied `platform_id` field.
*/
export function isLinkedToPlatformIdentity(
account: Pick<Account, 'platform' | 'platformId'>,
platform: number,
platformId: string
): boolean {
if (!account.platformId || platformId === '') return false
return account.platformId === platformId && accountPlatform(account) === platform
}
/**
* Project a linked account into the client's CachedLogin DTO the account-picker
* entry on the login screen. The client posts the chosen `accountId` back as a
* `grant_type=cached_login`. `requirePassword` is false because platform ownership
* (the platform_auth ticket) is the credential for a cached login no prompt.
* (the verified `platform_auth`) is the credential for a cached login no prompt.
*
* The platform and id come from the LINK, not from the account: an account linked to
* both a Steam and a Meta identity appears in both pickers, and each has to report the
* identity that picker was asked about that's what the client posts back, and what
* the grant then checks the link against.
*/
function toCachedLogin(account: Account) {
function toCachedLogin(account: Account, link: PlatformLink) {
return {
platform: accountPlatform(account),
platformId: account.platformId ?? '',
platform: link.platform,
platformId: link.platformId,
accountId: account.accountId,
lastLoginTime: account.lastLoginTime ?? account.createdAt,
requirePassword: false,
}
}
/**
* Project a set of links into picker entries, dropping any whose account no longer
* exists. One batched account read rather than one per link.
*
* Order follows the links (oldest first), so the picker is stable between launches.
*/
async function toCachedLogins(db: D1Database, links: PlatformLink[]) {
if (links.length === 0) return []
const accounts = await getAccountsByIds(db, [...new Set(links.map((l) => l.accountId))])
const byId = new Map(accounts.map((a) => [a.accountId, a]))
return links.flatMap((link) => {
const account = byId.get(link.accountId)
return account ? [toCachedLogin(account, link)] : []
})
}
/**
* Link the platform identity a password login proved to the account it logged into,
* so the next launch on that device is a cached login. Called only with a VERIFIED
* identity a link is a password-free way into the account.
*
* Already linked is the common case (every subsequent login on that device) and costs
* one read and nothing else.
*
* The per-identity cap applies here as well as at signup, or it wouldn't be a cap:
* an identity could otherwise sit at the limit, have accounts created for it with a
* password, and link its way into all of them. Reaching it does NOT fail the login
* the password was valid it just leaves the account without a cached login, so the
* player types their password each time rather than being locked out.
*
* The first identity linked also becomes the account's primary (the blob's
* `platform`/`platformId`), which is what the account DTO and the refresh grant's
* claims report. Later platforms link without disturbing it.
*/
async function linkLoginIdentity(
db: D1Database,
accountId: number,
platform: number,
platformId: string,
maxAccountsPerIdentity: number
): Promise<void> {
if (await isPlatformIdentityLinked(db, accountId, platform, platformId)) return
if (
maxAccountsPerIdentity > 0 &&
(await countAccountsForPlatformIdentity(db, platform, platformId)) >= maxAccountsPerIdentity
) {
logger.info('platform link refused: account limit reached for this platform identity', {
accountId,
platform,
platformId,
})
return
}
if (!(await linkPlatformIdentity(db, accountId, platform, platformId))) return
logger.info('linked platform identity to account', { accountId, platform, platformId })
const account = await getAccount(db, accountId)
if (account && !account.platformId) {
await updateAccount(db, accountId, { platform, platformId })
}
}
/**
* What a login's `platform_auth` proved, if anything. Failures are split because the
* callers act on them differently: a grant that authenticates BY platform identity has
* to refuse, while a password grant which has already proven who it is carries on
* and just doesn't link.
*
* `unconfigured` is an operator problem (no META_APP_SECRET), not a bad credential,
* and is the one case that warrants a 5xx.
*/
type PlatformProof =
/** Nothing was checked — the login offered no proof, so there is nothing to report. */
| { status: 'none' }
| { status: 'verified'; platform: number; platformId: string }
| { status: 'unsupported' }
| { status: 'unconfigured' }
| { status: 'rejected'; reason: string }
/**
* Verify a login's `platform_auth` and return the identity it proves.
*
* The two verifiable platforms prove the id in opposite directions, which is why they
* can't share a code path: Steam's ticket *carries* a SteamID64 we read out and trust,
* so the posted `platform_id` is discarded. Meta's nonce carries nothing it is
* validated *against* the posted `platform_id`, so that field is an input, and a
* spoofed one fails validation rather than being ignored. Either way the id that comes
* back is proven, never the raw client-supplied field, and only a proven id is ever
* written to an account or linked to one.
*/
async function verifyPlatformProof(
env: App['Bindings'],
platform: number,
platformAuth: string,
postedPlatformId: string
): Promise<PlatformProof> {
// A sideloaded APK reports the placeholder id (see SIDELOAD_PLATFORM_ID) because it
// has no Meta SDK behind it. Refuse it here, before anything is asked of Meta, so no
// caller downstream can treat it as an identity — above all `linkLoginIdentity` on the
// password grant, which is the path such a client actually takes. Linking it would
// hand every sideloaded headset a password-free login into that account, since they
// all report this same id.
//
// Refusing costs a sideloaded player nothing: their password login still succeeds (a
// password grant carries its own credential and only *links* on a verified proof), it
// just never gets a cached login, so they type their password each launch. That is
// the intended shape of the sideload flow.
if (platform === PlatformType.Oculus && postedPlatformId === SIDELOAD_PLATFORM_ID) {
return { status: 'rejected', reason: 'sideload placeholder platform id is never an identity' }
}
if (platform === PlatformType.Steam) {
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' }
return { status: 'verified', platform: PlatformType.Steam, platformId: verified.steamId }
}
if (platform === PlatformType.Oculus) {
// `.get()` throws when the secret doesn't exist in the store at all (as opposed to
// holding an empty/placeholder value) — the same misconfiguration from the player's
// side, so it takes the same branch.
const appSecret = await env.META_APP_SECRET.get().catch(() => '')
if (appSecret === '') return { status: 'unconfigured' }
const verified = await verifyMetaNonce(platformAuth, postedPlatformId, appSecret)
if (!verified.ok) return { status: 'rejected', reason: verified.reason }
return {
status: 'verified',
platform: PlatformType.Oculus,
platformId: verified.identity.userId,
}
}
return { status: 'unsupported' }
}
const app = new Hono<App>()
.use(
'*',
@@ -207,341 +364,609 @@ const app = new Hono<App>()
})(c, next)
)
// DEBUG: dump every incoming request (method, url, headers, body). Clone the raw
// request so reading the body here doesn't consume the stream the handlers need.
.use('*', async (c, next) => {
const clone = c.req.raw.clone()
const body = await clone.text().catch(() => '')
logger.info('incoming request', {
method: c.req.method,
url: c.req.url,
headers: Object.fromEntries(c.req.raw.headers),
body,
})
await next()
})
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
// EAC challenge — a fresh GUID, JSON-quoted, served as plain text.
.get('/eac/challenge', (c) => c.text(`"AA=="`))
.get(
'/eac/challenge',
describeRoute({
tags: ['EAC'],
summary: 'Easy Anti-Cheat challenge',
description:
'Returns a constant JSON-quoted string (`"AA=="`) as `text/plain`. Anti-cheat is not implemented; this exists so the client\'s EAC handshake succeeds.',
responses: {
200: {
description: 'The challenge, JSON-quoted, as text/plain',
content: { 'text/plain': { schema: { type: 'string', example: '"AA=="' } } },
},
},
}),
(c) => c.text(`"AA=="`)
)
// Cached logins for a platform id — the accounts linked to this platform-native
// id, so the client can offer them on the login screen (and post one back as a
// cached_login grant). No linked account → [], and the client falls back to a
// fresh login / create_account.
.get('/cachedlogin/forplatformid/:platform/:id', async (c) => {
const { platform, id } = c.req.param()
logger.info('cached login lookup', { platform, id })
const platformInt = Number.parseInt(platform, 10)
const accounts = await getAccountsByPlatformId(c.env.DB, id)
// Offer only accounts the `cached_login` grant will actually accept — same check.
return c.json(
accounts
.filter((a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id))
.map(toCachedLogin)
)
})
.get(
'/cachedlogin/forplatformid/:platform/:id',
describeRoute({
tags: ['Cached login'],
summary: 'Accounts linked to a platform id',
description: [
'Accounts the client may offer on its login screen for this platform identity —',
'the links this identity has, so an entry here is always redeemable by a',
'`cached_login` grant (both read the same table). An account linked to several',
'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)',
'and the client falls back to a fresh login or create_account.',
'EXCEPT the exact identity `1/1` (Oculus, id `1`), which is stubbed for SIDELOADED',
'APKs: with no Meta SDK they have no real identity to ask about and stall on an',
'empty picker. It consults nothing and returns one canned, non-redeemable entry',
'with `requirePassword: true`, sending the build to username/password login.',
].join(' '),
parameters: [
{
name: 'platform',
in: 'path',
required: true,
description: 'PlatformType integer. A non-numeric value matches the id on any platform.',
schema: { type: 'string' },
},
{
name: 'id',
in: 'path',
required: true,
description: 'Platform-native id — a SteamID64 for Steam, a user id for Meta.',
schema: { type: 'string' },
},
],
responses: {
200: json(
CachedLogin.or(FakeCachedLogin).array(),
'Matching accounts; `[]` if none. The canned entry for `1/1`.'
),
},
}),
async (c) => {
const { platform, id } = c.req.param()
logger.info('cached login lookup', { platform, id })
const platformInt = Number.parseInt(platform, 10)
// SIDELOADED APKs ONLY. A sideloaded build has no Meta SDK behind it, so it can't
// produce a real Meta identity or a nonce to prove one with — it asks about the
// placeholder identity `1/1`, and an empty picker leaves it stuck on the platform
// login screen with nothing to do. Hand back one canned entry to push it onto the
// username/password login instead, which is the only flow such a build can finish.
// `requirePassword` is true for exactly that reason: there's no platform proof here,
// and the `cached_login` grant would (correctly) refuse this entry.
//
// Scoped to that ONE identity rather than to all of platform 1 — store builds do
// real Meta logins, and shadowing the whole platform would hide genuine links from
// their pickers.
if (platformInt === PlatformType.Oculus && id === SIDELOAD_PLATFORM_ID) {
return c.json([FAKE_OCULUS_CACHED_LOGIN])
}
// Listed straight from the link table, which is also what the `cached_login`
// grant authorizes against — so the picker can't offer an account the grant
// then refuses.
const links = Number.isNaN(platformInt)
? await getLinksForPlatformId(c.env.DB, id)
: await getLinksForPlatformIdentity(c.env.DB, platformInt, id)
return c.json(await toCachedLogins(c.env.DB, links))
}
)
// Bulk cached-login lookup by platform id (friends resolution). The client POSTs
// repeated `id=` params on the auth host; resolve each to its linked accounts.
.post('/cachedlogin/forplatformids', async (c) => {
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const raw = body.id
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
const out: Array<ReturnType<typeof toCachedLogin>> = []
for (const pid of ids) {
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
.post(
'/cachedlogin/forplatformids',
describeRoute({
tags: ['Cached login'],
summary: 'Bulk cached-login lookup (friends resolution)',
description: [
'Resolves many platform ids at once. Results are flattened across all ids, so the',
'response cannot be mapped back to a specific input id — the client uses each',
'entrys own `platformId`. No platform accompanies these ids, so each matches on',
'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
].join(' '),
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
}),
async (c) => {
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const raw = body.id
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
const out: Array<ReturnType<typeof toCachedLogin>> = []
for (const pid of ids) {
// No platform accompanies these ids, so they match on any platform.
out.push(...(await toCachedLogins(c.env.DB, await getLinksForPlatformId(c.env.DB, pid))))
}
return c.json(out)
}
return c.json(out)
})
)
// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
.post('/connect/token', async (c) => {
// Reads `grant_type`, `account_id`, `platform_id` and `platform` from the
// form body.
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
// `platform`/`platform_id` come from the body for a fresh login; a refresh
// grant overrides them below with what was stored when the token was issued.
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
// `platform` is the PlatformType int → its enum name (e.g. 0 → "Steam").
const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '')
.post(
'/connect/token',
describeRoute({
tags: ['Token'],
summary: 'OAuth token endpoint — issues a JWT',
description: [
'Issues an access token (plus a single-use refresh token) for one of four grants,',
'selected by `grant_type`. Every grant returns the same body on success.',
'',
'**`create_account`** — mints a new account with an auto-assigned random username',
'(players do not pick one initially) and places it in the Orientation room. A posted',
'`password` becomes the login credential. Subject to two independent signup caps,',
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
'must verify.',
'',
'**`cached_login`** — logs into an already-linked account using platform ownership as',
'the credential; no password. Requires a verifying `platform_auth`, and the posted',
'`account_id` must be LINKED to exactly the identity it proves. An account with no',
'link for that identity cannot be cached-logged-into.',
'',
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
'platform and platform id come from what was stored at issue time, not the body.',
'',
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
'identifies the account by `username` or numeric `account_id` and requires the',
'matching `password`. An account with no stored hash cannot be logged into at all,',
'which is what closes id/username-only takeover. When it also posts a `platform_auth`',
'that verifies, that identity is LINKED to the account — this is how a player who',
'signed up on one platform gets a cached login on a second device. The login is',
'never failed over the link: an unverifiable proof (or one over the per-identity',
'cap) just leaves the account without a cached login there.',
'',
'**Platform identity.** An account can be reached from several platform identities;',
'the links are the one thing both the picker and `cached_login` consult, and only a',
'VERIFIED identity is ever linked. Two platforms can be verified. Steam (`0`) posts a',
'Steam-signed `platform_auth` ticket, checked offline; the SteamID64 it carries',
'replaces the client-supplied `platform_id`. Meta/Oculus (`1`) posts `platform_auth`',
'as `{"Nonce":…,"AppId":…}`, which recflare sends to Meta together with the posted',
'`platform_id` — validation is what binds the nonce to that user id, so a spoofed id',
'fails. Meta logins therefore need the app secret (`META_APP_SECRET`) and answer 500',
'when it is unset. The first identity linked also becomes the accounts primary',
'(what the account DTO and a refreshed token report); later ones only link.',
'',
'The one platform id that is never verified and never linked is `1` on platform `1`',
'— what a SIDELOADED Oculus APK reports, having no Meta SDK to ask. Every such',
'build reports it, so it identifies nobody. A password login that carries it still',
'succeeds; it simply links nothing, and the player types their password each launch.',
'',
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant.',
].join('\n'),
requestBody: form(
TokenRequest,
'Union of all grants; see the description for per-grant requirements'
),
responses: {
200: json(TokenResponse, 'Access token, refresh token and granted scopes'),
400: json(
OAuthError,
[
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
].join(' ')
),
500: json(
OAuthError,
[
'The server is missing a secret it cannot proceed without: JWT_SECRET (a token is',
'refused rather than signed with an empty key) or, on a Meta login, META_APP_SECRET',
'(no nonce can be validated without it).',
].join(' ')
),
},
}),
async (c) => {
// Reads `grant_type`, `account_id`, `platform_id` and `platform` from the
// form body.
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
// `platform`/`platform_id` come from the body for a fresh login; a refresh
// grant overrides them below with what was stored when the token was issued.
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
const platformInt =
typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
// The token's `platform` claim is the PlatformType int. A grant that asserts no
// platform (a password login) falls back to Steam/0, the same default the account
// itself carries — see `accountPlatform`.
let platform = Number.isNaN(platformInt) ? PlatformType.Steam : platformInt
// The device this login came from. The client posts both on every grant; they're
// unverified (client-picked) so they're recorded on the account, never trusted as
// a credential. Stored on account creation AND refreshed on each successful login,
// so the account's device tracks the player across devices — the raw material for
// linking accounts that share a device later.
const deviceId = typeof body.device_id === 'string' ? body.device_id : ''
const deviceClassInt =
typeof body.device_class === 'string' ? Number.parseInt(body.device_class, 10) : NaN
const deviceClass = Number.isNaN(deviceClassInt) ? 0 : deviceClassInt
// The device this login came from. The client posts both on every grant; they're
// unverified (client-picked) so they're recorded on the account, never trusted as
// a credential. Stored on account creation AND refreshed on each successful login,
// so the account's device tracks the player across devices — the raw material for
// linking accounts that share a device later.
const deviceId = typeof body.device_id === 'string' ? body.device_id : ''
const deviceClassInt =
typeof body.device_class === 'string' ? Number.parseInt(body.device_class, 10) : NaN
const deviceClass = Number.isNaN(deviceClassInt) ? 0 : deviceClassInt
// The client's real IP, per Cloudflare (the client can't spoof CF-Connecting-IP —
// the edge sets it — unlike X-Forwarded-For, which is why we don't read that).
// Recorded as the immutable `signupIp` at creation and as `lastLoginIp` on every
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
// The client's real IP, per Cloudflare (the client can't spoof CF-Connecting-IP —
// the edge sets it — unlike X-Forwarded-For, which is why we don't read that).
// Recorded as the immutable `signupIp` at creation and as `lastLoginIp` on every
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
// A platform-authenticated login proves who you are with the platform itself,
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
// ticket. So those logins must be Steam:
// - cached_login authenticates purely by platform identity → always Steam-only.
// - create_account that asserts a platform is rejected unless it's Steam, since
// we won't bind an identity we can't prove. (create_account with NO platform
// is the password-account path — allowed, but it binds no platformId.)
// The verified SteamID64 replaces the unauthenticated `platform_id` field and is
// the ONLY value ever written to an account's `platformId`. Credential (password)
// and refresh_token grants carry their own credential and aren't gated here.
let verifiedSteamId: string | null = null
const platformAsserted = !Number.isNaN(platformInt)
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
if (platformInt !== 0) {
return c.json(
{
error: 'invalid_grant',
error_description: 'unsupported platform; only Steam can be verified',
},
400
)
}
// A platform-authenticated login proves who you are with the platform itself, and
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
// verifyPlatformProof). Only a verified identity is ever bound or linked.
//
// Two grants are GATED on it — they have no other credential, so an unverifiable
// platform is fatal:
// - cached_login authenticates purely by platform identity.
// - create_account that asserts a platform: we won't bind an identity we can't
// prove. (create_account with NO platform is the password-account path —
// allowed, but binds no platformId.)
//
// A password grant is NOT gated: the password already proved who it is. It posts
// its platform proof too, and if that verifies we LINK the identity to the account
// (see below), which is how a player who created an account on Steam gets a cached
// login on their headset. If it doesn't verify, the login still succeeds — it just
// links nothing, because a link is a password-free way into the account and must
// never rest on an unproven id.
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) {
const platformAsserted = !Number.isNaN(platformInt)
const gatedOnPlatform =
grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)
// The password grant only spends a verification when the client actually offered
// one; the rest of the time there is nothing to link.
const proof: PlatformProof =
gatedOnPlatform || (platformAsserted && platformAuth !== '')
? await verifyPlatformProof(c.env, platformInt, platformAuth, platformId)
: { status: 'none' }
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 ticket' },
{ error: 'invalid_grant', error_description: 'invalid or missing platform_auth' },
400
)
}
verifiedSteamId = verified.steamId
platformId = verified.steamId
}
// Resolve the account this token is for:
// - create_account: mint + persist a brand-new account (auto-assigned random
// username — players don't pick one initially); the token's `sub` is its id.
// A `password` may be posted to establish the account's login credential.
// - refresh_token: redeem a stored (single-use) refresh token for its account +
// platform, so an expiring session renews without re-login.
// - otherwise: a credential login. The request identifies the account by
// `username` (RecRoom's password grant posts the username, not the id) or a
// numeric `account_id`, and MUST post the account's correct `password`. An
// account with no password set can't be logged into (no credential to verify)
// — closing the id/username-only takeover. New accounts establish a password
// via create_account or /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
// Signup caps. Checked before minting anything, so a rejected signup leaves no
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
// identity is unknown (no verified platform id / no client IP) — an unattributable
// signup can't be counted against anyone, and lumping them together would lock out
// real players. The disabled check comes first so a disabled arm costs no D1 read.
const maxPerPlatformId = intVar(
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
// From here on `platformId` is the PROVEN identity wherever there is one — the
// SteamID64 out of the ticket or the Meta user id the nonce validated against,
// never the raw client-supplied field.
if (verifiedPlatformId !== null) platformId = verifiedPlatformId
// Resolve the account this token is for:
// - create_account: mint + persist a brand-new account (auto-assigned random
// username — players don't pick one initially); the token's `sub` is its id.
// A `password` may be posted to establish the account's login credential.
// - refresh_token: redeem a stored (single-use) refresh token for its account +
// platform, so an expiring session renews without re-login.
// - otherwise: a credential login. The request identifies the account by
// `username` (RecRoom's password grant posts the username, not the id) or a
// numeric `account_id`, and MUST post the account's correct `password`. An
// account with no password set can't be logged into (no credential to verify)
// — closing the id/username-only takeover. New accounts establish a password
// via create_account or /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
// Signup caps. Checked before minting anything, so a rejected signup leaves no
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
// identity is unknown (no verified platform id / no client IP) — an unattributable
// signup can't be counted against anyone, and lumping them together would lock out
// real players. The disabled check comes first so a disabled arm costs no D1 read.
const maxPerPlatformId = intVar(
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
)
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
if (
maxPerPlatformId > 0 &&
verifiedPlatformId !== null &&
(await countAccountsForPlatformIdentity(
c.env.DB,
verifiedPlatform ?? 0,
verifiedPlatformId
)) >= maxPerPlatformId
) {
logger.info('signup rejected: platform account limit', {
platformId: verifiedPlatformId,
})
return c.json(
{
error: 'invalid_grant',
error_description: 'account limit reached for this platform account',
},
400
)
}
if (
maxPerIp > 0 &&
clientIp !== '' &&
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
) {
logger.info('signup rejected: per-IP account limit', { ip: clientIp })
return c.json(
{
error: 'invalid_grant',
error_description: 'too many accounts created from this network',
},
400
)
}
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
// a Meta-validated nonce). A password/anonymous create_account (no platform)
// binds nothing. The account blob keeps this first identity as its PRIMARY one
// (for the account DTO and the refresh grant's claims); the link written just
// below is what a later cached login is actually authorized against.
const account = await createAccount(c.env.DB, {
platforms: platformInt || 0,
platform: verifiedPlatform ?? undefined,
platformId: verifiedPlatformId ?? undefined,
lastLoginTime: new Date().toISOString(),
deviceId: deviceId || undefined,
deviceClass: deviceId ? deviceClass : undefined,
signupIp: clientIp || undefined,
lastLoginIp: clientIp || undefined,
})
accountId = String(account.accountId)
if (verifiedPlatformId !== null) {
await linkPlatformIdentity(
c.env.DB,
account.accountId,
verifiedPlatform ?? 0,
verifiedPlatformId
)
}
// Establish the login password when one is posted (raw password never stored).
const password = typeof body.password === 'string' ? body.password : ''
if (password !== '') {
await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password))
}
// Place the new player in Orientation (they don't explicitly matchmake into it).
await placeNewPlayerInOrientation(c.env, account.accountId, deviceClass)
} else if (grantType === 'refresh_token') {
const presented = typeof body.refresh_token === 'string' ? body.refresh_token : ''
const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null
if (!refreshed) {
return c.json(
{ error: 'invalid_grant', error_description: 'refresh_token is invalid or expired' },
400
)
}
// `platform`/`platform_id` aren't stored with the token — they're taken from
// the account below, so a refreshed token always reflects the identity the
// account is bound to now.
accountId = String(refreshed)
} else if (grantType === 'cached_login') {
// Platform-authenticated login into an already-linked account. The client posts
// the `account_id` it got from /cachedlogin/forplatformid together with the
// `platform_id` its platform_auth vouches for. Authorize ONLY when the link
// table says that account is linked to exactly this platform identity — this is
// the check that keeps anyone but that platform user out of the account
// (platform ownership is the credential; no password needed). An account with no
// link for the presented identity must use a password.
//
// The picker lists straight from the same table, so it can only offer accounts
// this check accepts.
//
// NB: `platform_id` here is the verified identity set above — the SteamID64 from
// the ticket, or the Meta user id the nonce validated against — never the raw
// client-supplied field. See steam-ticket.ts and meta-nonce.ts.
//
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
const linked =
account !== null &&
(await isPlatformIdentityLinked(c.env.DB, account.accountId, platformInt, platformId))
if (!account || !linked) {
return c.json(
{
error: 'invalid_grant',
error_description: 'no linked account for this platform identity',
},
400
)
}
accountId = String(account.accountId)
await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString())
await setLoginContext(c.env.DB, account.accountId, { deviceId, deviceClass, ip: clientIp })
} else {
// Resolve the account from a posted numeric `account_id` or, as RecRoom's
// password grant sends, a `username` (case-insensitive; trailing whitespace
// is trimmed off the posted value).
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const postedUsername = typeof body.username === 'string' ? body.username.trim() : ''
let resolvedId: number | null = null
if (/^\d+$/.test(postedId)) {
resolvedId = Number(postedId)
} else if (postedUsername !== '') {
resolvedId = (await getAccountByUsername(c.env.DB, postedUsername))?.accountId ?? null
}
if (resolvedId === null) {
return c.json(
{ error: 'invalid_request', error_description: 'account_id or username is required' },
400
)
}
// The account's password MUST be presented and match. An account with no
// stored hash has no credential to authenticate against, so login is refused
// — this closes the id/username-only takeover.
const storedHash = await getPasswordHash(c.env.DB, resolvedId)
const password = typeof body.password === 'string' ? body.password : ''
if (!storedHash || !(await verifyPassword(password, storedHash))) {
return c.json(
{ error: 'invalid_grant', error_description: 'invalid account_id or password' },
400
)
}
accountId = String(resolvedId)
// The password proved the account; the platform proof (when the client sent one
// and it verified) proves the device's platform identity. Linking the two is
// what gives a player who signed up on Steam a cached login on their headset —
// they type their password once there, and never again.
if (verifiedPlatformId !== null) {
await linkLoginIdentity(
c.env.DB,
resolvedId,
verifiedPlatform ?? 0,
verifiedPlatformId,
intVar(c.env.MAX_ACCOUNTS_PER_PLATFORM_ID, DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID)
)
}
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
// binding) would still yield a well-formed token — but one signed with an empty
// key, which every worker validates against, so anyone could forge it. Refuse to
// issue a token at all rather than complete the login with a forgeable credential.
const jwtSecret = await c.env.JWT_SECRET.get()
if (jwtSecret === '') {
logger.error('refusing to issue token: JWT_SECRET is empty')
return c.json(
{ error: 'server_error', error_description: 'token signing is not configured' },
500
)
}
// Stamp the account's elevated roles into the token's `role` claim so the client
// authorizes developer/moderator powers from the token itself (not just the
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
// every login and every refresh_token grant.
const roleAccount = await getAccount(c.env.DB, Number(accountId))
// A refresh grant posts no platform of its own, so the identity comes off the
// account — the same read, and the only place the bound identity is authoritative.
if (grantType === 'refresh_token' && roleAccount) {
platform = accountPlatform(roleAccount)
platformId = roleAccount.platformId ?? ''
}
const accessToken = await generateToken(
accountId,
platformId,
platform,
jwtSecret,
accountRoles(roleAccount)
)
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
if (
maxPerPlatformId > 0 &&
verifiedSteamId !== null &&
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
) {
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
return c.json(
{
error: 'invalid_grant',
error_description: 'account limit reached for this platform account',
},
400
)
}
if (
maxPerIp > 0 &&
clientIp !== '' &&
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
) {
logger.info('signup rejected: per-IP account limit', { ip: clientIp })
return c.json(
{
error: 'invalid_grant',
error_description: 'too many accounts created from this network',
},
400
)
}
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
// grant_type=refresh_token). A refresh grant thus rotates its token.
const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId))
// Bind the platform identity ONLY when a Steam ticket proved it. That bound
// `platformId` (the SteamID64) is what a later cached login is checked against,
// so only this Steam user can log back into the account. A password/anonymous
// create_account (no platform) binds no platformId.
const account = await createAccount(c.env.DB, {
platforms: platformInt || 0,
platform: verifiedSteamId !== null ? 0 : undefined,
platformId: verifiedSteamId ?? undefined,
lastLoginTime: new Date().toISOString(),
deviceId: deviceId || undefined,
deviceClass: deviceId ? deviceClass : undefined,
signupIp: clientIp || undefined,
lastLoginIp: clientIp || undefined,
return c.json({
access_token: accessToken,
expires_in: TOKEN_TTL_SECONDS,
token_type: 'Bearer',
refresh_token: refreshToken,
scope: TOKEN_SCOPE,
// @kludge Why is this necessary? Who knows.
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
})
accountId = String(account.accountId)
// Establish the login password when one is posted (raw password never stored).
const password = typeof body.password === 'string' ? body.password : ''
if (password !== '') {
await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password))
}
// Place the new player in Orientation (they don't explicitly matchmake into it).
await placeNewPlayerInOrientation(c.env, account.accountId, deviceClass)
} else if (grantType === 'refresh_token') {
const presented = typeof body.refresh_token === 'string' ? body.refresh_token : ''
const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null
if (!refreshed) {
return c.json(
{ error: 'invalid_grant', error_description: 'refresh_token is invalid or expired' },
400
)
}
accountId = String(refreshed.accountId)
platform = refreshed.platform
platformId = refreshed.platformId
} else if (grantType === 'cached_login') {
// Platform-authenticated login into an already-linked account. The client posts
// the `account_id` it got from /cachedlogin/forplatformid together with the
// `platform_id` its platform_auth ticket vouches for. Authorize ONLY when that
// account is linked to exactly this platform identity — this is the check that
// keeps anyone but platform user `platform_id` out of the account (platform
// ownership is the credential; no password needed). An account with no stored
// platform identity can't be cached-logged-into and must use a fresh login.
//
// NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket
// above), never the client-supplied field. See steam-ticket.ts.
//
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) {
return c.json(
{
error: 'invalid_grant',
error_description: 'no linked account for this platform identity',
},
400
)
}
accountId = String(account.accountId)
await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString())
await setLoginContext(c.env.DB, account.accountId, { deviceId, deviceClass, ip: clientIp })
} else {
// Resolve the account from a posted numeric `account_id` or, as RecRoom's
// password grant sends, a `username` (case-insensitive; trailing whitespace
// is trimmed off the posted value).
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const postedUsername = typeof body.username === 'string' ? body.username.trim() : ''
let resolvedId: number | null = null
if (/^\d+$/.test(postedId)) {
resolvedId = Number(postedId)
} else if (postedUsername !== '') {
resolvedId = (await getAccountByUsername(c.env.DB, postedUsername))?.accountId ?? null
}
if (resolvedId === null) {
return c.json(
{ error: 'invalid_request', error_description: 'account_id or username is required' },
400
)
}
// The account's password MUST be presented and match. An account with no
// stored hash has no credential to authenticate against, so login is refused
// — this closes the id/username-only takeover.
const storedHash = await getPasswordHash(c.env.DB, resolvedId)
const password = typeof body.password === 'string' ? body.password : ''
if (!storedHash || !(await verifyPassword(password, storedHash))) {
return c.json(
{ error: 'invalid_grant', error_description: 'invalid account_id or password' },
400
)
}
accountId = String(resolvedId)
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
// binding) would still yield a well-formed token — but one signed with an empty
// key, which every worker validates against, so anyone could forge it. Refuse to
// issue a token at all rather than complete the login with a forgeable credential.
const jwtSecret = await c.env.JWT_SECRET.get()
if (jwtSecret === '') {
logger.error('refusing to issue token: JWT_SECRET is empty')
return c.json(
{ error: 'server_error', error_description: 'token signing is not configured' },
500
)
}
// Stamp the account's elevated roles into the token's `role` claim so the client
// authorizes developer/moderator powers from the token itself (not just the
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
// every login and every refresh_token grant.
const roleAccount = await getAccount(c.env.DB, Number(accountId))
const accessToken = await generateToken(
accountId,
platformId,
platform,
jwtSecret,
accountRoles(roleAccount)
)
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
// grant_type=refresh_token). A refresh grant thus rotates its token.
const refreshToken = await issueRefreshToken(c.env.DB, {
accountId: Number(accountId),
platform,
platformId,
})
return c.json({
access_token: accessToken,
expires_in: TOKEN_TTL_SECONDS,
token_type: 'Bearer',
refresh_token: refreshToken,
scope: TOKEN_SCOPE,
// @kludge Why is this necessary? Who knows.
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
})
})
)
// Change the caller's password. Auth-gated. Stores a PBKDF2 hash on the account
// row (the raw password is never persisted). When the account already has a
// password, `oldPassword` must match; the first time it's set, `oldPassword` is
// empty (as the client sends).
.post('/account/me/changepassword', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
.post(
'/account/me/changepassword',
describeRoute({
tags: ['Account'],
summary: "Change the caller's password",
description: [
'Stores a PBKDF2 hash on the account row; the raw password is never persisted.',
'When the account already has a password, `oldPassword` must match. The first time',
'a password is set, `oldPassword` is empty — which is what the client sends.',
].join(' '),
security: [{ bearerAuth: [] }],
requestBody: form(ChangePasswordRequest, 'New password, plus the old one when one is set'),
responses: {
200: json(ChangePasswordResponse, 'Password changed'),
400: json(ChangePasswordResponse, '`newPassword` was empty, or `oldPassword` was wrong'),
401: { description: 'Missing or invalid bearer token (empty body)' },
404: { description: 'The account no longer exists (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const oldPassword = typeof body.oldPassword === 'string' ? body.oldPassword : ''
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
if (newPassword === '') {
return c.json({ success: false, error: 'You must enter a new password.' }, 400)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const oldPassword = typeof body.oldPassword === 'string' ? body.oldPassword : ''
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
if (newPassword === '') {
return c.json({ success: false, error: 'You must enter a new password.' }, 400)
}
const currentHash = await getPasswordHash(c.env.DB, id)
if (currentHash && !(await verifyPassword(oldPassword, currentHash))) {
return c.json({ success: false, error: 'Your old password is incorrect.' }, 400)
}
const ok = await setPasswordHash(c.env.DB, id, await hashPassword(newPassword))
if (!ok) return c.body(null, 404)
return c.json({ success: true })
}
const currentHash = await getPasswordHash(c.env.DB, id)
if (currentHash && !(await verifyPassword(oldPassword, currentHash))) {
return c.json({ success: false, error: 'Your old password is incorrect.' }, 400)
}
const ok = await setPasswordHash(c.env.DB, id, await hashPassword(newPassword))
if (!ok) return c.body(null, 404)
return c.json({ success: true })
})
)
// Developer role lookup. Returns a bare JSON boolean (the client reads the body as
// a bool), and 404s for an unknown player — mirroring the reference API. The role
// is off by default and only an operator grants it (via `runx admin grant-developer`,
// which sets the account's isDeveloper flag); it also rides in the token's `role`
// claim (see accountRoles).
.get('/role/developer/:id', async (c) => {
.get('/role/developer/:id', describeRoute(roleLookup('developer')), async (c) => {
const { id } = c.req.param()
logger.info('developer role lookup', { id })
const accountId = Number.parseInt(id, 10)
@@ -553,7 +978,7 @@ const app = new Hono<App>()
// Moderator role lookup, mirroring developer (bare boolean, 404 for unknown player).
// Operator-granted only (via `runx admin grant-moderator`); the flag also rides in
// the token's `role` claim.
.get('/role/moderator/:id', async (c) => {
.get('/role/moderator/:id', describeRoute(roleLookup('moderator')), async (c) => {
const { id } = c.req.param()
logger.info('moderator role lookup', { id })
const accountId = Number.parseInt(id, 10)
@@ -562,4 +987,36 @@ const app = new Hono<App>()
return c.json(account.isModerator === true)
})
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare auth',
version: '1.0.0',
description: [
'Authentication and token issuance for recflare, a private-server reimplementation',
'of the Rec Room backend.',
].join('\n'),
},
servers: [{ url: 'https://auth.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+7
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
+172
View File
@@ -0,0 +1,172 @@
/**
* Verification of a Meta (Oculus) `platform_auth` nonce, against Meta's graph API.
*
* Steam's ticket is signed by Steam, so we verify it offline with no network and no
* credential (see steam-ticket.ts). Meta's user proof is the opposite: an opaque
* nonce that means nothing on its own. The only way to know it is genuine is to ask
* Meta which is why this path makes an outbound request on every Meta login and
* cannot work at all without the app secret.
*
* A Meta login posts
*
* platform_auth = {"Nonce":"<64 chars>","AppId":"1232175103309633","Source":"logged in user"}
* platform_id = <the Meta user id>
*
* and validation is what BINDS those two together: `user_nonce_validate` answers
* "was this nonce issued to this user, for this app?". So the posted `platform_id` is
* an *input* here rather than something read out of a ticket, and a spoofed one fails
* a nonce Meta issued to user A does not validate as user B. The id is therefore
* proven exactly as much as a Steam ticket's SteamID64 is, and is safe to bind to an
* account. (It's an app-scoped id: it identifies the player within this app only.)
*
* The `AppId` comes from the payload rather than config because it must be the app the
* nonce was issued for a different one simply fails, since the access token below
* pairs it with our secret. `Source` is informational and ignored.
*
* Shape and retry policy follow the reference Go server's utils/oculus.go.
*/
/** Meta's nonce-validation endpoint. Takes a form body, answers `{"is_valid":true}`. */
const NONCE_VALIDATE_URL = 'https://graph.oculus.com/user_nonce_validate'
/**
* Graph error codes worth retrying 1 (unknown) and 2 (service temporarily
* unavailable) are Meta-side hiccups, not a verdict on the nonce. Anything else is a
* real answer and retrying it just delays a login that is going to fail anyway.
*/
const TRANSIENT_ERROR_CODES = new Set([1, 2])
/**
* Attempts per verification. A login is latency-sensitive and a nonce is single-use
* with a short life, so this is deliberately small: two quick retries (250ms, 1s of
* backoff) ride out a blip, and a longer outage fails the login rather than hanging
* the client on a headset loading screen.
*/
const MAX_ATTEMPTS = 3
/** The trustworthy identity proven by a validated nonce. */
export interface VerifiedMetaIdentity {
/** The Meta user id the nonce was issued to — app-scoped, numeric. */
userId: string
/** The Meta app the nonce was issued for. */
appId: string
}
/**
* The outcome of a verification. Failures carry a `reason` for the server log: the
* client is told only that its platform_auth was rejected (it can't act on more), but
* an operator debugging a headset that won't log in needs to know whether Meta said
* "bad nonce", "bad access token" (the wrong app secret) or nothing at all.
*/
export type MetaVerification =
{ ok: true; identity: VerifiedMetaIdentity } | { ok: false; reason: string }
/** The `{Nonce, AppId}` a Meta `platform_auth` payload carries. */
export interface MetaPlatformAuth {
nonce: string
appId: string
}
/**
* Parse a Meta `platform_auth` payload, or null when it isn't one. The `AppId` must be
* numeric it is interpolated into the access token below, and this is what keeps a
* client-supplied string out of that credential.
*/
export function parseMetaPlatformAuth(platformAuth: string): MetaPlatformAuth | null {
let parsed: { Nonce?: unknown; AppId?: unknown }
try {
parsed = JSON.parse(platformAuth) as { Nonce?: unknown; AppId?: unknown }
} catch {
return null
}
const { Nonce: nonce, AppId: appId } = parsed
if (typeof nonce !== 'string' || nonce === '') return null
if (typeof appId !== 'string' || !/^\d+$/.test(appId)) return null
return { nonce, appId }
}
/** The graph response we care about; everything else in the body is ignored. */
interface NonceValidateResponse {
is_valid?: boolean
error?: { message?: string; code?: number; type?: string; is_transient?: boolean }
}
/** One validation round-trip. `retryable` says whether another attempt could differ. */
async function validateOnce(
form: URLSearchParams,
fetcher: typeof fetch
): Promise<{ ok: boolean; retryable: boolean; reason: string }> {
let res: Response
try {
res = await fetcher(NONCE_VALIDATE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
})
} catch (err) {
return { ok: false, retryable: true, reason: `request failed: ${String(err)}` }
}
let body: NonceValidateResponse
try {
body = (await res.json()) as NonceValidateResponse
} catch {
// A non-JSON body is Meta's edge (a 5xx error page, a rate-limit page), not a
// verdict — treat it the way a dropped connection is treated.
return { ok: false, retryable: true, reason: `HTTP ${res.status} with a non-JSON body` }
}
if (body.error) {
const { code, message, is_transient } = body.error
return {
ok: false,
retryable: is_transient === true || (code !== undefined && TRANSIENT_ERROR_CODES.has(code)),
reason: `graph error ${code ?? '?'}: ${message ?? 'no message'}`,
}
}
if (body.is_valid !== true) return { ok: false, retryable: false, reason: 'nonce rejected' }
return { ok: true, retryable: false, reason: '' }
}
/**
* Verify a Meta `platform_auth` payload against the `userId` it is claimed for, and
* return the identity it proves. Only ever succeeds for a nonce Meta itself confirms
* was issued to that user for that app.
*
* `appSecret` is the app's secret from the Meta developer dashboard; without it no
* Meta login can be verified, so callers must treat an unset secret as a server
* misconfiguration rather than a bad credential. `fetcher` is injectable so tests can
* run the retry and response handling without reaching the network.
*/
export async function verifyMetaNonce(
platformAuth: string,
userId: string,
appSecret: string,
fetcher?: typeof fetch
): Promise<MetaVerification> {
if (appSecret === '') return { ok: false, reason: 'no app secret configured' }
// The user id is what the nonce is checked against, so an absent or non-numeric one
// can't be verified — reject before spending a round-trip on it.
if (!/^\d+$/.test(userId)) return { ok: false, reason: 'missing or non-numeric platform_id' }
const auth = parseMetaPlatformAuth(platformAuth)
if (!auth) return { ok: false, reason: 'malformed platform_auth payload' }
// `OC|<app id>|<app secret>` is Meta's app access token — it authenticates the
// *app*, which is why the secret never leaves the server.
const form = new URLSearchParams({
nonce: auth.nonce,
user_id: userId,
access_token: `OC|${auth.appId}|${appSecret}`,
})
// Resolved per call, not at module load, so a test's stubbed global is honoured.
const doFetch = fetcher ?? globalThis.fetch
let last = { ok: false, retryable: false, reason: 'not attempted' }
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
last = await validateOnce(form, doFetch)
if (last.ok) return { ok: true, identity: { userId, appId: auth.appId } }
if (!last.retryable || attempt === MAX_ATTEMPTS) break
await new Promise((resolve) => setTimeout(resolve, attempt * attempt * 250))
}
return { ok: false, reason: last.reason }
}
+222
View File
@@ -0,0 +1,222 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the auth worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`.
*
* That is deliberate, not an oversight. This worker serves a reverse-engineered
* protocol: the Rec Room client is the only real consumer, and the handlers are
* intentionally lenient every field is read as
* `typeof body.x === 'string' ? body.x : ''` and missing/malformed input falls
* through to a graceful path rather than a 400. Which parts of that tolerance the
* client actually depends on is not fully known, so enforcing a schema would risk
* rejecting requests that work today, for a client that is hard to debug against.
*
* So: these schemas record what the client is *observed* to send and what we send
* back. If you want to enforce one, do it per-route and land a test with it.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
/**
* Emit a zod schema as an `application/x-www-form-urlencoded` request body.
*
* Unlike `responses`, `describeRoute`'s `requestBody` takes a plain OpenAPI schema
* and won't accept a `resolver()`, so convert here. zod's `$schema` key is dropped
* (not meaningful in an OpenAPI schema position), as is `additionalProperties: false`
* these handlers read the fields they know and ignore the rest, so claiming a
* closed object would misreport the server as stricter than it is.
*/
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return {
description,
content: {
// zod's JSONSchema type is far wider than OpenAPI's SchemaObject (it carries
// `~standard` and every draft keyword), so the two never match structurally
// even though the emitted value is valid OpenAPI 3.1. Cast at the boundary.
'application/x-www-form-urlencoded': { schema: jsonSchema as OpenAPIV3_1.SchemaObject },
},
}
}
/**
* PlatformType, the client's platform enum. Declaration order is wire order, and is
* the single source for the schema and description below. The `platform` form field
* is posted as the integer; the token's `platform` claim carries the name.
*/
export const PlatformType = {
All: -1,
Steam: 0,
Oculus: 1,
PlayStation: 2,
Xbox: 3,
RecNet: 4,
IOS: 5,
GooglePlay: 6,
Standalone: 7,
Pico: 8,
} as const
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
/**
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified
* see the platform-auth notes on `POST /connect/token`.
*/
export const PlatformTypeSchema = z
.union([
z.literal(-1),
z
.int()
.min(0)
.max(Math.max(...Object.values(PlatformType))),
])
.describe(
Object.entries(PlatformType)
.map(([name, value]) => `${value} ${name}`)
.join(', ')
)
/**
* One entry on the client's login screen, from `toCachedLogin` an account platform
* identity LINK, not an account. An account linked to two platforms yields one entry in
* each of their pickers, each reporting the identity that picker was asked about.
*/
export const CachedLogin = z.object({
platform: PlatformTypeSchema,
platformId: z
.string()
.describe('The linked platform-native id — a SteamID64 for Steam, a user id for Meta'),
accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'),
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
requirePassword: z
.literal(false)
.describe('Always false — platform ownership is the credential for a cached login'),
})
/**
* The stubbed Oculus cached login served to sideloaded APKs. Same shape as `CachedLogin`,
* but `requirePassword` is true with no Meta SDK there is nothing to prove platform
* ownership with, so the client falls through to username/password.
*/
export const FakeCachedLogin = CachedLogin.extend({
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
})
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
export const OAuthError = z.object({
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
error_description: z.string(),
})
/** Successful `POST /connect/token` body. */
export const TokenResponse = z.object({
access_token: z.string().describe('Signed JWT; `sub` is the account id'),
expires_in: z.int().describe('Access-token lifetime in seconds (TOKEN_TTL_SECONDS)'),
token_type: z.literal('Bearer'),
refresh_token: z
.string()
.describe('Single-use; redeem via grant_type=refresh_token, which rotates it'),
scope: z.string().describe('Space-separated granted scopes'),
key: z.string().describe('@kludge Constant the client appears to require. Purpose unknown.'),
})
/**
* `POST /connect/token` form body the union of every grant's fields, since
* OpenAPI cannot express "these fields iff grant_type=X" without splitting the
* endpoint. Per-grant requirements are spelled out in the route description.
*/
export const TokenRequest = z.object({
grant_type: z
.enum(['create_account', 'cached_login', 'refresh_token', 'password'])
.describe('Anything unrecognised (including absent) is treated as a password grant'),
account_id: z.string().optional().describe('Numeric account id, as a string'),
username: z
.string()
.optional()
.describe('Password grant alternative to account_id; case-insensitive, trimmed'),
password: z
.string()
.optional()
.describe('Required on a password grant. On create_account, sets the initial password'),
platform: z.string().optional().describe('PlatformType as an integer string'),
platform_id: z
.string()
.optional()
.describe(
'On Steam, unverified and ignored in favour of the id the ticket carries. On Meta it is ' +
'the id the nonce is validated against, so it must be the real (numeric) user id'
),
platform_auth: z
.string()
.optional()
.describe(
'Platform proof, required for cached_login and platform create_account, and used to ' +
'link the identity on a password grant. Steam: `{"Ticket":"<hex>","AppId":…}`. ' +
'Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
),
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
device_id: z
.string()
.optional()
.describe('Client-chosen, unverified. Recorded on the account, never trusted'),
device_class: z.string().optional().describe('Integer string; defaults to 0'),
})
/** `POST /account/me/changepassword` form body. */
export const ChangePasswordRequest = z.object({
newPassword: z.string().describe('Required; empty is rejected'),
oldPassword: z
.string()
.optional()
.describe('Must match when the account already has a password; empty when first setting it'),
})
/** `POST /account/me/changepassword` response body. */
export const ChangePasswordResponse = z.object({
success: z.boolean(),
error: z.string().optional(),
})
/**
* Spec for the `/role/:role/:id` lookups, which are identical apart from the role.
* Both return a BARE JSON boolean rather than an object the client reads the whole
* body as a bool and 404 an unknown player, mirroring the reference API.
*/
export function roleLookup(role: 'developer' | 'moderator') {
return {
tags: ['Roles'],
summary: `Whether a player has the ${role} role`,
description:
`Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
`in the access token's \`role\` claim, so the client rarely needs this route.`,
parameters: [
{
name: 'id',
in: 'path' as const,
required: true,
description: 'Account id. A non-numeric value is treated as unknown (404).',
schema: { type: 'string' as const },
},
],
responses: {
200: json(z.boolean(), `\`true\` if the player has the ${role} role`),
404: { description: 'No such player (empty body)' },
},
}
}
/** Bulk cached-login lookup form body: repeated `id=` fields. */
export const PlatformIdsRequest = z.object({
id: z.union([z.string(), z.array(z.string())]).describe('Repeated `id=` form fields'),
})
+198
View File
@@ -0,0 +1,198 @@
/**
* Platform identity links on the shared `recflare` D1 database (owned by the `auth`
* worker, migration 0007). One row per (platform, platform id, account): the Steam
* user 76561211 is linked to account 42, the Meta user 27061 is linked to account
* 42 as well, and both let that player into that account without a password.
*
* This table replaced the single `platformId`/`platform` pair on the account blob as
* the thing logins are decided from, because that pair could only hold ONE identity
* a player with a PC and a headset had to pick which device got a cached login. The
* blob fields are kept as the account's *primary* identity (the first one linked) for
* the account DTO and the refresh grant's claims; nothing authorizes off them.
*
* It is deliberately the ONE source of truth for both halves of a cached login: the
* picker (`/cachedlogin/forplatformid`) lists the accounts this table links to an
* identity, and the `cached_login` grant asks this table whether the account it was
* handed is linked to the identity that was proven. When those two disagreed the
* client was offered an account it could never log into see the regression test.
*
* A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a
* Meta-validated nonce). It is what turns "this platform user" into "may enter this
* account with no password", so an unproven `platform_id` must never reach it.
*/
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
export const PLATFORM_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS platform_account (
account_id INTEGER NOT NULL,
platform INTEGER NOT NULL,
platform_id TEXT NOT NULL,
linked_at TEXT NOT NULL,
PRIMARY KEY (platform, platform_id, account_id)
)`,
// The picker's lookup: "which accounts does this identity open?". Covered by the
// primary key's leading columns, so no separate index is needed for it.
`CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id)`,
// Lookup by bare platform id, across platforms — the bulk (friends) route, which
// resolves ids it has no platform for.
`CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id)`,
]
/**
* The one-time backfill 0007 ran after creating the table: every identity already bound
* to an account became a link, so nobody lost their cached login at deploy. It has run;
* this exists so a test can still exercise it, which is the only coverage that legacy
* blob-bound accounts get a link at all.
*
* `platform` is COALESCEd to 0 because nothing ever defaulted that field an account
* can carry a platformId with no platform recorded, and back when Steam was the only
* verifiable platform an unset one *was* Steam.
*
* NOT byte-identical to the migration any more, deliberately. 0007 selected the
* `account.platform_id` generated column; 0008 drops it, so that text is unrunnable
* against the head schema the tests build. This selects the blob directly instead
* the same values, since the dropped column was DEFINED as
* `json_extract(data, '$.platformId')`. 0007 is left exactly as it ran on prod.
*/
export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
SELECT
account_id,
COALESCE(json_extract(data, '$.platform'), 0),
json_extract(data, '$.platformId'),
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
FROM account
WHERE json_extract(data, '$.platformId') IS NOT NULL
AND json_extract(data, '$.platformId') <> ''`
/** One account ↔ platform identity link. */
export interface PlatformLink {
accountId: number
platform: number
platformId: string
/** ISO-8601 time the link was made. */
linkedAt: string
}
interface LinkRow {
accountId: number
platform: number
platformId: string
linkedAt: string
}
const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS platformId,
linked_at AS linkedAt FROM platform_account`
/**
* Link a verified platform identity to an account. Idempotent re-logging in on the
* same platform doesn't churn the row, and `linkedAt` keeps the time of the FIRST
* link. Returns true when this created a new link.
*
* Callers must pass an identity the platform itself proved. Nothing in here can tell
* a verified id from a spoofed one.
*/
export async function linkPlatformIdentity(
db: D1Database,
accountId: number,
platform: number,
platformId: string
): Promise<boolean> {
if (platformId === '') return false
const res = await db
.prepare(
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(accountId, platform, platformId, new Date().toISOString())
.run()
return res.meta.changes > 0
}
/**
* The accounts a platform identity opens what the login-screen picker lists.
* Ordered oldest link first so the list is stable between launches (D1 row order
* isn't). Empty id yields nothing rather than matching every link.
*/
export async function getLinksForPlatformIdentity(
db: D1Database,
platform: number,
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
const { results } = await db
.prepare(
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
)
.bind(platform, platformId)
.all<LinkRow>()
return results
}
/**
* Links for a bare platform id, whatever platform it belongs to. For the bulk
* (friends-resolution) lookup, which posts ids with no platform alongside them, and
* for the single-id route when the client sends a non-numeric platform.
*/
export async function getLinksForPlatformId(
db: D1Database,
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
const { results } = await db
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
.bind(platformId)
.all<LinkRow>()
return results
}
/** Every platform identity linked to an account (a player's PC and headset, say). */
export async function getLinksForAccount(
db: D1Database,
accountId: number
): Promise<PlatformLink[]> {
const { results } = await db
.prepare(`${SELECT_LINK} WHERE account_id = ?1 ORDER BY linked_at, platform`)
.bind(accountId)
.all<LinkRow>()
return results
}
/**
* Whether this account is linked to this platform identity the single check the
* `cached_login` grant authorizes on. An account with no link for the presented
* identity cannot be cached-logged-into and must use a password.
*/
export async function isPlatformIdentityLinked(
db: D1Database,
accountId: number,
platform: number,
platformId: string
): Promise<boolean> {
if (platformId === '') return false
const row = await db
.prepare(
`SELECT 1 AS ok FROM platform_account
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
)
.bind(accountId, platform, platformId)
.first<{ ok: number }>()
return row !== null
}
/**
* How many accounts one platform identity already opens the count both signup caps
* and link caps are enforced against, so an identity can't accumulate accounts by
* creating them under the cap and then linking more in.
*/
export async function countAccountsForPlatformIdentity(
db: D1Database,
platform: number,
platformId: string
): Promise<number> {
if (platformId === '') return 0
const row = await db
.prepare(`SELECT COUNT(*) AS n FROM platform_account WHERE platform = ?1 AND platform_id = ?2`)
.bind(platform, platformId)
.first<{ n: number }>()
return row?.n ?? 0
}
+17 -30
View File
@@ -1,21 +1,22 @@
/**
* Refresh-token storage on the shared `recflare` D1 database (owned by the `auth`
* worker, migration 0003). Only a SHA-256 hash of each token is stored never the
* raw value alongside the account + platform needed to re-mint an access token,
* and an absolute expiry. Tokens are single-use: redeeming one deletes it, so a
* fresh token is issued each refresh (rotation) and a replayed token stops working.
* raw value alongside the account it logs in and an absolute expiry. Tokens are
* single-use: redeeming one deletes it, so a fresh token is issued each refresh
* (rotation) and a replayed token stops working.
*
* The platform identity is NOT kept here (dropped in 0006); a refreshed token takes
* it from the account, which is where the bound identity actually lives.
*/
/** Refresh tokens live this long (s) before the client must log in again. */
export const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days
/** Schema DDL (mirror of migrations/0003_refresh_tokens.sql). */
/** Schema DDL (mirror of migrations/0003_refresh_tokens.sql + 0006). */
export const REFRESH_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS refresh_tokens (
token_hash TEXT PRIMARY KEY,
account_id INTEGER NOT NULL,
platform TEXT NOT NULL,
platform_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)`,
@@ -23,13 +24,6 @@ export const REFRESH_SCHEMA_DDL: string[] = [
`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at)`,
]
/** The login context needed to re-mint an access token from a refresh token. */
export interface RefreshContext {
accountId: number
platform: string
platformId: string
}
/** SHA-256 hex of the token. Tokens are high-entropy random, so no salt is needed. */
async function hashToken(token: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))
@@ -37,47 +31,40 @@ async function hashToken(token: string): Promise<string> {
}
/**
* Mint and persist a new refresh token for the given login, returning the raw
* Mint and persist a new refresh token for the given account, returning the raw
* token the only moment it exists in plaintext (only its hash is stored).
*/
export async function issueRefreshToken(db: D1Database, ctx: RefreshContext): Promise<string> {
export async function issueRefreshToken(db: D1Database, accountId: number): Promise<string> {
const token = `${crypto.randomUUID()}`
const now = Math.floor(Date.now() / 1000)
await db
.prepare(
`INSERT INTO refresh_tokens (token_hash, account_id, platform, platform_id, created_at, expires_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`
)
.bind(
await hashToken(token),
ctx.accountId,
ctx.platform,
ctx.platformId,
now,
now + REFRESH_TTL_SECONDS
`INSERT INTO refresh_tokens (token_hash, account_id, created_at, expires_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(await hashToken(token), accountId, now, now + REFRESH_TTL_SECONDS)
.run()
return token
}
/**
* Redeem a refresh token: if it exists and hasn't expired, delete it (single-use
* rotation) and return its login context; otherwise return null. The delete is
* rotation) and return the account it logs in; otherwise return null. The delete is
* atomic (`DELETE ... RETURNING`), so a token can't be redeemed twice a
* concurrent second attempt finds no row. An expired token is deleted and rejected.
*/
export async function consumeRefreshToken(
db: D1Database,
token: string
): Promise<RefreshContext | null> {
): Promise<number | null> {
const now = Math.floor(Date.now() / 1000)
const row = await db
.prepare(
`DELETE FROM refresh_tokens WHERE token_hash = ?1
RETURNING account_id AS accountId, platform, platform_id AS platformId, expires_at AS expiresAt`
RETURNING account_id AS accountId, expires_at AS expiresAt`
)
.bind(await hashToken(token))
.first<{ accountId: number; platform: string; platformId: string; expiresAt: number }>()
.first<{ accountId: number; expiresAt: number }>()
if (!row || row.expiresAt < now) return null
return { accountId: row.accountId, platform: row.platform, platformId: row.platformId }
return row.accountId
}
+579 -56
View File
@@ -4,9 +4,22 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../auth.app'
import { getAccountsByDeviceId, hashPassword, PRESENCE_SCHEMA_DDL, SCHEMA_DDL } from '@repo/domain'
import {
getAccountsByDeviceId,
hashPassword,
PRESENCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import { isLinkedToPlatformIdentity } from '../../auth.app'
import {
getLinksForAccount,
linkPlatformIdentity,
PLATFORM_BACKFILL_SQL,
PLATFORM_SCHEMA_DDL,
} from '../../platform-db'
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
import type { Env } from '../../context'
@@ -24,14 +37,28 @@ const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
// accounts the login tests authenticate as (42, 77).
const LOGIN_PASSWORD = 'correct-horse'
// Meta (Oculus) logins verify their nonce by calling graph.oculus.com authenticated
// as the app, so the tests seed an app secret and stub that call — see metaLogin.
const META_APP_SECRET = 'test-meta-app-secret'
const META_APP_ID = '1232175103309633'
const META_USER_ID = '27061366730207360'
const META_NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
/** Set in beforeAll; needed to overwrite the secret in the not-configured test. */
let metaSecretId: string
// Apply the accounts schema so create_account can persist (mirrors the migration),
// and seed the Orientation room (owned by the rooms worker) so signup can place
// the new player there.
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
// The Meta app secret, likewise — a Meta login is refused outright without one.
metaSecretId = await adminSecretsStore(env.META_APP_SECRET).create(META_APP_SECRET)
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Platform identity links — one account can hold several (a PC and a headset), and
// this table is what both the picker and the cached_login grant read.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -42,22 +69,17 @@ beforeAll(async () => {
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
.run()
}
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
)`
).run()
await env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: 13,
Name: 'Orientation',
IsDorm: false,
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
})
)
.run()
// The rooms worker's schema (room + interaction) — reading a room aggregates its
// cheer/favorite Stats from `interaction`, so both tables have to be here.
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Subrooms live in their own table; seed the Orientation room and split its subroom into it.
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await seedRoomWithSubRooms(env.DB, {
RoomId: 13,
Name: 'Orientation',
IsDorm: false,
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
})
})
/** Decode a JWT payload (no verification) for asserting claims. */
@@ -96,6 +118,51 @@ async function postToken(
return { status: res.status, json: (await res.json()) as Record<string, unknown> }
}
/**
* POST a Meta grant to /connect/token with graph.oculus.com stubbed to answer
* `is_valid`. The worker runs in this isolate, so replacing the global fetch is what
* stands in for Meta `verifyMetaNonce` resolves `globalThis.fetch` per call for
* exactly this reason. Returns the graph requests the worker made alongside the
* response, so a test can assert WHICH user id the nonce was validated against.
*/
async function metaLogin(
body: string,
isValid: boolean
): Promise<{ status: number; json: Record<string, unknown>; graphCalls: URLSearchParams[] }> {
const graphCalls: URLSearchParams[] = []
const realFetch = globalThis.fetch
globalThis.fetch = (async (url: string, init?: { body?: string }) => {
if (url.startsWith('https://graph.oculus.com/')) {
graphCalls.push(new URLSearchParams(init?.body ?? ''))
return Response.json({ is_valid: isValid })
}
return realFetch(url, init)
}) as unknown as typeof fetch
try {
return { ...(await postToken(body)), graphCalls }
} finally {
globalThis.fetch = realFetch
}
}
/** GET a JSON route on the worker and parse the body as `T`. */
async function getJson<T>(path: string): Promise<T> {
const res = await exports.default.fetch(`${ORIGIN}${path}`)
return (await res.json()) as T
}
/** The picker entries a platform identity yields, as the client sees them. */
function cachedLogins(platform: number, id: string) {
return getJson<Array<Record<string, unknown> & { accountId: number; platform: number }>>(
`/cachedlogin/forplatformid/${platform}/${id}`
)
}
/** The `platform_auth` payload a Meta client posts, as observed from a live login. */
function metaPlatformAuth(): string {
return JSON.stringify({ Nonce: META_NONCE, AppId: META_APP_ID, Source: 'logged in user' })
}
/** POST a form-urlencoded body to changepassword with an optional bearer token. */
function changePassword(body: string, token?: string): Promise<Response> {
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
@@ -117,16 +184,42 @@ describe('auth worker routes', () => {
expect(await res.text()).toBe('"AA=="')
})
test('GET /cachedlogin/forplatformid/:platform/:id returns [] (no cached login)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/abc123`)
test.each([
['0 (Steam)', 0],
['1 (Meta)', 1],
])(
'GET /cachedlogin/forplatformid/%s/:id returns [] for an unknown id',
async (_label, platform) => {
const res = await exports.default.fetch(
`${ORIGIN}/cachedlogin/forplatformid/${platform}/abc123`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
}
)
// The one stubbed identity: `1/1` consults nothing and always answers the canned
// entry, which is how a sideloaded APK (no Meta SDK, so no real identity) gets off
// the platform login screen and onto username/password.
test('GET /cachedlogin/forplatformid/1/1 returns the canned Oculus entry', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/1`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
expect(await res.json()).toEqual([
{
platform: 1,
platformId: '1',
accountId: 1,
lastLoginTime: '2026-07-19T17:13:29.225Z',
requirePassword: true,
},
])
})
// Only Steam (platform 0) can be verified (via its signed platform_auth ticket),
// so every OTHER platform is rejected on the platform-authenticated grants — we
// won't bind or authorize an identity we can't prove.
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
// Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth
// ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected
// on the platform-authenticated grants: we won't bind or authorize an identity we
// can't prove.
test.each([2, 3, 4, 5, 6, 7, 8])(
'create_account rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
@@ -134,11 +227,11 @@ describe('auth worker routes', () => {
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('only Steam')
expect(res.json.error_description).toContain('only Steam and Meta')
}
)
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
test.each([2, 3, 4, 5, 6, 7, 8])(
'cached_login rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
@@ -146,7 +239,7 @@ describe('auth worker routes', () => {
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('only Steam')
expect(res.json.error_description).toContain('only Steam and Meta')
}
)
@@ -170,6 +263,109 @@ describe('auth worker routes', () => {
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta create_account requires a platform_auth nonce', async () => {
// platform=1 with no nonce must not bind the spoofable platform_id field.
const res = await postToken(`grant_type=create_account&platform=1&platform_id=${META_USER_ID}`)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta create_account binds the id Meta validated the nonce against', async () => {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}&device_id=meta-device`,
true
)
expect(res.status).toBe(200)
// The nonce was validated against the posted user id, authenticated as the app.
expect(res.graphCalls).toHaveLength(1)
expect(res.graphCalls[0].get('nonce')).toBe(META_NONCE)
expect(res.graphCalls[0].get('user_id')).toBe(META_USER_ID)
expect(res.graphCalls[0].get('access_token')).toBe(`OC|${META_APP_ID}|${META_APP_SECRET}`)
// The account is bound to platform 1 with that id — which is what makes the
// cached-login picker offer it, and the cached_login grant accept it.
const payload = decodePayload(res.json.access_token as string)
const accountId = Number(payload.sub)
const linked = await cachedLogins(1, META_USER_ID)
expect(linked).toContainEqual(
expect.objectContaining({ accountId, platform: 1, platformId: META_USER_ID })
)
// Platform ownership is the credential, so the client is not asked for a password.
expect(linked.every((a) => a.requirePassword === false)).toBe(true)
})
test('Meta create_account is rejected when Meta does not vouch for the nonce', async () => {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
false
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta cached_login logs into the linked account with no password', async () => {
const userId = '27061366730209999'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 5150,
username: 'MetaPlayer',
platform: 1,
platformId: userId,
})
)
.run()
await linkPlatformIdentity(env.DB, 5150, 1, userId)
const res = await metaLogin(
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${userId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(200)
expect(res.graphCalls[0].get('user_id')).toBe(userId)
const payload = decodePayload(res.json.access_token as string)
expect(payload.sub).toBe('5150')
})
test('a Meta user id cannot log into an account it is not linked to', async () => {
// The Meta account seeded above, claimed by a different (but genuinely proven)
// Meta user. Even with a nonce Meta vouches for, the identity has to be one the
// account is actually linked to.
const res = await metaLogin(
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(400)
expect(res.json.error_description).toContain('no linked account')
})
test('a Meta login is refused (500) when META_APP_SECRET is unset', async () => {
// An operator misconfiguration, not a bad credential: without the secret no nonce
// can be validated, and the alternative — trusting the posted platform_id — would
// let anyone log into any Meta-linked account by naming its user id.
const admin = adminSecretsStore(env.META_APP_SECRET)
await admin.update('', metaSecretId)
try {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(500)
expect(res.json.error).toBe('server_error')
// Nothing was asked of Meta, and nothing was trusted.
expect(res.graphCalls).toHaveLength(0)
} finally {
await admin.update(META_APP_SECRET, metaSecretId)
}
})
test('cachedlogin/forplatformid returns the DTO for a bound (Steam) account', async () => {
// Seed a Steam-linked account directly (a real create_account needs a live
// ticket); assert the picker projects the CachedLogin DTO the client expects.
@@ -185,6 +381,7 @@ describe('auth worker routes', () => {
})
)
.run()
await linkPlatformIdentity(env.DB, 31380, 0, steamId)
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
@@ -198,32 +395,69 @@ describe('auth worker routes', () => {
])
})
test('a Steam-linked account with no stored `platform` field still cached-logs in', async () => {
// Regression: nothing defaults an account's `platform` (see defaultAccount), so a
// Steam-linked account can carry a platformId with no platform. The picker offered
// such an account (it treats a missing platform as Steam) while the cached_login
// grant rejected it — "no linked account for this platform identity" forever.
// Both now run the same check.
test('one account, a Steam and a Meta identity: both pickers offer it', async () => {
// The point of the link table. The same account is reachable from the PC and from
// the headset, and each picker reports the identity IT was asked about — that's
// what the client posts back on the cached_login grant.
const steamId = '76561197962463777'
const metaId = '27061366730207777'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 6200,
username: 'CrossPlatform',
platform: 0,
platformId: steamId,
lastLoginTime: '2026-08-01T10:00:00.000Z',
})
)
.run()
await linkPlatformIdentity(env.DB, 6200, 0, steamId)
await linkPlatformIdentity(env.DB, 6200, 1, metaId)
const onSteam = await cachedLogins(0, steamId)
const onMeta = await cachedLogins(1, metaId)
expect(onSteam).toEqual([
expect.objectContaining({ accountId: 6200, platform: 0, platformId: steamId }),
])
expect(onMeta).toEqual([
expect.objectContaining({ accountId: 6200, platform: 1, platformId: metaId }),
])
// And the grant accepts both, without a password.
const viaMeta = await metaLogin(
`grant_type=cached_login&account_id=6200&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(viaMeta.status).toBe(200)
expect(decodePayload(viaMeta.json.access_token as string).sub).toBe('6200')
})
test('the picker and the cached_login grant read the same table', async () => {
// Regression: the picker used to derive links from the account blob (treating a
// missing `platform` as Steam) while the grant ran its own check, so the client
// could be handed an account_id that answered "no linked account" forever. Both
// now read platform_account, which is why an account with a stale blob identity
// is NOT offered — and, since it isn't offered, never rejected either.
const steamId = '76561197962463211'
const account = { platformId: steamId } // no `platform` field
// The grant now accepts it — this is what was returning invalid_grant.
expect(isLinkedToPlatformIdentity(account, 0, steamId)).toBe(true)
// The identity is still the credential: another SteamID, an account with no
// platform identity, and an account bound to a different platform are all refused.
expect(isLinkedToPlatformIdentity(account, 0, '76561197962463299')).toBe(false)
expect(isLinkedToPlatformIdentity({}, 0, steamId)).toBe(false)
expect(isLinkedToPlatformIdentity({ ...account, platform: 3 }, 0, steamId)).toBe(false)
// And the picker offers exactly the accounts the grant accepts.
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId }))
.run()
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
const offered = (await res.json()) as Array<{ accountId: number; platform: number }>
expect(offered.map((a) => a.accountId)).toContain(8)
expect(offered.find((a) => a.accountId === 8)?.platform).toBe(0)
// No link row yet: not offered.
const before = await cachedLogins(0, steamId)
expect(before.map((a) => a.accountId)).not.toContain(8)
// The 0007 backfill is what gives accounts like this one — bound before the link
// table existed, and carrying no `platform` field at all — their link.
await env.DB.prepare(PLATFORM_BACKFILL_SQL).run()
const after = await cachedLogins(0, steamId)
expect(after.map((a) => a.accountId)).toContain(8)
// COALESCEd to Steam, which is what an unset platform meant.
expect(after.find((a) => a.accountId === 8)?.platform).toBe(0)
})
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
@@ -496,9 +730,17 @@ describe('auth worker routes', () => {
})
})
test('POST /connect/token maps the platform int to its enum name', async () => {
const payload = await tokenFor(`account_id=42&platform=0&password=${LOGIN_PASSWORD}`)
expect(payload.platform).toBe('Steam')
test('POST /connect/token carries the platform int on the token', async () => {
const payload = await tokenFor(`account_id=42&platform=5&password=${LOGIN_PASSWORD}`)
expect(payload.platform).toBe(5)
// `rn.plat` is the same int, not a pinned 0.
expect(payload['rn.plat']).toBe(5)
})
test('POST /connect/token defaults the platform claim when none is posted', async () => {
const payload = await tokenFor(`account_id=42&password=${LOGIN_PASSWORD}`)
expect(payload.platform).toBe(0)
expect(payload['rn.plat']).toBe(0)
})
test('POST /connect/token returns a refresh_token that redeems for a new token', async () => {
@@ -514,15 +756,195 @@ describe('auth worker routes', () => {
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
)
expect(refreshed.status).toBe(200)
// A fresh access token for the same account, carrying the stored platform.
const payload = decodePayload(refreshed.json.access_token as string)
expect(payload.sub).toBe('42')
expect(payload.platform).toBe('Steam')
expect(payload.platform_id).toBe('steam-123')
// The platform identity comes off the account, not the refresh token. Account 42
// has none bound (the posted `platform_id` above was never Steam-verified, so it
// was never written), so the refreshed token carries no identity either.
expect(payload.platform).toBe(0)
expect(payload.platform_id).toBe('')
// The refresh token is rotated (single-use), so a new one is returned.
expect(refreshed.json.refresh_token).not.toBe(refreshToken)
})
test('a refreshed token carries the identity bound to the account', async () => {
// A Steam-bound account: only a verified ticket writes `platformId`, so seed it
// directly rather than posting an (unverified) platform_id on the login.
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 43,
username: 'Player43',
passwordHash: await hashPassword(LOGIN_PASSWORD),
platform: 0,
platformId: 'steam-123',
})
)
.run()
const login = await postToken(`account_id=43&password=${LOGIN_PASSWORD}`)
expect(login.status).toBe(200)
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(login.json.refresh_token as string)}`
)
expect(refreshed.status).toBe(200)
const payload = decodePayload(refreshed.json.access_token as string)
expect(payload.sub).toBe('43')
expect(payload.platform).toBe(0)
expect(payload.platform_id).toBe('steam-123')
})
// A password login is how a player who already has an account signs in on a NEW
// device. The client posts its platform proof alongside the password, and linking
// the two is what turns the next launch on that device into a cached login.
describe('password grant links the platform identity it proves', () => {
/** Seed an account with LOGIN_PASSWORD set and no platform identity at all. */
async function seedPasswordAccount(id: number, username: string) {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
username,
passwordHash: await hashPassword(LOGIN_PASSWORD),
})
)
.run()
}
test('a verified Meta login on an existing account links it, and cached login follows', async () => {
// Exactly the client's flow: an account made elsewhere, signed into on a headset
// with username + password, with the Meta nonce riding along.
await seedPasswordAccount(7100, 'djdevin')
const metaId = '27061366730201234'
const login = await metaLogin(
`grant_type=password&username=djdevin&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(login.status).toBe(200)
expect(decodePayload(login.json.access_token as string).sub).toBe('7100')
// The nonce was validated against the id being linked — an unproven id is never
// linked, since a link is a password-free way into the account.
expect(login.graphCalls[0].get('user_id')).toBe(metaId)
// The headset now gets a cached login: offered by the picker…
const offered = await cachedLogins(1, metaId)
expect(offered.map((a) => a.accountId)).toContain(7100)
// …and accepted by the grant, with no password.
const cached = await metaLogin(
`grant_type=cached_login&account_id=7100&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(cached.status).toBe(200)
})
test('the first identity linked becomes the account primary; later ones just link', async () => {
await seedPasswordAccount(7101, 'multiplatform')
const metaId = '27061366730205678'
await metaLogin(
`grant_type=password&username=multiplatform&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
// The blob's primary identity was empty, so the first link fills it in — this is
// what the account DTO and the refresh grant's claims report.
const account = (await env.DB.prepare(
'SELECT data FROM account WHERE account_id = 7101'
).first<{ data: string }>())!
expect(JSON.parse(account.data)).toMatchObject({ platform: 1, platformId: metaId })
// A second identity on another platform links without disturbing the primary.
await linkPlatformIdentity(env.DB, 7101, 0, '76561197962465678')
const links = await getLinksForAccount(env.DB, 7101)
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
[1, metaId],
[0, '76561197962465678'],
])
})
test('an unverified platform_auth logs in but links nothing', async () => {
// The password already proved who this is, so the login stands — but a link is a
// password-free way in, and this identity was never proven, so none is written.
await seedPasswordAccount(7102, 'unproven')
const metaId = '27061366730209876'
const login = await metaLogin(
`grant_type=password&username=unproven&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
false // Meta rejects the nonce
)
expect(login.status).toBe(200)
expect(await getLinksForAccount(env.DB, 7102)).toEqual([])
})
test('a login with no platform_auth links nothing and asks Meta nothing', async () => {
await seedPasswordAccount(7103, 'noproof')
const login = await metaLogin(
`grant_type=password&username=noproof&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=27061366730204321`,
true
)
expect(login.status).toBe(200)
expect(login.graphCalls).toHaveLength(0)
expect(await getLinksForAccount(env.DB, 7103)).toEqual([])
})
test('a sideloaded APK (platform id 1) logs in but is never linked', async () => {
// The sideload placeholder identifies nobody — every sideloaded headset reports
// `1`, so a link on it would be a password-free way into this account from any of
// them. The password login still stands; Meta is never even asked, since there is
// nothing there to validate.
await seedPasswordAccount(7105, 'sideloader')
const login = await metaLogin(
`grant_type=password&username=sideloader&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=1` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true // even with Meta answering yes to everything
)
expect(login.status).toBe(200)
expect(login.graphCalls).toHaveLength(0)
expect(await getLinksForAccount(env.DB, 7105)).toEqual([])
// And so the picker never offers this account off the placeholder — only the
// canned stub entry is there.
expect((await cachedLogins(1, '1')).map((a) => a.accountId)).toEqual([1])
})
test('linking obeys the per-identity account cap, without failing the login', async () => {
// Otherwise the signup cap would be trivially bypassable: create accounts with a
// password, then link the capped identity into all of them.
const metaId = '27061366730203333'
for (let i = 0; i < 3; i++) await linkPlatformIdentity(env.DB, 8000 + i, 1, metaId)
await seedPasswordAccount(8100, 'overcap')
const login = await metaLogin(
`grant_type=password&username=overcap&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
// The password was valid, so the player is logged in — they just don't get a
// cached login on this account.
expect(login.status).toBe(200)
expect(await getLinksForAccount(env.DB, 8100)).toEqual([])
})
test('re-logging in on the same device does not duplicate the link', async () => {
await seedPasswordAccount(7104, 'repeatlogin')
const metaId = '27061366730207654'
const body =
`grant_type=password&username=repeatlogin&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`
await metaLogin(body, true)
await metaLogin(body, true)
expect(await getLinksForAccount(env.DB, 7104)).toHaveLength(1)
})
})
test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => {
const login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`)
const refreshToken = login.json.refresh_token as string
@@ -624,4 +1046,105 @@ describe('auth worker routes', () => {
const res = await exports.default.fetch(`${ORIGIN}/nope`)
expect(res.status).toBe(404)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'GET /cachedlogin/forplatformid/{platform}/{id}',
'GET /eac/challenge',
'GET /role/developer/{id}',
'GET /role/moderator/{id}',
'POST /account/me/changepassword',
'POST /cachedlogin/forplatformids',
'POST /connect/token',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
})
// The website is a browser origin calling these endpoints directly — the same ones the
// game calls — instead of proxying them through `www`. That only works if the responses
// carry CORS headers: without them the browser discards a perfectly good token response
// and sign-in fails with nothing in any server log to explain it.
describe('CORS', () => {
test('answers the preflight the browser sends before a token grant', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/connect/token`, {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com',
'access-control-request-method': 'POST',
'access-control-request-headers': 'content-type',
},
}),
env
)
expect(res.status).toBe(204)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
'content-type'
)
})
// The header has to be on the REAL response too, not just the preflight — and on a
// refusal as much as a success, or a rejected sign-in reaches the page as an opaque
// network error rather than "that password is incorrect".
test('allows the origin on the response itself, refusals included', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: {
origin: 'https://www.example.com',
'content-type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ grant_type: 'password', username: 'nobody' }).toString(),
}),
env
)
expect(res.status).toBe(400)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
})
// The bearer header is what the SPA authenticates with, so it must be allowed by name
// — a preflight that omits it makes every signed-in call fail.
test('allows the Authorization header the SPA signs its calls with', async () => {
const res = await exports.default.fetch(
new Request(`${ORIGIN}/account/me/changepassword`, {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com',
'access-control-request-method': 'POST',
'access-control-request-headers': 'authorization',
},
}),
env
)
expect(res.status).toBe(204)
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
'authorization'
)
})
})
@@ -0,0 +1,155 @@
import { describe, expect, test } from 'vitest'
import { parseMetaPlatformAuth, verifyMetaNonce } from '../../meta-nonce'
// The payload shape a real Meta login posts, captured from a live client. `Source`
// is informational and ignored; the AppId is Rec Room's Meta app.
const NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
const APP_ID = '1232175103309633'
const USER_ID = '27061366730207360'
const PLATFORM_AUTH = JSON.stringify({ Nonce: NONCE, AppId: APP_ID, Source: 'logged in user' })
const APP_SECRET = 'test-app-secret'
/**
* A fetch stub answering with `bodies` (one body, or one per attempt), recording every
* request it was handed. Typed to what `verifyMetaNonce` actually passes a string URL
* and a string body rather than the whole of `fetch`, then cast at the boundary.
*/
function stubFetch(bodies: unknown, status = 200) {
const queue = Array.isArray(bodies) ? [...(bodies as unknown[])] : [bodies]
const calls: Array<{ url: string; form: URLSearchParams }> = []
const fetcher = (async (url: string, init?: { body?: string }) => {
calls.push({ url, form: new URLSearchParams(init?.body ?? '') })
const body = queue.length > 1 ? queue.shift() : queue[0]
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}) as unknown as typeof fetch
return { fetcher, calls }
}
describe('meta-nonce', () => {
test('parses the platform_auth payload the client posts', () => {
expect(parseMetaPlatformAuth(PLATFORM_AUTH)).toEqual({ nonce: NONCE, appId: APP_ID })
})
test.each([
['not json', 'nonsense'],
['no nonce', JSON.stringify({ AppId: APP_ID })],
['empty nonce', JSON.stringify({ Nonce: '', AppId: APP_ID })],
['no app id', JSON.stringify({ Nonce: NONCE })],
// The app id is interpolated into the graph access token, so a non-numeric one
// is refused rather than sent.
['non-numeric app id', JSON.stringify({ Nonce: NONCE, AppId: 'OC|evil' })],
])('rejects a malformed payload (%s)', (_label, payload) => {
expect(parseMetaPlatformAuth(payload)).toBeNull()
})
test('validates the nonce against the posted user id and returns the identity', async () => {
const { fetcher, calls } = stubFetch({ is_valid: true })
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({ ok: true, identity: { userId: USER_ID, appId: APP_ID } })
// The request Meta actually sees: the nonce is bound to THIS user id, and the
// app authenticates itself with `OC|<app id>|<secret>`.
expect(calls).toHaveLength(1)
expect(calls[0].url).toBe('https://graph.oculus.com/user_nonce_validate')
expect(calls[0].form.get('nonce')).toBe(NONCE)
expect(calls[0].form.get('user_id')).toBe(USER_ID)
expect(calls[0].form.get('access_token')).toBe(`OC|${APP_ID}|${APP_SECRET}`)
})
test('rejects a nonce Meta does not vouch for', async () => {
const { fetcher } = stubFetch({ is_valid: false })
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)).toEqual({
ok: false,
reason: 'nonce rejected',
})
})
// The whole point of validating against the posted id: a nonce genuinely issued to
// one user does not authenticate another. Meta answers is_valid:false for the
// mismatch, so nobody can log in by naming someone else's Meta user id.
test('a nonce presented for the wrong user id fails', async () => {
const { fetcher, calls } = stubFetch({ is_valid: false })
const result = await verifyMetaNonce(PLATFORM_AUTH, '99999999999999999', APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls[0].form.get('user_id')).toBe('99999999999999999')
})
test.each([
['missing', ''],
['non-numeric', 'not-an-id'],
])('refuses a %s user id without calling Meta', async (_label, userId) => {
const { fetcher, calls } = stubFetch({ is_valid: true })
const result = await verifyMetaNonce(PLATFORM_AUTH, userId, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls).toHaveLength(0)
})
test('refuses to attempt verification with no app secret', async () => {
const { fetcher, calls } = stubFetch({ is_valid: true })
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, '', fetcher)).toEqual({
ok: false,
reason: 'no app secret configured',
})
expect(calls).toHaveLength(0)
})
test('surfaces a graph error with its code, for the server log', async () => {
const { fetcher } = stubFetch({
error: { code: 100, message: 'Invalid OAuth access token', type: 'OAuthException' },
})
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({
ok: false,
reason: 'graph error 100: Invalid OAuth access token',
})
})
test('a non-retryable graph error is not retried', async () => {
const { fetcher, calls } = stubFetch({ error: { code: 100, message: 'bad token' } })
await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(calls).toHaveLength(1)
})
test('retries a transient graph error and succeeds on a later attempt', async () => {
const { fetcher, calls } = stubFetch([
{ error: { code: 2, message: 'service temporarily unavailable' } },
{ is_valid: true },
])
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(true)
expect(calls).toHaveLength(2)
})
test('gives up after three attempts when Meta stays unavailable', async () => {
const { fetcher, calls } = stubFetch({ error: { code: 1, message: 'unknown error' } })
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls).toHaveLength(3)
})
test('treats a network failure as transient', async () => {
let attempts = 0
const fetcher = (async () => {
attempts++
throw new Error('connection reset')
}) as unknown as typeof fetch
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(attempts).toBe(3)
})
test('treats a non-JSON body (an edge error page) as transient', async () => {
let attempts = 0
const fetcher = (async () => {
attempts++
return new Response('<html>502</html>', { status: 502 })
}) as unknown as typeof fetch
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({ ok: false, reason: 'HTTP 502 with a non-JSON body' })
expect(attempts).toBe(3)
})
})
+12
View File
@@ -22,11 +22,23 @@
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
//
// META_APP_SECRET is the Meta (Oculus) app secret, bound only by this worker: Meta
// logins are verified by asking Meta to validate the login nonce, which requires
// authenticating as the app (see src/meta-nonce.ts). Both secrets must EXIST in the
// store or the deploy fails an operator with no Meta app still has to create
// META_APP_SECRET (any placeholder will do); Meta logins then fail with a 500 until
// it holds the real value, and nothing else is affected. See DEPLOYING.md.
"secrets_store_secrets": [
{
"binding": "JWT_SECRET",
"store_id": "local",
"secret_name": "JWT_SECRET"
},
{
"binding": "META_APP_SECRET",
"store_id": "local",
"secret_name": "META_APP_SECRET"
}
],
"upload_source_maps": true,
+29 -1
View File
@@ -1,6 +1,34 @@
# cdn
A Cloudflare Workers application using Hono
CDN Worker served on the `cdn` subdomain (`cdn.recflare.net`) — a Hono app that streams
the binary blobs the client downloads while playing out of the shared `recflare-cdn` R2
bucket, plus the one bundled config file the loading screen reads.
Objects are keyed by prefix — `sigs/` (anti-cheat signatures), `room/` (saved room
scenes, and room images by their bare `ImageName`), `invention/` (invention data) — and
served as `application/octet-stream`; the worker never interprets what it hands back.
Reads are unauthenticated: a caller needs the exact key, which only comes from an
authenticated call to another worker.
This worker only reads. Uploads go through `storage`, which writes the same bucket, and
images are served by `img`.
## API documentation
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
alongside each handler, with the schemas in `src/openapi.ts`. It's also aggregated into
the docs page www serves at `/docs`.
**The spec is descriptive, not enforced** — same rationale as the `img`/`auth` workers: a
reverse-engineered protocol, lenient handlers, no runtime validation. A test asserts
every route appears in the spec, so adding one without documenting it fails.
## Conditional and range requests
Every asset route honours `If-None-Match` (→ 304) and a single `Range` (→ 206). The range
support is not an optimization: large-file downloaders fetch in chunks, and answering 200
where a 206 is expected corrupts the reassembled file — which surfaces as an anti-cheat
"Signatures don't match" failure rather than a download error.
## Development
+6 -1
View File
@@ -17,8 +17,13 @@
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+161 -18
View File
@@ -1,17 +1,26 @@
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 loadingScreenTipData from '../static/loading-screen-tip-data.json'
import {
assetResponses,
CONDITIONAL_HEADERS,
json,
keyParam,
LoadingScreenTip,
ServiceStatus,
} from './openapi'
import type { Context } from 'hono'
import type { App, Env } from './context'
/**
* CDN routes. The `cdn` prefix maps to this worker's subdomain, so method routes
* are served bare. File-backed routes (`sigs`, `upload`) have no storage binding
* yet and are stubbed.
* are served bare. Everything but the liveness probe and the bundled tip data is
* streamed out of the shared `recflare-cdn` R2 bucket, keyed by prefix.
*/
/** Parse a single-range `Range: bytes=start-end` header into an R2 range. */
@@ -60,16 +69,16 @@ async function serveAsset(c: Context<App>, key: string) {
// Range honored → 206 Partial Content with Content-Range.
if (object.range && c.req.header('range')) {
const r = object.range
let offset: number
let length: number
if ('suffix' in r) {
length = r.suffix
offset = object.size - length
} else {
offset = r.offset ?? 0
length = r.length ?? object.size - offset
}
// 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}`)
return new Response(object.body, { status: 206, headers })
@@ -92,25 +101,159 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
.get('/', (c) => c.json({ service: 'cdn', status: 'ok' }))
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Service liveness',
description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.',
responses: { 200: json(ServiceStatus, 'Always `{ service: "cdn", status: "ok" }`') },
}),
(c) => c.json({ service: 'cdn', status: 'ok' })
)
// Loading-screen tips, bundled here as static JSON.
.get('/config/LoadingScreenTipData', (c) => c.json(loadingScreenTipData))
.get(
'/config/LoadingScreenTipData',
describeRoute({
tags: ['Config'],
summary: 'Loading-screen tips',
description: [
'The tips the client cycles through on a loading screen. A bundled static file',
'(`static/loading-screen-tip-data.json`), captured from the real service and served',
'verbatim — nothing here is editable at runtime, and every client gets the same list',
'regardless of platform or room. The per-tip `Context`/`Visibility`/`PlatformMask`',
'fields are the clients own filters, applied client-side.',
].join(' '),
responses: { 200: json(LoadingScreenTip.array(), 'The bundled tips') },
}),
(c) => c.json(loadingScreenTipData)
)
// Signature blobs by name. Streamed from R2 under the `sigs/` key prefix;
// 404 when missing.
.get('/sigs/:sigName', (c) => serveAsset(c, `sigs/${c.req.param('sigName')}`))
.get(
'/sigs/:sigName',
describeRoute({
tags: ['Assets'],
summary: 'Serve a signature blob',
description: [
'Streams the object stored under `sigs/<sigName>`. These are the anti-cheat signature',
'blobs the client fetches at startup; nothing here inspects or validates them.',
].join(' '),
parameters: [keyParam('sigName', 'The blob name.', false), ...CONDITIONAL_HEADERS],
responses: assetResponses('The signature blob'),
}),
(c) => serveAsset(c, `sigs/${c.req.param('sigName')}`)
)
// Room build data by name. The client fetches this for a SubRoom's DataBlob to
// load the room. Streamed from R2 under `room/`. The name may contain slashes
// (uploads are foldered by date, e.g. `2026-02-03/<uuid>`), so match the rest of
// the path.
.get('/room/:dataBlob{.+}', (c) => serveAsset(c, `room/${c.req.param('dataBlob')}`))
.get(
'/room/:dataBlob{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve room build data',
description: [
'Streams the object stored under `room/<dataBlob>` — the saved scene the client',
'downloads to load a room. The name comes from a subrooms `DataBlob` (see the `rooms`',
'worker) and is date-foldered by the upload, e.g. `2026-02-03/<uuid>`, so it contains',
'slashes.',
'',
'A rooms IMAGE also lives under this prefix, stored by its bare `ImageName` — the',
'same route serves both.',
].join('\n'),
parameters: [keyParam('dataBlob', 'The blob name.', true), ...CONDITIONAL_HEADERS],
responses: assetResponses('The room data'),
}),
(c) => serveAsset(c, `room/${c.req.param('dataBlob')}`)
)
// Invention data by name. The client fetches this for an invention's
// `CurrentVersion.BlobName` to spawn it. Streamed from R2 under `invention/`.
// Like room blobs the name is date-foldered, and it carries the `.inv` extension
// the upload stored it under, so the rest of the path is matched as-is.
.get('/invention/:dataBlob{.+}', (c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`))
.get(
'/invention/:dataBlob{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve invention data',
description: [
'Streams the object stored under `invention/<dataBlob>` — the data the client',
'downloads to spawn an invention. The name comes from an inventions',
'`CurrentVersion.BlobName` (see the `api` worker); like room blobs it is date-foldered,',
'and it keeps the `.inv` extension the upload stored it under.',
].join(' '),
parameters: [
keyParam('dataBlob', 'The blob name, including `.inv`.', true),
...CONDITIONAL_HEADERS,
],
responses: assetResponses('The invention data'),
}),
(c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`)
)
// Generic client data by name. Anything the client uploads as FileType 2 lands
// under `data/` (a Holotar recording is the one seen in the wild) and the client
// fetches it back from this prefix. Date-foldered like the room and invention
// blobs, so the rest of the path is matched as-is.
.get(
'/data/:id{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve a client data blob',
description: [
'Streams the object stored under `data/<id>` — whatever the client uploaded as',
'`UploadFileType` 2 (see the `storage` worker), a Holotar recording being the case',
'observed. Like room and invention blobs the name is date-foldered by the upload,',
'e.g. `2026-02-03/<uuid>`, so it contains slashes. The worker does not interpret the',
'bytes — the prefix exists because the client expects to read these back from `/data/`.',
].join(' '),
parameters: [keyParam('id', 'The blob name.', true), ...CONDITIONAL_HEADERS],
responses: assetResponses('The data blob'),
}),
(c) => serveAsset(c, `data/${c.req.param('id')}`)
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare cdn',
version: '1.0.0',
description: [
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
'signatures, saved room scenes, invention data and generic client uploads — out of',
'the shared `recflare-cdn` R2 bucket, plus the one bundled config file the loading',
'screen reads.',
'',
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
'are unauthenticated — a caller needs the exact key, which only comes from an',
'authenticated call to another worker.',
'',
'This worker only READS. Uploads go through the `storage` worker, which writes the',
'same bucket, and images are served by `img` rather than from here.',
'',
'Every asset route supports conditional GETs (`If-None-Match` → 304) and single',
'byte ranges (`Range` → 206). The ranges matter: large-file downloaders fetch in',
'chunks, and answering 200 where a 206 is expected corrupts the reassembled file —',
'which surfaces as an anti-cheat “Signatures dont match” failure, not a download',
'error.',
].join('\n'),
},
servers: [{ url: 'https://cdn.recflare.net', description: 'Production' }],
},
})
)
)
export default app
+114
View File
@@ -0,0 +1,114 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the cdn worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/img workers: a reverse-engineered protocol, lenient
* handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*
* Most of this worker's surface is opaque BYTES, not JSON, so those responses are
* described with a binary content type rather than a zod schema (the same way the `img`
* worker describes image bytes).
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
/**
* A binary asset response. Everything streamed out of the bucket is served as
* `application/octet-stream` regardless of what it actually is the client downloads
* these blobs, it never sniffs their type.
*/
export function assetBytes(description: string): OpenAPIV3_1.ResponseObject {
return {
description,
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
}
}
/** The shared responses of every asset route: the byte-serving ones plus its failures. */
export function assetResponses(description: string): OpenAPIV3_1.ResponsesObject {
return {
200: assetBytes(description),
206: assetBytes('A byte range, when the request carried a `Range` header'),
304: { description: '`If-None-Match` matched the stored etag (no body)' },
400: { description: 'The key contains `..` (no body)' },
404: { description: 'No such object in the bucket' },
}
}
/** The `Range` / `If-None-Match` headers every asset route honours. */
export const CONDITIONAL_HEADERS: OpenAPIV3_1.ParameterObject[] = [
{
name: 'Range',
in: 'header',
required: false,
description:
'A single byte range (`bytes=start-end`, `bytes=start-`, `bytes=-suffix`). Honoured with a 206; a malformed or multi-range value is ignored and the whole object served.',
schema: { type: 'string', example: 'bytes=0-1023' },
},
{
name: 'If-None-Match',
in: 'header',
required: false,
description: 'The etag of a previously fetched copy; a match answers 304 with no body.',
schema: { type: 'string' },
},
]
/** A path parameter naming an object in the bucket. */
export function keyParam(
name: string,
description: string,
slashes: boolean
): OpenAPIV3_1.ParameterObject {
return {
name,
in: 'path',
required: true,
description: slashes ? `${description} May contain slashes.` : description,
schema: { type: 'string' },
}
}
// ---- Response schemas ------------------------------------------------------
/** `GET /` — the liveness probe body. */
export const ServiceStatus = z.object({
service: z.literal('cdn'),
status: z.literal('ok'),
})
/**
* One loading-screen tip. `Context`/`InputType`/`Visibility` are client-side enums that
* decide where a tip may appear, and `PlatformMask` is a bit field of the platforms it
* shows on every tip in the bundled set is left at whatever the 2019 capture had.
*/
export const LoadingScreenTip = z.object({
Name: z.string().describe('A GUID (no dashes) — the tips id, not a display name'),
Title: z.string(),
Message: z.string(),
RoomNames: z
.array(z.string())
.describe('Rooms to restrict the tip to; empty everywhere in the bundled set'),
Context: z.int(),
InputType: z.int(),
Visibility: z.int(),
AllowCycling: z.boolean(),
RestrictToNewUsers: z.boolean(),
ImageName: z.string().describe('An image key the client resolves against the img worker'),
PlatformMask: z.int().describe('Bit field of the platforms the tip shows on'),
CreatedAt: z.string(),
})
+75
View File
@@ -52,6 +52,27 @@ describe('cdn endpoints', () => {
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([12, 13, 14]))
})
// The other two Range forms. Both resolve to a concrete offset/length inside R2, so
// they exercise the same Content-Range math as the closed range above — which read
// every range as a suffix range and emitted `bytes NaN-NaN/6` until it was fixed.
test('GET /sigs/:sigName honors open-ended and suffix Range requests', async () => {
await env.CDN_ASSETS.put('sigs/ranged2', new Uint8Array([10, 11, 12, 13, 14, 15]))
const fetchRange = (range: string) =>
exports.default.fetch(`${ORIGIN}/sigs/ranged2`, { headers: { Range: range } })
// `bytes=4-` — from an offset to the end.
const open = await fetchRange('bytes=4-')
expect(open.status).toBe(206)
expect(open.headers.get('content-range')).toBe('bytes 4-5/6')
expect(new Uint8Array(await open.arrayBuffer())).toEqual(new Uint8Array([14, 15]))
// `bytes=-2` — the last N bytes.
const suffix = await fetchRange('bytes=-2')
expect(suffix.status).toBe(206)
expect(suffix.headers.get('content-range')).toBe('bytes 4-5/6')
expect(new Uint8Array(await suffix.arrayBuffer())).toEqual(new Uint8Array([14, 15]))
})
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`)
@@ -79,4 +100,58 @@ describe('cdn endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/invention/missing.inv`)
expect(res.status).toBe(404)
})
test('GET /data/:id streams the data blob from R2', async () => {
// Date-foldered — the name the storage worker generates for a FileType 2 upload.
const name = '2026-08-05/3b9c1f0a-5d2e-4c1b-9a77-2e6f0b4d8c31'
await env.CDN_ASSETS.put(`data/${name}`, new Uint8Array([4, 5, 6]))
const res = await exports.default.fetch(`${ORIGIN}/data/${name}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('application/octet-stream')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([4, 5, 6]))
})
test('GET /data/:id 404s when the blob is absent', async () => {
const res = await exports.default.fetch(`${ORIGIN}/data/missing`)
expect(res.status).toBe(404)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'GET /',
'GET /config/LoadingScreenTipData',
'GET /data/{id}',
'GET /invention/{dataBlob}',
'GET /room/{dataBlob}',
'GET /sigs/{sigName}',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
// Schemas must inline: a `$ref` here is a dangling reference (see openapi.ts).
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
})
+22
View File
@@ -0,0 +1,22 @@
-- Chat messages, owned by the `chat` worker. One row per message posted to a thread;
-- columns rather than a JSON blob (mirroring the reference model), since every field is a
-- scalar the server reads: threads are listed newest-first by (chat_thread_id,
-- chat_message_id), and `time_sent` is carried for display only.
--
-- `chat_message_id` is server-assigned (AUTOINCREMENT) so ids are unique across every
-- thread, matching the client's expectation of a global message id. `contents` is the
-- client's envelope — `{"Type":0,"Version":1,"Data":"..."}` — stored verbatim as an
-- opaque string and served back untouched, so new message types need no schema change.
-- `moderation_state` is the ChatModerationState enum (0 = none). Kept in sync with
-- SCHEMA_DDL in src/message-db.ts.
CREATE TABLE IF NOT EXISTS message (
chat_message_id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_thread_id INTEGER NOT NULL,
sender_player_id INTEGER NOT NULL,
time_sent TEXT NOT NULL,
contents TEXT NOT NULL,
moderation_state INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_message_thread ON message (chat_thread_id, chat_message_id);
CREATE INDEX IF NOT EXISTS idx_message_sender ON message (sender_player_id);
+37
View File
@@ -0,0 +1,37 @@
-- Chat threads and their membership, owned by the `chat` worker.
--
-- A thread is a conversation — a DM pair, a group chat, or a system thread. Membership
-- in `thread_member` is the authorization gate: a player may read or post to a thread
-- only if they hold a row here, and the same rows render the `playerIds` array the
-- client shows. There is deliberately no FK to accounts, here or on
-- `message.sender_player_id`: that table belongs to the `auth` worker, and a thread
-- outlives the accounts in it.
--
-- `latest_message_id` is denormalized onto the thread so the thread list renders from
-- one indexed row per thread instead of a per-thread MAX() over `message`; it also
-- orders that list (message ids being monotonic, newest thread = highest id). Kept in
-- sync on every insert — see touchThread in src/thread-db.ts.
--
-- The per-viewer fields live on the membership row, not the thread: two players in one
-- DM have independent read positions, snoozes, and favorites. Kept in sync with
-- THREAD_SCHEMA_DDL in src/thread-db.ts.
CREATE TABLE IF NOT EXISTS message_thread (
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Null for DMs and unnamed groups; the client falls back to rendering the members.
chat_thread_name TEXT,
latest_message_id INTEGER,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_message_thread_latest ON message_thread (latest_message_id);
CREATE TABLE IF NOT EXISTS thread_member (
chat_thread_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
last_read_message_id INTEGER,
snoozed_until TEXT,
is_favorited INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (chat_thread_id, player_id)
);
-- The thread-list query is "every thread this player is in", so player_id leads.
CREATE INDEX IF NOT EXISTS idx_thread_member_player ON thread_member (player_id);
+8 -1
View File
@@ -12,12 +12,19 @@
"deploy": "run-wrangler-deploy",
"dev": "run-wrangler-dev",
"fix:workers-types": "run-wrangler-types",
"migrate": "run-wrangler-migrate",
"test": "run-vitest"
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+818 -4
View File
@@ -1,9 +1,313 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { NotificationType } from '../../notify/src/notification-types'
import { getThreadMessages } from './message-db'
import {
AUTHED,
ChatMessageDto,
ChatResult,
ChatThreadDto,
ChatThreadWithMessagesDto,
CreateThreadRequest,
CreateThreadResponse,
FavoriteThreadRequest,
form,
json,
messageCountParam,
NOT_A_MEMBER_RESPONSE,
RenameThreadRequest,
SendMessageRequest,
SendMessageResponse,
ServiceStatus,
SnoozeThreadRequest,
THREAD_ID_PARAM,
UNAUTHORIZED_RESPONSE,
WithMembersRequest,
} from './openapi'
import {
addThreadMember,
getOrCreateThreadWithMembers,
getThreadForPlayer,
getThreadMemberIds,
getThreadsForPlayer,
isThreadMember,
leftChatContents,
markThreadRead,
postMessage,
removeThreadMember,
setThreadFavorited,
setThreadName,
setThreadSnoozed,
SYSTEM_SENDER_ID,
} from './thread-db'
import type { Context } from 'hono'
import type { App } from './context'
import type { ChatMessage } from './message-db'
/**
* Resolve the account id from a Bearer token. Returns `null` when the header is
* missing, the token is invalid, or the `sub` claim isn't an integer.
*/
async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* How many items a `MessageCount` query param asks for. The client sends 16; anything
* missing, unparseable, or out of range falls back to the default rather than 400ing,
* and the cap keeps a hand-written request from pulling a whole thread history.
*/
const DEFAULT_MESSAGE_COUNT = 16
const MAX_MESSAGE_COUNT = 100
/** What the client asks for when opening a thread (`messageCount=50`). */
const DEFAULT_THREAD_MESSAGE_COUNT = 50
function messageCount(c: Context<App>, fallback = DEFAULT_MESSAGE_COUNT): number {
// The GET routes spell it `MessageCount` in the query; the POST forms spell it
// `messageCount` in the body. Accept either, wherever it turns up.
const raw = Number.parseInt(c.req.query('MessageCount') ?? c.req.query('messageCount') ?? '', 10)
if (Number.isNaN(raw) || raw <= 0) return fallback
return Math.min(raw, MAX_MESSAGE_COUNT)
}
/** The page size a POST form asks for, which may also arrive in the body. */
async function formMessageCount(c: Context<App>, fallback: number): Promise<number> {
const raw = Number.parseInt((await formField(c, 'messageCount')) ?? '', 10)
if (Number.isNaN(raw) || raw <= 0) return messageCount(c, fallback)
return Math.min(raw, MAX_MESSAGE_COUNT)
}
/**
* What a chat action reports back to the client alongside its payload the reference's
* ChatResult. Only success and "bad arguments" are reachable here.
*/
const CHAT_SUCCESS = 0
const CHAT_INVALID_ARGUMENTS = 1
const CHAT_MEMBERSHIP_NOT_FOUND = 3
const CHAT_PLAYER_ALREADY_ON_THREAD = 4
/** The hub is a single global Durable Object instance, as every worker addresses it. */
const HUB_INSTANCE = 'global'
/**
* Push ChatMessageReceived to everyone in the thread once a message lands, so the
* conversation updates live instead of on the next poll.
*
* The sender is notified too, deliberately: the client doesn't fold the HTTP response
* into its local thread cache, so without a self-targeted push its own outgoing message
* doesn't appear until the thread is refetched.
*
* Best-effort a hub failure is logged and swallowed, since the message has already
* committed and the client will still see it on the next fetch.
*/
async function pushChatMessage(c: Context<App>, message: ChatMessage): Promise<void> {
try {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
const members = await getThreadMemberIds(c.env.DB, message.chatThreadId)
await Promise.all(
members.map((playerId) =>
hub.notifyPlayer(playerId, NotificationType.ChatMessageReceived, { ...message })
)
)
} catch (err) {
logger.error('failed to push ChatMessageReceived notification', {
chatThreadId: message.chatThreadId,
chatMessageId: message.chatMessageId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Send a message to a thread that already exists every message after the one that
* opened the conversation. `/thread/18` is what the client posts; `/thread/18/message` is
* the same call under the reference's other spelling, so both routes land here.
*
* Answers `{chatResult, chatThread}` the whole thread with its messages, not just the
* message that was sent, so the client re-renders the conversation from one response.
* Blank or missing contents stores nothing and reports invalid-arguments, still with the
* thread attached, rather than an error status.
*/
async function sendToThread(c: Context<App>) {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id') ?? '', 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
// Stored exactly as sent: the envelope carries its own Type/Version and may hold
// fields we know nothing about (the client sends Version 2 with a `<=>` prefix in
// Data, and a `Blocks` array alongside it), so nothing here parses or rewrites it.
const contents = (await formField(c, 'messageContents'))?.trim()
const posted =
contents === undefined || contents === ''
? null
: await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents })
if (posted !== null) {
await pushChatMessage(c, posted)
// Sending is reading: the reference answers with `lastReadMessageId` already at the
// message just posted, so the sender's own thread doesn't come back unread.
await markThreadRead(c.env.DB, chatThreadId, id, posted.chatMessageId)
}
const thread = await threadWithMessages(c, chatThreadId, id, DEFAULT_THREAD_MESSAGE_COUNT)
return c.json({
chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS,
chatThread: thread,
})
}
/**
* Move the caller's read pointer on a thread, to `chatMessageId` or (undefined) to the
* thread's latest message. Answers the bare ChatResult integer the reference sends.
*/
async function markRead(c: Context<App>, chatMessageId?: number) {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
// Every route reaching here constrains `:id` to digits, so the parse can't fail.
const chatThreadId = Number.parseInt(c.req.param('id') ?? '', 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
await markThreadRead(c.env.DB, chatThreadId, id, chatMessageId)
return c.json(CHAT_SUCCESS)
}
/**
* A thread rendered for opening a conversation: the thread's own fields plus a page of
* its messages, newest first. `latestMessage` gives way to the full page the client is
* sent one or the other, never both and `messages` is always present, empty for a
* thread with nothing in it yet.
*
* Null when the caller isn't a member (or the thread doesn't exist); membership is the
* gate, so the two cases are indistinguishable from outside.
*/
async function threadWithMessages(
c: Context<App>,
chatThreadId: number,
playerId: number,
limit: number
) {
const thread = await getThreadForPlayer(c.env.DB, chatThreadId, playerId)
if (thread === null) return null
const messages = await getThreadMessages(c.env.DB, chatThreadId, { limit })
const { latestMessage: _latest, ...rest } = thread
return { ...rest, messages }
}
/** Ceiling on a new thread's roster, counting the caller. */
const MAX_THREAD_MEMBERS = 50
/** Longest a thread name may be; anything beyond is truncated, not rejected. */
const MAX_THREAD_NAME_LENGTH = 128
/**
* What `snooze=True` stores in `snoozedUntil`. The client sends a boolean but reads back
* an instant, so "snoozed" is expressed as a time far enough out to mean indefinitely.
*/
const SNOOZED_INDEFINITELY = '9999-12-31T23:59:59Z'
/**
* The repeated `ids` fields naming a new thread's members (`ids=2&ids=155`). The client
* sends them as a urlencoded body, but they're read from the query string too, since
* the same call is easy to hand-write that way. Values that aren't integers are dropped.
*/
async function memberIds(c: Context<App>): Promise<number[]> {
const raw = [...(c.req.queries('ids') ?? [])]
const form = await c.req.formData().catch(() => null)
if (form !== null) raw.push(...form.getAll('ids').map(String))
return raw.map((value) => Number.parseInt(value, 10)).filter((id) => Number.isInteger(id))
}
/** A form boolean as the client spells it (`True`/`False`), tolerant of the variants. */
async function formBool(c: Context<App>, name: string): Promise<boolean> {
const value = (await formField(c, name))?.trim().toLowerCase()
return value === 'true' || value === '1' || value === 'yes'
}
/** A single form field, or the query param of the same name. Hono caches the body, so
* this is safe to call alongside `memberIds`. */
async function formField(c: Context<App>, name: string): Promise<string | undefined> {
const form = await c.req.formData().catch(() => null)
const value = form?.get(name)
return typeof value === 'string' ? value : c.req.query(name)
}
/**
* A concise `describeRoute` spec for one of the thread-scoped actions that answers the
* bare ChatResult integer rather than an HTTP status rename, leave, snooze, favorite,
* add-member and the read-pointer moves. They share the auth gate, the `:id` path param,
* and the "3 when the caller isn't on the thread" behaviour.
*/
function chatResultRoute(
summary: string,
description: string,
extra: {
requestBody?: ReturnType<typeof form>
parameters?: unknown[]
successDescription?: string
/** Set for the read-pointer routes, which 404 a non-member instead of answering 3. */
notFound?: boolean
} = {}
) {
return describeRoute({
tags: ['Chat'],
summary,
description,
security: AUTHED,
parameters: [THREAD_ID_PARAM, ...((extra.parameters ?? []) as never[])],
...(extra.requestBody === undefined ? {} : { requestBody: extra.requestBody }),
responses: {
200: json(
ChatResult,
extra.successDescription ??
'The ChatResult (0 on success, 3 when the caller isnt on the thread)'
),
401: UNAUTHORIZED_RESPONSE,
...(extra.notFound === true ? { 404: NOT_A_MEMBER_RESPONSE } : {}),
},
})
}
/**
* The `describeRoute` spec shared by the two spellings of "send to an existing thread".
* `/thread/{id}` is what the client posts; `/thread/{id}/message` is the same call under
* the reference's other spelling, and both land in `sendToThread`.
*/
function sendToThreadRoute(spelling: string) {
return describeRoute({
tags: ['Messages'],
summary: `Send a message to an existing thread (${spelling})`,
description: [
'Every message after the one that opened the conversation. Answers',
'`{ chatResult, chatThread }` — the WHOLE thread with its messages, not just the message',
'that was sent, so the client re-renders the conversation from one response. Blank or',
'missing `messageContents` stores nothing and reports invalid-arguments (1), still with',
'the thread attached, rather than an error status. Sending is reading: the senders own',
'`lastReadMessageId` comes back already at the message just posted. Pushes',
'ChatMessageReceived to every member, the sender included — the client doesnt fold the',
'HTTP response into its local cache, so without a self-targeted push its own outgoing',
'message doesnt appear until the thread is refetched. Note the hub frames `Id` is a',
'STRING: the client dispatches on it and silently drops a numeric one.',
].join(' '),
security: AUTHED,
parameters: [THREAD_ID_PARAM],
requestBody: form(SendMessageRequest, 'The message envelope'),
responses: {
200: json(SendMessageResponse, 'The ChatResult plus the whole thread with its messages'),
401: UNAUTHORIZED_RESPONSE,
404: NOT_A_MEMBER_RESPONSE,
},
})
}
const app = new Hono<App>()
.use(
@@ -19,9 +323,519 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
.get('/', (c) => c.json({ service: 'chat', status: 'ok' }))
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Service liveness',
description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.',
responses: { 200: json(ServiceStatus, 'Always `{ service: "chat", status: "ok" }`') },
}),
(c) => c.json({ service: 'chat', status: 'ok' })
)
// Chat threads. No DB binding yet — returns `[]`.
.get('/thread', (c) => c.json([]))
// The player's own thread list, newest conversation first — each thread carrying its
// latest message and the caller's own read/snooze/favorite state. `MessageCount` is
// the page size (of threads, despite the name). Membership scopes the query, so a
// player only ever sees their own threads.
.get(
'/thread',
describeRoute({
tags: ['Threads'],
summary: 'The callers thread list',
description: [
'Every thread the caller is a member of, newest conversation first — each carrying its',
'`latestMessage` and the callers own read/snooze/favorite state. `MessageCount` is the',
'page size (of THREADS, despite the name). Membership scopes the query, so a player',
'only ever sees their own threads.',
].join(' '),
security: AUTHED,
parameters: [messageCountParam(DEFAULT_MESSAGE_COUNT)],
responses: {
200: json(ChatThreadDto.array(), 'The callers threads, newest first (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
return c.json(await getThreadsForPlayer(c.env.DB, id, { limit: messageCount(c) }))
}
)
// Send to a set of players (`ids=155&ids=2&messageContents=…`) — the client's
// create-thread-and-post-first-message call, in one. Resolves to the thread those
// players already share rather than opening a second one.
//
// `messageContents` is the same envelope a message carries
// (`{"Type":0,"Version":1,"Data":"…"}`) and is stored verbatim, unparsed. The client
// also sends it blank, right after /thread/withmembers: that opens the thread without
// posting an empty message, and reports invalid-arguments the way the reference does.
.post(
'/thread',
describeRoute({
tags: ['Threads'],
summary: 'Open a thread with a set of players and post the first message',
description: [
'The clients create-thread-and-post-first-message call, in one. Resolves to the thread',
'those players already share rather than opening a second one. `messageContents` is the',
'same envelope a message carries and is stored verbatim, unparsed; the client also sends',
'it blank right after `/thread/withmembers`, which opens the thread without posting and',
'reports invalid-arguments. Answers a `{ chatThread, chatResult }` wrapper, not a bare',
'thread. Pushes ChatMessageReceived to every member (including the sender).',
].join(' '),
security: AUTHED,
requestBody: form(CreateThreadRequest, 'The member ids and the first message'),
responses: {
200: json(CreateThreadResponse, 'The thread plus the result of the first message'),
400: {
description: [
'Fewer than 2 members (naming only yourself) or more than 50, counting the caller',
'(empty body)',
].join(' '),
},
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const members = [...new Set([id, ...(await memberIds(c))])]
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
const contents = (await formField(c, 'messageContents'))?.trim()
const posted =
contents === undefined || contents === ''
? null
: await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents })
if (posted !== null) {
await pushChatMessage(c, posted)
await markThreadRead(c.env.DB, chatThreadId, id, posted.chatMessageId)
}
const thread = await getThreadForPlayer(c.env.DB, chatThreadId, id)
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
// The reference answers a wrapper here, not a bare thread.
return c.json({
chatThread: thread,
chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS,
})
}
)
// "Open the chat with these people" — the client's GetChatBetweenPlayers. Fetch or
// create: the thread whose membership is exactly `ids` plus the caller, opened only
// if they don't already share one. Returning a fresh empty thread each call would
// bury the real conversation and hand the client a thread with no messages.
//
// Answers the thread with a `messages` array (what `messageCount` sizes) rather than
// the list's single `latestMessage`, so the client can open straight into the
// conversation. The array is always present, empty for a brand-new thread.
.post(
'/thread/withmembers',
describeRoute({
tags: ['Threads'],
summary: 'Fetch or open the thread with exactly these members',
description: [
'The clients GetChatBetweenPlayers. Fetch-or-create: the thread whose membership is',
'exactly `ids` plus the caller, opened only if they dont already share one (returning a',
'fresh empty thread each call would bury the real conversation). Answers the thread with',
'a `messages` array — what `messageCount` sizes — rather than the lists single',
'`latestMessage`, so the client can open straight into the conversation. The array is',
'always present, empty for a brand-new thread.',
].join(' '),
security: AUTHED,
requestBody: form(WithMembersRequest, 'The member ids and the page size'),
responses: {
200: json(ChatThreadWithMessagesDto, 'The thread with a page of its messages'),
400: {
description: [
'Fewer than 2 members (naming only yourself) or more than 50, counting the caller',
'(empty body)',
].join(' '),
},
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const members = [...new Set([id, ...(await memberIds(c))])]
// A thread needs someone else in it; naming only yourself is a bad request
// rather than a lonely thread.
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
const limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
const thread = await threadWithMessages(c, chatThreadId, id, limit)
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
return c.json(thread)
}
)
// A page of one thread's messages, newest first — a bare array, not a thread object.
// The client reads a conversation through either spelling: `/thread/2?messageCount=50`
// and `/thread/2/message?MessageCount=16` answer the same thing, so they share a
// handler; only the default page size differs, matching what each caller sends.
//
// 404 rather than 403 for a thread the caller isn't in: whether a thread exists is
// itself private, so a non-member gets the same answer as for a thread that's gone.
// An empty thread is still a 200 with `[]` — a conversation just opened with someone
// has no messages yet and still has to open.
// One thread with its recent messages — what the client opens a conversation with
// (`/thread/13?messageCount=50`). An OBJECT, the same shape /thread/withmembers
// answers: the client parses this one as a thread and rejects a bare array
// ("expected '{', actual '['"). Only /thread/:id/message below serves an array.
//
// 404s only for a thread the caller isn't in, not for one that's simply empty: a
// thread just opened with someone has no messages yet and still has to open.
.get(
'/thread/:id{[0-9]+}',
describeRoute({
tags: ['Threads'],
summary: 'One thread with its recent messages',
description: [
'What the client opens a conversation with (`/thread/13?messageCount=50`). An OBJECT —',
'the same shape `/thread/withmembers` answers: the client parses this one as a thread',
"and rejects a bare array (\"expected '{', actual '['\"). Only `/thread/{id}/message`",
'serves an array. 404s only for a thread the caller isnt in, not for one thats simply',
'empty — a thread just opened with someone has no messages yet and still has to open.',
].join(' '),
security: AUTHED,
parameters: [THREAD_ID_PARAM, messageCountParam(DEFAULT_THREAD_MESSAGE_COUNT)],
responses: {
200: json(ChatThreadWithMessagesDto, 'The thread with a page of its messages'),
401: UNAUTHORIZED_RESPONSE,
404: NOT_A_MEMBER_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
const limit = messageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
const thread = await threadWithMessages(c, chatThreadId, id, limit)
return thread === null ? c.notFound() : c.json(thread)
}
)
// Send a message to a thread that already exists — every message after the one that
// opened the conversation. `/thread/18` is what the client posts; `/thread/18/message`
// is the same call under the reference's other spelling.
//
// Answers the SendMessageResponse wrapper (`{chatMessage, chatResult}`), not a bare
// message. Blank or missing contents is invalid-arguments with no message attached,
// rather than an error status.
.post('/thread/:id{[0-9]+}', sendToThreadRoute('`/thread/{id}`'), (c) => sendToThread(c))
.post('/thread/:id{[0-9]+}/message', sendToThreadRoute('`/thread/{id}/message`'), (c) =>
sendToThread(c)
)
// Rename a thread (`name=my chat`). Any member may rename — there's no owner — and an
// empty name clears it back to unnamed, which renders as the member list. Answers a
// bare ChatResult: 3 when the caller isn't on the thread, 0 on success.
.on(
['POST', 'PUT'],
'/thread/:id{[0-9]+}/rename',
chatResultRoute(
'Rename a thread',
[
'Any member may rename — there is no owner — and an empty name clears it back to unnamed,',
'which renders as the member list. The name is truncated to 128 characters rather than',
'rejected. Answers a bare ChatResult: 3 when the caller isnt on the thread, 0 on success.',
].join(' '),
{ requestBody: form(RenameThreadRequest, 'The new name') }
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
const name = ((await formField(c, 'name')) ?? '').trim().slice(0, MAX_THREAD_NAME_LENGTH)
await setThreadName(c.env.DB, chatThreadId, name)
return c.json(CHAT_SUCCESS)
}
)
// Leave a thread. The thread and its history survive — only the caller's membership
// goes, so they stop seeing it and the remaining members keep the conversation.
//
// A "Player <@U…> left" notice is posted first, so the others see why the roster
// changed; the leaver is still a member at that moment and gets the push too, which
// is what tells their client the thread is gone.
.on(
['POST', 'DELETE'],
'/thread/:id{[0-9]+}/leave',
chatResultRoute(
'Leave a thread',
[
'The thread and its history survive — only the callers membership goes, so they stop',
'seeing it and the remaining members keep the conversation. A "Player <@U…> left" system',
'notice is posted first so the others see why the roster changed; the leaver is still a',
'member at that moment and gets the push too, which is what tells their client the thread',
'is gone.',
].join(' ')
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
const notice = await postMessage(c.env.DB, {
chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
contents: leftChatContents(id),
})
await pushChatMessage(c, notice)
await removeThreadMember(c.env.DB, chatThreadId, id)
return c.json(CHAT_SUCCESS)
}
)
// Snooze or unsnooze a thread (`snooze=True`), for the caller alone — snoozing is a
// per-member setting, so it never affects what anyone else sees.
//
// The client sends a boolean while the field it reads back is `snoozedUntil`, a time.
// `True` is therefore stored as a far-future instant meaning "muted indefinitely", and
// `False` clears it. If the real server instead snoozes for a fixed window, this is
// the one line to change.
.on(
['POST', 'PUT'],
'/thread/:id{[0-9]+}/snooze',
chatResultRoute(
'Snooze or unsnooze a thread',
[
'Per-member, for the caller alone — it never affects what anyone else sees. The client',
'sends a boolean while the field it reads back (`snoozedUntil`) is a time, so `True` is',
'stored as a far-future instant (9999-12-31T23:59:59Z) meaning "muted indefinitely" and',
'`False` clears it.',
].join(' '),
{ requestBody: form(SnoozeThreadRequest, 'The snooze flag') }
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
const on = await formBool(c, 'snooze')
await setThreadSnoozed(c.env.DB, chatThreadId, id, on ? SNOOZED_INDEFINITELY : null)
return c.json(CHAT_SUCCESS)
}
)
// Favorite or unfavorite a thread (`favorite=True`), for the caller alone — like
// snoozing, it's a per-member flag that pins the thread in their own inbox.
.on(
['PUT', 'POST'],
'/thread/:id{[0-9]+}/favorite',
chatResultRoute(
'Favorite or unfavorite a thread',
[
'Like snoozing, a per-member flag that pins the thread in the callers own inbox and',
'leaves everyone elses untouched.',
].join(' '),
{ requestBody: form(FavoriteThreadRequest, 'The favorite flag') }
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
await setThreadFavorited(c.env.DB, chatThreadId, id, await formBool(c, 'favorite'))
return c.json(CHAT_SUCCESS)
}
)
// Add a player to a thread (`/thread/20/member/2`). Gated on the caller already being
// in it — you can only pull someone into a conversation you're part of.
//
// Answers a bare ChatResult rather than an HTTP status, as the reference does: 3 when
// the caller isn't a member (which doubles as "no such thread", keeping a thread's
// existence private), 4 when the target is already on it, 0 on success. Idempotent —
// re-adding an existing member changes nothing.
.post(
'/thread/:id{[0-9]+}/member/:playerId{[0-9]+}',
chatResultRoute(
'Add a player to a thread',
[
'Gated on the caller already being in it — you can only pull someone into a conversation',
'youre part of. Answers a bare ChatResult rather than an HTTP status, as the reference',
'does: 3 when the caller isnt a member (which doubles as "no such thread", keeping a',
'threads existence private), 4 when the target is already on it, 0 on success.',
'Idempotent — re-adding an existing member changes nothing.',
].join(' '),
{
parameters: [
{
name: 'playerId',
in: 'path',
required: true,
description: 'The account id to add (digits only)',
schema: { type: 'string' },
},
],
successDescription: '0 success · 3 caller not a member · 4 target already on the thread',
}
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
const playerId = Number.parseInt(c.req.param('playerId'), 10)
if (await isThreadMember(c.env.DB, chatThreadId, playerId)) {
return c.json(CHAT_PLAYER_ALREADY_ON_THREAD)
}
await addThreadMember(c.env.DB, chatThreadId, playerId)
return c.json(CHAT_SUCCESS)
}
)
// Move the caller's read pointer — `/thread/15/read` for the whole thread, or
// `/thread/15/message/:messageId/read` for a specific message, which the client uses
// when the view sits on a message rather than the bottom. Both verbs, as the client
// sends either. Answers the bare ChatResult integer the reference does.
//
// The pointer only moves forward, and never past the thread's real latest message: an
// id the client made up (or one it read from a synthetic message) can't strand the
// thread as permanently read.
.on(
['PUT', 'POST'],
'/thread/:id{[0-9]+}/read',
chatResultRoute(
'Mark a whole thread read',
[
'Moves the callers read pointer to the threads latest message. The pointer only moves',
'forward and never past the threads real latest message, so an id the client made up',
'cant strand the thread as permanently read. 404s for a thread the caller isnt on.',
].join(' '),
{ successDescription: 'Always 0 (success)', notFound: true }
),
(c) => markRead(c)
)
.on(
['PUT', 'POST'],
'/thread/:id{[0-9]+}/message/:messageId{[0-9]+}/read',
chatResultRoute(
'Mark read up to a specific message',
[
'What the client sends when the view sits on a message rather than the bottom. Same',
'forward-only, clamped pointer as the whole-thread form. 404s for a thread the caller',
'isnt on.',
].join(' '),
{
parameters: [
{
name: 'messageId',
in: 'path',
required: true,
description: 'The message to read up to (digits only)',
schema: { type: 'string' },
},
],
successDescription: 'Always 0 (success)',
notFound: true,
}
),
(c) => markRead(c, Number.parseInt(c.req.param('messageId'), 10))
)
// A page of one thread's messages, newest first — a bare array, unlike /thread/:id.
// `MessageCount` is the page size. 404 rather than 403 for a thread the caller isn't
// in: whether a thread exists is itself private, so a non-member gets the same answer
// as for a thread that's gone.
.get(
'/thread/:id{[0-9]+}/message',
describeRoute({
tags: ['Messages'],
summary: 'A page of one threads messages',
description: [
'Newest first — a bare ARRAY, unlike `/thread/{id}`, which serves the thread object.',
'`MessageCount` is the page size. 404 rather than 403 for a thread the caller isnt in:',
'whether a thread exists is itself private, so a non-member gets the same answer as for a',
'thread thats gone. An empty thread is still a 200 with `[]`.',
].join(' '),
security: AUTHED,
parameters: [THREAD_ID_PARAM, messageCountParam(DEFAULT_MESSAGE_COUNT)],
responses: {
200: json(ChatMessageDto.array(), 'The page of messages, newest first (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
404: NOT_A_MEMBER_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
return c.json(await getThreadMessages(c.env.DB, chatThreadId, { limit: messageCount(c) }))
}
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare chat',
version: '1.0.0',
description: [
'Chat threads and messages for recflare, a private-server reimplementation of the Rec',
'Room backend. A thread is a conversation — a DM pair, a named group, or a system',
'thread — and membership is both the authorization gate and the `playerIds` the client',
'renders. Threads, membership and messages are D1-backed; every message also fans out',
'over the `notify` hub Durable Object as a ChatMessageReceived frame, so a conversation',
'updates live instead of on the next poll. (The hub frame carries a STRING `Id` — the',
'client dispatches on it and silently drops a numeric one.)',
].join('\n'),
},
servers: [{ url: 'https://chat.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+11 -1
View File
@@ -1,8 +1,18 @@
import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
// Type-only import (erased at build) of the DO class owned by the `notify` worker,
// so this worker can push websocket notifications through its RPC surface.
import type { NotificationsHub } from '../../notify/src/notifications-hub'
export type Env = SharedHonoEnv & {
// add additional Bindings here
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
// signed by `auth` verify here.
JWT_SECRET: SecretsStoreSecret
/** Shared `recflare` D1 — this worker owns the `message` and thread tables. */
DB: D1Database
/** The `notify` worker's NotificationsHub DO — pushes ChatMessageReceived to members. */
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
}
/** Variables can be extended */
+150
View File
@@ -0,0 +1,150 @@
/**
* Chat messages on the shared `recflare` D1 database the individual messages posted
* to a chat thread (a DM pair or a group). Stored as columns rather than a JSON blob
* (mirroring the reference model): every field is a scalar the server itself reads, and the
* one client-shaped part `contents` is already an opaque string.
*
* `contents` is the client's envelope, e.g.
* `{"Type":0,"Version":1,"Data":"This is jordanparki7 from your Oculus friends."}`,
* where `Type` selects how the client renders `Data` (plain text, an invite, an image
* ) and `Version` versions that encoding. It is stored verbatim and served back
* untouched, so new message types need no schema change here.
*
* `chatMessageId` is server-assigned and unique across all threads (AUTOINCREMENT), the
* way the client expects to be able to reference a message by id alone.
*
* The `chat` worker owns this schema/migration (migrations/0001_message.sql, applied
* under its own `migrations_table` so it doesn't clash with the other workers'
* migrations that share the database). `SCHEMA_DDL` mirrors that migration so tests can
* build the table directly.
*/
/** Schema DDL (mirror of migrations/0001_message.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS message (
chat_message_id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_thread_id INTEGER NOT NULL,
sender_player_id INTEGER NOT NULL,
time_sent TEXT NOT NULL,
contents TEXT NOT NULL,
moderation_state INTEGER NOT NULL DEFAULT 0
)`,
// Thread listing is always (thread, id) — newest-first pages walk this index.
`CREATE INDEX IF NOT EXISTS idx_message_thread ON message (chat_thread_id, chat_message_id)`,
`CREATE INDEX IF NOT EXISTS idx_message_sender ON message (sender_player_id)`,
]
/**
* Whether a message has been touched by moderation. `None` is the overwhelmingly common
* case and the column default; the others let a message be withheld from the thread
* without deleting the row.
*/
export enum ChatModerationState {
None = 0,
Flagged = 1,
Hidden = 2,
}
/** A chat message, in the shape the client sends and receives it. */
export interface ChatMessage {
chatMessageId: number
chatThreadId: number
senderPlayerId: number
/** ISO-8601 UTC instant, as .NET serializes `DateTime` (e.g. `2022-05-22T12:47:03.6536656`). */
timeSent: string
/** The raw message envelope, e.g. `{"Type":0,"Version":1,"Data":"hello"}`. */
contents: string
moderationState: ChatModerationState
}
/** A new message, before the server assigns its id and (by default) its timestamp. */
export interface NewChatMessage {
chatThreadId: number
senderPlayerId: number
contents: string
/** Defaults to now. Pass only when replaying a message with its original timestamp. */
timeSent?: string
moderationState?: ChatModerationState
}
/** The stored row, before it's mapped back to the client's camelCase shape. */
interface MessageRow {
chat_message_id: number
chat_thread_id: number
sender_player_id: number
time_sent: string
contents: string
moderation_state: number
}
function toMessage(row: MessageRow): ChatMessage {
return {
chatMessageId: row.chat_message_id,
chatThreadId: row.chat_thread_id,
senderPlayerId: row.sender_player_id,
timeSent: row.time_sent,
contents: row.contents,
moderationState: row.moderation_state,
}
}
/** Post a message to a thread, returning it with its server-assigned id. */
export async function insertMessage(db: D1Database, message: NewChatMessage): Promise<ChatMessage> {
const row = await db
.prepare(
`INSERT INTO message (chat_thread_id, sender_player_id, time_sent, contents, moderation_state)
VALUES (?1, ?2, ?3, ?4, ?5)
RETURNING *`
)
.bind(
message.chatThreadId,
message.senderPlayerId,
message.timeSent ?? new Date().toISOString(),
message.contents,
message.moderationState ?? ChatModerationState.None
)
.first<MessageRow>()
// RETURNING on an INSERT that ran always yields the row; a null here means the
// insert itself failed, which D1 would already have thrown for.
if (row === null) throw new Error('failed to insert chat message')
return toMessage(row)
}
/**
* A page of a thread's messages, newest first. `before` pages backwards through the
* history: pass the `chatMessageId` of the oldest message you already have.
*/
export async function getThreadMessages(
db: D1Database,
chatThreadId: number,
{ limit = 50, before }: { limit?: number; before?: number } = {}
): Promise<ChatMessage[]> {
const { results } = before
? await db
.prepare(
`SELECT * FROM message WHERE chat_thread_id = ?1 AND chat_message_id < ?2
ORDER BY chat_message_id DESC LIMIT ?3`
)
.bind(chatThreadId, before, limit)
.all<MessageRow>()
: await db
.prepare(
`SELECT * FROM message WHERE chat_thread_id = ?1
ORDER BY chat_message_id DESC LIMIT ?2`
)
.bind(chatThreadId, limit)
.all<MessageRow>()
return results.map(toMessage)
}
/** A single message by id, or null if there's no such message. */
export async function getMessage(
db: D1Database,
chatMessageId: number
): Promise<ChatMessage | null> {
const row = await db
.prepare('SELECT * FROM message WHERE chat_message_id = ?1')
.bind(chatMessageId)
.first<MessageRow>()
return row === null ? null : toMessage(row)
}
+234
View File
@@ -0,0 +1,234 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the chat worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/econ/match workers: a reverse-engineered protocol,
* lenient handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** The empty-body 401 the auth-gated routes return. */
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/**
* The 404 a thread-scoped route answers when the caller isn't a member. Deliberately
* indistinguishable from "no such thread" whether a thread exists is itself private.
*/
export const NOT_A_MEMBER_RESPONSE = {
description: 'Not a member of the thread (or no such thread) — the two are indistinguishable',
}
// ---- Response schemas ------------------------------------------------------
/**
* A chat message as stored and served (see message-db.ts). `contents` is the client's own
* envelope (`{"Type":0,"Version":1,"Data":"hello"}`) stored verbatim and served back
* untouched, so new message types need no schema change. A `senderPlayerId` of -5 is the
* system pseudo-player the "started a chat" / "left" notices are posted as.
*/
export const ChatMessageDto = z.object({
chatMessageId: z.int().describe('Server-assigned, unique across all threads'),
chatThreadId: z.int(),
senderPlayerId: z.int().describe('-5 is the system sender (join/leave notices)'),
timeSent: z.string().describe('ISO-8601 UTC instant, as .NET serializes DateTime'),
contents: z.string().describe('The raw client envelope, e.g. {"Type":0,"Version":1,"Data":"hi"}'),
moderationState: z.int().describe('0 None, 1 Flagged, 2 Hidden'),
})
/** The per-viewer fields every rendered thread carries, plus the thread's own. */
const threadBase = {
chatThreadId: z.int(),
playerIds: z.array(z.int()).describe('The threads members, ordered by id'),
lastReadMessageId: z
.int()
.describe('0 when never read — never null (the client deserializes a non-nullable int)'),
chatThreadName: z
.string()
.describe('Empty for DMs and unnamed groups — never null (the client dereferences it)'),
chatThreadType: z.int().describe('Always 0 — the only type the reference serves'),
snoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
isFavorited: z.boolean(),
}
/**
* A thread as it appears in the thread LIST: the thread, its members, the caller's own
* read/snooze/favorite state, and its single most recent message.
*/
export const ChatThreadDto = z.object({
latestMessage: ChatMessageDto.nullable().describe('Null only for a thread with no messages yet'),
...threadBase,
})
/**
* A thread as it appears when a conversation is OPENED: the same fields, but with a page
* of `messages` (newest first) in place of `latestMessage`. The client is sent one or the
* other, never both; `messages` is always present, empty for a brand-new thread.
*/
export const ChatThreadWithMessagesDto = z.object({
...threadBase,
messages: z.array(ChatMessageDto).describe('Newest first; empty for a thread with nothing in it'),
})
/**
* The bare ChatResult integer several actions answer with (HTTP 200 either way): 0
* success, 1 invalid arguments, 3 membership not found (which doubles as "no such
* thread"), 4 player already on the thread.
*/
export const ChatResult = z
.int()
.describe('0 success · 1 invalid arguments · 3 membership not found · 4 already on thread')
/**
* `POST /thread` the reference's wrapper: the created (or resolved) thread plus the
* result of the first message. Blank `messageContents` opens the thread without posting
* and reports invalid-arguments (1), still with the thread attached.
*/
export const CreateThreadResponse = z.object({
chatThread: ChatThreadDto,
chatResult: ChatResult,
})
/**
* `POST /thread/:id` and `/thread/:id/message` the whole thread with its messages, not
* just the message that was sent, so the client re-renders the conversation from one
* response.
*/
export const SendMessageResponse = z.object({
chatResult: ChatResult,
chatThread: ChatThreadWithMessagesDto.nullable(),
})
/** `GET /` — the liveness probe. */
export const ServiceStatus = z.object({
service: z.literal('chat'),
status: z.literal('ok'),
})
// ---- Request schemas -------------------------------------------------------
/**
* `POST /thread` form body. `ids` is repeated (`ids=2&ids=155`) and names the OTHER
* members; the caller is always added. Values that aren't integers are dropped. The
* fields are also read from the query string, since the same call is easy to hand-write
* that way.
*/
export const CreateThreadRequest = z.object({
ids: z.array(z.int()).describe('Repeated: ids=2&ids=155. The caller is added automatically'),
messageContents: z
.string()
.optional()
.describe(
[
'The client envelope, stored verbatim and unparsed. Blank/absent opens the thread',
'without posting a message and reports chatResult 1',
].join(' ')
),
})
/**
* `POST /thread/withmembers` form body the client's GetChatBetweenPlayers. Same
* repeated `ids`, plus the page size for the returned `messages`.
*/
export const WithMembersRequest = z.object({
ids: z.array(z.int()).describe('Repeated: ids=2&ids=155. The caller is added automatically'),
messageCount: z
.int()
.optional()
.describe('Page size for `messages`; defaults to 50, capped at 100'),
})
/** `POST /thread/:id` (and `/thread/:id/message`) form body. */
export const SendMessageRequest = z.object({
messageContents: z
.string()
.describe(
[
'The client envelope (Type/Version/Data), stored verbatim. Blank or missing stores',
'nothing and reports chatResult 1, still with the thread attached',
].join(' ')
),
messageCount: z.int().optional().describe('Page size for the returned threads `messages`'),
})
/** `POST|PUT /thread/:id/rename` form body. Any member may rename; there is no owner. */
export const RenameThreadRequest = z.object({
name: z
.string()
.describe('Truncated to 128 chars, not rejected. Empty clears it back to unnamed'),
})
/** `POST|PUT /thread/:id/snooze` form body. */
export const SnoozeThreadRequest = z.object({
snooze: z
.string()
.describe('`True`/`False` as the client spells it (`1`/`yes` also count as true)'),
})
/** `PUT|POST /thread/:id/favorite` form body. */
export const FavoriteThreadRequest = z.object({
favorite: z
.string()
.describe('`True`/`False` as the client spells it (`1`/`yes` also count as true)'),
})
// ---- Shared parameters -----------------------------------------------------
/** The numeric `:id` path segment naming a thread (constrained to digits by the route). */
export const THREAD_ID_PARAM = {
name: 'id',
in: 'path',
required: true,
description: 'Chat thread id (digits only — a non-numeric path matches no route)',
schema: { type: 'string' },
} as const
/** The `MessageCount` / `messageCount` query param the GET routes accept. */
export function messageCountParam(fallback: number) {
return {
name: 'MessageCount',
in: 'query',
required: false,
description: `Page size; defaults to ${fallback}, capped at 100. \`messageCount\` is accepted too. Anything unparseable or out of range falls back rather than 400ing`,
schema: { type: 'integer' },
} as const
}
+1315 -4
View File
@@ -1,10 +1,75 @@
import { SELF } from 'cloudflare:test'
import { describe, expect, it } from 'vitest'
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
import '../../chat.app'
import { NotificationType } from '../../../../notify/src/notification-types'
import {
ChatModerationState,
getMessage,
getThreadMessages,
insertMessage,
SCHEMA_DDL,
} from '../../message-db'
import {
createThread,
findThreadWithMembers,
getThreadForPlayer,
getThreadsForPlayer,
isThreadMember,
leftChatContents,
markThreadRead,
postMessage,
removeThreadMember,
setThreadFavorited,
startedChatContents,
SYSTEM_SENDER_ID,
THREAD_SCHEMA_DDL,
} from '../../thread-db'
import type { Env } from '../../context'
import type { ChatMessage } from '../../message-db'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
// Mint a token the way the `auth` worker does, signing with the shared test key seeded
// into the JWT_SECRET store.
const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub: number): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub: String(sub), exp: now + 3600 })
)}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of THREAD_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
describe('chat endpoints', () => {
it('GET / reports service status', async () => {
const res = await SELF.fetch(`${ORIGIN}/`)
@@ -12,9 +77,1255 @@ describe('chat endpoints', () => {
expect(await res.json()).toEqual({ service: 'chat', status: 'ok' })
})
it('GET /thread returns an empty array', async () => {
it('GET /thread 401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread`)
expect(res.status).toBe(401)
})
it('GET /thread serves the caller their own threads', async () => {
const player = 881001
const thread = await createThread(env.DB, [player, 881002])
const latest = await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 881002,
timeSent: '2022-02-21T18:08:56.0362822',
contents: '{"Type":0,"Version":1,"Data":"hi"}',
})
const res = await SELF.fetch(`${ORIGIN}/thread?MessageCount=16&Mode=0`, {
headers: await bearer(player),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
expect(await res.json()).toEqual([
{
latestMessage: latest,
chatThreadId: thread,
playerIds: [player, 881002],
lastReadMessageId: 0,
chatThreadName: '',
chatThreadType: 0,
snoozedUntil: null,
isFavorited: false,
},
])
})
it('GET /thread/:id/message serves the thread newest first', async () => {
const player = 881003
const thread = await createThread(env.DB, [player, 881004])
const older = await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 881004,
timeSent: '2022-02-19T22:13:56.7224503',
contents: '{"Type":0,"Version":1,"Data":"on discord?"}',
})
const newer = await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 881004,
timeSent: '2022-02-21T18:08:56.0362822',
contents: '{"Type":0,"Version":1,"Data":"hi"}',
})
const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message?MessageCount=16&Mode=0`, {
headers: await bearer(player),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([newer, older])
})
it('GET /thread/:id/message honours MessageCount', async () => {
const player = 881005
const thread = await createThread(env.DB, [player])
for (const data of ['one', 'two', 'three']) {
await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: player,
contents: JSON.stringify({ Type: 0, Version: 1, Data: data }),
})
}
const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message?MessageCount=2`, {
headers: await bearer(player),
})
expect(await res.json()).toHaveLength(2)
})
// A thread's existence is itself private, so a non-member gets the same 404 as for a
// thread that never existed — not a 403 that confirms it's there.
it('GET /thread/:id/message hides threads the caller is not in', async () => {
const thread = await createThread(env.DB, [881006, 881007])
const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message`, {
headers: await bearer(881008),
})
expect(res.status).toBe(404)
const missing = await SELF.fetch(`${ORIGIN}/thread/999999/message`, {
headers: await bearer(881008),
})
expect(missing.status).toBe(404)
})
})
describe('message storage', () => {
// A real message as the client sends it, kept verbatim (including the JSON-in-a-string
// `contents` envelope) so the round-trip is tested against the actual payload shape.
const CONTENTS =
'{"Type":0,"Version":1,"Data":"This is jordanparki7 from your Oculus friends. We\'re friends in Rec Room now!"}'
it('round-trips a message, assigning an id', async () => {
const stored = await insertMessage(env.DB, {
chatThreadId: 116181128,
senderPlayerId: 10441985,
timeSent: '2022-05-22T12:47:03.6536656',
contents: CONTENTS,
})
expect(stored.chatMessageId).toBeGreaterThan(0)
expect(stored).toEqual({
chatMessageId: stored.chatMessageId,
chatThreadId: 116181128,
senderPlayerId: 10441985,
timeSent: '2022-05-22T12:47:03.6536656',
contents: CONTENTS,
moderationState: ChatModerationState.None,
})
expect(await getMessage(env.DB, stored.chatMessageId)).toEqual(stored)
})
it('defaults timeSent to now', async () => {
const stored = await insertMessage(env.DB, {
chatThreadId: 999,
senderPlayerId: 42,
contents: CONTENTS,
})
expect(Date.parse(stored.timeSent)).toBeGreaterThan(Date.now() - 60_000)
})
it('lists a thread newest first and pages backwards', async () => {
const thread = 116181129
const first = await insertMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 1,
contents: CONTENTS,
})
const second = await insertMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 2,
contents: CONTENTS,
})
const page = await getThreadMessages(env.DB, thread)
expect(page.map((m) => m.chatMessageId)).toEqual([second.chatMessageId, first.chatMessageId])
const older = await getThreadMessages(env.DB, thread, { before: second.chatMessageId })
expect(older.map((m) => m.chatMessageId)).toEqual([first.chatMessageId])
// Messages from other threads never leak into a thread's listing.
expect(await getThreadMessages(env.DB, 404)).toEqual([])
})
})
describe('thread storage', () => {
// The viewing player from the captured thread-list response.
const VIEWER = 10441985
function contents(data: string): string {
return JSON.stringify({ Type: 0, Version: 1, Data: data })
}
it('renders the thread list in the shape the client expects', async () => {
const dm = await createThread(env.DB, [9489959, VIEWER])
const latest = await postMessage(env.DB, {
chatThreadId: dm,
senderPlayerId: VIEWER,
timeSent: '2022-05-22T12:47:03.6536656',
contents: contents(
"This is jordanparki7 from your Oculus friends. We're friends in Rec Room now!"
),
})
await markThreadRead(env.DB, dm, VIEWER, latest.chatMessageId)
const [thread] = await getThreadsForPlayer(env.DB, VIEWER)
expect(thread).toEqual({
latestMessage: latest,
chatThreadId: dm,
playerIds: [9489959, VIEWER],
lastReadMessageId: latest.chatMessageId,
chatThreadName: '',
chatThreadType: 0,
snoozedUntil: null,
isFavorited: false,
})
})
it('keeps a named group thread with all its members', async () => {
const members = [VIEWER, 10452682, 12534039, 12535328, 12631702]
const group = await createThread(env.DB, members, 'Group Chat =]')
await postMessage(env.DB, {
chatThreadId: group,
senderPlayerId: VIEWER,
contents: contents('sussy baka'),
})
const thread = await getThreadForPlayer(env.DB, group, VIEWER)
expect(thread?.chatThreadName).toBe('Group Chat =]')
expect(thread?.playerIds).toEqual(members.sort((a, b) => a - b))
})
// System notices ("Player <@U…> started a chat") and player messages both carry
// markup the server must not touch — the mention token, and HTML entities the
// client escaped itself. Stored and served back byte-for-byte.
it('stores message contents verbatim, markup and all', async () => {
const thread = await createThread(env.DB, [VIEWER, 29565301])
const notice = await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 29565301,
contents: contents('Player <@U29565301> started a chat'),
})
const escaped = await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 29563053,
contents: contents('Ly2 bae &lt;&lt;&lt;333'),
})
expect(await getMessage(env.DB, notice.chatMessageId)).toEqual(notice)
expect((await getMessage(env.DB, escaped.chatMessageId))?.contents).toBe(escaped.contents)
})
it('orders threads newest first and honours the page size', async () => {
const viewer = 777001
const older = await createThread(env.DB, [viewer, 1])
const newer = await createThread(env.DB, [viewer, 2])
await postMessage(env.DB, {
chatThreadId: older,
senderPlayerId: viewer,
contents: contents('first'),
})
await postMessage(env.DB, {
chatThreadId: newer,
senderPlayerId: viewer,
contents: contents('second'),
})
const threads = await getThreadsForPlayer(env.DB, viewer)
expect(threads.map((t) => t.chatThreadId)).toEqual([newer, older])
expect(await getThreadsForPlayer(env.DB, viewer, { limit: 1 })).toHaveLength(1)
})
it('gates reads on membership', async () => {
const thread = await createThread(env.DB, [777002, 777003])
expect(await isThreadMember(env.DB, thread, 777002)).toBe(true)
expect(await isThreadMember(env.DB, thread, 777004)).toBe(false)
// A non-member sees neither the thread nor its place in their own list.
expect(await getThreadForPlayer(env.DB, thread, 777004)).toBeNull()
expect(await getThreadsForPlayer(env.DB, 777004)).toEqual([])
})
it('keeps read state, favorites, and snoozes per viewer', async () => {
const a = 777005
const b = 777006
const thread = await createThread(env.DB, [a, b])
const first = await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: a,
contents: contents('one'),
})
const second = await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: b,
contents: contents('two'),
})
await markThreadRead(env.DB, thread, a, second.chatMessageId)
await setThreadFavorited(env.DB, thread, a, true)
await markThreadRead(env.DB, thread, b, first.chatMessageId)
const forA = await getThreadForPlayer(env.DB, thread, a)
const forB = await getThreadForPlayer(env.DB, thread, b)
expect(forA?.lastReadMessageId).toBe(second.chatMessageId)
expect(forA?.isFavorited).toBe(true)
expect(forB?.lastReadMessageId).toBe(first.chatMessageId)
expect(forB?.isFavorited).toBe(false)
// A late ack from a second client can't walk the thread back to unread.
await markThreadRead(env.DB, thread, a, first.chatMessageId)
expect((await getThreadForPlayer(env.DB, thread, a))?.lastReadMessageId).toBe(
second.chatMessageId
)
})
it('leaves an empty thread with no latest message', async () => {
const thread = await createThread(env.DB, [777007])
expect(await getThreadForPlayer(env.DB, thread, 777007)).toMatchObject({
latestMessage: null,
lastReadMessageId: 0,
playerIds: [777007],
})
})
it('drops a removed member from the roster but keeps the thread', async () => {
const thread = await createThread(env.DB, [777008, 777009])
await postMessage(env.DB, {
chatThreadId: thread,
senderPlayerId: 777008,
contents: contents('mellon'),
})
await removeThreadMember(env.DB, thread, 777009)
expect(await getThreadForPlayer(env.DB, thread, 777009)).toBeNull()
expect((await getThreadForPlayer(env.DB, thread, 777008))?.playerIds).toEqual([777008])
})
})
describe('POST /thread/withmembers', () => {
async function withMembers(caller: number, body: string) {
return SELF.fetch(`${ORIGIN}/thread/withmembers`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
it('opens a thread with the named players plus the caller', async () => {
const caller = 882001
const res = await withMembers(caller, 'ids=2&ids=155&messageCount=50')
expect(res.status).toBe(200)
const thread = (await res.json()) as {
chatThreadId: number
playerIds: number[]
messages: unknown[]
}
// A page of messages, not the list's single latestMessage. A brand-new thread
// isn't empty: it opens with the system "started a chat" notice.
expect(thread.messages).toEqual([
expect.objectContaining({
senderPlayerId: SYSTEM_SENDER_ID,
contents: startedChatContents(caller),
}),
])
expect(thread).toMatchObject({
playerIds: [2, 155, caller],
lastReadMessageId: 0,
chatThreadName: '',
snoozedUntil: null,
isFavorited: false,
})
expect(thread).not.toHaveProperty('latestMessage')
// The thread is real: it shows up in the caller's list, and its members can read it.
expect((await getThreadsForPlayer(env.DB, caller)).map((t) => t.chatThreadId)).toContain(
thread.chatThreadId
)
expect(await isThreadMember(env.DB, thread.chatThreadId, 155)).toBe(true)
})
// Two nulls GetChatBetweenPlayers can't survive: lastReadMessageId deserializes into
// a non-nullable int ("expected 'Number Token', actual 'null'"), and chatThreadName
// is dereferenced unchecked (NullReferenceException). Unread is 0, unnamed is ''.
it('never serializes lastReadMessageId or chatThreadName as null', async () => {
const res = await withMembers(882008, 'ids=153')
const body = await res.text()
expect(body).not.toContain('"lastReadMessageId":null')
expect(body).not.toContain('"chatThreadName":null')
const thread = JSON.parse(body) as { lastReadMessageId: number; chatThreadName: string }
expect(thread.lastReadMessageId).toBe(0)
expect(thread.chatThreadName).toBe('')
})
it('collapses duplicate ids and the caller naming themselves', async () => {
const caller = 882002
const res = await withMembers(caller, `ids=${caller}&ids=882003&ids=882003`)
expect(((await res.json()) as { playerIds: number[] }).playerIds).toEqual([caller, 882003])
})
// Fetch-or-create: reopening a chat with the same people must land back in the
// conversation that already has the history, not a fresh empty one.
it('returns the existing thread rather than opening a second', async () => {
const caller = 882004
const first = (await (await withMembers(caller, 'ids=882005')).json()) as {
chatThreadId: number
}
await postMessage(env.DB, {
chatThreadId: first.chatThreadId,
senderPlayerId: 882005,
contents: '{"Type":0,"Version":1,"Data":"hi"}',
})
const second = (await (await withMembers(caller, 'ids=882005')).json()) as {
chatThreadId: number
messages: unknown[]
}
expect(second.chatThreadId).toBe(first.chatThreadId)
// The opening notice plus the real message — reopening adds neither a thread nor
// a second notice.
expect(second.messages).toHaveLength(2)
})
// Membership is matched as a whole set, so a DM isn't mistaken for a group that
// happens to contain the same two people.
it('does not confuse a subset or superset for the same thread', async () => {
const caller = 882009
const pair = (await (await withMembers(caller, 'ids=882010')).json()) as {
chatThreadId: number
}
const trio = (await (await withMembers(caller, 'ids=882010&ids=882011')).json()) as {
chatThreadId: number
}
expect(trio.chatThreadId).not.toBe(pair.chatThreadId)
})
it('honours messageCount when paging the thread', async () => {
const caller = 882012
const opened = (await (await withMembers(caller, 'ids=882013')).json()) as {
chatThreadId: number
}
for (const data of ['one', 'two', 'three']) {
await postMessage(env.DB, {
chatThreadId: opened.chatThreadId,
senderPlayerId: caller,
contents: JSON.stringify({ Type: 0, Version: 1, Data: data }),
})
}
const paged = (await (await withMembers(caller, 'ids=882013&messageCount=2')).json()) as {
messages: unknown[]
}
expect(paged.messages).toHaveLength(2)
})
it('rejects a thread with nobody else in it', async () => {
const caller = 882006
expect((await withMembers(caller, '')).status).toBe(400)
expect((await withMembers(caller, `ids=${caller}`)).status).toBe(400)
expect((await withMembers(caller, 'ids=notanumber')).status).toBe(400)
})
it('rejects an oversized roster', async () => {
const ids = Array.from({ length: 60 }, (_, i) => `ids=${883000 + i}`).join('&')
expect((await withMembers(882007, ids)).status).toBe(400)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/withmembers`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'ids=2',
})
expect(res.status).toBe(401)
})
})
describe('POST /thread', () => {
async function createViaPost(caller: number, body: string) {
return SELF.fetch(`${ORIGIN}/thread`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
// The call the client actually makes after /thread/withmembers: members, blank
// contents. A blank field must not post an empty message, and reports
// invalid-arguments rather than success.
it('opens a thread with no message when messageContents is blank', async () => {
const caller = 884001
const res = await createViaPost(caller, 'ids=155&ids=2&messageContents=')
expect(res.status).toBe(200)
const body = (await res.json()) as {
chatThread: { chatThreadId: number; latestMessage: { senderPlayerId: number } | null }
chatResult: number
}
expect(body.chatResult).toBe(1)
expect(body.chatThread).toMatchObject({ playerIds: [2, 155, caller] })
// Nothing of the caller's was posted — but the thread still isn't empty: it opens
// with the system notice, which is what the client needs to render it at all.
const messages = await getThreadMessages(env.DB, body.chatThread.chatThreadId)
expect(messages).toEqual([
expect.objectContaining({
senderPlayerId: SYSTEM_SENDER_ID,
contents: startedChatContents(caller),
}),
])
expect(body.chatThread.latestMessage?.senderPlayerId).toBe(SYSTEM_SENDER_ID)
})
it('posts the first message when messageContents is given', async () => {
const caller = 884002
const contents = '{"Type":0,"Version":1,"Data":"hi"}'
const res = await createViaPost(
caller,
`ids=884003&messageContents=${encodeURIComponent(contents)}`
)
const body = (await res.json()) as {
chatThread: {
chatThreadId: number
latestMessage: { contents: string; senderPlayerId: number } | null
}
chatResult: number
}
expect(body.chatResult).toBe(0)
// Stored verbatim, attributed to the caller, and already the thread's latest.
expect(body.chatThread.latestMessage).toMatchObject({ contents, senderPlayerId: caller })
// The opening notice, then the caller's message.
expect(await getThreadMessages(env.DB, body.chatThread.chatThreadId)).toHaveLength(2)
})
// Sending to people you already have a thread with appends to it, rather than
// stranding the message in a second conversation.
it('appends to the existing thread with the same members', async () => {
const caller = 884005
const first = (await (
await createViaPost(caller, 'ids=884006&messageContents=%7B%22Data%22%3A%22one%22%7D')
).json()) as { chatThread: { chatThreadId: number } }
const second = (await (
await createViaPost(caller, 'ids=884006&messageContents=%7B%22Data%22%3A%22two%22%7D')
).json()) as { chatThread: { chatThreadId: number } }
expect(second.chatThread.chatThreadId).toBe(first.chatThread.chatThreadId)
// The opening notice, then both messages.
expect(await getThreadMessages(env.DB, first.chatThread.chatThreadId)).toHaveLength(3)
})
it('rejects a thread with nobody else in it', async () => {
expect((await createViaPost(884004, 'messageContents=')).status).toBe(400)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'ids=2&messageContents=',
})
expect(res.status).toBe(401)
})
})
describe('GET /thread/:id', () => {
async function openThread(caller: number, chatThreadId: number, query = '?messageCount=50') {
return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}${query}`, {
headers: await bearer(caller),
})
}
it('opens a thread with its recent messages, newest first', async () => {
const caller = 885001
const chatThreadId = await createThread(env.DB, [caller, 885002])
const older = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 885002,
timeSent: '2022-02-19T22:13:56.7224503',
contents: '{"Type":0,"Version":1,"Data":"on discord?"}',
})
const newer = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 885002,
timeSent: '2022-02-21T18:08:56.0362822',
contents: '{"Type":0,"Version":1,"Data":"hi"}',
})
const res = await openThread(caller, chatThreadId)
expect(res.status).toBe(200)
// An object, not a bare array: the client parses this one as a thread and rejects
// an array outright ("expected '{', actual '['").
const body = await res.text()
expect(body.startsWith('{')).toBe(true)
const thread = JSON.parse(body) as { messages: unknown[] }
expect(thread).toMatchObject({
chatThreadId,
playerIds: [caller, 885002],
lastReadMessageId: 0,
chatThreadName: '',
snoozedUntil: null,
isFavorited: false,
})
expect(thread.messages).toEqual([newer, older])
expect(thread).not.toHaveProperty('latestMessage')
})
// The sibling route serves the same messages as a bare array — the two shapes are
// deliberately different, and the client depends on which is which.
it('carries the same messages /thread/:id/message serves as an array', async () => {
const caller = 885010
const chatThreadId = await createThread(env.DB, [caller, 885011])
await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 885011,
contents: '{"Type":0,"Version":1,"Data":"hi"}',
})
const thread = (await (await openThread(caller, chatThreadId)).json()) as {
messages: unknown[]
}
const messages = await (
await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/message?MessageCount=50`, {
headers: await bearer(caller),
})
).json()
expect(Array.isArray(messages)).toBe(true)
expect(thread.messages).toEqual(messages)
})
it('honours messageCount', async () => {
const caller = 885003
const chatThreadId = await createThread(env.DB, [caller, 885004])
for (const data of ['one', 'two', 'three']) {
await postMessage(env.DB, {
chatThreadId,
senderPlayerId: caller,
contents: JSON.stringify({ Type: 0, Version: 1, Data: data }),
})
}
const res = await openThread(caller, chatThreadId, '?messageCount=2')
expect(((await res.json()) as { messages: unknown[] }).messages).toHaveLength(2)
})
// A thread opened moments ago has nothing in it and still has to open — an empty
// messages array, not a 404.
it('opens an empty thread with an empty messages array', async () => {
const caller = 885005
const chatThreadId = await createThread(env.DB, [caller, 885006])
const res = await openThread(caller, chatThreadId)
expect(res.status).toBe(200)
const { messages } = (await res.json()) as { messages: unknown[] }
// Built directly by createThread with no starter, so genuinely empty.
expect(messages).toEqual([])
})
it('hides threads the caller is not in', async () => {
const chatThreadId = await createThread(env.DB, [885007, 885008])
expect((await openThread(885009, chatThreadId)).status).toBe(404)
expect((await openThread(885009, 999999)).status).toBe(404)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/1?messageCount=50`)
expect(res.status).toBe(401)
})
})
describe('ChatMessageReceived push', () => {
/** The stubbed hub (see vitest.config.ts) records what it was sent. */
interface SentNotification {
playerId: number
notificationType: NotificationType
data: Record<string, unknown>
}
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
getByName(name: string): { takeSent(): Promise<SentNotification[]> }
}
async function send(caller: number, body: string) {
return SELF.fetch(`${ORIGIN}/thread`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
beforeEach(async () => {
await hub.getByName('global').takeSent()
})
// Every member is notified, the sender included: the client doesn't fold the HTTP
// response into its thread cache, so without a self-push its own message doesn't
// show until the next refetch.
it('notifies every member of the thread, sender included', async () => {
const caller = 886001
const contents = '{"Type":0,"Version":1,"Data":"hi"}'
const res = await send(
caller,
`ids=886002&ids=886003&messageContents=${encodeURIComponent(contents)}`
)
const { chatThread } = (await res.json()) as {
chatThread: { chatThreadId: number; latestMessage: { chatMessageId: number } }
}
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
expect(
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
).toBe(true)
expect(sent[0]!.data).toEqual({
chatMessageId: chatThread.latestMessage.chatMessageId,
chatThreadId: chatThread.chatThreadId,
senderPlayerId: caller,
timeSent: expect.any(String),
contents,
moderationState: 0,
})
})
it('pushes nothing when there is no message to push', async () => {
await send(886004, 'ids=886005&messageContents=')
expect(await hub.getByName('global').takeSent()).toEqual([])
})
})
// A membership row whose thread row is gone must never be resolved to: it would hand
// back an id nothing can render ("thread N vanished after creation"), and because the
// oldest match wins it would keep winning on every later call.
describe('orphaned membership rows', () => {
it('ignores members of a thread whose message_thread row is gone', async () => {
const caller = 887001
const other = 887002
const orphaned = await createThread(env.DB, [caller, other])
await env.DB.prepare('DELETE FROM message_thread WHERE chat_thread_id = ?1')
.bind(orphaned)
.run()
expect(await findThreadWithMembers(env.DB, [caller, other])).toBeNull()
// Opening the chat recovers: a usable thread comes back, and it isn't the orphan.
const res = await SELF.fetch(`${ORIGIN}/thread/withmembers`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `ids=${other}&messageCount=50`,
})
expect(res.status).toBe(200)
const thread = (await res.json()) as { chatThreadId: number; playerIds: number[] }
expect(thread.chatThreadId).not.toBe(orphaned)
expect(thread.playerIds).toEqual([caller, other])
// And it stays stable — the orphan never wins a later lookup.
expect(await findThreadWithMembers(env.DB, [caller, other])).toBe(thread.chatThreadId)
})
})
describe('marking a thread read', () => {
async function read(caller: number, path: string, method = 'POST') {
return SELF.fetch(`${ORIGIN}${path}`, { method, headers: await bearer(caller) })
}
it('moves the pointer to a specific message, on both verbs', async () => {
const caller = 888001
const chatThreadId = await createThread(env.DB, [caller, 888002])
const first = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 888002,
contents: '{"Type":0,"Version":1,"Data":"one"}',
})
const second = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 888002,
contents: '{"Type":0,"Version":1,"Data":"two"}',
})
const res = await read(caller, `/thread/${chatThreadId}/message/${first.chatMessageId}/read`)
expect(res.status).toBe(200)
// The bare ChatResult integer, not an envelope.
expect(await res.json()).toBe(0)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
first.chatMessageId
)
await read(caller, `/thread/${chatThreadId}/message/${second.chatMessageId}/read`, 'PUT')
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
second.chatMessageId
)
})
it('marks the whole thread read without a message id', async () => {
const caller = 888003
const chatThreadId = await createThread(env.DB, [caller, 888004])
const latest = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 888004,
contents: '{"Type":0,"Version":1,"Data":"hi"}',
})
expect((await read(caller, `/thread/${chatThreadId}/read`)).status).toBe(200)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
latest.chatMessageId
)
})
// The client acks whatever id it was shown — including the synthetic message's
// 9007199254740976. Clamping keeps that from stranding the thread as read forever.
it('clamps an id beyond the thread to the real latest message', async () => {
const caller = 888005
const chatThreadId = await createThread(env.DB, [caller, 888006])
const real = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 888006,
contents: '{"Type":0,"Version":1,"Data":"hi"}',
})
await read(caller, `/thread/${chatThreadId}/message/9007199254740976/read`)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
real.chatMessageId
)
// A later real message is still unread, rather than swallowed by the bogus ack.
const next = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 888006,
contents: '{"Type":0,"Version":1,"Data":"later"}',
})
expect(
(await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId
).toBeLessThan(next.chatMessageId)
})
it('never moves the pointer backwards', async () => {
const caller = 888007
const chatThreadId = await createThread(env.DB, [caller, 888008])
const first = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 888008,
contents: '{"Type":0,"Version":1,"Data":"one"}',
})
const second = await postMessage(env.DB, {
chatThreadId,
senderPlayerId: 888008,
contents: '{"Type":0,"Version":1,"Data":"two"}',
})
await read(caller, `/thread/${chatThreadId}/message/${second.chatMessageId}/read`)
await read(caller, `/thread/${chatThreadId}/message/${first.chatMessageId}/read`)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
second.chatMessageId
)
})
it('is gated on membership and auth', async () => {
const chatThreadId = await createThread(env.DB, [888009, 888010])
expect((await read(888011, `/thread/${chatThreadId}/read`)).status).toBe(404)
const anon = await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/read`, { method: 'POST' })
expect(anon.status).toBe(401)
})
})
describe('POST /thread/:id', () => {
// The exact body the client sends: a Version 2 envelope whose Data carries a `<=>`
// prefix. Nothing in the worker parses it, so it must survive byte-for-byte.
const CONTENTS = '{"Type":0,"Version":2,"Data":"<=>hey"}'
async function send(caller: number, path: string, contents = CONTENTS) {
return SELF.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `messageContents=${encodeURIComponent(contents)}`,
})
}
it('appends to an existing thread and answers the send wrapper', async () => {
const caller = 889001
const chatThreadId = await createThread(env.DB, [caller, 889002], null, caller)
const res = await send(caller, `/thread/${chatThreadId}`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
chatResult: number
chatThread: {
chatThreadId: number
playerIds: number[]
lastReadMessageId: number
chatThreadType: number
messages: ChatMessage[]
}
}
expect(body.chatResult).toBe(0)
// The whole thread comes back, newest message first, with the opening notice
// beneath it — the shape the client re-renders the conversation from.
expect(body.chatThread).toMatchObject({ chatThreadId, playerIds: [caller, 889002] })
expect(body.chatThread.messages).toHaveLength(2)
expect(body.chatThread.messages[0]).toMatchObject({
chatThreadId,
senderPlayerId: caller,
contents: CONTENTS,
moderationState: 0,
})
expect(body.chatThread.messages[1]).toMatchObject({ senderPlayerId: SYSTEM_SENDER_ID })
// Sending marks the thread read for the sender, so it doesn't come back unread.
expect(body.chatThread.lastReadMessageId).toBe(body.chatThread.messages[0]!.chatMessageId)
// And it's stored, not just echoed.
expect(await getThreadMessages(env.DB, chatThreadId)).toEqual(body.chatThread.messages)
})
it('accepts the /thread/:id/message spelling too', async () => {
const caller = 889003
const chatThreadId = await createThread(env.DB, [caller, 889004], null, caller)
const res = await send(caller, `/thread/${chatThreadId}/message`)
expect(((await res.json()) as { chatResult: number }).chatResult).toBe(0)
expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(2)
})
it('pushes ChatMessageReceived to every member', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
getByName(name: string): {
takeSent(): Promise<
Array<{ playerId: number; notificationType: NotificationType }>
>
}
}
const caller = 889005
const chatThreadId = await createThread(env.DB, [caller, 889006], null, caller)
await hub.getByName('global').takeSent()
await send(caller, `/thread/${chatThreadId}`)
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 889006])
expect(
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
).toBe(true)
})
it('reports invalid arguments for blank contents without storing anything', async () => {
const caller = 889007
const chatThreadId = await createThread(env.DB, [caller, 889008], null, caller)
const res = await send(caller, `/thread/${chatThreadId}`, ' ')
expect(res.status).toBe(200)
const body = (await res.json()) as { chatResult: number; chatThread: { messages: unknown[] } }
expect(body.chatResult).toBe(1)
// The thread still comes back — only the opening notice is in it.
expect(body.chatThread.messages).toHaveLength(1)
expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(1)
})
it('is gated on membership and auth', async () => {
const chatThreadId = await createThread(env.DB, [889009, 889010], null, 889009)
expect((await send(889011, `/thread/${chatThreadId}`)).status).toBe(404)
const anon = await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'messageContents=hi',
})
expect(anon.status).toBe(401)
})
})
describe('POST /thread/:id/member/:playerId', () => {
async function addMember(caller: number, chatThreadId: number, playerId: number) {
return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/member/${playerId}`, {
method: 'POST',
headers: await bearer(caller),
})
}
it('adds a player to a thread the caller is in', async () => {
const caller = 890001
const chatThreadId = await createThread(env.DB, [caller, 890002], null, caller)
const res = await addMember(caller, chatThreadId, 890003)
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
expect(await isThreadMember(env.DB, chatThreadId, 890003)).toBe(true)
// The new member sees the thread, its history included.
const thread = await getThreadForPlayer(env.DB, chatThreadId, 890003)
expect(thread?.playerIds).toEqual([caller, 890002, 890003])
})
it('reports the player is already on the thread', async () => {
const caller = 890004
const chatThreadId = await createThread(env.DB, [caller, 890005], null, caller)
expect(await (await addMember(caller, chatThreadId, 890005)).json()).toBe(4)
})
// A non-member gets the same answer as for a thread that doesn't exist, so the
// endpoint can't be used to probe for threads.
it('refuses a caller who is not on the thread', async () => {
const chatThreadId = await createThread(env.DB, [890006, 890007], null, 890006)
expect(await (await addMember(890008, chatThreadId, 890009)).json()).toBe(3)
expect(await isThreadMember(env.DB, chatThreadId, 890009)).toBe(false)
expect(await (await addMember(890008, 999999, 890009)).json()).toBe(3)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/1/member/2`, { method: 'POST' })
expect(res.status).toBe(401)
})
})
describe('renaming and leaving a thread', () => {
async function post(caller: number, path: string, body?: string, method = 'POST') {
return SELF.fetch(`${ORIGIN}${path}`, {
method,
headers: {
...(await bearer(caller)),
...(body === undefined ? {} : { 'Content-Type': 'application/x-www-form-urlencoded' }),
},
body,
})
}
it('renames a thread for everyone on it', async () => {
const caller = 891001
const chatThreadId = await createThread(env.DB, [caller, 891002], null, caller)
const res = await post(caller, `/thread/${chatThreadId}/rename`, 'name=my%20chat')
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
// Both members see the new name.
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toBe('my chat')
expect((await getThreadForPlayer(env.DB, chatThreadId, 891002))?.chatThreadName).toBe('my chat')
})
it('clears the name back to unnamed, never null', async () => {
const caller = 891003
const chatThreadId = await createThread(env.DB, [caller, 891004], 'Group Chat =]', caller)
await post(caller, `/thread/${chatThreadId}/rename`, 'name=')
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toBe('')
})
it('truncates an overlong name rather than rejecting it', async () => {
const caller = 891005
const chatThreadId = await createThread(env.DB, [caller, 891006], null, caller)
await post(caller, `/thread/${chatThreadId}/rename`, `name=${'x'.repeat(200)}`)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toHaveLength(
128
)
})
it('refuses to rename a thread the caller is not on', async () => {
const chatThreadId = await createThread(env.DB, [891007, 891008], null, 891007)
expect(await (await post(891009, `/thread/${chatThreadId}/rename`, 'name=nope')).json()).toBe(3)
expect((await getThreadForPlayer(env.DB, chatThreadId, 891007))?.chatThreadName).toBe('')
})
it('leaves a thread, posting the notice and keeping the history', async () => {
const caller = 891010
const stayer = 891011
const chatThreadId = await createThread(env.DB, [caller, stayer], null, caller)
await postMessage(env.DB, {
chatThreadId,
senderPlayerId: caller,
contents: '{"Type":0,"Version":1,"Data":"bye"}',
})
const res = await post(caller, `/thread/${chatThreadId}/leave`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
// Gone for the leaver, intact for everyone else.
expect(await isThreadMember(env.DB, chatThreadId, caller)).toBe(false)
expect(await getThreadForPlayer(env.DB, chatThreadId, caller)).toBeNull()
const remaining = await getThreadForPlayer(env.DB, chatThreadId, stayer)
expect(remaining?.playerIds).toEqual([stayer])
expect(remaining?.latestMessage).toMatchObject({
senderPlayerId: SYSTEM_SENDER_ID,
contents: leftChatContents(caller),
})
// Opening notice, the message, and the leave notice.
expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(3)
})
it('accepts DELETE for leave as well as POST', async () => {
const caller = 891012
const chatThreadId = await createThread(env.DB, [caller, 891013], null, caller)
expect(
await (await post(caller, `/thread/${chatThreadId}/leave`, undefined, 'DELETE')).json()
).toBe(0)
expect(await isThreadMember(env.DB, chatThreadId, caller)).toBe(false)
})
it('reports membership-not-found when leaving a thread you are not on', async () => {
const chatThreadId = await createThread(env.DB, [891014, 891015], null, 891014)
expect(await (await post(891016, `/thread/${chatThreadId}/leave`)).json()).toBe(3)
// Nothing was posted to a thread the caller has no business touching.
expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(1)
})
it('401s without a token', async () => {
const rename = await SELF.fetch(`${ORIGIN}/thread/1/rename`, { method: 'POST' })
expect(rename.status).toBe(401)
const leave = await SELF.fetch(`${ORIGIN}/thread/1/leave`, { method: 'POST' })
expect(leave.status).toBe(401)
})
})
describe('POST /thread/:id/snooze', () => {
async function snooze(caller: number, chatThreadId: number, body: string) {
return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/snooze`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
it('snoozes and unsnoozes for the caller alone', async () => {
const caller = 892001
const other = 892002
const chatThreadId = await createThread(env.DB, [caller, other], null, caller)
const res = await snooze(caller, chatThreadId, 'snooze=True')
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.snoozedUntil).toBe(
'9999-12-31T23:59:59Z'
)
// Snoozing is per-member: the other player is untouched.
expect((await getThreadForPlayer(env.DB, chatThreadId, other))?.snoozedUntil).toBeNull()
await snooze(caller, chatThreadId, 'snooze=False')
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.snoozedUntil).toBeNull()
})
it('refuses a thread the caller is not on', async () => {
const chatThreadId = await createThread(env.DB, [892003, 892004], null, 892003)
expect(await (await snooze(892005, chatThreadId, 'snooze=True')).json()).toBe(3)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/1/snooze`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'snooze=True',
})
expect(res.status).toBe(401)
})
})
describe('PUT /thread/:id/favorite', () => {
async function favorite(caller: number, chatThreadId: number, body: string, method = 'PUT') {
return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/favorite`, {
method,
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
it('favorites and unfavorites for the caller alone', async () => {
const caller = 893001
const other = 893002
const chatThreadId = await createThread(env.DB, [caller, other], null, caller)
const res = await favorite(caller, chatThreadId, 'favorite=True')
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true)
// Per-member, like snoozing: the other player's inbox is untouched.
expect((await getThreadForPlayer(env.DB, chatThreadId, other))?.isFavorited).toBe(false)
await favorite(caller, chatThreadId, 'favorite=False')
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(false)
})
it('accepts POST as well as PUT', async () => {
const caller = 893003
const chatThreadId = await createThread(env.DB, [caller, 893004], null, caller)
expect(await (await favorite(caller, chatThreadId, 'favorite=True', 'POST')).json()).toBe(0)
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true)
})
it('refuses a thread the caller is not on', async () => {
const chatThreadId = await createThread(env.DB, [893005, 893006], null, 893005)
expect(await (await favorite(893007, chatThreadId, 'favorite=True')).json()).toBe(3)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/1/favorite`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'favorite=True',
})
expect(res.status).toBe(401)
})
})
describe('openapi', () => {
it('GET /openapi.json documents every route', async () => {
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every schema inlines — a `$ref` here means a schema picked up a `.meta({ id })`
// and emitted a reference the framework didn't hoist into components.schemas.
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
// `.on([...], …)` routes contribute every method they were registered for.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'DELETE /thread/{id}/leave',
'GET /',
'GET /thread',
'GET /thread/{id}',
'GET /thread/{id}/message',
'POST /thread',
'POST /thread/withmembers',
'POST /thread/{id}',
'POST /thread/{id}/favorite',
'POST /thread/{id}/leave',
'POST /thread/{id}/member/{playerId}',
'POST /thread/{id}/message',
'POST /thread/{id}/message/{messageId}/read',
'POST /thread/{id}/read',
'POST /thread/{id}/rename',
'POST /thread/{id}/snooze',
'PUT /thread/{id}/favorite',
'PUT /thread/{id}/message/{messageId}/read',
'PUT /thread/{id}/read',
'PUT /thread/{id}/rename',
'PUT /thread/{id}/snooze',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
})
+473
View File
@@ -0,0 +1,473 @@
/**
* Chat threads and their membership on the shared `recflare` D1 database. A thread is a
* conversation a DM pair, a named group chat, or a system thread; the messages in it
* live in `message` (see message-db.ts).
*
* Membership (`thread_member`) does double duty: it is the authorization gate a
* player may read or post to a thread only if they hold a row and it is what renders
* the `playerIds` array the client shows. Nothing here has a foreign key to accounts,
* here or on a message's sender: that table belongs to the `auth` worker, and a thread
* outlives the accounts in it.
*
* The thread denormalizes `latest_message_id` so the thread list renders from one
* indexed row per thread rather than a per-thread MAX() over `message`, and so it can
* be ordered by recency without a join message ids are monotonic, so the highest id
* is the newest thread. `postMessage` keeps it in sync.
*
* The per-viewer fields `lastReadMessageId`, `snoozedUntil`, `isFavorited` live on
* the membership row, not the thread: two players in one DM have independent read
* positions, snoozes, and favorites.
*
* The `chat` worker owns this schema/migration (migrations/0002_thread.sql).
* `THREAD_SCHEMA_DDL` mirrors it so tests can build the tables directly.
*/
import { insertMessage } from './message-db'
import type { ChatMessage, NewChatMessage } from './message-db'
/** Schema DDL (mirror of migrations/0002_thread.sql). */
export const THREAD_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS message_thread (
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_thread_name TEXT,
latest_message_id INTEGER,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_message_thread_latest ON message_thread (latest_message_id)`,
`CREATE TABLE IF NOT EXISTS thread_member (
chat_thread_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
last_read_message_id INTEGER,
snoozed_until TEXT,
is_favorited INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (chat_thread_id, player_id)
)`,
// The thread-list query is "every thread this player is in", so player_id leads.
`CREATE INDEX IF NOT EXISTS idx_thread_member_player ON thread_member (player_id)`,
]
/**
* A thread as the client receives it: the thread, its members, its most recent message,
* and the viewing player's own read/snooze/favorite state. This is the element shape of
* the thread-list response.
*/
export interface ChatThread {
/** Null only for a thread with no messages yet. */
latestMessage: ChatMessage | null
chatThreadId: number
playerIds: number[]
/**
* 0 when the player has never read the thread never null. The client deserializes
* this into a non-nullable int and fails the whole response on a null ("expected
* 'Number Token', actual 'null'"), unlike `latestMessage`, which it accepts as null.
*/
lastReadMessageId: number
/**
* Empty for DMs and unnamed groups never null. The client dereferences this name
* without a null check (a null NullReferenceExceptions its way out of
* GetChatBetweenPlayers) and falls back to naming the members when it's blank.
*/
chatThreadName: string
/**
* Which kind of conversation this is. Every thread the reference serves here comes back
* as 0, and nothing in the worker distinguishes DMs from groups, so it's a constant
* but the field itself has to be present: the client deserializes it as a non-nullable
* int and drops the whole response when it's missing.
*/
chatThreadType: number
snoozedUntil: string | null
isFavorited: boolean
}
/** The only thread type the reference ever serves. See `ChatThread.chatThreadType`. */
const CHAT_THREAD_TYPE_DEFAULT = 0
/** The joined row backing a rendered thread, before it's shaped for the client. */
interface ThreadRow {
chat_thread_id: number
chat_thread_name: string | null
player_ids: string | null
last_read_message_id: number | null
snoozed_until: string | null
is_favorited: number
msg_chat_message_id: number | null
msg_chat_thread_id: number | null
msg_sender_player_id: number | null
msg_time_sent: string | null
msg_contents: string | null
msg_moderation_state: number | null
}
function toThread(row: ThreadRow): ChatThread {
return {
latestMessage:
row.msg_chat_message_id === null
? null
: {
chatMessageId: row.msg_chat_message_id,
chatThreadId: row.msg_chat_thread_id!,
senderPlayerId: row.msg_sender_player_id!,
timeSent: row.msg_time_sent!,
contents: row.msg_contents!,
moderationState: row.msg_moderation_state!,
},
chatThreadId: row.chat_thread_id,
// group_concat of the membership rows, already ordered by player id.
playerIds: row.player_ids === null ? [] : row.player_ids.split(',').map(Number),
// Null in the column means "never read"; the client insists on a number.
lastReadMessageId: row.last_read_message_id ?? 0,
// Null in the column means "unnamed"; the client dereferences it unchecked.
chatThreadName: row.chat_thread_name ?? '',
chatThreadType: CHAT_THREAD_TYPE_DEFAULT,
snoozedUntil: row.snoozed_until,
isFavorited: row.is_favorited !== 0,
}
}
/**
* The thread list as it renders for one player, newest conversation first the
* `?MessageCount=N` page of the thread endpoint.
*
* Reads only threads the player is a member of, so the membership join is the
* authorization check as well as the query. The inner ordered subquery around
* group_concat is what makes `playerIds` come back sorted rather than in row order.
*/
export async function getThreadsForPlayer(
db: D1Database,
playerId: number,
{ limit = 50 }: { limit?: number } = {}
): Promise<ChatThread[]> {
const { results } = await db
.prepare(
`SELECT
t.chat_thread_id,
t.chat_thread_name,
(SELECT group_concat(player_id) FROM
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
ORDER BY player_id)) AS player_ids,
me.last_read_message_id,
me.snoozed_until,
me.is_favorited,
msg.chat_message_id AS msg_chat_message_id,
msg.chat_thread_id AS msg_chat_thread_id,
msg.sender_player_id AS msg_sender_player_id,
msg.time_sent AS msg_time_sent,
msg.contents AS msg_contents,
msg.moderation_state AS msg_moderation_state
FROM thread_member me
JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id
WHERE me.player_id = ?1
ORDER BY t.latest_message_id DESC
LIMIT ?2`
)
.bind(playerId, limit)
.all<ThreadRow>()
return results.map(toThread)
}
/** One thread as it renders for one player, or null if they aren't a member of it. */
export async function getThreadForPlayer(
db: D1Database,
chatThreadId: number,
playerId: number
): Promise<ChatThread | null> {
const row = await db
.prepare(
`SELECT
t.chat_thread_id,
t.chat_thread_name,
(SELECT group_concat(player_id) FROM
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
ORDER BY player_id)) AS player_ids,
me.last_read_message_id,
me.snoozed_until,
me.is_favorited,
msg.chat_message_id AS msg_chat_message_id,
msg.chat_thread_id AS msg_chat_thread_id,
msg.sender_player_id AS msg_sender_player_id,
msg.time_sent AS msg_time_sent,
msg.contents AS msg_contents,
msg.moderation_state AS msg_moderation_state
FROM thread_member me
JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id
WHERE me.chat_thread_id = ?1 AND me.player_id = ?2`
)
.bind(chatThreadId, playerId)
.first<ThreadRow>()
return row === null ? null : toThread(row)
}
/**
* Whether a player may read or post to a thread. Every thread-scoped route gates on
* this before touching messages.
*/
export async function isThreadMember(
db: D1Database,
chatThreadId: number,
playerId: number
): Promise<boolean> {
const row = await db
.prepare('SELECT 1 AS ok FROM thread_member WHERE chat_thread_id = ?1 AND player_id = ?2')
.bind(chatThreadId, playerId)
.first<{ ok: number }>()
return row !== null
}
/**
* The pseudo-player system messages are sent as. Not a real account the client renders
* a message from this sender as a notice rather than as someone speaking, which is why
* `message.sender_player_id` carries no foreign key and permits negative ids.
*/
export const SYSTEM_SENDER_ID = -5
/**
* The notice a thread opens with: `Player <@U10441985> started a chat`. The `<@U…>` token
* is a mention the client resolves to a display name, so the id goes in raw.
*/
export function startedChatContents(playerId: number): string {
return JSON.stringify({
Type: 0,
Version: 1,
Data: `Player <@U${playerId}> started a chat`,
})
}
/**
* The notice left behind when someone walks out of a group: `Player <@U14922080> left`.
* Same `<@U…>` mention token the opening notice uses.
*/
export function leftChatContents(playerId: number): string {
return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> left` })
}
/**
* Rename a thread. An empty name clears it back to unnamed, which renders as the member
* list rather than a blank title.
*/
export async function setThreadName(
db: D1Database,
chatThreadId: number,
name: string
): Promise<void> {
await db
.prepare('UPDATE message_thread SET chat_thread_name = ?2 WHERE chat_thread_id = ?1')
.bind(chatThreadId, name === '' ? null : name)
.run()
}
/**
* Open a thread between a set of players, returning its new id. `name` is null for DMs
* and unnamed groups. Duplicate player ids collapse, so a caller need not dedupe.
*
* Pass `startedBy` to open the thread the way the real server does with a system
* "started a chat" notice as its first message. A thread with no messages at all is one
* the client won't display, so every thread born from a request gets one; the parameter
* is optional only so tests can build a bare thread directly.
*
* Every call opens a *distinct* thread, even for a member set that already has one
* threads are not keyed by their membership, and the same pair may hold several.
*/
export async function createThread(
db: D1Database,
playerIds: number[],
name: string | null = null,
startedBy?: number
): Promise<number> {
const row = await db
.prepare(
`INSERT INTO message_thread (chat_thread_name, created_at) VALUES (?1, ?2)
RETURNING chat_thread_id`
)
.bind(name, new Date().toISOString())
.first<{ chat_thread_id: number }>()
if (row === null) throw new Error('failed to create chat thread')
const members = [...new Set(playerIds)]
if (members.length > 0) {
await db.batch(
members.map((playerId) =>
db
.prepare(
`INSERT OR IGNORE INTO thread_member (chat_thread_id, player_id)
VALUES (?1, ?2)`
)
.bind(row.chat_thread_id, playerId)
)
)
}
if (startedBy !== undefined) {
await postMessage(db, {
chatThreadId: row.chat_thread_id,
senderPlayerId: SYSTEM_SENDER_ID,
contents: startedChatContents(startedBy),
})
}
return row.chat_thread_id
}
/**
* The existing thread whose membership is *exactly* this set of players, or null. The
* oldest match wins, so a set that somehow accumulated duplicates keeps resolving to the
* conversation with the history in it.
*
* This is what makes "open a chat with these people" reuse the conversation you already
* have with them rather than starting an empty one each time. Matching is on the whole
* set: a DM and a group that happens to contain those two people are different threads.
*
* Only threads that still have a `message_thread` row can match. Membership rows whose
* thread is gone are ignored rather than resolved to: matching one would hand back an id
* that nothing else in the worker can render, and since the oldest match wins it
* would keep winning on every subsequent call.
*/
export async function findThreadWithMembers(
db: D1Database,
playerIds: number[]
): Promise<number | null> {
const members = [...new Set(playerIds)]
if (members.length === 0) return null
// ?1 is the member count; ?2… are the ids themselves.
const placeholders = members.map((_, i) => `?${i + 2}`).join(', ')
const row = await db
.prepare(
`SELECT m.chat_thread_id FROM thread_member m
JOIN message_thread t ON t.chat_thread_id = m.chat_thread_id
GROUP BY m.chat_thread_id
HAVING COUNT(*) = ?1
AND COUNT(CASE WHEN m.player_id IN (${placeholders}) THEN 1 END) = ?1
ORDER BY m.chat_thread_id
LIMIT 1`
)
.bind(members.length, ...members)
.first<{ chat_thread_id: number }>()
return row?.chat_thread_id ?? null
}
/**
* The thread with exactly these members, opening one if it doesn't exist yet. Two
* simultaneous first-messages to the same set can still race into two threads; the
* oldest-match rule in `findThreadWithMembers` means both parties converge on one of
* them afterwards.
*/
export async function getOrCreateThreadWithMembers(
db: D1Database,
playerIds: number[],
startedBy: number
): Promise<number> {
return (
(await findThreadWithMembers(db, playerIds)) ??
(await createThread(db, playerIds, null, startedBy))
)
}
/** Everyone in a thread, ordered by id — the fan-out list for a push notification. */
export async function getThreadMemberIds(db: D1Database, chatThreadId: number): Promise<number[]> {
const { results } = await db
.prepare('SELECT player_id FROM thread_member WHERE chat_thread_id = ?1 ORDER BY player_id')
.bind(chatThreadId)
.all<{ player_id: number }>()
return results.map((r) => r.player_id)
}
/** Add a player to an existing thread. A no-op if they're already in it. */
export async function addThreadMember(
db: D1Database,
chatThreadId: number,
playerId: number
): Promise<void> {
await db
.prepare('INSERT OR IGNORE INTO thread_member (chat_thread_id, player_id) VALUES (?1, ?2)')
.bind(chatThreadId, playerId)
.run()
}
/** Remove a player from a thread. The thread and its messages outlive the membership. */
export async function removeThreadMember(
db: D1Database,
chatThreadId: number,
playerId: number
): Promise<void> {
await db
.prepare('DELETE FROM thread_member WHERE chat_thread_id = ?1 AND player_id = ?2')
.bind(chatThreadId, playerId)
.run()
}
/**
* Post a message and advance the thread's denormalized `latest_message_id` the only
* way messages should be written, so the thread list never goes stale. Two statements
* rather than a batch, because the update needs the id the insert assigns.
*/
export async function postMessage(db: D1Database, message: NewChatMessage): Promise<ChatMessage> {
const stored = await insertMessage(db, message)
await db
.prepare('UPDATE message_thread SET latest_message_id = ?2 WHERE chat_thread_id = ?1')
.bind(stored.chatThreadId, stored.chatMessageId)
.run()
return stored
}
/**
* Advance a player's read position, to a specific message or (with no id) to the whole
* thread. Only ever moves forward: an out-of-order ack from a second client can't walk
* the thread back to unread.
*
* The id is also clamped to the thread's real latest message, so a client acking an id
* that was never stored can't strand the pointer beyond every future message and leave
* the thread permanently "read".
*/
export async function markThreadRead(
db: D1Database,
chatThreadId: number,
playerId: number,
chatMessageId?: number
): Promise<void> {
await db
.prepare(
`UPDATE thread_member
SET last_read_message_id = MAX(
COALESCE(last_read_message_id, 0),
MIN(
COALESCE(?3, (SELECT latest_message_id FROM message_thread WHERE chat_thread_id = ?1), 0),
COALESCE((SELECT latest_message_id FROM message_thread WHERE chat_thread_id = ?1), 0)
)
)
WHERE chat_thread_id = ?1 AND player_id = ?2`
)
.bind(chatThreadId, playerId, chatMessageId ?? null)
.run()
}
/** Favorite or unfavorite a thread, for one player only. */
export async function setThreadFavorited(
db: D1Database,
chatThreadId: number,
playerId: number,
isFavorited: boolean
): Promise<void> {
await db
.prepare(
'UPDATE thread_member SET is_favorited = ?3 WHERE chat_thread_id = ?1 AND player_id = ?2'
)
.bind(chatThreadId, playerId, isFavorited ? 1 : 0)
.run()
}
/** Snooze a thread's notifications until an instant, or clear the snooze with null. */
export async function setThreadSnoozed(
db: D1Database,
chatThreadId: number,
playerId: number,
snoozedUntil: string | null
): Promise<void> {
await db
.prepare(
'UPDATE thread_member SET snoozed_until = ?3 WHERE chat_thread_id = ?1 AND player_id = ?2'
)
.bind(chatThreadId, playerId, snoozedUntil)
.run()
}
+34
View File
@@ -9,6 +9,40 @@ export default defineConfig({
bindings: {
ENVIRONMENT: 'VITEST',
},
// The worker's RECFLARE_NOTIFICATIONS_HUB binding points at the `notify`
// worker's DO (script_name: "notify"). That worker isn't part of this
// isolated test, so provide a minimal stub exposing the same NotificationsHub
// RPC surface. This one also records what it was sent and hands it back via
// `takeSent`, so tests can assert on the ChatMessageReceived fan-out.
workers: [
{
name: 'notify',
modules: true,
compatibilityDate: '2026-06-16',
compatibilityFlags: ['nodejs_compat'],
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
constructor(ctx, env) {
super(ctx, env)
this.sent = []
}
async notifyPlayer(playerId, notificationType, data) {
this.sent.push({ playerId, notificationType, data })
return { delivered: 1, queued: false }
}
async broadcast() { return { delivered: 0 } }
async takeSent() {
const sent = this.sent
this.sent = []
return sent
}
}
export default { fetch() { return new Response('ok') } }
`,
},
],
},
}),
],
+35
View File
@@ -4,6 +4,41 @@
"main": "src/chat.app.ts",
"compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"],
// Shared `recflare` DB (created manually with `wrangler d1 create recflare`; the
// "local" placeholder is spliced out at deploy time). The `chat` worker owns the
// `message` table (schema/migration here); its own migrations_table keeps history
// separate from the other workers' migrations on the shared database.
"d1_databases": [
{
"binding": "DB",
"database_name": "recflare",
"database_id": "local",
"migrations_dir": "migrations",
"migrations_table": "d1_migrations_chat"
}
],
"logpush": false,
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
"secrets_store_secrets": [
{
"binding": "JWT_SECRET",
"store_id": "local",
"secret_name": "JWT_SECRET"
}
],
// The `notify` worker's NotificationsHub DO used to push ChatMessageReceived to
// every member of a thread when a message lands.
"durable_objects": {
"bindings": [
{
"name": "RECFLARE_NOTIFICATIONS_HUB",
"class_name": "NotificationsHub",
"script_name": "notify"
}
]
},
"upload_source_maps": true,
"observability": {
"logs": {
+7 -1
View File
@@ -16,10 +16,16 @@
"test": "run-vitest"
},
"dependencies": {
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+208 -7
View File
@@ -14,6 +14,10 @@
* 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 (
@@ -125,8 +129,17 @@ export interface 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
}
@@ -307,7 +320,12 @@ function clubPermission(
/** The club-details payload the client reads from create/details. */
export interface ClubDetails {
AdditionalImages: unknown[]
/**
* 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
@@ -319,8 +337,9 @@ export interface ClubDetails {
/**
* 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 have no storage
* yet, so they're empty; custom tags come from the club (set via `modifydetails`).
* 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,
@@ -328,7 +347,7 @@ export async function getClubDetails(
accountId: number | null
): Promise<ClubDetails> {
return {
AdditionalImages: [],
AdditionalImages: await getClubGallery(db, club.ClubId),
Club: club,
ClubId: club.ClubId,
CoownerPermissions: clubPermission(club.ClubId, ClubMembershipType.Coowner, {
@@ -519,6 +538,17 @@ export async function setHomeClub(
.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
@@ -643,6 +673,64 @@ function dedupeTags(tags: string[]): string[] {
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
@@ -659,6 +747,30 @@ export async function getClub(db: D1Database, clubId: number): Promise<Club | nu
)
}
/**
* 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"
@@ -666,6 +778,23 @@ export async function getClub(db: D1Database, clubId: number): Promise<Club | nu
*/
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
@@ -740,19 +869,70 @@ export async function joinClub(
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
* 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.
* 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<Club | null> {
): 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'
@@ -760,5 +940,26 @@ export async function leaveClub(
.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 }
}
+1330 -330
View File
@@ -1,15 +1,24 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import {
glyphLength,
MAX_CLUB_DESCRIPTION_LENGTH,
MAX_CLUB_NAME_LENGTH,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
clearHomeClub,
ClubJoinability,
ClubMembershipType,
ClubVisibility,
countClubsByCreator,
createClub,
createClubAnnouncement,
deleteClub,
getClub,
getClubAnnouncements,
getClubDetails,
@@ -20,14 +29,67 @@ import {
getMembership,
joinClub,
leaveClub,
MAX_ADDITIONAL_IMAGES,
requestToJoinClub,
searchClubs,
setClubAdditionalImage,
setHomeClub,
setMemberType,
updateClub,
} from './clubs-db'
import {
AnnouncementIdEnvelope,
AnnouncementRequest,
AUTHED,
CategoryTags,
ChatDisabledResponse,
ClubAnnouncementsEnvelope,
ClubDetailsDto,
ClubDetailsEnvelope,
ClubDto,
ClubEnvelope,
ClubhouseRequest,
ClubMembersEnvelope,
ClubSearchResponse,
CreateClubRequest,
EmptyObject,
ErrorEnvelope,
form,
HomeClubRequest,
ImageNameRequest,
InviteMemberRequest,
json,
JsonArray,
MinLevelRequest,
ModifyClubRequest,
NullEnvelope,
SubscriberCountResponse,
SubscriptionDetailsResponse,
UNAUTHORIZED_RESPONSE,
} from './openapi'
import type { Context } from 'hono'
import type { App } from './context'
/**
* Clubs Worker. Hosts the club endpoints the game client calls on the `clubs` host:
* club creation and editing, membership (join / ask-to-join / leave / ban tiers),
* search, announcements, the club gallery, a club's clubhouse room, and each player's
* home club. Everything is D1-backed (the shared `recflare` database); the
* `/subscription/*` routes are stubs, since there are no subscription clubs yet.
*
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
*/
/** The `clubId` path parameter, shared by every per-club route. */
const CLUB_ID_PARAM = {
name: 'clubId',
in: 'path',
required: true,
description: 'The clubs id (digits only — a non-numeric id doesnt match the route)',
schema: { type: 'string' },
} as const
/**
* Resolve the account id from a Bearer token. Returns `null` when the header is
* missing, the token is invalid, or the `sub` claim isn't an integer.
@@ -36,11 +98,28 @@ async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Longest a club name may be (the reference's MaxNameLength). */
const MAX_CLUB_NAME_LENGTH = 16
/**
* How many clubs one account may create, when the `MAX_CLUBS_PER_ACCOUNT` var is
* unset. Counts the clubs the account created (subscription clubs excluded those
* aren't made by hand). Setting the var to 0 lifts the cap entirely. Existing clubs
* are never touched: lowering the cap just stops new ones.
*/
const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
/**
* The tiers `members/invite` may grant the real member roles only. Creator (100) is
* excluded so an invite can't mint a second owner, and the pending/none/banned states
* aren't something you "invite" someone to.
*/
const INVITABLE_TIERS: ReadonlySet<number> = new Set([
ClubMembershipType.Member,
ClubMembershipType.Moderator,
ClubMembershipType.Coowner,
])
/** The punctuation a club name may use, on top of letters and digits. */
const ALLOWED_NAME_PUNCTUATION = new Set([...` .,'!?-_&()#@:+`])
const ALLOWED_NAME_PUNCTUATION = new Set(` .,'!?-_&()#@:+`)
/**
* Club names are letters (any Latin script), digits, and basic punctuation the
@@ -128,439 +207,1360 @@ const app = new Hono<App>()
// account. Auth-gated. 404 when they have no home club, the club is gone, or it has
// no clubhouse room: the client expects a 404 for "no home club" and errors on an
// empty object. Returns the bare club (not the envelope), as the reference does.
.get('/club/home/me', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const club = await getHomeClub(c.env.DB, id)
return club === null ? c.notFound() : c.json(club)
})
.get(
'/club/home/me',
describeRoute({
tags: ['Home club'],
summary: 'The players home club',
description: [
'The club whose clubhouse the player spawns into (a field on their account row).',
'404 when they have no home club, the club is gone, or it has no clubhouse room —',
'the client expects a 404 for “no home club” and errors on an empty object. Returns',
'the bare club, not the envelope, as the reference does.',
].join(' '),
security: AUTHED,
responses: {
200: json(ClubDto, 'The players home club'),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No home club, or it has no clubhouse room' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const club = await getHomeClub(c.env.DB, id)
return club === null ? c.notFound() : c.json(club)
}
)
// Set the player's home club (`clubId` form field). They must be a member of it —
// you can't make a club you don't belong to your home. Answers the envelope.
.put('/club/home/me', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
.put(
'/club/home/me',
describeRoute({
tags: ['Home club'],
summary: 'Set the players home club',
description: [
'Points the players home club at the posted `clubId`. They must already be a member',
'of it — you cant make a club you dont belong to your home. Answers the envelope',
'carrying the bare club.',
].join(' '),
security: AUTHED,
requestBody: form(HomeClubRequest, 'The club to make home'),
responses: {
200: json(ClubEnvelope, 'The envelope carrying the new home club'),
400: json(ErrorEnvelope, 'Missing, non-numeric or zero clubId'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The caller isnt a member of that club'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'clubid')
const clubId = Number.parseInt(
typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '',
10
)
if (Number.isNaN(clubId) || clubId === 0) return clubError(c, 'Invalid clubId.')
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Member) {
return c.json(
{ error: 'You are not a member of that club.', success: false, value: null },
403
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'clubid')
const clubId = Number.parseInt(
typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '',
10
)
}
if (Number.isNaN(clubId) || clubId === 0) return clubError(c, 'Invalid clubId.')
await setHomeClub(c.env.DB, id, clubId)
return c.json({ error: '', success: true, value: club })
})
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Member) {
return c.json(
{ error: 'You are not a member of that club.', success: false, value: null },
403
)
}
await setHomeClub(c.env.DB, id, clubId)
return c.json({ error: '', success: true, value: club })
}
)
// Clear the player's home club — they spawn into the default hub again instead of a
// clubhouse. No body, idempotent (clearing when there's none set is a no-op, not a
// 404), and it doesn't touch their membership of the club. The envelope's value is
// null because there's no home club left to describe; GET goes back to 404ing.
.delete(
'/club/home/me',
describeRoute({
tags: ['Home club'],
summary: 'Clear the players home club',
description: [
'The player spawns into the default hub again instead of a clubhouse. No body,',
'idempotent (clearing when none is set is a no-op, not a 404), and it doesnt touch',
'their membership of the club. The envelopes `value` is null because theres no home',
'club left to describe; GET goes back to 404ing.',
].join(' '),
security: AUTHED,
responses: {
200: json(NullEnvelope, 'Cleared (value null)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
await clearHomeClub(c.env.DB, id)
return c.json({ error: '', success: true, value: null })
}
)
// A real Rec Room client endpoint with no backing implementation yet. The
// client calls it on the clubs host at /subscription/mine/member (no /club
// prefix) and sends no auth header, so it isn't gated. Returns an empty
// array = no club subscription memberships (the client chokes on null).
.get('/subscription/mine/member', (c) => c.json([]))
.get(
'/subscription/mine/member',
describeRoute({
tags: ['Subscriptions'],
summary: 'The callers club-subscription memberships',
description: [
'A real client endpoint with no backing implementation yet. The client calls it on',
'the clubs host at `/subscription/mine/member` (no `/club` prefix) and sends no auth',
'header, so it isnt gated. Always `[]` — no subscription memberships (the client',
'chokes on null).',
].join(' '),
responses: { 200: json(JsonArray, 'Always empty for now') },
}),
(c) => c.json([])
)
// Subscription details for an account (numeric id) — simulated: no club, no subs.
.get('/subscription/details/:accountId{[0-9]+}', (c) =>
c.json({
accountId: Number.parseInt(c.req.param('accountId'), 10),
clubId: 0,
subscriberCount: 0,
})
.get(
'/subscription/details/:accountId{[0-9]+}',
describeRoute({
tags: ['Subscriptions'],
summary: 'Subscription details for an account',
description: 'Simulated — no subscription club, no subscribers.',
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'Account id (digits only)',
schema: { type: 'string' },
},
],
responses: { 200: json(SubscriptionDetailsResponse, 'Zeroed subscription details') },
}),
(c) =>
c.json({
accountId: Number.parseInt(c.req.param('accountId'), 10),
clubId: 0,
subscriberCount: 0,
})
)
// Details for a named subscription (e.g. `rrplus`). The client deserializes this
// into an object, so it must return `{}` (not `[]`).
.get('/subscription/details/:subscription', (c) => c.json({}))
.get(
'/subscription/details/:subscription',
describeRoute({
tags: ['Subscriptions'],
summary: 'Details for a named subscription',
description: [
'A named subscription (e.g. `rrplus`). The client deserializes this into an object,',
'so it must return `{}` — not `[]`.',
].join(' '),
parameters: [
{
name: 'subscription',
in: 'path',
required: true,
description: 'The subscription name, e.g. `rrplus`',
schema: { type: 'string' },
},
],
responses: { 200: json(EmptyObject, 'Always an empty object') },
}),
(c) => c.json({})
)
// Subscriber count for an account. No club subscriptions yet → 0.
.get('/subscription/subscriberCount/:accountId{[0-9]+}', (c) => c.json(0))
.get(
'/subscription/subscriberCount/:accountId{[0-9]+}',
describeRoute({
tags: ['Subscriptions'],
summary: 'Subscriber count for an account',
description: 'No club subscriptions yet, so this is always 0. A bare JSON integer.',
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'Account id (digits only)',
schema: { type: 'string' },
},
],
responses: { 200: json(SubscriberCountResponse, 'Always 0') },
}),
(c) => c.json(0)
)
// The player's clubs that have unread announcements (MyClubsWithUnread-
// Announcements). Nothing tracks what a player has read yet → nothing is unread.
.get('/announcements/v2/mine/unread', (c) => c.json([]))
.get(
'/announcements/v2/mine/unread',
describeRoute({
tags: ['Announcements'],
summary: 'The players clubs with unread announcements',
description: [
'MyClubsWithUnreadAnnouncements. Nothing tracks what a player has read yet, so',
'nothing is unread → always `[]`.',
].join(' '),
responses: { 200: json(JsonArray, 'Always empty for now') },
}),
(c) => c.json([])
)
// A club's announcements — its noticeboard, newest first. Public. Answers the
// envelope, with `LastAnnouncementId` the newest one (null when there are none)
// and `LastReadAnnouncementId` 0: nothing tracks read state yet.
.get('/announcements/club/:clubId{[0-9]+}', async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const announcements = await getClubAnnouncements(c.env.DB, clubId)
return c.json({
error: '',
success: true,
value: {
Announcements: announcements,
ClubId: clubId,
LastAnnouncementId: announcements[0]?.AnnouncementId ?? null,
LastReadAnnouncementId: 0,
},
})
})
.get(
'/announcements/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Announcements'],
summary: 'A clubs announcements',
description: [
'The clubs noticeboard, newest first. Public. Answers the envelope, with',
'`LastAnnouncementId` the newest one (null when there are none) and',
'`LastReadAnnouncementId` 0 — nothing tracks read state yet. An unknown club simply',
'has no announcements.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: { 200: json(ClubAnnouncementsEnvelope, 'The clubs noticeboard') },
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const announcements = await getClubAnnouncements(c.env.DB, clubId)
return c.json({
error: '',
success: true,
value: {
Announcements: announcements,
ClubId: clubId,
LastAnnouncementId: announcements[0]?.AnnouncementId ?? null,
LastReadAnnouncementId: 0,
},
})
}
)
// Post an announcement to a club. Co-owner or above only. The envelope's value is
// the new announcement's id.
.post('/announcements/club/:clubId{[0-9]+}', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
.post(
'/announcements/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Announcements'],
summary: 'Post an announcement to a club',
description: 'Co-owner or above only. The envelopes `value` is the new announcements id.',
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(AnnouncementRequest, 'The announcement fields'),
responses: {
200: json(AnnouncementIdEnvelope, 'The new announcements id'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
return typeof v === 'string' ? v : undefined
}
const announcementId = await createClubAnnouncement(c.env.DB, clubId, id, {
title: field('title'),
body: field('body'),
imageName: field('imageName'),
meta: field('meta'),
})
return c.json({ error: '', success: true, value: announcementId })
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
return typeof v === 'string' ? v : undefined
}
const announcementId = await createClubAnnouncement(c.env.DB, clubId, id, {
title: field('title'),
body: field('body'),
imageName: field('imageName'),
meta: field('meta'),
})
return c.json({ error: '', success: true, value: announcementId })
})
)
// The clubs the player is a member of (GetMyMembershipClubs). Reads the caller's
// memberships from `club_member`. A caller with no valid token has no clubs, so
// this answers an empty list rather than 401ing — the client shows the "my clubs"
// shelf either way, and an error there breaks the screen.
.get('/club/mine/member', async (c) => {
const id = await authedId(c)
if (id === null) return c.json([])
return c.json(await getClubsByMember(c.env.DB, id))
})
.get(
'/club/mine/member',
describeRoute({
tags: ['Clubs'],
summary: 'The clubs the player is a member of',
description: [
'GetMyMembershipClubs — the callers memberships from `club_member`, oldest club',
'first (pending/denied/banned rows excluded). A caller with no valid token has no',
'clubs, so this answers `[]` rather than 401ing: the client shows the “my clubs”',
'shelf either way, and an error there breaks the screen.',
].join(' '),
security: AUTHED,
responses: { 200: json(ClubDto.array(), 'The callers clubs (empty when signed out)') },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.json([])
return c.json(await getClubsByMember(c.env.DB, id))
}
)
// The clubs the player created (GetMyCreatedClubs). Empty list when signed out,
// like mine/member.
.get('/club/mine/created', async (c) => {
const id = await authedId(c)
if (id === null) return c.json([])
return c.json(await getClubsByCreator(c.env.DB, id))
})
.get(
'/club/mine/created',
describeRoute({
tags: ['Clubs'],
summary: 'The clubs the player created',
description: 'GetMyCreatedClubs, oldest first. Empty list when signed out, like mine/member.',
security: AUTHED,
responses: { 200: json(ClubDto.array(), 'The clubs the caller created') },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.json([])
return c.json(await getClubsByCreator(c.env.DB, id))
}
)
// Club search / browse. Public, non-subscription clubs; `category` filters to that
// category, `query` matches the name or description, `sort` picks the order (1 =
// newest, 2 = by name, default = most members first), and `count` caps the page
// (out of range → 30). Public. Answers `{ Clubs, ContinuationToken, TotalClubs }`.
.get('/club/search', async (c) => {
const count = Number.parseInt(c.req.query('count') ?? '', 10)
return c.json(
await searchClubs(
c.env.DB,
c.req.query('category') ?? '',
c.req.query('query') ?? '',
c.req.query('sort'),
Number.isNaN(count) || count <= 0 || count > 100 ? 30 : count
.get(
'/club/search',
describeRoute({
tags: ['Clubs'],
summary: 'Club search / browse',
description: [
'Public, non-subscription clubs. Public (no auth). `TotalClubs` is the full match',
'count, not the page size.',
].join(' '),
parameters: [
{
name: 'category',
in: 'query',
required: false,
description: 'Filter to one category (exact, case-insensitive)',
schema: { type: 'string' },
},
{
name: 'query',
in: 'query',
required: false,
description: 'Substring of the club name or description',
schema: { type: 'string' },
},
{
name: 'sort',
in: 'query',
required: false,
description: '1 = newest first, 2 = by name, anything else = most members first',
schema: { type: 'string' },
},
{
name: 'count',
in: 'query',
required: false,
description: 'Page size; out of range (or absent) falls back to 30',
schema: { type: 'string' },
},
],
responses: { 200: json(ClubSearchResponse, 'The matching page of clubs') },
}),
async (c) => {
const count = Number.parseInt(c.req.query('count') ?? '', 10)
return c.json(
await searchClubs(
c.env.DB,
c.req.query('category') ?? '',
c.req.query('query') ?? '',
c.req.query('sort'),
Number.isNaN(count) || count <= 0 || count > 100 ? 30 : count
)
)
)
})
}
)
// The set of club category tags a club can be filed under — a fixed list.
.get('/club/categoryTags', (c) =>
c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment'])
.get(
'/club/categoryTags',
describeRoute({
tags: ['Clubs'],
summary: 'Club category tags',
description: 'The fixed set of categories a club can be filed under.',
responses: { 200: json(CategoryTags, 'The category list') },
}),
(c) => c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment'])
)
// Create a club. The client posts a form to `/club/create` with lowercase fields
// (`name`, `description`, `category`). Auth-gated. Answers the `{ error, success,
// value }` envelope carrying the new club's details — not a bare club.
.post('/club/create', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
.post(
'/club/create',
describeRoute({
tags: ['Clubs'],
summary: 'Create a club',
description: [
'The client posts a form with lowercase fields (`name`, `description`, `category`);',
'either casing is accepted. Enums arrive by name (`visibility=Public`,',
'`joinability=Open`). `ClubType` is never taken from the client — a player-created',
'club is always a regular one, since letting the client pick would let it mint a',
'subscription club (type 1), which is excluded from every listing. The caller becomes',
'the clubs Creator. Answers the `{ error, success, value }` envelope carrying the new',
'clubs full details — not a bare club.',
].join(' '),
security: AUTHED,
requestBody: form(CreateClubRequest, 'The new clubs fields'),
responses: {
200: json(ClubDetailsEnvelope, 'The new clubs details'),
400: json(
ErrorEnvelope,
'Missing/invalid/too-long name, or the per-account club limit is reached'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
// The client sends lowercase field names; accept either casing.
const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
return typeof v === 'string' ? v : undefined
}
const int = (v: string | undefined): number | undefined => {
const n = v === undefined ? Number.NaN : Number.parseInt(v, 10)
return Number.isNaN(n) ? undefined : n
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
// The client sends lowercase field names; accept either casing.
const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
return typeof v === 'string' ? v : undefined
}
const int = (v: string | undefined): number | undefined => {
const n = v === undefined ? Number.NaN : Number.parseInt(v, 10)
return Number.isNaN(n) ? undefined : n
}
const name = field('name')?.trim() ?? ''
const description = field('description') ?? ''
if (name === '') return clubError(c, 'You must enter a name for your club.')
if (!isValidClubName(name)) {
return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.')
}
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
}
const club = await createClub(c.env.DB, id, {
name,
description,
// An unset category files the club under Social, as the reference does.
category: field('category')?.trim() || 'Social',
visibility: parseVisibility(field('visibility')),
joinability: parseJoinability(field('joinability')),
allowJuniors: parseFormBool(field('allowJuniors')),
mainImageName: field('mainImageName'),
// ClubType is deliberately not taken from the client: a player-created club
// is always a regular one. Letting the client pick would let it mint a
// subscription club (type 1), which is excluded from every club listing.
minLevel: int(field('minLevel')),
})
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, club, id),
})
})
// Edit a club's details. The client PUTs a form of the fields it's changing —
// enums by name (`visibility=Public`, `joinability=Open`, `allowJuniors=True`) —
// and absent fields keep their stored value. `customTags` may repeat; when present
// it replaces the club's tag set wholesale. Co-owner or above only. Answers the
// same `{ error, success, value }` envelope create does.
.put('/club/:clubId{[0-9]+}/modifydetails', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
// Editing details is a co-owner power — plain members and moderators can't.
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
// `all: true` so a repeated `customTags` field arrives as a list.
const body = (await c.req.parseBody({ all: true }).catch(() => ({}))) as Record<string, unknown>
const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
const first = Array.isArray(v) ? v[0] : v
return typeof first === 'string' ? first : undefined
}
const list = (name: string): string[] | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
if (key === undefined) return undefined
const v = body[key]
const values = Array.isArray(v) ? v : [v]
return values.filter((t): t is string => typeof t === 'string')
}
const int = (v: string | undefined): number | undefined => {
const n = v === undefined ? Number.NaN : Number.parseInt(v, 10)
return Number.isNaN(n) ? undefined : n
}
// An empty name/description means "unchanged", not "clear it" — the reference
// only applies these when non-empty.
const name = field('name')?.trim() || undefined
if (name !== undefined) {
const name = field('name')?.trim() ?? ''
const description = field('description') ?? ''
if (name === '') return clubError(c, 'You must enter a name for your club.')
if (!isValidClubName(name)) {
return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.')
}
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
}
// Counted in code points like the name above, so an emoji-heavy description is
// measured the way a player sees it rather than by UTF-16 units.
if (glyphLength(description) > MAX_CLUB_DESCRIPTION_LENGTH) {
return clubError(
c,
`Club descriptions can be at most ${MAX_CLUB_DESCRIPTION_LENGTH} characters.`
)
}
// The per-account cap, checked after the cheap validations so a rejected name
// costs no extra D1 read.
const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT)
if (maxClubs > 0 && (await countClubsByCreator(c.env.DB, id)) >= maxClubs) {
logger.info('club create rejected: per-account club limit', { accountId: id })
return clubError(c, `You can only have ${maxClubs} clubs.`)
}
const club = await createClub(c.env.DB, id, {
name,
description,
// An unset category files the club under Social, as the reference does.
category: field('category')?.trim() || 'Social',
visibility: parseVisibility(field('visibility')),
joinability: parseJoinability(field('joinability')),
allowJuniors: parseFormBool(field('allowJuniors')),
mainImageName: field('mainImageName'),
// ClubType is deliberately not taken from the client: a player-created club
// is always a regular one. Letting the client pick would let it mint a
// subscription club (type 1), which is excluded from every club listing.
minLevel: int(field('minLevel')),
})
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, club, id),
})
}
)
const updated = await updateClub(c.env.DB, clubId, {
name,
description: field('description') || undefined,
category: field('category')?.trim() || undefined,
visibility: parseVisibility(field('visibility')),
joinability: parseJoinability(field('joinability')),
allowJuniors: parseFormBool(field('allowJuniors')),
mainImageName: field('mainImageName') || undefined,
minLevel: int(field('minLevel')),
customTags: list('customTags'),
})
if (updated === null) return c.notFound()
// Edit a club's details. The client PUTs a form of the fields it's changing —
// enums by name (`visibility=Public`, `joinability=Open`, `allowJuniors=True`) —
// and absent fields keep their stored value. `customTags` may repeat; when present
// it replaces the club's tag set wholesale. Co-owner or above only. Answers the
// same `{ error, success, value }` envelope create does.
//
// `/modify` is the same endpoint under the shorter name the client also PUTs to
// (`name=…&description=…&category=…`); one handler, so the two can't drift.
.on(
'PUT',
['/club/:clubId{[0-9]+}/modifydetails', '/club/:clubId{[0-9]+}/modify'],
describeRoute({
tags: ['Clubs'],
summary: 'Edit a clubs details',
description: [
'The client PUTs a form of just the fields its changing — enums by name',
'(`visibility=Public`, `joinability=Open`, `allowJuniors=True`) — and absent fields',
'keep their stored value (an empty `name`/`description` means “unchanged”, not',
'“clear it”). `customTags` may repeat; when present it replaces the clubs tag set',
'wholesale. Co-owner or above only. `/modify` is the same endpoint under the shorter',
'name the client also PUTs to — one handler, so the two cant drift. Answers the same',
'details envelope create does, since the client re-renders the club screen from it.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(ModifyClubRequest, 'The fields to change'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Invalid or too-long name'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
})
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
// Editing details is a co-owner power — plain members and moderators can't.
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
// `all: true` so a repeated `customTags` field arrives as a list.
const body = (await c.req.parseBody({ all: true }).catch(() => ({}))) as Record<
string,
unknown
>
const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
const first = Array.isArray(v) ? v[0] : v
return typeof first === 'string' ? first : undefined
}
const list = (name: string): string[] | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
if (key === undefined) return undefined
const v = body[key]
const values = Array.isArray(v) ? v : [v]
return values.filter((t): t is string => typeof t === 'string')
}
const int = (v: string | undefined): number | undefined => {
const n = v === undefined ? Number.NaN : Number.parseInt(v, 10)
return Number.isNaN(n) ? undefined : n
}
// An empty name/description means "unchanged", not "clear it" — the reference
// only applies these when non-empty.
const name = field('name')?.trim() || undefined
if (name !== undefined) {
if (!isValidClubName(name)) {
return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.')
}
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
}
}
// Same absent-means-unchanged rule as the name, so a club with no description
// isn't forced to grow one just to be edited.
const description = field('description') || undefined
if (description !== undefined && glyphLength(description) > MAX_CLUB_DESCRIPTION_LENGTH) {
return clubError(
c,
`Club descriptions can be at most ${MAX_CLUB_DESCRIPTION_LENGTH} characters.`
)
}
const updated = await updateClub(c.env.DB, clubId, {
name,
description,
category: field('category')?.trim() || undefined,
visibility: parseVisibility(field('visibility')),
joinability: parseJoinability(field('joinability')),
allowJuniors: parseFormBool(field('allowJuniors')),
mainImageName: field('mainImageName') || undefined,
minLevel: int(field('minLevel')),
customTags: list('customTags'),
})
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
}
)
// A club's full details — the club plus its tags, the per-tier permissions, and the
// caller's own membership. Public (a signed-out viewer just gets MyMembershipType
// 0). Unlike create/modifydetails this one is *not* enveloped: the reference writes
// the details object straight out.
.get('/club/:clubId{[0-9]+}/details', async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const id = await authedId(c)
return c.json(await getClubDetails(c.env.DB, club, id))
})
.get(
'/club/:clubId{[0-9]+}/details',
describeRoute({
tags: ['Clubs'],
summary: 'A clubs full details',
description: [
'The club plus its custom tags, the per-tier permissions, its gallery, and the',
'callers own membership. Public — a signed-out viewer just gets `MyMembershipType` 0.',
'Unlike create/modifydetails this one is NOT enveloped: the details object is written',
'straight out, as the reference does.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsDto, 'The clubs details (not enveloped)'),
404: { description: 'No such club' },
},
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const id = await authedId(c)
return c.json(await getClubDetails(c.env.DB, club, id))
}
)
// Whether a club has turned its club chat off. Nothing can disable club chat yet
// (no setting, no storage), so chat is always on → `false`. A bare JSON boolean,
// like the other `is…`/`has…` gates the client polls; not in the reference, so if
// the client chokes on this it likely wants the `{ error, success, value }`
// envelope the other club endpoints use.
.get('/club/:clubId{[0-9]+}/hasDisabledClubChat', (c) => c.json(false))
.get(
'/club/:clubId{[0-9]+}/hasDisabledClubChat',
describeRoute({
tags: ['Clubs'],
summary: 'Whether the club has turned club chat off',
description: [
'Nothing can disable club chat yet (no setting, no storage), so chat is always on →',
'`false`. A bare JSON boolean, like the other `is…`/`has…` gates the client polls; not',
'in the reference, so if the client chokes on this it likely wants the',
'`{ error, success, value }` envelope the other club endpoints use.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: { 200: json(ChatDisabledResponse, 'Always false') },
}),
(c) => c.json(false)
)
// A club's members. `membershipType` filters to exactly that tier (an exact match,
// not a threshold — `30` lists co-owners only, not the creator above them), and
// `sortBy` picks the order (1 = account id, 2 = oldest first, default = highest
// tier first). Public, and an unknown club is an empty list. Answers the envelope.
.get('/club/:clubId{[0-9]+}/members', async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const raw = c.req.query('membershipType')
const membershipType = raw === undefined ? Number.NaN : Number.parseInt(raw, 10)
.get(
'/club/:clubId{[0-9]+}/members',
describeRoute({
tags: ['Membership'],
summary: 'A clubs members',
description:
'Public, and an unknown club is an empty list rather than a 404. Answers the envelope.',
parameters: [
CLUB_ID_PARAM,
{
name: 'membershipType',
in: 'query',
required: false,
description: [
'Filter to exactly that tier — an exact match, not a threshold, so `30` lists',
'co-owners only, not the creator above them',
].join(' '),
schema: { type: 'string' },
},
{
name: 'sortBy',
in: 'query',
required: false,
description: '1 = account id, 2 = oldest first, anything else = highest tier first',
schema: { type: 'string' },
},
],
responses: { 200: json(ClubMembersEnvelope, 'The clubs membership rows') },
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const raw = c.req.query('membershipType')
const membershipType = raw === undefined ? Number.NaN : Number.parseInt(raw, 10)
const members = await getClubMembers(
c.env.DB,
clubId,
Number.isNaN(membershipType) ? undefined : membershipType,
c.req.query('sortBy')
)
return c.json({ error: '', success: true, value: members })
})
const members = await getClubMembers(
c.env.DB,
clubId,
Number.isNaN(membershipType) ? undefined : membershipType,
c.req.query('sortBy')
)
return c.json({ error: '', success: true, value: members })
}
)
// Set the minimum player level required to join the club. The reference has no such
// route (it only takes `minLevel` on modifydetails), but the client PUTs it here.
// Same rules as the other club edits: co-owner or above, and the details envelope.
.put('/club/:clubId{[0-9]+}/minlevel', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
.put(
'/club/:clubId{[0-9]+}/minlevel',
describeRoute({
tags: ['Clubs'],
summary: 'Set the clubs minimum join level',
description: [
'The reference has no such route (it only takes `minLevel` on modifydetails), but the',
'client PUTs it here. Same rules as the other club edits: co-owner or above, and the',
'details envelope back.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(MinLevelRequest, 'The new minimum level'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Missing, non-numeric or negative minLevel'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'minlevel')
const minLevel = Number.parseInt(
typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '',
10
)
if (Number.isNaN(minLevel) || minLevel < 0) return clubError(c, 'Invalid minLevel.')
const updated = await updateClub(c.env.DB, clubId, { minLevel })
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'minlevel')
const minLevel = Number.parseInt(
typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '',
10
)
if (Number.isNaN(minLevel) || minLevel < 0) return clubError(c, 'Invalid minLevel.')
const updated = await updateClub(c.env.DB, clubId, { minLevel })
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
})
)
// Set (or clear) the club's clubhouse room — the room players spawn into when the
// club is their home. `roomId` sets it; omitting it clears the clubhouse. Co-owner
// or above only. Answers the envelope with a null value, as the reference does.
.put('/club/:clubId{[0-9]+}/clubhouse', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
// or above only. Answers the details envelope (the reference returns a null value
// here, but the client re-renders from the response and leaves the old clubhouse
// on screen unless it gets the updated club back).
//
// DELETE is the same thing with the clearing spelled out — it ignores any body and
// always unsets the room, so "remove the clubhouse" doesn't depend on the client
// remembering to send an empty PUT.
.on(
['PUT', 'DELETE'],
'/club/:clubId{[0-9]+}/clubhouse',
describeRoute({
tags: ['Clubs'],
summary: 'Set or clear the clubs clubhouse room',
description: [
'The clubhouse is the room players spawn into when the club is their home. PUT with',
'`roomId` sets it; omitting `roomId` clears it. DELETE is the same thing with the',
'clearing spelled out — it ignores any body and always unsets the room, so “remove the',
'clubhouse” doesnt depend on the client remembering to send an empty PUT. Co-owner or',
'above only. Answers the full details envelope: the reference returns a null value',
'here, but the client re-renders from the response and leaves the old clubhouse on',
'screen unless it gets the updated club back.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(ClubhouseRequest, 'The clubhouse room (PUT only; DELETE ignores the body)'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Non-numeric roomId'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
let roomId: number | null = null
if (c.req.method !== 'DELETE') {
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'roomid')
const raw = typeof body[key ?? ''] === 'string' ? String(body[key ?? '']).trim() : ''
if (raw !== '' && Number.isNaN(Number.parseInt(raw, 10))) {
return clubError(c, 'Invalid roomId.')
}
roomId = raw === '' ? null : Number.parseInt(raw, 10)
}
const updated = await updateClub(c.env.DB, clubId, { clubhouseRoomId: roomId })
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'roomid')
const raw = typeof body[key ?? ''] === 'string' ? String(body[key ?? '']).trim() : ''
if (raw !== '' && Number.isNaN(Number.parseInt(raw, 10))) {
return clubError(c, 'Invalid roomId.')
}
await updateClub(c.env.DB, clubId, {
clubhouseRoomId: raw === '' ? null : Number.parseInt(raw, 10),
})
return c.json({ error: '', success: true, value: null })
})
)
// The club's main image. PUT sets it from an uploaded image's `imageName` (the
// name the `storage` worker handed back); co-owner or above only. GET reads it —
// the reference has no GET here (it 404s), but the client asks for it, so this
// answers the same details envelope rather than erroring; the image name is on
// `value.Club.MainImageName`.
.get('/club/:clubId{[0-9]+}/mainimage', async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const id = await authedId(c)
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, club, id),
})
})
.put('/club/:clubId{[0-9]+}/mainimage', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
.get(
'/club/:clubId{[0-9]+}/mainimage',
describeRoute({
tags: ['Images'],
summary: 'Read the clubs main image',
description: [
'The reference has no GET here (it 404s), but the client asks for it, so this answers',
'the same details envelope rather than erroring; the image name is on',
'`value.Club.MainImageName`. Public.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsEnvelope, 'The clubs details, carrying MainImageName'),
404: { description: 'No such club' },
},
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const id = await authedId(c)
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, club, id),
})
}
)
.put(
'/club/:clubId{[0-9]+}/mainimage',
describeRoute({
tags: ['Images'],
summary: 'Set the clubs main image',
description: [
'Sets the main image from an uploaded images `imageName` (the name the `storage`',
'worker handed back). Co-owner or above only. Answers the details envelope.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(ImageNameRequest, 'The uploaded images name'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Missing imageName'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename')
const imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : ''
if (imageName === '') return clubError(c, 'imageName is required.')
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const updated = await updateClub(c.env.DB, clubId, { mainImageName: imageName })
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
})
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename')
const imageName =
typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : ''
if (imageName === '') return clubError(c, 'imageName is required.')
const updated = await updateClub(c.env.DB, clubId, { mainImageName: imageName })
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
}
)
// One of the club's gallery images, by position (`/additionalimage/{index}`, 0-based
// — the client PUTs the first image to 0, the second to 1). Takes the same
// `imageName` the `storage` worker handed back. Co-owner or above, like the main
// image. The list is packed: a PUT past the end appends rather than leaving a gap,
// and the images come back in order on `value.AdditionalImages`.
//
// DELETE removes that position's image and shifts the rest up, so there's never a
// blank slot in the gallery. It ignores any body, so it can't accidentally set an
// image instead, and deleting a position that holds nothing is a no-op.
.on(
['PUT', 'DELETE'],
'/club/:clubId{[0-9]+}/additionalimage/:index{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'Set or remove one of the clubs gallery images',
description: [
'One gallery image by position (0-based — the client PUTs the first image to 0, the',
'second to 1), taking the same `imageName` the `storage` worker handed back. Co-owner',
'or above, like the main image. The list is PACKED, never sparse: a PUT past the end',
'appends rather than leaving a gap, and DELETE removes that position and shifts the',
'rest up, so theres never a blank slot. DELETE ignores any body (so it cant',
'accidentally set an image instead) and deleting an empty position is a no-op. The',
'images come back on `value.AdditionalImages` as whole image records, in order — a',
'bare array of names fails the clients parser.',
].join(' '),
security: AUTHED,
parameters: [
CLUB_ID_PARAM,
{
name: 'index',
in: 'path',
required: true,
description: 'The 0-based gallery slot; a club has 3 slots (02)',
schema: { type: 'string' },
},
],
requestBody: form(ImageNameRequest, 'The uploaded images name (PUT only)'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'The index is past the clubs gallery slots'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
const index = Number.parseInt(c.req.param('index'), 10)
if (index >= MAX_ADDITIONAL_IMAGES) {
return clubError(c, `A club has ${MAX_ADDITIONAL_IMAGES} additional image slots (0-based).`)
}
let imageName = ''
if (c.req.method !== 'DELETE') {
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename')
imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : ''
}
const updated = await setClubAdditionalImage(c.env.DB, clubId, index, imageName)
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
}
)
// A single club by id. 404 when the club isn't in the DB. Public.
.get('/club/:clubId{[0-9]+}', async (c) => {
const club = await getClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10))
return club ? c.json(club) : c.notFound()
})
.get(
'/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Clubs'],
summary: 'A single club by id',
description: 'The bare club (not the details view, not enveloped). Public.',
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDto, 'The club'),
404: { description: 'No such club' },
},
}),
async (c) => {
const club = await getClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10))
return club ? c.json(club) : c.notFound()
}
)
// Delete a club, along with its memberships and announcements. The creator only —
// not co-owners, who can edit a club but can't destroy one — which is also the way
// out for a creator, since they aren't allowed to leave (see /members/leave).
// Answers the envelope with a null value; the club is gone, so there are no details
// left to return.
.delete(
'/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Clubs'],
summary: 'Delete a club',
description: [
'Deletes the club along with its memberships and announcements, and clears it from the',
'home club of anyone whod set it. The creator only — not co-owners, who can edit a',
'club but cant destroy one — which is also the way out for a creator, since they',
'arent allowed to leave. The envelopes `value` is null: the club is gone, so there',
'are no details left to return.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(NullEnvelope, 'Deleted (value null)'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Not the clubs creator'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Creator) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
await deleteClub(c.env.DB, clubId)
return c.json({ error: '', success: true, value: null })
}
)
// Ask to join a club. No body — the club id and the Bearer token are the whole
// request. What it does depends on the club's Joinability: an Open club takes the
// caller straight in as a Member, an AskToJoin club records a PendingRequested row
// for a co-owner to approve, and an InviteOnly club refuses (you can only get in
// through an invite). Repeats are idempotent; a banned account stays out. Answers
// the details envelope so the client can read its new `MyMembershipType`.
.put(
'/club/:clubId{[0-9]+}/members/requesttojoin',
describeRoute({
tags: ['Membership'],
summary: 'Ask to join a club',
description: [
'No body — the club id and the Bearer token are the whole request. What it does',
'depends on the clubs Joinability: an Open club takes the caller straight in as a',
'Member, an AskToJoin club records a PendingRequested row for a co-owner to approve,',
'and an InviteOnly club refuses (you can only get in through an invite). Repeats are',
'idempotent; a banned account stays out. Answers the details envelope so the client',
'can read its new `MyMembershipType`.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsEnvelope, 'The clubs details, with the callers new membership'),
400: json(ErrorEnvelope, 'The club is invite only'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The caller is banned from the club'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const outcome = await requestToJoinClub(c.env.DB, clubId, id)
if (outcome === null) return c.notFound()
if (outcome.result === 'inviteOnly') {
return clubError(c, 'This club is invite only.')
}
if (outcome.result === 'banned') {
return c.json({ error: 'You are banned from this club.', success: false, value: null }, 403)
}
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, outcome.club, id),
})
}
)
// Invite an account into the club at a given tier — the co-owner's "add member" /
// role-assignment write. `accountId` is who to add and `membershipType` the tier they
// get (10 Member, 20 Moderator, 30 Co-owner); the client sends both as form fields.
// Co-owner or above only. The membership is upserted, so this also promotes/demotes an
// existing member and overrides a ban — but it can't mint another Creator (100) and it
// can't touch the club's own Creator. Answers the details envelope, like the other
// membership writes.
.put(
'/club/:clubId{[0-9]+}/members/invite',
describeRoute({
tags: ['Membership'],
summary: 'Invite an account into the club',
description: [
'Adds `accountId` to the club at `membershipType` (10 Member, 20 Moderator, 30',
'Co-owner) — the co-owners “add member” / role-assignment write; both arrive as form',
'fields, and an absent `membershipType` defaults to Member. Co-owner or above only. The',
'membership is upserted, so this also promotes/demotes an existing member and overrides',
'a ban; it cant mint another Creator (100) or change the clubs own Creator. Answers the',
'details envelope, like the other membership writes.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(InviteMemberRequest, 'The account to add and the tier to grant'),
responses: {
200: json(ClubDetailsEnvelope, 'The clubs details after the invite'),
400: json(
ErrorEnvelope,
'Missing/invalid accountId, a tier outside Member/Moderator/Co-owner, or targeting the creator'
),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound()
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Coowner) {
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
return typeof v === 'string' ? v : undefined
}
const accountId = Number.parseInt(field('accountId') ?? '', 10)
if (Number.isNaN(accountId) || accountId <= 0) return clubError(c, 'Invalid accountId.')
// An absent tier means "add as a plain Member"; anything present must be one of the
// grantable roles (in particular not Creator), so an invite can't mint a second owner.
const rawType = field('membershipType')
const membershipType =
rawType === undefined || rawType.trim() === ''
? ClubMembershipType.Member
: Number.parseInt(rawType, 10)
if (!INVITABLE_TIERS.has(membershipType)) return clubError(c, 'Invalid membershipType.')
// The Creator is fixed — you can't demote them or promote someone over them.
if (accountId === club.CreatorAccountId) {
return clubError(c, 'You cant change the clubs creator.')
}
const updated = await setMemberType(c.env.DB, clubId, accountId, membershipType)
if (updated === null) return c.notFound()
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, updated, id),
})
}
)
// Leave a club. No body, like requesttojoin — the club id and the Bearer token are
// the whole request. Idempotent (leaving a club you're not in is a no-op), and it
// also withdraws a pending request; a ban is preserved, since you can't clear one
// by leaving. The creator is refused — they'd leave the club ownerless, so they
// have to delete it instead. Answers the details envelope so the client sees
// `MyMembershipType` drop to 0 (or stay at -1 for a banned account).
.post(
'/club/:clubId{[0-9]+}/members/leave',
describeRoute({
tags: ['Membership'],
summary: 'Leave a club',
description: [
'No body, like requesttojoin. Idempotent (leaving a club youre not in is a no-op),',
'and it also withdraws a pending request; a ban is preserved, since you cant clear',
'one by leaving. The creator is refused — theyd leave the club ownerless, so they',
'have to delete it instead. Answers the details envelope so the client sees',
'`MyMembershipType` drop to 0 (or stay at -1 for a banned account).',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsEnvelope, 'The clubs details, with the callers membership gone'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The creator cant leave — delete the club instead'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const outcome = await leaveClub(c.env.DB, clubId, id)
if (outcome === null) return c.notFound()
if (outcome.result === 'creator') {
return c.json(
{
error: 'You created this club — delete it instead of leaving.',
success: false,
value: null,
},
403
)
}
return c.json({
error: '',
success: true,
value: await getClubDetails(c.env.DB, outcome.club, id),
})
}
)
// Join / leave a club (auth-gated, idempotent). Both return the club with its
// refreshed MemberCount; 404 when the club doesn't exist.
.post('/club/:clubId{[0-9]+}/join', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const club = await joinClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
return club ? c.json(club) : c.notFound()
})
.post('/club/:clubId{[0-9]+}/leave', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const club = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
return club ? c.json(club) : c.notFound()
})
.post(
'/club/:clubId{[0-9]+}/join',
describeRoute({
tags: ['Membership'],
summary: 'Join a club',
description: [
'Auth-gated and idempotent. On an Open club the caller becomes a Member immediately;',
'on an InviteOnly/AskToJoin club the join is recorded as PendingRequested, and a ban',
'cant be shed by re-joining. Returns the bare club with its refreshed MemberCount',
'(not the details envelope — see members/requesttojoin for that).',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDto, 'The club, with its refreshed MemberCount'),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const club = await joinClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
return club ? c.json(club) : c.notFound()
}
)
// Leaving is refused for the creator here too (see /members/leave), so the two
// routes can't disagree about who's still in the club.
.post(
'/club/:clubId{[0-9]+}/leave',
describeRoute({
tags: ['Membership'],
summary: 'Leave a club (bare-club form)',
description: [
'The counterpart to `/join`: returns the bare club with its refreshed MemberCount',
'rather than the details envelope. Leaving is refused for the creator here too (see',
'`/members/leave`), so the two routes cant disagree about whos still in the club.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDto, 'The club, with its refreshed MemberCount'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The creator cant leave — delete the club instead'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const outcome = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
if (outcome === null) return c.notFound()
if (outcome.result === 'creator') {
return c.json(
{
error: 'You created this club — delete it instead of leaving.',
success: false,
value: null,
},
403
)
}
return c.json(outcome.club)
}
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare clubs',
version: '1.0.0',
description: [
'Club endpoints for recflare, a private-server reimplementation of the Rec Room',
'backend. The client calls these on the `clubs` host: club creation and editing,',
'membership (join / ask-to-join / leave, with the ban and pending tiers),',
'search, announcements, the club gallery and clubhouse room, and each players home',
'club. Everything is D1-backed on the shared `recflare` database; the',
'`/subscription/*` routes are stubs, since there are no subscription clubs yet.',
'',
'Most writes answer the `{ error, success, value }` envelope with HTTP 200, and the',
'ones the client re-renders a club screen from carry the clubs FULL details as',
'`value` rather than null.',
].join('\n'),
},
servers: [{ url: 'https://clubs.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+6
View File
@@ -8,6 +8,12 @@ export type Env = SharedHonoEnv & {
JWT_SECRET: SecretsStoreSecret
// Shared `recflare` D1 database holding the club / club_member tables. See clubs-db.ts.
DB: D1Database
// How many clubs one account may create (optional). Unset falls back to
// DEFAULT_MAX_CLUBS_PER_ACCOUNT in clubs.app.ts; 0 lifts the cap. Typed
// `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
// number while the same var set from the dashboard or `--var` arrives as a string —
// read it through `intVar`, never as a bare number.
MAX_CLUBS_PER_ACCOUNT?: string | number
// add additional Bindings here
}
+350
View File
@@ -0,0 +1,350 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the clubs worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/econ/match workers: a reverse-engineered protocol,
* lenient handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** The empty-body 401 the auth-gated routes return. */
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/** An opaque JSON array — the empty-list stubs (`[]`) the client still expects. */
export const JsonArray = z.array(z.unknown())
/** An empty JSON object — the stub shape the client deserializes into an object. */
export const EmptyObject = z.object({})
// ---- Core entities ---------------------------------------------------------
/**
* The client-facing club DTO (mirror of the Go `Club` JSON tags). The stored blob also
* carries `CreatedAt`, `CustomTags` and `AdditionalImages`, none of which are on this
* object the tags and gallery are served on the details view instead.
*/
export const ClubDto = z.object({
ClubId: z.int(),
Name: z.string().describe('At most 40 characters; letters, digits and basic punctuation'),
Description: z.string(),
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
Joinability: z.int().describe('ClubJoinability: 0 = Open, 1 = InviteOnly, 2 = AskToJoin'),
AllowJuniors: z.boolean(),
MainImageName: z.string().describe('An image name from the storage worker; DefaultImgPurple'),
ClubType: z.int().describe('0 = a regular club; 1 = a subscription club (never listed)'),
ClubhouseRoomId: z.int().nullable().describe('The room a home-club member spawns into'),
CreatorAccountId: z.int(),
IsRRO: z.boolean(),
MinLevel: z.int(),
State: z.int(),
MemberCount: z.int().describe('Derived from the club_member rows at/above Member (10)'),
})
/**
* An image record as every image on the site is served (`SavedImage`). A club's gallery
* serves these whole see AdditionalImages on the details view.
*/
export const SavedImageDto = z.object({
Id: z.int(),
Type: z.int().describe('SavedImageType: 1 = share camera, 3 = room, 4 = profile, …'),
Accessibility: z.int(),
AccessibilityLocked: z.boolean(),
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
Description: z.string().nullable(),
PlayerId: z.int(),
TaggedPlayerIds: z.array(z.int()),
RoomId: z.int().nullable(),
PlayerEventId: z.int().nullable(),
CreatedAt: z.string(),
CheerCount: z.int(),
CommentCount: z.int(),
})
/**
* What a membership tier may do in a club. These are the defaults every club gets
* (co-owners everything, moderators approve/ban, members none); nothing edits them yet,
* so they're derived per club rather than stored.
*/
export const ClubPermissionDto = z.object({
ClubId: z.int(),
Type: z.int().describe('The ClubMembershipType tier these permissions describe'),
ApproveMember: z.boolean(),
BanUnban: z.boolean(),
CreateEvent: z.boolean(),
EditDetails: z.boolean(),
EditPermissionSettings: z.boolean(),
PostAnnouncement: z.boolean(),
})
/**
* The club-details payload the client renders a club screen from: the club, its tags,
* the per-tier permissions, its gallery, and the caller's own membership.
*/
export const ClubDetailsDto = z.object({
AdditionalImages: z
.array(SavedImageDto)
.describe(
[
'The clubs gallery as WHOLE image records, not image names — the client',
'deserializes each entry into an object, so a bare array of names fails its parser',
'("expected \'{\'"). The list is packed and in order: removing an image shifts the',
'rest up, never leaving a blank slot.',
].join(' ')
),
Club: ClubDto,
ClubId: z.int(),
CoownerPermissions: ClubPermissionDto,
CustomTags: z.array(z.string()).describe('Set wholesale by modifydetails repeated customTags'),
MemberPermissions: ClubPermissionDto,
ModeratorPermissions: ClubPermissionDto,
MyMembershipType: z
.int()
.describe(
[
'The callers own ClubMembershipType: -1 banned, 0 none (also a signed-out viewer),',
'1 pending request, 2 pending invite, 3 denied, 10 member, 20 moderator, 30 co-owner,',
'100 creator',
].join(' ')
),
})
/** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */
export const ClubMemberDto = z.object({
ClubMemberId: z.int(),
ClubId: z.int(),
AccountId: z.int(),
MembershipType: z.int().describe('See MyMembershipType for the tiers'),
CreatedAt: z.string().nullable().describe('When the membership row was first written'),
})
/** One entry on a club's noticeboard (mirror of the Go `ClubAnnouncement`). */
export const ClubAnnouncementDto = z.object({
AnnouncementId: z.int(),
ClubId: z.int(),
AccountId: z.int().describe('Who posted it'),
Title: z.string(),
Body: z.string(),
ImageName: z.string(),
Meta: z.string(),
CreatedAt: z.string().nullable(),
})
// ---- Envelopes -------------------------------------------------------------
//
// Most club writes answer the `{ error, success, value }` envelope with HTTP 200 (or
// 400/403 carrying the same shape with `success: false`). The envelope's `value` is the
// entity the client re-renders from, so routes that change a club return the FULL
// details view rather than a null value — `PUT /club/:id/clubhouse` left the old
// clubhouse on screen until it answered the details envelope.
/** The success envelope carrying a club's full details. */
export const ClubDetailsEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: ClubDetailsDto,
})
/** The success envelope carrying a bare club (`PUT /club/home/me`). */
export const ClubEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: ClubDto,
})
/**
* The envelope with nothing left to describe clearing the home club, deleting a club.
* Only used where the entity is genuinely gone; anything the client re-renders from
* returns the details envelope instead.
*/
export const NullEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.null(),
})
/** A rejected action: the same envelope, carrying the message the client shows. */
export const ErrorEnvelope = z.object({
error: z.string().describe('The message shown to the player'),
success: z.boolean().describe('Always false'),
value: z.null(),
})
/** The envelope carrying a club's members (`GET /club/:clubId/members`). */
export const ClubMembersEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.array(ClubMemberDto),
})
/** The envelope carrying a club's noticeboard (`GET /announcements/club/:clubId`). */
export const ClubAnnouncementsEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.object({
Announcements: z.array(ClubAnnouncementDto).describe('Newest first'),
ClubId: z.int(),
LastAnnouncementId: z.int().nullable().describe('The newest one; null when there are none'),
LastReadAnnouncementId: z.int().describe('Always 0 — nothing tracks read state yet'),
}),
})
/** The envelope carrying a new announcement's id (`POST /announcements/club/:clubId`). */
export const AnnouncementIdEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.int().describe('The new announcements id'),
})
// ---- Other response shapes -------------------------------------------------
/** `GET /club/search` — a page of clubs plus the full match count. */
export const ClubSearchResponse = z.object({
Clubs: z.array(ClubDto),
ContinuationToken: z.null().describe('Always null — the whole page is served at once'),
TotalClubs: z.int().describe('How many clubs matched, not the page size'),
})
/** `GET /subscription/details/:accountId` — simulated: no club, no subscribers. */
export const SubscriptionDetailsResponse = z.object({
accountId: z.int(),
clubId: z.int().describe('Always 0 — no subscription clubs yet'),
subscriberCount: z.int().describe('Always 0'),
})
/** The set of category tags a club can be filed under — a fixed list. */
export const CategoryTags = z.array(z.string())
/** `GET /subscription/subscriberCount/:accountId` — a bare JSON integer. */
export const SubscriberCountResponse = z
.int()
.describe('Always 0 — there are no club subscriptions yet')
/**
* `GET /club/:clubId/hasDisabledClubChat` a bare JSON boolean, like the other
* `is…`/`has…` gates the client polls. Nothing can turn club chat off yet, so it's
* always false; not in the reference, so if the client chokes on this it likely wants
* the `{ error, success, value }` envelope the other club endpoints use.
*/
export const ChatDisabledResponse = z.boolean()
// ---- Request schemas -------------------------------------------------------
//
// Every write takes a form body (urlencoded or multipart — the client posts both) with
// lowercase field names; the handlers match field names case-insensitively.
/** `POST /club/create` form body. */
export const CreateClubRequest = z.object({
name: z
.string()
.describe('Required; at most 40 characters, letters/digits/basic punctuation only'),
description: z.string().optional().describe('At most 512 characters'),
category: z.string().optional().describe('Defaults to Social when unset'),
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
joinability: z
.string()
.optional()
.describe('By name (`Open`/`InviteOnly`/`AskToJoin`) or number'),
allowJuniors: z.string().optional().describe('`True`/`false`/`1`/`yes`'),
mainImageName: z.string().optional(),
minLevel: z.string().optional(),
})
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
export const ModifyClubRequest = z.object({
name: z
.string()
.optional()
.describe('At most 40 characters. Empty means unchanged, not "clear it"'),
description: z.string().optional().describe('At most 512 characters. Empty means unchanged'),
category: z.string().optional(),
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
joinability: z
.string()
.optional()
.describe('By name (`Open`/`InviteOnly`/`AskToJoin`) or number'),
allowJuniors: z.string().optional().describe('`True`/`false`/`1`/`yes`'),
mainImageName: z.string().optional(),
minLevel: z.string().optional(),
customTags: z
.array(z.string())
.optional()
.describe('May repeat; when present it replaces the clubs tag set wholesale'),
})
/** `PUT /club/home/me` form body. */
export const HomeClubRequest = z.object({
clubId: z.string().describe('The club to make home; the caller must be a member of it'),
})
/** `PUT /club/:clubId/minlevel` form body. */
export const MinLevelRequest = z.object({
minLevel: z.string().describe('The minimum player level to join; negative/NaN is 400'),
})
/** `PUT /club/:clubId/clubhouse` form body. */
export const ClubhouseRequest = z.object({
roomId: z.string().optional().describe('The clubhouse room; omitting it clears the clubhouse'),
})
/** `PUT /club/:clubId/mainimage` and `/additionalimage/:index` form body. */
export const ImageNameRequest = z.object({
imageName: z.string().describe('The image name the `storage` worker handed back'),
})
/** `PUT /club/:clubId/members/invite` form body. */
export const InviteMemberRequest = z.object({
accountId: z.string().describe('The account to add to the club; a positive integer'),
membershipType: z
.string()
.optional()
.describe('The tier to grant — 10 Member, 20 Moderator, 30 Co-owner; defaults to Member'),
})
/** `POST /announcements/club/:clubId` form body. */
export const AnnouncementRequest = z.object({
title: z.string().optional(),
body: z.string().optional(),
imageName: z.string().optional(),
meta: z.string().optional(),
})
+581 -7
View File
@@ -28,6 +28,16 @@ beforeAll(async () => {
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL
)`
).run()
// Image metadata (owned by the img worker, written by api on upload) — a club's
// gallery serves the whole image record behind each stored image name.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS image (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL,
image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL
)`
).run()
const insertAccount = env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
await env.DB.batch(
[42, 9100, 9101].map((accountId) =>
@@ -231,9 +241,16 @@ describe('clubs endpoints', () => {
expect(emoji.status).toBe(400)
expect(await emoji.json()).toMatchObject({ success: false, value: null })
// Names cap at 16 characters.
expect((await create({ name: 'a'.repeat(17) })).status).toBe(400)
expect((await create({ name: 'a'.repeat(16) })).status).toBe(200)
// Names cap at 40 characters.
expect((await create({ name: 'a'.repeat(41) })).status).toBe(400)
expect((await create({ name: 'a'.repeat(40) })).status).toBe(200)
// Descriptions cap at 512. Counted in code points, so an emoji-heavy one isn't
// refused at half the length a player can see (the description has no charset rule
// — only the name does).
expect((await create({ name: 'DescTooLong', description: 'd'.repeat(513) })).status).toBe(400)
expect((await create({ name: 'DescAtLimit', description: 'd'.repeat(512) })).status).toBe(200)
expect((await create({ name: 'DescEmoji', description: '🎉'.repeat(512) })).status).toBe(200)
// Basic punctuation is allowed.
expect((await create({ name: "Bob's Club (2)" })).status).toBe(200)
@@ -325,6 +342,25 @@ describe('clubs endpoints', () => {
body: 'name=Ghost',
})
expect(missing.status).toBe(404)
// /modify is the same endpoint under the client's shorter name.
const short = async (sub: string) =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/modify`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=my%20club&description=rock%20out&category=Casual',
})
const renamed = (await (await short('6000')).json()) as Details
expect(renamed.value.Club).toMatchObject({
Name: 'my club',
Description: 'rock out',
Category: 'Casual',
})
// ...and it's gated the same way.
expect((await short('6001')).status).toBe(403)
expect(
(await exports.default.fetch(`${ORIGIN}/club/${clubId}/modify`, { method: 'PUT' })).status
).toBe(401)
})
test('GET/PUT /club/:id/mainimage reads and sets the club image, co-owner only', async () => {
@@ -473,26 +509,255 @@ describe('clubs endpoints', () => {
// Give it a clubhouse → the home club now resolves.
const clubhouse = await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=77', '9100')
expect(clubhouse.status).toBe(200)
expect(await clubhouse.json()).toEqual({ error: '', success: true, value: null })
// The details envelope carries the new room back — the client re-renders from it.
const setRoom = (await clubhouse.json()) as {
error: string
success: boolean
value: { Club: { ClubhouseRoomId: number | null } }
}
expect(setRoom).toMatchObject({ error: '', success: true })
expect(setRoom.value.Club.ClubhouseRoomId).toBe(77)
const home = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
headers: await bearer('9100'),
})
expect((await home.json()) as Club).toMatchObject({ ClubId: clubId, ClubhouseRoomId: 77 })
// Clearing the clubhouse takes the home club away again.
await form(`/club/${clubId}/clubhouse`, 'PUT', '', '9100')
// Clearing the clubhouse takes the home club away again — and reports the cleared
// room, so the client doesn't keep showing the old one.
const clear = (await (await form(`/club/${clubId}/clubhouse`, 'PUT', '', '9100')).json()) as {
value: { Club: { ClubhouseRoomId: number | null } }
}
expect(clear.value.Club.ClubhouseRoomId).toBeNull()
const cleared = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
headers: await bearer('9100'),
})
expect(cleared.status).toBe(404)
// Only co-owners may set the clubhouse; signed out is a 401 on both.
// DELETE clears it too, ignoring any body it's sent.
await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=88', '9100')
const deleted = (await (
await form(`/club/${clubId}/clubhouse`, 'DELETE', 'roomId=99', '9100')
).json()) as { success: boolean; value: { Club: { ClubhouseRoomId: number | null } } }
expect(deleted.success).toBe(true)
expect(deleted.value.Club.ClubhouseRoomId).toBeNull()
// DELETE /club/home/me drops the home club without touching the membership, and
// is idempotent when there's none set.
await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=77', '9100')
await form('/club/home/me', 'PUT', `clubId=${clubId}`, '9100')
const dropped = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
method: 'DELETE',
headers: await bearer('9100'),
})
expect(dropped.status).toBe(200)
expect(await dropped.json()).toEqual({ error: '', success: true, value: null })
expect(
(await exports.default.fetch(`${ORIGIN}/club/home/me`, { headers: await bearer('9100') }))
.status
).toBe(404)
// Still a member of the club they'd made their home.
const mine = (await (
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('9100') })
).json()) as Club[]
expect(mine.map((c) => c.ClubId)).toContain(clubId)
// Clearing again, and clearing when nothing was set, both succeed.
for (const sub of ['9100', '9101']) {
const again = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
method: 'DELETE',
headers: await bearer(sub),
})
expect(again.status).toBe(200)
}
expect(
(await exports.default.fetch(`${ORIGIN}/club/home/me`, { method: 'DELETE' })).status
).toBe(401)
// Only co-owners may set or clear the clubhouse; signed out is a 401 on both.
expect((await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=1', '9101')).status).toBe(403)
expect((await form(`/club/${clubId}/clubhouse`, 'DELETE', '', '9101')).status).toBe(403)
expect(
(await exports.default.fetch(`${ORIGIN}/club/${clubId}/clubhouse`, { method: 'DELETE' }))
.status
).toBe(401)
const anon = await exports.default.fetch(`${ORIGIN}/club/home/me`, { method: 'PUT' })
expect(anon.status).toBe(401)
})
test('PUT /club/:id/additionalimage/:index fills the clubs gallery slots', async () => {
type Image = { Id: number; ImageName: string; PlayerId: number }
type Details = { error: string; success: boolean; value: { AdditionalImages: Image[] } }
// The gallery is served as whole image records, joined from the image table the
// `api` worker writes on upload. Seed the rows those names point at.
const first = 'sharecamera/2026-07-21/e37fc41f-005e-4216-8f1e-a37dca953981.jpg'
const insertImage = env.DB.prepare('INSERT OR IGNORE INTO image (data) VALUES (?1)')
await env.DB.batch(
[first, 'b.jpg', 'c.jpg'].map((ImageName, i) =>
insertImage.bind(
JSON.stringify({
Id: 500 + i,
Type: 1,
Accessibility: 1,
AccessibilityLocked: false,
ImageName,
Description: null,
PlayerId: 7100,
TaggedPlayerIds: [],
RoomId: null,
PlayerEventId: null,
CreatedAt: '2026-07-21T00:00:00Z',
CheerCount: 0,
CommentCount: 0,
})
)
)
)
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('7100')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Gallery',
})
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
const setImage = async (index: number, imageName: string, sub = '7100') =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/${index}`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ imageName }).toString(),
})
const names = (images: Image[]) => images.map((i) => i.ImageName)
// A fresh club has no gallery images.
const fresh = (await (
await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`)
).json()) as { AdditionalImages: Image[] }
expect(fresh.AdditionalImages).toEqual([])
// The client's exact request: the storage worker's image name into slot 0. It
// comes back as the whole image record, not a bare name.
const set = (await (await setImage(0, first)).json()) as Details
expect(set).toMatchObject({ error: '', success: true })
expect(set.value.AdditionalImages).toEqual([
expect.objectContaining({ Id: 500, ImageName: first, PlayerId: 7100 }),
])
// The list stays packed: a PUT past the end appends rather than leaving a gap.
const third = (await (await setImage(2, 'c.jpg')).json()) as Details
expect(names(third.value.AdditionalImages)).toEqual([first, 'c.jpg'])
const second = (await (await setImage(2, 'b.jpg')).json()) as Details
expect(names(second.value.AdditionalImages)).toEqual([first, 'c.jpg', 'b.jpg'])
// Re-PUTting a position replaces just that image. A name with no image row still
// renders, as a placeholder record.
const replaced = (await (await setImage(0, 'a2.jpg')).json()) as Details
expect(names(replaced.value.AdditionalImages)).toEqual(['a2.jpg', 'c.jpg', 'b.jpg'])
expect(replaced.value.AdditionalImages[0]).toMatchObject({ Id: 0, ImageName: 'a2.jpg' })
// An empty name removes that image and shifts the rest up — no blank left behind.
const cleared = (await (await setImage(1, '')).json()) as Details
expect(names(cleared.value.AdditionalImages)).toEqual(['a2.jpg', 'b.jpg'])
// They're on the club's details payload, for everyone reading the club.
const details = (await (
await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`)
).json()) as { AdditionalImages: Image[] }
expect(names(details.AdditionalImages)).toEqual(['a2.jpg', 'b.jpg'])
// DELETE removes that position's image and shifts the rest up, ignoring any body.
// Deleting a position that holds nothing is a no-op.
const deleteImage = async (index: number, sub = '7100', body?: string) =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/${index}`, {
method: 'DELETE',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const dropped = (await (await deleteImage(0, '7100', 'imageName=sneaky.jpg')).json()) as Details
expect(dropped).toMatchObject({ error: '', success: true })
expect(names(dropped.value.AdditionalImages)).toEqual(['b.jpg'])
// Position 1 holds nothing now, so deleting it changes nothing.
expect(
names(((await (await deleteImage(1)).json()) as Details).value.AdditionalImages)
).toEqual(['b.jpg'])
// A PUT past the end appends, so the club is back to two images.
const refilled = (await (await setImage(1, 'a3.jpg')).json()) as Details
expect(names(refilled.value.AdditionalImages)).toEqual(['b.jpg', 'a3.jpg'])
// Same gate as the PUT.
expect((await deleteImage(0, '7101')).status).toBe(403)
expect(
(
await exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/0`, {
method: 'DELETE',
})
).status
).toBe(401)
expect((await deleteImage(3)).status).toBe(400)
// There are only three slots, and only co-owners may set them.
expect((await setImage(3, 'd.jpg')).status).toBe(400)
expect((await setImage(0, 'hijack.jpg', '7101')).status).toBe(403)
expect(
(
await exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/0`, {
method: 'PUT',
})
).status
).toBe(401)
expect(
(
await exports.default.fetch(`${ORIGIN}/club/99999/additionalimage/0`, {
method: 'PUT',
headers: await bearer('7100'),
})
).status
).toBe(404)
})
test('POST /club/create enforces the per-account club cap', async () => {
const create = async (name: string) =>
exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('7200')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ name }).toString(),
})
// The cap an operator actually runs is the `MAX_CLUBS_PER_ACCOUNT` var; the
// constant in the worker is only the fallback.
const original = env.MAX_CLUBS_PER_ACCOUNT
try {
env.MAX_CLUBS_PER_ACCOUNT = 2
expect((await create('CapOne')).status).toBe(200)
expect((await create('CapTwo')).status).toBe(200)
const rejected = await create('CapThree')
expect(rejected.status).toBe(400)
const body = (await rejected.json()) as { error: string; success: boolean }
expect(body.success).toBe(false)
expect(body.error).toMatch(/only have 2 clubs/i)
// A subscription club doesn't count against the cap — it isn't made by hand.
await env.DB.prepare('INSERT INTO club (data) VALUES (?1)')
.bind(
JSON.stringify({
ClubId: 9500,
Name: 'Subs7200',
ClubType: 1,
CreatorAccountId: 7200,
CreatedAt: '2026-07-01T00:00:00Z',
})
)
.run()
env.MAX_CLUBS_PER_ACCOUNT = 3
expect((await create('CapThreeReal')).status).toBe(200)
// 0 lifts the cap entirely.
env.MAX_CLUBS_PER_ACCOUNT = 0
expect((await create('Uncapped')).status).toBe(200)
} finally {
env.MAX_CLUBS_PER_ACCOUNT = original
}
})
test('GET /club/search filters by category/query and sorts', async () => {
type Result = {
Clubs: Array<{ ClubId: number; Name: string; Category: string }>
@@ -875,4 +1140,313 @@ describe('clubs endpoints', () => {
).json()) as Club[]
expect(member811.map((c) => c.ClubId)).not.toContain(club.ClubId)
})
test('requesttojoin follows the club joinability', async () => {
const create = async (sub: string, fields: Record<string, string>) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
).json()) as { value: { Club: { ClubId: number } } }
).value.Club.ClubId
const request = async (clubId: number, sub: string) =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/members/requesttojoin`, {
method: 'PUT',
headers: await bearer(sub),
})
type Details = { error: string; success: boolean; value: { MyMembershipType: number } | null }
const open = await create('820', { name: 'Open Doors', joinability: 'Open' })
const ask = await create('821', { name: 'Ask First', joinability: 'AskToJoin' })
const invite = await create('822', { name: 'Invite Only', joinability: 'InviteOnly' })
// Open → straight in as a Member (10).
const joined = (await (await request(open, '830')).json()) as Details
expect(joined).toMatchObject({ success: true })
expect(joined.value?.MyMembershipType).toBe(10)
// AskToJoin → PendingRequested (1), and a repeat request leaves it there.
const asked = (await (await request(ask, '830')).json()) as Details
expect(asked.value?.MyMembershipType).toBe(1)
expect(
(((await (await request(ask, '830')).json()) as Details).value ?? {}).MyMembershipType
).toBe(1)
// InviteOnly → refused, with no membership row created.
const refused = await request(invite, '830')
expect(refused.status).toBe(400)
expect(((await refused.json()) as Details).success).toBe(false)
// No token, and an unknown club.
expect(
(
await exports.default.fetch(`${ORIGIN}/club/${open}/members/requesttojoin`, {
method: 'PUT',
})
).status
).toBe(401)
expect((await request(99999, '830')).status).toBe(404)
})
test('PUT /club/:id/members/invite adds and promotes members, co-owner only', async () => {
type Member = { AccountId: number; MembershipType: number }
type Details = {
error: string
success: boolean
value: { Club: { MemberCount: number } } | null
}
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('870')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Invitational',
})
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
const invite = async (fields: Record<string, string>, sub = '870'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/members/invite`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
const tiers = async (): Promise<Map<number, number>> => {
const res = await exports.default.fetch(`${ORIGIN}/club/${clubId}/members`)
const body = (await res.json()) as { value: Member[] }
return new Map(body.value.map((m) => [m.AccountId, m.MembershipType]))
}
// The client's exact request: add account 872 as a Member (10).
const added = await invite({ accountId: '872', membershipType: '10' })
expect(added.status).toBe(200)
const addedBody = (await added.json()) as Details
expect(addedBody).toMatchObject({ error: '', success: true })
expect(addedBody.value?.Club.MemberCount).toBe(2) // creator + 872
expect((await tiers()).get(872)).toBe(10)
// Inviting an existing member at a higher tier promotes them in place.
expect((await invite({ accountId: '872', membershipType: '20' })).status).toBe(200)
expect((await tiers()).get(872)).toBe(20)
// membershipType defaults to Member when omitted.
await invite({ accountId: '873' })
expect((await tiers()).get(873)).toBe(10)
// Can't mint another creator, can't touch the creator, needs a valid accountId — and
// a rejected invite writes nothing.
expect((await invite({ accountId: '874', membershipType: '100' })).status).toBe(400)
expect((await invite({ accountId: '870', membershipType: '30' })).status).toBe(400)
expect((await invite({ accountId: 'abc' })).status).toBe(400)
expect((await tiers()).has(874)).toBe(false)
// A non-co-owner can't invite (872 is a moderator now, still below co-owner); signed
// out is a 401; an unknown club 404s.
expect((await invite({ accountId: '875' }, '872')).status).toBe(403)
const anon = await exports.default.fetch(`${ORIGIN}/club/${clubId}/members/invite`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'accountId=875',
})
expect(anon.status).toBe(401)
const missing = await exports.default.fetch(`${ORIGIN}/club/99999/members/invite`, {
method: 'PUT',
headers: { ...(await bearer('870')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'accountId=875',
})
expect(missing.status).toBe(404)
})
test('members/leave drops a membership and withdraws a pending request', async () => {
const create = async (sub: string, fields: Record<string, string>) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
).json()) as { value: { Club: { ClubId: number } } }
).value.Club.ClubId
const call = async (clubId: number, sub: string, action: 'requesttojoin' | 'leave') =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/members/${action}`, {
method: action === 'leave' ? 'POST' : 'PUT',
headers: await bearer(sub),
})
type Details = {
success: boolean
value: { Club: { MemberCount: number }; MyMembershipType: number } | null
}
const open = await create('840', { name: 'Revolving', joinability: 'Open' })
const ask = await create('841', { name: 'Waitlist', joinability: 'AskToJoin' })
// A member leaves → membership 0, and the count drops back to the creator alone.
await call(open, '850', 'requesttojoin')
const left = (await (await call(open, '850', 'leave')).json()) as Details
expect(left.success).toBe(true)
expect(left.value?.MyMembershipType).toBe(0)
expect(left.value?.Club.MemberCount).toBe(1)
// Leaving again is a no-op, not an error.
expect(
(((await (await call(open, '850', 'leave')).json()) as Details).value ?? {}).MyMembershipType
).toBe(0)
// Leaving withdraws a pending request too.
expect(
(((await (await call(ask, '850', 'requesttojoin')).json()) as Details).value ?? {})
.MyMembershipType
).toBe(1)
expect(
(((await (await call(ask, '850', 'leave')).json()) as Details).value ?? {}).MyMembershipType
).toBe(0)
// The creator can't leave their own club — they'd leave it ownerless.
for (const path of [`/club/${open}/members/leave`, `/club/${open}/leave`]) {
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: await bearer('840'),
})
expect(res.status).toBe(403)
}
// ...and they're still the creator afterwards.
const stillIn = (await (
await exports.default.fetch(`${ORIGIN}/club/${open}/details`, {
headers: await bearer('840'),
})
).json()) as { MyMembershipType: number }
expect(stillIn.MyMembershipType).toBe(100)
// No token, and an unknown club.
expect(
(await exports.default.fetch(`${ORIGIN}/club/${open}/members/leave`, { method: 'POST' }))
.status
).toBe(401)
expect((await call(99999, '850', 'leave')).status).toBe(404)
})
test('DELETE /club/:id is the creators only, and takes the memberships with it', async () => {
const create = async (sub: string, fields: Record<string, string>) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
).json()) as { value: { Club: { ClubId: number } } }
).value.Club.ClubId
const del = async (clubId: number, sub?: string) =>
exports.default.fetch(`${ORIGIN}/club/${clubId}`, {
method: 'DELETE',
...(sub === undefined ? {} : { headers: await bearer(sub) }),
})
const clubId = await create('860', { name: 'Doomed', joinability: 'Open' })
await exports.default.fetch(`${ORIGIN}/club/${clubId}/members/requesttojoin`, {
method: 'PUT',
headers: await bearer('861'),
})
// Signed out, a plain member, and an unknown club.
expect((await del(clubId)).status).toBe(401)
expect((await del(clubId, '861')).status).toBe(403)
expect((await del(99999, '860')).status).toBe(404)
// The creator can. The club, and its members, are gone.
const res = await del(clubId, '860')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ error: '', success: true, value: null })
expect((await exports.default.fetch(`${ORIGIN}/club/${clubId}`)).status).toBe(404)
expect(
await (await exports.default.fetch(`${ORIGIN}/club/${clubId}/members`)).json()
).toMatchObject({ value: [] })
const member861 = (await (
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('861') })
).json()) as Array<{ ClubId: number }>
expect(member861.map((c) => c.ClubId)).not.toContain(clubId)
// Deleting twice 404s rather than reporting success.
expect((await del(clubId, '860')).status).toBe(404)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every schema inlines: a `.meta({ id })` on any of them would emit a $ref this
// setup doesn't always hoist into components.schemas, leaving it dangling.
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax (regex constraints and all) becomes
// OpenAPI's `{param}`; the `.on([...])` clubhouse and additionalimage routes
// contribute both their methods, and modifydetails/modify both their paths.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'DELETE /club/home/me',
'DELETE /club/{clubId}',
'DELETE /club/{clubId}/additionalimage/{index}',
'DELETE /club/{clubId}/clubhouse',
'GET /announcements/club/{clubId}',
'GET /announcements/v2/mine/unread',
'GET /club/categoryTags',
'GET /club/home/me',
'GET /club/mine/created',
'GET /club/mine/member',
'GET /club/search',
'GET /club/{clubId}',
'GET /club/{clubId}/details',
'GET /club/{clubId}/hasDisabledClubChat',
'GET /club/{clubId}/mainimage',
'GET /club/{clubId}/members',
'GET /subscription/details/{accountId}',
'GET /subscription/details/{subscription}',
'GET /subscription/mine/member',
'GET /subscription/subscriberCount/{accountId}',
'POST /announcements/club/{clubId}',
'POST /club/create',
'POST /club/{clubId}/join',
'POST /club/{clubId}/leave',
'POST /club/{clubId}/members/leave',
'PUT /club/home/me',
'PUT /club/{clubId}/additionalimage/{index}',
'PUT /club/{clubId}/clubhouse',
'PUT /club/{clubId}/mainimage',
'PUT /club/{clubId}/members/invite',
'PUT /club/{clubId}/members/requesttojoin',
'PUT /club/{clubId}/minlevel',
'PUT /club/{clubId}/modify',
'PUT /club/{clubId}/modifydetails',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
})
+4
View File
@@ -35,6 +35,10 @@
"head_sampling_rate": 1 // 100%
}
},
// The per-account cap (MAX_CLUBS_PER_ACCOUNT) is deliberately NOT set here. It's
// injected at deploy time from the gitignored .env (RECFLARE_MAX_CLUBS_PER_ACCOUNT, see
// .env.example), so tuning it never means editing a versioned file. Unset the
// default falls back to DEFAULT_MAX_CLUBS_PER_ACCOUNT in src/clubs.app.ts.
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment
+109 -70
View File
@@ -1,77 +1,116 @@
# econ
Economy Worker served on the `econ` subdomain. Hosts the avatar/economy
endpoints the game client calls on the `econ` service (distinct from the main
`api` worker). DB-backed data is stubbed for now — no bindings yet.
Economy Worker served on the `econ` subdomain (`econ.recflare.net`). Hosts the
avatar/economy endpoints the game client calls on the `econ` service (distinct from the
main `api` worker, which also serves many of them — the client may call either host).
## Endpoints
Balances, inventory, consumables, saved outfits, avatars and gift boxes are D1-backed;
storefront catalogs are static assets (`static/storefronts/sf{N}.json`) served via the
ASSETS binding. Several routes are still empty-list stubs.
- `GET /api/avatar/v1/defaultunlocked` — default-unlocked avatar items, served
from the bundled `static/default-avatar-items.json` catalog.
- `GET /api/avatar/v1/defaultbaseavataritems` — default base avatar items. Reads
the same source file as `defaultunlocked`, so it returns the identical
catalog.
- `GET /api/avatar/v4/items``[Authorize]`. The player's avatar items: the
items they've bought (from `buyItem`, in the `inventory` table) prepended to
the default catalog. A player who has bought nothing gets just the catalog.
- `GET /api/avatar/v2``[Authorize]`. The player's avatar. No DB binding yet,
so it returns the default `{ OutfitSelections, FaceFeatures, SkinColor,
HairColor }` seeded for a new player.
- `GET /econ/customAvatarItems/v1/owned` — the player's owned custom avatar
items. No auth; returns `{ items: [] }` with no DB binding.
The client requests this when custom-item creation is allowed, so a missing
route here shows up as "Failed to download unlocked avatar items".
- `GET /api/objectives/v1/myprogress` — objectives progress. No auth (serves a
static JSON file verbatim); returns the bundled
`static/my-progress.json` default for all players until a DB binding exists.
- `GET /api/avatar/v3/saved``[Authorize]`. Saved outfits; `[]` without a DB.
- `GET /api/avatar/v2/gifts``[Authorize]`. The player's unopened gift boxes
(from their purchases), out of the shared `received_gift` table; `[]` when
they have none.
- `POST /api/avatar/v2/gifts/consume` — open a box (form body `Id=<n>&UnlockedLevel=<n>`,
posted with a trailing slash). Deletes the box scoped to the caller; the item was
already granted at purchase, so this is cosmetic. Always answers the success envelope
`{ error: "", success: true, value: null }` (a captured real consume returns this, not
an empty body — the client parses it to finish opening the box), even for a
missing/already-opened box, so a fire-and-forget re-open never errors. Also served by
the `api` worker (the client may call either host).
- `POST /api/storefronts/v2/buyItem``[Authorize]`. Buy a storefront item.
Looks the item up in `static/storefronts/sf{StorefrontType}.json`, confirms the
client's `RequestedPrice` still matches, debits the buyer atomically, grants the
item, and returns a gift box. An avatar-item drop goes into the `inventory` table
(own-once); a consumable drop goes into the `consumable` table (each buy stacks a
new instance). The response's `Balance` is the change applied (the negated price),
not the resulting total — the client reads its new total from `GET /balance/:type`.
`409` on a stale price, `404` on an unknown item, `400` on insufficient balance.
- `GET /api/equipment/v2/getUnlocked` — unlocked equipment; `[]` (no auth).
- `POST /api/settings/v2/set``[Authorize]`. Persist settings; 200 ack only.
- `GET /api/consumables/v2/getUnlocked``[Authorize]`. The consumables the
player has bought (from `buyItem`, in the `consumable` table), grouped by item
into the unlocked-consumable DTO (`Ids`/`CreatedAts` per instance, `Count` their
sum); `[]` when they've bought none.
- `GET /api/storefronts/v4/balance/2``[Authorize]`. Token balance; `[]`.
- `GET /api/storefronts/v3/giftdropstore/3` — gift-drop storefront, served from
the bundled `static/storefronts-v3-giftdropstore-3.json`.
- `GET /api/storefronts/v1/adcarouselitems` — storefront ad-carousel items,
served from the bundled `static/ad-carousel-items.json` (one placeholder
banner until real promo data exists).
- `GET /api/challenge/v2/getCurrent` — current weekly challenge, served from the
bundled `static/weekly-challenge.json`.
- `GET /api/gamerewards/v1/pending` — pending rewards; `[]`.
- `GET /api/roomkeys/v1/mine` — the player's room keys; `[]`.
- `POST /api/CampusCard/v1/UpdateAndGetSubscription` — subscription lookup;
`{ subscription: null, platformAccountSubscribedPlayerId: null }`.
- Stubbed: `GET /api/roomconsumables/v1/roomConsumable/room/:id`
and `GET /api/roomcurrencies/v1/currencies` both return `[]`.
## Routes
These economy routes are also served by the `api` worker; they're
duplicated here because the client calls them on the `econ` host.
`✓` = auth-gated (validates the Bearer JWT from the `auth` worker; empty-body 401 when
missing/invalid).
## TODO before production
| Method | Path | Auth | Description |
| -------- | ---------------------------------------------------- | ---- | --------------------------------------- |
| GET | `/api/avatar/v1/defaultunlocked` | | Default-unlocked avatar items (static) |
| GET | `/api/avatar/v1/defaultbaseavataritems` | | Default base avatar items (stub `[]`) |
| GET | `/api/avatar/v4/items` | ✓ | Owned items + the default catalog |
| GET | `/econ/customAvatarItems/v1/owned` | ✓ | Owned custom avatar items (stub) |
| GET | `/api/objectives/v1/myprogress` | | Objectives progress (static) |
| GET/POST | `/api/objectives/v1/cleargroup` | | Clear an objectives group (no-op `[]`) |
| GET | `/api/avatar/v2` | ✓ | The player's own avatar |
| POST | `/api/avatar/v2/set` | ✓ | Save the player's avatar |
| GET | `/api/checklist/v1/current` | ✓ | NUX checklist (stub `[]`) |
| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | Item wishlist (stub `[]`) |
| GET | `/api/avatar/v3/saved` | ✓ | Saved outfits |
| POST | `/api/avatar/v3/saved/set` | ✓ | Save an outfit into a slot |
| GET | `/api/avatar/v2/gifts` | ✓ | Pending (unopened) gift boxes |
| POST | `/api/avatar/v2/gifts/consume` | | Open a gift box → success envelope |
| GET | `/api/avatar/v2/:id` | | Another player's avatar (render subset) |
| GET | `/api/equipment/v2/getUnlocked` | | Unlocked equipment (stub `[]`) |
| GET | `/api/roomconsumables/v1/roomConsumable/room/:id` | | Room consumables (stub `[]`) |
| GET | `/api/roomconsumables/v1/roomConsumable/room/:id/me` | | Caller's room consumables (stub `[]`) |
| GET | `/api/roomcurrencies/v1/currencies` | | Room currencies (stub `[]`) |
| GET | `/api/roomcurrencies/v1/getAllBalances` | | Room balances (stub `[]`) |
| POST | `/api/settings/v2/set` | ✓ | Persist settings (accept-and-ack) |
| GET | `/api/consumables/v2/getUnlocked` | ✓ | Unlocked consumables |
| POST | `/api/consumables/v1/consume` | ✓ | Consume an owned consumable |
| GET | `/api/storefronts/v4/balance/:currencyType` | ✓ | Currency balance |
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
| GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) |
| GET | `/api/challenge/v2/getCurrent` | | Current weekly challenge (static) |
| POST | `/api/challenge/v2/updateProgress` | | Report challenge progress (stub) |
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
| POST | `/api/gamerewards/v1/request` | | Request a game reward (stub `[]`) |
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) |
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
- Gifting to another player (`buyItem` with a `Gift` block) grants the item and
box to the recipient, but there's no notification. `buyItem` grants avatar-item
and consumable drops; currency/xp drops aren't granted yet.
- Consumables are granted and listed but never spent — nothing consumes them, so
`Count` only ever grows (each purchase grants `1`; catalogs don't specify a
per-item quantity).
The app runs with `strict: false`, so trailing-slash variants match (the client posts
`/gifts/consume/` with a trailing slash).
## API documentation
`GET /openapi.json` serves a spec generated from `describeRoute` blocks alongside each
handler, with the schemas in `src/openapi.ts`. **Descriptive, not enforced** — same
rationale as the `auth`/`accounts`/`match` workers. A test asserts every route appears
in the spec, so adding one without documenting it fails.
## Purchases (`buyItem`)
The core flow. The client posts the storefront/item ids, the currency, and the
`RequestedPrice` it rendered; the handler:
1. looks the item up in `static/storefronts/sf{StorefrontType}.json`;
2. rejects a stale price (`409`) — this stops a stale or tampered client buying at a
price the catalog no longer offers;
3. debits the buyer **atomically** (`400` on insufficient balance);
4. grants the drop — an avatar item into the `inventory` table (own-once), a consumable
into the `consumable` table (each buy stacks a new instance); currency/xp drops
aren't granted yet;
5. returns a **gift box** and pushes a `StorefrontBalanceUpdate` over the socket.
Two things are easy to get wrong:
- **`Balance` in the response is the _change_ applied** (the negated price), not the
resulting total. The client reads its new total from `GET /balance/:type`.
- **Ownership is persisted at purchase**, not when the box is opened. Opening a box
(`/gifts/consume`) just deletes it — the item was already granted. So the grant never
waits on the cosmetic "open it" moment.
A `Gift` block routes the item (and box) to another player, but the caller always pays.
A self-buy or anonymous gift is attributed to the "Coach" system account (id 1).
## Consume envelopes
Both consume routes (`/gifts/consume`, `/consumables/consume`) always answer HTTP 200
with `{ error: "", success: true, value: null }` — even for a missing or already-gone
target. A captured real consume returns this envelope, not an empty body: the client
parses it to finish the action, so a bare 200 reads as a failure and the item never
finishes unlocking. Deletes are scoped to the caller, so an unauthenticated or
mismatched call is a harmless no-op (opening _another_ player's box is a 403).
## Bindings
| Binding | Type | Notes |
| ---------------------------- | -------------- | -------------------------------------------------------- |
| `DB` | D1 | Shared `recflare` database — balances, inventory, etc. |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
| `STARTING_TOKENS` | var | Optional; new-player token grant (default in balance-db) |
Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no code change.
## Known gaps
- Gifting to another player grants the item and box but does not notify the recipient.
- `buyItem` grants avatar-item and consumable drops; currency/xp drops aren't granted.
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
- Several routes (room keys, wishlist, equipment, room consumables/currencies, game
rewards) are empty-list stubs pending their own stores.
+15
View File
@@ -0,0 +1,15 @@
-- Owned equipment, owned by the `econ` worker. Like avatar items (own-once, one row
-- per (account, item)) rather than consumables (which stack): equipment is a boolean
-- unlock keyed by its `EquipmentModificationGuid` (the gift-drop's equipment guid), so
-- re-buying the same skin is a no-op rather than a duplicate row. Granted at purchase
-- time by `/api/storefronts/v2/buyItem` (when the gift-drop carries an
-- `EquipmentModificationGuid`) and read back by `/api/equipment/v2/getUnlocked`; `data`
-- is the rendered unlocked-equipment DTO. Kept in sync with EQUIPMENT_SCHEMA_DDL in
-- src/equipment-db.ts.
CREATE TABLE IF NOT EXISTS equipment (
account_id INTEGER NOT NULL,
equipment_modification_guid TEXT NOT NULL,
data TEXT NOT NULL,
PRIMARY KEY (account_id, equipment_modification_guid)
);
@@ -0,0 +1,24 @@
-- Rewrite the stored unlocked-equipment DTOs onto the shape the client actually reads.
-- Rows written before this used `EquipmentModificationGuid`/`EquipmentPrefabName` (the
-- gift-drop's prefixed names, carried straight over at grant time) and had no
-- `Favorited`/`PlatformMask`. The live endpoint serves the unprefixed
-- `ModificationGuid`/`PrefabName` plus both of those, and the entries the client PUTs
-- back to `/api/equipment/v1/update` use the unprefixed names too — so an un-rewritten
-- row renders with a blank prefab and can never be favourited (the update matches on a
-- guid the row's `data` no longer spells the same way).
--
-- The `data` column is the DTO verbatim, so the fix is a JSON rewrite in place; the row
-- key (`equipment_modification_guid`) is unchanged. Guarded on the old key being
-- present, which also makes it a no-op on re-run.
UPDATE equipment
SET data = json_object(
'ModificationGuid', json_extract(data, '$.EquipmentModificationGuid'),
'PrefabName', json_extract(data, '$.EquipmentPrefabName'),
'FriendlyName', json_extract(data, '$.FriendlyName'),
'Tooltip', json_extract(data, '$.Tooltip'),
'Rarity', json_extract(data, '$.Rarity'),
'PlatformMask', -1,
'Favorited', json('false')
)
WHERE json_extract(data, '$.EquipmentModificationGuid') IS NOT NULL;
@@ -0,0 +1,18 @@
-- Owned inventions, owned by the `econ` worker. One row per (account, invention): the
-- inventions a player has bought from the invention store. Written at purchase time by
-- `/api/storefronts/v2/buyInvention`, which also uses it to reject a re-buy. Ownership
-- is boolean (you own an invention or you don't), so the pair is the primary key and a
-- second purchase is a no-op rather than a duplicate row.
--
-- The invention itself lives in the `invention` table, whose schema/migrations the `api`
-- worker owns (apps/api/migrations/0002_invention.sql) on this same `recflare` database;
-- only the id is stored here. Creators are NOT listed here — an invention's creator owns
-- it by virtue of `CreatorPlayerId`, and never buys their own. Kept in sync with
-- INVENTORY_INVENTION_SCHEMA_DDL in src/inventory-invention-db.ts.
CREATE TABLE IF NOT EXISTS inventory_invention (
account_id INTEGER NOT NULL,
invention_id INTEGER NOT NULL,
acquired_at TEXT NOT NULL,
PRIMARY KEY (account_id, invention_id)
);
+6 -1
View File
@@ -19,8 +19,13 @@
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+1045 -310
View File
@@ -1,14 +1,25 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain'
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
import {
consumeGift,
createGift,
getGift,
getPendingGifts,
grantInvention,
ownsInvention,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
// Invention storage (owned by the `api` worker, on this same `recflare` database).
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
// their own, and buyInvention has to read the very rows `api` writes.
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../notify/src/notification-types'
import adCarouselItems from '../static/ad-carousel-items.json'
import defaultAvatarItems from '../static/default-avatar-items.json'
import defaultAvatar from '../static/default-avatar.json'
@@ -17,13 +28,48 @@ import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db'
import {
ALL_PLATFORMS,
creditCurrency,
CurrencyType,
DEFAULT_STARTING_TOKENS,
ensureStartingBalances,
getBalance,
isSpendable,
spendCurrency,
} from './balance-db'
import { consumeConsumable, countConsumable, getConsumables, grantConsumable } from './consumables-db'
import {
consumeConsumable,
countConsumable,
getConsumables,
grantConsumable,
} from './consumables-db'
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
import { getInventory, grantItem } from './inventory-db'
import {
AUTHED,
AvatarV2Dto,
BalanceEntry,
BuyInventionResponse,
BuyItemRequest,
BuyItemResponse,
ChallengeProgressRequest,
ChallengeProgressResponse,
ConsumeConsumableRequest,
ConsumeEnvelope,
ConsumeGiftRequest,
CustomAvatarItemsResponse,
EquipmentUpdateRequest,
ErrorResponse,
form,
json,
JsonArray,
jsonBody,
JsonObject,
OpaqueJsonBody,
SaveOutfitRequest,
SaveOutfitV4Response,
SubscriptionResponse,
UNAUTHORIZED_RESPONSE,
} from './openapi'
import { getOutfits, setOutfit } from './outfit-db'
import type { Context } from 'hono'
@@ -31,15 +77,19 @@ import type { GiftContent, StoredGift } from '@repo/domain'
import type { Avatar } from './avatar-db'
import type { ConsumeResult } from './consumables-db'
import type { App } from './context'
import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db'
import type { Outfit } from './outfit-db'
/**
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
* the `econ` service (these are separate from the main `api` worker). DB-backed
* data is stubbed for now no bindings yet.
* the `econ` service (these are separate from the main `api` worker). Balances,
* inventory (avatar items, equipment, bought inventions), consumables, saved outfits,
* avatars and gift boxes are D1-backed;
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
* binding. Some routes are still empty-list stubs (room keys, wishlist, ).
*
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
*/
/**
@@ -55,6 +105,26 @@ function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/**
* Shared parse/validate/store for the save-outfit routes (v3 and v4). Persists the
* posted outfit into its `Slot` verbatim and returns the stored `Outfit`; on the
* unauth or bad-body path it returns the Response to send directly (401, or 400 for a
* non-object body or missing/non-integer `Slot`). Callers format the success body v3
* echoes the whole outfit, v4 answers a lean `{ Success, Slot }` ack.
*/
async function persistPostedOutfit(c: Context<App>): Promise<Outfit | Response> {
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 || typeof body !== 'object' || Array.isArray(body)) {
return c.body(null, 400)
}
if (!Number.isInteger(body.Slot)) return c.body(null, 400)
const outfit = body as Outfit
await setOutfit(c.env.DB, id, outfit)
return outfit
}
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
@@ -133,23 +203,30 @@ async function pushConsumableAdded(
* Push a StorefrontBalanceUpdate to a player after their balance changes, mirroring the
* reference's
* `HubSendToPlayer(accountID, NotifFrame(StorefrontBalanceUpdate, {Balance, CurrencyType, BalanceType}))`.
* The client applies it to the shown balance so a purchase debit reflects immediately,
* without waiting for a `GET /balance` re-fetch. `Balance` is the resulting total in that
* currency (not the delta), `BalanceType` is -2 (account-wide, all platforms). Best-effort:
* a hub failure is logged and swallowed, since the balance change has already committed.
* The client applies it to the shown balance so a purchase reflects immediately, without
* waiting for a `GET /balance` re-fetch.
*
* `Balance` is the CHANGE negative for a debit, positive for a payout not the
* resulting total. The client ADDS what it receives to the balance it is already showing,
* so sending the total made a 10,000-token player who earned 250 read 20,250: their own
* balance plus the new total. That also makes this frame non-idempotent, so push exactly
* once per change and never re-send it as a "refresh".
*
* `BalanceType` is -2 (account-wide, all platforms). Best-effort: a hub failure is logged
* and swallowed, since the balance change has already committed.
*/
async function pushBalanceUpdate(
c: Context<App>,
accountId: number,
currencyType: number,
balance: number
change: number
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
accountId,
NotificationType.StorefrontBalanceUpdate,
{
Balance: balance,
Balance: change,
CurrencyType: currencyType,
BalanceType: ALL_PLATFORMS,
}
@@ -245,6 +322,19 @@ function toAvatarItem(giftDrop: StoreGiftDrop): AvatarItem {
}
}
/** Build the owned equipment DTO granted into the buyer's inventory from a gift-drop. */
function toEquipment(giftDrop: StoreGiftDrop): Equipment {
return {
ModificationGuid: giftDrop.EquipmentModificationGuid,
PrefabName: giftDrop.EquipmentPrefabName,
FriendlyName: giftDrop.FriendlyName,
Tooltip: giftDrop.Tooltip,
Rarity: giftDrop.Rarity,
PlatformMask: -1,
Favorited: false,
}
}
/** Quantity of a consumable granted per purchase — our storefront catalogs don't specify one. */
const CONSUMABLE_GRANT_COUNT = 1
@@ -280,6 +370,24 @@ function toGiftContent(
}
}
/**
* A concise `describeRoute` spec for a route that serves an opaque JSON array either
* a static catalog served verbatim or an empty-list stub. `auth` adds the bearer
* requirement + a 401 response.
*/
function listRoute(summary: string, description: string, auth = false) {
return describeRoute({
tags: ['Econ'],
summary,
description,
...(auth ? { security: AUTHED } : {}),
responses: {
200: json(JsonArray, description),
...(auth ? { 401: UNAUTHORIZED_RESPONSE } : {}),
},
})
}
// strict: false so trailing-slash routes (e.g. `/gifts/consume/`, which the client
// posts with a trailing slash) match either form. Mirrors the `api` worker.
const app = new Hono<App>({ strict: false })
@@ -297,85 +405,199 @@ const app = new Hono<App>({ strict: false })
.notFound(withNotFound())
// Default-unlocked avatar items, served from the bundled static JSON.
.get('/api/avatar/v1/defaultunlocked', (c) => c.json(defaultAvatarItems))
.get(
'/api/avatar/v1/defaultunlocked',
listRoute('Default-unlocked avatar items', 'The bundled default avatar-item catalog'),
(c) => c.json(defaultAvatarItems)
)
// Default base avatar items — empty stub for now. No auth.
.get('/api/avatar/v1/defaultbaseavataritems', (c) => c.json([]))
.get(
'/api/avatar/v1/defaultbaseavataritems',
listRoute('Default base avatar items', 'Empty stub for now'),
(c) => c.json([])
)
// The player's avatar items — the items they've bought (from `buyItem`, stored in
// the inventory table) prepended to the default catalog. A player who has bought
// nothing gets just the catalog.
.get('/api/avatar/v4/items', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const owned = await getInventory(c.env.DB, id)
return c.json([...owned, ...defaultAvatarItems])
})
.get(
'/api/avatar/v4/items',
describeRoute({
tags: ['Avatar'],
summary: 'The players avatar items',
description: [
'The items the player has bought (from buyItem, in the inventory table) prepended',
'to the default catalog. A player who has bought nothing gets just the catalog.',
].join(' '),
security: AUTHED,
responses: {
200: json(JsonArray, 'Owned items followed by the default catalog'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const owned = await getInventory(c.env.DB, id)
return c.json([...owned, ...defaultAvatarItems])
}
)
// The player's owned custom avatar items. [Authorize]; paginated. Empty stub for
// now (no DB binding). The client downloads these when custom-item creation is
// allowed; a 404 here surfaces as "Failed to download unlocked avatar items".
.get('/econ/customAvatarItems/v1/owned', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ Results: [], TotalResults: 0 })
})
.get(
'/econ/customAvatarItems/v1/owned',
describeRoute({
tags: ['Avatar'],
summary: 'Owned custom avatar items',
description: [
'Paginated owned custom items. Empty stub for now. The client requests this when',
'custom-item creation is allowed; a 404 shows as “Failed to download unlocked',
'avatar items”.',
].join(' '),
security: AUTHED,
responses: {
200: json(CustomAvatarItemsResponse, 'Paginated results (empty for now)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ Results: [], TotalResults: 0 })
}
)
// The player's objectives progress. Serves a static JSON file verbatim with
// no auth — same default for everyone until there's a DB binding to track
// per-player progress.
.get('/api/objectives/v1/myprogress', (c) => c.json(myProgress))
.get(
'/api/objectives/v1/myprogress',
describeRoute({
tags: ['Econ'],
summary: 'Objectives progress',
description:
'Serves the bundled static progress verbatim (no per-player store yet). No auth.',
responses: { 200: json(JsonObject, 'The bundled objectives-progress default') },
}),
(c) => c.json(myProgress)
)
// Clears a group of objectives. No per-player progress to clear yet, so this
// is a no-op that returns an empty array (a 404 here breaks the client). Accepts
// GET or POST since the client may use either.
.on(['GET', 'POST'], '/api/objectives/v1/cleargroup', (c) => c.json([]))
.on(
['GET', 'POST'],
'/api/objectives/v1/cleargroup',
describeRoute({
tags: ['Econ'],
summary: 'Clear an objectives group (no-op)',
description: 'No per-player progress to clear yet → []. Accepts GET or POST.',
responses: { 200: json(JsonArray, 'Always empty for now') },
}),
(c) => c.json([])
)
// The player's avatar, stored as a JSON blob on their account row. Falls back
// to the default outfit when they haven't saved one — the client's parser NREs
// on an empty OutfitSelections (real RecNet never returns one).
.get('/api/avatar/v2', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json((await getAvatar(c.env.DB, id)) ?? defaultAvatar)
})
.get(
'/api/avatar/v2',
describeRoute({
tags: ['Avatar'],
summary: 'The players own avatar',
description: [
'The avatar JSON blob stored on the account row, or the default outfit when none is',
'saved (the client NREs on an empty OutfitSelections).',
].join(' '),
security: AUTHED,
responses: {
200: json(JsonObject, 'The stored avatar blob (or the default)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json((await getAvatar(c.env.DB, id)) ?? defaultAvatar)
}
)
// Save the player's avatar. [Authorize]. Stores the posted JSON payload verbatim
// on the account row and echoes it back. 400 on a non-object body; 404 when the
// caller has no account row to attach it to.
.post('/api/avatar/v2/set', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const avatar = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (avatar === null || typeof avatar !== 'object' || Array.isArray(avatar)) {
return c.body(null, 400)
.post(
'/api/avatar/v2/set',
describeRoute({
tags: ['Avatar'],
summary: 'Save the players avatar',
description: 'Stores the posted JSON blob verbatim on the account row and echoes it back.',
security: AUTHED,
requestBody: jsonBody(OpaqueJsonBody, 'The avatar blob'),
responses: {
200: json(JsonObject, 'The saved avatar (echoed back)'),
400: { description: 'Body was not a JSON object (empty body)' },
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No account row to attach it to (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const avatar = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (avatar === null || typeof avatar !== 'object' || Array.isArray(avatar)) {
return c.body(null, 400)
}
if (!(await setAvatar(c.env.DB, id, avatar))) return c.body(null, 404)
return c.json(avatar)
}
if (!(await setAvatar(c.env.DB, id, avatar))) return c.body(null, 404)
return c.json(avatar)
})
)
// NUX checklist — the client fetches this on the econ host during load. []
// with no DB. A 404 here can abort the load orchestration before matchmake.
.get('/api/checklist/v1/current', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
})
.get(
'/api/checklist/v1/current',
listRoute('NUX checklist', 'The new-user checklist; [] for now. A 404 can abort load.', true),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
}
)
// The player's item wishlist. [Authorize]; empty without a DB binding.
.get('/api/itemWishlists/v1/wishlist/me', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
})
.get(
'/api/itemWishlists/v1/wishlist/me',
listRoute('The players item wishlist', 'Empty for now', true),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
}
)
// The player's saved outfits. [Authorize]. Served back as the client posted them
// (see /saved/set); a player who has saved none gets [].
.get('/api/avatar/v3/saved', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getOutfits(c.env.DB, id))
})
.get(
'/api/avatar/v3/saved',
describeRoute({
tags: ['Avatar'],
summary: 'The players saved outfits',
description: 'Served back as the client posted them (see /saved/set); [] when none.',
security: AUTHED,
responses: {
200: json(JsonArray, 'Saved outfits (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getOutfits(c.env.DB, id))
}
)
// Save an outfit into one of the player's slots. [Authorize]. The posted `Slot` is
// the slot to write, and re-saving a slot overwrites it — that's the avatar screen's
@@ -385,28 +607,91 @@ const app = new Hono<App>({ strict: false })
//
// A missing/non-integer `Slot` is a 400 rather than a default slot — guessing would
// silently overwrite an outfit the player didn't mean to touch.
.post('/api/avatar/v3/saved/set', 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 || typeof body !== 'object' || Array.isArray(body)) {
return c.body(null, 400)
//
// v3 and v4 share this handler: newer clients POST to /v4/saved/set with the same
// payload shape (Slot, PreviewImageName, OutfitSelections(V2), FaceFeatures, Skin/HairColor,
// CustomAvatarItems) and expect the same slot-overwrite semantics, so they store into the
// same outfit table and read back through /api/avatar/v3/saved.
.post(
'/api/avatar/v3/saved/set',
describeRoute({
tags: ['Avatar'],
summary: 'Save an outfit into a slot',
description: [
'Writes the posted outfit into the given `Slot` (overwriting it) and echoes it back.',
'The payload is stored verbatim — its inner fields are JSON-in-a-string from the',
'clients own serializer. A missing/non-integer `Slot` is a 400 (guessing would',
'silently overwrite another outfit).',
].join(' '),
security: AUTHED,
requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'),
responses: {
200: json(JsonObject, 'The saved outfit (echoed back)'),
400: { description: 'Non-object body or missing/non-integer Slot (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const result = await persistPostedOutfit(c)
if (result instanceof Response) return result
return c.json(result)
}
if (!Number.isInteger(body.Slot)) return c.body(null, 400)
const outfit = body as Outfit
await setOutfit(c.env.DB, id, outfit)
return c.json(outfit)
})
)
// v4 of the save-outfit route. Same payload, table and slot-overwrite semantics as v3
// (see above) — newer clients moved to /v4/saved/set. The one difference is the response:
// v4 answers a lean `{ Success, Slot }` acknowledgement rather than echoing the whole
// outfit back. The outfit is read back through /api/avatar/v3/saved either way.
.post(
'/api/avatar/v4/saved/set',
describeRoute({
tags: ['Avatar'],
summary: 'Save an outfit into a slot (v4)',
description: [
'Writes the posted outfit into the given `Slot` (overwriting it), same as',
'`POST /api/avatar/v3/saved/set`, but answers a lean `{ Success, Slot }` ack instead',
'of echoing the outfit. A missing/non-integer `Slot` is a 400.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'),
responses: {
200: json(SaveOutfitV4Response, 'Save acknowledgement'),
400: { description: 'Non-object body or missing/non-integer Slot (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const result = await persistPostedOutfit(c)
if (result instanceof Response) return result
return c.json({ Success: true, Slot: result.Slot })
}
)
// Pending avatar gifts for the player — the unopened gift boxes from their purchases
// (and, once gifting lands, from other players). [Authorize]. The client opens each
// box and consumes it via the consume route below; the item itself was already
// granted at purchase, so an unopened box is cosmetic.
.get('/api/avatar/v2/gifts', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getPendingGifts(c.env.DB, id))
})
.get(
'/api/avatar/v2/gifts',
describeRoute({
tags: ['Gifts'],
summary: 'Pending gift boxes',
description: [
'The players unopened gift boxes from their purchases (and, later, from other',
'players). The item was already granted at purchase, so an unopened box is cosmetic.',
].join(' '),
security: AUTHED,
responses: {
200: json(JsonArray, 'Unopened gift boxes (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getPendingGifts(c.env.DB, id))
}
)
// Open (consume) a gift box. [Authorize]. The client posts this on the econ host after
// the box animation, form-encoded as `Id=<giftId>&UnlockedLevel=<n>`. Opening just
@@ -421,86 +706,216 @@ const app = new Hono<App>({ strict: false })
// as a failure and the consumable never finishes unlocking. The delete is scoped to the
// caller's account, so an unauthenticated or mismatched call is simply a no-op. Mirrors
// the same route on the `api` worker (the client may call either host).
.post('/api/avatar/v2/gifts/consume', async (c) => {
const id = await authedId(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0
if (id !== null && giftId !== 0) {
// Scoped delete: only the box's owner deletes it. A returned box means it was
// theirs and is now consumed.
const gift = await consumeGift(c.env.DB, id, giftId)
if (gift !== null) {
// If the box carried a consumable, tell the client it now has it (so it shows
// up in inventory without a refetch). Avatar-item boxes carry no ConsumableItemDesc.
if (gift.ConsumableItemDesc !== '') await pushConsumableAdded(c, id, gift)
} else {
// Nothing was consumed: either the box is already gone (a harmless no-op —
// re-opening your own consumed box still succeeds) or it belongs to another
// player, which is forbidden.
const other = await getGift(c.env.DB, giftId)
if (other !== null && other.accountId !== id) return c.body(null, 403)
.post(
'/api/avatar/v2/gifts/consume',
describeRoute({
tags: ['Gifts'],
summary: 'Open (consume) a gift box',
description: [
'Deletes the box (the item was already granted at purchase). Always answers the',
'`{ error, success, value }` envelope with HTTP 200 — even with no token, a zero id,',
'or a box already gone — because the client parses it to finish opening the box. The',
'delete is scoped to the caller; opening someone elses box is 403. Also served by',
'the `api` worker.',
].join(' '),
requestBody: form(ConsumeGiftRequest, 'The gift-box id'),
responses: {
200: json(ConsumeEnvelope, 'Success envelope'),
403: { description: 'The box belongs to another player (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0
if (id !== null && giftId !== 0) {
// Scoped delete: only the box's owner deletes it. A returned box means it was
// theirs and is now consumed.
const gift = await consumeGift(c.env.DB, id, giftId)
if (gift !== null) {
// If the box carried a consumable, tell the client it now has it (so it shows
// up in inventory without a refetch). Avatar-item boxes carry no ConsumableItemDesc.
if (gift.ConsumableItemDesc !== '') await pushConsumableAdded(c, id, gift)
} else {
// Nothing was consumed: either the box is already gone (a harmless no-op —
// re-opening your own consumed box still succeeds) or it belongs to another
// player, which is forbidden.
const other = await getGift(c.env.DB, giftId)
if (other !== null && other.accountId !== id) return c.body(null, 403)
}
}
return c.json({ error: '', success: true, value: null })
}
return c.json({ error: '', success: true, value: null })
})
)
// A player's avatar by account id, projected to the public render subset (used
// to draw other players' avatars). No auth — like the accounts `/account/:id`
// lookup. Falls back to the default outfit when the player hasn't saved one.
// Registered after the static `/api/avatar/v2/*` routes so `:id` can't shadow them.
.get('/api/avatar/v2/:id', async (c) => {
const accountId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(accountId)) return c.body(null, 400)
return c.json(toAvatarV2Dto((await getAvatar(c.env.DB, accountId)) ?? defaultAvatar))
})
.get(
'/api/avatar/v2/:id',
describeRoute({
tags: ['Avatar'],
summary: 'Another players avatar (render subset)',
description: [
'The public render subset used to draw another players avatar. No auth. Falls back',
'to the default outfit when the player hasnt saved one.',
].join(' '),
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: 'Account id; non-numeric is 400',
schema: { type: 'string' },
},
],
responses: {
200: json(AvatarV2Dto, 'The render subset'),
400: { description: 'Non-numeric id (empty body)' },
},
}),
async (c) => {
const accountId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(accountId)) return c.body(null, 400)
return c.json(toAvatarV2Dto((await getAvatar(c.env.DB, accountId)) ?? defaultAvatar))
}
)
// Unlocked equipment. Returns "[]" with no auth.
.get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
// Unlocked equipment. [Authorize]. The equipment skins the player has bought (from
// `buyItem`, stored in the `equipment` table). A player who has bought none gets an
// empty list.
.get(
'/api/equipment/v2/getUnlocked',
listRoute('Unlocked equipment', 'The equipment skins the player has bought', true),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getEquipment(c.env.DB, id))
}
)
// Favourite/un-favourite owned equipment. [Authorize]. The client PUTs the entries
// it wants changed (one request can carry several) and reads nothing back. Only
// `Favorited` is written — the rest of each entry is the client echoing what it was
// served, and a guid the caller doesn't own matches no row and is dropped.
.put(
'/api/equipment/v1/update',
describeRoute({
tags: ['Equipment'],
summary: 'Update owned equipment',
description: [
'Applies the posted `Favorited` flags to the callers owned equipment, matched by',
'`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller',
'doesnt own is silently skipped. Empty body on success.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'),
responses: {
200: { description: 'Applied (empty body)' },
400: { description: 'Body isnt a JSON array (empty 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 unknown
if (!Array.isArray(body)) return c.body(null, 400)
const updates = body
.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null)
.filter((e) => typeof e.ModificationGuid === 'string' && e.ModificationGuid !== '')
.map((e) => ({
ModificationGuid: e.ModificationGuid as string,
Favorited: e.Favorited === true,
}))
await setEquipmentFavorited(c.env.DB, id, updates)
return c.body(null, 200)
}
)
// Room consumables/currencies for a given room. Stubbed as empty lists so the
// client doesn't 404.
.get('/api/roomconsumables/v1/roomConsumable/room/:roomId', (c) => c.json([]))
.get('/api/roomconsumables/v1/roomConsumable/room/:roomId/me', (c) => c.json([]))
.get('/api/roomcurrencies/v1/currencies', (c) => c.json([]))
.get('/api/roomcurrencies/v1/getAllBalances', (c) => c.json([]))
// Persist player settings. [Authorize]; would replace the player's settings.
// No DB binding yet, so accept-and-ack.
.post('/api/settings/v2/set', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// TODO: replace stored settings for `id` once a DB binding exists.
return c.body(null, 200)
})
.get(
'/api/roomconsumables/v1/roomConsumable/room/:roomId',
listRoute('Room consumables', 'Empty stub so the client doesnt 404'),
(c) => c.json([])
)
.get(
'/api/roomconsumables/v1/roomConsumable/room/:roomId/me',
listRoute('The callers room consumables', 'Empty stub'),
(c) => c.json([])
)
.get('/api/roomcurrencies/v1/currencies', listRoute('Room currencies', 'Empty stub'), (c) =>
c.json([])
)
.get('/api/roomcurrencies/v1/getAllBalances', listRoute('Room balances', 'Empty stub'), (c) =>
c.json([])
)
// Unlocked consumables. [Authorize]. The consumables the player has bought (from
// `buyItem`, stored in the `consumable` table), grouped by item into the client's
// unlocked-consumable DTO. A player who has bought none gets an empty list.
.get('/api/consumables/v2/getUnlocked', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getConsumables(c.env.DB, id))
})
.get(
'/api/consumables/v2/getUnlocked',
describeRoute({
tags: ['Consumables'],
summary: 'Unlocked consumables',
description: [
'The consumables the player has bought (from buyItem, in the consumable table),',
'grouped by item into the unlocked-consumable DTO (Ids/CreatedAts per instance,',
'Count their sum). [] when theyve bought none.',
].join(' '),
security: AUTHED,
responses: {
200: json(JsonArray, 'Grouped unlocked consumables (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getConsumables(c.env.DB, id))
}
)
// Consume a quantity of an owned consumable instance. [Authorize]. Body is JSON
// `{ Id, DeltaCount }` where `Id` is the consumable row id. Reduces that instance's
// count by DeltaCount, deleting the row once it hits zero. Scoped to the caller so
// they can only consume their own. Envelope mirrors the gift-consume ack.
.post('/api/consumables/v1/consume', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req
.json<{ Id?: unknown; DeltaCount?: unknown }>()
.catch(() => ({}) as { Id?: unknown; DeltaCount?: unknown })
const consumableId = typeof body.Id === 'number' ? body.Id : Number.NaN
const delta = typeof body.DeltaCount === 'number' ? body.DeltaCount : 1
if (!Number.isNaN(consumableId) && delta > 0) {
const consumed = await consumeConsumable(c.env.DB, id, consumableId, delta)
// Notify the player so their client removes/updates the item in inventory.
if (consumed !== null) await pushConsumableRemoved(c, id, consumed)
.post(
'/api/consumables/v1/consume',
describeRoute({
tags: ['Consumables'],
summary: 'Consume a quantity of an owned consumable',
description: [
'Reduces the given consumable instances count by `DeltaCount` (default 1), deleting',
'the row at zero. Scoped to the caller. Pushes a ConsumableMappingRemoved socket',
'notification. Envelope mirrors the gift-consume ack.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(ConsumeConsumableRequest, 'The consumable id and delta'),
responses: {
200: json(ConsumeEnvelope, 'Success envelope'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req
.json<{ Id?: unknown; DeltaCount?: unknown }>()
.catch(() => ({}) as { Id?: unknown; DeltaCount?: unknown })
const consumableId = typeof body.Id === 'number' ? body.Id : Number.NaN
const delta = typeof body.DeltaCount === 'number' ? body.DeltaCount : 1
if (!Number.isNaN(consumableId) && delta > 0) {
const consumed = await consumeConsumable(c.env.DB, id, consumableId, delta)
// Notify the player so their client removes/updates the item in inventory.
if (consumed !== null) await pushConsumableRemoved(c, id, consumed)
}
return c.json({ error: '', success: true, value: null })
}
return c.json({ error: '', success: true, value: null })
})
)
// Currency balance. [Authorize]. The trailing int is a CurrencyType — the client
// fetches `/balance/2` (RecCenterTokens) on load. Backed by the `balance` table; a
@@ -509,30 +924,78 @@ const app = new Hono<App>({ strict: false })
// An unknown or non-account-scoped currency (a room currency, ProgressionEvent,
// Invalid) returns a 0 balance rather than 404: the client treats a failed balance
// fetch as a load error, and "you have none of that" is the honest answer anyway.
.get('/api/storefronts/v4/balance/:currencyType', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const currencyType = Number.parseInt(c.req.param('currencyType'), 10)
if (Number.isNaN(currencyType)) return c.body(null, 400)
const amount = isSpendable(currencyType)
? await getBalance(
c.env.DB,
id,
currencyType,
intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
)
: 0
return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }])
})
.get(
'/api/storefronts/v4/balance/:currencyType',
describeRoute({
tags: ['Storefront'],
summary: 'Currency balance',
description: [
'The players balance in a CurrencyType (the client fetches `/balance/2`,',
'RecCenterTokens, on load). A first read seeds their starting balance. An unknown or',
'non-account currency returns a 0 balance rather than 404.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'currencyType',
in: 'path',
required: true,
description: 'CurrencyType integer; non-numeric is 400',
schema: { type: 'string' },
},
],
responses: {
200: json(BalanceEntry.array(), 'A single-entry balance array'),
400: { description: 'Non-numeric currencyType (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const currencyType = Number.parseInt(c.req.param('currencyType'), 10)
if (Number.isNaN(currencyType)) return c.body(null, 400)
const amount = isSpendable(currencyType)
? await getBalance(
c.env.DB,
id,
currencyType,
intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
)
: 0
return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }])
}
)
// Gift-drop storefront. Serves `static/storefronts/sf{id}.json` for the requested
// storefront id via the ASSETS binding; 404s when no such catalog exists.
.get('/api/storefronts/v3/giftdropstore/:id', async (c) => {
const id = c.req.param('id')
const res = await c.env.ASSETS.fetch(new URL(`/sf${id}.json`, c.req.url))
if (!res.ok) return c.notFound()
return c.json(await res.json())
})
.get(
'/api/storefronts/v3/giftdropstore/:id',
describeRoute({
tags: ['Storefront'],
summary: 'Gift-drop storefront catalog',
description: 'Serves the `sf{id}.json` catalog via the ASSETS binding. 404 when none exists.',
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: 'Storefront id (selects sf{id}.json)',
schema: { type: 'string' },
},
],
responses: {
200: json(JsonObject, 'The storefront catalog'),
404: { description: 'No such storefront catalog' },
},
}),
async (c) => {
const id = c.req.param('id')
const res = await c.env.ASSETS.fetch(new URL(`/sf${id}.json`, c.req.url))
if (!res.ok) return c.notFound()
return c.json(await res.json())
}
)
// Buy a storefront item. [Authorize]. The client posts the storefront/item ids, the
// currency and the price it sees; we look the item up in that storefront's catalog,
@@ -546,154 +1009,357 @@ const app = new Hono<App>({ strict: false })
//
// `RequestedPrice` is the price the client rendered; rejecting a mismatch stops a stale
// client (or a tampered request) from buying at a price the catalog no longer offers.
.post('/api/storefronts/v2/buyItem', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.post(
'/api/storefronts/v2/buyItem',
describeRoute({
tags: ['Storefront'],
summary: 'Buy a storefront item',
description: [
'Looks the item up in its storefront catalog, confirms the clients `RequestedPrice`',
'still matches, debits the buyer atomically, grants the item (into the inventory or',
'consumable table), and returns a gift box. A `Gift` block routes the item to another',
'player, but the caller always pays. `Balance` in the response is the CHANGE (negated',
'price), not the new total. Pushes a StorefrontBalanceUpdate socket frame carrying the',
'same change, which the client ADDS to the balance it is showing.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'),
responses: {
200: json(BuyItemResponse, 'The purchase result (gift box + balance change)'),
400: json(ErrorResponse, 'Invalid body, unavailable currency, or insufficient balance'),
401: UNAUTHORIZED_RESPONSE,
404: json(ErrorResponse, 'No such item'),
409: json(ErrorResponse, 'The price has changed since the client rendered it'),
},
}),
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 || typeof body !== 'object' || Array.isArray(body)) {
return c.json({ error: 'Invalid request body' }, 400)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return c.json({ error: 'Invalid request body' }, 400)
}
const storefrontType = body.StorefrontType
const purchasableItemId = body.PurchasableItemId
const currencyType = body.CurrencyType
const requestedPrice = body.RequestedPrice
if (
!Number.isInteger(storefrontType) ||
!Number.isInteger(purchasableItemId) ||
!Number.isInteger(currencyType) ||
!Number.isInteger(requestedPrice)
) {
return c.json(
{
error:
'StorefrontType, PurchasableItemId, CurrencyType and RequestedPrice are required',
},
400
)
}
const item = await findStoreItem(c, storefrontType as number, purchasableItemId as number)
if (item === null) return c.json({ error: 'Item not found' }, 404)
const price = item.Prices.find((p) => p.CurrencyType === currencyType)
if (price === undefined) {
return c.json({ error: 'Currency type not available for this item' }, 400)
}
if (price.Price !== requestedPrice) {
return c.json({ error: 'Price has changed' }, 409)
}
// The item's currency must be an account balance we can debit (RecCenterTokens et al),
// not a room-scoped or non-spendable currency.
if (!isSpendable(currencyType as number)) {
return c.json({ error: 'Currency type is not spendable' }, 400)
}
const gift = (
typeof body.Gift === 'object' && body.Gift !== null ? body.Gift : null
) as GiftRequest | null
const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id
// A named (non-anonymous) gift shows the sender; a self-purchase or an anonymous gift
// is attributed to the "Coach" system account (id 1), never a null/0 sender.
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
// Debit the buyer atomically; a false return means they couldn't afford it and
// nothing changed, so no item is granted.
const paid = await spendCurrency(
c.env.DB,
id,
currencyType as number,
price.Price,
startingTokens
)
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
// Grant the item to the recipient. A gift-drop carries an avatar item, a consumable,
// an equipment skin, or none of these (currency/xp drops aren't granted yet); grant
// whichever it actually has.
if (typeof item.GiftDrop.AvatarItemDesc === 'string' && item.GiftDrop.AvatarItemDesc !== '') {
await grantItem(c.env.DB, receiverId, toAvatarItem(item.GiftDrop))
}
if (
typeof item.GiftDrop.EquipmentModificationGuid === 'string' &&
item.GiftDrop.EquipmentModificationGuid !== ''
) {
await grantEquipment(c.env.DB, receiverId, toEquipment(item.GiftDrop))
}
const isConsumable =
typeof item.GiftDrop.ConsumableItemDesc === 'string' &&
item.GiftDrop.ConsumableItemDesc !== ''
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0
// Capture the granted consumable's row id and the player's pre-existing count so
// the gift box can carry them — gift-consume fires ConsumableMappingAdded from these.
let consumableMappingId = 0
let consumablePreExisting = 0
if (isConsumable) {
consumablePreExisting = await countConsumable(
c.env.DB,
receiverId,
item.GiftDrop.ConsumableItemDesc
)
consumableMappingId = await grantConsumable(
c.env.DB,
receiverId,
item.GiftDrop.ConsumableItemDesc,
consumableCount
)
}
const { id: giftId } = await createGift(
c.env.DB,
receiverId,
toGiftContent(
item.GiftDrop,
message,
consumableCount,
consumableMappingId,
consumablePreExisting
)
)
// Push the debit over the socket so the buyer's client updates the shown total
// immediately — the buyer (`id`) is who was charged, in the currency they spent. The
// frame carries the CHANGE, so a purchase is negative. Best-effort; the HTTP response
// carries the same change either way.
await pushBalanceUpdate(c, id, currencyType as number, -price.Price)
// The response mirrors a captured real buyItem: `Balance` is the change applied (the
// negated price), not the resulting balance (the client reads its new total from
// `GET /balance/:type`); `BalanceType` is -2 (account-wide, all platforms). The Data
// entry is the gift-drop the client received — it carries no FriendlyName or
// consumable count (the count is a getUnlocked concept; each box is one instance).
return c.json({
BalanceUpdates: [
{
UpdateResponse: 0,
Data: [
{
Id: giftId,
FromPlayerId: fromPlayerId,
ConsumableItemDesc: item.GiftDrop.ConsumableItemDesc,
AvatarItemDesc: item.GiftDrop.AvatarItemDesc,
AvatarItemType: item.GiftDrop.AvatarItemType ?? 0,
EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName,
EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid,
CurrencyType: item.GiftDrop.CurrencyType,
Currency: item.GiftDrop.Currency,
Xp: 0,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
GiftContext: Number.isInteger(gift?.GiftContext)
? (gift?.GiftContext as number)
: item.GiftDrop.Context,
GiftRarity: item.GiftDrop.Rarity,
Message: message,
},
],
},
],
Balance: -price.Price,
CurrencyType: currencyType,
BalanceType: ALL_PLATFORMS,
})
}
const storefrontType = body.StorefrontType
const purchasableItemId = body.PurchasableItemId
const currencyType = body.CurrencyType
const requestedPrice = body.RequestedPrice
if (
!Number.isInteger(storefrontType) ||
!Number.isInteger(purchasableItemId) ||
!Number.isInteger(currencyType) ||
!Number.isInteger(requestedPrice)
) {
return c.json(
)
// Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends
// `?inventionId=…&requestedPrice=…` with no body, so that's what we answer.
//
// A priced invention is settled player-to-player: the buyer is debited its `Price` in
// RecCenterTokens and the CREATOR is credited the same amount — no house cut, so the
// tokens are moved rather than minted or burned. A free invention (`Price` 0) skips the
// money entirely: nothing is debited and nobody is paid. The stored price is confirmed
// against the price the client rendered first, so a stale or tampered client can't buy
// at a price the creator no longer offers (409), and an unaffordable one is a 400 —
// the same "Insufficient balance" buyItem answers with.
//
// Ownership is recorded in `inventory_invention`; the creator is not sold their own
// invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
// than a second row. The invention's `NumDownloads` counter is deliberately NOT
// bumped: that column lives on the `invention` table the `api` worker owns, and this
// worker only reads it.
.get(
'/api/storefronts/v2/buyInvention',
describeRoute({
tags: ['Storefront'],
summary: 'Buy an invention',
description: [
'Looks the invention up by id, confirms the clients `requestedPrice` still matches',
'its stored `Price`, debits the buyer and pays the creator that price in',
'RecCenterTokens (a free invention moves nothing), records ownership in',
'`inventory_invention`, and returns the invention alongside the buyers resulting',
'balance. When tokens moved, both players get a StorefrontBalanceUpdate push carrying',
'their CHANGE (the buyers negative, the creators positive), which the client adds to',
'the balance it is showing — unlike this response body, which replaces it.',
'A GET because that is how the client sends it.',
].join(' '),
security: AUTHED,
parameters: [
{
error: 'StorefrontType, PurchasableItemId, CurrencyType and RequestedPrice are required',
name: 'inventionId',
in: 'query',
required: true,
description: 'Invention id; missing or non-numeric is 400',
schema: { type: 'integer' },
},
400
)
}
const item = await findStoreItem(c, storefrontType as number, purchasableItemId as number)
if (item === null) return c.json({ error: 'Item not found' }, 404)
const price = item.Prices.find((p) => p.CurrencyType === currencyType)
if (price === undefined) {
return c.json({ error: 'Currency type not available for this item' }, 400)
}
if (price.Price !== requestedPrice) {
return c.json({ error: 'Price has changed' }, 409)
}
// The item's currency must be an account balance we can debit (RecCenterTokens et al),
// not a room-scoped or non-spendable currency.
if (!isSpendable(currencyType as number)) {
return c.json({ error: 'Currency type is not spendable' }, 400)
}
const gift = (
typeof body.Gift === 'object' && body.Gift !== null ? body.Gift : null
) as GiftRequest | null
const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id
// A named (non-anonymous) gift shows the sender; a self-purchase or an anonymous gift
// is attributed to the "Coach" system account (id 1), never a null/0 sender.
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
// Debit the buyer atomically; a false return means they couldn't afford it and
// nothing changed, so no item is granted.
const paid = await spendCurrency(
c.env.DB,
id,
currencyType as number,
price.Price,
startingTokens
)
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
// Grant the item to the recipient. A gift-drop carries an avatar item, a consumable,
// or neither (currency/xp drops aren't granted yet); grant whichever it actually has.
if (typeof item.GiftDrop.AvatarItemDesc === 'string' && item.GiftDrop.AvatarItemDesc !== '') {
await grantItem(c.env.DB, receiverId, toAvatarItem(item.GiftDrop))
}
const isConsumable =
typeof item.GiftDrop.ConsumableItemDesc === 'string' &&
item.GiftDrop.ConsumableItemDesc !== ''
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0
// Capture the granted consumable's row id and the player's pre-existing count so
// the gift box can carry them — gift-consume fires ConsumableMappingAdded from these.
let consumableMappingId = 0
let consumablePreExisting = 0
if (isConsumable) {
consumablePreExisting = await countConsumable(
c.env.DB,
receiverId,
item.GiftDrop.ConsumableItemDesc
)
consumableMappingId = await grantConsumable(
c.env.DB,
receiverId,
item.GiftDrop.ConsumableItemDesc,
consumableCount
)
}
const { id: giftId } = await createGift(
c.env.DB,
receiverId,
toGiftContent(item.GiftDrop, message, consumableCount, consumableMappingId, consumablePreExisting)
)
// Push the buyer's new (reduced) balance over the socket so their client updates the
// shown total immediately — the buyer (`id`) is who was debited, in the currency they
// spent. Best-effort; the HTTP response still carries the change either way.
const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
await pushBalanceUpdate(c, id, currencyType as number, newBalance)
// The response mirrors a captured real buyItem: `Balance` is the change applied (the
// negated price), not the resulting balance (the client reads its new total from
// `GET /balance/:type`); `BalanceType` is -2 (account-wide, all platforms). The Data
// entry is the gift-drop the client received — it carries no FriendlyName or
// consumable count (the count is a getUnlocked concept; each box is one instance).
return c.json({
BalanceUpdates: [
{
UpdateResponse: 0,
Data: [
{
Id: giftId,
FromPlayerId: fromPlayerId,
ConsumableItemDesc: item.GiftDrop.ConsumableItemDesc,
AvatarItemDesc: item.GiftDrop.AvatarItemDesc,
AvatarItemType: item.GiftDrop.AvatarItemType ?? 0,
EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName,
EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid,
CurrencyType: item.GiftDrop.CurrencyType,
Currency: item.GiftDrop.Currency,
Xp: 0,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
GiftContext: Number.isInteger(gift?.GiftContext)
? (gift?.GiftContext as number)
: item.GiftDrop.Context,
GiftRarity: item.GiftDrop.Rarity,
Message: message,
},
],
name: 'requestedPrice',
in: 'query',
required: false,
description: 'The price the client rendered; a mismatch is 409. Defaults to 0',
schema: { type: 'integer' },
},
],
Balance: -price.Price,
CurrencyType: currencyType,
BalanceType: ALL_PLATFORMS,
})
})
responses: {
200: json(BuyInventionResponse, 'The purchase result (invention + balance)'),
400: json(
ErrorResponse,
'Missing/non-numeric inventionId, buying your own, or insufficient balance'
),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'The invention is not published, so it is not for sale'),
404: json(ErrorResponse, 'No such invention'),
409: json(ErrorResponse, 'Already owned, or the price has changed'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
// Absent/non-numeric requestedPrice reads as 0, which only matches a free invention —
// a priced one then fails the confirmation below rather than selling for nothing.
const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return c.json({ error: 'Invention not found' }, 404)
// An unpublished invention is a draft: it isn't on sale, not even for free.
if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403)
if (invention.CreatorPlayerId === id) {
return c.json({ error: 'Cannot buy your own invention' }, 400)
}
if (await ownsInvention(c.env.DB, id, inventionId)) {
return c.json({ error: 'Already owned' }, 409)
}
// The price the client rendered must still be the stored one: a mismatch is a stale
// catalog or a tampered request, never a sale.
if (invention.Price !== requestedPrice) {
return c.json({ error: 'Price has changed' }, 409)
}
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
// Inventions are priced in RecCenterTokens only — the store shows no other currency
// for them, and `Price` carries no currency of its own to pick a different one from.
const price = invention.Price
if (price > 0) {
// Debit the buyer atomically; false means they couldn't afford it and nothing
// changed, so no ownership is recorded and the creator is not paid.
const paid = await spendCurrency(
c.env.DB,
id,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
}
// Grant before paying out: these are three separate D1 writes with no transaction
// around them, so order them by what a failure costs. A buyer who paid and got the
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
// is not.
await grantInvention(c.env.DB, id, inventionId)
if (price > 0) {
// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
// the balance row, and `ensureStartingBalances` is an INSERT OR IGNORE, so a
// creator who had never touched their balance would otherwise have the row created
// here and lose their starting tokens forever.
await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
await creditCurrency(
c.env.DB,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
// The creator is a different, probably-online player: push the payout so a sale
// lands on their shown balance without a re-fetch. Positive, because the frame
// carries the change. Best-effort, as everywhere.
await pushBalanceUpdate(c, invention.CreatorPlayerId, CurrencyType.RecCenterTokens, price)
}
// Unlike buyItem — whose `Balance` is the change applied — the reference server
// answers this one with the RESULTING total (a first read seeds the buyer's starting
// grant, as everywhere else). The socket frame below is the other way round: the HTTP
// body REPLACES the shown balance, the push ADDS to it.
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
// A free invention moved nothing, so there is no change to push for it.
if (price > 0) {
await pushBalanceUpdate(c, id, CurrencyType.RecCenterTokens, -price)
}
return c.json({
BalanceUpdateResponse: {
Balance: balance,
BalanceType: ALL_PLATFORMS,
CurrencyType: CurrencyType.RecCenterTokens,
BalanceUpdates: [{ UpdateResponse: 0, Data: invention }],
},
// The same `{ Status, Invention, InventionVersion }` envelope the invention
// save/read endpoints serve — the client re-renders the invention from it.
InventionResponse: toSaveResult(invention),
})
}
)
// Storefront ad-carousel items. Served from the bundled static JSON — one
// placeholder banner with no purchasable items until real promo data exists.
.get('/api/storefronts/v1/adcarouselitems', (c) => c.json(adCarouselItems))
.get(
'/api/storefronts/v1/adcarouselitems',
listRoute('Storefront ad-carousel items', 'The bundled carousel (one placeholder banner)'),
(c) => c.json(adCarouselItems)
)
// Current weekly challenge. Served from the bundled static JSON until
// per-rotation challenge data is wired up.
.get('/api/challenge/v2/getCurrent', (c) => c.json(weeklyChallenge))
.get(
'/api/challenge/v2/getCurrent',
describeRoute({
tags: ['Econ'],
summary: 'Current weekly challenge',
description: 'Served from the bundled static challenge until per-rotation data is wired up.',
responses: { 200: json(JsonObject, 'The current weekly challenge') },
}),
(c) => c.json(weeklyChallenge)
)
// Report progress on a weekly challenge. The client evaluates the challenge's rule
// tree locally and posts ChallengeMapId/ChallengeId, that tree in `Config`, and
@@ -701,34 +1367,103 @@ const app = new Hono<App>({ strict: false })
// progress DB yet we persist nothing and never mark a challenge complete (so the
// gift flow isn't triggered). Echo the identifying fields back with Complete=false
// so the client gets a well-formed, non-null body to deserialize.
.post('/api/challenge/v2/updateProgress', async (c) => {
const body = await c.req
.json<{ ChallengeMapId?: string | number; ChallengeId?: string | number; Config?: string }>()
.catch(() => ({}) as Record<string, never>)
return c.json({
ChallengeMapId: Number(body.ChallengeMapId) || 0,
ChallengeId: Number(body.ChallengeId) || 0,
Config: typeof body.Config === 'string' ? body.Config : '',
Complete: false,
})
})
.post(
'/api/challenge/v2/updateProgress',
describeRoute({
tags: ['Econ'],
summary: 'Report weekly-challenge progress',
description: [
'Stubbed: with no challenge-progress store we persist nothing and never mark a',
'challenge complete. Echoes the identifying fields back with `Complete: false` so the',
'client gets a well-formed body.',
].join(' '),
requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'),
responses: { 200: json(ChallengeProgressResponse, 'Echoed fields, Complete false') },
}),
async (c) => {
const body = await c.req
.json<{
ChallengeMapId?: string | number
ChallengeId?: string | number
Config?: string
}>()
.catch(() => ({}) as Record<string, never>)
return c.json({
ChallengeMapId: Number(body.ChallengeMapId) || 0,
ChallengeId: Number(body.ChallengeId) || 0,
Config: typeof body.Config === 'string' ? body.Config : '',
Complete: false,
})
}
)
// Pending game rewards. Returns "[]".
.get('/api/gamerewards/v1/pending', (c) => c.json([]))
.get('/api/gamerewards/v1/pending', listRoute('Pending game rewards', 'Empty for now'), (c) =>
c.json([])
)
// Request a game reward (client posts `rewardType`/`Message`, e.g.
// FirstActivityOfDay). Stubbed: with no reward DB yet we grant nothing and return an
// empty list of rewards — matching the `pending` shape so the client deserializes it.
.post('/api/gamerewards/v1/request', (c) => c.json([]))
// The player's room keys. Returns "[]".
.get('/api/roomkeys/v1/mine', (c) => c.json([]))
// Room keys for a given room (client calls this on the econ host). [] with no DB.
.get('/api/roomkeys/v1/room', (c) => c.json([]))
// Subscription lookup. Returns both fields null with no auth.
.post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) =>
c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
.post(
'/api/gamerewards/v1/request',
listRoute('Request a game reward', 'Stubbed — grants nothing, returns []'),
(c) => c.json([])
)
// The player's room keys. Returns "[]".
.get('/api/roomkeys/v1/mine', listRoute('The players room keys', 'Empty for now'), (c) =>
c.json([])
)
// Room keys for a given room (client calls this on the econ host). [] with no DB.
.get('/api/roomkeys/v1/room', listRoute('Room keys for a room', 'Empty for now'), (c) =>
c.json([])
)
// Subscription lookup. Returns both fields null with no auth.
.post(
'/api/CampusCard/v1/UpdateAndGetSubscription',
describeRoute({
tags: ['Econ'],
summary: 'Subscription lookup',
description: 'No subscriptions yet — both fields null. No auth.',
responses: { 200: json(SubscriptionResponse, 'Both fields null') },
}),
(c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
)
// 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 econ',
version: '1.0.0',
description: [
'Avatar and economy endpoints for recflare, a private-server reimplementation of the',
'Rec Room backend. The client calls these on the `econ` host; many are also served by',
'the `api` worker. Storefront catalogs are static assets (`sf{N}.json`); balances,',
'inventory, consumables, saved outfits and gift boxes are D1-backed.',
].join('\n'),
},
servers: [{ url: 'https://econ.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+115
View File
@@ -0,0 +1,115 @@
/**
* Owned equipment on the shared `recflare` D1 database the equipment skins a player
* has bought from a storefront (e.g. a "Bow Skin (Dryad Summer)"). One row per
* (account, item): like avatar items, owning a piece of equipment is boolean, so the
* skin is granted at purchase time (`POST /api/storefronts/v2/buyItem`, when the
* gift-drop carries an `EquipmentModificationGuid`) and read back by
* `GET /api/equipment/v2/getUnlocked`.
*
* The item is keyed by the gift-drop's equipment guid, so re-buying the same skin
* upserts rather than piling up duplicate rows (these drops are flagged `Unique`).
* `data` is the rendered unlocked-equipment DTO, stored opaquely and served back
* untouched.
*
* This worker (`econ`) owns the table and its migration see apps/econ/migrations/
* 0006_equipment.sql. The gift box the purchase also creates lives in a separate table
* (@repo/domain's received_gift); ownership does not depend on the box being opened.
*/
/** Schema DDL (mirror of migrations 0006_equipment.sql) — also builds the table in tests. */
export const EQUIPMENT_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS equipment (
account_id INTEGER NOT NULL,
equipment_modification_guid TEXT NOT NULL,
data TEXT NOT NULL,
PRIMARY KEY (account_id, equipment_modification_guid)
)`,
]
/**
* A rendered piece of unlocked equipment, as `/api/equipment/v2/getUnlocked` serves it.
* `ModificationGuid` is the item's guid string and the row's key; `PrefabName` names the
* base equipment the modification applies to.
*
* The names are UNPREFIXED here, unlike the gift-drop/gift-box shapes that carry the
* same two values as `EquipmentPrefabName`/`EquipmentModificationGuid`. That's not an
* inconsistency to tidy up: a drop is a flat record holding avatar, consumable and
* equipment fields side by side, so it needs the prefix to disambiguate, while this
* record is all equipment. Confirmed against the live endpoint, and the entries the
* client PUTs back to `/api/equipment/v1/update` use the same unprefixed names.
*/
export interface Equipment extends Record<string, unknown> {
ModificationGuid: string
PrefabName: string
FriendlyName: string
Tooltip: string
Rarity: number
/** Always -1 (all platforms) — we don't gate equipment per platform. */
PlatformMask: number
/** Player-set favourite flag, toggled by `PUT /api/equipment/v1/update`. */
Favorited: boolean
}
/**
* Grant a piece of equipment into a player's inventory. Upserts on
* (account_id, equipment_modification_guid): owning equipment is boolean, so re-buying
* it refreshes the stored DTO rather than adding a second copy. The refresh carries the
* player's `Favorited` flag over, so re-buying doesn't quietly un-favourite the skin
* (a row written before the flag existed reads as not favourited).
*/
export async function grantEquipment(
db: D1Database,
accountId: number,
equipment: Equipment
): Promise<void> {
await db
.prepare(
`INSERT INTO equipment (account_id, equipment_modification_guid, data) VALUES (?1, ?2, ?3)
ON CONFLICT (account_id, equipment_modification_guid) DO UPDATE SET
data = json_set(?3, '$.Favorited',
json(CASE WHEN json_extract(equipment.data, '$.Favorited') THEN 'true' ELSE 'false' END))`
)
.bind(accountId, equipment.ModificationGuid, JSON.stringify(equipment))
.run()
}
/** One entry of the `PUT /api/equipment/v1/update` body. */
export interface EquipmentFavoriteUpdate {
ModificationGuid: string
Favorited: boolean
}
/**
* Apply the client's favourite toggles. Only the `Favorited` flag is writable the
* rest of the posted entry (PrefabName, Rarity, ) is the client echoing back what it
* was served, and the reference server ignores it too.
*
* A guid the caller doesn't own matches no row and is silently dropped: equipment is
* only ever granted by a purchase, so there is nothing to favourite until then (and an
* insert here would let a client mint equipment for itself).
*/
export async function setEquipmentFavorited(
db: D1Database,
accountId: number,
updates: EquipmentFavoriteUpdate[]
): Promise<void> {
if (updates.length === 0) return
const stmt = db.prepare(
`UPDATE equipment SET data = json_set(data, '$.Favorited', json(?3))
WHERE account_id = ?1 AND equipment_modification_guid = ?2`
)
await db.batch(
updates.map((u) => stmt.bind(accountId, u.ModificationGuid, u.Favorited ? 'true' : 'false'))
)
}
/** Every piece of equipment a player owns, ordered by guid for a stable listing. */
export async function getEquipment(db: D1Database, accountId: number): Promise<Equipment[]> {
const { results } = await db
.prepare(
'SELECT data FROM equipment WHERE account_id = ?1 ORDER BY equipment_modification_guid'
)
.bind(accountId)
.all<{ data: string }>()
return results.map((r) => JSON.parse(r.data) as Equipment)
}
+232
View File
@@ -0,0 +1,232 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the econ worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/match workers: a reverse-engineered protocol, lenient
* handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** The empty-body 401 the auth-gated routes return. */
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
// ---- Loose shapes ----------------------------------------------------------
// Several routes serve opaque static catalogs (avatar items, the weekly challenge) or
// empty-list stubs. Modelling every catalog field adds noise without value, so these
// use deliberately loose schemas.
/** An opaque JSON object (a catalog entry, an avatar blob, …). */
export const JsonObject = z.record(z.string(), z.unknown())
/** An opaque JSON array (a static catalog served verbatim). */
export const JsonArray = z.array(z.unknown())
// ---- Response schemas ------------------------------------------------------
/**
* The public avatar render subset (`GET /api/avatar/v2/:id`) the fields needed to
* draw another player's avatar. The stored blob also holds OutfitSelectionsV2 /
* CustomAvatarItems, which this view omits.
*/
export const AvatarV2Dto = z.object({
OutfitSelections: z.unknown(),
FaceFeatures: z.unknown(),
SkinColor: z.unknown(),
HairColor: z.unknown(),
})
/**
* The `{ error, success, value }` envelope both consume routes return. Always HTTP 200,
* even for a missing/already-gone target the client parses this to finish the action,
* so a bare 200 reads as a failure.
*/
export const ConsumeEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.null(),
})
/** One currency balance entry (`GET /api/storefronts/v4/balance/:currencyType`). */
export const BalanceEntry = z.object({
CurrencyType: z.int(),
Platform: z.int().describe('-2 = all platforms (account-wide)'),
Balance: z.int(),
})
/** `GET /econ/customAvatarItems/v1/owned` — paginated owned custom items. */
export const CustomAvatarItemsResponse = z.object({
Results: JsonArray,
TotalResults: z.int(),
})
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both fields null (no subs yet). */
export const SubscriptionResponse = z.object({
subscription: z.null(),
platformAccountSubscribedPlayerId: z.null(),
})
/** `POST /api/challenge/v2/updateProgress` — the identifying fields echoed back. */
export const ChallengeProgressResponse = z.object({
ChallengeMapId: z.int(),
ChallengeId: z.int(),
Config: z.string(),
Complete: z.boolean().describe('Always false — no challenge-progress store yet'),
})
/**
* `POST /api/storefronts/v2/buyItem` the purchase result. `Balance` is the CHANGE
* applied (the negated price), not the resulting total; the client reads its new total
* from `GET /balance/:type`. `BalanceType` -2 is account-wide. Each `Data` entry is the
* gift-drop the recipient received.
*/
export const BuyItemResponse = z.object({
BalanceUpdates: z.array(
z.object({
UpdateResponse: z.int(),
Data: z.array(JsonObject).describe('The gift-drop(s) granted'),
})
),
Balance: z.int().describe('The change applied (negated price), not the new total'),
CurrencyType: z.int(),
BalanceType: z.int().describe('-2 = account-wide'),
})
/**
* `GET /api/storefronts/v2/buyInvention` the purchase result. Two envelopes side by
* side: the balance update (shaped like buyItem's, except `Balance` is the RESULTING
* total, not the change, and `Data` is a single invention rather than a gift-drop list)
* and the invention envelope the invention endpoints already serve.
*/
export const BuyInventionResponse = z.object({
BalanceUpdateResponse: z.object({
Balance: z.int().describe('The resulting balance — NOT the change, unlike buyItem'),
BalanceType: z.int().describe('-2 = account-wide'),
CurrencyType: z.int().describe('2 = RecCenterTokens'),
BalanceUpdates: z.array(
z.object({
UpdateResponse: z.int(),
Data: JsonObject.describe('The bought invention (`RRInvention`)'),
})
),
}),
InventionResponse: z
.object({
Status: z.int(),
Invention: JsonObject,
InventionVersion: JsonObject,
})
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
})
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
export const ErrorResponse = z.object({ error: z.string() })
// ---- Request schemas -------------------------------------------------------
/** `POST /api/storefronts/v2/buyItem` JSON body. */
export const BuyItemRequest = z.object({
StorefrontType: z.int().describe('Which storefront catalog (sf{N}.json)'),
PurchasableItemId: z.int(),
CurrencyType: z.int().describe('Must be a spendable account currency'),
RequestedPrice: z.int().describe('The price the client rendered; a mismatch is 409'),
Gift: z
.object({
ToPlayerId: z.int().optional(),
Anonymous: z.boolean().optional(),
Message: z.string().optional(),
GiftContext: z.int().optional(),
})
.optional()
.describe('Present when buying for another player; the caller still pays'),
})
/** `POST /api/consumables/v1/consume` JSON body. */
export const ConsumeConsumableRequest = z.object({
Id: z.int().describe('The consumable row id to spend from'),
DeltaCount: z.int().optional().describe('How many to spend; defaults to 1'),
})
/** `POST /api/avatar/v2/gifts/consume` form body (posted with a trailing slash). */
export const ConsumeGiftRequest = z.object({
Id: z.string().describe('The gift-box id to open'),
UnlockedLevel: z.string().optional().describe('Consumable-level hint; unused'),
})
/** `POST /api/challenge/v2/updateProgress` JSON body. */
export const ChallengeProgressRequest = z.object({
ChallengeMapId: z.union([z.string(), z.int()]).optional(),
ChallengeId: z.union([z.string(), z.int()]).optional(),
Config: z.string().optional().describe('The client-evaluated rule tree'),
})
/** `POST /api/avatar/v3/saved/set` JSON body — an outfit with a target `Slot`. */
export const SaveOutfitRequest = z
.object({ Slot: z.int().describe('Which slot to overwrite; a non-integer is 400') })
.catchall(z.unknown())
.describe('Plus opaque outfit fields (OutfitSelectionsV2, FaceFeatures, …) stored verbatim')
/**
* `POST /api/avatar/v4/saved/set` response a lean acknowledgement. Unlike v3 (which
* echoes the whole outfit), v4 answers just the success flag and the slot it wrote.
*/
export const SaveOutfitV4Response = z.object({
Success: z.boolean(),
Slot: z.int().describe('The slot that was written'),
})
/**
* `PUT /api/equipment/v1/update` JSON body the client's favourite toggles. It echoes
* back the whole entry it was served, but only `Favorited` is written; the rest is
* ignored (as on the reference server).
*/
export const EquipmentUpdateRequest = z.array(
z
.object({
ModificationGuid: z.string().describe('Identifies the owned equipment row'),
Favorited: z.boolean(),
})
.catchall(z.unknown())
.describe('Plus the echoed-back PrefabName / FriendlyName / Tooltip / Rarity, all ignored')
)
/** An opaque JSON body stored verbatim (the avatar blob for `POST /api/avatar/v2/set`). */
export const OpaqueJsonBody = JsonObject.describe('Stored verbatim and echoed back')
+464 -31
View File
@@ -4,8 +4,15 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../econ.app'
import { RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain'
import {
getOwnedInventionIds,
INVENTORY_INVENTION_SCHEMA_DDL,
RECEIVED_GIFT_SCHEMA_DDL,
} from '@repo/domain'
// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL
// is built here too (see the same cross-worker import in econ.app.ts).
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
import { SCHEMA_DDL } from '../../avatar-db'
import {
BALANCE_SCHEMA_DDL,
@@ -15,6 +22,7 @@ import {
spendCurrency,
} from '../../balance-db'
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
@@ -36,12 +44,78 @@ beforeAll(async () => {
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
.run()
for (const invention of SEEDED_INVENTIONS) {
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(invention))
.run()
}
})
/**
* Inventions the buyInvention tests buy (or fail to buy). Only the fields that path
* reads are meaningful id, creator, published flag and price but the record is
* shaped like a real stored `RRInvention` so the response envelope is realistic.
*/
function invention(
inventionId: number,
overrides: { CreatorPlayerId?: number; IsPublished?: boolean; Price?: number } = {}
) {
return {
InventionId: inventionId,
ReplicationId: `replication-${inventionId}`,
CreatorPlayerId: 999,
Name: `Invention ${inventionId}`,
Description: 'A test invention',
ImageName: '',
CurrentVersionNumber: 1,
CurrentVersion: {
InventionId: inventionId,
ReplicationId: `version-${inventionId}`,
VersionNumber: 1,
BlobName: `invention-${inventionId}.inv`,
BlobHash: null,
InstantiationCost: 0,
LightsCost: 0,
ChipsCost: 0,
CloudVariablesCost: 0,
AICost: 0,
},
Accessibility: 0,
IsPublished: true,
IsFeatured: false,
ModifiedAt: '2026-01-01T00:00:00.000Z',
CreatedAt: '2026-01-01T00:00:00.000Z',
FirstPublishedAt: '2026-01-01T00:00:00.000Z',
CreationRoomId: 0,
NumPlayersHaveUsedInRoom: 0,
NumDownloads: 0,
CheerCount: 0,
CreatorPermission: 100,
GeneralPermission: 20,
IsAGInvention: false,
IsCertifiedInvention: false,
Price: 0,
AllowTrial: true,
HideFromPlayer: false,
ReferencedInventions: [],
...overrides,
}
}
const SEEDED_INVENTIONS = [
invention(8), // free, published, someone else's — the sellable one
invention(9, { Price: 250 }), // priced: buying it pays creator 999 250 tokens
invention(10, { IsPublished: false }), // a draft, not on sale even at 0
invention(11, { CreatorPlayerId: 60 }), // account 60's own invention
]
/**
* A real outfit as the client posts it to /api/avatar/v3/saved/set kept verbatim
* (including the JSON-in-a-string OutfitSelectionsV2/FaceFeatures fields) so the
@@ -354,6 +428,39 @@ describe('econ endpoints', () => {
expect(res.status).toBe(400)
})
test('POST /api/avatar/v4/saved/set stores like v3 but acks with { Success, Slot }', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/saved/set`, {
method: 'POST',
body: JSON.stringify(SAVED_OUTFIT),
})
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/saved/set`, {
method: 'POST',
headers: { ...(await bearer('25')), 'Content-Type': 'application/json' },
body: JSON.stringify(SAVED_OUTFIT),
})
expect(res.status).toBe(200)
// v4 answers a lean ack, not the echoed outfit.
expect(await res.json()).toEqual({ Success: true, Slot: SAVED_OUTFIT.Slot })
// Shares the v3 outfit table, so the v3 read serves the outfit back verbatim.
const saved = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`, {
headers: await bearer('25'),
})
expect(await saved.json()).toEqual([SAVED_OUTFIT])
})
test('POST /api/avatar/v4/saved/set 400s without an integer Slot', async () => {
const { Slot: _Slot, ...noSlot } = SAVED_OUTFIT
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/saved/set`, {
method: 'POST',
headers: { ...(await bearer('26')), 'Content-Type': 'application/json' },
body: JSON.stringify(noSlot),
})
expect(res.status).toBe(400)
})
test('GET /api/avatar/v2/gifts 401s without a token, returns [] with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`)
expect(anon.status).toBe(401)
@@ -364,8 +471,13 @@ describe('econ endpoints', () => {
expect(await res.json()).toEqual([])
})
test('GET /api/equipment/v2/getUnlocked returns [] (no auth)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`)
test('GET /api/equipment/v2/getUnlocked 401s without a token, returns [] when none owned', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`)
expect(anon.status).toBe(401)
// Account 30 has bought no equipment → empty list.
const res = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, {
headers: await bearer('30'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
@@ -406,16 +518,6 @@ describe('econ endpoints', () => {
expect(await res.json()).toEqual([])
})
test('POST /api/settings/v2/set 401s without a token, 200s with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/settings/v2/set`, { method: 'POST' })
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/settings/v2/set`, {
method: 'POST',
headers: await bearer(),
})
expect(res.status).toBe(200)
})
test('GET /api/consumables/v2/getUnlocked 401s without a token, returns []', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`)
expect(anon.status).toBe(401)
@@ -582,7 +684,7 @@ describe('econ endpoints', () => {
expect(await res.json()).toBeTruthy()
})
// Item 73 in sf3.json — "Class of 2016", 4500 RecCenterTokens (CurrencyType 2).
// Item 73 in sf3.json — "Bowtie (White)", 450 RecCenterTokens (CurrencyType 2).
test('POST /api/storefronts/v2/buyItem 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
@@ -591,7 +693,7 @@ describe('econ endpoints', () => {
StorefrontType: 3,
PurchasableItemId: 73,
CurrencyType: 2,
RequestedPrice: 4500,
RequestedPrice: 450,
}),
})
expect(res.status).toBe(401)
@@ -599,6 +701,7 @@ describe('econ endpoints', () => {
test('POST /api/storefronts/v2/buyItem debits, grants the item, and hands back a gift box', async () => {
// Account 20: fresh, so its first balance touch grants the 10000 default.
await drainFrames()
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('20')), 'Content-Type': 'application/json' },
@@ -606,7 +709,7 @@ describe('econ endpoints', () => {
StorefrontType: 3,
PurchasableItemId: 73,
CurrencyType: 2,
RequestedPrice: 4500,
RequestedPrice: 450,
}),
})
expect(res.status).toBe(200)
@@ -619,25 +722,35 @@ describe('econ endpoints', () => {
}>
}
// `Balance` is the change applied (the negated price), not the resulting total.
expect(body.Balance).toBe(-4500)
expect(body.Balance).toBe(-450)
expect(body.CurrencyType).toBe(2)
expect(body.BalanceType).toBe(-2)
const gift = body.BalanceUpdates[0].Data[0]
expect(gift.AvatarItemDesc).not.toBe('')
expect(gift.Id).toBeGreaterThan(0)
// The balance endpoint reflects the debit (this is the resulting total, 10000 - 4500).
// The socket frame carries the same change the response does — the client adds it to
// the balance it is showing, so the resulting total here would double-count the 9550.
expect(await drainFrames()).toEqual([
{
accountId: 20,
notificationType: STOREFRONT_BALANCE_UPDATE,
payload: { Balance: -450, CurrencyType: 2, BalanceType: -2 },
},
])
// The balance endpoint reflects the debit (this is the resulting total, 10000 - 450).
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('20'),
})
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 5500 }])
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 9550 }])
// The item is now owned — it leads the v4/items list (owned items prepend the catalog).
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('20'),
})
const list = (await items.json()) as Array<{ AvatarItemDesc: string; FriendlyName: string }>
expect(list[0].FriendlyName).toBe('Class of 2016')
expect(list[0].FriendlyName).toBe('Bowtie (White)')
expect(list[0].AvatarItemDesc).toBe(gift.AvatarItemDesc)
// And a pending gift box is waiting to be opened.
@@ -732,6 +845,107 @@ describe('econ endpoints', () => {
expect(second[0].CreatedAts).toHaveLength(2)
})
test('POST /api/storefronts/v2/buyItem grants equipment, read back by getUnlocked, no re-buy dupe', async () => {
// Item 1950 (Disc Skin (Coop)) in storefront 3 is a pure equipment drop — its
// gift-drop carries an EquipmentModificationGuid but no avatar/consumable desc.
const guid = '19ef59c7-f74b-4c63-935a-1d4b1abd8518'
const buy = async () =>
exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('31')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 1950,
CurrencyType: 2,
RequestedPrice: 3500,
}),
})
const res = await buy()
expect(res.status).toBe(200)
const body = (await res.json()) as {
Balance: number
BalanceUpdates: Array<{
Data: Array<{ Id: number; EquipmentModificationGuid: string; EquipmentPrefabName: string }>
}>
}
expect(body.Balance).toBe(-3500)
const gift = body.BalanceUpdates[0].Data[0]
expect(gift.EquipmentModificationGuid).toBe(guid)
expect(gift.EquipmentPrefabName).toBe('[DiscGolfDisc]')
const unlocked = async () => {
const r = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, {
headers: await bearer('31'),
})
expect(r.status).toBe(200)
return (await r.json()) as Array<{
ModificationGuid: string
PrefabName: string
FriendlyName: string
PlatformMask: number
Favorited: boolean
}>
}
const first = await unlocked()
expect(first).toHaveLength(1)
// The unlocked DTO is unprefixed, unlike the gift-drop the grant came from.
expect(first[0].ModificationGuid).toBe(guid)
expect(first[0].PrefabName).toBe('[DiscGolfDisc]')
expect(first[0].FriendlyName).toBe('Disc Skin (Coop)')
expect(first[0].PlatformMask).toBe(-1)
// Equipment is not an avatar item — it does not show up in v4/items.
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('31'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.every((i) => i.FriendlyName !== 'Disc Skin (Coop)')).toBe(true)
expect(first[0].Favorited).toBe(false)
// Owning equipment is boolean: re-buying upserts, it does not add a second row.
expect((await buy()).status).toBe(200)
expect(await unlocked()).toHaveLength(1)
// Favouriting sticks.
const update = async (favorited: boolean) =>
exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method: 'PUT',
headers: { ...(await bearer('31')), 'Content-Type': 'application/json' },
body: JSON.stringify([
{ PrefabName: '[DiscGolfDisc]', ModificationGuid: guid, Favorited: favorited },
// A guid the caller doesn't own is silently skipped, not inserted.
{ PrefabName: '[Basketball]', ModificationGuid: 'not-owned', Favorited: true },
]),
})
expect((await update(true)).status).toBe(200)
let after = await unlocked()
expect(after).toHaveLength(1)
expect(after[0].Favorited).toBe(true)
// …and un-favouriting flips it back.
expect((await update(false)).status).toBe(200)
after = await unlocked()
expect(after[0].Favorited).toBe(false)
})
test('PUT /api/equipment/v1/update 401s without a token, 400s on a non-array body', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: '[]',
})
expect(anon.status).toBe(401)
const bad = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method: 'PUT',
headers: { ...(await bearer('32')), 'Content-Type': 'application/json' },
body: '{}',
})
expect(bad.status).toBe(400)
})
test('POST /api/storefronts/v2/buyItem 409s when the sent price no longer matches', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
@@ -759,7 +973,7 @@ describe('econ endpoints', () => {
StorefrontType: 3,
PurchasableItemId: 9999999,
CurrencyType: 2,
RequestedPrice: 4500,
RequestedPrice: 450,
}),
})
expect(res.status).toBe(404)
@@ -777,7 +991,7 @@ describe('econ endpoints', () => {
StorefrontType: 3,
PurchasableItemId: 73,
CurrencyType: 2,
RequestedPrice: 4500,
RequestedPrice: 450,
}),
})
expect(res.status).toBe(400)
@@ -786,7 +1000,154 @@ describe('econ endpoints', () => {
headers: await bearer('23'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.every((i) => i.FriendlyName !== 'Class of 2016')).toBe(true)
expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true)
})
/**
* The StorefrontBalanceUpdate (and other) frames the worker has pushed since the last
* drain, read back off the stub hub in vitest.config.ts. Notification sends are
* best-effort the worker logs and swallows a hub failure so this is the only way a
* test sees what was actually pushed.
*/
const drainFrames = async (): Promise<
Array<{ accountId: number; notificationType: number; payload: Record<string, number> }>
> =>
(
env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as {
drainFrames(): Promise<
Array<{ accountId: number; notificationType: number; payload: Record<string, number> }>
>
}
).drainFrames()
/** `NotificationType.StorefrontBalanceUpdate` in the notify worker's enum. */
const STOREFRONT_BALANCE_UPDATE = 61
// buyInvention is a GET with query params — that is how the client sends it.
const buyInvention = async (sub: string, inventionId: number, requestedPrice = 0) =>
exports.default.fetch(
`${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=${inventionId}&requestedPrice=${requestedPrice}`,
{ headers: await bearer(sub) }
)
test('GET /api/storefronts/v2/buyInvention 401s without a token', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=8&requestedPrice=0`
)
expect(res.status).toBe(401)
})
test('GET /api/storefronts/v2/buyInvention records ownership of a free invention', async () => {
const res = await buyInvention('50', 8)
expect(res.status).toBe(200)
const body = (await res.json()) as {
BalanceUpdateResponse: {
Balance: number
BalanceType: number
CurrencyType: number
BalanceUpdates: Array<{ UpdateResponse: number; Data: { InventionId: number } }>
}
InventionResponse: {
Status: number
Invention: { InventionId: number; Name: string }
InventionVersion: { InventionId: number; VersionNumber: number }
}
}
// Nothing was debited, so `Balance` is the resulting total — the untouched starting
// grant — not a change, unlike buyItem's.
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS)
expect(body.BalanceUpdateResponse.CurrencyType).toBe(CurrencyType.RecCenterTokens)
expect(body.BalanceUpdateResponse.BalanceType).toBe(-2)
expect(body.BalanceUpdateResponse.BalanceUpdates[0].Data.InventionId).toBe(8)
expect(body.InventionResponse.Status).toBe(0)
expect(body.InventionResponse.Invention.Name).toBe('Invention 8')
expect(body.InventionResponse.InventionVersion.VersionNumber).toBe(1)
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
// Owning an invention is boolean: buying it again is a conflict, not a second row.
expect((await buyInvention('50', 8)).status).toBe(409)
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
})
test('GET /api/storefronts/v2/buyInvention pays the creator the buyers tokens', async () => {
// Invention 9 costs 250 and was made by account 999. Buying it moves 250 tokens from
// the buyer to that creator — no house cut, so the two sides are equal and opposite.
await drainFrames()
const res = await buyInvention('51', 9, 250)
expect(res.status).toBe(200)
const body = (await res.json()) as { BalanceUpdateResponse: { Balance: number } }
// `Balance` is the buyer's RESULTING total, so it already has the debit in it.
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS - 250)
expect(
await getBalance(env.DB, 51, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(DEFAULT_STARTING_TOKENS - 250)
// The creator had never touched their balance: they keep their starting grant AND get
// paid, rather than the payout standing in for the grant.
expect(
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(DEFAULT_STARTING_TOKENS + 250)
expect(await getOwnedInventionIds(env.DB, 51)).toEqual([9])
// Both sides get a socket frame carrying their CHANGE, not their new total: the client
// ADDS what it receives to the balance it is showing, so a total would have the creator
// reading their own balance plus the payout. Equal and opposite, like the ledger.
expect(await drainFrames()).toEqual([
{
accountId: 999,
notificationType: STOREFRONT_BALANCE_UPDATE,
payload: { Balance: 250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 },
},
{
accountId: 51,
notificationType: STOREFRONT_BALANCE_UPDATE,
payload: { Balance: -250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 },
},
])
})
test('GET /api/storefronts/v2/buyInvention rejects a stale price and an unaffordable one', async () => {
// Sending 0 for the 250-token invention 9 is a stale (or tampered) price.
expect((await buyInvention('53', 9, 0)).status).toBe(409)
// Account 54 can't afford it: nothing is debited, nobody is paid, nothing is owned.
await spendCurrency(
env.DB,
54,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS,
DEFAULT_STARTING_TOKENS
)
const creatorBefore = await getBalance(
env.DB,
999,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS
)
expect((await buyInvention('54', 9, 250)).status).toBe(400)
expect(
await getBalance(env.DB, 54, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(0)
expect(
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(creatorBefore)
expect(await getOwnedInventionIds(env.DB, 53)).toEqual([])
expect(await getOwnedInventionIds(env.DB, 54)).toEqual([])
})
test('GET /api/storefronts/v2/buyInvention rejects drafts, self-buys and unknown ids', async () => {
// Unpublished — a draft is not on sale, free or not.
expect((await buyInvention('52', 10)).status).toBe(403)
// Account 60 created invention 11; a creator already owns it.
expect((await buyInvention('60', 11)).status).toBe(400)
expect((await buyInvention('52', 9999)).status).toBe(404)
// Missing/non-numeric inventionId.
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyInvention`, {
headers: await bearer('52'),
})
expect(res.status).toBe(400)
expect(await getOwnedInventionIds(env.DB, 52)).toEqual([])
expect(await getOwnedInventionIds(env.DB, 60)).toEqual([])
})
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
@@ -799,7 +1160,7 @@ describe('econ endpoints', () => {
StorefrontType: 3,
PurchasableItemId: 73,
CurrencyType: 2,
RequestedPrice: 4500,
RequestedPrice: 450,
}),
})
const bought = (await buy.json()) as {
@@ -827,7 +1188,7 @@ describe('econ endpoints', () => {
headers: await bearer('24'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.some((i) => i.FriendlyName === 'Class of 2016')).toBe(true)
expect(list.some((i) => i.FriendlyName === 'Bowtie (White)')).toBe(true)
// Opening it again is a harmless no-op — still 200.
const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, {
@@ -855,8 +1216,9 @@ describe('econ endpoints', () => {
}),
})
expect(buy.status).toBe(200)
const giftId = ((await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> })
.BalanceUpdates[0].Data[0].Id
const giftId = (
(await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> }
).BalanceUpdates[0].Data[0].Id
// Opening the box succeeds and fires the ConsumableMappingAdded push (which no-ops
// against the test hub stub — this asserts the notify path doesn't throw).
@@ -871,7 +1233,9 @@ describe('econ endpoints', () => {
// The box is gone; the consumable stays owned (granted at purchase).
expect(
await (
await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { headers: await bearer('26') })
await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
headers: await bearer('26'),
})
).json()
).toEqual([])
const unlocked = (await (
@@ -891,11 +1255,12 @@ describe('econ endpoints', () => {
StorefrontType: 3,
PurchasableItemId: 73,
CurrencyType: 2,
RequestedPrice: 4500,
RequestedPrice: 450,
}),
})
const giftId = ((await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> })
.BalanceUpdates[0].Data[0].Id
const giftId = (
(await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> }
).BalanceUpdates[0].Data[0].Id
// Account 28 trying to open 27's box is forbidden — and 27 keeps it.
const forbidden = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, {
@@ -1002,4 +1367,72 @@ describe('econ endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/nope`)
expect(res.status).toBe(404)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
// `.on(['GET','POST'], …)` cleargroup route contributes both methods.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'GET /api/avatar/v1/defaultbaseavataritems',
'GET /api/avatar/v1/defaultunlocked',
'GET /api/avatar/v2',
'GET /api/avatar/v2/gifts',
'GET /api/avatar/v2/{id}',
'GET /api/avatar/v3/saved',
'GET /api/avatar/v4/items',
'GET /api/challenge/v2/getCurrent',
'GET /api/checklist/v1/current',
'GET /api/consumables/v2/getUnlocked',
'GET /api/equipment/v2/getUnlocked',
'GET /api/gamerewards/v1/pending',
'GET /api/itemWishlists/v1/wishlist/me',
'GET /api/objectives/v1/cleargroup',
'GET /api/objectives/v1/myprogress',
'GET /api/roomconsumables/v1/roomConsumable/room/{roomId}',
'GET /api/roomconsumables/v1/roomConsumable/room/{roomId}/me',
'GET /api/roomcurrencies/v1/currencies',
'GET /api/roomcurrencies/v1/getAllBalances',
'GET /api/roomkeys/v1/mine',
'GET /api/roomkeys/v1/room',
'GET /api/storefronts/v1/adcarouselitems',
'GET /api/storefronts/v2/buyInvention',
'GET /api/storefronts/v3/giftdropstore/{id}',
'GET /api/storefronts/v4/balance/{currencyType}',
'GET /econ/customAvatarItems/v1/owned',
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
'POST /api/avatar/v2/gifts/consume',
'POST /api/avatar/v2/set',
'POST /api/avatar/v3/saved/set',
'POST /api/avatar/v4/saved/set',
'POST /api/challenge/v2/updateProgress',
'POST /api/consumables/v1/consume',
'POST /api/gamerewards/v1/request',
'POST /api/objectives/v1/cleargroup',
'POST /api/storefronts/v2/buyItem',
'PUT /api/equipment/v1/update',
])
// 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()
}
})
})
+1 -401
View File
@@ -1,5 +1,5 @@
{
"NextUpdate": "2026-06-14T00:12:20.1324853Z",
"NextUpdate": "2226-06-14T00:12:20.1324853Z",
"StoreItems": [
{
"GiftDrop": {
@@ -46401,166 +46401,6 @@
],
"Type": 0
},
{
"GiftDrop": {
"AvatarItemDesc": "gwLiwmqBIk6glFxvaTzxeA,D_Xmo0rOzkS-kgq1CYXt3g,fXnd_COSzUeznwe7P4ZYRg,",
"AvatarItemType": 0,
"ConsumableItemDesc": "",
"Context": 0,
"Currency": 1,
"CurrencyType": 2,
"EquipmentModificationGuid": "",
"EquipmentPrefabName": "",
"FriendlyName": "TransSteampunkHat - JOGMANSON",
"GiftDropId": 2674,
"IsQuery": false,
"ItemSetFriendlyName": "",
"ItemSetId": 0,
"Rarity": 0,
"SubscribersOnly": false,
"Tooltip": "0 Debug: 2674",
"Unique": true
},
"IsFeatured": false,
"Prices": [
{
"CurrencyType": 2,
"Price": 250,
"StorefrontSaleData": null,
"Type": 0
}
],
"PurchasableItemId": 2674,
"SubscriberPrices": [
{
"CurrencyType": 2,
"Price": 250,
"StorefrontSaleData": null,
"Type": 0
}
],
"Type": 0
},
{
"GiftDrop": {
"AvatarItemDesc": "qxovV6Vym0ufL2cTCyKjOQ,D_Xmo0rOzkS-kgq1CYXt3g,rPpYm5fIzky4vqVB26guAw,",
"AvatarItemType": 0,
"ConsumableItemDesc": "",
"Context": 0,
"Currency": 1,
"CurrencyType": 2,
"EquipmentModificationGuid": "",
"EquipmentPrefabName": "",
"FriendlyName": "TransSteampunkWrist - JOGMANSON",
"GiftDropId": 2675,
"IsQuery": false,
"ItemSetFriendlyName": "",
"ItemSetId": 0,
"Rarity": 0,
"SubscribersOnly": false,
"Tooltip": "0 Debug: 2675",
"Unique": true
},
"IsFeatured": false,
"Prices": [
{
"CurrencyType": 2,
"Price": 250,
"StorefrontSaleData": null,
"Type": 0
}
],
"PurchasableItemId": 2675,
"SubscriberPrices": [
{
"CurrencyType": 2,
"Price": 250,
"StorefrontSaleData": null,
"Type": 0
}
],
"Type": 0
},
{
"GiftDrop": {
"AvatarItemDesc": "E9VTiIVFPEa-OuQRto0e_Q,DIeasJUIj0KNetAzPzuIrg,C8gCmBZ8RUG83vvDw_UYew,",
"AvatarItemType": 0,
"ConsumableItemDesc": "",
"Context": 0,
"Currency": 1,
"CurrencyType": 2,
"EquipmentModificationGuid": "",
"EquipmentPrefabName": "",
"FriendlyName": "Bisexual Steampunk Shirt - JOGMANSON",
"GiftDropId": 2677,
"IsQuery": false,
"ItemSetFriendlyName": "",
"ItemSetId": 0,
"Rarity": 0,
"SubscribersOnly": false,
"Tooltip": "Tooltip Debug: 2677",
"Unique": true
},
"IsFeatured": false,
"Prices": [
{
"CurrencyType": 2,
"Price": 250,
"StorefrontSaleData": null,
"Type": 0
}
],
"PurchasableItemId": 2677,
"SubscriberPrices": [
{
"CurrencyType": 2,
"Price": 250,
"StorefrontSaleData": null,
"Type": 0
}
],
"Type": 0
},
{
"GiftDrop": {
"AvatarItemDesc": "qxovV6Vym0ufL2cTCyKjOQ,DIeasJUIj0KNetAzPzuIrg,rPpYm5fIzky4vqVB26guAw,",
"AvatarItemType": 0,
"ConsumableItemDesc": "",
"Context": 0,
"Currency": 1,
"CurrencyType": 2,
"EquipmentModificationGuid": "",
"EquipmentPrefabName": "",
"FriendlyName": "Bisexual Steampunk Wrist - JOGMANSON",
"GiftDropId": 2678,
"IsQuery": false,
"ItemSetFriendlyName": "",
"ItemSetId": 0,
"Rarity": 0,
"SubscribersOnly": false,
"Tooltip": "Tooltip Debug: 2678",
"Unique": true
},
"IsFeatured": false,
"Prices": [
{
"CurrencyType": 2,
"Price": 250,
"StorefrontSaleData": null,
"Type": 0
}
],
"PurchasableItemId": 2678,
"SubscriberPrices": [
{
"CurrencyType": 2,
"Price": 250,
"StorefrontSaleData": null,
"Type": 0
}
],
"Type": 0
},
{
"GiftDrop": {
"AvatarItemDesc": "3044adce-90c0-4f66-81f5-39c4fca86fdf,Uz2hrptCa0erjqyPoiZxew,_Yq00H-NWUyQNvGxRunHbg,",
@@ -46600,246 +46440,6 @@
}
],
"Type": 0
},
{
"GiftDrop": {
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,724c79a3-822c-422f-9462-a5374ee0211c,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemType": 0,
"ConsumableItemDesc": "",
"Context": 0,
"Currency": 0,
"CurrencyType": 2,
"EquipmentModificationGuid": "",
"EquipmentPrefabName": "",
"FriendlyName": "White Blazer",
"GiftDropId": 2769,
"IsQuery": false,
"ItemSetFriendlyName": "",
"ItemSetId": 0,
"Rarity": 0,
"SubscribersOnly": false,
"Tooltip": "White Debug: 2769",
"Unique": true
},
"IsFeatured": false,
"Prices": [
{
"CurrencyType": 2,
"Price": 250,
"StorefrontSaleData": null,
"Type": 0
}
],
"PurchasableItemId": 2769,
"SubscriberPrices": [
{
"CurrencyType": 2,
"Price": 250,
"StorefrontSaleData": null,
"Type": 0
}
],
"Type": 0
},
{
"GiftDrop": {
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,6564acf1-4d70-4f92-92ac-08e2b76dbb6b,,,",
"AvatarItemType": 0,
"ConsumableItemDesc": "",
"Context": 0,
"Currency": 0,
"CurrencyType": 2,
"EquipmentModificationGuid": "",
"EquipmentPrefabName": "",
"FriendlyName": "Blazer (IGOR)",
"GiftDropId": 2804,
"IsQuery": false,
"ItemSetFriendlyName": "",
"ItemSetId": 0,
"Rarity": 30,
"SubscribersOnly": false,
"Tooltip": "IGOR Debug: 2804",
"Unique": true
},
"IsFeatured": false,
"Prices": [
{
"CurrencyType": 2,
"Price": 5000,
"StorefrontSaleData": null,
"Type": 0
}
],
"PurchasableItemId": 2804,
"SubscriberPrices": [
{
"CurrencyType": 2,
"Price": 5000,
"StorefrontSaleData": null,
"Type": 0
}
],
"Type": 0
},
{
"GiftDrop": {
"AvatarItemDesc": "ce81210f-8eb8-4cd2-95d5-046d4da229c8,e69372eb-2fd8-4e17-a448-ee0be61a2c7a,,",
"AvatarItemType": 0,
"ConsumableItemDesc": "",
"Context": 0,
"Currency": 0,
"CurrencyType": 2,
"EquipmentModificationGuid": "",
"EquipmentPrefabName": "",
"FriendlyName": "Barbershop Wrist (Black... Actually black.)",
"GiftDropId": 2827,
"IsQuery": false,
"ItemSetFriendlyName": "",
"ItemSetId": 0,
"Rarity": 10,
"SubscribersOnly": false,
"Tooltip": "Can you believe it guys Debug: 2827",
"Unique": true
},
"IsFeatured": false,
"Prices": [
{
"CurrencyType": 2,
"Price": 200,
"StorefrontSaleData": null,
"Type": 0
}
],
"PurchasableItemId": 2827,
"SubscriberPrices": [
{
"CurrencyType": 2,
"Price": 200,
"StorefrontSaleData": null,
"Type": 0
}
],
"Type": 0
},
{
"GiftDrop": {
"AvatarItemDesc": "caf1e229-9660-4abf-8074-f706709f4097,TuAjeDmPuUmFr_ESx4q6WA,,",
"AvatarItemType": 0,
"ConsumableItemDesc": "",
"Context": 0,
"Currency": 0,
"CurrencyType": 2,
"EquipmentModificationGuid": "",
"EquipmentPrefabName": "",
"FriendlyName": "Overall Shorts (Black, Orange)",
"GiftDropId": 2839,
"IsQuery": false,
"ItemSetFriendlyName": "",
"ItemSetId": 0,
"Rarity": 50,
"SubscribersOnly": false,
"Tooltip": "Debug: 2839",
"Unique": true
},
"IsFeatured": false,
"Prices": [
{
"CurrencyType": 2,
"Price": 1500,
"StorefrontSaleData": null,
"Type": 0
}
],
"PurchasableItemId": 2839,
"SubscriberPrices": [
{
"CurrencyType": 2,
"Price": 1500,
"StorefrontSaleData": null,
"Type": 0
}
],
"Type": 0
},
{
"GiftDrop": {
"AvatarItemDesc": "35d67043-ca27-4b8a-a893-2d42674e6e68,rcdwgNvO30Cvx2ycE1GfHw,,",
"AvatarItemType": 0,
"ConsumableItemDesc": "",
"Context": 0,
"Currency": 0,
"CurrencyType": 2,
"EquipmentModificationGuid": "",
"EquipmentPrefabName": "",
"FriendlyName": "Overcoat (Black, Blue)",
"GiftDropId": 2840,
"IsQuery": false,
"ItemSetFriendlyName": "",
"ItemSetId": 0,
"Rarity": 20,
"SubscribersOnly": false,
"Tooltip": "Debug: 2840",
"Unique": true
},
"IsFeatured": false,
"Prices": [
{
"CurrencyType": 2,
"Price": 1000,
"StorefrontSaleData": null,
"Type": 0
}
],
"PurchasableItemId": 2840,
"SubscriberPrices": [
{
"CurrencyType": 2,
"Price": 1000,
"StorefrontSaleData": null,
"Type": 0
}
],
"Type": 0
},
{
"GiftDrop": {
"AvatarItemDesc": "KNZ1DvCLC0WHyH4opYAXtw,ed134bee-d199-43eb-98bd-206571603f40,,",
"AvatarItemType": 0,
"ConsumableItemDesc": "",
"Context": 0,
"Currency": 0,
"CurrencyType": 2,
"EquipmentModificationGuid": "",
"EquipmentPrefabName": "",
"FriendlyName": "Visor (Blue)",
"GiftDropId": 2841,
"IsQuery": false,
"ItemSetFriendlyName": "",
"ItemSetId": 0,
"Rarity": 20,
"SubscribersOnly": false,
"Tooltip": "Debug: 2841",
"Unique": true
},
"IsFeatured": false,
"Prices": [
{
"CurrencyType": 2,
"Price": 3000,
"StorefrontSaleData": null,
"Type": 0
}
],
"PurchasableItemId": 2841,
"SubscriberPrices": [
{
"CurrencyType": 2,
"Price": 3000,
"StorefrontSaleData": null,
"Type": 0
}
],
"Type": 0
}
],
"StorefrontType": 3,
+17 -1
View File
@@ -14,6 +14,12 @@ export default defineConfig({
// isolated test, so provide a minimal stub exposing the same NotificationsHub
// RPC surface — enough for the runtime to start and for notification sends to
// no-op.
//
// The stub RECORDS what it was sent (`drainFrames`) rather than discarding it.
// Pushes are best-effort and swallow their own errors, so a frame carrying the
// wrong payload is otherwise invisible here — which is exactly how
// StorefrontBalanceUpdate shipped with the resulting total in a field the
// client adds to what it is already showing.
workers: [
{
name: 'notify',
@@ -24,8 +30,18 @@ export default defineConfig({
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
async notifyPlayer() { return { delivered: 0, queued: true } }
frames = []
async notifyPlayer(accountId, notificationType, payload) {
this.frames.push({ accountId, notificationType, payload })
return { delivered: 0, queued: true }
}
async broadcast() { return { delivered: 0 } }
/** Everything pushed since the last call, then forget it. */
async drainFrames() {
const drained = this.frames
this.frames = []
return drained
}
}
export default { fetch() { return new Response('ok') } }
`,
+6 -1
View File
@@ -18,8 +18,13 @@
"dependencies": {
"@cf-wasm/photon": "^0.3.6",
"@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",
+6
View File
@@ -6,6 +6,12 @@ export type Env = SharedHonoEnv & {
DB: D1Database
/** R2 bucket holding the served image objects, keyed by filename. */
IMAGES: R2Bucket
/**
* Shared `recflare-cdn` bucket. Only its `image/` prefix is read here: images
* uploaded through the `storage` worker are stored extensionless under
* `image/<date>/<uuid>` and requested from this worker by the bare name.
*/
CDN_ASSETS: R2Bucket
/** Static assets (fallback images) served from `static/`. */
ASSETS: Fetcher
/**
+174 -13
View File
@@ -1,8 +1,11 @@
import { crop, PhotonImage, resize, SamplingFilter } from '@cf-wasm/photon'
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 { imageBytes, json, ServiceStatus } from './openapi'
import type { App, Env } from './context'
@@ -12,6 +15,9 @@ const SIGNATURE_KEY_ID = 'KEY:RSA:p1.rec.net'
/** Static asset served (200) when the requested key is missing from R2. */
const FALLBACK_ASSET_PATH = '/DefaultProfileImage.jpg'
/** Prefix extensionless keys resolve under in the shared `recflare-cdn` bucket. */
const CDN_IMAGE_PREFIX = 'image/'
/**
* Cache-Control for served images. Uploaded images are immutable once written,
* so cache for a year and mark `immutable` so browsers never revalidate. A new
@@ -98,6 +104,23 @@ function resizeImage(input: Uint8Array, transform: Transform): Uint8Array {
}
}
/**
* Which bucket (and under which key) a requested path resolves in.
*
* Every object the `api` worker writes to `recflare-img` keeps a file extension
* (`.jpg` is forced when the upload has none), so an extensionless key can only be
* a `storage` upload: FileType 3 lands in the shared `recflare-cdn` bucket as
* `image/<date>/<uuid>` and the client references it by the bare `<date>/<uuid>`
* name it got back. That makes the extension a reliable discriminator
* `/2028-06-01/<uuid>` here is `recflare-cdn`'s `image/2028-06-01/<uuid>`.
*/
function resolveObject(env: Env, key: string): { bucket: R2Bucket; objectKey: string } {
const filename = key.slice(key.lastIndexOf('/') + 1)
return filename.includes('.')
? { bucket: env.IMAGES, objectKey: key }
: { bucket: env.CDN_ASSETS, objectKey: CDN_IMAGE_PREFIX + key }
}
// Import the signing key once per isolate. The key material is constant for the
// lifetime of the Worker, so caching the promise is safe.
let signingKey: Promise<CryptoKey | null> | undefined
@@ -197,16 +220,152 @@ const app = new Hono<App>()
.onError(withOnError())
.notFound(withNotFound())
.get('/', (c) => c.json({ service: 'img', status: 'ok' }))
.get(
'/',
describeRoute({
tags: ['Images'],
summary: 'Service status',
description: 'Liveness probe. Always `{ service: "img", status: "ok" }`.',
responses: { 200: json(ServiceStatus, 'The worker is up') },
}),
(c) => c.json({ service: 'img', status: 'ok' })
)
// Stream an image straight from the R2 bucket by key, e.g.
// `GET /DefaultProfileImage.jpg`. The key may contain slashes for nested
// objects. Supports conditional requests via If-None-Match.
//
// When the client appends `?sig=p1`, the response body is RSA-SHA1 signed and
// the signature returned in a `Content-Signature` header. Signing requires the
// full body, so the object is buffered.
.get('/:key{.+}', async (c) => {
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
//
// Registered BEFORE the `/:key{.+}` catch-all below: that route matches every path and
// always returns a Response (the DefaultProfileImage.jpg fallback when nothing is
// stored), so anything declared after it is unreachable. The spec is still complete —
// `openAPIRouteHandler` walks `app.routes` at request time, after the catch-all has
// been registered.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare img',
version: '1.0.0',
description: [
'Image hosting for recflare, a private-server reimplementation of the Rec Room',
'backend. Serves every image the client renders — profile photos, room thumbnails,',
'club banners and the photo feed — out of R2, with bundled static assets',
'(`static/`) taking precedence over the bucket and `DefaultProfileImage.jpg` served',
'as the fallback when a key is missing. Keys with an extension come from the',
'`recflare-img` bucket; extensionless ones are `storage` uploads and come from the',
'shared `recflare-cdn` bucket under its `image/` prefix. Optional center-crop and resize',
'run through the Photon WASM codec; `?sig=p1` adds the RSA-SHA1 `Content-Signature`',
'header the client verifies against `KEY:RSA:p1.rec.net`.',
'',
'Note that this worker only serves bytes: the image metadata the client lists (the',
'`SavedImage` records behind `/api/images/...`) lives in the `api` worker, which',
'points at keys here.',
].join('\n'),
},
servers: [{ url: 'https://img.recflare.net', description: 'Production' }],
},
})
)
)
// Stream an image straight from the R2 bucket by key, e.g.
// `GET /DefaultProfileImage.jpg`. The key may contain slashes for nested
// objects. Supports conditional requests via If-None-Match.
//
// When the client appends `?sig=p1`, the response body is RSA-SHA1 signed and
// the signature returned in a `Content-Signature` header. Signing requires the
// full body, so the object is buffered.
app.get(
'/:key{.+}',
describeRoute({
tags: ['Images'],
summary: 'Serve an image by key',
description: [
'Serves the image stored under `key`, which may contain slashes for nested objects',
'(e.g. `Base/Clearcut.jpg`). A bundled static asset always wins over an R2 object of',
'the same key; when neither exists the bundled `DefaultProfileImage.jpg` is served',
'with a 200 rather than a 404, so the client never renders a broken image.',
'',
'Which bucket the key resolves in depends on its extension. A key with one (always',
'the case for an `api` image upload) comes from `recflare-img`. A key WITHOUT one is',
'a `storage` upload and comes from the shared `recflare-cdn` bucket under its',
'`image/` prefix, so `/2028-06-01/<uuid>` here serves `image/2028-06-01/<uuid>`',
'there.',
'',
'Responses carry `Cache-Control: public, max-age=31536000, immutable` — an uploaded',
'image is never rewritten in place, a new image gets a new key.',
'',
'`?width`/`?height`/`?cropSquare=1` run the body through the Photon codec and always',
'return JPEG with no `ETag` (the source etag no longer describes the body), and the',
'`If-None-Match` precondition is skipped. An out-of-range or non-integer dimension is',
'ignored and the original is served — never an error.',
].join('\n'),
parameters: [
{
name: 'key',
in: 'path',
required: true,
description: 'Object key; may contain slashes. A key containing `..` is rejected (400).',
schema: { type: 'string' },
},
{
name: 'width',
in: 'query',
required: false,
description: [
'Output width. Only 128, 256, 512 or 1024 are honoured — any other value is',
'ignored and the source served untouched. Given alone, height follows the aspect ratio.',
].join(' '),
schema: { type: 'integer', enum: [128, 256, 512, 1024], example: 512 },
},
{
name: 'height',
in: 'query',
required: false,
description:
'Output height, same allowed set as `width`. Given alone, width follows the aspect ratio.',
schema: { type: 'integer', enum: [128, 256, 512, 1024], example: 512 },
},
{
name: 'cropSquare',
in: 'query',
required: false,
description: [
'`1` center-crops the source to a square before any resize. Used for the square',
'profile/thumbnail slots. Any other value is ignored.',
].join(' '),
schema: { type: 'string', enum: ['1'] },
},
{
name: 'sig',
in: 'query',
required: false,
description: [
'`p1` RSA-SHA1 signs the response body and returns it as',
'`Content-Signature: key-id=KEY:RSA:p1.rec.net; data=<base64>`. Signed over the',
'bytes actually returned, i.e. the resized body when a transform applies. Omitted',
'when the worker has no `IMG_SIGNING_KEY`.',
].join(' '),
schema: { type: 'string', enum: ['p1'] },
},
{
name: 'If-None-Match',
in: 'header',
required: false,
description:
'Conditional request against the R2 object etag. Ignored when a transform is requested.',
schema: { type: 'string' },
},
],
responses: {
200: imageBytes('The image bytes (or the DefaultProfileImage.jpg fallback)'),
304: { description: 'If-None-Match matched the stored object etag; no body' },
400: { description: 'The key contained `..`; no body' },
},
}),
async (c) => {
const key = c.req.param('key')
if (key.includes('..')) return c.body(null, 400)
@@ -229,8 +388,9 @@ const app = new Hono<App>()
// resized response carries no etag, so the client can never send a matching
// one. Skip the precondition when a transform is requested.
const ifNoneMatch = transform ? undefined : c.req.header('if-none-match')?.replace(/"/g, '')
const object = await c.env.IMAGES.get(
key,
const { bucket, objectKey } = resolveObject(c.env, key)
const object = await bucket.get(
objectKey,
ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined
)
if (!object) {
@@ -255,6 +415,7 @@ const app = new Hono<App>()
}
return new Response(object.body, { headers })
})
}
)
export default app
+50
View File
@@ -0,0 +1,50 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the img 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 workers: a reverse-engineered protocol, lenient
* handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*
* Most of this worker's surface is image BYTES, not JSON, so those responses are
* described with a binary content type rather than a zod schema.
*/
/** 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) } } }
}
/**
* An image-bytes response. The stored object's own content type is served verbatim
* (usually `image/jpeg`, occasionally `image/png`); any response that went through the
* Photon resize/crop path is re-encoded and is always `image/jpeg`.
*/
export function imageBytes(description: string): OpenAPIV3_1.ResponseObject {
const schema: OpenAPIV3_1.SchemaObject = { type: 'string', format: 'binary' }
return {
description,
content: {
'image/jpeg': { schema },
'image/png': { schema },
},
}
}
// ---- Response schemas ------------------------------------------------------
/** `GET /` — the liveness probe body. */
export const ServiceStatus = z.object({
service: z.literal('img'),
status: z.literal('ok'),
})
+71
View File
@@ -34,10 +34,17 @@ const PUBLIC_SPKI_B64 =
// bucket path rather than a static asset.
const R2_KEY = 'user-photo.jpg'
// An extensionless name, as returned by the `storage` worker for a FileType 3
// upload — served from `recflare-cdn` under `image/`, not `recflare-img`.
const CDN_NAME = '2028-06-01/12345-67890-12345'
beforeAll(async () => {
await env.IMAGES.put(R2_KEY, IMAGE_BYTES, {
httpMetadata: { contentType: 'image/jpeg' },
})
await env.CDN_ASSETS.put(`image/${CDN_NAME}`, IMAGE_BYTES, {
httpMetadata: { contentType: 'image/jpeg' },
})
// Seed R2 with a key that ALSO exists in `static/` to prove static wins.
await env.IMAGES.put('3DCharades.jpg', IMAGE_BYTES, {
httpMetadata: { contentType: 'image/jpeg' },
@@ -58,6 +65,38 @@ describe('img endpoints', () => {
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
})
it('serves an extensionless key from the cdn bucket under image/', async () => {
const res = await SELF.fetch(`${ORIGIN}/${CDN_NAME}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('image/jpeg')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
})
it('does not look for an extensionless key in the image bucket', async () => {
// Same bare name seeded into `recflare-img` instead: extensionless keys only
// ever resolve against `recflare-cdn`, so this falls through to the default.
await env.IMAGES.put('2028-06-02/only-in-img', IMAGE_BYTES)
const res = await SELF.fetch(`${ORIGIN}/2028-06-02/only-in-img`)
expect(res.status).toBe(200)
const body = new Uint8Array(await res.arrayBuffer())
expect(body.length).toBeGreaterThan(IMAGE_BYTES.length)
})
it('resizes an extensionless cdn image', async () => {
// Exercises the transform path against the cdn bucket, not just the stream-through.
// Needs a decodable JPEG, so reuse a bundled static asset's bytes.
const real = await (await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)).arrayBuffer()
await env.CDN_ASSETS.put('image/2028-06-03/real-photo', real, {
httpMetadata: { contentType: 'image/jpeg' },
})
const res = await SELF.fetch(`${ORIGIN}/2028-06-03/real-photo?width=128`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('image/jpeg')
expect(res.headers.get('etag')).toBeNull()
expect(jpegSize(new Uint8Array(await res.arrayBuffer())).width).toBe(128)
})
it('serves a static asset in preference to an R2 object of the same key', async () => {
const res = await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)
expect(res.status).toBe(200)
@@ -226,4 +265,36 @@ describe('img endpoints', () => {
const ok = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', publicKey, signature, body)
expect(ok).toBe(true)
})
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. Hono's `:param` syntax becomes OpenAPI's `{param}`.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual(['GET /', 'GET /{key}'])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
// Schemas must inline: a `$ref` here is a dangling reference (see openapi.ts).
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+8
View File
@@ -17,10 +17,18 @@
"run_worker_first": true
},
// Images are stored as objects in an R2 bucket and streamed back by key.
// `recflare-cdn` (owned by the `cdn` worker, written by `storage`) is bound
// alongside it: uploads posted to `storage` as FileType 3 land under its
// `image/` prefix with no extension, and the client asks THIS worker for them
// by the bare name see the extensionless-key branch in src/img.app.ts.
"r2_buckets": [
{
"binding": "IMAGES",
"bucket_name": "recflare-img"
},
{
"binding": "CDN_ASSETS",
"bucket_name": "recflare-cdn"
}
],
// Shared `recflare` D1 the `img` worker owns the `images` metadata table
+101 -22
View File
@@ -1,28 +1,107 @@
# match
Matchmaking Worker served on the `match` subdomain. A Hono app for matchmaking.
Database queries are stubbed for now — no real bindings yet.
Matchmaking Worker served on the `match` subdomain (`match.recflare.net`) — a Hono app
that handles player presence and places players into room instances. Rooms, room
instances and presence all live in the shared `recflare` D1 database.
## Behavior
## Routes
- **Auth-gated routes** (`/player/heartbeat`, `/goto/room/:room`) validate the
Bearer JWT issued by the `auth` worker (same dev secret, see `src/jwt.ts`) and
401 when it's missing/invalid.
- **`GET /player`** always returns the inlined `JSON/getplayer.json` default
(falls back to that file when the account/room instance isn't found).
- **`POST /goto/none`** returns the static offline-dorm instance with a fresh
`photonRoomId`.
- **`POST /goto/room/:room`** synthesizes the room-instance response (no Rooms
binding yet). The dorm gets its known scene id and a private instance; other
rooms get an empty `location` and respect the posted `JoinMode` (2 = private).
- **`POST /player/heartbeat`** echoes the posted heartbeat fields; `roomInstance`
is always null and `isOnline` false until there's a DB binding.
- **`/player/login`, `/player/statusvisibility`, `/roominstance/:id/reportjoinresult`**
return empty 200s.
| Method | Path | Auth | Description |
| ------ | ------------------------------------ | ---- | ------------------------------------------------ |
| POST | `/player/login` | | Login ack (no-op; must not touch presence) |
| POST | `/player/exclusivelogin` | | Exclusive-login ack (no-op) → `{ errorCode: 0 }` |
| POST | `/player/logout` | ✓\* | Clear presence (except the Orientation seed) |
| POST | `/player/notifydisconnect` | | Disconnect notification (no-op ack) |
| GET | `/player?id=1&id=2,3` | | Batch player presence lookup |
| POST | `/player/heartbeat` | ✓ | Presence heartbeat (JSON body) |
| PUT | `/player/statusvisibility` | ✓\* | Set status visibility |
| POST | `/goto/room/:room` | ✓ | Go to a room (`dormroom` → personal dorm) |
| POST | `/matchmake/none` | | Preserve current instance, else dorm |
| POST | `/matchmake/room/:roomId/:subRoomId` | ✓ | Matchmake into a specific subroom |
| POST | `/matchmake/room/:roomId` | ✓ | Matchmake into a room (default subroom) |
| POST | `/matchmake/:room` | ✓ | Matchmake by id or name (`dorm` → personal dorm) |
| POST | `/goto/none` | | Go to the dorm |
| PUT | `/player/photonregionpings` | | Region ping report (no-op ack) |
| PUT | `/player/gameserverregionpings` | | Region ping report (no-op ack) |
| POST | `/roominstance/:id/reportjoinresult` | | Report join result (no-op ack) |
| PUT | `/roominstance/:id/inprogress` | ✓ | Set the instance's in-progress flag |
| GET | `/room/:roomId/instances` | ✓ | A room's live instances (owner/co-owner only) |
| GET | `/rooms/requiring/developer` | | Rooms requiring a developer → `[]` |
| GET | `/rooms/requiring/rrplus` | | Rooms requiring RR+ → `[]` |
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
## TODO before production
\* `logout` and `statusvisibility` read the token when present but never 401 — an
unauthenticated call is a no-op ack. The other ✓ routes return an empty-body 401 when
the Bearer JWT (issued by the `auth` worker) is missing or invalid.
- Wire a DB binding (D1/DO) for `Accounts`, `Rooms`/`SubRooms`, `RoomInstances`.
- Implement `/goto/room/:room` (resolve room, upsert instance) and the
per-account `/player` + `/player/heartbeat` room-instance lookups.
- Move the JWT secret to a shared secret binding (shared with `auth`).
## API documentation
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
alongside each handler, with the schemas in `src/openapi.ts`.
**The spec is descriptive, not enforced** — same rationale as the `auth`/`accounts`
workers: a reverse-engineered protocol, lenient handlers, no runtime validation. A test
asserts every route appears in the spec, so adding one without documenting it fails.
## Presence
Presence is a per-player row in the shared `presence` table recording the room instance
that player is currently in, plus status fields (visibility, device class, VR movement
mode, platform, app version). It's written by matchmake/goto and refreshed by the
heartbeat, and read by the heartbeat and the batch `GET /player`.
- **`isOnline` means "has a live presence row"**, not "is in a room". Rows expire on a
TTL, so a player who stops heartbeating drops offline; a player can be online in the
lobby with `roomInstance` null.
- **The heartbeat is write-thrifty.** An unchanged heartbeat re-writes the row (to
extend its TTL) only once the TTL is within `PRESENCE_REFRESH_THRESHOLD` seconds of
lapsing — a still player is refreshed periodically rather than on every beat.
- **A cron sweep** (`scheduled`) clears presence past its TTL and, crucially,
recomputes the fullness of the instances those rows pointed at. Nothing else notices
a player who crashed or hard-quit, so without the sweep their instance can stay
flagged full — and unjoinable — with nobody in it.
The heartbeat also accepts a non-JSON (LoginLock form) body, which it reads and ignores;
only a JSON body carries status fields.
## Matchmaking and room instances
A matchmake resolves the room (by numeric id or name) from D1, then finds a joinable
public instance of the requested subroom or creates a new `room_instance`. The result
is persisted as the player's presence so the heartbeat can replay it — keeping the
client's local presence in sync. `errorCode` 0 with a `roomInstance` is success; an
unknown room returns `errorCode` 20 (NoSuchRoom) with `roomInstance: null`.
Several behaviours are load-bearing and reverse-engineered from the client:
- **Instance names are `^`-prefixed** so the client resolves the new scene; personal
dorms are the exception (`@owner's Dorm`, no `^`). An empty `location` (the SubRoom's
Unity scene id) makes the client reject the session.
- **Never re-place a player into their current instance.** The client keys the room
transition off a _changing_ `roomInstanceId`; returning the same id hangs it mid-join,
so the join search excludes the caller's current instance.
- **Subrooms are separate places.** Joining one must never land you in an instance of
another, so instance reuse is scoped to the exact `(roomId, subRoomId)`.
- **The dorm is a single stable instance** with a constant Photon room id, returned
identically by every dorm entry point and the heartbeat, so the client's whole-instance
presence check never reads out-of-sync.
- **Two dorm keywords:** `goto/room/dormroom` and `matchmake/dorm` — different spellings
the 2023 client uses for the same destination.
- **`matchmake/none` preserves existing presence** (it's how the client establishes the
solo Orientation room) and only falls back to the dorm when the player has none.
`goto/none` always goes to the dorm.
## Bindings
| Binding | Type | Notes |
| ------------ | ------------- | ------------------------------------------------------------ |
| `DB` | D1 | Shared `recflare` database — rooms, room instances, presence |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
The `presence` and `room_instance` tables are owned/migrated by the `rooms` worker;
this worker has no migrations of its own.
## Known gaps
- `/rooms/requiring/developer` and `/rooms/requiring/rrplus` always return `[]` — no
such gating queue exists yet.
+6 -1
View File
@@ -18,8 +18,13 @@
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20",
+8
View File
@@ -1,5 +1,8 @@
import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
// Type-only import (erased at build) of the DO class owned by the `notify` worker,
// so this worker can push websocket notifications through its RPC surface.
import type { NotificationsHub } from '../../notify/src/notifications-hub'
export type Env = SharedHonoEnv & {
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
@@ -12,6 +15,11 @@ export type Env = SharedHonoEnv & {
// read by the heartbeat and the batch `/player` lookup). See @repo/domain's
// presence-db (table owned/migrated by the `rooms` worker).
DB: D1Database
/**
* The `notify` worker's NotificationsHub DO pushes websocket notifications to a
* player. Used by `POST /invite` to deliver the game-invite message to the invitee.
*/
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
}
/** Variables can be extended */
+1147 -299
View File
@@ -1,28 +1,63 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
areFriends,
canManageRoom,
createRoomInstance,
deleteExpiredPresence,
deletePresence,
GAME_VERSION,
getAccount,
getClubSummary,
getExpiredPresenceInstanceIds,
getFriendIds,
getJoinableInstance,
getOrCreateDormRoom,
getPresence,
getPresences,
getRoomById,
getRoomByName,
getRoomInstance,
getRoomInstancesByRoom,
getRoomInstanceSummariesByRoom,
isClubMember,
isPlayerBannedFromRoom,
MessageType,
refreshInstanceFullness,
RoomInstanceType,
setPresence,
setRoomInstanceInProgress,
setRoomInstancePrivate,
subRoomDataBlob,
} from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
// Value import of the notify worker's NotificationType enum (its bundle has no runtime
// deps), so /invite sends a typed MessageReceived frame instead of a magic number.
import { NotificationType } from '../../notify/src/notification-types'
import {
AUTHED,
EMPTY_OK,
ExclusiveLoginResponse,
form,
InProgressRequest,
InviteRequest,
JoinModeRequest,
json,
LoginLockRequest,
MatchmakeResponse,
MatchmakeRoomRequest,
NotifyDisconnectRequest,
PlayerDto,
RoomInstanceDto,
RoomInstanceSummaryDto,
StatusVisibilityRequest,
UNAUTHORIZED_RESPONSE,
} from './openapi'
import type { Context } from 'hono'
import type { Room, StoredPresence } from '@repo/domain'
import type { App, Env } from './context'
@@ -77,16 +112,6 @@ function playerPayload(playerId: number, presence?: Presence | null) {
}
}
/** Heartbeat body posted by the client (all fields optional). */
interface HeartbeatRequest {
playerId?: number
statusVisibility?: number
deviceClass?: number
vrMovementMode?: number
appVersion?: string | null
platform?: number
}
/**
* Resolve the account id from a Bearer token, mirroring the repeated
* auth-header check. Returns `null` when the header is missing,
@@ -102,7 +127,7 @@ function unauthorized(c: Context<App>) {
}
/** A synthesized room instance (same shape for dorm and other rooms). */
type RoomInstance = ReturnType<typeof dormRoomInstance>
type RoomInstance = ReturnType<typeof roomInstanceFromRoom>
/**
* Stored presence for a player the room instance they matchmade into plus the
@@ -120,13 +145,6 @@ type Presence = StoredPresence<RoomInstance>
*/
const PRESENCE_REFRESH_THRESHOLD = 300
/**
* Game build version reported in presence. This is a server-side constant the
* client doesn't supply it, and an empty value breaks the client's
* presence/version handling. Matches our target 2023 client build.
*/
const GAME_VERSION = '20230302'
/**
* Default `/player` payload, served whenever the `id` is missing/invalid or the
* account isn't found. Inlined here (Workers have no filesystem). The stub player
@@ -134,6 +152,86 @@ const GAME_VERSION = '20230302'
*/
const DEFAULT_GET_PLAYER = [{ ...playerPayload(1), isOnline: true }]
/**
* The wire subset of a room instance a friend sees in a presence update the
* reference's `RoomInstanceDto.Redact` projection. `photonRoomId` and `dataBlob` are
* BLANKED (empty string): they're safe only for the player themselves. A leaked
* `photonRoomId` would let anyone who can read your presence `JoinByName` the Photon
* room directly, bypassing the private-instance invite check the friend list only
* needs `roomId`/`name`/`isPrivate` to render the row, and joins go back through
* matchmaking (`/matchmake/player/:playerId`), which enforces access. `photonRegion` is omitted
* (not on the presence DTO); `name` is already the `^`-prefixed wire name.
*/
function redactInstanceForPresence(instance: RoomInstance) {
return {
roomInstanceId: instance.roomInstanceId,
roomId: instance.roomId,
subRoomId: instance.subRoomId,
roomInstanceType: instance.roomInstanceType,
location: instance.location,
// Blanked — see above: never hand another player the join coordinates.
dataBlob: '',
eventId: instance.eventId,
clubId: instance.clubId,
roomCode: instance.roomCode,
photonRegionId: instance.photonRegionId,
photonRoomId: '',
name: instance.name,
maxCapacity: instance.maxCapacity,
isFull: instance.isFull,
isPrivate: instance.isPrivate,
isInProgress: instance.isInProgress,
EncryptVoiceChat: instance.EncryptVoiceChat,
}
}
/**
* The SubscriptionUpdatePresence message a friend receives when the player changes rooms:
* a presence snapshot of who, and the redacted instance they're now in (null when in no
* room `isOnline` false). `statusVisibility` is forced to 0 (Everyone) so the player
* isn't hidden from friends. `appVersion` MUST be a string — the client's presence DTO
* reads it with a string reader, and a numeric value aborts the whole SignalR frame
* ("expected String Begin Token"), dropping the room/presence update.
*/
function presenceUpdateMessage(playerId: number, instance: RoomInstance | null) {
return {
playerId,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 0,
roomInstance: instance ? redactInstanceForPresence(instance) : null,
isOnline: instance != null,
appVersion: GAME_VERSION,
}
}
/**
* Push a SubscriptionUpdatePresence to every online friend of `playerId` after their
* presence changes (they entered a room). Mirrors the reference's PlayerPresenceChanged:
* only currently-connected friends receive it (an offline friend gets nothing, not a
* queued stale frame), so it's an ephemeral batch send. The room instance the friends
* see is read from the player's stored presence the authoritative record just written,
* the same one the heartbeat replays. Best-effort: a hub or lookup failure is logged and
* swallowed, so it never fails the matchmake that triggered it.
*/
async function notifyFriendsPresence(c: Context<App>, playerId: number): Promise<void> {
try {
const friendIds = await getFriendIds(c.env.DB, playerId)
if (friendIds.length === 0) return
const presence = await getPresence<RoomInstance>(c.env.DB, playerId)
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
friendIds,
NotificationType.SubscriptionUpdatePresence,
presenceUpdateMessage(playerId, presence?.roomInstance ?? null)
)
} catch (err) {
logger.error('failed to push SubscriptionUpdatePresence to friends', {
playerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Store the room instance the player just matchmade into, preserving status.
*
@@ -156,6 +254,9 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
vrMovementMode: prev?.vrMovementMode ?? 1,
platform: prev?.platform ?? account?.platform ?? 0,
appVersion: prev?.appVersion || GAME_VERSION,
// Carry the session lock recorded at login forward, so matchmake doesn't wipe it
// and the heartbeat can keep verifying against it.
loginLock: prev?.loginLock,
})
// Keep the destination instance's is_full flag in sync with live presence (the
// player's own presence, just written, is counted). Then re-evaluate the
@@ -166,20 +267,78 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
if (leftId != null && leftId !== roomInstance.roomInstanceId) {
await refreshInstanceFullness(c.env.DB, leftId)
}
}
/**
* Fixed Photon room id for the dorm. With no RoomInstance DB we can't persist
* the GUID minted at matchmake time, and the client's presence check compares
* the *whole* instance (photonRoomId included). Every dorm entry point
* (matchmake/goto, matchmake/none, the heartbeat) must therefore return the
* exact same instance, so the id is a constant rather than random/per-account.
*/
const DORM_PHOTON_ROOM_ID = '00000000-0000-4000-8000-000000000001'
// The player's presence changed — tell their online friends where they went, reading
// the instance back from the presence we just stored. Best-effort; never blocks or
// fails the matchmake.
await notifyFriendsPresence(c, id)
}
/** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */
const NO_SUCH_ROOM = 20
/**
* MatchmakingErrorCode for "you are banned from this room". Unlike the opaque
* NoSuchRoom every other refusal answers, a banned player is told why: they already
* know the room exists, so there's nothing to hide, and the client can say so instead
* of showing a room that mysteriously fails to load.
*/
const BANNED_FROM_ROOM = 55
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/**
* A fresh id for a *live* (non-persisted) message the reference's
* `NextLiveMessageID`. A game invite is ephemeral (never stored), so there's no
* database sequence to draw from; epoch milliseconds give a monotonically increasing,
* effectively unique id the client can key the invite off. It only has to be distinct
* among a player's in-flight invites, not globally.
*/
function nextLiveMessageId(): number {
return Date.now()
}
/**
* Deliver a game invite from `fromId` to `toId` for a room instance a `MessageReceived`
* frame carrying a game-invite `Message` the client renders the join prompt from. `data`
* is the raw roomInstanceId string the message carries; `roomId` (nullable) tells the
* client which room it points at. Best-effort: a hub failure is logged and swallowed.
*
* Shared by `POST /invite` (a single explicit invite) and the party fan-out on a room
* matchmake (one per `AdditionalPlayerIds` entry), so the two can't drift.
*/
async function sendGameInvite(
c: Context<App>,
fromId: number,
toId: number,
data: string,
roomId: number | null
): Promise<void> {
const message = {
Id: nextLiveMessageId(),
FromPlayerId: fromId,
ToPlayerId: toId,
Type: MessageType.GameInvite,
Data: data,
SentTime: new Date().toISOString(),
RoomId: roomId,
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toId,
NotificationType.MessageReceived,
message
)
} catch (err) {
logger.error('failed to push game-invite MessageReceived notification', {
fromPlayerId: fromId,
toPlayerId: toId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* The sentinel room-instance id the `auth` worker seeds a brand-new player's
* Orientation presence with (see auth's `placeNewPlayerInOrientation`). The client
@@ -189,34 +348,6 @@ const NO_SUCH_ROOM = 20
*/
const ORIENTATION_INSTANCE_ID = -2
/**
* The canonical dorm room instance (room 1, instance 1.1). Returned identically
* by every dorm entry point and the presence heartbeat so the client's local
* presence never reads as out-of-sync.
*/
function dormRoomInstance() {
return {
roomInstanceId: 1,
roomId: 1,
subRoomId: 1,
roomInstanceType: RoomInstanceType.Dormroom,
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
dataBlob: '',
eventId: 0,
clubId: 0,
roomCode: '',
photonRegion: 'us',
photonRegionId: 'us',
photonRoomId: DORM_PHOTON_ROOM_ID,
name: '^DormRoom',
maxCapacity: 4,
isFull: false,
isPrivate: true,
isInProgress: false,
EncryptVoiceChat: false,
}
}
/**
* Instance-relevant fields pulled from a stored room (scene, name, capacity, ).
* The `location` is the SubRoom's real `UnitySceneId` an empty/unknown location
@@ -244,7 +375,11 @@ function instanceFieldsFromRoom(room: Room, subRoomId?: number) {
roomId: num(room.RoomId, 1),
subRoomId: num(sub?.SubRoomId, 1),
location: str(sub?.UnitySceneId),
dataBlob: str(sub?.DataBlob),
// Always the PUBLISHED save. A creator who wants their unpublished work is offered
// the choice client-side from the `/subrooms/{id}/saves` list — matchmaking is not
// involved, and serving a staged blob here would put two people in one instance on
// different versions.
dataBlob: subRoomDataBlob(sub),
name,
maxCapacity: num(sub?.MaxPlayers, 4),
roomInstanceType: room.IsDorm === true ? RoomInstanceType.Dormroom : RoomInstanceType.Public,
@@ -263,7 +398,7 @@ function roomInstanceFromRoom(
instanceId: number,
photonRoomId: string,
subRoomId?: number
): RoomInstance {
) {
const f = instanceFieldsFromRoom(room, subRoomId)
return {
roomInstanceId: instanceId,
@@ -287,16 +422,86 @@ function roomInstanceFromRoom(
}
}
/** Read the session's `LoginLock` GUID from a form body (undefined when absent/empty). */
async function readLoginLock(c: Context<App>): Promise<string | undefined> {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
return typeof body.LoginLock === 'string' && body.LoginLock ? body.LoginLock : undefined
}
/** Read the `JoinMode` form field (2 = private instance). */
async function readJoinMode(c: Context<App>): Promise<number> {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
return typeof body.JoinMode === 'string' ? Number.parseInt(body.JoinMode, 10) || 0 : 0
}
/**
* Read the room-matchmake form body once: `JoinMode` (2 = private) plus the party members
* to pull along (`AdditionalPlayerIds`). The 2023 client posts its party on a room
* matchmake so they can be invited into the instance the leader lands in;
* `AdditionalPlayerIds` is a repeated field (one id each, never comma-separated), and
* ids are parsed defensively, de-duplicated, and non-positive/garbage entries dropped.
* Parsed with `{ all: true }` in one pass so the repeated fields survive.
*/
async function readMatchmakeBody(
c: Context<App>
): Promise<{ joinMode: number; additionalPlayerIds: number[] }> {
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const firstString = (v: unknown): string | undefined => {
const first = Array.isArray(v) ? v[0] : v
return typeof first === 'string' ? first : undefined
}
const joinModeRaw = firstString(body.JoinMode)
const joinMode = joinModeRaw === undefined ? 0 : Number.parseInt(joinModeRaw, 10) || 0
const key = Object.keys(body).find((k) => k.toLowerCase() === 'additionalplayerids')
const raw = key === undefined ? [] : body[key]
const values = Array.isArray(raw) ? raw : [raw]
const additionalPlayerIds = [
...new Set(
values
.filter((v): v is string => typeof v === 'string')
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n) && n > 0)
),
]
return { joinMode, additionalPlayerIds }
}
/**
* Invite the caller's party members into the instance the caller just matchmade into
* the `AdditionalPlayerIds` fan-out. Each member gets the same game invite `POST /invite`
* sends, pointing at this instance, so a party matchmake pulls the whole party along. The
* leader is skipped (already in). Best-effort per member (sendGameInvite swallows its own
* failures), and never blocks the matchmake beyond the sends themselves.
*/
async function inviteParty(
c: Context<App>,
leaderId: number,
playerIds: number[],
instance: RoomInstance
): Promise<void> {
const data = String(instance.roomInstanceId)
await Promise.all(
playerIds
.filter((pid) => pid !== leaderId)
.map((pid) => sendGameInvite(c, leaderId, pid, data, instance.roomId))
)
}
/**
* The outcome of resolving a room to join: the instance, or the `errorCode` to answer
* with. Kept as a pair rather than a bare null so callers can tell a room that isn't
* there (NoSuchRoom) from one the caller is banned from those answer different codes.
*/
type ResolvedInstance =
| { instance: RoomInstance; errorCode: 0 }
| { instance: null; errorCode: number }
/**
* Resolve a room by `:room` path segment (numeric id or name) from D1, then find a
* joinable instance of it (public matchmakes reuse one via the `room_instance`
* table) or create a new one. Returns null when the room isn't found.
* table) or create a new one. A null instance carries the error code to answer:
* NoSuchRoom when the room isn't in the DB, BannedFromRoom when the caller is banned.
*/
async function resolveRoomInstance(
c: Context<App>,
@@ -304,14 +509,24 @@ async function resolveRoomInstance(
isPrivate: boolean,
ownerId: number,
subRoomId?: number
): Promise<RoomInstance | null> {
): Promise<ResolvedInstance> {
const id = Number.parseInt(roomKey, 10)
const room = Number.isNaN(id)
? await getRoomByName(c.env.DB, roomKey)
: await getRoomById(c.env.DB, id)
if (!room) return null
if (!room) return { instance: null, errorCode: NO_SUCH_ROOM }
const f = instanceFieldsFromRoom(room, subRoomId)
// A banned player never gets an instance. This is the whole enforcement of a room
// ban: the Photon room id only ever reaches a player through a matchmake, so
// refusing here means they have no coordinates to join or interact with. Handled
// before any instance is created or reused so a ban can't spawn one.
if (await isPlayerBannedFromRoom(c.env.DB, f.roomId, ownerId)) {
logger.info('matchmake refused: player banned from room', { roomId: f.roomId, ownerId })
return { instance: null, errorCode: BANNED_FROM_ROOM }
}
// Never place the player back into the instance they're already in: the client
// keys the room transition off a changing `roomInstanceId`, so re-matchmaking into
// your current instance (e.g. the only public instance of a room you're already in)
@@ -342,13 +557,16 @@ async function resolveRoomInstance(
roomInstanceType: f.roomInstanceType,
})
}
return roomInstanceFromRoom(
room,
isPrivate,
instance.roomInstanceId,
instance.photonRoomId,
f.subRoomId
)
return {
instance: roomInstanceFromRoom(
room,
isPrivate,
instance.roomInstanceId,
instance.photonRoomId,
f.subRoomId
),
errorCode: 0,
}
}
/**
@@ -395,12 +613,66 @@ const app = new Hono<App>()
.notFound(withNotFound())
// ---- Player presence -----------------------------------------------------
// login/exclusivelogin are no-op acks and MUST NOT touch presence: the client
// fires exclusivelogin when going online, and clearing presence there would bounce
// the player to the dorm. Presence is overwritten by matchmake/goto and expires on
// its own TTL.
.post('/player/login', (c) => c.body(null, 200))
.post('/player/exclusivelogin', (c) => c.json({ errorCode: 0 }))
// login records the session's `LoginLock` in presence so the heartbeat can verify
// each beat belongs to this login; it must otherwise leave presence intact (clearing
// the room instance here would bounce the player to the dorm). Presence is overwritten
// by matchmake — which carries the lock forward — and expires on its own TTL.
.post(
'/player/login',
describeRoute({
tags: ['Presence'],
summary: 'Record the session login lock',
description: [
'Records the posted `LoginLock` in the players presence so later heartbeats can',
'verify they still own the session. Updates the live presence row if there is one,',
'otherwise seeds a lobby presence (no room) carrying the lock. Empty ack.',
].join(' '),
requestBody: form(LoginLockRequest, 'The session LoginLock GUID'),
responses: { 200: EMPTY_OK },
}),
async (c) => {
const id = await authedId(c)
if (id !== null) {
const loginLock = await readLoginLock(c)
if (loginLock !== undefined) {
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence) {
presence.loginLock = loginLock
await setPresence(c.env.DB, presence)
} else {
// No live presence yet — seed a lobby row (roomInstance null) holding the
// lock, so it survives to the first matchmake (enterRoom carries it forward).
const account = await getAccount(c.env.DB, id)
await setPresence(c.env.DB, {
accountId: id,
roomInstance: null,
statusVisibility: 0,
deviceClass: account?.deviceClass ?? 0,
vrMovementMode: 1,
platform: account?.platform ?? 0,
appVersion: GAME_VERSION,
loginLock,
})
}
}
}
return c.body(null, 200)
}
)
.post(
'/player/exclusivelogin',
describeRoute({
tags: ['Presence'],
summary: 'Exclusive-login ack (no-op)',
description: [
'Player exclusive login. Carries the session `LoginLock` (as every presence',
'lifecycle call does) but is currently a no-op ack. @todo implement login locking.',
].join(' '),
requestBody: form(LoginLockRequest, 'The session LoginLock GUID'),
responses: { 200: json(ExclusiveLoginResponse, 'errorCode 0') },
}),
(c) => c.json({ errorCode: 0 })
)
// Logout clears the player's presence so they read offline immediately and the
// instance they were in frees up (rather than waiting out the presence TTL).
@@ -410,263 +682,797 @@ const app = new Hono<App>()
// worker writes that presence with instance id -2). Clearing presence there wipes
// the seed and bounces the new player to the dorm — so a logout that still points
// at Orientation is left as a no-op ack. An unauthenticated logout is also a no-op
// (no player to clear).
.post('/player/logout', async (c) => {
const id = await authedId(c)
if (id !== null) {
const presence = await getPresence<RoomInstance>(c.env.DB, id)
const instanceId = presence?.roomInstance?.roomInstanceId
if (presence && instanceId !== ORIENTATION_INSTANCE_ID) {
await deletePresence(c.env.DB, id)
// The instance they were in lost a player — recompute its fullness so a
// full room frees up. No-op for the synthetic dorm/orientation instances.
if (instanceId != null) await refreshInstanceFullness(c.env.DB, instanceId)
}
}
return c.body(null, 200)
})
// Fire-and-forget disconnect notification (form body `PlayerId`/`RoomInstanceId`).
// The client posts this when it drops a room; we don't act on it — presence is
// cleared by logout and otherwise expires on its own TTL — so just ack with 200.
.post('/player/notifydisconnect', (c) => c.body(null, 200))
.get('/player', async (c) => {
// Returns each requested player's presence. Reads the `id` query param(s);
// with none it serves the static getplayer.json default.
const ids = c.req
.queries('id')
?.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n))
if (!ids || ids.length === 0) return c.json(DEFAULT_GET_PLAYER)
// One query for the whole batch (D1 `WHERE account_id IN (…)`), rather than a
// point read per id as the KV store required.
const presences = await getPresences<RoomInstance>(c.env.DB, ids)
return c.json(ids.map((playerId) => playerPayload(playerId, presences.get(playerId))))
})
.post('/player/heartbeat', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// Body may be a JSON HeartbeatRequest or a form post (LoginLock); only JSON
// carries presence/status fields.
const raw = await c.req.text().catch(() => '')
let hb: HeartbeatRequest = {}
if (raw.trimStart().startsWith('{')) {
try {
hb = JSON.parse(raw) as HeartbeatRequest
} catch {
hb = {}
}
}
// Return the player's stored presence (set by matchmake/goto), mirroring the
// reference server's HeartbeatDB.GetPlayerHeartbeat. No presence → the player
// isn't in a room yet, so roomInstance=null / isOnline=false. Posted status
// fields are merged back; the row is re-written (refreshing the TTL) only when
// something changed or its TTL is close to lapsing — see below.
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence) {
// Merge the posted status fields, tracking whether any actually changed.
let changed = false
const apply = <K extends keyof Presence>(key: K, value: Presence[K]) => {
if (presence[key] !== value) {
presence[key] = value
changed = true
// (no player to clear). @kludge probably a better solution for this.
.post(
'/player/logout',
describeRoute({
tags: ['Presence'],
summary: 'Clear presence on logout',
description: [
'Clears the players presence so they read offline immediately and the instance',
'they were in frees up. Carries the session `LoginLock` (as every presence',
'lifecycle call does). EXCEPTION: a logout whose presence still points at the',
'Orientation seed (instance -2) is left as a no-op, so the account-creation',
'bootstrap isnt wiped. An unauthenticated logout is also a no-op.',
].join(' '),
requestBody: form(LoginLockRequest, 'The session LoginLock GUID'),
responses: { 200: EMPTY_OK },
}),
async (c) => {
const id = await authedId(c)
if (id !== null) {
const presence = await getPresence<RoomInstance>(c.env.DB, id)
const instanceId = presence?.roomInstance?.roomInstanceId
if (presence && instanceId !== ORIENTATION_INSTANCE_ID) {
await deletePresence(c.env.DB, id)
// The instance they were in lost a player — recompute its fullness so a
// full room frees up. No-op for the synthetic dorm/orientation instances.
if (instanceId != null) await refreshInstanceFullness(c.env.DB, instanceId)
// Their presence changed — tell online friends they went offline. Presence
// is already cleared, so notifyFriendsPresence reads null and sends the
// offline snapshot (roomInstance null, isOnline false).
await notifyFriendsPresence(c, id)
}
}
if (hb.statusVisibility !== undefined) apply('statusVisibility', hb.statusVisibility)
if (hb.deviceClass !== undefined) apply('deviceClass', hb.deviceClass)
if (hb.vrMovementMode !== undefined) apply('vrMovementMode', hb.vrMovementMode)
if (hb.platform !== undefined) apply('platform', hb.platform)
if (hb.appVersion) apply('appVersion', hb.appVersion)
if (!presence.appVersion) apply('appVersion', GAME_VERSION)
// Extending the TTL means re-writing the row, so skip the write on an
// unchanged heartbeat until the TTL is within PRESENCE_REFRESH_THRESHOLD
// (s) of lapsing — a still player is refreshed periodically rather than on
// every beat. `expiresAt` is epoch seconds (set by setPresence).
const nowSeconds = Math.floor(Date.now() / 1000)
const dueForRefresh = presence.expiresAt - nowSeconds <= PRESENCE_REFRESH_THRESHOLD
if (changed || dueForRefresh) {
await setPresence(c.env.DB, presence)
}
return c.body(null, 200)
}
)
// The heartbeat echoes the same player payload `/player` serves; with no stored
// presence it falls back to what the client just posted.
return c.json({
...playerPayload(hb.playerId ? hb.playerId : id, presence),
statusVisibility: presence?.statusVisibility ?? hb.statusVisibility ?? 0,
deviceClass: presence?.deviceClass ?? hb.deviceClass ?? 0,
vrMovementMode: presence?.vrMovementMode ?? (hb.vrMovementMode ? hb.vrMovementMode : 1),
appVersion: presence?.appVersion || hb.appVersion || GAME_VERSION,
platform: presence?.platform ?? hb.platform ?? 0,
})
})
.put('/player/statusvisibility', async (c) => {
const id = await authedId(c)
if (id !== null) {
// Photon disconnect notification (form body `PlayerId`/`RoomInstanceId`) — posted when
// Photon sees a player drop a room instance. We don't act on it yet (presence is cleared
// by logout and otherwise expires on its TTL), but the fields are parsed and logged so
// the hook is in place for a future background reconciliation check.
.post(
'/player/notifydisconnect',
describeRoute({
tags: ['Presence'],
summary: 'Photon disconnect notification',
description: [
'Posted by Photon when it sees a player drop a room instance (form body',
'`PlayerId`/`RoomInstanceId`). Currently just logged and acked — presence is cleared',
'by logout and otherwise expires on its TTL — but the hook is here for a future check.',
].join(' '),
requestBody: form(
NotifyDisconnectRequest,
'The disconnecting player and the instance they left'
),
responses: { 200: EMPTY_OK },
}),
async (c) => {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const sv =
typeof body.statusVisibility === 'string' ? Number.parseInt(body.statusVisibility, 10) : NaN
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence && !Number.isNaN(sv)) {
presence.statusVisibility = sv
await setPresence(c.env.DB, presence)
const parseId = (v: unknown): number | null => {
const n = typeof v === 'string' ? Number.parseInt(v, 10) : NaN
return Number.isNaN(n) ? null : n
}
logger.info('player disconnect notification', {
playerId: parseId(body.PlayerId),
roomInstanceId: parseId(body.RoomInstanceId),
})
return c.body(null, 200)
}
return c.body(null, 200)
})
)
.get(
'/player',
describeRoute({
tags: ['Presence'],
summary: 'Batch player presence lookup',
description: [
'Returns each requested players presence. `id` is a repeated query param',
'(`?id=2&id=155&id=153`) — one value each, not comma-separated. With no ids, serves',
'a single default (online) player.',
].join(' '),
parameters: [
{
name: 'id',
in: 'query',
required: false,
description: 'Repeated once per player id (`?id=2&id=155`); not comma-separated',
schema: { type: 'array', items: { type: 'string' } },
},
],
responses: { 200: json(PlayerDto.array(), 'One entry per requested player') },
}),
async (c) => {
// Returns each requested player's presence. Reads the repeated `id` query
// param(s); with none it serves the static getplayer.json default.
const ids = c.req
.queries('id')
?.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n))
if (!ids || ids.length === 0) return c.json(DEFAULT_GET_PLAYER)
// One query for the whole batch (D1 `WHERE account_id IN (…)`), rather than a
// point read per id as the KV store required.
const presences = await getPresences<RoomInstance>(c.env.DB, ids)
return c.json(ids.map((playerId) => playerPayload(playerId, presences.get(playerId))))
}
)
.post(
'/player/heartbeat',
describeRoute({
tags: ['Presence'],
summary: 'Presence heartbeat',
description: [
'Returns the players current presence payload without mutating any stored fields —',
'the only side effect is refreshing the rows TTL, and even that only when the TTL',
'is close to lapsing so a still player isnt written on every beat. The posted',
'`LoginLock` is verified against the one recorded at login: a heartbeat carrying a',
'different lock is a superseded session and gets an empty body. With no stored',
'presence the player isnt in a room yet (roomInstance null, isOnline false).',
].join(' '),
security: AUTHED,
requestBody: form(LoginLockRequest, 'The session LoginLock GUID (verified, not stored)'),
responses: {
200: json(PlayerDto, 'The players current presence payload'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// The body is either a JSON status blob (no longer read — presence is returned
// verbatim) or a form carrying the session `LoginLock`. We only read the
// LoginLock, to verify this beat still owns the session; a JSON body simply
// yields no lock (parseBody fails and is swallowed).
const postedLock = await readLoginLock(c)
// Return the player's stored presence (set at login/matchmake), mirroring the
// reference server's HeartbeatDB.GetPlayerHeartbeat. No presence → the player
// isn't in a room yet, so roomInstance=null / isOnline=false.
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence) {
// A heartbeat whose LoginLock disagrees with the one recorded at login belongs
// to a superseded session — return nothing so that stale client stops acting as
// the live one. (No posted lock, or none recorded yet, skips the check.)
if (
postedLock !== undefined &&
presence.loginLock !== undefined &&
presence.loginLock !== postedLock
) {
return c.body(null, 200)
}
// The heartbeat's only side effect is refreshing the TTL, and only once it's
// within PRESENCE_REFRESH_THRESHOLD (s) of lapsing — a still player is refreshed
// periodically rather than re-written on every beat. `expiresAt` is epoch seconds.
const nowSeconds = Math.floor(Date.now() / 1000)
if (presence.expiresAt - nowSeconds <= PRESENCE_REFRESH_THRESHOLD) {
await setPresence(c.env.DB, presence)
}
}
return c.json(playerPayload(id, presence))
}
)
.put(
'/player/statusvisibility',
describeRoute({
tags: ['Presence'],
summary: 'Set status visibility',
description: [
'Updates the stored presences status visibility. No-op when the player has no live',
'presence or an unauthenticated/invalid token — always acks 200.',
].join(' '),
requestBody: form(StatusVisibilityRequest, 'The statusVisibility value'),
responses: { 200: EMPTY_OK },
}),
async (c) => {
const id = await authedId(c)
if (id !== null) {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const sv =
typeof body.statusVisibility === 'string'
? Number.parseInt(body.statusVisibility, 10)
: NaN
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence && !Number.isNaN(sv)) {
presence.statusVisibility = sv
await setPresence(c.env.DB, presence)
}
}
return c.body(null, 200)
}
)
// ---- Room navigation -----------------------------------------------------
// Each matchmake/goto persists the resulting instance as the player's presence
// so the heartbeat can replay it (keeping client presence in sync).
.post('/goto/room/:room', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// Each matchmake persists the resulting instance as the player's presence so the
// heartbeat can replay it (keeping client presence in sync).
//
// Matchmake into a club's clubhouse (`/matchmake/club/{clubId}`). Registered before
// the single-segment `/matchmake/:room` route so `club` isn't read as a room name.
// Members only: the clubhouse is the club's private space, so a non-member (or
// someone with a pending request, or banned) is refused rather than let in.
.post(
'/matchmake/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake into a clubs clubhouse',
description: [
'Looks the club up, checks the caller is a member of it, and places them into an',
'instance of its clubhouse room. Returns errorCode 20 with a null instance when the',
'club is unknown, has no clubhouse set, or the caller isnt a member — and errorCode',
'55 when they are banned from the clubhouse room.',
].join(' '),
security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
parameters: [
{
name: 'clubId',
in: 'path',
required: true,
description: 'Club id (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(
MatchmakeResponse,
'The clubhouse instance (or a null instance with errorCode 20 / 55 when it cant be entered)'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const room = c.req.param('room')
const joinMode = await readJoinMode(c)
const instance =
room.toLowerCase() === 'dormroom'
? await playerDormInstance(c, id)
: await resolveRoomInstance(c, room, joinMode === 2, id)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
})
// Register the static `none` route before the `:room` param route so it
// isn't swallowed by the auth-gated matchmake handler.
.post('/matchmake/none', async (c) => {
const id = await authedId(c)
// Return the player's *current* heartbeat here rather than forcing the dorm.
// Orientation is a solo room the client establishes via matchmake/none; if we
// force the dorm, the new player is warped out of Orientation within seconds.
// So: preserve existing presence; only fall back to the offline dorm when the
// player has none (e.g. the title screen before they've entered any room).
if (id !== null) {
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence?.roomInstance) {
return c.json({ errorCode: 0, roomInstance: presence.roomInstance })
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClubSummary(c.env.DB, clubId)
// One response for "no such club", "no clubhouse", and "not a member": the
// client only needs "you're not going there", and a distinct code for the last
// case would tell a non-member which clubs exist and have a clubhouse.
if (!club?.clubhouseRoomId) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
if (!(await isClubMember(c.env.DB, clubId, id))) {
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
}
const joinMode = await readJoinMode(c)
const { instance, errorCode } = await resolveRoomInstance(
c,
String(club.clubhouseRoomId),
joinMode === 2,
id
)
if (!instance) return c.json({ errorCode, roomInstance: null })
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
// Authed but no presence → their personal dorm; unauthenticated → offline dorm.
const instance = id !== null ? await playerDormInstance(c, id) : dormRoomInstance()
if (id !== null) await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
})
)
// Follow a friend into the room they're in (`/matchmake/player/{playerId}`). Friends
// ONLY — the caller must be a mutual friend of the target, or it's refused; otherwise
// anyone could read a player's presence and warp to them. Reads the friend's current
// instance from their stored presence and places the caller into that same instance
// (the real, un-redacted Photon coordinates — the caller is authorized to join).
// Registered before the single-segment `/matchmake/:room` route so `player` isn't read
// as a room name. Returns errorCode 20 with a null instance when the target isn't a
// friend or isn't currently in a room.
.post(
'/matchmake/player/:playerId{[0-9]+}',
describeRoute({
tags: ['Navigation'],
summary: 'Follow a friend into their room',
description: [
'Places the caller into the room instance the target player is currently in, read',
'from the targets stored presence. FRIENDS ONLY: the caller must be a mutual friend',
'of the target (otherwise anyone could read a players presence and warp to them).',
'Returns errorCode 20 with a null instance when the target isnt a friend, is the',
'caller themselves, or isnt currently in a room, and errorCode 55 when the caller is',
'banned from the room the friend is in — this path hands out join coordinates without',
'going through the room resolver, so it carries its own ban check.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'playerId',
in: 'path',
required: true,
description: 'The friend to follow (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(
MatchmakeResponse,
'The friends instance (or a null instance with errorCode 20 / 55 when it cant be joined)'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const targetId = Number.parseInt(c.req.param('playerId'), 10)
// Friends only, and never yourself — otherwise refuse without leaking whether the
// target is even online (same opaque NoSuchRoom the club path uses).
if (targetId === id || !(await areFriends(c.env.DB, id, targetId))) {
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
}
// The instance the friend is currently in, straight off their presence row.
const targetPresence = await getPresence<RoomInstance>(c.env.DB, targetId)
const instance = targetPresence?.roomInstance ?? null
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
// This path hands out a Photon room id without going through
// resolveRoomInstance, so the room's bans have to be checked here too —
// otherwise following a friend in is a way around a ban.
if (await isPlayerBannedFromRoom(c.env.DB, instance.roomId, id)) {
logger.info('follow refused: player banned from room', { roomId: instance.roomId, id })
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
}
// Join that same instance (same id + Photon room) and store it as the caller's
// presence, so the heartbeat replays it and their own friend fan-out fires.
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Join one SPECIFIC live instance by id (`/matchmake/instance/{roomInstanceId}`) —
// the action behind the owner's instance listing (`GET /room/{roomId}/instances`),
// where they pick a session of their room and drop into it. Unlike every other
// matchmake this targets a fixed instance: nothing is reused, nothing is created,
// and a full or in-progress instance is still entered (moderating a full instance
// is the point). OWNER-ONLY, gated with the same creator-or-co-owner check as the
// listing — the Photon room id is the join coordinate, so an open version of this
// would let anyone warp into any private session by guessing an id. Registered
// before the `/matchmake/room/…` routes so `instance` isn't read as a room name.
.post(
'/matchmake/instance/:instanceId{[0-9]+}',
describeRoute({
tags: ['Navigation'],
summary: 'Join a specific instance (owner only)',
description: [
'Places the caller into one specific live instance of their own room, picked by id',
'from the owners instance listing. Gated to the rooms creator or a co-owner.',
'Unlike the other matchmakes this never reuses or creates an instance, and enters',
'even a full or in-progress one. Returns errorCode 20 with a null instance when the',
'instance or its room is gone, or the caller doesnt manage that room; errorCode 55',
'when banned.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'instanceId',
in: 'path',
required: true,
description: 'Room instance id (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(
MatchmakeResponse,
'The instance (or a null instance with errorCode 20 / 55 when it cant be joined)'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const instanceId = Number.parseInt(c.req.param('instanceId'), 10)
const stored = await getRoomInstance(c.env.DB, instanceId)
// One opaque refusal for "no such instance", "no such room" and "not yours":
// a distinct code for the last would confirm which instance ids are live.
if (!stored) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
const room = await getRoomById(c.env.DB, stored.roomId)
if (!room) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
if (!canManageRoom(room, id)) {
logger.info('instance matchmake refused: not the rooms owner', {
roomInstanceId: instanceId,
roomId: stored.roomId,
accountId: id,
})
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
}
// Like the follow-a-friend path, this hands out a Photon room id without going
// through resolveRoomInstance, so the room's bans are checked here too. An owner
// can't ban themselves out of their own room in practice, but a co-owner can be
// banned, and a ban must beat every route that yields join coordinates.
if (await isPlayerBannedFromRoom(c.env.DB, stored.roomId, id)) {
logger.info('instance matchmake refused: player banned from room', {
roomId: stored.roomId,
id,
})
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
}
// Rebuild the wire instance from the room (fresh scene + published save) keyed to
// this instance's own id and Photon room, so the owner lands in exactly the
// session they picked rather than a new one alongside it.
const instance = roomInstanceFromRoom(
room,
stored.isPrivate,
stored.roomInstanceId,
stored.photonRoomId,
stored.subRoomId
)
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}`
// — the client uses this to enter a room's other scenes). The subroom decides the
// scene the client loads and which instances are joinable, so it must be carried
// through; an unknown subroom falls back to the room's first.
.post('/matchmake/room/:roomId/:subRoomId{[0-9]+}', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const joinMode = await readJoinMode(c)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const instance = await resolveRoomInstance(
c,
c.req.param('roomId'),
joinMode === 2,
id,
subRoomId
)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
})
.post(
'/matchmake/room/:roomId/:subRoomId{[0-9]+}',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake into a specific subroom',
description: [
'Enters a specific subroom (scene) of a room. The subroom decides the scene loaded',
'and which instances are joinable; an unknown subroom falls back to the rooms first.',
].join(' '),
security: AUTHED,
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
parameters: [
{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } },
{
name: 'subRoomId',
in: 'path',
required: true,
description: 'Subroom id (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(
MatchmakeResponse,
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const { instance, errorCode } = await resolveRoomInstance(
c,
c.req.param('roomId'),
joinMode === 2,
id,
subRoomId
)
if (!instance) return c.json({ errorCode, roomInstance: null })
await enterRoom(c, id, instance)
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
await inviteParty(c, id, additionalPlayerIds, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// The 2023 client uses a two-segment matchmake/room/{roomId}. Look the room up
// in D1 so the instance carries its real scene, and store it as presence.
.post('/matchmake/room/:roomId', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const joinMode = await readJoinMode(c)
const instance = await resolveRoomInstance(c, c.req.param('roomId'), joinMode === 2, id)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
})
.post('/matchmake/:room', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
.post(
'/matchmake/room/:roomId',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake into a room (default subroom)',
description: [
'The 2023 clients two-segment matchmake. Resolves the room from D1 so the instance',
'carries its real scene, and stores it as presence.',
].join(' '),
security: AUTHED,
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
200: json(
MatchmakeResponse,
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
const { instance, errorCode } = await resolveRoomInstance(
c,
c.req.param('roomId'),
joinMode === 2,
id
)
if (!instance) return c.json({ errorCode, roomInstance: null })
await enterRoom(c, id, instance)
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
await inviteParty(c, id, additionalPlayerIds, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
.post(
'/matchmake/dorm',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake into the players dorm',
description: [
'Single-segment matchmake into the callers personal dorm, stored as presence. The',
'client only ever calls this with the `dorm` keyword — real rooms go through',
'`/matchmake/room/:roomId`.',
].join(' '),
security: AUTHED,
responses: {
200: json(MatchmakeResponse, 'The players personal dorm instance'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const room = c.req.param('room')
const joinMode = await readJoinMode(c)
// The dorm check here is "dorm" (goto/room uses "dormroom").
const instance =
room.toLowerCase() === 'dorm'
? await playerDormInstance(c, id)
: await resolveRoomInstance(c, room, joinMode === 2, id)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
})
// Offline dorm — also persisted as presence so the heartbeat stays in sync.
.post('/goto/none', async (c) => {
const id = await authedId(c)
// Authed → their personal dorm; unauthenticated → the offline dorm.
const instance = id !== null ? await playerDormInstance(c, id) : dormRoomInstance()
if (id !== null) await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
})
const instance = await playerDormInstance(c, id)
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Region ping reports — accept-and-ack (the reference returns Ok()).
.put('/player/photonregionpings', (c) => c.body(null, 200))
.put('/player/gameserverregionpings', (c) => c.body(null, 200))
.put(
'/player/photonregionpings',
describeRoute({
tags: ['Presence'],
summary: 'Photon region pings (no-op ack)',
description: 'Region latency report; accepted and ignored.',
responses: { 200: EMPTY_OK },
}),
(c) => c.body(null, 200)
)
.put(
'/player/gameserverregionpings',
describeRoute({
tags: ['Presence'],
summary: 'Game-server region pings (no-op ack)',
description: 'Region latency report; accepted and ignored.',
responses: { 200: EMPTY_OK },
}),
(c) => c.body(null, 200)
)
// ---- Social --------------------------------------------------------------
// Invite a player to join the caller in their room instance. The caller is the
// inviter (from the Bearer token); the form carries the target `playerId` and the
// `roomInstanceId` they're being invited into. Delivers a game-invite Message to the
// target over the notify hub as a MessageReceived frame — the client renders the
// join prompt from it. When the room instance resolves, its RoomId rides along on the
// message so the client knows which room the invite points at. Always acks 200 (a bad
// playerId is a 400, a missing token a 401); hub delivery is best-effort, so a target
// who's offline simply has the frame queued (or dropped) without failing the invite.
.post(
'/invite',
describeRoute({
tags: ['Social'],
summary: 'Invite a player into the callers room instance',
description: [
'Sends a game invite from the caller (the Bearer token) to `playerId` for',
'`roomInstanceId`. Delivered to the target over the notify hub as a `MessageReceived`',
'notification carrying a game-invite `Message`; the resolved instances `RoomId` rides',
'on the message. Acks 200 (bad `playerId` → 400); hub delivery is best-effort.',
].join(' '),
security: AUTHED,
requestBody: form(InviteRequest, 'The target player and the room instance'),
responses: {
200: EMPTY_OK,
400: { description: 'Missing, non-numeric, or zero playerId (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const str = (v: unknown) => (typeof v === 'string' ? v : '')
const toPlayerId = Number.parseInt(str(body.playerId), 10)
// A missing or zero target is a bad request (mirrors the reference's guard).
if (Number.isNaN(toPlayerId) || toPlayerId === 0) return c.body(null, 400)
const roomInstanceIdStr = str(body.roomInstanceId)
const roomInstanceId = Number.parseInt(roomInstanceIdStr, 10)
// Resolve the instance to stamp the invite's RoomId — the client reads it to know
// which room the invite points at. A missing/unknown instance just leaves RoomId
// null (buildNotificationPayload drops it from the frame), as the reference does.
let roomId: number | null = null
if (!Number.isNaN(roomInstanceId) && roomInstanceId > 0) {
const instance = await getRoomInstance(c.env.DB, roomInstanceId)
if (instance) roomId = instance.roomId
}
await sendGameInvite(c, id, toPlayerId, roomInstanceIdStr, roomId)
return c.body(null, 200)
}
)
// ---- Room instance -------------------------------------------------------
.post('/roominstance/:id/reportjoinresult', (c) => c.body(null, 200))
.post(
'/roominstance/:id/reportjoinresult',
describeRoute({
tags: ['Room instance'],
summary: 'Report join result (no-op ack)',
description: 'The client reports how a join went; accepted and ignored.',
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: { 200: EMPTY_OK },
}),
(c) => c.body(null, 200)
)
// The room owner flips the instance's in-progress flag once the session starts
// (e.g. a game round begins). Body is a form post: `inProgress=True|False`.
.put('/roominstance/:id/inprogress', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// The instance's in-progress flag, flipped when a session starts (e.g. a game round
// begins). Deliberately NOT owner-gated, unlike the other room-instance mutations:
// this is set by whoever in the room starts the game, not by the room's owner — a
// gate here would break game starts for everyone else. Body is a form post:
// `inProgress=True|False`.
.put(
'/roominstance/:id/inprogress',
describeRoute({
tags: ['Room instance'],
summary: 'Set instance in-progress flag',
description: [
'Flips the instances in-progress flag when a session starts (e.g. a round begins).',
'Set by whoever in the room starts the game — any authenticated player, not just the',
'rooms owner. Body is `inProgress=True|False`.',
].join(' '),
security: AUTHED,
requestBody: form(InProgressRequest, 'The inProgress flag'),
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
responses: {
200: EMPTY_OK,
401: UNAUTHORIZED_RESPONSE,
404: { description: 'Non-numeric id or no such instance (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const instanceId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(instanceId)) return c.body(null, 404)
const instanceId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(instanceId)) return c.body(null, 404)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const inProgress =
typeof body.inProgress === 'string' && body.inProgress.toLowerCase() === 'true'
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const inProgress =
typeof body.inProgress === 'string' && body.inProgress.toLowerCase() === 'true'
const instance = await setRoomInstanceInProgress(c.env.DB, instanceId, inProgress)
if (!instance) return c.body(null, 404)
return c.body(null, 200)
})
const instance = await setRoomInstanceInProgress(c.env.DB, instanceId, inProgress)
if (!instance) return c.body(null, 404)
return c.body(null, 200)
}
)
// Close a live instance to strangers (`/roominstance/{id}/markprivate`) — the owner
// makes the session they're running private, so public matchmaking stops feeding new
// players into it (getJoinableInstance only reuses non-private instances). Everyone
// already inside stays put; this shuts the door rather than clearing the room.
// OWNER-ONLY (same creator-or-co-owner gate as the instance listing): whether a
// session is open is the room owner's call, not a passer-by's. Generic empty ack.
.post(
'/roominstance/:id/markprivate',
describeRoute({
tags: ['Room instance'],
summary: 'Mark an instance private (owner only)',
description: [
'Marks a live instance private, so public matchmaking stops placing new players',
'into it. Players already inside are unaffected. Auth-gated and gated to the',
'instances rooms creator or a co-owner (403 otherwise). Empty ack.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: 'Room instance id',
schema: { type: 'string' },
},
],
responses: {
200: EMPTY_OK,
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the rooms creator or a co-owner (empty body)' },
404: { description: 'Non-numeric id or no such instance (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const instanceId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(instanceId)) return c.body(null, 404)
const stored = await getRoomInstance(c.env.DB, instanceId)
if (!stored) return c.body(null, 404)
const room = await getRoomById(c.env.DB, stored.roomId)
if (!room || !canManageRoom(room, id)) return c.body(null, 403)
await setRoomInstancePrivate(c.env.DB, instanceId, true)
return c.body(null, 200)
}
)
// The room's live instances — the owner's view of active sessions of their room.
// Auth-gated (401) and owner/co-owner-only (403): the caller must be the room's
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns the
// bare RoomInstance DTO array (empty when the room has no live instances).
.get('/room/:roomId{[0-9]+}/instances', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns a
// summary per instance (empty when the room has no live instances) — id, subroom,
// fullness, creation time and who's currently in it — not the client's
// RoomInstance DTO: this is a management listing, so it answers "who's in there"
// and withholds the connection details of a session the owner isn't joining.
.get(
'/room/:roomId{[0-9]+}/instances',
describeRoute({
tags: ['Room instance'],
summary: 'A rooms live instances',
description: [
'The owners view of active sessions of their room — each instance with the',
'players currently in it. Auth-gated and gated to the rooms creator or a',
'co-owner (403 otherwise). Unknown room → 404.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'roomId',
in: 'path',
required: true,
description: 'Room id (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(RoomInstanceSummaryDto.array(), 'Live instances (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the rooms creator or a co-owner (empty body)' },
404: { description: 'No such room (empty body)' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return c.body(null, 404)
// The room's creator *or* a co-owner (Role 30) may see its live instances —
// same owner-or-co-owner gate the rooms worker uses for room-admin actions.
if (!canManageRoom(room, id)) return c.body(null, 403)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return c.body(null, 404)
// The room's creator *or* a co-owner (Role 30) may see its live instances —
// same owner-or-co-owner gate the rooms worker uses for room-admin actions.
if (!canManageRoom(room, id)) return c.body(null, 403)
return c.json(await getRoomInstancesByRoom(c.env.DB, roomId))
})
return c.json(await getRoomInstanceSummariesByRoom(c.env.DB, roomId))
}
)
// Rooms flagged as needing a developer/moderator to spawn in. No such queue
// yet → empty list.
.get('/rooms/requiring/developer', (c) => c.json([]))
.get(
'/rooms/requiring/developer',
describeRoute({
tags: ['Room instance'],
summary: 'Rooms requiring a developer',
description: 'Rooms flagged as needing a developer/moderator to spawn in. No queue yet → [].',
responses: { 200: json(RoomInstanceDto.array(), 'Always empty for now') },
}),
(c) => c.json([])
)
// Rooms flagged as requiring an RR+ subscription. No such queue yet → empty list.
.get('/rooms/requiring/rrplus', (c) => c.json([]))
.get(
'/rooms/requiring/rrplus',
describeRoute({
tags: ['Room instance'],
summary: 'Rooms requiring RR+',
description: 'Rooms flagged as requiring an RR+ subscription. No queue yet → [].',
responses: { 200: json(RoomInstanceDto.array(), 'Always empty for now') },
}),
(c) => c.json([])
)
/**
* Cron: sweep presence that has aged past its TTL. Reads already ignore expired rows,
@@ -690,9 +1496,51 @@ async function sweepExpiredPresence(env: Env): Promise<void> {
)
}
export default {
fetch: app.fetch,
scheduled: async (_controller, env, ctx) => {
ctx.waitUntil(sweepExpiredPresence(env))
},
} satisfies ExportedHandler<Env>
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output. Registered on
// `app` before it's wrapped in the exported handler below.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare match',
version: '1.0.0',
description: [
'Matchmaking and presence for recflare, a private-server reimplementation of the Rec',
'Room backend. Rooms and room instances are D1-backed (matchmaking finds or creates a',
'`room_instance` per session); presence — the instance each player is currently in —',
'lives in the shared `presence` table and expires on a TTL. A cron sweep clears',
'expired presence and frees up instances a crashed player never left.',
].join('\n'),
},
servers: [{ url: 'https://match.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
// The HTTP surface is a standard Hono app, exported by name so it can be mounted
// uniformly like every other worker (e.g. by a combined/facade worker). The cron
// that sweeps expired presence is exported alongside it.
export { app }
export const scheduled: ExportedHandlerScheduledHandler<Env> = (_controller, env, ctx) => {
ctx.waitUntil(sweepExpiredPresence(env))
}
// Standalone entry: a Worker only runs `scheduled` when it's on the default export,
// so match keeps the object form the runtime requires to fire its `*/5 * * * *` cron.
export default { fetch: app.fetch, scheduled } satisfies ExportedHandler<Env>
+216
View File
@@ -0,0 +1,216 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the match worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`.
*
* As with the auth/accounts workers, this is deliberate: the Rec Room client is the
* only real consumer, the handlers are lenient (bodies are parsed defensively and
* missing fields fall through to sensible defaults), and the exact request/response
* shapes are reverse-engineered. These schemas record observed behaviour; to enforce
* one, do it per-route and land a test with it.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
/**
* Convert a zod schema to a plain OpenAPI schema for a request body. `describeRoute`'s
* `requestBody` takes an OpenAPI schema (not a `resolver()`). zod's `$schema` key and
* `additionalProperties: false` are dropped the handlers read the fields they know
* and ignore the rest, so a closed object would misreport them as stricter than they
* are.
*/
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body (the heartbeat posts one). */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** An empty-body `200 OK` ack — the response many match routes return. */
export const EMPTY_OK = { description: 'Acknowledged (empty body)' }
/** 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: [] }]
/**
* RoomInstanceType enum, by value. `Dormroom` instances are private; `Public` are the
* shared, joinable ones matchmaking reuses.
*/
export const RoomInstanceType = z
.int()
.describe('RoomInstanceType: 0 Public, 1 Dormroom, … (see @repo/domain)')
/**
* A room instance the session the client connects to (scene + Photon coordinates).
* Joiners of the same public instance share `roomInstanceId` and `photonRoomId`. Names
* are `^`-prefixed so the client resolves the scene (personal dorms use `@owner's Dorm`
* instead). `location` is the SubRoom's Unity scene id; an empty one makes the client
* reject the session.
*/
export const RoomInstanceDto = z.object({
roomInstanceId: z.int(),
roomId: z.int(),
subRoomId: z.int().describe('Which subroom (scene) of the room this instance is'),
roomInstanceType: RoomInstanceType,
location: z.string().describe('SubRoom Unity scene id; empty is rejected by the client'),
dataBlob: z.string(),
eventId: z.int(),
clubId: z.int(),
roomCode: z.string(),
photonRegion: z.string(),
photonRegionId: z.string(),
photonRoomId: z.string().describe('Shared by joiners of the same instance'),
name: z.string().describe('`^`-prefixed (or `@owners Dorm` for personal dorms)'),
maxCapacity: z.int(),
isFull: z.boolean(),
isPrivate: z.boolean(),
isInProgress: z.boolean().describe('Set by the owner via PUT /roominstance/:id/inprogress'),
EncryptVoiceChat: z.boolean(),
})
/**
* One live instance in the owner's management listing (`GET /room/:roomId/instances`).
* Not the client `RoomInstanceDto`: it carries who's in there and drops the connection
* details (photon ids, data blob, room code) of a session the owner isn't in.
*/
export const RoomInstanceSummaryDto = z.object({
roomInstanceId: z.int(),
roomId: z.int(),
subRoomId: z.int().describe('Which subroom (scene) of the room this instance is'),
isFull: z.boolean(),
createdAt: z.string().describe('ISO 8601 UTC, stamped when the instance was created'),
playerIds: z
.array(z.int())
.describe('Accounts currently in the instance (live presence); empty when nobody is'),
})
/**
* A player's presence as the client reads it (`GET /player`, `POST /player/heartbeat`).
* `isOnline` means "has a live (unexpired) presence row", NOT "is in a room" a player
* can be online in the lobby with `roomInstance` null. The `photon*`/`voice*`
* connection fields are only populated in a matchmaking response, never here, but the
* client needs the keys present, so they're always null.
*/
export const PlayerDto = z.object({
playerId: z.int(),
isOnline: z.boolean().describe('Has a live presence row (presence expires on a TTL)'),
errorCode: z.int().describe('0 = no error; non-zero only on a failed matchmake'),
roomInstance: RoomInstanceDto.nullable().describe('null when not in a room'),
appVersion: z.string(),
deviceClass: z.int(),
statusVisibility: z.int(),
vrMovementMode: z.int(),
platform: z.int(),
photonAuthToken: z.null(),
photonRealtimeAppId: z.null(),
photonVoiceAppId: z.null(),
photonChatAppId: z.null(),
photonRegion: z.null(),
photonRoomId: z.null(),
voiceConnectionInfo: z.null(),
voiceServerId: z.null(),
experiments: z.null(),
})
/**
* The matchmake result envelope. `errorCode` 0 with a `roomInstance` is success;
* a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
*/
export const MatchmakeResponse = z.object({
errorCode: z
.int()
.describe('0 = success; 20 = NoSuchRoom; 55 = banned from the room (the one non-opaque code)'),
roomInstance: RoomInstanceDto.nullable(),
})
/** `POST /player/exclusivelogin` — a bare error code. */
export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') })
/**
* The session `LoginLock` GUID form field. The client posts it on every presence
* lifecycle call `POST /player/login`, `/player/exclusivelogin`, `/player/logout`,
* and `/player/heartbeat` so it's always present, not optional. Recorded in presence
* at login and verified on each heartbeat (a mismatched lock is a superseded session).
*/
export const LoginLockRequest = z.object({
LoginLock: z.string().describe('The session login-lock GUID (always sent)'),
})
/** `PUT /roominstance/:id/inprogress` form body. */
export const InProgressRequest = z.object({
inProgress: z.string().describe('"True" | "False" (case-insensitive)'),
})
/** `PUT /player/statusvisibility` form body. */
export const StatusVisibilityRequest = z.object({
statusVisibility: z.string().describe('Integer string; non-numeric is ignored'),
})
/**
* `POST /player/notifydisconnect` form body posted by Photon when it sees a player
* drop a room instance. Both fields are integer strings.
*/
export const NotifyDisconnectRequest = z.object({
PlayerId: z.string().describe('The account that disconnected'),
RoomInstanceId: z.string().describe('The room instance they dropped'),
})
/**
* The `JoinMode` form field the matchmake routes read (`2` = a private instance;
* anything else = public). Posted as a urlencoded/multipart body.
*/
export const JoinModeRequest = z.object({
JoinMode: z.string().optional().describe('"2" requests a private instance'),
})
/**
* The room-matchmake form body (`/matchmake/room/:roomId[/:subRoomId]`). Beyond
* `JoinMode` the 2023 client posts `AdditionalPlayerIds` the caller's party so each
* of them is invited (a game invite) into the instance the leader lands in. It's a
* repeated field (one id each, not comma-separated). Other fields the client sends
* (`LoginLock`, `MaxPersistenceVersion`, `BypassMovementModeRestriction`) are accepted
* and ignored.
*/
export const MatchmakeRoomRequest = z.object({
JoinMode: z.string().optional().describe('"2" requests a private instance'),
AdditionalPlayerIds: z
.string()
.optional()
.describe('Party members to invite into the room; repeated once per id'),
})
/** `POST /invite` form body — invite a player into the caller's room instance. */
export const InviteRequest = z.object({
playerId: z.string().describe('The account to invite; a non-zero integer (else 400)'),
roomInstanceId: z
.string()
.optional()
.describe('The callers room instance to invite them into; resolves the invites RoomId'),
})

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