Compare commits

..

1 Commits

Author SHA1 Message Date
Devin Zuczek aef4cc0139 Issue #10: rewards bootstrapping 2026-08-25 15:01:34 -04:00
300 changed files with 51629 additions and 818761 deletions
+20 -60
View File
@@ -1,21 +1,12 @@
---
name: weekly-challenge-config
description: Read and author the `Config` rule tree a weekly challenge carries — the full node-type enum, event types, event variables, named scene constants, the shared-scene traps, and where the generator in apps/econ/src/challenge-rotation.ts emits them from
description: Read and author the `Config` rule tree in apps/econ/static/weekly-challenge.json — the full node-type enum, event types, event variables, named scene constants, and the shared-scene traps
---
# The weekly-challenge `Config` rule tree
Reference for reading and writing the `Config` field of a weekly challenge (served by
`GET /api/challenge/v2/getCurrent`).
**Rotations are generated, so there are two places a tree comes from.** Normally
`apps/econ/src/challenge-rotation.ts` emits it: a week is five (room, kind) pairs drawn from
`CHALLENGE_ROOMS` with the week's seed, and the tree is built by `configFor` from one of the
three idioms below. Adding variety means adding a room or a kind there, not hand-writing a
tree. The other place is `apps/econ/static/weekly-challenge.json`: a non-empty `Challenges`
array in that file PINS the week to a hand-authored rotation and skips generation, which is
how a one-off debug or event week gets served. Both end up as the same `Config` string on
the wire, and everything below applies to both.
Reference for reading and writing the `Config` field of a challenge in
`apps/econ/static/weekly-challenge.json` (served by `GET /api/challenge/v2/getCurrent`).
**The server never evaluates these rules.** The client reads the tree, watches its own
gameplay, and posts the tree back to `/api/challenge/v2/updateProgress` with its verdict.
@@ -52,16 +43,13 @@ Author the tree as an object and stringify it into the field — don't hand-esca
bun -e 'const t={ct:0,ipc:false,wc:[{ct:6,vs:[2]}]}; console.log(JSON.stringify(JSON.stringify(t)))'
```
To read this week's back (the generated rotation, or the pinned file if one is in place):
To read one back:
```sh
bun -e 'const {buildRotation}=await import("./apps/econ/src/challenge-rotation.ts");
for (const x of buildRotation(new Date()).Challenges)
console.log(x.ChallengeId, x.Description, "\n ", JSON.parse(x.Config))'
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json");
for (const x of c.Challenges) console.log(x.ChallengeId, x.Description, "\n ", JSON.parse(x.Config))'
```
Pass a date to look at any other week — the rotation is a pure function of which week it is.
## Node types (`ct`) — the full enum
**(lib)** `ChallengeTypes`. Every node carries one. Bold rows are the ones the captured
@@ -305,12 +293,10 @@ On `updateProgress` the client posts the same tree back with its own progress wr
it: **`cc`** on a counter is the current count (`…,"t":5,"cc":1`), and **`c`** (`"c":true`)
marks a node it now considers satisfied.
Neither belongs in an authored tree — they are progress, not definition. The server
stores the posted tree per player (`challenge_status.config`; see
`apps/econ/src/challenge-db.ts`) and `getCurrent` serves it back in place of the authored
tree, which is how a half-finished challenge survives a session — but it still evaluates
none of it: the counting is the client's. Don't author `cc`/`c`, and don't try to read
progress out of the tree you author.
Neither belongs in `weekly-challenge.json` — they are progress, not definition. The server
echoes the posted `Config` back untouched and never persists it (`challenge_status` stores
only the completion flag; see `apps/econ/src/challenge-db.ts`), so the running count lives
only in the client. Don't author `cc`/`c`, and don't try to read progress out of one.
This is also the cheapest way to decode an unfamiliar tree: serve it, play the activity, and
watch which node grows a `cc`.
@@ -336,30 +322,7 @@ all worth checking before trusting a lib-only field:
- **`SpawnableToolTypes` re-rolls per build**, so `ct:5` and any `t_t` comparison is pinned
to one client version.
## Adding to the generator
This is the usual way a new challenge ships: the week picks from `CHALLENGE_ROOMS` in
`apps/econ/src/challenge-rotation.ts`, so a room added there starts appearing in rotations
on its own.
1. **A new room** — add an entry with its `UnitySceneId`(s) from
`apps/rooms/static/ImportRooms.json`. A scene no room on this server hosts can never be
completed and nothing will tell you. Check the shared-scene table above and record the
extra rooms in `shares`. Set `kinds` conservatively: `win` reads the `won` variable, so
only rooms where winning is a real outcome; `ai` is quests. **Append, never insert** —
`ChallengeId` is the candidate's index, so inserting renumbers every challenge after it.
2. **A new kind** — add it to `ChallengeKind` and give it a branch in all three of
`configFor` (the tree), `copyFor` (the strings) and `nameFor` (the slug). The compiler
will point at the two you forget. Build the tree from an idiom below; the copy is
generated from the same inputs so it can't drift out of step with the tree.
3. Keep the target constants (`GAMES_TARGET`, `AI_TARGET`) as the single source for both the
tree and the copy.
## Authoring a pinned rotation
For a one-off week: put challenges in `apps/econ/static/weekly-challenge.json` and the file
takes over completely — generation is skipped, and its `Gift`, window and `ChallengeMapId`
are served as written.
## Authoring a new challenge
1. Pick the idiom: one-shot (`ct:0` root, add the `won` predicate if winning is required),
counted (`ct:1` root with `t`), or buffered/streak (`ct:2` root, `rc` on the child).
@@ -375,24 +338,21 @@ are served as written.
client renders the strings and evaluates the tree independently, so a mismatch ships a
challenge that advances somewhere the text never mentions.
6. Leave `Complete: false`; `getCurrent` stamps it per caller.
7. Set a `ChallengeMapId` that no recent week has used — a new map id is what resets stored
completions, and generated weeks are `1000 + weekIndex`, so stay well clear of that range.
7. Bump `ChallengeMapId` if this is a new rotation — ids only need to be unique within one,
and a new map id is what resets stored completions.
8. Keep `ServerTime` inside `StartAt`…`EndAt`, or the client renders the rotation as expired.
A pinned file is static, so its clock has to be frozen there; a generated week doesn't,
because its window is really the current one.
Sanity check that every tree in this week's rotation parses, pinned or generated:
Sanity check the file parses and every tree parses:
```sh
bun -e 'const {buildRotation}=await import("./apps/econ/src/challenge-rotation.ts");
const c=buildRotation(new Date());
c.Challenges.forEach(x => JSON.parse(x.Config)); console.log("ok", c.ChallengeMapId, c.Challenges.length)'
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json");
c.Challenges.forEach(x => JSON.parse(x.Config)); console.log("ok", c.Challenges.length)'
```
Then `bun vitest run apps/econ` — `src/test/integration/api.test.ts` builds the same rotation
and asserts `getCurrent` against it, and walks two years of generated weeks checking every
tree. Note the gift threshold follows the rotation size (`CHALLENGES_REQUIRED_FOR_GIFT`
clamps to what you publish), so a pinned rotation of three or fewer asks for all of them.
Then `bun vitest run apps/econ` — `src/test/integration/api.test.ts` imports the file and
asserts `getCurrent` against it. Note the gift threshold follows the rotation size
(`CHALLENGES_REQUIRED_FOR_GIFT` clamps to what you publish), so a rotation of three or fewer
asks for all of them.
## Credits
+5 -110
View File
@@ -37,27 +37,6 @@ RECFLARE_DOMAIN=rec.example.com
# spliced in at deploy time. Required to deploy any worker.
# RECFLARE_SECRETS_STORE=00000000-0000-0000-0000-000000000000
# OPTIONAL: run the workers on a libSQL server (Turso) INSTEAD of the D1 database above. Both
# are SQLite; the schema, the migrations and the workers are the same, and the D1 database
# stays bound (and RECFLARE_D1 still required) so switching back is unsetting this line and
# redeploying. Leave both unset to use D1, which is the default.
#
# The URL is the one Turso shows for the database (`turso db show <name> --url`), as-is:
#
# RECFLARE_LIBSQL_DB_URL=libsql://<database>-<org>.turso.io
#
# The token (`turso db tokens create <name>`) is the one entry in this file that IS a secret.
# It is never passed as a var: `just deploy` puts it as an encrypted worker secret, and
# `just migrate` / `runx admin --remote` read it from here to reach the database directly.
#
# RECFLARE_LIBSQL_DB_AUTH_TOKEN=eyJ...
#
# `just dev` and the tests ignore both and stay on the local D1. Setting the URL changes what
# `just migrate` and every `--remote` operator command talk to — the libSQL database, not D1 —
# so it is one switch for the whole deployment: the workers, their migrations and the tooling
# all move together, which is the only sane state for fifteen workers sharing one database.
# To carry existing data over, see "Using Turso instead of D1" in DEPLOYING.md.
# --- Server tuning (all optional; the shown value is the built-in default) ---
# Everything below is passed to the workers as a variable, named without the RECFLARE_
# prefix: RECFLARE_STARTING_TOKENS becomes STARTING_TOKENS. Every worker gets every knob —
@@ -108,17 +87,6 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
# RECFLARE_MAX_CLUBS_PER_ACCOUNT=10
# Largest binary the storage worker accepts in one upload, in bytes. The default is
# 64 MiB. The endpoint rejects larger files with HTTP 413 before writing to R2. This
# value must be a positive integer; zero or an invalid value restores the default.
# RECFLARE_MAX_UPLOAD_BYTES=67108864
# Largest file the API worker accepts through either saved-image upload or either
# custom-avatar-item file field, in bytes. The default is 64 MiB PER FILE. Larger
# parsed files receive HTTP 413 before arrayBuffer() or an R2 write. This complements
# RECFLARE_MAX_UPLOAD_BYTES, which protects the separate storage worker.
# RECFLARE_MAX_API_UPLOAD_BYTES=67108864
# Rooms to switch out at matchmake time (`match`), as comma-separated <fromRoomId>=<to>
# pairs, where <to> is a room id or room name. This is how a stock RRO room is replaced
# with your own: 2=MyHub sends everyone who matchmakes into the Rec Center (room 2) to the
@@ -140,26 +108,12 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_PHOTON_VOICE_APP_ID=
# RECFLARE_PHOTON_CHAT_APP_ID=
# The pool of Tachyon servers sessions are spread across (`match`, GET
# /player/connection-info): a COMMA-SEPARATED list of `host:port` entries, one of which
# the client is handed as `voiceConnectionInfo`. EMPTY unless you set it — no separate
# voice server. Not a secret (the client receives the address in the clear), like the
# Photon ids above. One entry is the ordinary single-server case:
# The Tachyon voice server (`match`, GET /player/connection-info): the `host:port` the
# client is handed as `voiceConnectionInfo`, and its id as `voiceServerId`. EMPTY unless
# you set them — no separate voice server. Set both or neither; like the Photon ids they
# are not secrets (the client receives them in the clear).
# RECFLARE_TACHYON_HOST_PORT=127.0.0.1:7777
#
# List several and each room instance is assigned one for its lifetime, so everybody in
# a session lands on the same server while different sessions spread across the pool.
# The id the client displays (`voiceServerId`) is generated from an entry's POSITION —
# `tachyon-1`, `tachyon-2`, … — so listing one address twice models two server slots on
# one box, and inserting an entry renames every server after it. The five below are a
# mock pool (RFC 5737 documentation addresses, which answer nothing): tachyon-1/-2 share
# a host, as do tachyon-4/-5.
# RECFLARE_TACHYON_HOST_PORT=198.51.100.10:7777,198.51.100.10:7778,198.51.100.11:7777,203.0.113.20:7777,203.0.113.20:7778
#
# The pool SANDBOX accounts (the account's `isSandbox` flag) are served from instead,
# same format. Its ids are generated as `dev-1`, `dev-2`, …. Unset, sandbox accounts
# use the pool above.
# RECFLARE_TACHYON_HOST_PORT_SANDBOX=198.51.100.12:7777,198.51.100.12:7778
# RECFLARE_TACHYON_NAME=server-1
# The Photon region every session is pinned to (`match`). Unlike the app ids above this
# does default, to `us` (us-east1) — an instance stamped with an empty region is one the
@@ -175,15 +129,6 @@ RECFLARE_DOMAIN=rec.example.com
# raising it later does NOT top up existing players.
# RECFLARE_STARTING_TOKENS=10000
# The most RecCenterTokens one gift from the staff card on a player's profile can carry
# (`www`). A guard against typos — a gift can't be taken back — not a limit on how much
# staff can give in total.
# RECFLARE_MAX_TOKEN_GIFT=10000
# The same guard for one XP gift from that staff card (`www`). Every level to the top costs
# 15,090 XP in all (level 1 to 50).
# RECFLARE_MAX_XP_GIFT=100
# 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.
@@ -196,53 +141,3 @@ RECFLARE_DOMAIN=rec.example.com
# 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.
# The website's benefits claim (www `/claim`): a player proves a role in your Discord and
# gets Rec Room Plus. FOUR settings, and ALL FOUR are required — with any missing, the claim
# stays closed, `/api/config` reports `benefitsEnabled: false`, and the page and its nav link
# never appear. That is the usual reason "I set the secrets and nothing shows up".
#
# The two CREDENTIALS live in the Secrets Store, like the Turnstile pair above:
#
# wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_ID \
# --scopes workers --remote
# wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_SECRET \
# --scopes workers --remote
#
# The two IDS are plain vars, and go HERE — they are not secrets, and setting the secrets
# alone is not enough. Both are Discord snowflakes: all digits, no letters, copied with
# Developer Mode on (right-click the server or role -> Copy ID). Ids, not names.
#
# ROLE_IDS is a list and ANY one of them qualifies, so several tiers can share the benefit.
# SEPARATE THEM WITH COMMAS AND NO SPACES: these knobs are passed to wrangler as `--var`
# flags that are word-split, so a value containing a space silently breaks the deploy. (The
# worker itself also accepts whitespace, which is fine in wrangler.jsonc but not here.)
# RECFLARE_DISCORD_GUILD_ID=1077000000000000000
# RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002
#
# Also add https://<your domain>/claim to the app's Redirects in the Discord developer
# portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT
# sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
#
# OPTIONAL: the daily role refresh (www cron) re-reads claimed members' roles through a
# bot token, so `platform_account.role` tracks the guild instead of the day they claimed.
# Add a bot user to the same Discord app, invite it to the server (no permissions needed),
# and store its token in the Secrets Store:
#
# wrangler secrets-store secret create <store-id> --name DISCORD_BOT_TOKEN \
# --scopes workers --remote
#
# The binding has to resolve for `www` to deploy, so create it with a placeholder even if
# you don't want the sweep — it then does nothing. It never touches hasPlus.
# OPTIONAL: a token gift for Discord supporters (`econ` cron). Maps Discord ROLE ids to the
# RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as
# `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in
# apps/econ/wrangler.jsonc ships weekly (Monday 05:00 UTC); change that line for daily. ONE
# box per member per run — a member holding several mapped roles is paid the HIGHEST amount
# among them, not the sum, so the map reads as tiers. Same rules as ROLE_IDS above:
# snowflakes, not names; commas, no spaces. Roles are read from the snapshot the benefits
# claim and the daily sweep above keep, so it reaches only players who have claimed on the
# website, and is only as fresh as that sweep (with a bot, the morning's; without one, the
# day they claimed). Unset — or parsing to no roles — leaves the cron a logged no-op.
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
-122
View File
@@ -37,10 +37,6 @@
- Syncpack ensures dependency version consistency
- Turborepo enables parallel task execution and caching
- Workers configured via `wrangler.jsonc` with environment variables
- The shared `recflare` database is D1 by default, or a libSQL server (Turso) when
`LIBSQL_DB_URL` is set: `withDatabase` (`packages/domain/src/libsql-db.ts`) wraps each
worker's default export and swaps `env.DB` for an adapter with the D1 API, so query code
only ever sees `D1Database`. Migrations and `runx` tooling follow the same switch.
- Each worker has `context.ts` for typed environment bindings
- Integration tests in `src/test/integration/`
- Workers use `nodejs_compat` compatibility flag
@@ -48,20 +44,6 @@
- Changesets manage versions and changelogs
</architecture>
<game-clients>
Supported client builds live in `SUPPORTED_GAME_VERSIONS`
(`packages/domain/src/presence-db.ts`); `GAME_VERSION` is the default the stack targets.
- `20230414` — default, official. Manifest `7859140924515540835` (2023).
- `20250718.01` — beta, official. Manifest `1151455856673601091`; reaches this server via
the [patch-2025](https://github.com/recflare/patch-2025) patch.
- `20250424.01`, `20231207`, `20230616` — alpha.
Builds are date-stamped (`YYYYMMDD[.NN]`) so they order as plain strings; several
surfaces gate on "newer than `20230414`" (econ storefront catalog, `api` event tags,
`rooms` featured rooms) rather than on an explicit list.
</game-clients>
<code-style>
- Use tabs for indentation, spaces for alignment
- Type imports use `import type`
@@ -169,110 +151,6 @@ inconsistency here without checking the client first.
served under TWO names, `result` (what the client reads) and `errorCode` (what this
server has always sent); they are the same number and must never disagree, which is why
everything answers through `matchmakeResult` rather than building the envelope by hand.
- What PLAYS a cheer on the cheered player's client is a `MessageReceived` frame carrying a
Message of type 50 `PlayerCheer` (51 `PlayerCheerAnonymous`, `FromPlayerId` 0, when the
body says `Anonymous`) with `Data` = the category as a string — the same frame every
reference server (meownet-api, DorkNet, E12354) sends. `ReputationUpdate` alone refreshes
the counters and shows nothing: the cheer "worked" server-side and nobody saw it. The
`ReputationUpdate` frames the cheer (`api`: `POST /api/PlayerCheer/v1/create`) sends are
the RECORD, trimmed — `IsCheerful` (a profile flag, always true) and `SelectedCheer` (the
cheer pinned via `POST /api/PlayerCheer/v1/SetSelectedCheer`, stored on `reputation`) come
off the row exactly as the DTO serves them. This server once overrode both per frame to
"play" the cheer; no reference does, and it played nothing.
- A cheer is a thing that happens in FRONT of people, so the `ReputationUpdate` naming the
cheered player goes to everyone in the room instance, not just the two players. The
cheered player gets it durably (their counters moved); the rest of the room gets it
ephemerally. The audience comes from the giver's live `presence` row, NOT the body's
`RoomId`, which is accepted and unused. Neither `RoomId` nor `Anonymous` is stored.
- The cheer's reply is `{ Success, Message }` — PascalCase, with `Message` NULL on success.
That is NOT the lowercase `{ success, error: "" }` envelope the reports and warnings use;
the two live side by side in the same worker and must not be unified.
- A Message's `Data` (every `MessageReceived` frame) is a STRING on the wire, so a payload
with structure to it goes in ESCAPED — `"Data": "{\"PlayerId\":\"205\"}"`, never a nested
object. An object there does not degrade: the client's decoder rejects it outright
(`expected:'String Begin Token', actual:'{'`) and loses the whole notification, not just
the field. Bites a chat message's `Contents` the same way.
- The vote-to-kick frame (`api`: `POST /api/PlayerReporting/v3/voteToKick`) carries the player
being VOTED ON as the Message's `FromPlayerId` — not the caller, whom nothing on the frame
names — because the client raises its prompt about whoever that field names. Sending the
voter asks the room to kick the player who called the vote. Its `Data` is the posted
`Reason` as PLAIN TEXT (`Inactive in games (AFK)`), which is the text the prompt shows: it
once carried an escaped `{ PlayerId, Response, GameSessionId }`, and players saw that JSON
printed where the reason belongs. `Data` is still a string on the wire — that part of the
rule above holds; a reason simply needs no escaping to satisfy it.
- Leaderboard `Rank` (`leaderboard`: `GetRanks`, `GetNearbyScores`, `GetPlayerRank`) is
0-BASED — the client adds one before it draws, so a `Rank` of 1 shows in game as second
place and the top of a board must be 0. Its own slice says the same: it asks for the first
ten rows as `RankStart` 0, `RankEnd` 9, both inclusive, so reading them as 1-based also
serves nine rows starting at the runner-up. The unranked sentinel stays a big number
(99999) precisely because 0 is now a real rank, first place.
- A custom avatar item (`api`: `custom_avatar_item`, every `/api/customAvatarItems/*` read) is ONE
record for two kinds of thing. A player-made shirt has `BaseAvatarItemId`/`BaseAvatarItemColor`
and a design PNG (`DesignFilename`, `ThumbnailImageFilename`) and an empty `CurrentSaves`; a
FIRST-PARTY item (imported from the official export, Coach-authored, its own `OutfitType`) has
those four NULL and is rendered from `CurrentSaves` — one built Unity assetbundle per
`BodyType`, which the client picks by the wearer's body. The row is the record as JSON, so an
import is the export verbatim except that `CreatorAccountId` is forced to 1, the Coach — which
is what files it as stock content and whom a sale pays — and each save's `ThumbnailFileName`
is put under `avatar/`, where this server serves it from (`just cai-load` loads it;
`apps/api/scripts/import-custom-avatar-items.ts` writes a migration; re-importing an id
replaces it). The export's `Price` is mostly 0, and a purchase charges the record's `Price`,
so first-party prices are written into the export from the storefront dump
(`apps/econ/static/db/Watch_EnumValue_3.json`, joined on
`AvatarItemInfo.DownloadableAvatarItemId`) by `apps/api/scripts/price-custom-avatar-items.ts`
before loading. Serve both through one shape: the shirt carries
empty `CurrentSaves`/`Tags` and null `CustomBadgeMetadata`, never a missing key. The saves
name their assetbundles and thumbnails by bare filename; storing the record does nothing
about serving those files.
- An assetbundle is built PER UNITY TARGET, and the client names the one it wants as
`unityAssetTarget` on every custom-avatar-item read (0 PC/Windows, 2 Android/Oculus — the
rest unobserved; a query param on the GETs, a FORM field on `POST …/v1/bulk`). The row stores
the bare PC names; a caller asking for 2 is served `UnityAsset`/`UnityAsset2` under `quest/`
(`toQuestCustomAvatarItem`), fetched from the cdn as `/avatar/quest/<name>.assetbundle`.
Every read a Quest renders from has to make the switch — `api` search and bulk, AND `econ`
`GET /econ/customAvatarItems/v1/owned`, which the client reads FIRST and renders worn items
from: with only `api` switched the Quest kept downloading PC bundles. The load BLANKS each
save's `UnityAssetHash`/`UnityAsset2Hash` (`""`, null stays null): the export's are the PC
builds' hashes, one stored save serves both targets, and the client refuses a download that
fails the hash it was given but skips the check on an empty one.
- Buying a custom avatar item goes through the ordinary bag (`econ`: `POST /api/items/bulkpurchase`)
as a line whose `ItemPurchaseMethodId` is `{ Type: 1, Guid: <CustomAvatarItemId> }`, beside
the `Type: 0, NumberId` catalog lines. It is a SALE between players: the price leaves the buyer
in the bag's one debit and is credited to the item's `CreatorAccountId` (pushed to them as a
`StorefrontBalanceUpdate` carrying their resulting total), ownership is a row in
`inventory_custom`, and the entry's `Data.CustomAvatarItem` is the whole item record. The item
still comes in a gift box, like any purchase: its `GiftPackage` names it by
`CustomAvatarItemId` with `AvatarItemDesc`/`EquipmentPrefabName`/etc. empty — without a box
the purchase shows the player nothing. `BypassGiftPackages` skips it as it does any line.
A custom line may carry a `Gift` block: ownership and the box go to `ToPlayerId` (announced
with `GiftPackageReceivedImmediate`), the buyer still pays and the creator is still paid, and
the owned/creator refusals are checked against the RECEIVER — so a creator may gift their own. `GET /econ/customAvatarItems/v1/owned` is that table
joined to the item record PLUS everything the caller created (drafts included): a creator
owns their own through `CreatorAccountId`, and the bag refuses to sell it to them.
- A chat report (`api`: `POST /api/chatreport/createChatReport`) is form-encoded and sends
`ReportCategory` as an enum NAME (`Discriminatory`), where every other report sends the
number. It names no player: the reported one is the message's sender, read from `message`.
The name is mapped onto the numeric `KickReportCategory` the `report` table stores
(`chatReportCategory`); only `Discriminatory` has been observed, so an unmapped name is
filed as 0 with the name kept in the details — extend the alias table as more are seen.
The reply is the `{ success, error: "" }` envelope the other reports use, which is an
ASSUMPTION here: what the client does with this response has not been observed.
- A friend request is SHOWN to the target as a Message of type 4 `FriendInvite` (`api`:
`/api/relationships/v2/sendfriendrequest`), and an acceptance to the requester as type 40
`FriendRequestAccepted` (`acceptfriendrequest`, and the crossing-request auto-accept),
each stored and pushed as `MessageReceived`. The `RelationshipChanged` frames only
refresh relationship state: sent alone, the request "worked" server-side and the target
saw nothing. Same pattern as the cheer.
- A gift box's `AvatarItemType` (`econ`: the stored box in `GET /api/avatar/v2/gifts`, `buyItem`'s
`BalanceUpdates[].Data`, `bulkpurchase`'s `GiftPackage`, and the `GiftPackageReceived*` frame;
`www`'s staff boxes; `api`'s `gifts/generate`) must be NULL for anything that is not an avatar
item — a consumable, a skin, a box, tokens, XP — and a NUMBER (the type, or 0) when the box holds
one, whether a baked item by `AvatarItemDesc` or a custom item by `CustomAvatarItemId`. The
client routes the box on that field before it reads what else the box names, so a 0 sends it
looking for avatar item type 0 and it fails with "can't find avatar item". The 2025 store lists
every non-avatar drop with `AvatarItemType: null` for this reason; `boxAvatarItemType` applies
it on every surface, so don't coalesce it with `?? 0` anywhere a box is built. This holds for
every worker that mints a box, not only `econ`.
- 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
+1 -71
View File
@@ -51,76 +51,6 @@ bun runx admin grant-developer --account 1 --revoke
bun runx admin grant-moderator --username alice --remote
```
### `reload-plus` — credit every Plus subscriber's tokens
Also exposed directly as `just reload-plus`.
```sh
just reload-plus 1000 --dry-run # list subscribers and their balances, change nothing
just reload-plus 1000 --remote # +1000 RecCenterTokens to each subscriber, production
```
Adds `<amount>` RecCenterTokens (currency type 2) to the `balance` row of every account
whose `hasPlus` flag is set — the flag `grant-plus` and the website's Discord claim write.
Subscribers are found through the indexed `has_plus` column on `account` (auth migration
0009), so the reload is one statement however many accounts there are. Nothing schedules
it: run it when the subscription's tokens are due, and note that running it twice credits
twice.
A subscriber who has never loaded the game has no balance row yet. They get one holding
their signup grant **plus** the reload, so the grant is not lost — the grant amount is
`RECFLARE_STARTING_TOKENS` from the environment or `.env`, falling back to econ's default
of 10000. Prints every credited account with its resulting balance.
This command targets the `balance` table, so it runs under `apps/econ`'s wrangler config;
with `--local` that is econ's dev D1 state, which needs both `balance` and a migrated
`account` table in it.
### `cai-load` — load the first-party custom avatar items
```sh
bun runx admin cai-load # apps/econ/static/db/2025-1-cai.json → local
bun runx admin cai-load --remote
bun runx admin cai-load --file path/to/export.json --dry-run
just cai-load --remote # the same, as a recipe
```
Loads an export of `CustomAvatarItem` records (a JSON array, a `{ Results }` page, or one
record) into the `custom_avatar_item` table, each stored as the row's JSON with its
`CreatorAccountId` forced to 1, the Coach account, whatever the export said: that is what
files it as stock content (the storefront tab searches by creator) and what a purchase
pays. Each save's `ThumbnailFileName` is put under `avatar/`, where this server serves
those images from (a name already prefixed is left alone). It MERGES: an id already in the table has its row replaced, and nothing is deleted, so the
players' own shirts, which share the table, are untouched. Re-running is safe. The load is
verified by counting rows and probing ids from across the export; it fails loudly rather
than report a partial load. `--dry-run` reads and validates the export and prints the
statement count without writing.
The saves in an export name their assetbundles by bare filename; the load stores the
names only.
A record's `Price` is what a purchase charges, and the official export mostly says 0. The
prices come from a storefront dump instead, `apps/econ/static/db/Watch_EnumValue_3.json`
(a `{ StoreItems }` page with price and rarity, joined on
`GiftDrop.AvatarItemInfo.DownloadableAvatarItemId` = `CustomAvatarItemId`). Before loading
a fresh export, write those prices into it:
```sh
bun apps/api/scripts/price-custom-avatar-items.ts \
apps/econ/static/db/2025-1-cai.json apps/econ/static/db/Watch_EnumValue_3.json
just cai-load --remote
```
The script rewrites the export in place with the base token price (`CurrencyType` 2),
ignoring any sale. It warns about every price it changes that wasn't 0 before. The
storefront only prices items already in the export; nothing is added from it. An item the
storefront doesn't list keeps its old price, unless that price is 0, which would make it
free to buy. Those items (the Cryptid Creek event rewards and the Evergrown Hip Pack) get the
catalog's rarity-50 price, `PRICE_BY_RARITY[50]` in `apps/econ/src/catalog-load.ts`. The
script names every item the storefront doesn't list. The
committed `2025-1-cai.json` is already priced. Rarity isn't carried over, since the record
has no field for it.
### `lookup` — print an account
```sh
@@ -135,7 +65,7 @@ the account has a password, the developer role, and the moderator role.
### Selecting an account
Every command except `reload-plus` and `cai-load` targets exactly one account, by **either**:
Every command targets exactly one account, by **either**:
- `--account <id>` — numeric account id
- `--username <name>` — username (case-insensitive)
+14 -58
View File
@@ -10,7 +10,7 @@ These are the instructions for deploying the RecFlare infrastructure to Cloudfla
the real thing — services scale,
fail, and deploy independently — instead of collapsing everything into a single
giant server.
- **It's free/cheap to run a lot of services.**
- **It's free/cheap to run a lot of service.**
Cloudflare Workers' free tier is keyed
to usage, not to the number of Workers — so whether you deploy 1 service or all
36, the baseline cost is the same. You only start paying once usage crosses the
@@ -43,16 +43,13 @@ services but would require small code changes.
## Prerequisites
**You must have all these requirements or RecFlare will not work!**
**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
- **For 2023:** A Photon Cloud Realtime and Voice account OR a custom Photon server (Luxon, OpenPhoton)
- **For 2025:** A Photon Cloud Realtime account AND a Tachyon server, which is a custom fork of Dissonance voice that Rec Room developed. See https://discord.com/channels/1524485206550118460/1544055485336915979
If you don't provide a Tachyon server address during deployment, your server will still work but without voice.
- 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
Rec Room client is very chatty. Frequent testing may exhaust that quota. The $5/month
@@ -62,8 +59,6 @@ See https://developers.cloudflare.com/workers/platform/pricing/#workers
## Getting Started
**WSL** is preferred for deploying if you are on Windows. Only Linux has been fully tested.
**Install dependencies:**
We use [Just](https://github.com/casey/just) for convenience. This will install all dependencies across the microservices.
@@ -72,7 +67,7 @@ We use [Just](https://github.com/casey/just) for convenience. This will install
just install
```
You do not have to use `just` but then you will have to run things manually with `pnpm`/`bun`.
You do not have to use `just` but you will have to run things manually with `pnpm`/`bun`.
**Configure your custom domain:**
@@ -84,8 +79,6 @@ cp .env.example .env
Edit `.env` and set `RECFLARE_DOMAIN` to your domain (or declare it with `export RECFLARE_DOMAIN=rec.example.com`)
Go through the rest of the file and set the variables as needed.
(Optional) - per-service subdomain overrides come from `RECFLARE_SUBDOMAINS`, a JSON
object keyed by each service's default subdomain (see `SERVICES.md`), e.g.
`'{"playersettings":"settings"}'`. A single entry both decides which host `just deploy`
@@ -147,42 +140,6 @@ just migrate # migrate every worker that owns migrations
just migrate -F rooms # or scope to one worker
```
### Using Turso instead of D1
The workers can run on a [Turso](https://turso.tech) (libSQL) database instead of D1,
with nothing else changing: same schema, same migrations, same workers. D1 stays the
default and stays bound, so this is a switch, not a migration of the code. The
switch is two lines in `.env`:
```bash
turso db create recflare
turso db show recflare --url # -> RECFLARE_LIBSQL_DB_URL=libsql://...
turso db tokens create recflare # -> RECFLARE_LIBSQL_DB_AUTH_TOKEN=...
```
With both set, `just migrate` applies the workers' migrations to that database (with
the same per-worker history tables wrangler keeps on D1), `just deploy` hands every
worker the URL as a var and the token as an encrypted worker secret, and the
`--remote` operator commands (`just admin`, `just catalog`) act on it. Local dev and
the tests keep using the local D1 regardless.
To carry an existing D1 database across, export it and load the dump before the
first `just migrate` — the dump includes the migration history, so nothing is
re-applied:
```bash
wrangler d1 export recflare --remote --output recflare.sql # from any D1-backed app dir
turso db shell recflare < recflare.sql
```
To go back to D1, unset (or comment out) the two lines and run `just deploy`. The
token secret is left on the workers and ignored without a URL; remove it with
`wrangler secret delete LIBSQL_DB_AUTH_TOKEN` per worker if you want it gone.
> ⚠️ The URL is one switch for the whole deployment. Deploy every worker after
> changing it: a worker left on the old database would be reading and writing
> different data from the rest.
### R2 and Durable Objects
You only have to create the buckets:
@@ -236,17 +193,16 @@ the same `.env` you already created. `.env.example` carries each one commented o
its built-in default: copy the lines you want to change into your `.env`, uncomment them,
edit the value, then re-deploy the worker that reads them.
| `.env` variable | Read by | Default | What it does |
| --------------------------------------- | --------- | ---------- | -------------------------------------------------------------- |
| `RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID` | `auth` | `3` | Accounts one Steam-verified identity may create. `0` disables. |
| `RECFLARE_MAX_ACCOUNTS_PER_IP` | `auth` | `3` | Accounts one signup IP may create. `0` disables. |
| `RECFLARE_STARTING_TOKENS` | `econ` | `10000` | RecCenterTokens a new player is granted. |
| `RECFLARE_MAX_UPLOAD_BYTES` | `storage` | `67108864` | Maximum binary upload size; larger files receive HTTP 413. |
| `RECFLARE_ROOM_REDIRECTS` | `match` | unset | Rooms to switch out on matchmake, e.g. `2=MyHub`. |
| `RECFLARE_PHOTON_REALTIME_APP_ID` | `match` | empty | Your Photon Realtime app id. No app ships with recflare. |
| `RECFLARE_PHOTON_VOICE_APP_ID` | `match` | empty | Your Photon Voice app id. |
| `RECFLARE_PHOTON_CHAT_APP_ID` | `match` | empty | Your Photon Chat app id. |
| `RECFLARE_PHOTON_REGION` | `match` | `us` | Photon region every session is pinned to. |
| `.env` variable | Read by | Default | What it does |
| --------------------------------------- | ------- | ------- | -------------------------------------------------------------- |
| `RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID` | `auth` | `3` | Accounts one Steam-verified identity may create. `0` disables. |
| `RECFLARE_MAX_ACCOUNTS_PER_IP` | `auth` | `3` | Accounts one signup IP may create. `0` disables. |
| `RECFLARE_STARTING_TOKENS` | `econ` | `10000` | RecCenterTokens a new player is granted. |
| `RECFLARE_ROOM_REDIRECTS` | `match` | unset | Rooms to switch out on matchmake, e.g. `2=MyHub`. |
| `RECFLARE_PHOTON_REALTIME_APP_ID` | `match` | empty | Your Photon Realtime app id. No app ships with recflare. |
| `RECFLARE_PHOTON_VOICE_APP_ID` | `match` | empty | Your Photon Voice app id. |
| `RECFLARE_PHOTON_CHAT_APP_ID` | `match` | empty | Your Photon Chat app id. |
| `RECFLARE_PHOTON_REGION` | `match` | `us` | Photon region every session is pinned to. |
Then deploy just the worker that reads it:
+1 -19
View File
@@ -109,28 +109,10 @@ update *args:
runx *args:
bun runx "$@"
# Admin account tools (set-password, clear-password, grant-developer, grant-plus, lookup).
# Admin account tools (set-password, clear-password, grant-developer, lookup).
# Run `just admin --help` for usage and examples. See CLI.md.
[group('4. utility')]
[positional-arguments]
[no-cd]
admin *args:
bun runx admin "$@"
# Credit every Rec Room Plus subscriber's RecCenterTokens balance by <amount>. Defaults to
# --local; pass --remote for production, --dry-run to only list who'd be credited. See CLI.md.
[group('4. utility')]
[positional-arguments]
[no-cd]
reload-plus *args:
bun runx admin reload-plus "$@"
# Load the first-party custom avatar items (apps/econ/static/db/2025-1-cai.json by default)
# into the custom_avatar_item table. Merges: existing ids are replaced, nothing is deleted.
# Defaults to --local; pass --remote for production, --dry-run to only validate. Prices come
# from the storefront dump via apps/api/scripts/price-custom-avatar-items.ts. See CLI.md.
[group('4. utility')]
[positional-arguments]
[no-cd]
cai-load *args:
bun runx admin cai-load "$@"
+16 -34
View File
@@ -2,7 +2,7 @@
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
![badge](https://github.com/djdevin/recflare/actions/workflows/test.yml/badge.svg?branch=main)
![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
@@ -36,24 +36,15 @@ resulting in bugs getting fixed faster. I hope.
## Game client
See [RecFlare Client](https://github.com/djdevin/recflare-client) for the official RecFlare build.
See [RecFlare Client](https://github.com/djdevin/recflare-client)
These game builds are supported:
| Build | Manifest | Support |
| ------------- | --------------------- | --------------------------------------------------------------------------------------- |
| `20230414` | `7859140924515540835` | **Default**, official — the 2023 build the rest of the stack targets |
| `20250718.01` | `1151455856673601091` | **Beta**, official — use the [patch-2025](https://github.com/recflare/patch-2025) patch |
| `20250424.01` | | Alpha |
| `20231207` | | Alpha |
| `20230616` | | Alpha |
Alpha builds get past the version check and largely work, but nothing else in the
stack targets them, so expect protocol differences. Other client or game versions
may expect different endpoints and response shapes and are not supported.
RecFlare is compatible with the
[RecNet Plugin](https://github.com/djdevin/recnet-plugin) and the build of Rec
Room with manifest `7859140924515540835` (around 2023). Other client or game versions may expect
different endpoints and response shapes and are not supported.
Generally speaking any client that effectively rewrites the nameserver with the
right mods (see [RecNet Plugin](https://github.com/djdevin/recnet-plugin), [2025 patch](https://github.com/recflare/patch-2025)) can be used with this server.
right mods can be used with this server.
## Services
@@ -67,11 +58,9 @@ Want to run it yourself? See [DEPLOYING.md](DEPLOYING.md)
### What year is this for?
Most 2023 and 2025 clients. A few older builds work at
alpha quality — see the table in the "Game client" section above. Other clients may not
work.
This works with 2023 clients. It has been tested with manifest `7859140924515540835`. Other clients may not work.
See the "Game client" section above for instructions on how to modify a client to connect to this server.
See the "Client" section above for instructions on how to modify a client to connect to this server.
### Can I run this locally on my PC?
@@ -81,13 +70,11 @@ See "Run the development microservices" above. It may be possible later as Wrang
### Can I use this to make my own server?
Yes, that's the point. See [DEPLOYING.md](DEPLOYING.md)
Yes, that's the point. Just set your custom domain and deploy it.
### Is there an admin panel?
Yes, the server comes bundled with a simple web panel with more functionality being added.
There are also [CLI tools](CLI.md) you can use for admin tasks like granting roles.
Not yet. But there could be.
### Can I copy this project and modify it?
@@ -102,20 +89,15 @@ keeps those in sync: `pnpm` workspaces share dependencies, `@repo/` packages
share code, Turborepo runs build/test/lint with a single cached task graph, and
cross-service changes land in one atomic commit.
This makes it easier to deploy the whole stack at once or a smaller selection
of microservices to avoid downtime events.
## Credits
I started this soon after the official servers shut down when I saw there were
only monolithic servers usually running on one server. I could only see the
I started this after the official servers shut down, so I could only see the
request shapes coming from the game client. I used many different projects as
resources to get response shapes, logic examples, enums, etc. They all had
missing pieces. Again, another reason to come together on one project and
stop gatekeeping.
- [CannedNet](https://github.com/CannedNet/CannedNet)
- [DorkNet](https://github.com/DorkSquadRR/DorkNet)
- jordanparki7's postman collection of RecNet APIs which is gone for some reason.
- Leaked C# projects I won't list (for response shapes)
- Claude and my [wire shapes skill](https://github.com/recflare/skills/blob/main/.claude/skills/wire-shapes/SKILL.md)
Unfortunately, they were all leaked code except for
[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.
+1 -1
View File
@@ -40,7 +40,7 @@ apex/`ns` host and isn't listed within it. Each implemented worker has its own
| GameLogs | `gamelogs` | — | Not yet implemented |
| Geo | `geo` | — | Not yet implemented |
| Images | `img` | `img` | Image storage & signed delivery (R2) |
| Leaderboard | `leaderboard` | `leaderboard` | Per-room stat leaderboards, one board per stat channel (D1) |
| Leaderboard | `leaderboard` | `leaderboard` | Stub — deploys and answers, no leaderboard endpoints yet |
| Link | `link` | `link` | Stub — deploys and answers, no link endpoints yet |
| Lists | `lists` | `lists` | Curated & algorithmic discovery lists (canned — nothing ranks yet) |
| Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) |
+2 -3
View File
@@ -15,13 +15,12 @@
"test": "run-vitest"
},
"dependencies": {
"@2toad/profanity": "3.3.0",
"@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.13.5",
"hono": "4.12.27",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -33,6 +32,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.128.0"
"wrangler": "4.105.0"
}
}
+44 -144
View File
@@ -4,14 +4,12 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
createAccount,
DEFAULT_USERNAME_CHANGES,
defaultAccount,
getAccount,
getAccountByUsername,
getAccountsByIds,
searchAccounts,
updateAccount,
withDatabase,
} from '@repo/domain'
import {
logger,
@@ -30,12 +28,10 @@ import {
BannerImageRequest,
BioRequest,
BioResponse,
BulkIdsRequest,
CreateAccountRequest,
CreateAccountResult,
DisplayNameRequest,
EmailRequest,
EmojiRequest,
form,
HealthResponse,
IdentityFlagsRequest,
@@ -49,14 +45,11 @@ import {
SuccessResponse,
UsernameRequest,
UsernameResult,
WhitelistedEmojis,
} from './openapi'
import { resolveWhitelistedEmoji, WHITELISTED_EMOJIS } from './whitelisted-emojis'
import type { Context } from 'hono'
import type { DescribeRouteOptions } from 'hono-openapi'
import type { Account } from '@repo/domain'
import type { App, Env } from './context'
import type { App } from './context'
/**
* Account reads/writes are backed by the shared `accounts` table in D1 (schema
@@ -82,6 +75,9 @@ function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/** Username changes a fresh account starts with (until one has been consumed). */
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
@@ -103,69 +99,6 @@ async function formField(c: Context<App>, name: string): Promise<string> {
return typeof value === 'string' ? value : ''
}
/**
* Read a REPEATED string field from a form-urlencoded / multipart body (`id=1&id=2`).
* `parseBody` keeps only the last value for a duplicated key unless it's told to collect
* them all, which is the whole point here.
*/
async function formFields(c: Context<App>, name: string): Promise<string[]> {
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const value = body[name]
const values = Array.isArray(value) ? value : [value]
return values.filter((v): v is string => typeof v === 'string')
}
/**
* OpenAPI spec for the two `/account/bulk` registrations, which differ only in where the
* ids ride.
*/
function bulkRouteSpec(source: 'query' | 'body'): DescribeRouteOptions {
return {
tags: ['Lookup'],
summary: 'Look up many accounts by id',
description: [
'Accepts repeated `id` values (query params and/or a form-urlencoded body) and',
'comma-separated lists. Every requested id appears in the response — ids with no',
'stored row get a synthesized default.',
].join(' '),
...(source === 'query'
? {
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' } },
},
],
}
: { requestBody: form(BulkIdsRequest, 'Repeated `id` fields') }),
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
}
}
/**
* Look up many accounts at once. Ids come from repeated `id` query params AND, on the
* POST form, repeated `id` body fields; either may carry a comma-separated list. Deduped
* (first occurrence wins) so a repeated id can't double a row or waste a bound parameter.
*/
async function bulkAccounts(c: Context<App>) {
const raw = [...(c.req.queries('id') ?? []), ...(await formFields(c, 'id'))]
const ids = [
...new Set(
raw
.flatMap((v) => v.split(','))
.map((v) => Number.parseInt(v.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))))
}
/**
* Project a stored account into the public account DTO — the client's camelCase
* shape, excluding private fields like `email` (surfaced only by /account/me).
@@ -176,8 +109,8 @@ function toAccountDto(account: Account) {
username: account.username,
displayName: account.displayName,
profileImage: account.profileImage,
// Rows stored before these fields existed have neither key — always emit them as
// "" rather than letting them go missing.
// Nothing writes these yet, and rows stored before they existed have neither
// key — always emit them as "" rather than letting them go missing.
bannerImage: account.bannerImage ?? '',
displayEmoji: account.displayEmoji ?? '',
isJunior: account.isJunior,
@@ -277,24 +210,6 @@ const app = new Hono<App>()
(c) => c.json({ service: 'accounts', status: 'ok' })
)
// ---- Emoji config --------------------------------------------------------
// The picker the client fills its displayEmoji grid from. A BARE array — no
// `{ success, error, value }` envelope and no wrapper object; the client parses the
// response body itself as the list.
.get(
'/emojiConfig/whitelistedEmojis',
describeRoute({
tags: ['Config'],
summary: 'Emoji a player may use as their displayEmoji',
description: [
'A bare JSON array of emoji, in the order the client draws them. Static — not',
'auth-gated, and identical for every player.',
].join(' '),
responses: { 200: json(WhitelistedEmojis, 'The whitelisted emoji, in picker order') },
}),
(c) => c.json(WHITELISTED_EMOJIS)
)
// ---- Self account --------------------------------------------------------
.get(
'/account/me',
@@ -350,12 +265,40 @@ const app = new Hono<App>()
// ---- Bulk / single lookup ------------------------------------------------
// Register the static `bulk` path before the `/account/:id` param route.
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
// The 2023 client asks for its friends list as a POST with the ids in a
// form-urlencoded body — the same `id=1&id=2&…` it would put in a query string,
// moved off the URL because a few hundred friends overflow it. GET is the same
// lookup and stays for callers (and docs) that prefer it.
.post('/account/bulk', describeRoute(bulkRouteSpec('body')), bulkAccounts)
.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',
@@ -460,7 +403,7 @@ const app = new Hono<App>()
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ accountId: id, disallowInAppPurchases: true })
return c.json({ accountId: id, disallowInAppPurchases: false })
}
)
@@ -505,9 +448,7 @@ const app = new Hono<App>()
security: AUTHED,
responses: {
200: json(SuccessResponse, 'Updated'),
400: {
description: 'Empty, over 15 characters, non-alphanumeric, or profane (empty body)',
},
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -534,8 +475,7 @@ const app = new Hono<App>()
tags: ['Profile'],
summary: 'Change username',
description: [
'Letters and digits only, at most 50 characters, and free of profanity (the same',
'word list as `api`’s `POST /api/sanitize/v1/isPure`). Rejects a name taken by another',
'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).',
@@ -722,46 +662,6 @@ const app = new Hono<App>()
}
)
// The emoji shown beside the player's display name. The body is a single field —
// `displayEmoji=%F0%9F%A4%AA` — and the value is checked against the same list
// `GET /emojiConfig/whitelistedEmojis` serves, then stored in that list's CANONICAL
// form: `displayEmoji` is compared as a plain string, and the client highlights the
// current pick by matching it against the picker list it fetched, so a stored value
// that differs only by a variation selector highlights nothing.
//
// Broadcast like every other public-DTO mutation here — the emoji rides along in the
// AccountUpdate payload, so it redraws beside the name without a refetch.
.put(
'/account/me/emoji',
describeRoute({
tags: ['Profile'],
summary: 'Set display emoji',
description: [
'Persists the emoji shown beside the display name and broadcasts it in the',
'AccountUpdate payload. The value must be one the whitelist serves; an empty value',
'clears the pick.',
].join(' '),
security: AUTHED,
requestBody: form(EmojiRequest, 'A whitelisted emoji, or "" to clear'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Not a whitelisted emoji (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const submitted = (await formField(c, 'displayEmoji')).trim()
// An empty value clears the pick; anything else has to be on the list.
const displayEmoji = submitted === '' ? '' : resolveWhitelistedEmoji(submitted)
if (displayEmoji === null) return c.body(null, 400)
const account = await updateAccount(c.env.DB, id, { displayEmoji })
await pushAccountUpdate(c, account)
return c.json({ success: true })
}
)
// The profile banner — the wide image behind the header on a player's profile. Same
// shape as the avatar below: the body names an image the player has already uploaded
// (the client posts one of their own photos, `sharecamera/<date>/<uuid>.jpg`), so this
@@ -857,4 +757,4 @@ app.get(
)
)
export default withDatabase<Env>(app)
export default app
+5 -57
View File
@@ -9,11 +9,6 @@ import {
nameRejection,
} from '@repo/domain'
// The profanity filter behind `api`'s `POST /api/sanitize/v1/isPure`, imported rather
// than copied so a name is held to the very same word list every other player-typed
// string is.
import { nameContainsSwears } from '../../api/src/sanitize'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
@@ -75,7 +70,7 @@ export const AccountDto = z.object({
bannerImage: z.string().describe('Profile banner key — always "" (nothing sets it yet)'),
displayEmoji: z
.string()
.describe('Emoji beside the display name, set by PUT /account/me/emoji; "" when unset'),
.describe('Emoji beside the display name — always "" (nothing sets it yet)'),
isJunior: z.boolean(),
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
personalPronouns: z.int().describe('Pronoun flags bitmask'),
@@ -139,15 +134,6 @@ export const ParentalControl = z.object({ accountId: z.int(), disallowInAppPurch
*/
export const PrivacySettings = z.object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() })
/**
* `GET /emojiConfig/whitelistedEmojis` response — a BARE array of emoji, no envelope
* and no object around it (see `WHITELISTED_EMOJIS`).
*/
export const WhitelistedEmojis = z
.string()
.array()
.describe('The emoji a player may set as their displayEmoji, in picker order')
/** Root health check. */
export const HealthResponse = z.object({ service: z.literal('accounts'), status: z.literal('ok') })
@@ -172,54 +158,26 @@ export const CreateAccountRequest = z.object({
* 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, and then refuses a name
* with a swear in it — the same filter, and the same word list, as `api`'s
* `POST /api/sanitize/v1/isPure`.
*
* Shape first, profanity second: a name that already broke the charset rule gets the one
* sentence that explains it rather than two, and the swear check never sees the
* punctuation the charset rule has already refused.
*/
/** 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 })
} else if (nameContainsSwears(value)) {
// Deliberately vague about WHICH word: naming it back to the player prints the
// swear in the UI, and the player knows what they typed.
ctx.addIssue({ code: 'custom', message: `Your ${label} can't contain that word.` })
}
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, no profanity. Empty or invalid is rejected (400)'
),
.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, no profanity. Must be unique and changes must remain'
),
})
/**
* `POST /account/bulk` form body. `id` repeats once per requested account (the client
* sends its whole friends list this way); each value may also be a comma-separated list.
*/
export const BulkIdsRequest = z.object({
id: z
.union([z.string(), z.string().array()])
.describe('Repeatable; each value may be a comma-separated list of ids'),
.describe('Trimmed; letters and digits only, max 50. Must be unique and changes must remain'),
})
export const EmailRequest = z.object({
@@ -249,16 +207,6 @@ export const BioRequest = z.object({
bio: z.string().refine(isValidBio).describe('Free text, max 255; empty is allowed'),
})
/**
* `PUT /account/me/emoji` form body. The value must be one of the emoji served by
* `GET /emojiConfig/whitelistedEmojis`; an empty value clears the current pick. Checked
* in the handler rather than here, because the check also CANONICALIZES the value
* (see `resolveWhitelistedEmoji`) and a schema can only accept or reject it.
*/
export const EmojiRequest = z.object({
displayEmoji: z.string().describe('A whitelisted emoji, or "" to clear'),
})
export const ProfileImageRequest = z.object({
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
})
+9 -239
View File
@@ -6,8 +6,6 @@ import '../../accounts.app'
import { SCHEMA_DDL } from '@repo/domain'
import { WHITELISTED_EMOJIS } from '../../whitelisted-emojis'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
@@ -97,48 +95,6 @@ describe('public endpoints', () => {
expect(accounts[1].username).toBe('Player2')
})
test('POST /account/bulk reads the ids from a form body', async () => {
// The 2023 client asks for its friends list as a POST — the ids are in a
// form-urlencoded body, not the query string. GET-only left it a 404.
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: 'id=1&id=2&id=3',
})
expect(res.status).toBe(200)
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
expect(accounts.map((a) => a.accountId)).toEqual([1, 2, 3])
expect(accounts[0].username).toBe('Coach')
})
test('POST /account/bulk answers more ids than D1 will bind at once', async () => {
// D1 caps a statement at 100 bound parameters; a real friends list runs past that,
// and an unchunked `IN (…)` 500s. Requesting a stored id in the LAST chunk proves
// the later statements ran, not just the first.
const ids = [...Array.from({ length: 250 }, (_, i) => 1000 + i), 1]
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: ids.map((id) => `id=${id}`).join('&'),
})
expect(res.status).toBe(200)
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
expect(accounts.map((a) => a.accountId)).toEqual(ids)
expect(accounts.at(-1)?.username).toBe('Coach')
})
test('POST /account/bulk returns one entry per id, deduped', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
// Negative ids are real in the client's list and synthesize like any other.
body: 'id=1&id=-1&id=1',
})
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
expect(accounts.map((a) => a.accountId)).toEqual([1, -1])
expect(accounts[1].username).toBe('Player-1')
})
test('GET /account/search prefix-matches usernames, returns public DTOs', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/search?name=coa`)
expect(res.status).toBe(200)
@@ -193,15 +149,6 @@ describe('auth-gated endpoints', () => {
expect(res.status).toBe(401)
})
test('GET /account/me rejects a signed token with a non-canonical account subject', async () => {
for (const sub of ['42junk', '42.5', '042', '-42', '9007199254740992']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me`, {
headers: await bearer(sub),
})
expect(res.status, sub).toBe(401)
}
})
test('GET /account/me returns the self account with a valid token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer() })
expect(res.status).toBe(200)
@@ -213,11 +160,11 @@ describe('auth-gated endpoints', () => {
username: 'Player42',
personalPronouns: 0,
identityFlags: 0,
availableUsernameChanges: 3,
availableUsernameChanges: 1,
// An unset email is "", not null — the client reads it as a string, and the
// hub frame this DTO also rides drops null values outright.
email: '',
// Unset on a fresh account, but the key has to be present — the client reads
// Nothing sets these yet, but the key has to be present — the client reads
// both off the account DTO.
bannerImage: '',
displayEmoji: '',
@@ -234,7 +181,7 @@ describe('auth-gated endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/parentalcontrol/me`, {
headers: await bearer(),
})
expect(await res.json()).toEqual({ accountId: 42, disallowInAppPurchases: true })
expect(await res.json()).toEqual({ accountId: 42, disallowInAppPurchases: false })
})
test('GET /accountprivacysettings/:id echoes the id with the privacy flags', async () => {
@@ -267,32 +214,6 @@ describe('auth-gated endpoints', () => {
expect(((await me.json()) as { displayName: string }).displayName).toBe('laskdjfasdlfkj')
})
test('PUT /account/me/displayname 400s on a name with a swear in it', async () => {
const headers = {
...(await bearer('895')),
'Content-Type': 'application/x-www-form-urlencoded',
}
// A name carries no spaces, so the filter has to find the swear at the seam the
// player typed instead of one.
for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
expect(res.status).toBe(400)
}
// And the words that merely contain one still get through — refusing these is worse
// than missing a swear, because the player can't see why.
for (const displayName of ['Scunthorpe', 'ClassicCar', 'Cumberland']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
expect(res.status).toBe(200)
}
})
test('PUT /account/me/username 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'whoever' }),
@@ -314,31 +235,7 @@ describe('auth-gated endpoints', () => {
expect(body.value).toBe('')
})
test('PUT /account/me/username refuses a swear without spending a change', async () => {
const headers = {
...(await bearer('894')),
'Content-Type': 'application/x-www-form-urlencoded',
}
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'ShitLord' }),
headers,
})
expect(res.status).toBe(200)
const body = (await res.json()) as { success: boolean; error: string; value: string }
expect(body.success).toBe(false)
// Vague on purpose — the message must not print the swear back at the player.
expect(body.error).toMatch(/can't contain that word/i)
expect(body.value).toBe('')
// The schema runs before the handler, so a refused name costs none of the account's
// rationed changes.
const me = (await (
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('894') })
).json()) as { username: string; availableUsernameChanges: number }
expect(me.availableUsernameChanges).toBe(3)
})
test('PUT /account/me/username allows three changes, decrements the counter, then blocks', async () => {
test('PUT /account/me/username changes the name, decrements the counter, then blocks', async () => {
const headers = {
...(await bearer('892')),
'Content-Type': 'application/x-www-form-urlencoded',
@@ -363,27 +260,11 @@ describe('auth-gated endpoints', () => {
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('892') })
).json()) as { username: string; availableUsernameChanges: number }
expect(me.username).toBe('coachx')
expect(me.availableUsernameChanges).toBe(2)
expect(me.availableUsernameChanges).toBe(0)
// The second and third changes consume the rest of the account's allowance.
for (const username of ['coachy', 'coachz']) {
const changed = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username }),
headers,
})
expect(changed.status).toBe(200)
expect(((await changed.json()) as { success: boolean }).success).toBe(true)
}
const exhausted = (await (
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('892') })
).json()) as { username: string; availableUsernameChanges: number }
expect(exhausted.username).toBe('coachz')
expect(exhausted.availableUsernameChanges).toBe(0)
// A fourth change is blocked — no changes remaining (still HTTP 200).
// A second change is blocked — no changes remaining (still HTTP 200).
const blocked = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'coachq' }),
...form({ username: 'coachy' }),
headers,
})
expect(blocked.status).toBe(200)
@@ -447,91 +328,6 @@ describe('auth-gated endpoints', () => {
expect(sent.map((n) => (n.data as { bannerImage?: string }).bannerImage)).toContain(key)
})
test('PUT /account/me/emoji persists the emoji and pushes the profile update', async () => {
type Sent = { playerId: number; notificationType: string | number; data: unknown }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/', { method: 'DELETE' })
// Exactly the body the client sends: one urlencoded field (`%F0%9F%A4%AA`).
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
method: 'PUT',
headers: { ...(await bearer('779')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'displayEmoji=%F0%9F%A4%AA',
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
// Served back by both the self and public reads — displayEmoji is in the public DTO.
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('779') })
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('\u{1F92A}')
const pub = await exports.default.fetch(`${ORIGIN}/account/779`)
expect(((await pub.json()) as { displayEmoji: string }).displayEmoji).toBe('\u{1F92A}')
// And it rides the profile-update notification, so it redraws beside the name.
const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[]
expect(sent.length).toBeGreaterThan(0)
expect(sent.map((n) => (n.data as { displayEmoji?: string }).displayEmoji)).toContain(
'\u{1F92A}'
)
})
// The pick is stored in the whitelist's CANONICAL form. A client that posts the emoji
// without its U+FE0F variation selector means the same pick, but storing what arrived
// would leave a string the picker list no longer matches, so the current pick would
// stop highlighting.
test('PUT /account/me/emoji canonicalizes a pick sent without its variation selector', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{2764}' }),
headers: { ...(await bearer('780')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(res.status).toBe(200)
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('780') })
const stored = ((await me.json()) as { displayEmoji: string }).displayEmoji
expect(stored).toBe('\u{2764}\u{FE0F}')
expect(WHITELISTED_EMOJIS).toContain(stored)
})
// An empty value clears the pick rather than 400ing — that's how the picker's "none"
// gets back to no emoji at all.
test('PUT /account/me/emoji clears the pick on an empty value', async () => {
const set = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{1F389}' }),
headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(set.status).toBe(200)
const cleared = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '' }),
headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(cleared.status).toBe(200)
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('781') })
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('')
})
// displayEmoji renders beside the display name, so an unchecked field would be a
// free-text label on every profile. Off-list values are refused, not stored.
test('PUT /account/me/emoji 401s without a token, 400s on an off-list value', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{1F92A}' }),
})
expect(anon.status).toBe(401)
for (const displayEmoji of ['not an emoji', '\u{1F92A}\u{1F92A}', '\u{1F595}\u{1F3FB}']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji }),
headers: { ...(await bearer('782')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(res.status).toBe(400)
}
// Nothing was stored by the refusals.
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('782') })
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('')
})
test('PUT /account/me/bannerimage 401s without a token, 400s without an imageName', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/account/me/bannerimage`, {
...form({ imageName: 'x.jpg' }),
@@ -692,16 +488,13 @@ describe('auth-gated endpoints', () => {
'GET /account/{id}',
'GET /account/{id}/bio',
'GET /accountprivacysettings/{id}',
'GET /emojiConfig/whitelistedEmojis',
'GET /parentalcontrol/me',
'POST /account/bulk',
'POST /account/create',
'POST /account/me/email',
'POST /account/me/phone',
'PUT /account/me/bannerimage',
'PUT /account/me/bio',
'PUT /account/me/displayname',
'PUT /account/me/emoji',
'PUT /account/me/identityflags',
'PUT /account/me/personalpronouns',
'PUT /account/me/profileimage',
@@ -772,12 +565,12 @@ describe('name, email and bio validation', () => {
expect(body.value).toBe('')
}
// A rationed change must NOT be spent by a refusal: an account starts with three,
// 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(3)
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`, {
@@ -890,26 +683,3 @@ test('POST /account/me/phone stores an E.164 number exactly as the client sends
// Verbatim — no normalising, no stripping of the +.
expect(row?.phone).toBe('+15552223333')
})
// The emoji picker. Served as a BARE array — the client parses the response body itself
// as the list, so wrapping it in `{ value: [...] }` or the success envelope every
// mutation here uses would leave the picker empty.
test('GET /emojiConfig/whitelistedEmojis serves the list as a bare array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/emojiConfig/whitelistedEmojis`)
expect(res.status).toBe(200)
const body = (await res.json()) as string[]
expect(Array.isArray(body)).toBe(true)
expect(body).toEqual(WHITELISTED_EMOJIS)
// Order is the picker's order, and the first entry anchors it.
expect(body[0]).toBe('😀')
// Every entry is a non-empty string and appears once — a duplicate draws twice in
// the grid, and the list is compared against `displayEmoji` as an exact string.
expect(body.every((e) => typeof e === 'string' && e.length > 0)).toBe(true)
expect(new Set(body).size).toBe(body.length)
})
// Not auth-gated: the client asks for the picker before it has a token in hand.
test('GET /emojiConfig/whitelistedEmojis needs no bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/emojiConfig/whitelistedEmojis`)
expect(res.status).toBe(200)
})
-312
View File
@@ -1,312 +0,0 @@
/**
* The emoji a player may pick as their `displayEmoji` — served verbatim as a bare JSON
* array by `GET /emojiConfig/whitelistedEmojis`, with no envelope around it.
*
* ORDER IS THE PICKER'S ORDER: the client renders the array as it arrives, so this is
* grouped by theme (smileys, hearts, gestures, animals, food, weather, tech, music,
* sport, objects, vehicles, symbols). Sorting it would reshuffle the grid; append a new
* entry to the end of the group it belongs to instead.
*
* Entries are UNIQUE — a repeat draws the same emoji twice in the grid — and are stored
* as the exact code-point sequence the client sends back, variation selectors (U+FE0F)
* and zero-width joiners included: `displayEmoji` is compared as a plain string, so
* `\u{2764}` and `\u{2764}\u{FE0F}` are two different values and only the form listed
* here round-trips.
*/
export const WHITELISTED_EMOJIS: string[] = [
'😀',
'😃',
'😄',
'😁',
'😆',
'😅',
'😂',
'🤣',
'😊',
'😇',
'🙂',
'🙃',
'😉',
'😍',
'🥰',
'😘',
'😗',
'😚',
'😋',
'😛',
'😜',
'🤪',
'😝',
'🤑',
'🤗',
'🤭',
'🤫',
'🤔',
'🤨',
'😐',
'😑',
'😶',
'🙄',
'😏',
'😣',
'😥',
'😮',
'🤐',
'😯',
'😪',
'😫',
'🥱',
'😴',
'😌',
'🤓',
'😎',
'🤩',
'🥳',
'😤',
'😭',
'😢',
'🥺',
'😡',
'🤬',
'😱',
'😨',
'😰',
'😬',
'🤯',
'🥶',
'🥵',
'🤠',
'🤖',
'👽',
'👻',
'💀',
'☠️',
'👹',
'👺',
'👾',
'❤️',
'🧡',
'💛',
'💚',
'💙',
'💜',
'🖤',
'🤍',
'🤎',
'💖',
'💗',
'💓',
'💕',
'💞',
'💘',
'💝',
'💟',
'❣️',
'💔',
'👍',
'👎',
'👌',
'✌️',
'🤞',
'🤟',
'🤘',
'🤙',
'👏',
'🙌',
'👐',
'🤲',
'🙏',
'👋',
'✋',
'🤚',
'🫶',
'💪',
'🧠',
'👀',
'👁️',
'👄',
'🦾',
'🦿',
'🐶',
'🐱',
'🐭',
'🐹',
'🐰',
'🦊',
'🐻',
'🐼',
'🐨',
'🐯',
'🦁',
'🐸',
'🐵',
'🐧',
'🐦',
'🦅',
'🦆',
'🦄',
'🐴',
'🐢',
'🐙',
'🦈',
'🐬',
'🐳',
'🦋',
'🐝',
'🐞',
'🦖',
'🦕',
'🐲',
'🍎',
'🍌',
'🍇',
'🍉',
'🍓',
'🍒',
'🥝',
'🍍',
'🥑',
'🌮',
'🍕',
'🍔',
'🍟',
'🌭',
'🥪',
'🍗',
'🍿',
'🍩',
'🍪',
'🎂',
'🍫',
'🍬',
'🍭',
'🧋',
'☕',
'🥤',
'🍺',
'🥛',
'☀️',
'🌤️',
'⛅',
'🌥️',
'☁️',
'🌧️',
'⛈️',
'❄️',
'🌈',
'⭐',
'🌟',
'✨',
'⚡',
'🔥',
'💧',
'🌊',
'🌸',
'🌹',
'🍀',
'🌲',
'🎮',
'🕹️',
'💻',
'⌨️',
'🖥️',
'📱',
'🖱️',
'🎧',
'📷',
'📹',
'💿',
'💾',
'🔋',
'🔌',
'🛰️',
'🚀',
'🎵',
'🎶',
'🎼',
'🎤',
'🥁',
'🎸',
'🎹',
'🎺',
'🎷',
'🎻',
'⚽',
'🏀',
'🏈',
'⚾',
'🎾',
'🏐',
'🏓',
'🥊',
'🏆',
'🥇',
'🥈',
'🥉',
'💎',
'💰',
'💸',
'🪙',
'🎁',
'📦',
'🔑',
'🗝️',
'🛡️',
'⚔️',
'🧸',
'🎈',
'🎉',
'🎊',
'🕯️',
'💡',
'📚',
'📖',
'✏️',
'🖊️',
'🚗',
'🚕',
'🚌',
'🚓',
'🚑',
'🚒',
'🏎️',
'🚲',
'✈️',
'🚁',
'🚢',
'✔️',
'✅',
'❌',
'⭕',
'❗',
'❓',
'💯',
'♾️',
'🔔',
'🔕',
'❤️‍🔥',
]
/**
* The whitelist keyed by its FE0F-stripped form, so a lookup tolerates the one way the
* client legitimately disagrees with this list: U+FE0F is a PRESENTATION hint, and
* `\u{2764}`/`\u{2764}\u{FE0F}` are the same picked emoji even though they are different
* strings. Verified collision-free — stripping FE0F maps the 271 entries onto 271
* distinct keys — so the fold can never make two picks ambiguous.
*/
const BY_STRIPPED = new Map(
WHITELISTED_EMOJIS.map((emoji) => [emoji.replaceAll('\u{FE0F}', ''), emoji])
)
/**
* Resolve a client-submitted emoji to its CANONICAL whitelist entry, or null when it
* isn't on the list. Storing the canonical form (rather than what arrived) is what keeps
* a stored `displayEmoji` string-equal to the picker entry it came from — the client
* highlights the current pick by comparing against the list it fetched.
*
* Whitelisting matters here beyond tidiness: `displayEmoji` renders beside the display
* name, so an unchecked field is a free-text label on every player's profile.
*/
export function resolveWhitelistedEmoji(input: string): string | null {
return BY_STRIPPED.get(input.replaceAll('\u{FE0F}', '')) ?? null
}
+4 -1
View File
@@ -39,7 +39,10 @@
],
"upload_source_maps": true,
"observability": {
"enabled": false
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.13.5",
"hono": "4.12.27",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.128.0"
"wrangler": "4.105.0"
}
}
+4 -1
View File
@@ -16,7 +16,10 @@
],
"upload_source_maps": true,
"observability": {
"enabled": false
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
-2
View File
@@ -8,8 +8,6 @@ API surface. Database-backed queries and on-disk JSON files are stubbed for now
- **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.
- **API-owned uploads** enforce `RECFLARE_MAX_API_UPLOAD_BYTES` per file (64 MiB
by default) before copying a parsed file into an `ArrayBuffer` or writing it to R2.
- **Static data** is served verbatim:
- `src/default-avatar-items.ts` → `GET /api/avatar/v4/items`
- `src/default-settings.ts` → `GET /api/settings/v2`
+3 -3
View File
@@ -4,9 +4,9 @@
-- 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, 3 Pending (a bulk
-- invite's row for a player who hasn't answered yet). 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
-- `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).
--
@@ -15,10 +15,6 @@
-- Partial index: event reports are a small minority of rows, so indexing only the ones
-- that name an event keeps "reports against this event" off a full scan without paying
-- for the NULLs.
--
-- SUPERSEDED by 0016_report_invention.sql, which DROPS that index: nothing ever queried
-- `event_id`, so it only cost writes. Left here so an unmigrated database still applies
-- the migrations in order and ends up in the same place.
ALTER TABLE report ADD COLUMN event_id INTEGER;
CREATE INDEX IF NOT EXISTS idx_report_event ON report (event_id) WHERE event_id IS NOT NULL;
-50
View File
@@ -1,50 +0,0 @@
-- Player reputation (the cheer counters on a profile) and the daily cheer credit that
-- pays for handing one out. Both owned by the `api` worker, which is the only reader and
-- the only writer — `POST /api/PlayerCheer/v1/create` writes them and the
-- `/api/playerReputation/…` reads serve them. Generated from src/reputation-db.ts
-- (SCHEMA_DDL) — keep in sync.
--
-- Two tables because they answer two different questions. `reputation` is what a player
-- has RECEIVED: one counter per cheer category. `player_cheer` is what they have left to
-- GIVE, refilling to 20 once the window in `created` is a day old — a lazy reset, so
-- nothing has to run on a schedule.
--
-- Neither row is created until it is needed: a missing `reputation` row means nobody has
-- cheered that player, which is the all-zero record the endpoints already served, and a
-- missing `player_cheer` row means they have never spent a cheer, i.e. full credit. So
-- reads fall back to the defaults instead of inserting on a GET.
--
-- `CheerCredit` on the DTO is NOT a column here even though the client's record carries it
-- next to the counters: it is `player_cheer.cheers_left` with the rollover applied. Storing
-- it in both places would let the number a player reads drift from the one the spend checks.
--
-- `noteriety` keeps the reference's spelling (the client's field is `Noteriety`). It and
-- the subscriber counts are stored but nothing writes them yet — they are per-player
-- numbers that will have a source one day, so the column is here and turning them on later
-- is a write rather than a migration.
--
-- `IsCheerful` and `SelectedCheer` were left off here on the theory that nothing varied
-- them per player; 0014 adds them — `SelectedCheer` is written by `SetSelectedCheer`.
CREATE TABLE IF NOT EXISTS reputation (
account_id INTEGER PRIMARY KEY,
noteriety INTEGER NOT NULL DEFAULT 0,
cheer_general INTEGER NOT NULL DEFAULT 0,
cheer_helpful INTEGER NOT NULL DEFAULT 0,
cheer_creative INTEGER NOT NULL DEFAULT 0,
cheer_great_host INTEGER NOT NULL DEFAULT 0,
cheer_sportsman INTEGER NOT NULL DEFAULT 0,
subscriber_count INTEGER NOT NULL DEFAULT 0,
subscribed_count INTEGER NOT NULL DEFAULT 0
);
-- `created` is the START of the live credit window, not the row's creation time: spending
-- a cheer inside a window leaves it alone, so a player refills 24h after their FIRST cheer
-- rather than sliding the deadline forward with every one they hand out. Stored as an
-- ISO-8601 UTC string, which is fixed-width and so orders correctly under SQLite's plain
-- string comparison — the spend compares against a cutoff without any date functions.
CREATE TABLE IF NOT EXISTS player_cheer (
player_id INTEGER PRIMARY KEY,
cheers_left INTEGER NOT NULL,
created TEXT NOT NULL
);
@@ -1,12 +0,0 @@
-- The two profile fields 0013 left off the `reputation` table on the theory that nothing
-- varied them per player. Owned by the `api` worker; generated from src/reputation-db.ts
-- (SCHEMA_DDL) — keep in sync.
--
-- `selected_cheer` is the cheer a player has PINNED to their profile, written by
-- `POST /api/PlayerCheer/v1/SetSelectedCheer` (form `CheerCategory`), which every reference
-- server stores per player — 0013's "no endpoint sets one" was wrong. `is_cheerful` is the
-- profile flag the client's DTO and `ReputationUpdate` frame both carry, read straight off
-- the record like every reference does; it is a column so it can vary one day without a
-- migration, defaulted true because that is what every reference serves.
ALTER TABLE reputation ADD COLUMN is_cheerful INTEGER NOT NULL DEFAULT 1;
ALTER TABLE reputation ADD COLUMN selected_cheer INTEGER NOT NULL DEFAULT 0;
@@ -1,33 +0,0 @@
-- Custom avatar items (the player-designed shirts/hats built on a base catalog item).
-- Owned by the `api` worker: `POST /api/customAvatarItems/v1` inserts a row. Generated
-- from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
--
-- One column per field of the client's `CustomAvatarItem` DTO, so a row IS the response.
-- The two uploads (the design and the thumbnail PNG) live in the `recflare-img` bucket
-- under `avatar-item/<date>/<id>-thumb.png` / `<id>-design.png`; the filename columns
-- hold those bucket keys.
--
-- `ranking_context` and `purchase_info` are served as null and `current_saves` as an
-- empty list; none of them has a source yet, so they are not columns.
CREATE TABLE IF NOT EXISTS custom_avatar_item (
custom_avatar_item_id TEXT PRIMARY KEY,
creator_account_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
price INTEGER NOT NULL DEFAULT 0,
accessibility INTEGER NOT NULL DEFAULT 0,
force_cannot_publish INTEGER NOT NULL DEFAULT 0,
is_featured INTEGER NOT NULL DEFAULT 0,
is_rec_room_approved INTEGER NOT NULL DEFAULT 0,
base_avatar_item_id INTEGER NOT NULL,
base_avatar_item_color TEXT NOT NULL,
design_filename TEXT NOT NULL,
thumbnail_image_filename TEXT NOT NULL,
created_at TEXT NOT NULL,
modified_at TEXT NOT NULL,
preview_orientation INTEGER NOT NULL DEFAULT 0,
outfit_type INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id);
@@ -1,22 +0,0 @@
-- Reporting an INVENTION (`POST /api/inventions/v1/report`) reuses the report table, as
-- the event report next to it does: same fields, same moderation life — a moderator
-- acting on one sets `banned` on the row exactly as they would for a player report.
-- Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `invention_id` names the reported invention; NULL on every other kind of report. It
-- sits beside `event_id` and the two are mutually exclusive: a row names an event, or an
-- invention, or neither (an ordinary player report), which is what tells the kinds apart.
--
-- The row's `reported_player_id` is the invention's CREATOR, read from the invention
-- rather than sent by the client — the column is NOT NULL, and "who is answerable for
-- this invention" is the only honest answer. No `room_id`: an invention is not tied to
-- one room the way an event is, so there is nothing to fill it in from.
--
-- Neither id column is INDEXED. Both are written on every report of their kind and read
-- by nothing — no query in any worker filters on either, and the moderation reads that do
-- exist go by player (`idx_report_reported`) or by the ban flag. So the partial index
-- 0011 built over `event_id` is dropped here rather than being mirrored for
-- `invention_id`: it only cost writes. Add one back with the query that needs it.
ALTER TABLE report ADD COLUMN invention_id INTEGER;
DROP INDEX IF EXISTS idx_report_event;
@@ -1,26 +0,0 @@
-- Reporting a CUSTOM AVATAR ITEM (`POST /api/customAvatarItems/v1/{id}/report`) reuses the
-- report table, exactly as the event and invention reports beside it do: same fields, same
-- moderation life — a moderator acting on one sets `banned` on the row as they would for a
-- player report. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `custom_avatar_item_id` names the reported item. TEXT, not INTEGER, because a custom
-- avatar item is keyed by a GUID (`aeef6bfa-09b1-4859-a77c-47a6e7523543`) where an event and
-- an invention are keyed by a number — the three columns are the same idea in three key
-- types, which is why they are three columns rather than one polymorphic id.
--
-- NULL on every other kind of report. It joins `event_id` and `invention_id`, and all three
-- are mutually exclusive: a row names an event, an invention, an item, or none of them (an
-- ordinary player report), which is what tells the kinds apart.
--
-- The row's `reported_player_id` is the item's CREATOR, read from the item rather than sent
-- by the client. The client sends `ReportedPlayerId: null` on this route — it does not know
-- who made the item — and the column is NOT NULL, so deriving it is the only way to fill it,
-- and "who is answerable for this item" is the only honest answer anyway.
--
-- No `room_id`: an item is not tied to one room the way an event is.
--
-- NOT indexed, for the same reason 0016 gave and then dropped 0011's index over `event_id`:
-- it is written on every report of this kind and read by nothing. Every moderation read goes
-- by player (`idx_report_reported`) or by the ban flag. Add an index with the query needing it.
ALTER TABLE report ADD COLUMN custom_avatar_item_id TEXT;
@@ -1,13 +0,0 @@
-- A player's interaction with an invention. One row per (player, invention); `cheered`
-- is toggled in place and the invention JSON's denormalized `CheerCount` is resynced
-- after every write. Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS invention_interaction (
player_id INTEGER NOT NULL,
invention_id INTEGER NOT NULL,
cheered INTEGER NOT NULL DEFAULT 0,
created_at TEXT,
PRIMARY KEY (player_id, invention_id)
);
CREATE INDEX IF NOT EXISTS idx_invention_interaction_invention
ON invention_interaction (invention_id);
-46
View File
@@ -1,46 +0,0 @@
-- Stored notifications — the delayed half of a WebSocket push, served back by
-- `GET /api/messages/v2/get` when a player logs in. Owned by the `api` worker; also written
-- by `rooms` (a room-role invite) and `notify` (a coach message to one player), which push
-- the matching `MessageReceived` frame. Generated from
-- packages/domain/src/notifications-db.ts (NOTIFICATION_SCHEMA_DDL) — keep in sync.
--
-- Until this table existed the inbox endpoint answered an empty list unconditionally and the
-- hub's queued frame was the whole of a notification sent to an offline player. That queue is
-- best-effort by construction — dropped once sent rather than acked, bounded per player, and
-- skipped whenever the hub believes it delivered (a socket the client has abandoned still
-- counts) — so a push could be lost with nothing left to show for it.
--
-- `notification_id` is what ties the two halves together: it is the `Id` on the frame that
-- delivers the notification, so the client can match what it was pushed to what it later
-- reads from the inbox, and `POST /api/messages/v3/delete` has an id to match. Senders write
-- the row FIRST and build the frame from it. AUTOINCREMENT rather than a bare rowid alias
-- because the id is handed to the client and deleting a row frees its rowid — a reused id
-- would point a client's stale entry at somebody else's notification.
--
-- Named `notification` and not `message`: the `chat` worker already owns a `message` table on
-- this same shared database (its thread posts). `CREATE TABLE IF NOT EXISTS` against a name
-- already taken does nothing and says nothing, so the first draft of this migration silently
-- adopted chat's table and failed only on the index below, over a column it doesn't have.
--
-- Deliberately NOT stored, because none of it is worth reading later: the coach BROADCAST (a
-- maintenance notice to everyone online at once — a row per player, thousands per send), the
-- vote-to-kick prompt (about a session that has since ended), and a cheer (its durable record
-- is the `reputation` counters; the Message is the part that plays in front of the room).
--
-- `data` is nullable because a Message's `Data` is: the types carrying no payload of their
-- own send null, distinct from the empty string a DM sends. Where set it is always a STRING,
-- whatever it means to that type (a room-role invite puts the offered role tier in it).
CREATE TABLE IF NOT EXISTS notification (
notification_id INTEGER PRIMARY KEY AUTOINCREMENT,
from_player_id INTEGER NOT NULL,
to_player_id INTEGER NOT NULL,
type INTEGER NOT NULL,
data TEXT,
room_id INTEGER,
player_event_id INTEGER,
sent_time TEXT NOT NULL
);
-- The inbox read and the newest-wins trim are both "this player's notifications, newest
-- first", which is the whole access pattern.
CREATE INDEX IF NOT EXISTS idx_notification_recipient ON notification (to_player_id, notification_id DESC);
@@ -1,24 +0,0 @@
-- Who handed a ban down, and when. A ban lives on the `report` row it rests on (see
-- 0009_report_ban.sql), which recorded the decision but not the deciding: there was no way
-- to tell which moderator set `banned`, and no way to tell WHEN they set it. Generated from
-- src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `banned_at` is not bookkeeping. The client's block screen reads `TimeoutStartedAt` and
-- `Duration` as a PAIR — the block runs from the start for the duration — and
-- `moderationBlockDetails` had only the report's `created_at` to offer as that start. So a
-- ban applied to a month-old report told the banned player their block began a month ago,
-- and the duration (computed from there to `ban_expires`) came out a month short: a 7-day
-- ban on a 30-day-old report reads as already served. With this column the ban's own start
-- is recorded and `banBlockDetails` uses it, falling back to `created_at` so every row
-- written before this migration keeps behaving exactly as it did.
--
-- Both are NULL on an unbanned row, and are CLEARED when a ban is lifted — the report
-- itself survives, as it does for `banned`/`ban_expires`. So a non-null `banned_at` means
-- "a ban is or was in force from here", which is the same thing `banned = 1` means.
--
-- Neither column is indexed. `banned_by_player_id` is read per-row when a moderator looks
-- at a ban, never filtered on; the ban reads that exist go by player or by the ban flag
-- (idx_report_banned). Add an index alongside a query that needs one.
ALTER TABLE report ADD COLUMN banned_by_player_id INTEGER;
ALTER TABLE report ADD COLUMN banned_at TEXT;
@@ -1,7 +0,0 @@
-- File every custom avatar item under OutfitType 105 (`RecRoom.Avatars.OutfitType.CustomShirt`).
-- Creation left `outfit_type` at the column's default of 0 (Hat), and the store's
-- user-generated-content tab searches `GET /api/customAvatarItems/v1/search?outfitTypes=105`
-- ALONE, so the tab sat empty over a full catalog. The custom shirt is the one kind of custom avatar item the
-- client can make, so every row written before this migration is one. Creation now writes
-- 105 itself (src/custom-avatar-items-db.ts, OUTFIT_TYPE_CUSTOM_SHIRT).
UPDATE custom_avatar_item SET outfit_type = 105 WHERE outfit_type = 0;
@@ -1,69 +0,0 @@
-- Custom avatar items become a JSON blob per row, the layout every other entity table on this
-- database uses (`invention`, `event`, `room`, `account`, `club`): the client's `CustomAvatarItem`
-- record verbatim in `data`, with the fields the queries filter and sort on exposed as generated
-- (virtual) columns. Generated from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
--
-- Why: the flat table (0015) had one NOT NULL column per field of the player-made shirt, and the
-- FIRST-PARTY items being imported are the same record with those four fields NULL
-- (`BaseAvatarItemId`, `BaseAvatarItemColor`, `DesignFilename`, `ThumbnailImageFilename` — a
-- studio-built item has no base item and no design PNG) plus fields the table had no home for:
-- `CurrentSaves` (one built Unity assetbundle per body type, which is how the client renders
-- them), `Tags`, `CustomBadgeMetadata`, `RankedEntityId`. Storing the record whole means the
-- import is the record as exported, and a field the source adds later is served without a
-- migration. `CurrentSaves` stays embedded rather than in a child table: the client reads a
-- save off its item and picks one by `BodyType`; nothing looks a save up by its bare id.
--
-- The generated columns keep the names the flat table had, so the indexes and every query
-- read the same. `name_lower`/`description_lower` back the store's search, which matches the
-- needle as a case-folded substring of either. Booleans come out of `json_extract` as 1/0
-- (see 0008), so `is_featured = 1` keeps working.
--
-- The existing player-made rows are carried across by rebuilding the DTO the old `toDto`
-- built from their columns — the same record the routes were already serving — with the
-- fields the flat table never had filled in as it served them (empty saves and tags, null
-- badge/ranking, `RankedEntityId` = the item id, as the official record carries it).
CREATE TABLE custom_avatar_item_new (
data TEXT NOT NULL,
custom_avatar_item_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.CustomAvatarItemId')) VIRTUAL,
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL,
outfit_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.OutfitType')) VIRTUAL,
price INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Price')) VIRTUAL,
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.CreatedAt')) VIRTUAL,
name_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Name'), ''))) VIRTUAL,
description_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Description'), ''))) VIRTUAL
);
INSERT INTO custom_avatar_item_new (data)
SELECT json_object(
'CustomAvatarItemId', custom_avatar_item_id,
'CreatorAccountId', creator_account_id,
'Name', name,
'Description', description,
'Price', price,
'Accessibility', accessibility,
'ForceCannotPublish', json(iif(force_cannot_publish, 'true', 'false')),
'IsFeatured', json(iif(is_featured, 'true', 'false')),
'IsRecRoomApproved', json(iif(is_rec_room_approved, 'true', 'false')),
'BaseAvatarItemId', base_avatar_item_id,
'BaseAvatarItemColor', base_avatar_item_color,
'DesignFilename', design_filename,
'ThumbnailImageFilename', thumbnail_image_filename,
'CreatedAt', created_at,
'ModifiedAt', modified_at,
'PreviewOrientation', preview_orientation,
'OutfitType', outfit_type,
'CurrentSaves', json('[]'),
'Tags', json('[]'),
'CustomBadgeMetadata', NULL,
'RankedEntityId', custom_avatar_item_id,
'RankingContext', NULL
) FROM custom_avatar_item;
DROP TABLE custom_avatar_item;
ALTER TABLE custom_avatar_item_new RENAME TO custom_avatar_item;
CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id);
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id);
-20
View File
@@ -1,20 +0,0 @@
-- Vote-to-kick ballots: one row per vote cast, appended, never updated.
--
-- Generated from apps/api/src/votes-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `response` is 1 for yes (kick them) and 0 for no. A player who votes twice leaves two
-- rows: the tally counts DISTINCT voters and reads only each one's latest ballot, so
-- re-posting a vote cannot carry one, and a voter may change their mind.
--
-- Rows are keyed to `game_session_id` (a `room_instance` id) and outlive the session.
-- Nothing prunes them; instance ids are not reused, so an old session's ballots can never
-- be counted into a new one's.
CREATE TABLE IF NOT EXISTS room_vote (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_session_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
response INTEGER NOT NULL,
voter_id INTEGER NOT NULL,
voted_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_room_vote_subject ON room_vote (game_session_id, player_id);
-40
View File
@@ -1,40 +0,0 @@
-- The audit log — one row per privileged action a staff account took. Owned by the `api`
-- worker; WRITTEN by `notify`, which records every call to its admin-gated `/internal/*`
-- endpoints here. Generated from packages/domain/src/audit-db.ts (AUDIT_LOG_SCHEMA_DDL) —
-- keep in sync.
--
-- This is not the worker log. `logger.info` lines are for reading during an incident: they
-- are sampled, they expire, and nothing can be asked of them later. The actions this table
-- exists for leave no other trace on the database at all — a maintenance broadcast is a
-- WebSocket frame and then nothing, and a coach message to every online player is
-- deliberately not stored (see 0019_notification.sql) — so without a row here there is no
-- answer at all to "who sent that, and when", a question that tends to arrive weeks later.
--
-- `player_id` is the ACTOR: the staff account that made the call, never whoever it was
-- aimed at. A target, where there is one, lives inside `data` with the rest of what was
-- asked for. The broadcasts have no target at all, so a column for one would be null on
-- half the table and could not be the thing every row is filed under.
--
-- `data` is JSON as TEXT, and deliberately unstructured: each action records what it was
-- asked to do, and those shapes have nothing in common beyond being worth keeping. It is
-- nullable because an action whose whole content is its name has nothing to put there, and
-- null says that where `{}` would only look like a payload that went missing. Capped at
-- 4096 characters by the writer, since a request body's size is the caller's to choose;
-- over that the row keeps the head of the JSON and says `truncated`.
--
-- `audit_log_id` is a plain rowid alias, not AUTOINCREMENT: the id never leaves the server
-- (unlike `notification`'s, which is handed to the client) and nothing deletes from this
-- table, so there are no freed rowids to be reused. Reads order by it rather than by
-- `date`, which ties when two actions land in the same millisecond.
CREATE TABLE IF NOT EXISTS audit_log (
audit_log_id INTEGER PRIMARY KEY,
player_id INTEGER NOT NULL,
action TEXT NOT NULL,
data TEXT,
date TEXT NOT NULL
);
-- The two ways anyone asks this table anything: everything one staffer did, and every time
-- a given action was taken. Both newest-first, which is the order an audit trail is read in.
CREATE INDEX IF NOT EXISTS idx_audit_log_player ON audit_log (player_id, audit_log_id DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log (action, audit_log_id DESC);
@@ -1,24 +0,0 @@
-- Reporting a CHAT MESSAGE (`POST /api/chatreport/createChatReport`) reuses the report
-- table, as the event, invention and custom-avatar-item reports before it do: same fields,
-- same moderation life — a moderator acting on one sets `banned` on the row exactly as they
-- would for a player report. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `chat_message_id` names the reported message; NULL on every other kind of report. It
-- joins `event_id`, `invention_id` and `custom_avatar_item_id`, and all four are mutually
-- exclusive: a row names one of those things or none of them (an ordinary player report),
-- which is what tells the kinds apart.
--
-- The message id is the WHOLE reference. The client also posts the `ChatThreadId`, which is
-- not stored: `message.chat_message_id` is unique across every thread (see the `chat`
-- worker's 0001_message.sql), so the thread is one lookup away and a stored copy could only
-- ever disagree with it.
--
-- The row's `reported_player_id` is the message's SENDER (`message.sender_player_id`), read
-- from the message rather than sent by the client — the body names no player at all, and
-- the column is NOT NULL. No `room_id`: a chat thread is not in a room.
--
-- NOT indexed, for the reason 0016 and 0017 give: it is written on every report of this kind
-- and read by nothing. Every moderation read goes by player (`idx_report_reported`) or by
-- the ban flag. Add an index with the query that needs it.
ALTER TABLE report ADD COLUMN chat_message_id INTEGER;
@@ -1,21 +0,0 @@
-- Marks the ballot that CALLED a vote-to-kick. Generated from src/votes-db.ts (SCHEMA_DDL) —
-- keep in sync.
--
-- The client posts the call and every answer to the same endpoint with the same body, so
-- nothing on the wire says which ballot started a vote. `init` is 1 on the first ballot cast
-- against a player in a session while no vote on them is open (VOTE_WINDOW_MS), 0 on every
-- answer. Two things read it:
-- - the tally counts only ballots from the open vote's `init` row on, so an expired vote's
-- yes ballots cannot carry the next one;
-- - the caller throttle: a player who called a vote within VOTE_CALL_COOLDOWN_MS may not
-- call another (`idx_room_vote_caller`). Answering is never throttled.
--
-- Backfill: each (session, player)'s first ballot is taken as its call. Those sessions are
-- over, so this only keeps the caller history honest.
ALTER TABLE room_vote ADD COLUMN init INTEGER NOT NULL DEFAULT 0;
UPDATE room_vote SET init = 1
WHERE id IN (SELECT MIN(id) FROM room_vote GROUP BY game_session_id, player_id);
CREATE INDEX IF NOT EXISTS idx_room_vote_caller ON room_vote (voter_id, voted_at) WHERE init = 1;
@@ -1,17 +0,0 @@
-- Index the browse/search feed (`searchInventions`) in the order it pages: newest first.
-- Without it every page — however small — parsed the JSON of every published invention,
-- sorted the lot in a temp B-tree and threw all but one page away. With it the query walks
-- this index in order and stops after OFFSET + LIMIT rows.
--
-- PARTIAL, over exactly the rows `VISIBLE_IN_FEEDS` admits, so it holds only what a feed can
-- show. SQLite uses a partial index only when the query's WHERE contains each of these terms
-- as written, so this WHERE must track `VISIBLE_IN_FEEDS` in src/inventions-db.ts term for
-- term — change one and the index silently stops being used. The key is the same
-- `json_extract` expression the query orders by, which is what lets it replace the sort.
-- Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
CREATE INDEX IF NOT EXISTS idx_invention_feed_newest
ON invention (json_extract(data, '$.CreatedAt') DESC, id DESC)
WHERE is_published = 1
AND hide_from_player = 0
AND COALESCE(json_extract(data, '$.Accessibility'), 0) <> 2;
-27
View File
@@ -1,27 +0,0 @@
-- A never-reused id sequence for player events. Owned by the `api` worker; generated
-- from src/events-db.ts (SCHEMA_DDL) — keep in sync.
--
-- Event ids used to be `MAX(id) + 1` over the `event` table, which hands the NEWEST
-- event's id straight back out once it is deleted: delete an event, create another,
-- and the new one wears the old one's id — and inherits everything still filed under
-- it that lives outside the event's own rows (a `PlayerEventInvitation` message's
-- `player_event_id`, the client's own cache of the old event). An event id has to be
-- unique for all time, like the other ids here.
--
-- SQLite's AUTOINCREMENT is exactly that guarantee: the counter in `sqlite_sequence`
-- only ever goes up, whatever rows are deleted. `createEvent` draws an id by inserting
-- a row here and reading it back; the row itself is throwaway (it is deleted once
-- used — only the counter matters).
--
-- The seed row moves the counter past every event that already exists: inserting an
-- explicit id into an AUTOINCREMENT table raises the sequence to at least that id, so
-- the first draw after this migration is one past the current max, exactly what the
-- old query would have produced. On an empty table it inserts nothing.
CREATE TABLE IF NOT EXISTS event_id_seq (
id INTEGER PRIMARY KEY AUTOINCREMENT
);
-- HAVING, not WHERE: an aggregate over an empty table still yields one (NULL) row, and
-- a NULL id would draw 1 from the counter, making the first real event 2.
INSERT INTO event_id_seq (id)
SELECT MAX(id) FROM event HAVING MAX(id) IS NOT NULL;
@@ -1,41 +0,0 @@
-- Who LIFTED a ban, and when. Owned by the `api` worker; generated from src/reports-db.ts
-- (SCHEMA_DDL) — keep in sync.
--
-- A lift clears `banned`, `ban_expires`, `banned_by_player_id` and `banned_at` (see
-- 0020_report_ban_audit.sql), which leaves the row looking exactly like a report nobody
-- ever acted on. On the staff panel that read as "Ban…" next to a report whose ban a
-- colleague had deliberately lifted an hour earlier, and a second moderator re-banned the
-- player without knowing. The `audit_log` still had the lift, but nothing reads it per row.
--
-- So the lift now signs the row: `unbanned_by_player_id` is the moderator who lifted the
-- ban and `unbanned_at` when they did. Both are NULL until a ban is lifted, and are CLEARED
-- again by a re-ban (the row describes the ban's current state, and a standing ban has not
-- been lifted). A non-null `unbanned_at` on a `banned = 0` row therefore means "somebody
-- decided this player should be back in the game", which is what the panel shows.
--
-- Rows lifted before this migration are backfilled from `audit_log` (0024), where every
-- lift has been recorded as an `unban` row naming the report in `data.reportId` — the
-- latest such row per report, since a report can be banned and lifted more than once.
-- Only rows not banned NOW are touched: a re-ban after a lift has cleared the lift.
--
-- Neither column is indexed: read per row on the panel's tables, never filtered on.
ALTER TABLE report ADD COLUMN unbanned_by_player_id INTEGER;
ALTER TABLE report ADD COLUMN unbanned_at TEXT;
UPDATE report
SET unbanned_by_player_id = (
SELECT player_id FROM audit_log
WHERE action = 'unban' AND json_extract(data, '$.reportId') = report.id
ORDER BY audit_log_id DESC LIMIT 1
),
unbanned_at = (
SELECT date FROM audit_log
WHERE action = 'unban' AND json_extract(data, '$.reportId') = report.id
ORDER BY audit_log_id DESC LIMIT 1
)
WHERE banned = 0
AND EXISTS (
SELECT 1 FROM audit_log
WHERE action = 'unban' AND json_extract(data, '$.reportId') = report.id
);
@@ -1,25 +0,0 @@
-- Cover the friend-list read (`getFriendIds` in @repo/domain, and the inlined copy in the
-- friends-filtered leaderboard). Owned by the `api` worker; generated from
-- packages/domain/src/relationships-db.ts (RELATIONSHIP_SCHEMA_DDL) — keep in sync.
--
-- The query is `relationship_type = ?2 AND (requester_id = ?1 OR target_id = ?1)`, which
-- SQLite already splits into one probe per side of the OR. But `relationship_type` was in
-- neither index and neither index carried the other side's id, so every row the player
-- appears in — of ANY type — cost an index read plus a table-row read, and the non-friends
-- were then discarded. Those rows accumulate: an unfriend keeps the row as type 0, ignoring
-- or muting a stranger inserts a type 0 row, pending requests are rows. And the query runs
-- on every matchmake and every disconnect (the presence push to friends), so D1 row reads
-- were roughly 2 x (all relationships) x (matchmakes).
--
-- These two indexes cover each half: seek to (player, Friend), read the other id off the
-- index, never touch the table. Reads become one per actual friend.
--
-- (target_id, relationship_type, requester_id) subsumes the old (target_id) index, so that
-- one goes. The unique (requester_id, target_id) index stays — it is the pair constraint
-- and what the single-pair lookups use.
CREATE INDEX IF NOT EXISTS idx_relationship_requester_type
ON relationship (requester_id, relationship_type, target_id);
CREATE INDEX IF NOT EXISTS idx_relationship_target_type
ON relationship (target_id, relationship_type, requester_id);
DROP INDEX IF EXISTS idx_relationship_target;
+2 -2
View File
@@ -22,7 +22,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.13.5",
"hono": "4.12.27",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -34,6 +34,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.128.0"
"wrangler": "4.105.0"
}
}
@@ -1,56 +0,0 @@
/**
* Turns an export of first-party custom avatar items into a D1 migration that seeds the
* `custom_avatar_item` table with them, the way `apps/rooms/migrations/0002_import_rooms.sql`
* seeds the rooms.
*
* bun apps/api/scripts/import-custom-avatar-items.ts <export.json> <NNNN>
*
* `export.json` is a JSON array of `CustomAvatarItem` records as the official service serves
* them (a `{ Results: [...] }` page or a single record are accepted too). `NNNN` is the
* migration number; the file is written to `apps/api/migrations/NNNN_import_custom_avatar_items.sql`.
*
* For a load that needs no migration and no deploy — the usual case — use
* `just admin cai-load` instead, which runs the same statements straight against a database.
* The statements come from src/custom-avatar-items-load.ts either way: each record stored as
* the row's JSON minus `PurchaseInfo`, with `CreatorAccountId` forced to the Coach account (1)
* and each save's `ThumbnailFileName` put under `avatar/` and its assetbundle hashes blanked,
* an existing id REPLACED, nothing deleted.
*
* The export's `Price` is mostly 0; run `price-custom-avatar-items.ts` against it first to fill
* in prices from the storefront dump.
*
* The saves name assetbundles by bare filename; the migration does nothing about those files,
* which have to be served from wherever the client fetches them.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import {
customAvatarItemUpsertStatements,
readCustomAvatarItemExport,
} from '../src/custom-avatar-items-load'
const [input, number] = process.argv.slice(2)
if (!input || !number || !/^\d{4}$/.test(number)) {
console.error('usage: bun apps/api/scripts/import-custom-avatar-items.ts <export.json> <NNNN>')
process.exit(2)
}
// Tolerate the BOM a .NET export carries (`JSON.parse` rejects U+FEFF).
const records = readCustomAvatarItemExport(
JSON.parse(readFileSync(input, 'utf8').replace(/^/, ''))
)
const lines = [
`-- Seed first-party custom avatar items (${records.length} records) from ${input.split('/').pop()}.`,
'-- Generated by apps/api/scripts/import-custom-avatar-items.ts — do not edit by hand.',
'-- Each record is the official `CustomAvatarItem`, filed under the Coach account (CreatorAccountId 1);',
'-- an existing id is replaced.',
'',
...customAvatarItemUpsertStatements(records),
]
const out = decodeURIComponent(
new URL(`../migrations/${number}_import_custom_avatar_items.sql`, import.meta.url).pathname
)
writeFileSync(out, lines.join('\n') + '\n')
console.log(`wrote ${records.length} records to ${out}`)
@@ -1,83 +0,0 @@
/**
* Fills in `Price` on an export of first-party custom avatar items from a storefront dump,
* rewriting the export in place so `import-custom-avatar-items.ts` / `just admin cai-load`
* carry the price into the table.
*
* bun apps/api/scripts/price-custom-avatar-items.ts <export.json> <storefront.json>
*
* The export (e.g. `apps/econ/static/db/2025-1-cai.json`) mostly carries `Price: 0`. The
* storefront (e.g. `Watch_EnumValue_3.json`, a `{ StoreItems: [...] }` page) prices the same
* items, joined on `GiftDrop.AvatarItemInfo.DownloadableAvatarItemId` = `CustomAvatarItemId`.
* The price taken is the base token price (`CurrencyType` 2), ignoring any sale. The storefront
* only prices what the export already holds: nothing is added from it.
*
* An item the storefront doesn't list keeps the price it had, unless that price is 0, which
* would make it free to buy. Those are the Cryptid Creek event items and the Evergrown Hip Pack,
* all rarity-50 rewards that were never sold, so they get the catalog's rarity-50 price.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { priceForRarity } from '../../econ/src/catalog-load'
const TOKENS = 2
/** The rarity an item missing from the storefront is priced as, when it has no price. */
const UNLISTED_RARITY = 50
interface StoreItem {
GiftDrop?: { AvatarItemInfo?: { DownloadableAvatarItemId?: string | null } | null }
Prices?: Array<{ CurrencyType: number; Price: number }>
}
const [exportPath, storefrontPath] = process.argv.slice(2)
if (!exportPath || !storefrontPath) {
console.error(
'usage: bun apps/api/scripts/price-custom-avatar-items.ts <export.json> <storefront.json>'
)
process.exit(2)
}
// Tolerate the BOM a .NET export carries (`JSON.parse` rejects U+FEFF).
const readJson = (path: string) => JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''))
const items: Array<{ CustomAvatarItemId: string; Name: string; Price: number }> =
readJson(exportPath)
const storefront: { StoreItems: StoreItem[] } = readJson(storefrontPath)
const prices = new Map<string, number>()
for (const storeItem of storefront.StoreItems) {
const id = storeItem.GiftDrop?.AvatarItemInfo?.DownloadableAvatarItemId?.toLowerCase()
const price = storeItem.Prices?.find((p) => p.CurrencyType === TOKENS)?.Price
if (id && price !== undefined && !prices.has(id)) {
prices.set(id, price)
}
}
let priced = 0
let changed = 0
const unmatched: string[] = []
for (const item of items) {
let price = prices.get(item.CustomAvatarItemId.toLowerCase())
if (price === undefined) {
unmatched.push(item.Name)
if (item.Price) continue
price = priceForRarity(UNLISTED_RARITY)
}
priced++
if (item.Price !== price) {
if (item.Price) {
console.warn(`${item.Name}: ${item.Price} -> ${price}`)
}
item.Price = price
changed++
}
}
writeFileSync(exportPath, JSON.stringify(items, null, 2) + '\n')
console.log(`priced ${priced}/${items.length} items (${changed} changed) in ${exportPath}`)
if (unmatched.length) {
console.log(
`not in the storefront (kept their price, or rarity ${UNLISTED_RARITY} if free): ${unmatched.join(', ')}`
)
}
+2 -3
View File
@@ -2,7 +2,6 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withDatabase } from '@repo/domain'
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { accountRoutes } from './routes/account'
@@ -17,7 +16,7 @@ import { progressionRoutes } from './routes/progression'
import { roomRoutes } from './routes/rooms'
import { socialRoutes } from './routes/social'
import type { App, Env } from './context'
import type { App } from './context'
/**
* The Game API surface. Endpoints that would be backed by a database or on-disk
@@ -106,4 +105,4 @@ app.get(
)
)
export default withDatabase<Env>(app)
export default app
-91
View File
@@ -206,94 +206,3 @@ export async function isPlayerBlocked(
): Promise<boolean> {
return (await resolveBan(db, accountId, options)) !== null
}
/**
* An account a ban on someone else would also reach, and what links the two.
*
* `via` is never `'account'` here: this is the answer to "who ELSE", so the account asked
* about is excluded by the query rather than returned as a match on itself.
*/
export interface LinkedAccount {
accountId: number
username: string | null
via: Exclude<BanVia, 'account'>
/**
* What matched — the shared platform id, or the shared IP address. Shown to the
* moderator so an IP match can be judged on its face: an address that reads as a
* carrier or campus range is a different proposition from a residential one.
*/
value: string
}
/**
* Which OTHER accounts a ban on `accountId` would also block — the preview a moderator
* sees before handing one down (`www`: `GET /api/staff/players/:id/linked`).
*
* This is `resolveBan` read backwards. That function asks "does any ban reach this
* caller", driving from the few banned reports inward, because it runs on every matchmake
* and every token grant. This one asks "who would this ban reach", driving from one
* account's identities outward — a question with no hot path, asked once when a moderator
* is about to act, and the only way to see the IP arm's blast radius BEFORE it lands
* rather than from a support ticket afterwards.
*
* Honours the same `arms`, so a preview on a server running `BAN_EVASION_MATCH=platform`
* doesn't list households that would not in fact be blocked. A disabled arm contributes
* nothing rather than being listed-but-flagged: the question is who gets blocked, and the
* answer under that setting is that they don't.
*
* The username comes off the account blob, which is `auth`'s — read here for the same
* reason the resolution query reads it: the moderator needs to know WHO, and an id alone
* makes the preview unreadable. A missing blob yields null rather than dropping the row;
* the block would still reach that account.
*
* An account matched by BOTH arms appears twice, once per arm. Deliberate: the arms are
* separate evidence, and "shares a Steam identity AND an address" is worth seeing as two
* lines. Platform rows sort first, being the arm that proves more.
*/
export async function linkedAccounts(
db: D1Database,
accountId: number,
arms: BanMatchArms = DEFAULT_BAN_MATCH_ARMS
): Promise<LinkedAccount[]> {
if (!arms.ip && !arms.platform) return []
const { results } = await db
.prepare(
`WITH me AS (
SELECT
NULLIF(json_extract(data, '$.signupIp'), '') AS signup_ip,
NULLIF(json_extract(data, '$.lastLoginIp'), '') AS last_login_ip
FROM account WHERE account_id = ?1
),
ips AS (
SELECT signup_ip AS ip FROM me WHERE signup_ip IS NOT NULL
UNION SELECT last_login_ip FROM me WHERE last_login_ip IS NOT NULL
),
ids AS (
SELECT platform, platform_id FROM platform_account WHERE account_id = ?1
)
SELECT p.account_id AS accountId,
json_extract(a.data, '$.username') AS username,
'platform' AS via,
p.platform_id AS value
FROM platform_account p
JOIN ids ON ids.platform = p.platform AND ids.platform_id = p.platform_id
LEFT JOIN account a ON a.account_id = p.account_id
WHERE ?2 = 1 AND p.account_id <> ?1
UNION
SELECT a.account_id AS accountId,
json_extract(a.data, '$.username') AS username,
'ip' AS via,
ips.ip AS value
FROM account a, ips
WHERE ?3 = 1 AND a.account_id <> ?1
AND ips.ip IN (
json_extract(a.data, '$.signupIp'),
json_extract(a.data, '$.lastLoginIp')
)
ORDER BY via DESC, accountId`
)
.bind(accountId, arms.platform ? 1 : 0, arms.ip ? 1 : 0)
.all<LinkedAccount>()
return results
}
-2
View File
@@ -15,8 +15,6 @@ export type Env = SharedHonoEnv & {
* for local dev and tests.
*/
DOMAIN: string
/** Maximum accepted size of each API-owned image upload, in bytes. */
RECFLARE_MAX_API_UPLOAD_BYTES?: string
// Shared rooms database (schema/migrations owned by the `rooms` worker). Used
// read-only here to resolve room roles for `/api/rooms/v1/verifyRole`.
DB: D1Database
-610
View File
@@ -1,610 +0,0 @@
/**
* Custom avatar items on the shared `recflare` D1 database: the player-designed shirts built
* on a base catalog item, and the first-party items imported from the official service, which
* are the same record with a set of built Unity assetbundles (`CurrentSaves`) in place of a
* base item and a design PNG.
*
* A row is the client's `CustomAvatarItem` record verbatim, as JSON in `data` — the layout the
* `invention`, `event`, `room`, `account` and `club` tables use — with the fields the queries
* filter and sort on exposed as generated (virtual) columns. So a row IS the response, an
* import is the exported record as-is, and a field the source adds later is served without a
* migration. The flat one-column-per-field table this replaced (0015) could not hold a
* first-party item at all: its base-item and filename columns were NOT NULL, and it had no home
* for the saves.
*
* `CurrentSaves` stays EMBEDDED rather than in a child table: the client reads a save off its
* item and picks one by `BodyType`, and nothing on this server looks a save up by its bare id
* (the legacy-item lookup keys them by `AvatarItemDesc`). Room saves got their own table
* because the client points at one by bare id; that does not apply here.
*
* The two uploads that accompany a player's creation (the design blob and the thumbnail PNG)
* live in the shared image bucket (`recflare-img`, the `IMAGES` binding) under
* `avatar-item/<date>/<id>-thumb.png` and `<id>-design.png`; `ThumbnailImageFilename` and
* `DesignFilename` hold those bucket keys, which the `img` worker serves back by key. A
* first-party item has both null and is rendered from its saves' assetbundles instead.
*
* The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.sql, rebuilt
* as JSON by 0022_custom_avatar_item_json.sql, applied under its own `migrations_table`).
*/
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load'
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
data TEXT NOT NULL,
custom_avatar_item_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.CustomAvatarItemId')) VIRTUAL,
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL,
outfit_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.OutfitType')) VIRTUAL,
price INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Price')) VIRTUAL,
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.CreatedAt')) VIRTUAL,
name_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Name'), ''))) VIRTUAL,
description_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Description'), ''))) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`,
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
]
/**
* One built version of a first-party item, as the client's PascalCase `CustomAvatarItemSave`:
* a Unity assetbundle (`UnityAsset`, with `UnityAsset2` a second variant) the client downloads
* to render the item, plus the thumbnail it shows in the store. An item carries one per
* `BodyType` it was built for; the client picks the save matching the wearer's body.
* `AdditionalConfiguration` is the client's own JSON-in-a-string document and is served as
* stored. Not the camelCase `CustomAvatarItemSave` the legacy-item lookup documents — same
* save, different casing and field set; keep the two apart.
*/
export interface CustomAvatarItemSave {
CustomAvatarItemSaveId: number
CustomAvatarItemId: string
UnityAssetId: string
BodyType: number
OutfitType: number
QAState: number
CreatedAt: string
ModifiedAt: string
Description: string | null
ThumbnailFileName: string
AdditionalConfiguration: string
UnityAsset: string
UnityAssetHash: string
UnityAsset2: string | null
UnityAsset2Hash: string | null
}
/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */
export interface CustomAvatarItemTag {
TagType: number
Value: string
}
/**
* The client's `CustomAvatarItem` record (PascalCase, as served). The four nullable fields are
* null on a first-party item, which has no base item or design PNG: it is rendered from
* `CurrentSaves` instead, which is empty on a player-made shirt.
*/
export interface CustomAvatarItem {
CustomAvatarItemId: string
CreatorAccountId: number
Name: string
Description: string
Price: number
Accessibility: number
ForceCannotPublish: boolean
IsFeatured: boolean
IsRecRoomApproved: boolean
BaseAvatarItemId: number | null
BaseAvatarItemColor: string | null
DesignFilename: string | null
ThumbnailImageFilename: string | null
CreatedAt: string
ModifiedAt: string
PreviewOrientation: number
RankingContext: null
OutfitType: number
CurrentSaves: CustomAvatarItemSave[]
Tags: CustomAvatarItemTag[]
CustomBadgeMetadata: unknown
RankedEntityId: string
PurchaseInfo: null
}
/**
* `RecRoom.Avatars.OutfitType.CustomShirt` — the slot of a custom shirt, the one kind of custom
* avatar item the client can make. The enum (unobfuscated in the client) is banded by body
* region: head None -1, Hat 0, Hair 2, Ear 3, Eye 10, Beard 20; torso Shoulder 100, Shirt 101,
* Waist 102, Neck 103, TeamJersey 104, CustomShirt 105; arms Wrist 200, TeamWrist 203; legs
* Legs 300, Feet 301; Roomie_Hat 500, Roomie_Waist 501, Roomie_Eye 502. It reaches the search
* as a bare `IEnumerable<int>` — nothing on the wire ties 105 to the name.
*
* This is what the store's user-generated-content tab searches for, ALONE
* (`GET /api/customAvatarItems/v1/search?outfitTypes=105&includeCoachItems=False`), so it is
* what every created item must be filed under: the table's default of 0 (Hat) matched nothing,
* and the tab sat empty over a full catalog. The imported first-party items carry their own
* slot (a wing is 100, Shoulder) and are found by the storefront tab's dozen-slot query.
*
* Not to be confused with the search's `itemTypes`, a different 3-member enum (All -1, None 0,
* Shirt 1) whose Shirt is 1, not 101 or 105. The client sends `itemTypes=-1` (All) alongside
* `outfitTypes=105`; the outfit type is the filter, and the two are never cross-mapped.
*/
export const OUTFIT_TYPE_CUSTOM_SHIRT = 105
/** What `POST /api/customAvatarItems/v1` needs to create an item. */
export interface CreateCustomAvatarItemInput {
/** The item's id. Chosen by the caller because the upload keys are derived from it. */
customAvatarItemId: string
creatorAccountId: number
name: string
description: string
price: number
baseAvatarItemId: number
baseAvatarItemColor: string
accessibility: number
designFilename: string
thumbnailImageFilename: string
/** The slot the item is worn in; {@link OUTFIT_TYPE_CUSTOM_SHIRT} when left out. */
outfitType?: number
}
interface Row {
data: string
}
/**
* What a stored record may lack and the response must carry: an imported record predates
* some of these, and `PurchaseInfo` is a store-side projection (`econ` prices items), never
* stored. Everything the record does carry wins over these.
*/
const DTO_DEFAULTS = {
CurrentSaves: [] as CustomAvatarItemSave[],
Tags: [] as CustomAvatarItemTag[],
CustomBadgeMetadata: null,
RankingContext: null,
PurchaseInfo: null,
} as const
/**
* A stored row's JSON as the client's record. Exported for the `econ` worker's owned-items
* read, which joins `inventory_custom` to this table and must serve the same shape.
*/
export function parseCustomAvatarItem(data: string): CustomAvatarItem {
const stored = JSON.parse(data) as CustomAvatarItem
return { ...DTO_DEFAULTS, ...stored, RankingContext: null, PurchaseInfo: null }
}
function toDto(row: Row): CustomAvatarItem {
return parseCustomAvatarItem(row.data)
}
/** Inserts a new player-made custom avatar item and returns it as the client's DTO. */
export async function createCustomAvatarItem(
db: D1Database,
input: CreateCustomAvatarItemInput,
now: Date = new Date()
): Promise<CustomAvatarItem> {
const ts = now.toISOString()
const record: Omit<CustomAvatarItem, 'PurchaseInfo'> = {
CustomAvatarItemId: input.customAvatarItemId,
CreatorAccountId: input.creatorAccountId,
Name: input.name,
Description: input.description,
Price: input.price,
Accessibility: input.accessibility,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: false,
BaseAvatarItemId: input.baseAvatarItemId,
BaseAvatarItemColor: input.baseAvatarItemColor,
DesignFilename: input.designFilename,
ThumbnailImageFilename: input.thumbnailImageFilename,
CreatedAt: ts,
ModifiedAt: ts,
PreviewOrientation: 0,
OutfitType: input.outfitType ?? OUTFIT_TYPE_CUSTOM_SHIRT,
CurrentSaves: [],
Tags: [],
CustomBadgeMetadata: null,
RankedEntityId: input.customAvatarItemId,
RankingContext: null,
}
const row = await db
.prepare('INSERT INTO custom_avatar_item (data) VALUES (?1) RETURNING data')
.bind(JSON.stringify(record))
.first<Row>()
if (!row) throw new Error('custom_avatar_item insert returned no row')
return toDto(row)
}
/**
* Stores a record as exported from the official service (the first-party items), verbatim,
* replacing any row with the same `CustomAvatarItemId` so a re-import with corrections lands.
* This is what the generated import migration does in SQL; it is here for tests and tooling.
*/
export async function importCustomAvatarItem(
db: D1Database,
record: Omit<CustomAvatarItem, 'PurchaseInfo'> & { PurchaseInfo?: unknown }
): Promise<CustomAvatarItem> {
const { PurchaseInfo: _purchaseInfo, ...stored } = record
const row = await db
.prepare(
`INSERT INTO custom_avatar_item (data) VALUES (?1)
ON CONFLICT(custom_avatar_item_id) DO UPDATE SET data = excluded.data
RETURNING data`
)
.bind(JSON.stringify(stored))
.first<Row>()
if (!row) throw new Error('custom_avatar_item import returned no row')
return toDto(row)
}
/**
* The `ItemType` that names a custom avatar item in a UGC-purchasable reference
* (`POST /api/ugcPurchasables/v1/items/bulk`'s `Ids[].itemType`).
*/
export const UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM = 3
/** A custom avatar item as the client's `UgcPurchasableItem` (the store-facing view). */
export interface UgcPurchasableItem {
ItemType: number
ItemId: string
Name: string
Description: string
ImageName: string
RoomId: number
Price: number
PurchaseCurrencyId: string | null
CreatedAt: string
ModifiedAt: string
}
/**
* The store-facing projection of a custom avatar item. `RoomId` is echoed from the
* request — the item table has no room; what the client wants it for is still unknown.
* `PurchaseCurrencyId` is null (the client's field is nullable) until a currency exists.
* `ImageName` is the item's own thumbnail for a player-made shirt; a first-party item has
* none at the item level, so the first save's thumbnail stands in.
*/
export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPurchasableItem {
return {
ItemType: UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
ItemId: item.CustomAvatarItemId,
Name: item.Name,
Description: item.Description,
ImageName: item.ThumbnailImageFilename ?? item.CurrentSaves[0]?.ThumbnailFileName ?? '',
RoomId: roomId,
Price: item.Price,
PurchaseCurrencyId: null,
CreatedAt: item.CreatedAt,
ModifiedAt: item.ModifiedAt,
}
}
/** Fetches the items with these ids, in the order asked; unknown ids are skipped. */
export async function getCustomAvatarItems(
db: D1Database,
ids: string[]
): Promise<CustomAvatarItem[]> {
if (ids.length === 0) return []
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id IN (${placeholders})`)
.bind(...ids)
.all<Row>()
const byId = new Map(results.map(toDto).map((item) => [item.CustomAvatarItemId, item]))
return ids.flatMap((id) => byId.get(id) ?? [])
}
/**
* Where the QUEST builds of the assetbundles live, relative to the PC ones. An assetbundle is
* built per Unity target, and a save names its bundle by bare filename for the client to fetch
* from the cdn's `/avatar/`; the Android build of the same bundle keeps the same filename one
* folder down, so a caller asking for the Quest target is served `quest/<name>.assetbundle` and asks the cdn for
* `/avatar/quest/<name>.assetbundle`.
*/
export const QUEST_ASSET_PREFIX = 'quest/'
/**
* The `unityAssetTarget` a Quest asks with — the Unity build target the caller wants its
* assetbundles built for, which the client names on every custom-avatar-item read. 0 is PC
* (Windows), and is what the saves' bare names are; 2 has been SEEN from the Android/Oculus
* client and is read as that. The rest of the enum has not been observed.
*/
export const UNITY_ASSET_TARGET_QUEST = 2
/**
* Whether a request's `unityAssetTarget` asks for the Quest builds. Anything else — 0, a
* target not seen yet, or none at all — is served the PC names, which is what was served
* before targets were read.
*/
export function isQuestAssetTarget(target: string | null | undefined): boolean {
return target?.trim() === String(UNITY_ASSET_TARGET_QUEST)
}
/**
* An assetbundle name under {@link QUEST_ASSET_PREFIX}. Only `.assetbundle` names are touched,
* and one that already carries the prefix is left alone; null (an absent `UnityAsset2`) is
* returned as it came.
*/
function questAsset<T extends string | null>(name: T): T | string {
if (name === null || !name.endsWith('.assetbundle')) return name
return name.startsWith(QUEST_ASSET_PREFIX) ? name : QUEST_ASSET_PREFIX + name
}
/**
* An item as a caller asking for the Quest target is served it: every save's `UnityAsset`/`UnityAsset2` pointed at
* the Quest build (see {@link QUEST_ASSET_PREFIX}). Per-response, never stored — the row keeps
* the bare names, which are what every other target is served. The hashes are left as
* stored. A player-made shirt has no saves and comes back unchanged.
*/
export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarItem {
return {
...item,
CurrentSaves: item.CurrentSaves.map((save) => ({
...save,
UnityAsset: questAsset(save.UnityAsset),
UnityAsset2: questAsset(save.UnityAsset2),
})),
}
}
/**
* The featured feed (`GET /api/customAvatarItems/v1/featured`): items flagged
* `is_featured` that are also published — `Accessibility` 0 is the unpublished state, so
* those are excluded even when flagged. Newest first. Nothing sets the flag yet, so the
* feed is empty until an operator writes `is_featured = 1`.
*/
export async function listFeaturedCustomAvatarItems(
db: D1Database,
limit = 50
): Promise<CustomAvatarItem[]> {
const { results } = await db
.prepare(
`SELECT data FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
)
.bind(limit)
.all<Row>()
return results.map(toDto)
}
/**
* The "hot" (trending) feed (`GET /api/customAvatarItems/v1/hot`): every PUBLISHED item —
* `Accessibility` 0 is the unpublished state and is the only thing held back. Newest
* first, standing in for a trend ranking there is nothing to compute one from yet (no
* purchase or wear counts are recorded).
*/
export async function listHotCustomAvatarItems(
db: D1Database,
limit = 50
): Promise<CustomAvatarItem[]> {
const { results } = await db
.prepare(
`SELECT data FROM custom_avatar_item WHERE accessibility != 0
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
)
.bind(limit)
.all<Row>()
return results.map(toDto)
}
/**
* The "Coach" system account — this server's stock content is authored by it, the same id the
* `econ` worker attributes a self-buy or an anonymous gift to. Lives in the loader module so
* the CLI can name it without Workers types; re-exported here for the worker-side callers.
*/
export { COACH_ACCOUNT_ID }
/** What `GET /api/customAvatarItems/v1/search` narrows the catalog by. */
export interface CustomAvatarItemSearch {
/** Free text, matched against an item's NAME or its DESCRIPTION. Blank means no filter. */
searchQuery?: string
/**
* `OutfitType`s to include. EMPTY means no filter rather than no results: the client sends
* the full set of types it can render, so an absent parameter is "everything", not "nothing".
*/
outfitTypes?: number[]
/**
* WHICH SIDE of the catalog to serve, not whether to add one to the other: `true` is the
* Coach's stock content ONLY, `false` is player-made content ONLY, and undefined is both.
* The client browses the store's two tabs with this — its storefront call says `True`
* and its user-generated-content call says `False` — so "include" reads as a toggle
* between them, never as a superset.
*/
includeCoachItems?: boolean
/**
* Only items this account authored — how the client lists what one player has made (a
* profile's creations, `creatorAccountId=<id>&includeCoachItems=False&take=1000`). Combines
* with the rest as one more AND; naming the Coach here with `includeCoachItems=false`
* matches nothing, as the two conditions read.
*/
creatorAccountId?: number
/** Lowest price to include, inclusive. */
minPrice?: number
/** Highest price to include, inclusive. */
maxPrice?: number
/** Rows to skip, for paging. */
skip?: number
/** Rows to return. Capped at {@link SEARCH_MAX_TAKE}. */
take?: number
}
/** The most rows one search returns, whatever `take` asks for. The client asks for 100. */
export const SEARCH_MAX_TAKE = 200
/**
* The store's item search (`GET /api/customAvatarItems/v1/search`), newest first.
*
* PUBLISHED items only — `Accessibility` 0 is the unpublished state, and this is the browse
* surface everyone shares, so an unpublished item must not appear here even to its creator (who
* has `fromCreator` for that).
*
* `searchQuery` matches an item's NAME or its DESCRIPTION, case-insensitively, as a substring,
* or its `CustomAvatarItemId` whole. Both sides are lowered rather than relying on `LIKE`, which
* folds case for ASCII only and would miss half of what players type. `%` and `_` in the needle
* are escaped, so searching for a literal one finds it instead of matching everything.
*
* `outfitTypes` is a WHITELIST when non-empty and no filter when empty, which is the opposite of
* how an empty IN () clause reads in SQL: the client sends every type it can render, so treating
* an absent parameter as "match nothing" would empty the store.
*
* `includeCoachItems` SPLITS the catalog by author rather than widening it: `true` is the Coach's
* items alone, `false` everyone else's alone, absent both. The client's storefront tab sends
* `True` and its user-generated-content tab sends `False`, and the two must not overlap — read
* as "stock plus players'", the storefront would show every player's item too.
*
* `creatorAccountId` narrows to one author. It was ignored until it was noticed that a player's
* creations list (which sends it) was serving the whole player-made catalog under every name.
*
* Ordered by recency because there is nothing else to order by — no purchase counts, no wear
* counts, no ratings are recorded — which is the same stand-in the `hot` feed makes. The
* `custom_avatar_item_id` tiebreak is what makes paging stable: without it, two items sharing a
* `created_at` can swap places between pages and one is served twice while the other is missed.
*/
export async function searchCustomAvatarItems(
db: D1Database,
search: CustomAvatarItemSearch = {}
): Promise<CustomAvatarItem[]> {
const take = Math.min(Math.max(search.take ?? 50, 0), SEARCH_MAX_TAKE)
const skip = Math.max(search.skip ?? 0, 0)
if (take === 0) return []
const where = ['accessibility != 0']
const binds: Array<number | string> = []
/** Bind a value and get its placeholder, so the numbering can't drift as clauses are added. */
const bind = (value: number | string): string => `?${binds.push(value)}`
const needle = search.searchQuery?.trim() ?? ''
if (needle !== '') {
// Escaped so a needle of LIKE metacharacters matches them literally rather than everything.
const escaped = needle.toLowerCase().replace(/[\\%_]/g, (ch) => `\\${ch}`)
const pattern = bind(`%${escaped}%`)
// `name_lower`/`description_lower` are generated columns, lowered once at the row rather
// than per query. An id typed in whole finds its item too, since the client searches by
// `CustomAvatarItemId` as well as by text.
const exact = bind(needle.toLowerCase())
where.push(
`(name_lower LIKE ${pattern} ESCAPE '\\' OR description_lower LIKE ${pattern} ESCAPE '\\'` +
` OR lower(custom_avatar_item_id) = ${exact})`
)
}
const outfitTypes = search.outfitTypes ?? []
if (outfitTypes.length > 0) {
where.push(`outfit_type IN (${outfitTypes.map((t) => bind(t)).join(', ')})`)
}
if (search.includeCoachItems === true) {
where.push(`creator_account_id = ${bind(COACH_ACCOUNT_ID)}`)
} else if (search.includeCoachItems === false) {
where.push(`creator_account_id != ${bind(COACH_ACCOUNT_ID)}`)
}
if (search.creatorAccountId !== undefined) {
where.push(`creator_account_id = ${bind(search.creatorAccountId)}`)
}
if (search.minPrice !== undefined) where.push(`price >= ${bind(search.minPrice)}`)
if (search.maxPrice !== undefined) where.push(`price <= ${bind(search.maxPrice)}`)
const limit = bind(take)
const offset = bind(skip)
const { results } = await db
.prepare(
`SELECT data FROM custom_avatar_item WHERE ${where.join(' AND ')}
ORDER BY created_at DESC, custom_avatar_item_id
LIMIT ${limit} OFFSET ${offset}`
)
.bind(...binds)
.all<Row>()
return results.map(toDto)
}
/**
* What an account has authored (`GET /api/customAvatarItems/v2/fromCreator/:id`), newest
* first, with the total for the client's paginated envelope. `includeUnpublished` is for
* the creator looking at their own shelf: it adds the `Accessibility` 0 items everyone
* else is not shown. Paging is not applied yet (the client sends none), so `TotalResults`
* always equals the list length.
*/
export async function listCustomAvatarItemsByCreator(
db: D1Database,
creatorAccountId: number,
includeUnpublished = false
): Promise<{ Results: CustomAvatarItem[]; TotalResults: number }> {
const { results } = await db
.prepare(
`SELECT data FROM custom_avatar_item
WHERE creator_account_id = ?1 AND (accessibility != 0 OR ?2)
ORDER BY created_at DESC, custom_avatar_item_id`
)
.bind(creatorAccountId, includeUnpublished ? 1 : 0)
.all<Row>()
const items = results.map(toDto)
return { Results: items, TotalResults: items.length }
}
/** The editable fields of `PUT /api/customAvatarItems/v1/:id`; null/undefined = leave alone. */
export interface UpdateCustomAvatarItemInput {
name?: string | null
description?: string | null
price?: number | null
accessibility?: number | null
}
/**
* Applies a partial edit to one item in place with `json_set`, bumping `ModifiedAt`. Fields
* the caller leaves null keep their value (the client sends every field, nulling the
* untouched ones).
* Returns the updated item, or null when no row has that id.
*/
export async function updateCustomAvatarItem(
db: D1Database,
id: string,
patch: UpdateCustomAvatarItemInput,
now: Date = new Date()
): Promise<CustomAvatarItem | null> {
const row = await db
.prepare(
`UPDATE custom_avatar_item SET data = json_set(data,
'$.Name', coalesce(?2, json_extract(data, '$.Name')),
'$.Description', coalesce(?3, json_extract(data, '$.Description')),
'$.Price', coalesce(?4, json_extract(data, '$.Price')),
'$.Accessibility', coalesce(?5, json_extract(data, '$.Accessibility')),
'$.ModifiedAt', ?6)
WHERE custom_avatar_item_id = ?1
RETURNING data`
)
.bind(
id,
patch.name ?? null,
patch.description ?? null,
patch.price ?? null,
patch.accessibility ?? null,
now.toISOString()
)
.first<Row>()
return row ? toDto(row) : null
}
/** Deletes one item's row. Returns the deleted item, or null when no row had that id. */
export async function deleteCustomAvatarItem(
db: D1Database,
id: string
): Promise<CustomAvatarItem | null> {
const row = await db
.prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING data')
.bind(id)
.first<Row>()
return row ? toDto(row) : null
}
/** Fetches one item by id, or null. */
export async function getCustomAvatarItem(
db: D1Database,
id: string
): Promise<CustomAvatarItem | null> {
const row = await db
.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
.bind(id)
.first<Row>()
return row ? toDto(row) : null
}
-171
View File
@@ -1,171 +0,0 @@
/**
* Turning an export of custom avatar items into `custom_avatar_item` rows — the loader half of
* the table, used by `runx admin cai-load` (in @repo/tools) and by the migration generator
* (scripts/import-custom-avatar-items.ts) rather than by the worker.
*
* Separate from `custom-avatar-items-db.ts` for the reason `catalog-load.ts` is separate from
* `catalog-db.ts` in econ: that module types its queries with `D1Database`, a Workers type,
* and the loader runs in a plain Node CLI that has no such types. Everything here is pure data
* mapping with no imports, so both sides can use it.
*
* A row is the record as JSON (see 0022_custom_avatar_item_json.sql), so a load is the export
* verbatim: no per-field mapping, and a field the source adds later rides along. Three things
* change on the way in: `PurchaseInfo`, a store-side projection the worker fills at read time,
* is stripped; `CreatorAccountId` is FORCED to the Coach account; and each save's
* `ThumbnailFileName` is put under the `avatar/` prefix — see {@link customAvatarItemRowLiteral}.
*/
/**
* The "Coach" system account — this server's stock content is authored by it, the same id the
* `econ` worker attributes a self-buy or an anonymous gift to. Defined here rather than in
* `custom-avatar-items-db.ts` (which re-exports it) so the loader can name it without pulling
* in Workers types.
*/
export const COACH_ACCOUNT_ID = 1
/**
* Where a save's thumbnail is served from. The export names it bare
* (`f2y1ndzuvm5ke2hjmn4cwfwfl.png`); this server keeps those images under `avatar/` in the
* image bucket, so the name is prefixed on the way in and the client asks for the right key.
*/
export const SAVE_THUMBNAIL_PREFIX = 'avatar/'
/** The least an export record must carry to be a row. Everything else is kept as-is. */
export interface CustomAvatarItemExportRecord {
CustomAvatarItemId: string
PurchaseInfo?: unknown
[key: string]: unknown
}
/**
* The records in an export, whether it is a bare JSON array, a `{ Results: [...] }` page, or a
* single record. Refuses a record without an id and a repeated id — a repeat in one upsert
* statement is an error at the database, and a repeat across statements would silently keep
* whichever came last, which is a defect in the export rather than a choice to make for it.
*/
export function readCustomAvatarItemExport(parsed: unknown): CustomAvatarItemExportRecord[] {
const records: unknown[] = Array.isArray(parsed)
? parsed
: parsed &&
typeof parsed === 'object' &&
Array.isArray((parsed as { Results?: unknown }).Results)
? (parsed as { Results: unknown[] }).Results
: [parsed]
const seen = new Set<string>()
return records.map((record, i) => {
if (!record || typeof record !== 'object' || Array.isArray(record)) {
throw new Error(`record ${i} is not an object`)
}
const id = (record as { CustomAvatarItemId?: unknown }).CustomAvatarItemId
if (typeof id !== 'string' || id === '') {
throw new Error(`record ${i} has no CustomAvatarItemId`)
}
if (seen.has(id)) throw new Error(`duplicate CustomAvatarItemId in export: ${id}`)
seen.add(id)
return record as CustomAvatarItemExportRecord
})
}
/**
* A save's `ThumbnailFileName` under {@link SAVE_THUMBNAIL_PREFIX}. Idempotent — a name that
* already carries the prefix is left alone — so loading a file that was itself dumped from
* this table does not stack a second `avatar/`. Anything that is not a non-empty string
* (null, absent) is returned as it came.
*/
export function prefixSaveThumbnail(name: unknown): unknown {
if (typeof name !== 'string' || name === '') return name
return name.startsWith(SAVE_THUMBNAIL_PREFIX) ? name : SAVE_THUMBNAIL_PREFIX + name
}
/**
* A save's assetbundle hash as it is stored: BLANK. The export's `UnityAssetHash` /
* `UnityAsset2Hash` are the hashes of the PC builds, and one stored save is served to every
* build target — a Quest asking with `unityAssetTarget=2` is pointed at the `quest/` build of
* the same bundle, whose bytes (and so whose hash) differ. The client checks a download against
* a hash it is given and refuses the mismatch, so the Quest rendered nothing until these were
* emptied by hand; given `""` it skips the check. A hash that is already null — the
* `UnityAsset2Hash` of a save with no second bundle — stays null, so the pair keeps agreeing.
*/
export function blankAssetHash(hash: unknown): unknown {
return typeof hash === 'string' ? '' : hash
}
/**
* One record as the SQL string literal its row holds: the JSON, minus `PurchaseInfo`, with
* three things rewritten.
*
* `CreatorAccountId` is overridden to {@link COACH_ACCOUNT_ID} whatever the export said. An
* imported item is first-party content by definition, and the creator id is what makes it so
* here: the storefront tab finds stock items by `creator_account_id = 1`
* (`includeCoachItems=True`), and a sale pays the creator. An export from another server
* carries THAT server's creator ids — an item filed under one of those would vanish from the
* storefront into the user-generated tab, and its price would be paid to whichever local
* account happened to share the number.
*
* Each save's `ThumbnailFileName` is prefixed with {@link SAVE_THUMBNAIL_PREFIX}, which is where
* this server serves those images from, and its `UnityAssetHash`/`UnityAsset2Hash` are blanked
* (see {@link blankAssetHash}). The rest of the save — the assetbundle names above all — is
* untouched.
*/
export function customAvatarItemRowLiteral(record: CustomAvatarItemExportRecord): string {
const { PurchaseInfo: _purchaseInfo, ...stored } = record
const saves = Array.isArray(stored.CurrentSaves)
? stored.CurrentSaves.map((save: unknown) => {
if (!save || typeof save !== 'object' || Array.isArray(save)) return save
const fields = save as Record<string, unknown>
return {
...fields,
ThumbnailFileName: prefixSaveThumbnail(fields.ThumbnailFileName),
// Only where the export carries the key, so a save is never GIVEN a field.
...('UnityAssetHash' in fields && {
UnityAssetHash: blankAssetHash(fields.UnityAssetHash),
}),
...('UnityAsset2Hash' in fields && {
UnityAsset2Hash: blankAssetHash(fields.UnityAsset2Hash),
}),
}
})
: stored.CurrentSaves
const row = { ...stored, CreatorAccountId: COACH_ACCOUNT_ID, CurrentSaves: saves }
return `'${JSON.stringify(row).replaceAll("'", "''")}'`
}
/**
* The largest statement a load builds, in bytes of SQL. D1 caps a single statement at 100 KB,
* and a record with three built saves runs to a few KB, so rows are grouped by size rather
* than by count — a fixed count would fit the small records and overflow on the large ones.
*/
export const CUSTOM_AVATAR_ITEM_LOAD_STATEMENT_BYTES = 64 * 1024
/**
* The statements that upsert `records` into `custom_avatar_item`, each a multi-row INSERT
* within {@link CUSTOM_AVATAR_ITEM_LOAD_STATEMENT_BYTES}. An id already in the table has its
* row REPLACED (`ON CONFLICT ... DO UPDATE`), so a re-run with a corrected export lands and a
* re-run with the same one changes nothing. Nothing is deleted: rows the export does not
* mention — the players' own shirts among them — are left alone.
*/
export function customAvatarItemUpsertStatements(
records: CustomAvatarItemExportRecord[],
maxBytes = CUSTOM_AVATAR_ITEM_LOAD_STATEMENT_BYTES
): string[] {
const head = 'INSERT INTO custom_avatar_item (data) VALUES\n'
const tail = '\nON CONFLICT(custom_avatar_item_id) DO UPDATE SET data = excluded.data;'
const statements: string[] = []
let rows: string[] = []
let bytes = 0
const flush = (): void => {
if (rows.length === 0) return
statements.push(head + rows.map((r) => `\t(${r})`).join(',\n') + tail)
rows = []
bytes = 0
}
for (const record of records) {
const literal = customAvatarItemRowLiteral(record)
// A single oversized record still gets its own statement rather than being dropped.
if (rows.length > 0 && bytes + literal.length > maxBytes) flush()
rows.push(literal)
bytes += literal.length + 4
}
flush()
return statements
}
+49 -113
View File
@@ -25,8 +25,8 @@ import {
} from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql +
* 0010_event_tag.sql + 0028_event_id_seq.sql, sans any seed rows).
* 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 (
@@ -58,12 +58,6 @@ export const SCHEMA_DDL: string[] = [
PRIMARY KEY (event_id, tag)
)`,
`CREATE INDEX IF NOT EXISTS idx_event_tag_tag ON event_tag (tag)`,
// The id sequence: AUTOINCREMENT so an id is never handed out twice, whatever gets
// deleted. See {@link nextEventId}. The migration also seeds it past the existing
// events; on a fresh database there are none, so the seed is omitted here.
`CREATE TABLE IF NOT EXISTS event_id_seq (
id INTEGER PRIMARY KEY AUTOINCREMENT
)`,
]
/**
@@ -73,29 +67,19 @@ export const SCHEMA_DDL: string[] = [
* 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).
*
* `pending` is not an answer: it's the row a bulk invite writes for the invitee, before
* they've said anything. It's what the client shows as an open invitation — an invite
* that landed as Going looked like the player had already accepted. Only the server
* writes it; `respond` won't take it as a `Type`.
*/
export const EVENT_RESPONSE = {
going: 0,
interested: 1,
cantGo: 2,
pending: 3,
} as const
/** The answers a player may give — every type but `pending`, for validating an incoming `Type`. */
const EVENT_ANSWER_VALUES: number[] = [
EVENT_RESPONSE.going,
EVENT_RESPONSE.interested,
EVENT_RESPONSE.cantGo,
]
/** 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 answers a player can give (never `pending`). */
/** Whether a number is one of the three response types. */
export function isEventResponseType(value: number): boolean {
return EVENT_ANSWER_VALUES.includes(value)
return EVENT_RESPONSE_VALUES.includes(value)
}
/**
@@ -190,9 +174,9 @@ interface EventRow {
}
/**
* A tag as the `v2` envelope carries it to a build newer than 20230414: the PascalCase
* form of the stored `{ tag, type }` pair. NOT the lowercase pair the v1 read serves —
* three casings of one tag, and the client parses each in exactly one place.
* A tag as the 2023 build's `v2` envelope carries it: the PascalCase form of the stored
* `{ tag, type }` pair. NOT the lowercase pair the v1 read serves — three casings of one
* tag, and the client parses each in exactly one place.
*/
export interface PlayerEventEnvelopeTag {
Tag: string
@@ -206,11 +190,9 @@ export interface PlayerEventEnvelopeTag {
*
* `Tags` is the one field whose shape depends on the caller's BUILD, because Rec Room
* changed it under the same unversioned path rather than minting a `v3`: the 2023 build
* (20230414 and older) parses `["celebration"]` and anything newer parses
* `[{ Tag, Type }]`. Serving either one to the other build leaves the event's tag chips
* empty — the decoder drops what it can't read rather than erroring.
* {@link toEventResult} picks; nothing else should. `TagModifyResult.Tags` is NOT part of
* this: it is a `List<string>` to every build.
* parses `[{ Tag, Type }]` and the 2025 build parses `["celebration"]`. Serving either
* one to the other build leaves the event's tag chips empty — the decoder drops what it
* can't read rather than erroring. {@link toEventResult} picks; nothing else should.
*/
export interface PlayerEventEnvelope extends PlayerEventBase {
Tags: string[] | PlayerEventEnvelopeTag[]
@@ -236,18 +218,17 @@ export interface PlayerEventResult {
* tags — pass what `getEventTags` returns, so the answer reflects what was actually
* written rather than what was asked for.
*
* `pairedTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see
* {@link PlayerEventEnvelope}): the `{ Tag, Type }` pairs of a build newer than 20230414
* when set, the 2023 build's bare names when not. It changes nothing else —
* `TagModifyResult.Tags` is a name list to both builds.
* `legacyTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see
* {@link PlayerEventEnvelope}): the 2023 pairs when set, the 2025 names when not. It
* changes nothing else — `TagModifyResult.Tags` is a name list to both builds.
*/
export function toEventResult(
event: PlayerEvent,
tags: EventTag[] = [],
pairedTags = false
legacyTags = false
): PlayerEventResult {
const names = tags.map((t) => t.tag)
const carried = pairedTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names
const carried = legacyTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names
return {
PlayerEvent: { Tags: carried, ...toEventBase(event) },
Result: 0,
@@ -309,13 +290,9 @@ export interface PlayerEventNotification {
}
/**
* The client's BASE event — the 17-key shape the browse feed (`GET /api/playerevents/v1`),
* the room shelf (`.../room/{roomId}`) and the bulk read (`POST|GET .../bulk`) all serve,
* and the same thing the v2 envelope carries once `Tags` is added. Those three are one
* generic helper over one element type on the client side, so they are shape-identical by
* construction there; `toEventBase` is what holds that here.
*
* PascalCase like the stored record, but not identical to it — don't unify them:
* The client's BASE event — the 17-key shape the browse feed (`GET /api/playerevents/v1`)
* serves, and the same thing the v2 envelope carries once `Tags` is added. PascalCase like
* the stored record, but not identical to it — don't unify them:
*
* - it drops `State`, which neither the feed nor the envelope carries;
* - it carries `BroadcastingRoomInstanceId`, which the record has no field for (nothing
@@ -323,8 +300,8 @@ export interface PlayerEventNotification {
* - its `ImageName` is a string: an event with no image reads `""`, where the record holds
* null.
*
* The by-id, search, searchlive and club reads serve the stored RECORD verbatim instead,
* `State` and nullable `ImageName` included. Two shapes; keep them apart.
* The by-id / bulk / search reads serve the stored RECORD verbatim instead, `State` and
* nullable `ImageName` included. Two shapes; keep them apart.
*/
export interface PlayerEventBase extends Omit<PlayerEvent, 'State' | 'ImageName'> {
ImageName: string
@@ -639,11 +616,14 @@ export async function createEvent(
creatorPlayerId: number,
input: EventInput
): Promise<PlayerEvent> {
const id = await nextEventId(db)
// 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: id,
PlayerEventId: row?.next ?? 1,
CreatorPlayerId: creatorPlayerId,
ImageName: input.imageName ?? null,
RoomId: input.roomId ?? 0,
@@ -681,51 +661,46 @@ export async function createEvent(
* 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 and the player's response row as it now stands (the route
* pushes that row on the `PlayerEventResponseChanged` frame), 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).
* 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<{ event: PlayerEvent; response: EventAttendeeRow } | null> {
): Promise<PlayerEvent | null> {
const event = await getEventById(db, eventId)
if (event === null) return null
const response = await db
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
RETURNING rowid AS id, *`
ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4`
)
.bind(eventId, playerId, status, eventTime(Date.now()))
.first<EventAttendeeRow>()
.run()
const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
await writeEvent(db, updated)
// An upsert with RETURNING always yields the row it inserted or updated.
return { event: updated, response: response! }
return updated
}
/**
* Add invited players to an event as Pending — the bulk invite. Returns the updated
* Add invited players to an event as Going — the bulk invite. Returns the updated
* event (with its recounted `AttendeeCount`) and the rows actually created, or null
* when there's no such event.
*
* An invite only ever INSERTS: a player who already has a row keeps the answer they
* gave, so being invited can't reset an answer to Pending, and re-inviting the same
* player is a no-op. The rows land as Pending (3), not Going: the invitee hasn't
* accepted anything yet, so they don't count toward `AttendeeCount` until they answer
* through `respond` — see the route.
* gave, so being invited can't flip a decline back to Going, and re-inviting the same
* player is a no-op rather than a reset. Since the rows land as Going, the invited
* count toward `AttendeeCount` from the moment they're invited — see the route.
*
* `added` is what `RETURNING` gave back, so it holds exactly the new rows: a conflict
* inserts nothing and returns nothing. That's who the route sends the invitation Message
* to — a player whose existing answer was left alone gets nothing, because nothing
* changed for them.
* inserts nothing and returns nothing. That's what the route notifies on — a player
* whose existing answer was left alone gets no frame, because nothing changed for them.
*
* Ids are deduplicated by the composite primary key; an empty list is a no-op that
* still returns the event.
@@ -749,7 +724,7 @@ export async function inviteToEvent(
ON CONFLICT (event_id, player_id) DO NOTHING
RETURNING rowid AS id, *`
)
.bind(eventId, playerId, EVENT_RESPONSE.pending, at)
.bind(eventId, playerId, EVENT_RESPONSE.going, at)
)
)
const added = inserts.flatMap((r) => r.results)
@@ -759,22 +734,6 @@ export async function inviteToEvent(
return { event: updated, added }
}
/**
* The players who said they're GOING to an event — who the `PlayerEventUpdated` and
* `PlayerEventDeleted` frames go to. Interested, declined and Pending rows are left out:
* an invitee who hasn't accepted isn't attending, so nothing about the event has changed
* for them. The creator is included through their own Going row from create.
*/
export async function getGoingPlayerIds(db: D1Database, eventId: number): Promise<number[]> {
const { results } = await db
.prepare(
'SELECT player_id FROM event_attendee WHERE event_id = ?1 AND status = ?2 ORDER BY player_id'
)
.bind(eventId, EVENT_RESPONSE.going)
.all<{ player_id: number }>()
return results.map((r) => r.player_id)
}
/** 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
@@ -866,31 +825,12 @@ export async function updateEvent(
return updated
}
/**
* Draw the next event id. Sequential and NEVER reused: the id comes from the
* `event_id_seq` AUTOINCREMENT counter, which only ever goes up, rather than from
* `MAX(id) + 1` over the events themselves — that handed the newest event's id straight
* back out once it was deleted, so a replacement event wore the old one's id and
* inherited whatever was still filed under it (an invitation message's `PlayerEventId`,
* the client's cache). The drawn row is thrown away in the same batch; only the counter
* matters, and SQLite keeps that in `sqlite_sequence` regardless of the rows.
*/
async function nextEventId(db: D1Database): Promise<number> {
const [drawn] = await db.batch<{ id: number }>([
db.prepare('INSERT INTO event_id_seq DEFAULT VALUES RETURNING id'),
db.prepare('DELETE FROM event_id_seq'),
])
const id = drawn.results[0]?.id
if (id === undefined) throw new Error('event_id_seq returned no id')
return id
}
/**
* Delete an event and everything hanging off it — its RSVPs (`event_attendee`) and its tags
* (`event_tag`) — in one batch, so a cancelled event can't leave rows behind that the
* attendee counts and the `#tag` search would still find. Event ids are drawn from a
* sequence that never reuses one (see {@link nextEventId}), so a deleted event's id is
* retired with it — but orphan rows would still be counted against whatever id they name.
* attendee counts and the `#tag` search would still find. Event ids are assigned in
* sequence and never reused, but orphan rows would still be counted against whatever id
* they name.
*
* Answers the event as it was, so the caller can report what it deleted; `null` when there
* was no such event.
@@ -916,12 +856,9 @@ export async function getEventById(db: D1Database, eventId: number): Promise<Pla
}
/**
* Several events by id — the bulk fetch behind `POST /api/playerevents/v1/bulk` (the form
* body the client sends) and the query-string GET on the same path. 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.
*
* Returns stored records; both routes project them with `toEventBase` before serving.
* 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 []
@@ -970,8 +907,7 @@ export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promi
/**
* A room's events — what is happening in this room and what is coming up, soonest first.
* Backs the room's event shelf (`GET /api/playerevents/v1/room/{roomId}`), which serves
* them through `toEventBase` like the browse feed and the bulk read.
* Backs the room's event shelf (`GET /api/playerevents/v1/room/{roomId}`).
*
* FINISHED events are left out, like the browse feed's: this answers "what can I still turn
* up to in this room", and an event that ended last month is not that. Running events count
+6 -20
View File
@@ -27,27 +27,13 @@ export function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/**
* Reads the `Ids` form field of a bulk POST into a list of integer ids.
*
* BOTH spellings, because the client uses both: `Ids` REPEATED once per id
* (`Ids=101&Ids=102&Ids=103`, what the player-events bulk sends) and a single
* comma-separated `Ids=1,2,3`. `parseBody({ all: true })` is what keeps the repeated form
* from collapsing to its last value — plain `parseBody()` would answer one id out of
* three, which reads as a short result rather than as an error.
*
* `ids` is accepted alongside `Ids` so a hand-written request doesn't silently come back
* empty. Values that aren't integers are dropped; duplicates and order are left alone,
* since the caller renders them in request order.
*/
/** Reads the `Ids` form field into a list of integer ids. */
export async function parseFormIds(c: Context<App>): Promise<number[]> {
const body = await c.req
.parseBody({ all: true })
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
const raw = [body.Ids, body.ids].flat()
return raw
.filter((v): v is string => typeof v === 'string')
.flatMap((v) => v.split(','))
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const ids = body.Ids
if (typeof ids !== 'string') return []
return ids
.split(',')
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n))
}
+35 -563
View File
@@ -23,8 +23,8 @@ import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domai
/**
* 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` most of the "may anyone see this"
* filter every feed shares (see `VISIBLE_IN_FEEDS`, which also excludes unlisted ones); json_extract of a JSON `true` is 1, so those columns are 1/0 — and
* 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[] = [
@@ -39,22 +39,6 @@ export const SCHEMA_DDL: string[] = [
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_invention_featured ON invention (is_featured)`,
// Backs `searchInventions`' newest-first paging (migrations/0027). Its WHERE must match
// `VISIBLE_IN_FEEDS` term for term or SQLite won't use it.
`CREATE INDEX IF NOT EXISTS idx_invention_feed_newest
ON invention (json_extract(data, '$.CreatedAt') DESC, id DESC)
WHERE is_published = 1
AND hide_from_player = 0
AND COALESCE(json_extract(data, '$.Accessibility'), 0) <> 2`,
`CREATE TABLE IF NOT EXISTS invention_interaction (
player_id INTEGER NOT NULL,
invention_id INTEGER NOT NULL,
cheered INTEGER NOT NULL DEFAULT 0,
created_at TEXT,
PRIMARY KEY (player_id, invention_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_invention_interaction_invention
ON invention_interaction (invention_id)`,
]
/** A single saved version of an invention (Rec Room's `RRInventionVersion`). */
@@ -69,16 +53,6 @@ export interface InventionVersion {
ChipsCost: number
CloudVariablesCost: number
AICost: number
/**
* Whether the blob uses content still in beta, sent from `v9/save` on. It sits on the
* VERSION, where the client's own `RRInventionVersion` carries it and for the same
* reason the costs do: it describes the one revision saved, not the invention across
* all of them. `UgcVersion`, which reads like its twin, is an INVENTION field — see
* {@link SavedInvention}. Absent on a record saved through `v6/save`, which sends
* neither; the client's decoder reads a missing member as its default, so an old
* record is not retroactively wrong.
*/
HasBetaContent?: boolean
}
/**
@@ -129,32 +103,9 @@ export interface SavedInvention {
HideFromPlayer: boolean
ReferencedInventions: number[]
/**
* The rest of what `v9/save` sends, kept beside `ReferencedInventions` — the field
* they most resemble, and the one this record has always carried on the invention.
* Note the v9 client's own `RRInvention` has no `Referenced*` at all (its VERSION
* carries them) and no `LongDescription`/`ConvertedFromInventionId` (it sends both and
* never reads them back); they are stored anyway, because what the client sent is
* worth keeping, and `toSaveResultV9` puts each where that client expects it.
*
* `UgcVersion` is the exception that has to be got right rather than tolerated: it is
* an invention field there, not a version one, next to `CurrentVersionNumber`.
*
* `DisplayMetadataJson` is stored as the opaque string the client sent: it is the
* client's own display state (`{"0":0,"99":0}`), and re-encoding it would be
* inventing a schema for something only the client reads.
*
* Each is absent on a `v6/save` record, which sends none of them.
*/
ReferencedUnityAssetIds?: string[]
UgcVersion?: number
LongDescription?: string
DisplayMetadataJson?: string
ConvertedFromInventionId?: number
/**
* Tags served by `v1/details` and written by `v1/settags`. Optional: the real
* `RRInvention` carries no Tags field, so an untagged invention's DTO stays
* identical to the real one. `v6/save` never sets it (that client tags in a second
* call); `v9/save` sets it when its `tagsRequest` names at least one tag.
* Tags served by `v1/details` and written by `v1/settags`. Optional and unset on
* save: the real `RRInvention` carries no Tags field and the client sends no tags
* when saving, so an untagged invention's DTO stays identical to the real one.
*/
Tags?: InventionTag[]
}
@@ -179,224 +130,6 @@ export function toSaveResult(invention: SavedInvention): InventionSaveResult {
return { Status: 0, Invention: invention, InventionVersion: invention.CurrentVersion }
}
/**
* `TagsResponse.Result` on a v9 save — the client's own tag-result enum, whose members
* run Success 0 … ReservedWordViolation 13. Only Success is named: the members between
* were not recovered from the client, and it never reads this field anyway, so a refused
* tag needs only to be something other than Success.
*/
export const INVENTION_TAG_RESULT = {
success: 0,
rejected: 1,
} as const
/**
* The `Invention` inside a v9 save response — the client's 28-key `RRInvention`, which is
* NOT the record this server stores (that one mirrors the older shape the read endpoints
* still serve). The differences that matter: no nested `CurrentVersion` (the version rides
* beside it), no `Referenced*` (they moved onto the version), no `IsPublished` (the client
* infers it from `FirstPublishedAt`), and `UgcVersion`/`LatestVersionNumber`/
* `ForceCannotPublish`/`IsRecRoomApproved` that the stored record has no equivalent for.
*
* The client reads exactly one of these keys — `InventionId` — and its decoder null-checks
* every member and drops the ones it doesn't know, so this projection is about being right
* rather than about being parseable.
*/
export interface InventionV9Dto {
InventionId: number
ReplicationId: string
CreatorPlayerId: number
Name: string
Description: string
ImageName: string
UgcVersion: number
CurrentVersionNumber: number
LatestVersionNumber: number
Accessibility: number
ForceCannotPublish: boolean
ModifiedAt: string
CreatedAt: string
FirstPublishedAt: string | null
CreationRoomId: number | null
NumPlayersHaveUsedInRoom: number
NumDownloads: number
CheerCount: number
CreatorPermission: number
GeneralPermission: number
IsAGInvention: boolean
IsCertifiedInvention: boolean
IsRecRoomApproved: boolean
AllowTrial: boolean
Price: number | null
HideFromPlayer: boolean
DisplayMetadataJson: string | null
}
/**
* The `InventionVersion` inside a v9 save response — the client's 13-key
* `RRInventionVersion`. It carries `HasBetaContent`, a `CreatedAt` of its own and a
* nullable `UgcAccessibility` the stored version has no field for, and notably NO
* `AICost`, which the request body still sends and this server still stores.
*
* Both `Referenced*` lists are emitted here even though the client's DTO has room for one:
* which of the two it is wasn't recovered, and an unknown member is dropped silently while
* a missing one would be the list the client asked for going astray.
*/
export interface InventionVersionV9Dto {
InventionId: number
ReplicationId: string
VersionNumber: number
HasBetaContent: boolean
InstantiationCost: number
LightsCost: number
ChipsCost: number
CloudVariablesCost: number
BlobName: string
BlobHash: string | null
CreatedAt: string
UgcAccessibility: number | null
ReferencedInventions: number[]
ReferencedUnityAssetIds: string[]
}
/** The tag half of a v9 save — `v1/settags`' answer, folded into the save response. */
export interface InventionTagsV9Dto {
Result: number
Tags: string[]
}
/**
* The four keys inside a v9 save envelope's `Value`. `Status` is 0 on success.
*
* `InventionVersion` and `TagsResponse` are nullable because the envelope is not the save
* route's alone: econ's `POST /api/storefronts/v3/buyInvention` answers in it too, and a
* BUY mints neither a version nor a tag result — it sends both as null, and the client
* (which reads only `Success` and `Value.Invention`) never looks. The keys stay present.
*/
export interface InventionSaveV9Value {
Status: number
Invention: InventionV9Dto
InventionVersion: InventionVersionV9Dto | null
TagsResponse: InventionTagsV9Dto | null
}
/**
* What `v9/save` answers, and the whole reason it isn't just v6 with a bigger body: the
* result is ENVELOPED, where v6 serves the bare `{ Status, Invention, InventionVersion }`.
*
* The client's contract is two fields deep. It checks `Success`, then reads
* `Value.Invention.InventionId` and tags the invention with it; `Error` is the only text
* that ever reaches a human (it is logged as "Invention datablob upload failed"). `Status`
* is deserialized and never read on this route — the failure channel is the envelope, not
* the 55-member status enum — and so are `InventionVersion` and `TagsResponse`.
*
* The one shape that CRASHES the client is `Success: true` with `Value` null or absent: it
* dereferences `Value.Invention` unguarded. `Success: false` with a null `Value` is safe —
* that branch reads only `Error` — which is why every refusal goes through
* {@link inventionSaveV9Failure} rather than answering a bare `{ error }` like v6 does. A
* body that doesn't deserialize into this envelope at all is the same crash, so even the
* 401 answers it.
*/
export interface InventionSaveV9Result {
Value: InventionSaveV9Value | null
Success: boolean
Error: string | null
error_id: string | null
}
/**
* Project a stored invention into the client's 28-key `RRInvention`. Shared by the v9 save
* envelope below and by econ's `v3/buyInvention`, which answers in that same envelope — so
* the two can never drift into serving one build two different inventions.
*
* Fields the stored record has no equivalent for are served as what they are here rather
* than guessed: nothing forces an invention not to publish, and nothing in this server
* approves one.
*/
export function toInventionV9(invention: SavedInvention): InventionV9Dto {
return {
InventionId: invention.InventionId,
ReplicationId: invention.ReplicationId,
CreatorPlayerId: invention.CreatorPlayerId,
Name: invention.Name,
Description: invention.Description,
ImageName: invention.ImageName,
UgcVersion: invention.UgcVersion ?? 0,
CurrentVersionNumber: invention.CurrentVersionNumber,
// One save, one version: the newest is the current one.
LatestVersionNumber: invention.CurrentVersionNumber,
Accessibility: invention.Accessibility,
ForceCannotPublish: false,
ModifiedAt: invention.ModifiedAt,
CreatedAt: invention.CreatedAt,
FirstPublishedAt: invention.FirstPublishedAt,
CreationRoomId: invention.CreationRoomId,
NumPlayersHaveUsedInRoom: invention.NumPlayersHaveUsedInRoom,
NumDownloads: invention.NumDownloads,
CheerCount: invention.CheerCount,
CreatorPermission: invention.CreatorPermission,
GeneralPermission: invention.GeneralPermission,
IsAGInvention: invention.IsAGInvention,
IsCertifiedInvention: invention.IsCertifiedInvention,
IsRecRoomApproved: false,
AllowTrial: invention.AllowTrial,
Price: invention.Price,
HideFromPlayer: invention.HideFromPlayer,
DisplayMetadataJson: invention.DisplayMetadataJson ?? null,
}
}
/**
* Project a stored invention into the v9 save envelope. `tags` are the ones stored with
* it, answered as the bare names `v1/settags` answers with; `tagResult` says whether they
* were taken (see {@link INVENTION_TAG_RESULT}) — a tag the rules refuse costs the tags,
* never the save, because the save is the thing the player would have to redo.
*/
export function toSaveResultV9(
invention: SavedInvention,
tags: InventionTag[],
tagResult: number = INVENTION_TAG_RESULT.success
): InventionSaveV9Result {
const version = invention.CurrentVersion
return {
Value: {
Status: 0,
Invention: toInventionV9(invention),
InventionVersion: {
InventionId: version.InventionId,
ReplicationId: version.ReplicationId,
VersionNumber: version.VersionNumber,
HasBetaContent: version.HasBetaContent ?? false,
InstantiationCost: version.InstantiationCost,
LightsCost: version.LightsCost,
ChipsCost: version.ChipsCost,
CloudVariablesCost: version.CloudVariablesCost,
BlobName: version.BlobName,
BlobHash: version.BlobHash,
// The version is minted with the invention, so they share a timestamp.
CreatedAt: invention.CreatedAt,
UgcAccessibility: null,
ReferencedInventions: invention.ReferencedInventions,
ReferencedUnityAssetIds: invention.ReferencedUnityAssetIds ?? [],
},
TagsResponse: { Result: tagResult, Tags: tags.map((t) => t.Tag) },
},
Success: true,
Error: null,
error_id: null,
}
}
/**
* A refused v9 save. `Value` is null, which is safe precisely because `Success` is false:
* the client reads `Error` on that branch and nothing else. See
* {@link InventionSaveV9Result} for why the alternative — a bare `{ error }` body — would
* take the client down instead.
*/
export function inventionSaveV9Failure(message: string): InventionSaveV9Result {
return { Value: null, Success: false, Error: message, error_id: null }
}
/**
* Invention data blobs are named `<name>.inv`, and the client expects the extension
* on the `BlobName` it reads back. Uploads through the `storage` worker already land
@@ -456,19 +189,6 @@ export interface NewInvention {
aiCost?: number
creationRoomId?: number | null
referencedInventions?: number[]
/**
* The rest of what `v9/save` sends. Every one is optional and is written onto the
* record only when the caller actually supplied it, so a `v6/save` — which sends
* none of them — stores and answers exactly the record it always did.
*/
ugcVersion?: number
hasBetaContent?: boolean
referencedUnityAssetIds?: string[]
longDescription?: string | null
displayMetadataJson?: string | null
convertedFromInventionId?: number | null
/** Tags to store with the record, already normalized by {@link normalizeInventionTags}. */
tags?: InventionTag[]
}
/**
@@ -481,11 +201,6 @@ export interface NewInvention {
* what narrows `GeneralPermission` down (to UseOnly by default). Trials are allowed.
* The client's `creatorAccountRole` is ignored: it's the player's role in the room
* they built it in, not a permission over the invention.
*
* The fields `v9/save` added over `v6/save` are written only when the caller supplies
* them, so the record a v6 client stores is byte-for-byte the one it always stored —
* the new keys appear on new records rather than being back-filled with defaults onto
* every old one.
*/
export async function createInvention(
db: D1Database,
@@ -518,7 +233,6 @@ export async function createInvention(
ChipsCost: input.chipsCost ?? 0,
CloudVariablesCost: input.cloudVariablesCost ?? 0,
AICost: input.aiCost ?? 0,
...(input.hasBetaContent === undefined ? {} : { HasBetaContent: input.hasBetaContent }),
},
Accessibility: 0,
IsPublished: false,
@@ -538,16 +252,6 @@ export async function createInvention(
AllowTrial: true,
HideFromPlayer: false,
ReferencedInventions: input.referencedInventions ?? [],
...(input.referencedUnityAssetIds === undefined
? {}
: { ReferencedUnityAssetIds: input.referencedUnityAssetIds }),
...(input.ugcVersion === undefined ? {} : { UgcVersion: input.ugcVersion }),
...(input.longDescription ? { LongDescription: input.longDescription } : {}),
...(input.displayMetadataJson ? { DisplayMetadataJson: input.displayMetadataJson } : {}),
...(typeof input.convertedFromInventionId === 'number'
? { ConvertedFromInventionId: input.convertedFromInventionId }
: {}),
...(input.tags?.length ? { Tags: input.tags } : {}),
}
await db.prepare('INSERT INTO invention (data) VALUES (?1)').bind(JSON.stringify(invention)).run()
return invention
@@ -640,30 +344,12 @@ export async function ownsAllInventions(
}
/**
* Invention search — the browse/search list the client shows when picking an invention to
* spawn. Only published, non-hidden inventions are visible here (a player's own unpublished
* ones come from `getInventionsByCreator`). Newest first, paginated via skip/take. Returns a
* bare array — the shape the client expects from v2/search.
*
* `value` is split into terms on whitespace and `+`, and EVERY term must match (AND, not OR),
* which is what makes typing more words narrow the list. Each is matched case-insensitively
* against the NAME and the DESCRIPTION.
*
* Filtered, ordered and paged entirely IN SQL. It used to read every published invention into
* memory, filter there and slice — which meant the cost of a search grew with the whole
* catalogue no matter how narrow the query or how small the page, and a browse screen asking
* for 100 rows paid for all of them. `Name`, `Description` and `CreatedAt` live inside the JSON
* blob, so they are reached with `json_extract`; `is_published`/`hide_from_player` are already
* generated columns.
*
* Both sides of the comparison are lowered rather than leaning on `LIKE`, which folds case for
* ASCII only — and invention names are full of things it would not fold. `%` and `_` in a term
* are escaped so a player searching for one finds it instead of matching everything.
*
* A term starting with `#` is NOT special here: the browse screen's filter chips send `#small`,
* and a tag appears in no name or description, so those searches find nothing. Matching tags
* needs them out of the JSON blob and into something indexable first; until then this stays a
* text search rather than one that scans every row to look at its tags.
* Invention search — the browse/search list the client shows when picking an
* invention to spawn. Only published, non-hidden inventions are visible here (a
* player's own unpublished ones come from `getInventionsByCreator`). `value` is
* matched case-insensitively against the name and description, term by term; an
* empty `value` browses everything published. Paginated via skip/take, newest
* first. Returns a bare array — the shape the client expects from v2/search.
*/
export async function searchInventions(
db: D1Database,
@@ -671,39 +357,22 @@ export async function searchInventions(
skip: number,
take: number
): Promise<SavedInvention[]> {
const limit = Math.max(take, 0)
const offset = Math.max(skip, 0)
if (limit === 0) return []
let inventions = await publicInventions(db)
const where = [...VISIBLE_IN_FEEDS]
const binds: Array<string | number> = []
/** Bind a value and get its placeholder, so the numbering can't drift as terms are added. */
const bind = (v: string | number): string => `?${binds.push(v)}`
for (const term of value
const terms = value
.trim()
.toLowerCase()
.split(/[\s+]+/)
.filter(Boolean)) {
const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`)
const pattern = bind(`%${escaped}%`)
where.push(
`(lower(json_extract(data, '$.Name')) LIKE ${pattern} ESCAPE '\\'` +
` OR lower(json_extract(data, '$.Description')) LIKE ${pattern} ESCAPE '\\')`
.filter(Boolean)
for (const term of terms) {
inventions = inventions.filter(
(i) => i.Name.toLowerCase().includes(term) || i.Description.toLowerCase().includes(term)
)
}
const limitAt = bind(limit)
const offsetAt = bind(offset)
const { results } = await db
.prepare(
`SELECT data FROM invention WHERE ${where.join(' AND ')}
ORDER BY json_extract(data, '$.CreatedAt') DESC, id DESC
LIMIT ${limitAt} OFFSET ${offsetAt}`
)
.bind(...binds)
.all<InventionRow>()
return results.map((r) => JSON.parse(r.data) as SavedInvention)
return inventions
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
}
/**
@@ -717,7 +386,8 @@ async function publicInventions(db: D1Database, featuredOnly = false): Promise<S
const { results } = await db
.prepare(
`SELECT data FROM invention
WHERE ${VISIBLE_IN_FEEDS.join(' AND ')}
WHERE is_published = 1
AND hide_from_player = 0
${featuredOnly ? 'AND is_featured = 1' : ''}`
)
.all<InventionRow>()
@@ -794,9 +464,10 @@ export async function getFeaturedInventions(
/**
* Replace an invention's tags (the `v1/settags` write). Auto tags are the ones the
* client derives from the invention itself (Type 2); custom tags are the creator's
* own (Type 0). Both lists are replaced wholesale, normalized as
* {@link normalizeInventionTags} describes. Returns the stored tag list, or null when
* there's no such invention.
* own (Type 0). Both lists are replaced wholesale — auto first, then custom, the
* order the tags come back in — and are lowercased/trimmed and de-duplicated so
* `details` doesn't echo back near-duplicates. Returns the stored tag list, or null
* when there's no such invention.
*/
export async function setInventionTags(
db: D1Database,
@@ -807,21 +478,6 @@ export async function setInventionTags(
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
const tags = normalizeInventionTags(autoTags, customTags)
await writeInvention(db, { ...invention, Tags: tags })
return tags
}
/**
* The two tag lists as they are stored: auto first (Type 2), then custom (Type 0) —
* the order they come back in — each trimmed, lowercased and de-duplicated across both
* lists so `details` doesn't echo back near-duplicates. Blanks are dropped: the client
* pads its lists with empties.
*
* Shared by `v1/settags` and by `v9/save`, which carries the same two lists in its
* `tagsRequest` — a tag has to mean the same thing however it arrived.
*/
export function normalizeInventionTags(autoTags: string[], customTags: string[]): InventionTag[] {
const tags: InventionTag[] = []
const seen = new Set<string>()
for (const [list, type] of [
@@ -835,6 +491,8 @@ export function normalizeInventionTags(autoTags: string[], customTags: string[])
tags.push({ Tag: tag, Type: type })
}
}
await writeInvention(db, { ...invention, Tags: tags })
return tags
}
@@ -847,9 +505,6 @@ export function normalizeInventionTags(autoTags: string[], customTags: string[])
export const INVENTION_PERMISSION = {
unassigned: 0,
limitedoneuseonly: 10,
// Recovered from the client's own ladder; nothing here sends it, and no name for it
// appears in `v1/update`'s picker.
disallowkeylock: 15,
useonly: 20,
editandsave: 40,
publish: 60,
@@ -857,39 +512,6 @@ export const INVENTION_PERMISSION = {
unlimited: 100,
} as const
/**
* Where a published invention may be FOUND, which `v4/publish` sets and nothing before it
* did — every record written before that endpoint carries 0, the value a save mints.
*
* Only `unlisted` is recovered from the client for certain; the other two mirror the room
* accessibility enum, which they match member-for-member, and the publish sheet sends 1 for
* an ordinary publish.
*
* Note what that leaves ambiguous: a stored 0 is either "private" or "written before this
* enum meant anything", and the two are indistinguishable without a backfill. So the browse
* filter excludes `unlisted` by name rather than requiring `public` — the latter reads
* every invention published through `v3/publish` as private and empties the feeds.
*/
export const INVENTION_ACCESSIBILITY = {
private: 0,
public: 1,
unlisted: 2,
} as const
/**
* The "anyone may come across this" test the browse feeds and search share: published, not
* hidden, and not unlisted. An unlisted invention is still reachable BY ID — that is what
* unlisted means — so the by-id reads deliberately don't apply it.
*
* `idx_invention_feed_newest` (SCHEMA_DDL, migrations/0027) is a partial index over exactly
* these terms — edit one here and edit it there, or the feed goes back to a full scan.
*/
const VISIBLE_IN_FEEDS = [
'is_published = 1',
'hide_from_player = 0',
`COALESCE(json_extract(data, '$.Accessibility'), 0) <> ${INVENTION_ACCESSIBILITY.unlisted}`,
]
/**
* Parse a permission level the way the client sends it: a name (`useonly`,
* `edit_and_save`) or the raw number. Undefined when it's neither.
@@ -910,13 +532,6 @@ export interface InventionPatch {
imageName?: string
allowTrial?: boolean
generalPermission?: number
/**
* The rest of what `v2/metadata` can edit. Undefined leaves the stored value alone,
* which is how both editors say "not this field" — `v1/update` by omitting the query
* param, `v2/metadata` by sending the key as null.
*/
longDescription?: string
tags?: InventionTag[]
}
/**
@@ -941,27 +556,11 @@ export async function updateInvention(
ImageName: patch.imageName ?? invention.ImageName,
AllowTrial: patch.allowTrial ?? invention.AllowTrial,
GeneralPermission: patch.generalPermission ?? invention.GeneralPermission,
// Both of these are optional ON the record, so an untouched one resolves to
// undefined and JSON.stringify drops the key — an invention that never had a long
// description doesn't acquire an empty one by being edited.
LongDescription: patch.longDescription ?? invention.LongDescription,
Tags: patch.tags ?? invention.Tags,
}
await writeInvention(db, updated)
return updated
}
/**
* What a publish decides. Each is optional and an omitted one keeps what the invention
* has — except the permission, which falls back to UseOnly, the level the older
* `v3/publish` has always defaulted to when its query string named none.
*/
export interface InventionPublish {
permissionLevel?: number
accessibility?: number
price?: number
}
/**
* Publish an invention (`v3/publish`) — what puts it into search and the feeds.
* Publishing sets the permission other players get (UseOnly unless the creator asks
@@ -971,7 +570,8 @@ export interface InventionPublish {
export async function publishInvention(
db: D1Database,
inventionId: number,
publish: InventionPublish = {}
permissionLevel: number | undefined,
price: number | undefined
): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
@@ -979,13 +579,8 @@ export async function publishInvention(
const updated: SavedInvention = {
...invention,
IsPublished: true,
GeneralPermission: publish.permissionLevel ?? INVENTION_PERMISSION.useonly,
Accessibility: publish.accessibility ?? invention.Accessibility,
// An unmentioned price is the price it already has, not zero: a republish that says
// nothing about money must not quietly give away something that was for sale. A
// first publish is unaffected — a fresh invention's price is 0 either way.
Price: publish.price ?? invention.Price,
// The FIRST publish is the one that gets dated; re-publishing doesn't reset it.
GeneralPermission: permissionLevel ?? INVENTION_PERMISSION.useonly,
Price: price ?? 0,
FirstPublishedAt: invention.FirstPublishedAt ?? new Date().toISOString(),
}
await writeInvention(db, updated)
@@ -1008,110 +603,6 @@ export async function setInventionPrice(
return updated
}
/**
* Delete an invention (`v2/delete`), returning the record that was removed, or null
* when there's no such row. The whole invention lives in the one JSON blob, so its
* versions, tags and referenced-invention lists go with it in a single DELETE.
*
* Two things are deliberately LEFT behind.
*
* The data blob in R2 stays: it is named by the file the creator uploaded through the
* `storage` worker, and nothing here knows whether another record still points at that
* name (a converted invention carries the same lineage, and a save that reuses a
* filename reuses the object). An orphan blob costs storage; a missing one breaks
* whatever still references it.
*
* The `inventory_invention` rows stay too — deleting a creator's invention must not
* rewrite what other players bought. They already fall out of every list on their own:
* `getMyInventions` resolves owned ids against this table and an id with no row left
* simply drops out, and `ownsAllInventions` reads a missing row as not-owned. Purging
* them would also erase the acquisition history that ranks the "top today" feed.
*/
export async function deleteInvention(
db: D1Database,
inventionId: number
): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
await db.batch([
db.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId),
db.prepare('DELETE FROM invention_interaction WHERE invention_id = ?1').bind(inventionId),
])
return invention
}
/**
* Set or clear one player's cheer on an invention and resync the invention's denormalized
* `CheerCount`. Repeating either state is idempotent because the interaction row is keyed by
* `(player_id, invention_id)` and the public count is always derived from those rows.
*/
export async function setInventionCheer(
db: D1Database,
playerId: number,
inventionId: number,
cheer: boolean
): Promise<number> {
await db
.prepare(
`INSERT INTO invention_interaction (player_id, invention_id, cheered, created_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(player_id, invention_id) DO UPDATE SET cheered = ?3`
)
.bind(playerId, inventionId, cheer ? 1 : 0, new Date().toISOString())
.run()
const row = await db
.prepare(
'SELECT COUNT(*) AS n FROM invention_interaction WHERE invention_id = ?1 AND cheered = 1'
)
.bind(inventionId)
.first<{ n: number }>()
const count = row?.n ?? 0
await db
.prepare(
"UPDATE invention SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1"
)
.bind(inventionId, count)
.run()
return count
}
/** Whether one player currently cheers an invention. */
export async function isInventionCheered(
db: D1Database,
playerId: number,
inventionId: number
): Promise<boolean> {
const row = await db
.prepare(
`SELECT 1 AS found FROM invention_interaction
WHERE player_id = ?1 AND invention_id = ?2 AND cheered = 1`
)
.bind(playerId, inventionId)
.first<{ found: number }>()
return row !== null
}
/**
* What `v2/delete` answers: the same `{ Value, Success, Error, error_id }` envelope the
* other newer-client invention routes use, with `Value` always NULL — the invention is
* gone, so there is nothing for the client to redraw from and it reads only `Success`
* (and `Error`, the one string that reaches a human). This is why the delete does not
* borrow {@link InventionSaveV9Result}: that envelope's `Value` carries an invention the
* client dereferences, and a delete has none to give.
*/
export interface InventionDeleteResult {
Value: null
Success: boolean
Error: string | null
error_id: string | null
}
/** The delete envelope: a refusal when given a message, success when given null. */
export function inventionDeleteResult(error: string | null = null): InventionDeleteResult {
return { Value: null, Success: error === null, Error: error, error_id: null }
}
/** The tag filter chips the client offers when browsing inventions. */
export interface InventionTagFilters {
PinnedFilters: string[]
@@ -1188,7 +679,8 @@ export async function getInventionsByRoom(
.prepare(
`SELECT data FROM invention
WHERE json_extract(data, '$.CreationRoomId') = ?1
AND ${VISIBLE_IN_FEEDS.join(' AND ')}`
AND is_published = 1
AND hide_from_player = 0`
)
.bind(roomId)
.all<InventionRow>()
@@ -1198,14 +690,6 @@ export async function getInventionsByRoom(
.slice(skip, skip + take)
}
/**
* The `version` that means "whichever is current" rather than a version number to match.
*
* Zero is not a version any invention has — a fresh save is version 1 — so a caller sending
* it does not know which version it wants, and reading it literally finds nothing.
*/
const CURRENT_INVENTION_VERSION = 0
/**
* A single version of an invention (`v1/version?inventionId=…&version=…`), which
* is how the client resolves the blob to download for a given version number.
@@ -1214,13 +698,6 @@ const CURRENT_INVENTION_VERSION = 0
* (there's no `v4/addversion` yet), and a fresh save is always version 1. So this
* answers for the current version number and reports null for any other, rather
* than inventing a version whose blob doesn't exist.
*
* VERSION 0 is the exception: it means "whichever version is current" rather than a
* version number to match, and gets {@link CURRENT_INVENTION_VERSION}. The client asks
* for 0 when it has an invention id but no version to go with it — a discovery row or a
* spawn that carries the id alone — and there is no version 0 to find, so matching it
* literally 404s and the invention silently fails to load. Answering with the current
* version is what it would have asked for had it known the number.
*/
export async function getInventionVersion(
db: D1Database,
@@ -1230,12 +707,7 @@ export async function getInventionVersion(
): Promise<InventionVersion | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
if (
versionNumber !== CURRENT_INVENTION_VERSION &&
invention.CurrentVersionNumber !== versionNumber
) {
return 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
+42 -624
View File
@@ -50,13 +50,6 @@ export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer t
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/**
* A bearer token is honoured but not required: anonymous is a valid alternative. For routes
* that serve public data but show more to a known caller (a creator's own unpublished
* custom avatar items) instead of 401ing.
*/
export const OPTIONAL_AUTHED: OpenAPIV3_1.SecurityRequirementObject[] = [{}, { 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' } }
@@ -98,9 +91,6 @@ 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 integer (e.g. `/api/customAvatarItems/v1/minPriceForPublicItem`). */
export const BareInteger = z.number().int()
/** A bare JSON string (`POST /api/sanitize/v1` echoes one back). */
export const BareString = z.string()
@@ -119,14 +109,12 @@ export const SuccessErrorEnvelope = z.object({
// ---- Config ----------------------------------------------------------------
/** `GET /api/config/v1/amplitude` — the client's analytics keys (blank; RudderStack and StatSig off). */
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
export const AmplitudeConfig = z.object({
AmplitudeKey: z.string(),
UseRudderStack: z.boolean(),
RudderStackKey: z.string(),
UseStatSig: z.boolean(),
StatSigKey: z.string(),
StatSigEnvironment: z.number().int(),
RudderStackKey: z.string(),
UseRudderStack: z.boolean(),
})
/** `GET /api/config/v1/azurespeech` — speech-to-text config; `Enabled` is false here. */
@@ -234,23 +222,6 @@ export const DeleteMessagesRequest = z.object({
MessageIds: z.array(z.int()).describe('Ids of the messages to delete'),
})
/**
* One stored message, as `GET /api/messages/v2/get` serves it — the client's Message model,
* which is also what the `MessageReceived` frame carries (see the `notify` worker's
* `MessageReceivedPayload`). Same shape in the inbox and on the wire, deliberately: they are
* one message, and `Id` is the same id in both so the client can match them up.
*/
export const MessageDto = z.object({
Id: z.int().describe('The message’s id — also the `Id` on the frame that delivered it'),
FromPlayerId: z.int(),
ToPlayerId: z.int(),
SentTime: z.string().describe('ISO-8601 UTC'),
Type: z.int().describe('The Message-model type (a different enum from `NotificationType`)'),
Data: z.string().nullable().describe('Null on the types carrying no payload of their own'),
RoomId: z.int().nullable(),
PlayerEventId: z.int().nullable(),
})
/**
* `POST /api/messages/v1/friendOnlineStatus` — how many of the caller's friends are
* online, wrapped in the client's `{ success, value }` envelope.
@@ -279,20 +250,16 @@ export const MutualFriendDto = z.object({
// ---- Progression -----------------------------------------------------------
/**
* A player's reputation (cheer counters), read from the `reputation` table. A player
* nobody has cheered yet has no row and reads back all-zero with full cheer credit.
* `SelectedCheer` is an int (0 = none), not null, and `IsCheerful` is a bool the client
* reads to decide whether the player may hand out cheers at all.
*
* `CheerCredit` is the odd one out: it is what the player has left to GIVE (out of 20 per
* day), not something they have received, and it comes from `player_cheer` rather than
* from the reputation row.
* 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('The cheer pinned to the profile; 0 = none selected'),
SelectedCheer: z.int().describe('0 = none selected'),
CheerCredit: z.int(),
CheerGeneral: z.int(),
CheerHelpful: z.int(),
@@ -310,154 +277,9 @@ export const ProgressionDto = z.object({
XP: z.int(),
})
/**
* The form body of `POST /api/PlayerCheer/v1/create`. Nothing here is stored beyond the
* counter the cheer increments: `Anonymous` is spent on the notification it triggers and
* `RoomId` is dropped outright — see the route.
*/
export const CheerPlayerRequest = z.object({
PlayerIdTo: z.string().describe('The account being cheered'),
CheerCategory: z
.string()
.describe('0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative'),
RoomId: z
.string()
.optional()
.describe(
'The room it happened in. Accepted but NOT used — the audience for the cheer’s ' +
'effect comes from the caller’s live presence, so a client cannot aim it at a room ' +
'it is not in'
),
Anonymous: z
.string()
.optional()
.describe(
'`True`/`False` (default `False`). Not stored — it picks the `PlayerCheerAnonymous` ' +
'message type (sender 0) over `PlayerCheer` for the frame that plays the cheer'
),
})
/** The form body of `POST /api/PlayerCheer/v1/SetSelectedCheer`. */
export const SetSelectedCheerRequest = z.object({
CheerCategory: z
.string()
.describe(
'The category to pin: 0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative; -1 unpins'
),
})
/**
* What a cheer answers — the reference's PascalCase `{ Success, Message }`, NOT the
* lowercase `{ success, error }` envelope the reports use, and `Message` is NULL on success
* where that one sends an empty string. On a refusal it names the reason (out of credit,
* bad category, cheering yourself), which the client shows the player.
*/
export const CheerPlayerResponse = z.object({
Success: z.boolean(),
Message: z.string().nullable().describe('Null when the cheer landed'),
})
/** The `metadata` JSON field of a custom-avatar-item creation. */
export const CreateCustomAvatarItemMetadata = z.object({
Name: z.string(),
Description: z.string().optional(),
Price: z.number().int().optional(),
BaseAvatarItemId: z.number().int(),
BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'),
Accessibility: z.number().int().optional(),
})
/** The multipart body `POST /api/customAvatarItems/v1` takes. */
export const CreateCustomAvatarItemRequest = z.object({
metadata: z.string().describe('JSON `CreateCustomAvatarItemMetadata`, posted as a text field'),
thumbnailImage: z.string().describe('The thumbnail PNG (binary file part)'),
design: z.string().describe('The design blob (binary file part)'),
})
/**
* One built version of a first-party item, PascalCase as it rides inside `CurrentSaves`: the
* Unity assetbundle the client downloads to render the item on one `BodyType`, its hash, and
* the thumbnail the store shows. `AdditionalConfiguration` is the client's own JSON-in-a-string.
* Not the camelCase `CustomAvatarItemSave` of the legacy-item lookup below — same save,
* different casing and field set.
*/
export const CustomAvatarItemCurrentSave = z.object({
CustomAvatarItemSaveId: z.int(),
CustomAvatarItemId: z.string(),
UnityAssetId: z.string(),
BodyType: z.int(),
OutfitType: z.int(),
QAState: z.int(),
CreatedAt: z.string(),
ModifiedAt: z.string(),
Description: z.string().nullable(),
ThumbnailFileName: z.string(),
AdditionalConfiguration: z.string().describe('JSON-in-a-string'),
UnityAsset: z.string(),
UnityAssetHash: z.string(),
UnityAsset2: z.string().nullable(),
UnityAsset2Hash: z.string().nullable(),
})
/**
* The client's `CustomAvatarItem` record, served straight out of the row's JSON. A player-made
* shirt has a base item and a design PNG and no saves; a first-party item has the four
* base/filename fields null and is rendered from `CurrentSaves` instead.
*/
export const CustomAvatarItemDto = z.object({
CustomAvatarItemId: z.string(),
CreatorAccountId: z.number().int(),
Name: z.string(),
Description: z.string(),
Price: z.number().int(),
Accessibility: z.number().int(),
ForceCannotPublish: z.boolean(),
IsFeatured: z.boolean(),
IsRecRoomApproved: z.boolean(),
BaseAvatarItemId: z.number().int().nullable(),
BaseAvatarItemColor: z.string().nullable(),
DesignFilename: z.string().nullable(),
ThumbnailImageFilename: z.string().nullable(),
CreatedAt: z.string(),
ModifiedAt: z.string(),
PreviewOrientation: z.number().int(),
RankingContext: z.null(),
OutfitType: z.number().int(),
CurrentSaves: z.array(CustomAvatarItemCurrentSave),
Tags: z.array(z.object({ TagType: z.int(), Value: z.string() })),
CustomBadgeMetadata: z.unknown().nullable(),
RankedEntityId: z.string(),
PurchaseInfo: z.null(),
})
/** The JSON body `PUT /api/customAvatarItems/v1/:id` takes; null leaves a field unchanged. */
export const UpdateCustomAvatarItemRequest = z.object({
Name: z.string().nullable().optional(),
Description: z.string().nullable().optional(),
Price: z.number().int().nullable().optional(),
Accessibility: z.number().int().nullable().optional(),
})
/** A bare list of custom avatar items (the featured feed). */
export const CustomAvatarItemList = z.array(CustomAvatarItemDto)
/** The PascalCase `{ Value, Success, Error, error_id }` envelope custom-avatar-item routes answer with. */
export const CustomAvatarItemResponse = z.object({
Value: CustomAvatarItemDto.nullable(),
Success: z.boolean(),
Error: z.string().nullable(),
error_id: z.string().nullable(),
})
/**
* The `Ids` form body the bulk POST endpoints take, in either of the two spellings the
* client sends: `Ids` REPEATED once per id (`Ids=101&Ids=102&Ids=103`) or a single
* comma-separated `Ids=1,2,3`. Both are read by `parseFormIds`.
*/
/** The `Ids` form body the bulk POST endpoints take. */
export const BulkIdsRequest = z.object({
Ids: z
.union([z.string(), z.array(z.string())])
.describe('Repeated (`Ids=101&Ids=102`) or comma-separated (`Ids=1,2,3`)'),
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
})
// ---- Inventions ------------------------------------------------------------
@@ -477,10 +299,6 @@ export const InventionVersionDto = z.object({
ChipsCost: z.int(),
CloudVariablesCost: z.int(),
AICost: z.int(),
HasBetaContent: z
.boolean()
.optional()
.describe('Set from `v9/save` on — absent on a version saved through `v6/save`'),
})
/** A tag on an invention. `Type` 0 = custom (creator-submitted), 2 = auto-derived. */
@@ -517,30 +335,10 @@ export const InventionDto = z.object({
AllowTrial: z.boolean(),
HideFromPlayer: z.boolean(),
ReferencedInventions: z.array(z.int()),
ReferencedUnityAssetIds: z
.array(z.string())
.optional()
.describe('Set from `v9/save` on — absent on an invention saved through `v6/save`'),
UgcVersion: z
.int()
.optional()
.describe('An invention field, not a version one — set from `v9/save` on'),
LongDescription: z.string().optional().describe('Set from `v9/save` on, when non-empty'),
DisplayMetadataJson: z
.string()
.optional()
.describe('The client’s own display state, stored as the opaque string it sent'),
ConvertedFromInventionId: z
.int()
.optional()
.describe('The invention this one was converted from, when `v9/save` named one'),
Tags: z
.array(InventionTagDto)
.optional()
.describe(
'The real RRInvention carries no Tags field. Unset by `v6/save`; set by `v9/save` ' +
'when its `tagsRequest` names at least one tag'
),
.describe('Unset on save — the real RRInvention carries no Tags field'),
})
/** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */
@@ -550,88 +348,6 @@ export const InventionSaveResult = z.object({
InventionVersion: InventionVersionDto,
})
/**
* The `Invention` a v9 save answers with — the newer client's own `RRInvention`, which is
* not the record this server stores or the read endpoints serve: no nested
* `CurrentVersion` (the version rides beside it), no `Referenced*` (those moved onto the
* version), no `IsPublished`.
*/
export const InventionV9Dto = z.object({
InventionId: z.int(),
ReplicationId: z.string(),
CreatorPlayerId: z.int(),
Name: z.string(),
Description: z.string(),
ImageName: z.string(),
UgcVersion: z.int().describe('The UGC format the blob was written in; 0 when unsent'),
CurrentVersionNumber: z.int(),
LatestVersionNumber: z.int().describe('The same as CurrentVersionNumber on a fresh save'),
Accessibility: z.int(),
ForceCannotPublish: z.boolean().describe('Always false — nothing here forbids publishing'),
ModifiedAt: z.string(),
CreatedAt: z.string(),
FirstPublishedAt: z.string().nullable(),
CreationRoomId: z.int().nullable(),
NumPlayersHaveUsedInRoom: z.int(),
NumDownloads: z.int(),
CheerCount: z.int(),
CreatorPermission: z.int(),
GeneralPermission: z.int(),
IsAGInvention: z.boolean(),
IsCertifiedInvention: z.boolean(),
IsRecRoomApproved: z.boolean().describe('Always false — nothing here approves an invention'),
AllowTrial: z.boolean(),
Price: z.int().nullable(),
HideFromPlayer: z.boolean(),
DisplayMetadataJson: z.string().nullable(),
})
/**
* The `InventionVersion` a v9 save answers with. It carries `HasBetaContent`, a `CreatedAt`
* of its own and a nullable `UgcAccessibility`, and notably no `AICost` — which the request
* still sends and this server still stores.
*/
export const InventionVersionV9Dto = z.object({
InventionId: z.int(),
ReplicationId: z.string(),
VersionNumber: z.int(),
HasBetaContent: z.boolean(),
InstantiationCost: z.int(),
LightsCost: z.int(),
ChipsCost: z.int(),
CloudVariablesCost: z.int(),
BlobName: z.string(),
BlobHash: z.string().nullable(),
CreatedAt: z.string(),
UgcAccessibility: z.int().nullable().describe('Always null — versions carry no accessibility'),
ReferencedInventions: z.array(z.int()),
ReferencedUnityAssetIds: z.array(z.string()),
})
/**
* What `v9/save` answers — the enveloped result. The client checks `Success` and then reads
* `Value.Invention.InventionId`; `Error` is the only text it shows a human, and `Status`,
* `InventionVersion` and `TagsResponse` are deserialized and never read. `Success: true`
* with a null `Value` crashes it, so a refusal is `Success: false` with `Value: null`.
*/
export const InventionSaveV9Result = z.object({
Value: z
.object({
Status: z.int().describe('0 = success; the client never reads it on this route'),
Invention: InventionV9Dto,
InventionVersion: InventionVersionV9Dto,
TagsResponse: z.object({
Result: z.int().describe('0 = success; non-zero when a tag broke the tag rule'),
Tags: z.array(z.string()).describe('The stored tag NAMES, auto first, then custom'),
}),
})
.nullable()
.describe('Null when Success is false — and only then'),
Success: z.boolean(),
Error: z.string().nullable().describe('The refusal message; the only text the client shows'),
error_id: z.string().nullable().describe('Always null'),
})
/** 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()),
@@ -647,13 +363,7 @@ 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('Whether the caller currently cheers this invention'),
})
/** `POST /api/inventions/v1/cheer` JSON body. */
export const InventionCheerRequest = z.object({
InventionId: z.int().describe('The invention whose cheer state is changing'),
Cheer: z.boolean().describe('True to cheer; false to remove the cheer'),
IsCheering: z.boolean().describe('Always false — nothing can cheer an invention yet'),
})
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
@@ -675,113 +385,6 @@ export const SetTagsResponse = z.object({
Tags: z.array(z.string()).describe('Auto tags first, then custom'),
})
/**
* `PUT /api/inventions/v2/metadata` JSON body — PascalCase, and every field but the id is
* NULLABLE: the newer client sends the whole shape on every edit and marks the fields it
* isn't touching as null. An empty string is not a null — it clears the field.
*/
export const UpdateInventionMetadataRequest = z.object({
InventionId: z.int(),
Name: z
.string()
.nullable()
.optional()
.describe('3–24 chars, letters/digits/spaces/dashes/colons; null leaves it alone'),
Description: z.string().nullable().optional().describe('Max 512 chars; empty clears it'),
LongDescription: z.string().nullable().optional().describe('Empty clears it'),
ImageName: z.string().nullable().optional().describe('New thumbnail; empty clears it'),
TagsRequest: z
.object({
AutoTags: z.array(z.string()).nullable().optional(),
CustomTags: z.array(z.string()).nullable().optional(),
})
.nullable()
.optional()
.describe('Replaces both lists wholesale, as `v1/settags` does; null leaves them alone'),
})
/**
* `POST /api/inventions/v2/update` JSON body — the newer client's counterpart to
* `v1/update`'s query string. PascalCase, and nullable the way `v2/metadata`'s is: a null
* field keeps what the invention already has, so this is a patch rather than a replace.
*
* Only `InventionId` and `Permission` are confirmed from a live capture (the permission
* picker sends exactly those two). The rest are accepted defensively — they are the fields
* `v1/update` took that `v2/metadata` does not — so that a build sending one gets it
* applied instead of silently dropped.
*/
export const UpdateInventionRequest = z.object({
InventionId: z.int(),
Permission: z
.int()
.nullable()
.optional()
.describe(
'The `GeneralPermission` other players get, as a raw ladder number: Unassigned 0, ' +
'LimitedOneUseOnly 10, DisallowKeyLock 15, UseOnly 20, EditAndSave 40, Publish 60, ' +
'Charge 80, Unlimited 100. Unlike `v4/publish`, null LEAVES IT ALONE rather than ' +
'falling back to UseOnly — an edit that names no permission is not a demotion'
),
AllowTrial: z
.boolean()
.nullable()
.optional()
.describe('Whether other players may trial it; null leaves it alone'),
Name: z
.string()
.nullable()
.optional()
.describe('3–24 chars, letters/digits/spaces/dashes/colons; null leaves it alone'),
Description: z.string().nullable().optional().describe('Max 512 chars; empty clears it'),
LongDescription: z.string().nullable().optional().describe('Empty clears it'),
ImageName: z.string().nullable().optional().describe('New thumbnail; empty clears it'),
})
/**
* `POST /api/inventions/v4/publish` JSON body — PascalCase, and nullable the way
* `v2/metadata`'s is: a null field keeps what the invention already has.
*/
export const PublishInventionRequest = z.object({
InventionId: z.int(),
Permission: z
.int()
.nullable()
.optional()
.describe(
'The `GeneralPermission` other players get, as a raw ladder number: Unassigned 0, ' +
'LimitedOneUseOnly 10, DisallowKeyLock 15, UseOnly 20, EditAndSave 40, Publish 60, ' +
'Charge 80, Unlimited 100. Null publishes as UseOnly'
),
Accessibility: z
.int()
.nullable()
.optional()
.describe('Private 0, Public 1, Unlisted 2. Unlisted stays out of browse and search'),
Price: z
.int()
.nullable()
.optional()
.describe('Price in tokens; null leaves it as it is, and a negative one is ignored'),
})
/** `POST /api/inventions/v2/delete` JSON body — the id and nothing else. */
export const DeleteInventionRequest = z.object({
InventionId: z.int().describe('The invention to delete; the caller must have created it'),
})
/**
* What `v2/delete` answers — the same `{ Value, Success, Error, error_id }` envelope the
* other newer-client invention routes use, with `Value` always null. The invention is
* gone, so there is nothing for the client to redraw from: it reads `Success`, and
* `Error` when that is false.
*/
export const InventionDeleteResult = z.object({
Value: z.null().describe('Always null — the invention no longer exists'),
Success: z.boolean(),
Error: z.string().nullable().describe('The refusal message; null on success'),
error_id: z.string().nullable().describe('Always null'),
})
/** `POST /api/inventions/v1/updateprice` JSON body. */
export const UpdatePriceRequest = z.object({
InventionId: z.int(),
@@ -809,34 +412,6 @@ export const SaveInventionRequest = z.object({
aiCost: z.int().optional(),
creationRoomId: z.int().optional(),
referencedInventions: z.array(z.int()).optional(),
creatorAccountRole: z
.int()
.optional()
.describe('Accepted and ignored — a room role, not a permission over the invention'),
})
/**
* `POST /api/inventions/v9/save` JSON body — `v6`’s fields plus what the invention
* points at, what it says about itself, and the tags that used to need a second
* `v1/settags` call.
*/
export const SaveInventionV9Request = SaveInventionRequest.extend({
ugcVersion: z.int().optional().describe('The UGC format the blob was written in'),
hasBetaContent: z.boolean().optional(),
referencedUnityAssetIds: z.array(z.string()).optional(),
longDescription: z.string().optional().describe('Stored when non-empty'),
displayMetadataJson: z
.string()
.optional()
.describe('Opaque client display state, e.g. `{"0":0,"99":0}`; stored verbatim'),
convertedFromInventionId: z.int().nullable().optional(),
tagsRequest: z
.object({
AutoTags: z.array(z.string()).nullable().optional(),
CustomTags: z.array(z.string()).nullable().optional(),
})
.optional()
.describe('The same two lists `v1/settags` takes, folded into the save'),
})
// ---- Avatar / custom avatar items ------------------------------------------
@@ -848,7 +423,7 @@ export const GeneratedGift = z.object({
ConsumableItemDesc: z.string(),
AvatarItemDesc: z.string(),
FriendlyName: z.string(),
AvatarItemType: z.int().nullable().describe('Null — a token box holds no avatar item'),
AvatarItemType: z.int(),
EquipmentPrefabName: z.string(),
EquipmentModificationGuid: z.string(),
CurrencyType: z.int(),
@@ -879,18 +454,11 @@ export const BulkCustomAvatarItemsRequest = z.object({
customAvatarItemIds: z
.array(z.string())
.describe('The ids to resolve; repeat the field once per id'),
unityAssetTarget: z
.int()
.optional()
.describe(
'The Unity build target the assetbundles are wanted for: 0 PC (Windows), 2 ' +
'Android/Oculus — which is served the `quest/` builds. Read off the query string too'
),
})
/** A paginated custom-avatar-item page, out of the `custom_avatar_item` table. */
/** A paginated custom-avatar-item page (no storage yet, so always empty). */
export const CustomAvatarItemsPage = z.object({
Results: CustomAvatarItemList,
Results: JsonArray,
TotalResults: z.int(),
})
@@ -1118,23 +686,13 @@ export const PlayerEventDetailsDto = PlayerEventDto.extend({
})
/**
* The client's BASE event, 17 keys — the stored record minus `State`, with `ImageName` as a
* string (`""`, not null) and a `BroadcastingRoomInstanceId` (always null — nothing
* broadcasts an event yet). It is also what the v2 envelope carries once `Tags` is added.
* The client's BASE event, 17 keys — what `GET /api/playerevents/v1` serves, and what the
* v2 envelope carries once `Tags` is added. The stored record minus `State`, with
* `ImageName` as a string (`""`, not null) and a `BroadcastingRoomInstanceId` (always null —
* nothing broadcasts an event yet).
*
* THREE reads serve exactly this, through one generic helper on the client and one element
* type: the browse feed (`GET /api/playerevents/v1`), the room shelf
* (`GET /api/playerevents/v1/room/{roomId}`) and the bulk read
* (`POST|GET /api/playerevents/v1/bulk`). They are shape-identical by construction on the
* client side; keep them that way here.
*
* The remaining reads — by id, search, searchlive and the club feeds — serve the stored
* RECORD verbatim, `State` and nullable `ImageName` included. Two projections; don't unify
* them.
*
* `DefaultBroadcastPermissions` and `CanRequestBroadcastPermissions` are the client's
* broadcast-permission enum, whose members are NOT 0/1/2: None 0, RoomOwners 256, All
* 2147483647. Reading them as an ordinal is the classic way to break broadcast.
* The by-id, bulk and search reads serve the stored RECORD verbatim instead, so don't unify
* the two.
*/
export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName: true }).extend({
ImageName: z.string().describe('Empty string when the event has no image, never null'),
@@ -1150,16 +708,15 @@ export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName:
* image.
*
* `Tags` has two shapes, picked from the caller's build: Rec Room reshaped it without
* minting a new path, so a build newer than `20230414` gets the `{ Tag, Type }` pairs and
* that build, every older one, and any caller whose token names no build get the tag
* NAMES. Neither is the lowercase `{ tag, type }` the v1 read serves. `TagModifyResult.Tags`
* is a name list to every build.
* minting a new path, so a build newer than `20230414` gets the tag NAMES and every older
* one (and any caller whose token names no build) gets the `{ Tag, Type }` pairs. Neither
* is the lowercase `{ tag, type }` the v1 read serves.
*/
export const PlayerEventEnvelopeDto = PlayerEventBaseDto.extend({
Tags: z
.union([z.array(z.string()), z.array(z.object({ Tag: z.string(), Type: z.int() }))])
.describe(
'The event’s tags: `{ Tag, Type }` pairs for a build newer than 20230414, names for ' +
'The event’s tags: names for a build newer than 20230414, `{ Tag, Type }` pairs for ' +
'that build and older'
),
})
@@ -1177,7 +734,7 @@ export const PlayerEventResultDto = z.object({
Result: z.int().describe('0 = success'),
TagModifyResult: z.object({
Result: z.int().describe('0 = success'),
Tags: z.array(z.string()).describe('The tags the event now carries — names to every build'),
Tags: z.array(z.string()).describe('The tags the event now carries'),
}),
})
@@ -1265,9 +822,7 @@ export const PlayerEventResponseDto = z.object({
'When the answer that stands was given — a changed answer updates the row, so this ' +
'moves with it rather than recording the player’s first response'
),
Type: z
.int()
.describe('0 Going, 1 Interested, 2 Can’t go, 3 Pending (invited, not yet answered)'),
Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'),
})
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
@@ -1290,39 +845,6 @@ export const PlayerEventReportRequest = z.object({
Details: z.string().optional().describe('The free-text description the reporter typed'),
})
/**
* `POST /api/inventions/v1/report` JSON body — a report against an invention. JSON, like
* the event report and unlike the form-encoded player report. The reporter is NOT in the
* body: it's the bearer token's player, and neither is the invention's creator, who is
* read from the invention.
*/
/**
* `POST /api/customAvatarItems/v1/{id}/report` JSON body. The item is named by the PATH, not
* the body, and `ReportedPlayerId` arrives NULL — the client does not know who made the item,
* so the creator is read off the item instead.
*/
export const CustomAvatarItemReportRequest = z.object({
ReportCategory: z
.int()
.optional()
.describe('The reason picked in the report UI. Stored verbatim; unmapped'),
Details: z.string().optional().describe('The free-text description the reporter typed'),
ReportedPlayerId: z
.int()
.nullable()
.optional()
.describe('Sent as null and IGNORED — the reported player is the item’s creator'),
})
export const InventionReportRequest = z.object({
InventionId: z.int().describe('The invention being reported'),
ReportCategory: z
.int()
.optional()
.describe('The reason picked in the report UI. Stored verbatim; unmapped'),
Details: z.string().optional().describe('The free-text description the reporter typed'),
})
/** `POST /api/playerevents/v1/bulkInvite` JSON body — who to invite to which event. */
export const PlayerEventBulkInviteRequest = z.object({
PlayerEventId: z.int(),
@@ -1360,62 +882,24 @@ export const VoteToKickReason = z.object({
})
/**
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — the caller's block. With an
* account-wide ban in force (a `report` row with `banned` set) it describes that ban:
* `IsBan` true, the report's `ReportCategory`, a fixed `Message` of "Rule violation", and
* its span as `TimeoutStartedAt` (the report's `created_at`) plus `Duration` (seconds to
* `ban_expires`; int32 max for a permanent ban). Otherwise it is the "not blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`:
* `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and
* `Message` is null — the client distinguishes "no message" from a blank one, so we send
* null where the reference sends an empty string. `IsVoiceModAutoban`/`TimeoutStartedAt`
* are on the DTO but unset by that stub, so they carry their C# defaults (false / null).
*
* Sixteen keys on the wire — every one the 2025 client's `ModerationBlockDetail` formatter
* reads. The seven past the stub's nine (`IsDeviceBan` … `BottomMessageOverride`) are
* block kinds and screen dressings this server never hands out, so they always carry their
* "none" value; they are sent so a decoder that wants the key present finds it.
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
* answer (no ban storage yet), mirroring the reference server's stub
* `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1) rather than 0,
* which is a real category, and `Message` is null — the client distinguishes "no
* message" from a blank one, so we send null where the reference sends an empty string.
* `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but unset by that stub, so
* they carry their C# defaults (false / null).
*/
export const ModerationBlockDetails = z.object({
ReportCategory: z
.int()
.describe(
'The category the ban’s report was filed under; -1 = ReportCategory.Unknown when not blocked (0 is a real category)'
),
Duration: z
.int()
.describe(
'Length of the block in seconds from `TimeoutStartedAt`; 2147483647 (int32 max) for a permanent ban; 0 when not blocked'
),
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
Duration: z.int(),
GameSessionId: z.int(),
IsHostKick: z.boolean().describe('Always false — no host kick is ever recorded here'),
Message: z.string().nullable().describe('“Rule violation” on a ban; null when not blocked'),
PlayerIdReporter: z
.int()
.nullable()
.describe('Always null — the reporter is not shown to the reported'),
IsBan: z.boolean().describe('True when an account-wide ban is in force'),
IsVoiceModAutoban: z.boolean().describe('Always false'),
IsDeviceBan: z.boolean().describe('Always false — bans here are account-wide, not per device'),
IsWarning: z
.boolean()
.describe('Always false — warnings are delivered as notifications, not here'),
VoteKickReason: z.string().nullable().describe('Always null — no vote-kick is recorded here'),
TimeoutStartedAt: z
.string()
.nullable()
.describe(
'When the block began — the ban’s report `created_at` (ISO-8601 UTC); `Duration` runs from it. Null when not blocked'
),
AssociatedAccountUsername: z.string().nullable().describe('Always null'),
ShowCreatorCodeOfConduct: z.boolean().describe('Always false'),
TopMessageOverride: z
.string()
.nullable()
.describe('Always null — the client’s default block-screen text stands'),
BottomMessageOverride: z
.string()
.nullable()
.describe('Always null — the client’s default block-screen text stands'),
IsBan: z.boolean(),
IsHostKick: z.boolean(),
IsVoiceModAutoban: z.boolean(),
Message: z.string().nullable(),
PlayerIdReporter: z.int().nullable(),
TimeoutStartedAt: z.string().nullable(),
})
/**
@@ -1442,27 +926,6 @@ export const CreateReportRequest = z.object({
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
})
/**
* `POST /api/chatreport/createChatReport` form body — a report against one chat message.
* Form-encoded, so everything is a string; only `ChatMessageId` is required. Neither the
* reporter (the bearer token) nor the reported player (the message's sender) is in the body.
*/
export const CreateChatReportRequest = z.object({
ChatMessageId: z.string().describe('Id of the message being reported, e.g. `72`'),
ChatThreadId: z
.string()
.optional()
.describe('The thread the message is in. Accepted and unused — the message names its own'),
ReportCategory: z
.string()
.optional()
.describe(
'The reason picked in the report UI, sent as a NAME (`Discriminatory`) where the ' +
'player report sends a number. Mapped onto the numeric category the table stores'
),
ReportDescription: z.string().optional().describe('The free-text description the reporter typed'),
})
/**
* `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
@@ -1481,51 +944,6 @@ export const CreateWarningRequest = z.object({
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
})
/**
* `POST /api/PlayerReporting/v3/voteToKick` form body — a player calling a vote on
* another. Everything is a string on the wire (it's form-encoded). `Reason` is one of the
* labels `GET /api/PlayerReporting/v1/voteToKickReasons` serves; the voter is NOT in the
* body — it's the bearer token's subject.
*/
export const VoteToKickRequest = z.object({
PlayerId: z
.string()
.describe(
'Account id of the player being voted on — relayed as the frame’s `FromPlayerId`, which is what the client’s prompt names'
),
Response: z.string().describe('The caller’s own vote, e.g. `True`. Not relayed'),
Reason: z
.string()
.optional()
.describe(
'A `voteToKickReasons` label, e.g. `Inactive in games (AFK)` — relayed verbatim as the frame’s `Data`, which is the text the prompt shows. Empty when absent'
),
GameSessionId: z.string().describe('The room instance both players are standing in'),
})
/**
* `POST /api/PlayerReporting/v1/instantKick` JSON body — the players a room's staff are
* ejecting from one live instance. JSON, not a form, unlike its neighbours in this
* controller. `GameSessionId` is the room INSTANCE id (`roomInstanceId`); the kick is
* scoped to it, so a player named here who is standing somewhere else is left alone.
*/
export const InstantKickRequest = z.object({
GameSessionId: z.int().describe('The room instance (game session) to eject them from'),
PlayerIds: z.array(z.int()).describe('Account ids to kick out of that instance'),
})
/** `POST /api/PlayerReporting/v1/roomModKick` form body — one player out of one instance. */
export const RoomModKickRequest = z.object({
PlayerId: z.string().describe('Account id of the player to kick'),
GameSessionId: z
.string()
.describe('The room instance (game session) to eject them from — they must be standing in it'),
Reason: z
.string()
.optional()
.describe('Free text, shown to the kicked player in the kick message'),
})
/** `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'),
+27 -412
View File
@@ -7,48 +7,28 @@
* what a player submitted: the table is a log of exactly what was reported.
*
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql,
* 0017_report_custom_avatar_item.sql, 0020_report_ban_audit.sql and
* 0025_report_chat_message.sql, applied under its own `migrations_table` so it doesn't clash
* with the other workers' migrations that share the database).
* 0009_report_ban.sql and 0011_report_event.sql, applied under its own
* `migrations_table` so it doesn't clash with the other workers' migrations that share
* the database).
*
* The moderation-side READS here — `searchReports`, `getTopReported`, `getBansInForce` —
* are not called by any endpoint on this worker. They back the staff panel served by
* `www` (`/api/staff/*`), which imports them: the endpoints are a recflare addition with
* no counterpart in the real service, and those live on `www` rather than on the workers
* that reimplement the game's own API. The SQL stays here, with the table that owns it.
*
* A reported player EVENT, INVENTION or CUSTOM AVATAR ITEM lands here too, rather than in a
* table of its own: same fields, same moderation life. Such a row carries `event_id`,
* `invention_id` or `custom_avatar_item_id`, and its `reported_player_id` is that thing's
* CREATOR — see `POST /api/playerevents/v1/report`, `POST /api/inventions/v1/report` and
* `POST /api/customAvatarItems/v1/{id}/report`. A reported CHAT MESSAGE is the fourth kind:
* `chat_message_id`, with the message's SENDER as the reported player — see
* `POST /api/chatreport/createChatReport`. The four id columns are mutually exclusive; a row
* with none of them is an ordinary player report. They are separate columns rather than one
* polymorphic id because the keys differ in TYPE (numbers and a guid) and in what they key.
* A reported player EVENT lands here too, rather than in a table of its own: same
* fields, same moderation life. Such a row carries `event_id`, and its
* `reported_player_id` is the event's creator — see `POST /api/playerevents/v1/report`.
*
* A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned`
* on that same row (see `banFromReport`), so the ban carries the evidence for it. It is
* ENFORCED by `match`, which refuses every matchmake for a banned player, and DESCRIBED
* by `/api/PlayerReporting/v1/moderationBlockDetails`, which tells the banned player why
* (via `getActiveBan`). `auth` still issues a banned account a token — that is what lets
* the client reach the block screen — and reads this table only for ban EVASION (an
* account sharing a device or network with a banned one; see bans-db). This is distinct
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player out
* of ONE room, this one out of the game.
* on that same row (see `banFromReport`), so the ban carries the evidence for it. Two
* workers read it — `match` refuses every matchmake for a banned player, and `auth`
* refuses to issue them a token at all — both via `isPlayerBanned`. This is distinct
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player
* out of ONE room, this one out of the game.
*
* `/api/PlayerReporting/v1/moderationBlockDetails` is NOT wired to it yet and still
* answers "not blocked" unconditionally.
*/
/**
* Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql +
* 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql +
* 0020_report_ban_audit.sql + 0025_report_chat_message.sql + 0029_report_ban_lifted.sql).
*
* None of `event_id`, `invention_id`, `custom_avatar_item_id` or `chat_message_id` is
* indexed: each is written on every report of its kind and read by nothing — no query here filters on any of them,
* and the reads that do exist go by player or by the ban flag. 0011's partial index over
* `event_id` was dropped in 0016 rather than mirrored. Add one back alongside the query that
* needs it.
* 0011_report_event.sql).
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS report (
@@ -64,18 +44,12 @@ export const SCHEMA_DDL: string[] = [
created_at TEXT NOT NULL,
banned INTEGER NOT NULL DEFAULT 0,
ban_expires TEXT,
event_id INTEGER,
invention_id INTEGER,
custom_avatar_item_id TEXT,
banned_by_player_id INTEGER,
banned_at TEXT,
chat_message_id INTEGER,
unbanned_by_player_id INTEGER,
unbanned_at TEXT
event_id INTEGER
)`,
`CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_report_banned ON report (reported_player_id) WHERE banned = 1`,
`CREATE INDEX IF NOT EXISTS idx_report_event ON report (event_id) WHERE event_id IS NOT NULL`,
]
/** A stored report row (snake_case columns, one row per submission). */
@@ -102,53 +76,6 @@ export interface ReportRow {
* `reported_player_id` and `room_id` are filled in from the event itself.
*/
event_id: number | null
/**
* The invention this report is against, or NULL for any other kind — mutually exclusive
* with `event_id`. See `POST /api/inventions/v1/report`: `reported_player_id` is the
* invention's creator, read from the invention itself. No `room_id` comes with it; an
* invention isn't tied to one room the way an event is.
*/
invention_id: number | null
/**
* The custom avatar item this report is against, or NULL for any other kind — mutually
* exclusive with the two above. TEXT because such an item is keyed by a GUID where an
* event and an invention are keyed by numbers. See
* `POST /api/customAvatarItems/v1/{id}/report`: `reported_player_id` is the item's
* creator, read from the item, because the client sends `ReportedPlayerId: null` here —
* it does not know who made it.
*/
custom_avatar_item_id: string | null
/**
* The moderator who set `banned` on this row, or NULL when nobody has (and cleared
* again when a ban is lifted). Read per-row by the staff panel; nothing filters on it.
*/
banned_by_player_id: number | null
/**
* ISO-8601 UTC instant the ban was HANDED DOWN — distinct from `created_at`, which is
* when the report was filed, and the two can be months apart. This is what the client's
* block screen counts the ban from (see `banBlockDetails`); rows written before
* 0020_report_ban_audit.sql carry NULL and fall back to `created_at`.
*/
banned_at: string | null
/**
* The chat message this report is against, or NULL for any other kind — mutually
* exclusive with `event_id`, `invention_id` and `custom_avatar_item_id`. See
* `POST /api/chatreport/createChatReport`: `reported_player_id` is the message's SENDER,
* read from the `message` table, because the body names no player. The thread the client
* also posts is not kept — a message id is unique across threads, so it is the whole
* reference.
*/
chat_message_id: number | null
/**
* The moderator who LIFTED the ban that once stood on this row, or NULL when none has
* been lifted — including while one stands: a re-ban clears it, with `unbanned_at`. A
* lift wipes the four ban columns above, so without this pair a lifted ban is
* indistinguishable from a report nobody acted on, and the staff panel offered to ban
* again. Read per row by the panel; nothing filters on it.
*/
unbanned_by_player_id: number | null
/** ISO-8601 UTC instant the ban was lifted; NULL with `unbanned_by_player_id`. */
unbanned_at: string | null
}
/**
@@ -168,12 +95,6 @@ export interface NewReport {
roomInstanceType?: string | null
/** Set only when reporting a player EVENT; absent on an ordinary player report. */
eventId?: number | null
/** Set only when reporting an INVENTION; never set alongside `eventId`. */
inventionId?: number | null
/** Set only when reporting a CUSTOM AVATAR ITEM; never set alongside the two above. */
customAvatarItemId?: string | null
/** Set only when reporting a CHAT MESSAGE; never set alongside the three above. */
chatMessageId?: number | null
}
/** Record a submitted report, returning the stored row (with its assigned id). */
@@ -183,8 +104,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
`INSERT INTO report (
reporter_player_id, reported_player_id, report_category, details,
height_reporter, height_reported, room_id, room_instance_type, created_at,
event_id, invention_id, custom_avatar_item_id, chat_message_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
event_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
RETURNING *`
)
.bind(
@@ -197,10 +118,7 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
input.roomId ?? null,
input.roomInstanceType ?? null,
new Date().toISOString(),
input.eventId ?? null,
input.inventionId ?? null,
input.customAvatarItemId ?? null,
input.chatMessageId ?? null
input.eventId ?? null
)
.first<ReportRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
@@ -208,11 +126,7 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
return row!
}
/**
* Every report filed against a player, newest first — unpaged, because it is the whole
* history a moderator reads on one account (`www`: `GET /api/staff/players/:id`), not a
* feed. `searchReports` is the paged, filtered form for looking across accounts.
*/
/** 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')
@@ -252,9 +166,9 @@ export async function getActiveBan(
}
/**
* Whether a player is banned right now. The hot-path form of `getActiveBan`, for a caller
* that has nothing to say about WHICH report did it. `moderationBlockDetails` is the
* caller that does, and reads `getActiveBan` itself.
* Whether a player is banned right now. The hot-path form of `getActiveBan` — `match`
* calls it on every matchmake and `auth` on every token grant, and neither has anything
* to say about WHICH report did it.
*/
export async function isPlayerBanned(
db: D1Database,
@@ -270,17 +184,6 @@ export async function isPlayerBanned(
* permanent ban. Passing `banned: false` lifts the ban and clears the expiry, leaving the
* report itself intact.
*
* `bannedBy` is the acting moderator, and is recorded alongside `banned_at` — the instant
* the ban was handed down, which is NOT the report's `created_at` and is what the client's
* block screen counts from. A lift clears all four columns together: an unbanned row must
* not keep an audit trail saying a ban runs from somewhere, and `banned = 0` with a
* `banned_at` still set would read as a ban to anything checking the timestamp.
*
* A lift instead SIGNS `unbanned_by_player_id`/`unbanned_at` with the same `bannedBy` and
* the same instant, and a re-ban clears those two — a standing ban has not been lifted.
* That is what lets the staff panel tell "a colleague let this player back in" from
* "nobody has acted on this report", so a second moderator doesn't undo the first.
*
* Returns the updated row, or null when there is no report with that id — so the caller
* can tell "banned" from "banned nobody" (wrangler's `d1 execute --json` reports no
* changes count, hence RETURNING).
@@ -288,299 +191,11 @@ export async function isPlayerBanned(
export async function banFromReport(
db: D1Database,
reportId: number,
options: { banned?: boolean; banExpires?: string | null; bannedBy?: number | null } = {}
options: { banned?: boolean; banExpires?: string | null } = {}
): Promise<ReportRow | null> {
const banned = options.banned ?? true
const now = new Date().toISOString()
return db
.prepare(
`UPDATE report
SET banned = ?2, ban_expires = ?3, banned_by_player_id = ?4, banned_at = ?5,
unbanned_by_player_id = ?6, unbanned_at = ?7
WHERE id = ?1
RETURNING *`
)
.bind(
reportId,
banned ? 1 : 0,
banned ? (options.banExpires ?? null) : null,
banned ? (options.bannedBy ?? null) : null,
banned ? now : null,
banned ? null : (options.bannedBy ?? null),
banned ? null : now
)
.prepare('UPDATE report SET banned = ?2, ban_expires = ?3 WHERE id = ?1 RETURNING *')
.bind(reportId, banned ? 1 : 0, banned ? (options.banExpires ?? null) : null)
.first<ReportRow>()
}
/**
* A page of reports matching a moderator's filters — the staff panel's search
* (`www`: `GET /api/staff/reports`).
*
* Every filter is optional and ANDed; an empty filter set is the whole table, newest
* first. `ORDER BY id DESC` rather than by `created_at`: the id is AUTOINCREMENT, so it is
* already chronological, and it is the primary key — ordering by the timestamp column,
* which is unindexed, would sort the whole result set. The date window is applied to
* `created_at` (an ISO-8601 string, so a lexical comparison IS a chronological one) and
* `to` is EXCLUSIVE, which is what makes a day-boundary window composable.
*
* `banned` filters on the FLAG, not on whether a ban is in force: a moderator reviewing
* what has been actioned wants the expired ones too (`getBansInForce` is the other
* question). The count is a second statement rather than a window function, so the caller
* can page without re-deriving the total — it counts the same predicate, unpaged.
*/
export interface ReportSearch {
reportedPlayerId?: number | null
reporterPlayerId?: number | null
reportCategory?: number | null
/** true = banned rows only, false = unbanned only, null/undefined = both. */
banned?: boolean | null
/** ISO-8601; inclusive lower bound on `created_at`. */
from?: string | null
/** ISO-8601; EXCLUSIVE upper bound on `created_at`. */
to?: string | null
}
/** A page of search results, plus how many rows the filters match in total. */
export interface ReportPage {
reports: ReportRow[]
total: number
}
export async function searchReports(
db: D1Database,
filters: ReportSearch = {},
page: { skip?: number; take?: number } = {}
): Promise<ReportPage> {
// Built as a parallel list of clauses and binds so a filter is added in one place and
// can't drift between the page query and the count query, which share both.
const clauses: string[] = []
const binds: Array<number | string> = []
const where = (sql: string, value: number | string) => {
binds.push(value)
clauses.push(sql.replace('?', `?${binds.length}`))
}
if (filters.reportedPlayerId != null) where('reported_player_id = ?', filters.reportedPlayerId)
if (filters.reporterPlayerId != null) where('reporter_player_id = ?', filters.reporterPlayerId)
if (filters.reportCategory != null) where('report_category = ?', filters.reportCategory)
if (filters.banned != null) where('banned = ?', filters.banned ? 1 : 0)
if (filters.from) where('created_at >= ?', filters.from)
if (filters.to) where('created_at < ?', filters.to)
const predicate = clauses.length === 0 ? '' : ` WHERE ${clauses.join(' AND ')}`
// Clamped rather than trusted: `take` reaches this from a query string, and an
// unbounded one would hand a moderator's browser the entire table.
const take = Math.min(Math.max(page.take ?? 50, 1), 200)
const skip = Math.max(page.skip ?? 0, 0)
const [rows, count] = await db.batch<ReportRow | { total: number }>([
db
.prepare(
`SELECT * FROM report${predicate} ORDER BY id DESC LIMIT ?${binds.length + 1} OFFSET ?${binds.length + 2}`
)
.bind(...binds, take, skip),
db.prepare(`SELECT COUNT(*) AS total FROM report${predicate}`).bind(...binds),
])
return {
reports: rows.results as ReportRow[],
total: (count.results as Array<{ total: number }>)[0]?.total ?? 0,
}
}
/** One report by id, or null. The detail behind a search row. */
export async function getReportById(db: D1Database, reportId: number): Promise<ReportRow | null> {
return db.prepare('SELECT * FROM report WHERE id = ?1').bind(reportId).first<ReportRow>()
}
/** A player who has collected reports, as the staff panel's triage list shows them. */
export interface ReportedPlayerTally {
playerId: number
/** Reports filed against them inside the window. */
reports: number
/**
* How many DIFFERENT accounts filed those reports. The number that actually means
* something: one player filing forty reports against someone they are feuding with
* should not outrank four unconnected players filing one each.
*/
distinctReporters: number
/** `created_at` of the most recent report in the window. */
lastReportAt: string
/** Whether a ban is in force against them right now — so the list can skip the done ones. */
bannedNow: boolean
}
/**
* The players with the most reports against them — the staff panel's triage list
* (`www`: `GET /api/staff/reports/top-reported`).
*
* WINDOWED by default (30 days), because the unwindowed version ossifies: an account that
* collected reports a year ago and stopped would sit at the top forever, above whoever is
* a problem this week. Pass `sinceDays: null` for all time.
*
* Ranked by distinct reporters FIRST and raw count second, for the reason
* `distinctReporters` exists at all. `minReports` keeps the long tail of single reports
* out — almost every report is a one-off, and a list of them is not a triage list.
*
* `GROUP BY reported_player_id` rides `idx_report_reported`. The in-force ban is decided
* in the same statement (a correlated EXISTS over the partial `idx_report_banned`) rather
* than with a round trip per player.
*/
export async function getTopReported(
db: D1Database,
options: { sinceDays?: number | null; minReports?: number; take?: number; now?: Date } = {}
): Promise<ReportedPlayerTally[]> {
const now = options.now ?? new Date()
const sinceDays = options.sinceDays === undefined ? 30 : options.sinceDays
// An all-time window is expressed as a floor no timestamp can precede, so the SQL keeps
// one shape rather than growing a second predicate.
const since =
sinceDays === null
? '0000-01-01T00:00:00.000Z'
: new Date(now.getTime() - sinceDays * 86_400_000).toISOString()
const minReports = Math.max(options.minReports ?? 3, 1)
const take = Math.min(Math.max(options.take ?? 50, 1), 200)
const { results } = await db
.prepare(
`SELECT
r.reported_player_id AS playerId,
COUNT(*) AS reports,
COUNT(DISTINCT r.reporter_player_id) AS distinctReporters,
MAX(r.created_at) AS lastReportAt,
EXISTS (
SELECT 1 FROM report b
WHERE b.reported_player_id = r.reported_player_id AND b.banned = 1
AND (b.ban_expires IS NULL OR b.ban_expires > ?2)
) AS bannedNow
FROM report r
WHERE r.created_at >= ?1
GROUP BY r.reported_player_id
HAVING COUNT(*) >= ?3
ORDER BY distinctReporters DESC, reports DESC, lastReportAt DESC
LIMIT ?4`
)
.bind(since, now.toISOString(), minReports, take)
.all<Omit<ReportedPlayerTally, 'bannedNow'> & { bannedNow: number }>()
return results.map((row) => ({ ...row, bannedNow: row.bannedNow === 1 }))
}
/**
* Every ban in force right now, MOST RECENTLY HANDED DOWN FIRST — the staff panel's
* standing-bans list (`www`: `GET /api/staff/bans`).
*
* "In force" is `getActiveBan`'s test applied to the whole table rather than to one
* player: a row whose `ban_expires` has passed has served its time and is not listed,
* though it stays as the record that it happened. A player with several in force appears
* once per ban — each is a separate report, and which report justified which ban is the
* point of the list.
*
* Ordered by WHEN THE BAN LANDED because the list's job is catching mistakes: the ban most
* likely to be wrong is the one just handed down, and a moderator who has slipped goes
* looking for it at the top. Ordering by severity instead (longest-lasting first, as this
* did) buries a fresh one-day ban under every permanent ban ever issued — exactly the
* wrong way round for the one read this list exists to serve.
*
* `COALESCE(banned_at, created_at)`: a ban set before 0020_report_ban_audit.sql added
* `banned_at` has only its report's date to sort by, which is the same fallback the block
* screen uses. Without the coalesce those rows would sort as NULL — all together at one
* end, in no order at all.
*/
export async function getBansInForce(
db: D1Database,
now: Date = new Date(),
take = 200
): Promise<ReportRow[]> {
const { results } = await db
.prepare(
`SELECT * FROM report
WHERE banned = 1 AND (ban_expires IS NULL OR ban_expires > ?1)
ORDER BY COALESCE(banned_at, created_at) DESC, id DESC
LIMIT ?2`
)
.bind(now.toISOString(), Math.min(Math.max(take, 1), 500))
.all<ReportRow>()
return results
}
// ---- Block details ----------------------------------------------------------
/**
* `Duration` on a permanent ban. The client's field is a 32-bit int of seconds that PAIRS
* with `TimeoutStartedAt` — start + duration is the end of the block — so a ban with no
* end gets the largest value the field holds, 68 years past its start.
*/
export const PERMANENT_BAN_DURATION = 2_147_483_647
/**
* The "not blocked" answer — the reference server's stub `ReturnModerationBlockDetails()`,
* widened to every key the client's `ModerationBlockDetail` decoder names (16 on the wire;
* the 2025 build's formatter reads them all). The ones past the stub's nine are the block
* kinds and screen dressings this server never uses — a device ban, a warning, the
* vote-kick reason, an associated account, the creator code of conduct, the top/bottom
* message overrides — so they carry their "none" values on every answer.
*/
export const NOT_BLOCKED = {
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsHostKick: false,
Message: null,
PlayerIdReporter: null,
IsBan: false,
IsVoiceModAutoban: false,
IsDeviceBan: false,
IsWarning: false,
VoteKickReason: null,
TimeoutStartedAt: null,
AssociatedAccountUsername: null,
ShowCreatorCodeOfConduct: false,
TopMessageOverride: null,
BottomMessageOverride: null,
}
/**
* The block details for a ban in force — the `report` row a moderator set `banned` on.
*
* Shared by the two places a player meets an account ban: `api`'s `moderationBlockDetails`
* (the screen at sign-in) and `www`'s live `ModerationKick` frame (the screen when the ban is
* handed down mid-session). Built once here so both screens describe the same ban.
*
* `Duration` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
* start for the duration. The start is `banned_at`, the instant the ban was handed down
* (see 0020_report_ban_audit.sql), and the duration is the seconds from there to
* `ban_expires`, so the two sum to the expiry; or `PERMANENT_BAN_DURATION` when there is
* none.
*
* It falls back to the report's `created_at` for a row banned before that column existed.
* The two can be months apart, and using `created_at` as the start — which is what this
* did before there was anything else to use — misreports both halves of the pair: a 7-day
* ban applied to a 30-day-old report told the player their block began a month ago and
* ended three weeks ago. Every pre-migration row still reads exactly as it used to, which
* is the point of the fallback rather than a coalesce to now.
*
* The category is the one the report was
* filed under, so the client's ban screen names the reason. `Message` is a fixed "Rule
* violation" rather than the report's `details` — those are the REPORTER's words, and the
* banned player isn't shown them, for the same reason `PlayerIdReporter` stays null: the
* reporter is not a host who kicked them, and naming them would tell the banned player who
* reported them. Everything else keeps its `NOT_BLOCKED` value: the other block kinds and
* screen dressings, none of which this server hands out.
*/
export function banBlockDetails(ban: ReportRow) {
const startedAtIso = ban.banned_at ?? ban.created_at
const startedAt = Date.parse(startedAtIso)
const duration =
ban.ban_expires === null
? PERMANENT_BAN_DURATION
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - startedAt) / 1000))
return {
...NOT_BLOCKED,
ReportCategory: ban.report_category,
Duration: duration,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: startedAtIso,
}
}
-359
View File
@@ -1,359 +0,0 @@
/**
* Player reputation — the cheer counters on a profile — and the daily cheer credit that
* pays for handing one out, on the shared `recflare` D1 database.
*
* Two tables, because they answer two different questions:
*
* - `reputation` is what a player has RECEIVED: one counter per cheer category, plus the
* assorted profile numbers the DTO carries. One row per account, created the first time
* somebody cheers them — a missing row means "nobody has cheered them yet", which is
* exactly the all-zero default the reputation endpoints already served, so reads fall
* back to it rather than inserting on a GET.
* - `player_cheer` is what a player has left to GIVE: a credit that refills to
* {@link DAILY_CHEER_CREDIT} once the window in `created` is a day old. One row per
* account, created the first time they spend one.
*
* One of the client's fields is deliberately NOT a column. `CheerCredit` sits alongside
* the counters in the client's record but is `player_cheer.cheers_left` with the rollover
* applied — storing it twice would let the number a player reads drift from the one the
* spend checks. `SelectedCheer` (the cheer pinned to the profile, set by
* `POST /api/PlayerCheer/v1/SetSelectedCheer`) and `IsCheerful` (a profile flag every
* reference serves as true) ARE columns, added in 0014 — the `ReputationUpdate` frame is
* the record trimmed, nothing more, so both come off the row.
*
* The `api` worker owns the schema/migrations (migrations/0013_reputation.sql and
* 0014_reputation_selected_cheer.sql, applied under its own `migrations_table` so they
* don't clash with the other workers' migrations that share the database).
*/
import { bindPlaceholders, chunkForBinds } from '@repo/domain'
/** Schema DDL (mirror of migrations/0013_reputation.sql + 0014, folded into one CREATE). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS reputation (
account_id INTEGER PRIMARY KEY,
noteriety INTEGER NOT NULL DEFAULT 0,
cheer_general INTEGER NOT NULL DEFAULT 0,
cheer_helpful INTEGER NOT NULL DEFAULT 0,
cheer_creative INTEGER NOT NULL DEFAULT 0,
cheer_great_host INTEGER NOT NULL DEFAULT 0,
cheer_sportsman INTEGER NOT NULL DEFAULT 0,
subscriber_count INTEGER NOT NULL DEFAULT 0,
subscribed_count INTEGER NOT NULL DEFAULT 0,
is_cheerful INTEGER NOT NULL DEFAULT 1,
selected_cheer INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS player_cheer (
player_id INTEGER PRIMARY KEY,
cheers_left INTEGER NOT NULL,
created TEXT NOT NULL
)`,
]
/**
* The cheer categories the client posts as `CheerCategory`. The gaps are the client's —
* the enum steps by ten, so it can grow without renumbering.
*/
export enum CheerCategory {
None = -1,
General = 0,
Helpful = 10,
Sportmanship = 20,
GreatHost = 30,
Creative = 40,
}
/**
* The `reputation` column each category counts into. Doubles as the whitelist the spend
* interpolates into its SQL: a category that isn't a key here never reaches the query.
* `None` is absent deliberately — it is the client's "no category", not a counter.
*/
const CHEER_COLUMN: Partial<Record<CheerCategory, string>> = {
[CheerCategory.General]: 'cheer_general',
[CheerCategory.Helpful]: 'cheer_helpful',
[CheerCategory.Sportmanship]: 'cheer_sportsman',
[CheerCategory.GreatHost]: 'cheer_great_host',
[CheerCategory.Creative]: 'cheer_creative',
}
/** Whether `value` names a category that counts — i.e. anything but `None`. */
export function isCheerCategory(value: number): value is CheerCategory {
return value in CHEER_COLUMN
}
/** How many cheers a player may hand out per window. */
export const DAILY_CHEER_CREDIT = 20
/** How long a credit window lasts before it refills. */
export const CHEER_WINDOW_MS = 24 * 60 * 60 * 1000
/** A stored reputation row (snake_case columns, one row per account). */
interface ReputationRow {
account_id: number
noteriety: number
cheer_general: number
cheer_helpful: number
cheer_creative: number
cheer_great_host: number
cheer_sportsman: number
subscriber_count: number
subscribed_count: number
/** SQLite boolean: 0 / 1. */
is_cheerful: number
selected_cheer: number
}
/**
* A player's reputation as the client's DTO renders it — and, trimmed, as the
* `ReputationUpdate` frame carries it.
*
* `Noteriety` (the reference's spelling), `SubscriberCount` and `SubscribedCount` are
* columns nothing writes yet. `IsCheerful` is a column nothing writes either, defaulted
* true like every reference serves it. `SelectedCheer` is the pinned cheer, 0 = none.
*/
export interface Reputation {
AccountId: number
IsCheerful: boolean
Noteriety: number
SelectedCheer: number
CheerCredit: number
CheerGeneral: number
CheerHelpful: number
CheerCreative: number
CheerGreatHost: number
CheerSportsman: number
SubscriberCount: number
SubscribedCount: number
}
/**
* What a player with no row has: nobody has cheered them, and they hold their full credit.
* `credit` is passed in rather than defaulted because a player can have spent cheers
* without having received any — the two tables are independent.
*/
export function defaultReputation(accountId: number, credit = DAILY_CHEER_CREDIT): Reputation {
return {
AccountId: accountId,
IsCheerful: true,
Noteriety: 0,
SelectedCheer: 0,
CheerCredit: credit,
CheerGeneral: 0,
CheerHelpful: 0,
CheerCreative: 0,
CheerGreatHost: 0,
CheerSportsman: 0,
SubscriberCount: 0,
SubscribedCount: 0,
}
}
/** Project a stored row onto the DTO, with the credit read from `player_cheer`. */
function toReputation(row: ReputationRow, credit: number): Reputation {
return {
AccountId: row.account_id,
IsCheerful: row.is_cheerful !== 0,
Noteriety: row.noteriety,
SelectedCheer: row.selected_cheer,
CheerCredit: credit,
CheerGeneral: row.cheer_general,
CheerHelpful: row.cheer_helpful,
CheerCreative: row.cheer_creative,
CheerGreatHost: row.cheer_great_host,
CheerSportsman: row.cheer_sportsman,
SubscriberCount: row.subscriber_count,
SubscribedCount: row.subscribed_count,
}
}
/**
* The instant a credit window has to have started AFTER for the stored `cheers_left` to
* still apply. Anything at or before it has rolled over. ISO-8601 UTC is fixed-width, so
* SQLite's string comparison orders these correctly — no date functions needed.
*/
function windowCutoff(now: Date): string {
return new Date(now.getTime() - CHEER_WINDOW_MS).toISOString()
}
/**
* How many cheers each of `playerIds` has left to give, in the order asked — a read, so
* a window that has rolled over reads as a full credit WITHOUT writing the reset back.
* The reset is the spend's job; doing it here would refill a player's credit every time
* somebody looked at their profile.
*/
export async function getCheerCredits(
db: D1Database,
playerIds: number[],
now: Date = new Date()
): Promise<Map<number, number>> {
const credits = new Map<number, number>()
if (playerIds.length === 0) return credits
// The window cutoff is `?1`, so the ids start at `?2` and each chunk is one short of
// D1's bind cap — a bulk read over a friends list runs well past it.
const pages = await Promise.all(
chunkForBinds(playerIds, 1).map((chunk) =>
db
.prepare(
`SELECT player_id, cheers_left FROM player_cheer
WHERE created > ?1 AND player_id IN (${bindPlaceholders(chunk, 1)})`
)
.bind(windowCutoff(now), ...chunk)
.all<{ player_id: number; cheers_left: number }>()
)
)
for (const row of pages.flatMap((page) => page.results)) {
credits.set(row.player_id, row.cheers_left)
}
return credits
}
/** One player's remaining cheer credit (see {@link getCheerCredits} — also a pure read). */
export async function getCheerCredit(
db: D1Database,
playerId: number,
now: Date = new Date()
): Promise<number> {
const credits = await getCheerCredits(db, [playerId], now)
return credits.get(playerId) ?? DAILY_CHEER_CREDIT
}
/**
* Reputations for a list of ids, in the order asked and one per id — the bulk lookups
* render a profile card per entry, so an id with no row still gets its default rather
* than being dropped from the list.
*/
export async function getReputations(
db: D1Database,
accountIds: number[],
now: Date = new Date()
): Promise<Reputation[]> {
if (accountIds.length === 0) return []
const [pages, credits] = await Promise.all([
// Chunked: the friends list behind `/api/playerReputation/v2/bulk` runs past D1's
// bind cap, and an unchunked statement fails the whole lookup rather than part of it.
Promise.all(
chunkForBinds(accountIds).map((chunk) =>
db
.prepare(`SELECT * FROM reputation WHERE account_id IN (${bindPlaceholders(chunk)})`)
.bind(...chunk)
.all<ReputationRow>()
)
),
getCheerCredits(db, accountIds, now),
])
const stored = new Map(pages.flatMap((page) => page.results).map((r) => [r.account_id, r]))
return accountIds.map((id) => {
const credit = credits.get(id) ?? DAILY_CHEER_CREDIT
const row = stored.get(id)
return row === undefined ? defaultReputation(id, credit) : toReputation(row, credit)
})
}
/** One player's reputation, defaulted when nobody has cheered them yet. */
export async function getReputation(
db: D1Database,
accountId: number,
now: Date = new Date()
): Promise<Reputation> {
const [reputation] = await getReputations(db, [accountId], now)
return reputation!
}
/**
* Take one cheer out of a player's daily credit, resolving the credit they have left, or
* null when they had none to spend.
*
* One statement, so two cheers fired off together can't both read the same stale credit
* and write it back — the client lets a player cheer several people in a row. The three
* cases fold into the upsert:
*
* - no row: insert one at `DAILY_CHEER_CREDIT - 1`, window starting now;
* - the window rolled over (`created` at or before the cutoff): reset to
* `DAILY_CHEER_CREDIT - 1` and start a fresh window, which is what makes the credit
* refill lazily rather than needing a cron;
* - the window is live: decrement, keeping the window's original start so a player who
* spends all day still refills 24h after their FIRST cheer, not their last.
*
* The `WHERE` on the update is the refusal: a live window with nothing left updates no
* row, so `RETURNING` yields nothing and the caller answers "out of cheers".
*/
export async function spendCheerCredit(
db: D1Database,
playerId: number,
now: Date = new Date()
): Promise<number | null> {
const cutoff = windowCutoff(now)
const row = await db
.prepare(
`INSERT INTO player_cheer (player_id, cheers_left, created) VALUES (?1, ?2, ?3)
ON CONFLICT (player_id) DO UPDATE SET
cheers_left = CASE WHEN player_cheer.created <= ?4
THEN ?2 ELSE player_cheer.cheers_left - 1 END,
created = CASE WHEN player_cheer.created <= ?4
THEN ?3 ELSE player_cheer.created END
WHERE player_cheer.created <= ?4 OR player_cheer.cheers_left > 0
RETURNING cheers_left`
)
.bind(playerId, DAILY_CHEER_CREDIT - 1, now.toISOString(), cutoff)
.first<{ cheers_left: number }>()
return row === null ? null : row.cheers_left
}
/**
* Count a received cheer against `accountId`'s category counter, returning the reputation
* they now hold. Creates the row on the first cheer they ever receive.
*
* The column is looked up in {@link CHEER_COLUMN} rather than built from the category, so
* only the five known names can reach the SQL; an unknown category is rejected by the
* route before it gets here.
*/
export async function addCheer(
db: D1Database,
accountId: number,
category: CheerCategory,
now: Date = new Date()
): Promise<Reputation> {
const column = CHEER_COLUMN[category]
if (column === undefined) throw new Error(`unknown cheer category ${category}`)
const [row, credit] = await Promise.all([
db
.prepare(
`INSERT INTO reputation (account_id, ${column}) VALUES (?1, 1)
ON CONFLICT (account_id) DO UPDATE SET ${column} = reputation.${column} + 1
RETURNING *`
)
.bind(accountId)
.first<ReputationRow>(),
getCheerCredit(db, accountId, now),
])
// RETURNING always yields the upserted row; the non-null assert keeps the caller from
// having to handle an impossible null.
return toReputation(row!, credit)
}
/**
* Pin `category` as `accountId`'s selected cheer — the badge the client shows on their
* profile — returning the reputation they now hold. `None` (-1) unpins it, stored as 0 the
* way the DTO reads "nothing selected". Creates the row if nobody has cheered them yet:
* a player can pin a badge before ever receiving a cheer.
*/
export async function setSelectedCheer(
db: D1Database,
accountId: number,
category: CheerCategory,
now: Date = new Date()
): Promise<Reputation> {
const selected = category === CheerCategory.None ? 0 : category
const [row, credit] = await Promise.all([
db
.prepare(
`INSERT INTO reputation (account_id, selected_cheer) VALUES (?1, ?2)
ON CONFLICT (account_id) DO UPDATE SET selected_cheer = ?2
RETURNING *`
)
.bind(accountId, selected)
.first<ReputationRow>(),
getCheerCredit(db, accountId, now),
])
return toReputation(row!, credit)
}
+103 -1319
View File
@@ -4,33 +4,17 @@ import { describeRoute } from 'hono-openapi'
import {
CURRENT_OUTFIT_SLOT,
getOutfit,
getOutfits,
getOutfitsByAccounts,
inventionDescriptionRejection,
inventionLongDescriptionRejection,
inventionNameRejection,
inventionTagRejection,
MAX_BULK_OUTFIT_ACCOUNTS,
setOutfit,
} from '@repo/domain'
import {
createCustomAvatarItem,
deleteCustomAvatarItem,
getCustomAvatarItem,
getCustomAvatarItems,
isQuestAssetTarget,
listCustomAvatarItemsByCreator,
listFeaturedCustomAvatarItems,
listHotCustomAvatarItems,
searchCustomAvatarItems,
toQuestCustomAvatarItem,
updateCustomAvatarItem,
} from '../custom-avatar-items-db'
import { authedId, unauthorized } from '../http'
import {
createInvention,
deleteInvention,
getFeaturedInventions,
getInventionById,
getInventionsByIds,
@@ -40,280 +24,75 @@ import {
getInventionVersion,
getMyInventions,
getTopInventions,
INVENTION_TAG_RESULT,
inventionDeleteResult,
inventionSaveV9Failure,
isInventionCheered,
normalizeInventionTags,
ownsAllInventions,
parsePermissionLevel,
publishInvention,
searchInventions,
setInventionCheer,
setInventionPrice,
setInventionTags,
toSaveResult,
toSaveResultV9,
updateInvention,
} from '../inventions-db'
import {
AUTHED,
BareBoolean,
BareInteger,
BulkCustomAvatarItemsRequest,
CreateCustomAvatarItemRequest,
CustomAvatarItemList,
CustomAvatarItemReportRequest,
CustomAvatarItemResponse,
CustomAvatarItemsPage,
DeleteInventionRequest,
ErrorResponse,
form,
GeneratedGift,
GenerateGiftRequest,
idParam,
intQuery,
InventionCheerRequest,
InventionDeleteResult,
InventionDetails,
InventionDto,
InventionPersonalDetails,
InventionReportRequest,
InventionSaveResult,
InventionSaveV9Result,
InventionVersionDto,
json,
JsonArray,
jsonBody,
LegacyAvatarItemSaves,
OPTIONAL_AUTHED,
OutfitSaveResponse,
OutfitsBulkRequest,
OutfitsBulkResponse,
OutfitsMeRequest,
OutfitsMeResponse,
pageParams,
PublishInventionRequest,
SaveInventionRequest,
SaveInventionV9Request,
SetTagsRequest,
SetTagsResponse,
stringParam,
stringQuery,
SuccessErrorEnvelope,
SuccessValueEnvelope,
TagFilters,
UNAUTHORIZED_RESPONSE,
UpdateCustomAvatarItemRequest,
UpdateInventionMetadataRequest,
UpdateInventionRequest,
UpdatePriceRequest,
} from '../openapi'
import { createReport } from '../reports-db'
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
import type { Context } from 'hono'
import type { App } from '../context'
import type { InventionTag, SavedInvention } from '../inventions-db'
/**
* The most ids `POST /api/customAvatarItems/v1/bulk` will resolve. A batch over this answers
* EMPTY rather than being truncated.
*
* Empty rather than the first 100, because a truncated answer is indistinguishable from the
* items simply not existing — the client reads the items it got back, not the ids it asked
* about, so it cannot tell a cut-off batch from a batch of misses and would cache the
* difference. Nothing renders this many custom items at once, so a batch this size is the
* client doing something other than filling a screen.
*/
const BULK_CUSTOM_AVATAR_ITEM_CAP = 100
/**
* The ids `POST /api/customAvatarItems/v1/bulk` was asked to resolve. They ride as repeated
* `customAvatarItemIds` form fields, and the same spelling is read off the query string
* too — the client's exact encoding here has not been pinned down, so both are accepted
* rather than guessing one and answering nothing when it's the other.
*
* Each value may itself be a comma-separated list, and blanks are dropped rather than
* failing the request: a stray id must not cost the caller the rest of the batch. The order
* asked for is preserved, since `getCustomAvatarItems` answers in it.
*/
async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
const raw = [...(c.req.queries('customAvatarItemIds') ?? [])]
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const key = Object.keys(body).find((k) => k.toLowerCase() === 'customavataritemids')
const posted = key === undefined ? [] : body[key]
for (const value of Array.isArray(posted) ? posted : [posted]) {
if (typeof value === 'string') raw.push(value)
}
return raw
.flatMap((value) => value.split(','))
.map((v) => v.trim())
.filter((v) => v !== '')
}
/**
* The `unityAssetTarget` `POST /api/customAvatarItems/v1/bulk` was asked for, read the way
* {@link bulkCustomAvatarItemIds} reads the ids and for the same reason: off the query string
* (where the GET reads carry it) or out of the form, whatever the casing. `parseBody` caches,
* so reading the form a second time costs nothing.
*/
async function bulkUnityAssetTarget(c: Context<App>): Promise<string | undefined> {
const queried = c.req.query('unityAssetTarget')
if (queried !== undefined) return queried
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const key = Object.keys(body).find((k) => k.toLowerCase() === 'unityassettarget')
const posted = key === undefined ? undefined : body[key]
const value = Array.isArray(posted) ? posted[0] : posted
return typeof value === 'string' ? value : undefined
}
import type { SavedInvention } from '../inventions-db'
/**
* The gate every invention write runs through: the caller must be signed in, the
* invention must exist, and it must be theirs. Yields the loaded invention, or why not —
* as a reason and the status it maps to, so that a caller answering an envelope can put
* the reason where its client will read it instead of in a body that client can't parse.
* {@link creatorsInvention} is the rendering the older routes want.
*/
async function creatorsInventionResult(
c: Context<App>,
inventionId: number
): Promise<{ invention: SavedInvention } | { rejection: string; status: 400 | 401 | 403 | 404 }> {
const playerId = await authedId(c)
if (playerId === null) return { rejection: 'Unauthorized', status: 401 }
if (Number.isNaN(inventionId)) return { rejection: 'inventionId is required', status: 400 }
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return { rejection: 'No such invention', status: 404 }
if (invention.CreatorPlayerId !== playerId) {
return { rejection: 'Not your invention', status: 403 }
}
return { invention }
}
/**
* {@link creatorsInventionResult} as the older invention writes answer it: the loaded
* invention, or the response to return as-is (400 / 401 / 403 / 404).
* invention must exist, and it must be theirs. Yields the loaded invention, or the
* error response to return as-is (401 / 404 / 403).
*/
async function creatorsInvention(
c: Context<App>,
inventionId: number
): Promise<{ invention: SavedInvention } | { response: Response | Promise<Response> }> {
const gate = await creatorsInventionResult(c, inventionId)
if ('invention' in gate) return gate
if (gate.status === 401) return { response: unauthorized(c) }
if (gate.status === 404) return { response: c.notFound() }
return { response: c.json({ error: gate.rejection }, gate.status) }
}
/**
* The tags a `{ AutoTags, CustomTags }` request asks for, and whether they were taken —
* the block the v9 save sends as `tagsRequest` and `v2/metadata` sends as `TagsRequest`.
* Null when the client named no block at all, which each caller reads its own way: a save
* stores no tags, an edit leaves the stored ones alone.
*
* Tags are held to the same rule `v1/settags` applies, but a tag that breaks it costs the
* TAGS and not the write: both replies carry a tag result of their own precisely because
* the two outcomes are separate, and refusing a save would make the player redo a build
* over a hyphen. All the tags go rather than the offending one alone, so nothing is
* silently half-applied — the creator re-submits the list and sees what took. Blanks are
* skipped rather than counted against it; the client pads its lists with empties.
*/
function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: number } | null {
if (typeof request !== 'object' || request === null) return null
const lists = request as Record<string, unknown>
const strings = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
const autoTags = strings(lists.AutoTags)
const customTags = strings(lists.CustomTags)
const rejected = [...autoTags, ...customTags].some((raw) => {
const tag = raw.trim().toLowerCase()
return tag !== '' && inventionTagRejection(tag) !== null
})
return rejected
? { tags: [], tagResult: INVENTION_TAG_RESULT.rejected }
: {
tags: normalizeInventionTags(autoTags, customTags),
tagResult: INVENTION_TAG_RESULT.success,
}
}
/**
* What an invention save produced: the stored record and how its tags fared, or the one
* message that refuses it. Both save routes go through {@link createInventionFromBody} to
* get one of these and then render it their own way — v6 bare, v9 enveloped — because the
* two versions disagree about the shape of a reply, not about what a save is.
*/
type InventionSaveOutcome =
{ rejection: string } | { invention: SavedInvention; tags: InventionTag[]; tagResult: number }
/**
* The invention save both `v6/save` and `v9/save` run through. v9 sends everything v6 does
* plus what the invention points at (`referencedUnityAssetIds`), what it says about itself
* (`longDescription`, `displayMetadataJson`, `convertedFromInventionId`), `ugcVersion` and
* `hasBetaContent`, and the tags that until now needed a second `v1/settags` call. One
* reader takes them all: a v6 client sends none of them, and each is optional, so parsing
* them here changes nothing about the record a v6 save stores.
*/
async function createInventionFromBody(
c: Context<App>,
creatorPlayerId: number,
body: Record<string, unknown>
): Promise<InventionSaveOutcome> {
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const bool = (v: unknown): boolean | undefined => (typeof v === 'boolean' ? v : undefined)
const list = <T>(v: unknown, is: (x: unknown) => x is T): T[] | undefined =>
Array.isArray(v) ? v.filter(is) : undefined
const isString = (v: unknown): v is string => typeof v === 'string'
const isNumber = (v: unknown): v is number => typeof v === 'number'
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
if (!inventionDataFilename) return { rejection: 'inventionDataFilename is required' }
// 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 { rejection: nameRejection }
const description = str(body.description)
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return { rejection: descriptionRejection }
// v9 folds `v1/settags` into the save; a client that names no tags gets none.
const requested = requestedTags(body.tagsRequest) ?? {
tags: [],
tagResult: INVENTION_TAG_RESULT.success,
const playerId = await authedId(c)
if (playerId === null) return { response: unauthorized(c) }
if (Number.isNaN(inventionId)) {
return { response: c.json({ error: 'inventionId is required' }, 400) }
}
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
creatorPlayerId,
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: list(body.referencedInventions, isNumber),
ugcVersion: num(body.ugcVersion),
hasBetaContent: bool(body.hasBetaContent),
referencedUnityAssetIds: list(body.referencedUnityAssetIds, isString),
longDescription: str(body.longDescription),
displayMetadataJson: str(body.displayMetadataJson),
convertedFromInventionId: num(body.convertedFromInventionId),
tags: requested.tags,
})
return { invention, ...requested }
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return { response: c.notFound() }
if (invention.CreatorPlayerId !== playerId) {
return { response: c.json({ error: 'Not your invention' }, 403) }
}
return { invention }
}
/**
@@ -372,8 +151,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
ConsumableItemDesc: '',
AvatarItemDesc: '',
FriendlyName: '',
// A token box holds no avatar item: null, or the client goes looking for type 0.
AvatarItemType: null,
AvatarItemType: 0,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
CurrencyType: 2,
@@ -449,428 +227,38 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}),
(c) => c.json(true)
)
.get(
'/api/customAvatarItems/v1/minPriceForPublicItem',
describeRoute({
tags: ['Avatar'],
summary: 'Minimum token price for a public custom item',
description:
'The floor the creation UI enforces when listing a custom item publicly. A fixed `100`.',
responses: { 200: json(BareInteger, 'A bare `100`') },
}),
(c) => c.json(100)
)
.post(
'/api/customAvatarItems/v1',
describeRoute({
tags: ['Avatar'],
summary: 'Create a custom avatar item',
description:
'Multipart: a `metadata` JSON text field plus two file parts, `thumbnailImage` ' +
'(PNG) and `design` (the design blob). Inserts a `custom_avatar_item` row owned ' +
'by the caller and answers with it in the PascalCase `{ Value, Success, Error, ' +
'error_id }` envelope. The item is filed under `OutfitType` 105, the custom shirt, ' +
'which is what the store’s user-generated-content tab searches for.\n\n' +
'The two files go to the shared image bucket (`recflare-img`) under ' +
'`avatar-item/<date>/<id>-thumb.png` and `avatar-item/<date>/<id>-design.png`; those ' +
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.',
security: AUTHED,
requestBody: form(CreateCustomAvatarItemRequest, 'The metadata and the two files'),
responses: {
200: json(CustomAvatarItemResponse, 'The created item'),
400: json(CustomAvatarItemResponse, 'Missing or malformed metadata / files'),
401: UNAUTHORIZED_RESPONSE,
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const fail = (message: string) =>
c.json({ Value: null, Success: false, Error: message, error_id: null }, 400)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
if (typeof body.metadata !== 'string') return fail('metadata is required')
let meta: Record<string, unknown>
try {
const parsed: unknown = JSON.parse(body.metadata)
if (!parsed || typeof parsed !== 'object') return fail('metadata must be a JSON object')
meta = parsed as Record<string, unknown>
} catch {
return fail('metadata is not valid JSON')
}
if (typeof meta.Name !== 'string' || meta.Name.trim() === '') return fail('Name is required')
if (typeof meta.BaseAvatarItemId !== 'number') return fail('BaseAvatarItemId is required')
if (typeof meta.BaseAvatarItemColor !== 'string')
return fail('BaseAvatarItemColor is required')
if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required')
if (!(body.design instanceof File)) return fail('design is required')
const limit = maxApiUploadBytes(c.env)
// Each file gets the full per-file ceiling. Check both before either is copied into
// an ArrayBuffer or written, so a rejected request never leaves half an item in R2.
if (exceedsApiUploadLimit(body.thumbnailImage, limit)) {
return c.json(
{
Value: null,
Success: false,
Error: `thumbnailImage exceeds the ${limit}-byte upload limit`,
error_id: null,
},
413
)
}
if (exceedsApiUploadLimit(body.design, limit)) {
return c.json(
{
Value: null,
Success: false,
Error: `design exceeds the ${limit}-byte upload limit`,
error_id: null,
},
413
)
}
// Both files go to the shared image bucket, foldered by upload date and keyed by
// the item's id (chosen here so the keys can carry it). The `img` worker serves
// them back by key.
const customAvatarItemId = crypto.randomUUID()
const prefix = `avatar-item/${new Date().toISOString().slice(0, 10)}/${customAvatarItemId}`
const thumbnailImageFilename = `${prefix}-thumb.png`
const designFilename = `${prefix}-design.png`
await Promise.all([
c.env.IMAGES.put(thumbnailImageFilename, await body.thumbnailImage.arrayBuffer(), {
httpMetadata: { contentType: body.thumbnailImage.type || 'image/png' },
}),
c.env.IMAGES.put(designFilename, await body.design.arrayBuffer(), {
httpMetadata: { contentType: body.design.type || 'image/png' },
}),
])
const item = await createCustomAvatarItem(c.env.DB, {
customAvatarItemId,
creatorAccountId: id,
name: meta.Name,
description: typeof meta.Description === 'string' ? meta.Description : '',
price: typeof meta.Price === 'number' ? meta.Price : 0,
baseAvatarItemId: meta.BaseAvatarItemId,
baseAvatarItemColor: meta.BaseAvatarItemColor,
accessibility: typeof meta.Accessibility === 'number' ? meta.Accessibility : 0,
designFilename,
thumbnailImageFilename,
})
return c.json({ Value: item, Success: true, Error: null, error_id: null })
}
)
.put(
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}',
describeRoute({
tags: ['Avatar'],
summary: 'Edit a custom avatar item',
description:
'A partial edit of `Name`, `Description`, `Price` and `Accessibility` — the client ' +
'sends every field and nulls the ones it is not changing, so null means "leave ' +
'alone". Only the creator may edit. `ModifiedAt` is bumped. Answers the updated ' +
'item in the same `{ Value, Success, Error, error_id }` envelope as the create.',
security: AUTHED,
parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
requestBody: jsonBody(UpdateCustomAvatarItemRequest, 'The fields to change'),
responses: {
200: json(CustomAvatarItemResponse, 'The updated item'),
400: json(CustomAvatarItemResponse, 'Malformed body'),
401: UNAUTHORIZED_RESPONSE,
403: json(CustomAvatarItemResponse, 'Not the creator'),
404: json(CustomAvatarItemResponse, 'No such item'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const fail = (status: 400 | 403 | 404, message: string) =>
c.json({ Value: null, Success: false, Error: message, error_id: null }, status)
const itemId = c.req.param('id')
const existing = await getCustomAvatarItem(c.env.DB, itemId)
if (!existing) return fail(404, 'No such item')
if (existing.CreatorAccountId !== id) return fail(403, 'Not your item')
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (!body) return fail(400, 'A JSON body is required')
const str = (v: unknown, field: string): string | null | undefined => {
if (v === null || v === undefined) return null
if (typeof v !== 'string') throw new TypeError(`${field} must be a string`)
return v
}
const int = (v: unknown, field: string): number | null => {
if (v === null || v === undefined) return null
if (typeof v !== 'number' || !Number.isInteger(v))
throw new TypeError(`${field} must be an integer`)
return v
}
let patch
try {
patch = {
name: str(body.Name, 'Name'),
description: str(body.Description, 'Description'),
price: int(body.Price, 'Price'),
accessibility: int(body.Accessibility, 'Accessibility'),
}
} catch (e) {
return fail(400, (e as Error).message)
}
if (patch.name !== null && patch.name?.trim() === '')
return fail(400, 'Name must not be blank')
const item = await updateCustomAvatarItem(c.env.DB, itemId, patch)
if (!item) return fail(404, 'No such item')
return c.json({ Value: item, Success: true, Error: null, error_id: null })
}
)
.delete(
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}',
describeRoute({
tags: ['Avatar'],
summary: 'Delete a custom avatar item',
description:
'Removes the item and its two bucket objects (thumbnail and design). Only the ' +
'creator may delete. Answers the deleted item in the `{ Value, Success, Error, ' +
'error_id }` envelope.',
security: AUTHED,
parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
responses: {
200: json(CustomAvatarItemResponse, 'The deleted item'),
401: UNAUTHORIZED_RESPONSE,
403: json(CustomAvatarItemResponse, 'Not the creator'),
404: json(CustomAvatarItemResponse, 'No such item'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const fail = (status: 403 | 404, message: string) =>
c.json({ Value: null, Success: false, Error: message, error_id: null }, status)
const itemId = c.req.param('id')
const existing = await getCustomAvatarItem(c.env.DB, itemId)
if (!existing) return fail(404, 'No such item')
if (existing.CreatorAccountId !== id) return fail(403, 'Not your item')
const item = await deleteCustomAvatarItem(c.env.DB, itemId)
if (!item) return fail(404, 'No such item')
// The row is gone; the objects follow. A missing key is a no-op for R2. A first-party
// item has neither (it is rendered from its saves' assetbundles), so there is nothing
// to delete for one.
const keys = [item.ThumbnailImageFilename, item.DesignFilename].filter(
(k): k is string => k !== null
)
if (keys.length > 0) await c.env.IMAGES.delete(keys)
return c.json({ Value: item, Success: true, Error: null, error_id: null })
}
)
// The featured custom-avatar-item feed: flagged (`is_featured`) AND published
// (`Accessibility` != 0) items from the `custom_avatar_item` table.
// The featured custom-avatar-item feed. No curated items yet → an empty list.
.get(
'/api/customAvatarItems/v1/featured',
describeRoute({
tags: ['Avatar'],
summary: 'Featured custom avatar items',
description:
'The curated feed: items with `IsFeatured` set that are also published ' +
'(`Accessibility` 0 is unpublished and is excluded even when flagged), newest first, ' +
'up to 50. Nothing sets the flag yet, so it stays empty until an operator does.',
responses: { 200: json(CustomAvatarItemList, 'The items, newest first') },
description: 'The curated feed. Nothing is curated yet, so it is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
async (c) => c.json(await listFeaturedCustomAvatarItems(c.env.DB))
(c) => c.json([])
)
// The store's item search. The client sends the full set of `outfitTypes` it can render
// plus paging, and expects a BARE ARRAY of items back — not the `{ Results, TotalResults }`
// envelope `fromCreator` uses.
//
// Several parameters are accepted and not yet acted on; they are listed in the description
// rather than dropped silently, because a caller cannot tell the difference between a filter
// that was applied and one that was ignored by looking at the results.
.get(
'/api/customAvatarItems/v1/search',
describeRoute({
tags: ['Avatar'],
summary: 'Search custom avatar items',
description: [
'The store’s item search: published items (`Accessibility` 0 is unpublished and is',
'left out, from its creator too — `fromCreator` is where they see their own),',
'newest first, as a BARE ARRAY.',
'`searchQuery` matches an item’s NAME or its DESCRIPTION, case-insensitively, as a',
'substring; `%` and `_` in it are literal.',
'`outfitTypes` may repeat and acts as a whitelist; sending none means no filter',
'rather than no results, since the client sends every type it can render.',
'`minPrice`/`maxPrice` bound the price, inclusive.',
'`skip`/`take` page the results, `take` capped at 200.',
'`includeCoachItems` picks a SIDE of the catalog: `true` serves only the Coach’s',
'stock content (account 1), `false` only player-made items, and leaving it out serves',
'both. The client’s storefront tab sends `True` and its user-generated-content tab',
'`False`, so the two never overlap.',
'`creatorAccountId` narrows to one author’s items — what a player’s creations list',
'sends (`creatorAccountId=<id>&includeCoachItems=False`); still published items only.',
'`outfitTypes` values are `RecRoom.Avatars.OutfitType` ordinals; the user-generated-',
'content tab sends 105 (`CustomShirt`) alone, the storefront tab a dozen slots.',
'`itemTypes` (a separate enum: All -1, None 0, Shirt 1 — not the outfit type),',
'`ordering` (0 is `SearchScoreDescending`) and `unityAssetVersion` are accepted and NOT',
'yet acted on — nothing records purchase or wear counts to rank by, no per-version',
'asset variants are stored, and custom shirts are the only item type there is.',
'`unityAssetTarget` IS acted on: 2 (Android/Oculus) serves each save’s',
'`UnityAsset`/`UnityAsset2` under `quest/`, the Android build of the same assetbundle;',
'0 (PC), any other value, and none at all get the bare filenames as stored.',
'`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,',
'whatever it says.',
].join(' '),
parameters: [
{
name: 'searchQuery',
in: 'query',
required: false,
description: 'Free text matched against the item’s name or description',
schema: { type: 'string' },
},
{
name: 'outfitTypes',
in: 'query',
required: false,
description: 'OutfitType to include; repeat for several. None means all.',
schema: { type: 'array', items: { type: 'integer' } },
},
{
name: 'skip',
in: 'query',
required: false,
description: 'Rows to skip (default 0)',
schema: { type: 'integer', minimum: 0 },
},
{
name: 'take',
in: 'query',
required: false,
description: 'Rows to return (default 50, capped at 200)',
schema: { type: 'integer', minimum: 0 },
},
{
name: 'minPrice',
in: 'query',
required: false,
description: 'Lowest price to include, inclusive',
schema: { type: 'integer', minimum: 0 },
},
{
name: 'maxPrice',
in: 'query',
required: false,
description: 'Highest price to include, inclusive',
schema: { type: 'integer', minimum: 0 },
},
{
name: 'includeCoachItems',
in: 'query',
required: false,
description:
'true: only the Coach’s stock items; false: only player-made items; absent: both',
schema: { type: 'boolean' },
},
{
name: 'creatorAccountId',
in: 'query',
required: false,
description: 'Only items this account made',
schema: { type: 'integer' },
},
{
name: 'unityAssetTarget',
in: 'query',
required: false,
description:
'The Unity build target the assetbundles are wanted for: 0 PC (Windows), 2 ' +
'Android/Oculus — which is served the `quest/` builds',
schema: { type: 'integer' },
},
],
responses: { 200: json(CustomAvatarItemList, 'The matching items, newest first') },
}),
async (c) => {
// `?outfitTypes=0&outfitTypes=2&…` — repeated, so read every value. A non-numeric one is
// dropped rather than turned into NaN, which would match nothing and quietly empty a
// filter the caller believes they set.
const outfitTypes = c.req
.queries('outfitTypes')
?.map((v) => Number.parseInt(v, 10))
.filter((n) => Number.isInteger(n))
// Three-way: `True` is the Coach's side of the store, `False` the players', absent both.
// The client capitalises its booleans (`includeCoachItems=True`), so this is folded
// before comparing; anything that isn't recognisably true or false is treated as absent.
const coach = c.req.query('includeCoachItems')?.toLowerCase()
const includeCoachItems = coach === 'true' ? true : coach === 'false' ? false : undefined
const int = (name: string): number | undefined => {
const raw = c.req.query(name)
if (raw === undefined) return undefined
const n = Number.parseInt(raw, 10)
return Number.isInteger(n) ? n : undefined
}
const items = await searchCustomAvatarItems(c.env.DB, {
searchQuery: c.req.query('searchQuery'),
outfitTypes,
includeCoachItems,
creatorAccountId: int('creatorAccountId'),
minPrice: int('minPrice'),
maxPrice: int('maxPrice'),
skip: int('skip'),
take: int('take'),
})
// The same switch the bulk lookup makes: a caller asking for the Quest target is
// pointed at the `quest/` builds of the assetbundles the saves name.
return c.json(
isQuestAssetTarget(c.req.query('unityAssetTarget'))
? items.map(toQuestCustomAvatarItem)
: items
)
}
)
// The "hot" (trending) custom-avatar-item feed: every published (`Accessibility` != 0)
// item from the `custom_avatar_item` table. There is nothing to rank a trend from yet,
// so it is the accessible items, newest first.
// The "hot" (trending) custom-avatar-item feed. No items yet → an empty list.
.get(
'/api/customAvatarItems/v1/hot',
describeRoute({
tags: ['Avatar'],
summary: 'Trending custom avatar items',
description:
'The “hot” feed: the published items (`Accessibility` 0 is unpublished and is left ' +
'out), newest first, up to 50. No purchase or wear counts are recorded, so there is ' +
'no trend to rank by and recency stands in for one.',
responses: { 200: json(CustomAvatarItemList, 'The items, newest first') },
description: 'The “hot” feed. No custom items exist yet, so it is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
async (c) => c.json(await listHotCustomAvatarItems(c.env.DB))
(c) => c.json([])
)
// A batch lookup of custom avatar items by id, out of the `custom_avatar_item` table.
// The reference filters its catalog down to the posted ids and returns the MATCHES AS A
// BARE ARRAY — not the `{ Results, TotalResults }` page that catalog is written in, and
// not a 404 for ids it doesn't hold.
//
// This is how a `1.<guid>` entity in a GENERIC discovery row (`lists`
// `/algorithmiclists/:list?type=5`) gets resolved, so a row naming a custom item renders
// nothing at all when this doesn't answer. It stubbed out `[]` while nothing stored custom
// items; the table has existed since migration 0015 and the stub outlived it.
// A batch lookup of custom avatar items by id. The reference filters a static catalog
// down to the posted ids and returns the MATCHES AS A BARE ARRAY — not the
// `{ Results, TotalResults }` page its catalog file is written in, and not a 404 for
// ids it doesn't hold. Nothing stores custom items here (the reference's own catalog
// ships empty too), so every id misses and the array is empty.
//
// Auth-gated, and the token is checked before anything else, as the reference does.
//
// A batch over {@link BULK_CUSTOM_AVATAR_ITEM_CAP} ids answers EMPTY. The client has been
// seen posting far more ids than a screen could draw, and serving those is both a large
// query and a large response for a request that is already not what it looks like. Empty is
// the safe answer because a miss here is not an error: unknown ids are simply absent, so the
// client already handles getting back fewer items than it asked about.
.post(
'/api/customAvatarItems/v1/bulk',
describeRoute({
@@ -878,83 +266,42 @@ export const avatarRoutes = new Hono<App>({ strict: false })
summary: 'Custom avatar items in bulk',
description:
'Resolves a batch of custom-avatar-item ids to their items: the posted ' +
'`customAvatarItemIds` filtered against the `custom_avatar_item` table, returned ' +
'as a BARE ARRAY of the ones that matched, in the order they were asked for. Not ' +
'the `{ Results, TotalResults }` page the sibling custom-item reads serve — the ' +
'reference keeps its catalog in that shape but answers this route with the ' +
'filtered array alone.\n\n' +
'`customAvatarItemIds` filtered against the catalog, returned as a BARE ARRAY of ' +
'the ones that matched. Not the `{ Results, TotalResults }` page the sibling ' +
'custom-item reads serve — the reference keeps its catalog in that shape but ' +
'answers this route with the filtered array alone.\n\n' +
'A miss is not an error: unknown ids are simply absent from the response, and the ' +
'client reads the items it got back rather than the ids it asked for. Unpublished ' +
'items (`Accessibility` 0) miss for everyone but their creator, the same rule the ' +
'feeds and the creator shelf apply.\n\n' +
'Ids ride as repeated `customAvatarItemIds` form fields; a comma-separated value ' +
'and the same spelling on the query string are both accepted, since the client’s ' +
'exact encoding here has not been pinned down.\n\n' +
'A caller asking for the Quest build — `unityAssetTarget` 2 (Android/Oculus), on ' +
'the query string or in the form — is served each save’s `UnityAsset`/`UnityAsset2` ' +
'under `quest/`, the Android build of the same assetbundle. Target 0 (PC), any ' +
'other value, and no target at all get the bare filenames as stored.\n\n' +
'A batch of more than 100 ids answers an EMPTY array without reading the table: the ' +
'client has been seen posting more than a screen could draw, and a miss is already ' +
'not an error here.',
'client reads the items it got back rather than the ids it asked for. Nothing ' +
'stores custom items here, so every id misses and this is always `[]` — which is ' +
'why the posted ids are not parsed.',
security: AUTHED,
requestBody: form(BulkCustomAvatarItemsRequest, 'The custom-avatar-item ids to resolve'),
responses: {
200: json(CustomAvatarItemList, 'The items that matched, in request order'),
200: json(JsonArray, 'The matching items — always empty here'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const ids = await bulkCustomAvatarItemIds(c)
// Over the cap: empty, and the table is not touched. Answering the batch would be a
// large query and a large response for a request that is already not what it looks
// like — a screen does not draw this many items.
if (ids.length > BULK_CUSTOM_AVATAR_ITEM_CAP) return c.json([])
const items = await getCustomAvatarItems(c.env.DB, ids)
// Unpublished items are held back from everyone but their creator — the same rule
// the featured/hot feeds and the creator shelf apply, so an item can't be surfaced
// through this route that the feeds hide.
const visible = items.filter(
(item) => item.Accessibility !== 0 || item.CreatorAccountId === id
)
// A Quest can't load the PC assetbundles the saves name; its builds sit beside them
// under `quest/`, so the names are pointed there for a caller asking for that target.
return c.json(
isQuestAssetTarget(await bulkUnityAssetTarget(c))
? visible.map(toQuestCustomAvatarItem)
: visible
)
return c.json([])
}
)
// Custom avatar items created by a given account, from the `custom_avatar_item` table,
// in the paginated shape (matches the econ `customAvatarItems/v1/owned` shape). Auth is
// optional: the creator themselves also sees their unpublished (`Accessibility` 0) items.
// 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]+}',
describeRoute({
tags: ['Avatar'],
summary: 'A creator’s custom avatar items',
description:
'The items an account has authored, newest first, in the same page shape as the ' +
'`econ` worker’s `customAvatarItems/v1/owned`. Published items only — unless the ' +
'bearer token is the creator’s, in which case their unpublished (`Accessibility` 0) ' +
'items are included too. Paging is not applied (the client sends none), so ' +
'`TotalResults` is the length of `Results`.',
security: OPTIONAL_AUTHED,
'The items an account has authored. Nothing stores custom items yet, so this is an ' +
'empty page — in the same shape as the `econ` worker’s `customAvatarItems/v1/owned`.',
parameters: [idParam('accountId', 'Creator account id')],
responses: { 200: json(CustomAvatarItemsPage, 'The creator’s items') },
responses: { 200: json(CustomAvatarItemsPage, 'An empty page') },
}),
async (c) => {
const accountId = Number.parseInt(c.req.param('accountId'), 10)
const viewer = await authedId(c)
return c.json(await listCustomAvatarItemsByCreator(c.env.DB, accountId, viewer === accountId))
}
(c) => c.json({ Results: [], TotalResults: 0 })
)
// The client asks which legacy avatar items have been rebuilt as custom items, so it
@@ -1125,46 +472,29 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The caller's outfit wardrobe — every slot they have saved, ordered by slot. The same
// read as `econ`'s `GET /api/avatar/v3/saved`, on the bare path the newer client uses:
// both worker's write paths land in the shared `outfit` table, so both list endpoints
// serve the same rows.
//
// Slot 0 is INCLUDED. It is the outfit being worn (what `/outfits/me` reads), but it is
// also a saved slot: the newer client picks the slot it saves into (`/api/avatar/v4/saved/set`
// 400s without one), so filtering slot 0 out would hide a real saved outfit whenever a
// wardrobe entry lands there. Showing the worn outfit as a wardrobe entry is the cheaper
// mistake of the two.
//
// Rows are served exactly as they were stored, unprojected — see the note atop
// `outfits-db.ts`: econ's saved slots hold the old flat PascalCase outfit while
// `/outfits/me` holds the newer envelope, and neither is converted into the other.
// The caller's outfit wardrobe. An empty list for now — the outfits saved through
// `PUT /outfits/me` are in the shared `outfit` table already, but which of them
// belong in this list (and in what shape) has not been pinned down, so it answers []
// rather than guessing.
.get(
'/outfits/me/saved',
describeRoute({
tags: ['Avatar', '2025'],
summary: 'The caller’s saved outfits',
description:
'The wardrobe behind the newer outfit screen: every slot the caller has saved, ' +
'ordered by slot, and `[]` when they have saved none. The same rows `econ`’s ' +
'`GET /api/avatar/v3/saved` serves — both write paths land in the shared `outfit` ' +
'table.\n\n' +
'Slot 0 is included. It is the outfit being worn (what `GET /outfits/me` reads) but ' +
'it is a saved slot too, and the client chooses the slot it saves into, so omitting ' +
'it would hide a real outfit whenever a wardrobe entry lands there.\n\n' +
'Each outfit is served exactly as it was stored, unprojected: slots written through ' +
'`PUT /outfits/me` hold the newer envelope while `econ`’s saved-set slots hold the ' +
'old flat shape, and neither is converted into the other.',
'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' +
'through `PUT /outfits/me` are in the shared `outfit` table, but which of them this ' +
'list should carry, and in what shape, is not pinned down yet.',
security: AUTHED,
responses: {
200: json(JsonArray, 'The saved outfits, ordered by slot (empty when none)'),
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(await getOutfits(c.env.DB, id))
return c.json([])
}
)
@@ -1295,36 +625,30 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The signed-in player's own relationship to an invention (`/personaldetails/2`) —
// just whether they're cheering it. Signed-out callers read false: there is no player
// whose interaction could be looked up, and the client still needs a flag to render.
// 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]+}',
describeRoute({
tags: ['Inventions'],
summary: 'The caller’s own relation to an invention',
description:
'Whether the caller is cheering this invention. Signed-out callers receive false, ' +
'since there is no player interaction to look up.',
'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, 'The caller’s cheer state') },
responses: { 200: json(InventionPersonalDetails, 'Always not cheering') },
}),
async (c) => {
const playerId = await authedId(c)
if (playerId === null) return c.json({ IsCheering: false })
const inventionId = Number.parseInt(c.req.param('inventionId'), 10)
return c.json({ IsCheering: await isInventionCheered(c.env.DB, playerId, inventionId) })
}
(c) => c.json({ IsCheering: false })
)
// A single version of an invention (`?inventionId=…&version=…`) — the bare
// 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 — except `version=0`, which means "whichever is current"
// rather than a number to match. Nothing has a version 0, so a caller sending it
// doesn't know which version it wants, and matching it literally 404s an invention
// that exists.
// blob that isn't there.
.get(
'/api/inventions/v1/version',
describeRoute({
@@ -1335,19 +659,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'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.\n\n' +
'`version=0` is the exception: it means “whichever is current” rather than a ' +
'number to match, and gets the current version. No invention has a version 0 — a ' +
'fresh save is version 1 — so a caller sending it does not know which version it ' +
'wants, and matching it literally 404s an invention that exists.',
'is not there.',
parameters: [
intQuery('inventionId', 'Invention id; required'),
intQuery('version', 'Version number; required. `0` means the current version'),
intQuery('version', 'Version number; required'),
],
responses: {
200: json(InventionVersionDto, 'The version'),
400: json(ErrorResponse, 'Missing inventionId or version'),
404: { description: 'No such invention, or a version number that is not the current one' },
404: { description: 'No such invention, or not the current version' },
},
}),
async (c) => {
@@ -1479,11 +799,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
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:
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
price: Number.isNaN(price) || price < 0 ? undefined : price,
})
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))
}
)
@@ -1663,19 +984,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The featured dorm-skin feed (inventions that reskin the dorm). Nothing curates these
// yet → an empty list, so the client's shelf renders empty rather than 404ing.
.get(
'/api/inventions/v1/featureddormskins',
describeRoute({
tags: ['Inventions'],
summary: 'The featured dorm-skin feed',
description: 'Curated dorm-skin inventions. Nothing is curated yet, so it is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
// Inventions by particular creators (`?id=207&id=…`) — what the client fills a creator's
// shelf, and the "from creators you follow" row, from.
//
@@ -1706,20 +1014,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
// 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.
//
// Filtered, ordered and paged in SQL — it must not read the catalogue into memory to
// answer one page.
.get(
'/api/inventions/v2/search',
describeRoute({
tags: ['Inventions'],
summary: 'Search / browse inventions',
description:
'Published inventions matching `value`, newest first. `value` is split into terms ' +
'and every term must match, each against the name and the description. An absent ' +
'`value` browses everything published — that is the browse screen’s initial ' +
'request. Tags are NOT searched: a `#tag` term from the browse screen’s filter ' +
'chips is treated as text and matches nothing.',
'Published inventions matching `value` (matched against name and description), ' +
'newest first. An absent `value` browses everything published — that is the ' +
'browse screen’s initial request.',
parameters: [
stringQuery('value', 'Search text; absent browses everything'),
...pageParams(100),
@@ -1761,175 +1064,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// Report a custom avatar item. Stored in the `report` table the player, event and invention
// reports use — same fields, same moderation life — with `custom_avatar_item_id` set. See
// migrations/0017_report_custom_avatar_item.sql.
//
// The item is named by the PATH, not the body, which is what distinguishes this from its
// siblings; the body's `ReportedPlayerId` arrives NULL and is ignored, since the client does
// not know who made the item.
.post(
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}/report',
describeRoute({
tags: ['Avatar', 'Moderation'],
summary: 'Report a custom avatar item',
description:
'Files a report against a custom avatar item, named by the PATH. Stored as a row in ' +
'the same `report` table a player report goes to (`POST /api/PlayerReporting/v3/create`), ' +
'an event report and an invention report — the same submission with the same ' +
'moderation life, which a moderator converts into a ban the same way. What marks it ' +
'as an item report is `custom_avatar_item_id`; the row’s `reported_player_id` is the ' +
'item’s CREATOR, read from the item. The body’s `ReportedPlayerId` is sent as null ' +
'and IGNORED even when set — the client does not know who made the item, and letting ' +
'a client name who a report is against would let it point one at anybody. Nothing ' +
'fills `room_id`: an item isn’t tied to one room the way an event is.\n\n' +
'The reporter is the caller (from the bearer token), never a body field. ' +
'`ReportCategory` is stored verbatim — the enum is not mapped here. Nothing dedupes ' +
'the rows: reporting the same item twice files two reports, and reporting your own ' +
'is allowed rather than being a special case.\n\n' +
'Answers the `{ success, error }` envelope the event and invention reports use, ' +
'`error` being an empty string rather than null, on the rejected branches too so ' +
'there is only one shape to parse.',
security: AUTHED,
parameters: [idParam('id', 'The custom avatar item’s guid')],
requestBody: jsonBody(CustomAvatarItemReportRequest, 'The report'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such custom avatar item'),
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const customAvatarItemId = c.req.param('id')
// The item supplies the reported player. An unknown item is refused rather than filed
// against nobody: the row's reported player has to be someone, and a report naming an
// item that never existed isn't actionable.
const item = await getCustomAvatarItem(c.env.DB, customAvatarItemId)
if (item === null) return c.json({ success: false, error: 'No such item' }, 404)
// A body that won't parse is not a reason to lose the report: the path already names
// what is being reported and the token names who reported it, so an unreadable body
// costs the category and the description, not the row.
const body = await c.req
.json<{ ReportCategory?: unknown; Details?: unknown }>()
.catch(() => ({}) as Record<string, unknown>)
const category = Number(body.ReportCategory)
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId: item.CreatorAccountId,
reportCategory: Number.isInteger(category) ? category : 0,
details: typeof body.Details === 'string' ? body.Details : null,
customAvatarItemId,
})
return c.json({ success: true, error: '' })
}
)
// Cheer or un-cheer an invention. The interaction row is per player and the stored
// invention's public CheerCount is derived from all active cheers.
.post(
'/api/inventions/v1/cheer',
describeRoute({
tags: ['Inventions'],
summary: 'Cheer or un-cheer an invention',
description:
'Persists the caller’s cheer state and resyncs the invention’s `CheerCount`. ' +
'Repeating the same state is idempotent.',
security: AUTHED,
requestBody: jsonBody(InventionCheerRequest, 'The invention and new cheer state'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'Invalid body'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such invention'),
},
}),
async (c) => {
const playerId = await authedId(c)
if (playerId === null) return unauthorized(c)
const body = await c.req
.json<{ InventionId?: unknown; Cheer?: unknown }>()
.catch(() => ({}) as Record<string, unknown>)
const inventionId = Number(body.InventionId)
if (!Number.isInteger(inventionId) || typeof body.Cheer !== 'boolean') {
return c.json({ success: false, error: 'InventionId and Cheer are required' }, 400)
}
if ((await getInventionById(c.env.DB, inventionId)) === null) {
return c.json({ success: false, error: 'No such invention' }, 404)
}
await setInventionCheer(c.env.DB, playerId, inventionId, body.Cheer)
return c.json({ success: true, error: '' })
}
)
// Report an invention. Stored in the `report` table the player and event reports use —
// same fields, same moderation life — with `invention_id` set. See
// migrations/0016_report_invention.sql.
.post(
'/api/inventions/v1/report',
describeRoute({
tags: ['Inventions', 'Moderation'],
summary: 'Report an invention',
description:
'Files a report against an invention. Stored as a row in the same `report` table a ' +
'player report goes to (`POST /api/PlayerReporting/v3/create`) and an event report ' +
'(`POST /api/playerevents/v1/report`) — it is the same submission with the same ' +
'moderation life, and a moderator converts any of them into a ban the same way. ' +
'What marks it as an invention report is `invention_id`; the row’s ' +
'`reported_player_id` is the invention’s CREATOR — who a moderator would act ' +
'against — read from the invention rather than sent by the client. Nothing fills ' +
'`room_id`: an invention isn’t tied to one room the way an event is.\n\n' +
'The reporter is the caller (from the bearer token), never a body field. ' +
'`ReportCategory` is stored verbatim — the enum is not mapped here. Nothing ' +
'dedupes the rows: reporting the same invention twice files two reports, and ' +
'reporting your own is allowed rather than being a special case.\n\n' +
'Answers the same `{ success, error }` envelope as the event report, `error` being ' +
'an empty string rather than null, on the rejected branches too so there is only ' +
'one shape to parse.',
security: AUTHED,
requestBody: jsonBody(InventionReportRequest, 'The report'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No usable `InventionId` in the body'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such invention'),
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const body = await c.req
.json<{ InventionId?: unknown; ReportCategory?: unknown; Details?: unknown }>()
.catch(() => ({}) as Record<string, unknown>)
const inventionId = Number(body.InventionId)
if (!Number.isInteger(inventionId)) {
return c.json({ success: false, error: 'InventionId is required' }, 400)
}
// The invention supplies the reported player. An unknown invention is refused rather
// than filed against nobody: the row's reported player has to be someone, and a
// report naming an invention that never existed isn't actionable.
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return c.json({ success: false, error: 'No such invention' }, 404)
const category = Number(body.ReportCategory)
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId: invention.CreatorPlayerId,
reportCategory: Number.isInteger(category) ? category : 0,
details: typeof body.Details === 'string' ? body.Details : null,
inventionId,
})
return c.json({ success: true, error: '' })
}
)
// Save an invention's metadata. The data file itself is uploaded separately
// through the `storage` worker and referenced here by `inventionDataFilename` —
// the one required field, since an invention with no data blob is unusable. An
@@ -1968,393 +1102,43 @@ export const avatarRoutes = new Hono<App>({ strict: false })
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 outcome = await createInventionFromBody(c, id, body)
if ('rejection' in outcome) return c.json({ error: outcome.rejection }, 400)
return c.json(toSaveResult(outcome.invention))
}
)
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
// The same save as the newer client sends it: v6's body plus the invention's
// references, its long description and display metadata, what the saved blob is, and
// the tags — which v6 clients set afterwards through `v1/settags`. It stores the same
// record; what differs is the REPLY, which is enveloped. See `InventionSaveV9Result`:
// the client reads `Success` and then `Value.Invention.InventionId`, and a body that
// isn't this envelope — a bare `{ error }`, or the empty 401 the other routes answer —
// takes it down rather than failing it, which is why every branch below answers one.
.post(
'/api/inventions/v9/save',
describeRoute({
tags: ['Inventions'],
summary: 'Save a new invention (v9)',
description:
'`v6/save` plus the fields the newer client sends: `referencedUnityAssetIds`, ' +
'`longDescription`, `displayMetadataJson`, `convertedFromInventionId`, ' +
'`ugcVersion`, `hasBetaContent`, and a `tagsRequest` carrying the same ' +
'`AutoTags`/`CustomTags` lists `v1/settags` takes. Every one is optional and is ' +
'stored only when sent, so a body v6 would accept produces the same record here.' +
'\n\n' +
'The reply is where the two versions part: v9 is ENVELOPED as ' +
'`{ Value, Success, Error, error_id }`, with v6’s ' +
'`{ Status, Invention, InventionVersion }` inside `Value` alongside a ' +
'`TagsResponse`. The client reads `Success` and then ' +
'`Value.Invention.InventionId`; `Error` is the only text it ever shows a human.' +
'\n\n' +
'So a refusal is *also* a 200 carrying `{ Success: false, Error, Value: null }` — ' +
'the client dereferences `Value` unguarded when `Success` is true, and treats ' +
'anything that isn’t this envelope as a null one. Tags are held to the ' +
'`v1/settags` rule (at most 15 letters each), but one that breaks it costs the ' +
'tags and not the save: `TagsResponse.Result` comes back non-zero and the creator ' +
're-submits them through `v1/settags`.\n\n' +
'A freshly saved invention is private: it shows up only in the creator’s own list ' +
'until they call `v3/publish`.',
security: AUTHED,
requestBody: jsonBody(SaveInventionV9Request, 'The invention metadata (camelCase)'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the stored invention under `Value`, or `Success: false` with ' +
'`Error` when the save was refused'
),
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.json(inventionSaveV9Failure('Unauthorized'), 401)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
const outcome = await createInventionFromBody(c, id, body)
return c.json(
'rejection' in outcome
? inventionSaveV9Failure(outcome.rejection)
: toSaveResultV9(outcome.invention, outcome.tags, outcome.tagResult)
)
}
)
// Edit an invention's metadata, as the newer client sends it: one PUT with a PascalCase
// body where every field but the id is nullable, and NULL means "leave this alone" —
// the client sends the whole shape every time and marks the fields it isn't touching.
// The tags ride along the way they do on `v9/save`, and the reply is that same
// envelope: `v1/update` is the older client's version of this endpoint, query params
// and a bare body and all.
.put(
'/api/inventions/v2/metadata',
describeRoute({
tags: ['Inventions'],
summary: 'Edit an invention’s metadata (v2)',
description:
'Creator only. Every field but `InventionId` is nullable and a null one is left ' +
'as it is — the client sends the whole shape on every edit — so this is a patch, ' +
'not a replace. An empty string is not a null: it is how a creator CLEARS a ' +
'description, long description or image. `Name` is the exception, since a nameless ' +
'invention isn’t a thing the client can draw: it is held to the same 3–24 ' +
'character rule `v6/save` enforces, which an empty name fails.\n\n' +
'`TagsRequest` replaces both tag lists wholesale, exactly as `v1/settags` does; a ' +
'null one leaves the stored tags alone. A tag that breaks the tag rule costs the ' +
'tags and not the edit — `TagsResponse.Result` comes back non-zero.\n\n' +
'Answers the enveloped result `v9/save` answers, carrying the UPDATED invention: ' +
'the client re-renders the detail page from `Value.Invention`. Refusals — an ' +
'unknown invention and someone else’s alike — are `Success: false` with a null ' +
'`Value` rather than a bare error body, which that client cannot parse.',
security: AUTHED,
requestBody: jsonBody(UpdateInventionMetadataRequest, 'The fields to change'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the updated invention under `Value`, or `Success: false` with ' +
'`Error` when the edit was refused'
),
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
// The id rides in the body here, not the query string.
const gate = await creatorsInventionResult(
c,
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
)
// Only a missing token is answered as a transport failure. An unknown invention
// or someone else's is a domain answer the client is meant to read — its own
// status enum has DoesNotExist and NotCreator members — so it goes in the
// envelope, where the message reaches a human.
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionSaveV9Failure(gate.rejection), 401)
: c.json(inventionSaveV9Failure(gate.rejection))
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
if (!inventionDataFilename) {
return c.json({ error: 'inventionDataFilename is required' }, 400)
}
// Null is "leave it"; a string, empty or not, is an edit.
const edited = (key: string): string | undefined =>
typeof body[key] === 'string' ? body[key] : undefined
const name = edited('Name')?.trim()
const description = edited('Description')
const longDescription = edited('LongDescription')
// 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)
for (const rejection of [
name === undefined ? null : inventionNameRejection(name),
description === undefined ? null : inventionDescriptionRejection(description),
longDescription === undefined ? null : inventionLongDescriptionRejection(longDescription),
]) {
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
}
const description = str(body.description)
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
// A null TagsRequest leaves the stored tags alone, and the reply still reports
// them: the client reads the list back as the tags the invention now has, not as
// the ones this call changed.
const requested = requestedTags(body.TagsRequest)
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
creatorPlayerId: id,
inventionDataFilename,
name,
description,
longDescription,
imageName: edited('ImageName'),
tags: requested?.tags,
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,
})
if (updated === null) return c.json(inventionSaveV9Failure('No such invention'))
return c.json(
toSaveResultV9(
updated,
updated.Tags ?? [],
requested?.tagResult ?? INVENTION_TAG_RESULT.success
)
)
}
)
// Edit an invention, as the newer client sends it: the `v1/update` write with a
// PascalCase body instead of a query string. What the permission picker posts —
// `{ InventionId, Permission }` — and so the endpoint that changes what other players
// may DO with an invention, where `v2/metadata` beside it changes what it SAYS.
//
// Creator only, which is the whole point of the gate: `GeneralPermission` is what lets
// another player edit, re-publish or charge for someone's build, so a write that took
// the caller's word for the id would let anyone hand themselves rights over any
// invention in the game.
.post(
'/api/inventions/v2/update',
describeRoute({
tags: ['Inventions'],
summary: 'Edit an invention (v2)',
description:
'The newer client’s form of `v1/update`: a PascalCase JSON body in place of the ' +
'query string. A patch, not a replace — every field but `InventionId` is nullable ' +
'and a null one is left as it is, since the client sends the whole shape on every ' +
'edit.\n\n' +
'**Creator only.** `Permission` decides what other players may do with the ' +
'invention — up to editing, re-publishing and charging for it — so only the ' +
'account that created it may set it.\n\n' +
'`Permission` is the raw `GeneralPermission` ladder number (UseOnly 20, ' +
'EditAndSave 40, Publish 60 …), stored verbatim and unmapped, exactly as ' +
'`v1/update` stores the value its picker names. A null one LEAVES IT ALONE — ' +
'unlike `v4/publish`, where null means UseOnly: publishing something with no ' +
'permission named is a decision, but editing it without naming one is not.\n\n' +
'Publishing, pricing and tags are elsewhere (`v4/publish`, `v1/updateprice`, ' +
'`v2/metadata`), as they are for `v1/update`.\n\n' +
'Answers the enveloped result `v9/save` answers, carrying the UPDATED invention: ' +
'the client re-renders the detail sheet from `Value.Invention`, so a refusal — an ' +
'unknown invention and someone else’s alike — is `Success: false` with a null ' +
'`Value` rather than a bare error body, which that client cannot parse.',
security: AUTHED,
requestBody: jsonBody(UpdateInventionRequest, 'The fields to change'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the updated invention under `Value`, or `Success: false` with ' +
'`Error` when the edit was refused'
),
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
// The id rides in the body here, not the query string. The gate is what makes this
// creator-only: it loads the invention and refuses anyone but the account that
// created it.
const gate = await creatorsInventionResult(
c,
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
)
// Only a missing token is answered as a transport failure. An unknown invention or
// someone else's is a domain answer this client is meant to read — its own status
// enum has DoesNotExist and NotCreator members — so it goes in the envelope, where
// the message reaches a human. Same split `v2/metadata` makes.
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionSaveV9Failure(gate.rejection), 401)
: c.json(inventionSaveV9Failure(gate.rejection))
}
// A number is the picker's own form; a string is accepted through the same parser
// `v1/update`'s query param goes through, so a build sending `"EditAndSave"` or
// `"40"` lands on the same level. Anything else (null included) leaves it alone.
const permission =
typeof body.Permission === 'number'
? body.Permission
: typeof body.Permission === 'string'
? parsePermissionLevel(body.Permission)
: undefined
// Null is "leave it"; a string, empty or not, is an edit — `v2/metadata`'s rule,
// since these are the same client's bodies.
const edited = (key: string): string | undefined =>
typeof body[key] === 'string' ? body[key] : undefined
const name = edited('Name')?.trim()
const description = edited('Description')
const longDescription = edited('LongDescription')
for (const rejection of [
name === undefined ? null : inventionNameRejection(name),
description === undefined ? null : inventionDescriptionRejection(description),
longDescription === undefined ? null : inventionLongDescriptionRejection(longDescription),
]) {
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
}
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
generalPermission: permission,
allowTrial: typeof body.AllowTrial === 'boolean' ? body.AllowTrial : undefined,
name,
description,
longDescription,
imageName: edited('ImageName'),
})
if (updated === null) return c.json(inventionSaveV9Failure('No such invention'))
// The tags are reported as the ones the invention HAS, not as ones this call
// changed — it changes none. Same reading `v2/metadata` gives a null TagsRequest.
return c.json(toSaveResultV9(updated, updated.Tags ?? [], INVENTION_TAG_RESULT.success))
}
)
// Publish an invention, as the newer client sends it: a PascalCase body instead of a
// query string, and an Accessibility of its own — where `v3/publish` only ever flipped
// the published flag, this decides whether the result can be FOUND. Same enveloped
// reply as `v9/save`, carrying the published invention.
.post(
'/api/inventions/v4/publish',
describeRoute({
tags: ['Inventions'],
summary: 'Publish an invention (v4)',
description:
'What puts an invention into search and the feeds. Creator only.\n\n' +
'`Permission` is the `GeneralPermission` other players get, as a raw ladder ' +
'number (the publish sheet sends 20, UseOnly). `Accessibility` says where it can ' +
'be found — 1 (Public) lists it, 2 (Unlisted) publishes it reachable by id but ' +
'keeps it out of browse and search. A null `Price` leaves the price alone rather ' +
'than zeroing it, so re-publishing something that was for sale doesn’t give it ' +
'away; every field but `InventionId` is nullable and an omitted one keeps what ' +
'the invention has.\n\n' +
'Publishing is not undone here, and re-publishing doesn’t re-date the first ' +
'publish. Refusals answer `Success: false` with a null `Value`, the way ' +
'`v9/save` does.',
security: AUTHED,
requestBody: jsonBody(PublishInventionRequest, 'What the publish decides'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the published invention under `Value`, or `Success: false` ' +
'with `Error` when the publish was refused'
),
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
const gate = await creatorsInventionResult(
c,
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
)
// As on `v2/metadata`: only a missing token is a transport failure. The rest are
// answers the client is meant to read out of the envelope.
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionSaveV9Failure(gate.rejection), 401)
: c.json(inventionSaveV9Failure(gate.rejection))
}
// Null is "leave it". The permission and accessibility are taken as sent rather
// than checked against the ladder, the way `parsePermissionLevel` already accepts
// a raw number: the ladders are the client's, and a level this server hasn't heard
// of is better stored than swapped for one the creator didn't pick.
const int = (key: string): number | undefined =>
typeof body[key] === 'number' && Number.isInteger(body[key]) ? body[key] : undefined
const price = int('Price')
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel: int('Permission'),
accessibility: int('Accessibility'),
// A negative price is dropped rather than stored, as it is on `v3/publish`.
price: price !== undefined && price < 0 ? undefined : price,
})
if (published === null) return c.json(inventionSaveV9Failure('No such invention'))
return c.json(toSaveResultV9(published, published.Tags ?? []))
}
)
// Delete an invention. The newer client's shape: a POST with a PascalCase body
// carrying nothing but the id. Auth-gated, creator only — the only thing that may
// remove an invention is the account that made it, not a co-owner and not a buyer.
//
// The record and everything inside it (versions, tags, referenced-invention lists)
// go in one DELETE; the data blob in R2 and the `inventory_invention` rows of
// players who bought it are left alone. See `deleteInvention` for why.
.post(
'/api/inventions/v2/delete',
describeRoute({
tags: ['Inventions'],
summary: 'Delete an invention',
description:
'Creator only — a buyer or a co-owner cannot delete someone else’s invention. ' +
'The record goes entirely: its versions, tags and referenced-invention lists live ' +
'in the same row.\n\n' +
'What survives is deliberate. The data blob stays in storage, because nothing ' +
'here knows whether another record still points at that filename. The ownership ' +
'rows of players who bought it stay too — a delete must not rewrite what someone ' +
'else paid for — and they fall out of every list on their own, since an owned id ' +
'with no invention row behind it is skipped.\n\n' +
'Answers the `{ Value, Success, Error, error_id }` envelope the other v2+ ' +
'invention routes use, with `Value` NULL: the invention is gone, so there is ' +
'nothing to redraw from and the client reads only `Success`. Refusals — an ' +
'unknown invention and someone else’s alike — are `Success: false` with a ' +
'message, not a bare error body that client cannot parse.',
security: AUTHED,
requestBody: jsonBody(DeleteInventionRequest, 'The invention to delete'),
responses: {
200: json(InventionDeleteResult, 'The delete envelope, `Value` null either way'),
401: json(InventionDeleteResult, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json(inventionDeleteResult('Invalid request body'))
// The id rides in the body, as it does on `v2/metadata` and `v4/publish`.
const gate = await creatorsInventionResult(
c,
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
)
// As on those two: only a missing token is a transport failure. An unknown
// invention or someone else's is a domain answer the client reads out of the
// envelope, where the message reaches a human.
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionDeleteResult(gate.rejection), 401)
: c.json(inventionDeleteResult(gate.rejection))
}
// The gate already loaded the row, so a null here is a race — someone deleted it
// between the two reads — and lands where the client would put it anyway: gone.
const deleted = await deleteInvention(c.env.DB, gate.invention.InventionId)
return c.json(
deleted === null ? inventionDeleteResult('No such invention') : inventionDeleteResult()
)
return c.json(toSaveResult(invention))
}
)
+6 -8
View File
@@ -29,19 +29,17 @@ export const configRoutes = new Hono<App>({ strict: false })
tags: ['Config'],
summary: 'Analytics keys',
description:
'The Amplitude / RudderStack / StatSig keys the client initialises its analytics ' +
'with. This server collects nothing, so the keys are blank and RudderStack and ' +
'StatSig are off — but the client needs the object to finish loading.',
'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: '',
AmplitudeKey: 'a',
StatSigKey: 'a',
RudderStackKey: 'a',
UseRudderStack: false,
RudderStackKey: '',
UseStatSig: false,
StatSigKey: '',
StatSigEnvironment: 0,
})
)
.get(
+60 -290
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { Accessibility, createNotification, GAME_VERSION, MessageType } from '@repo/domain'
import { Accessibility, GAME_VERSION } from '@repo/domain'
import { logger } from '@repo/hono-helpers'
import { validateAndGetVersion } from '@repo/jwt'
@@ -21,7 +21,6 @@ import {
getEventsByIds,
getEventsByRoom,
getEventTags,
getGoingPlayerIds,
getLiveEvents,
inviteToEvent,
isEventResponseType,
@@ -36,10 +35,9 @@ import {
toEventResult,
updateEvent,
} from '../events-db'
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
import { authedId, queryIds, unauthorized } from '../http'
import {
AUTHED,
BulkIdsRequest,
form,
idParam,
intQuery,
@@ -71,11 +69,7 @@ import {
import { createReport } from '../reports-db'
import type { Context } from 'hono'
import type { StoredNotification } from '@repo/domain'
import type {
PlayerEventIdPayload,
PlayerEventResponsePayload,
} from '../../../notify/src/notification-payloads'
import type { PlayerEventResponsePayload } from '../../../notify/src/notification-payloads'
import type { App } from '../context'
import type { EventAttendeeRow, EventInput, EventTag, PlayerEvent } from '../events-db'
@@ -110,84 +104,12 @@ async function notifyEventCreated(
}
/**
* Push one frame to every player who is GOING to an event — the audience for a change
* to the event itself. Only Going rows (see {@link getGoingPlayerIds}): a player who is
* merely invited, interested or declined has nothing on screen to update. The creator
* is among them through their own Going row, so their own edit or delete reaches their
* client too, the way `PlayerEventCreated` does.
* Push a `PlayerEventResponseChanged` (83) to each player a bulk invite just added —
* what puts the event on their screen without a refetch, since an invite writes their
* response row for them.
*
* `playerIds` is passed in rather than read here because a delete has to read them
* BEFORE the rows go. Hub failures are logged and swallowed, and one player's failure
* doesn't stop the rest: the change is already stored by the time this runs.
*/
async function notifyGoing(
c: Context<App>,
playerIds: number[],
type: NotificationType,
payload: Record<string, unknown>,
playerEventId: number
): Promise<void> {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
for (const playerId of playerIds) {
try {
await hub.notifyPlayer(playerId, type, payload)
} catch (err) {
logger.error('failed to push player event notification', {
notificationType: type,
playerEventId,
playerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
}
/**
* Push a `PlayerEventUpdated` (81) to everyone Going to an event that was just edited —
* the whole-event update and every single-field edit alike — carrying the event as it
* now stands, in the same camelCase {@link toEventNotification} projection the
* `PlayerEventCreated` frame uses: the two share a payload shape on the client
* (`PlayerEventPayload` in notification-payloads.ts documents its fields; the decoder
* accepts either casing).
*/
async function notifyEventUpdated(
c: Context<App>,
event: PlayerEvent,
tags: EventTag[]
): Promise<void> {
const payload: Record<string, unknown> = { ...toEventNotification(event, tags) }
await notifyGoing(
c,
await getGoingPlayerIds(c.env.DB, event.PlayerEventId),
NotificationType.PlayerEventUpdated,
payload,
event.PlayerEventId
)
}
/**
* Push a `PlayerEventDeleted` (82) to everyone who was Going to an event that was just
* deleted, so it leaves their screen. The frame is just the id — there is no event left
* to carry. `playerIds` must have been read before the delete took the RSVP rows with it.
*/
async function notifyEventDeleted(
c: Context<App>,
playerIds: number[],
playerEventId: number
): Promise<void> {
const payload = { PlayerEventId: playerEventId } satisfies PlayerEventIdPayload
await notifyGoing(c, playerIds, NotificationType.PlayerEventDeleted, payload, playerEventId)
}
/**
* Push a `PlayerEventResponseChanged` (83) to a player who just ANSWERED an event
* (`POST /api/playerevents/v1/respond`) — what puts the event on their screen without a
* refetch. This is the responder's own frame, carrying their own row.
*
* An INVITE does not send this. What an invitee gets is the invitation as a Message
* (see {@link pushEventInvitation}); their Pending row is not a response, and the
* client populates the event from this frame only once they have actually responded.
* Sending it on invite made the event appear as if the player had already answered.
* Only the players who actually gained a row are notified: an invite that hit an
* existing answer changed nothing, so there is nothing to tell them about.
*
* The frame carries BOTH nested objects the client's decoder expects. That is not
* optional — several of its handlers dereference one level down with no null guard, so
@@ -197,100 +119,30 @@ async function notifyEventDeleted(
* response in the PascalCase {@link toEventResponse} one the RSVP list serves; the
* decoder accepts either casing, so the two need not agree.
*
* Hub failures are logged and swallowed: the answer is already stored by the time this
* runs.
*/
async function notifyResponseChanged(
c: Context<App>,
event: PlayerEvent,
response: EventAttendeeRow
): Promise<void> {
const payload = {
PlayerEvent: { ...toEventNotification(event) },
PlayerEventResponse: { ...toEventResponse(response) },
} satisfies PlayerEventResponsePayload
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
response.player_id,
NotificationType.PlayerEventResponseChanged,
payload
)
} catch (err) {
logger.error('failed to push PlayerEventResponseChanged notification', {
playerEventId: event.PlayerEventId,
playerId: response.player_id,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Tell each player a bulk invite just added about it: the invitation as a Message of
* type 81 `PlayerEventInvitation`, stored and pushed as `MessageReceived` — see
* {@link pushEventInvitation}. That Message is the whole of what an invitee is sent; the
* `PlayerEventResponseChanged` frame is theirs only once they answer.
*
* Only the players who actually gained a row are notified: an invite that hit an
* existing answer changed nothing, so there is nothing to tell them about. One player's
* failure doesn't stop the rest: the invites are already stored by the time this runs.
* Hub failures are logged and swallowed, and one player's failure doesn't stop the
* rest: the invites are already stored by the time this runs.
*/
async function notifyInvited(
c: Context<App>,
inviterId: number,
event: PlayerEvent,
added: EventAttendeeRow[]
): Promise<void> {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
const PlayerEvent = { ...toEventNotification(event) }
for (const row of added) {
await pushEventInvitation(c, inviterId, row.player_id, event.PlayerEventId)
}
}
/**
* Store and push the invitation Message a bulk invite shows the invitee: a Message of
* type 81 `PlayerEventInvitation` from the inviter, delivered as `MessageReceived` and
* kept in the inbox (`GET /api/messages/v2/get`) so a player who was offline still finds
* it. The event is named twice — on the row's own `PlayerEventId` column, and as `Data`,
* the event id as a STRING (a Message's `Data` is always a string on the wire).
*
* The row is committed before the push; a hub failure is logged and swallowed, since the
* invite itself is already stored.
*/
async function pushEventInvitation(
c: Context<App>,
fromPlayerId: number,
toPlayerId: number,
playerEventId: number
): Promise<void> {
let message: StoredNotification
try {
message = await createNotification(c.env.DB, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: MessageType.PlayerEventInvitation,
Data: String(playerEventId),
PlayerEventId: playerEventId,
})
} catch (err) {
logger.error('failed to store PlayerEventInvitation message', {
playerEventId,
toPlayerId,
error: err instanceof Error ? err.message : String(err),
})
return
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toPlayerId,
NotificationType.MessageReceived,
{ ...message }
)
} catch (err) {
logger.error('failed to push PlayerEventInvitation MessageReceived notification', {
notificationId: message.Id,
playerEventId,
toPlayerId,
error: err instanceof Error ? err.message : String(err),
})
const payload = {
PlayerEvent,
PlayerEventResponse: { ...toEventResponse(row) },
} satisfies PlayerEventResponsePayload
try {
await hub.notifyPlayer(row.player_id, NotificationType.PlayerEventResponseChanged, payload)
} catch (err) {
logger.error('failed to push PlayerEventResponseChanged notification', {
playerEventId: event.PlayerEventId,
playerId: row.player_id,
error: err instanceof Error ? err.message : String(err),
})
}
}
}
@@ -298,13 +150,12 @@ async function pushEventInvitation(
* Wrap an event in the v2 envelope for THIS caller's build.
*
* Rec Room reshaped `PlayerEvent.Tags` without minting a new path, so the same endpoint
* owes the 2023 build `["celebration"]` and anything newer `[{ Tag, Type }]`. The build
* owes the 2023 build `[{ Tag, Type }]` and the 2025 build `["celebration"]`. The build
* comes off the token's `rn.ver` claim — the request carries no version of its own — and
* the split is the one `/api/gameconfigs/v1/all` already makes: anything NEWER than
* `GAME_VERSION` (20230414) gets the pairs; that build, anything older, and a request
* with no readable token version all get the 2023 names. Builds are date-stamped, so they
* order as strings. `TagModifyResult.Tags` is a name list to every build — only the
* inline field moves.
* `GAME_VERSION` (20230414) is the 2025 client; that build, anything older, and a request
* with no readable token version all get the 2023 shape. Builds are date-stamped, so they
* order as strings.
*
* Like the other version gates here the claim is unverified — a client that lies about its
* build only empties its own tag chips.
@@ -312,7 +163,7 @@ async function pushEventInvitation(
async function eventResult(c: Context<App>, event: PlayerEvent, tags: EventTag[]) {
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
const isModernBuild = version !== null && version > GAME_VERSION
return toEventResult(event, tags, isModernBuild)
return toEventResult(event, tags, !isModernBuild)
}
/**
@@ -328,7 +179,6 @@ async function eventResult(c: Context<App>, event: PlayerEvent, tags: EventTag[]
*
* These edits are creator-only like the whole-event update, and they go through the same
* {@link updateEvent}, so a patch touching one field leaves the rest of the event alone.
* Like it, each pushes a `PlayerEventUpdated` to everyone Going.
*/
function editEventField(
parse: (c: Context<App>, event: PlayerEvent) => Promise<EventInput | null>
@@ -348,9 +198,7 @@ function editEventField(
if (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.
const tags = await getEventTags(c.env.DB, eventId)
await notifyEventUpdated(c, updated!, tags)
return c.json(await eventResult(c, updated!, tags))
return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId)))
}
}
@@ -535,12 +383,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
)
// A room's event shelf (`/room/12`) — what is on in this room, current and upcoming.
// A bare array, no envelope: the single-club form's `{ ContinuationToken, Events }` is
// the odd one out, and a room's shelf is small enough that there is nothing to page.
//
// The BASE event, not the stored record. The client reads this through the same generic
// helper and the same element type as the browse feed and the bulk read, so those three
// are shape-identical on its side — `toEventBase` is what keeps them identical here.
// A bare array of the stored record, like the multi-club shelf and `/searchlive`: the
// single-club form's `{ ContinuationToken, Events }` envelope is the odd one out, and a
// room's shelf is small enough that there is nothing to page.
.get(
'/api/playerevents/v1/room/:roomId{[0-9]+}',
describeRoute({
@@ -548,25 +393,17 @@ export const eventRoutes = new Hono<App>({ strict: false })
summary: 'Player events in one room',
description:
'The events scheduled in a room — the shelf on the room’s page — soonest first. A ' +
'bare array of the client’s BASE event (17 keys — no `State`, `ImageName` as `""` ' +
'rather than null, plus `BroadcastingRoomInstanceId`), the same projection the ' +
'browse feed and the bulk read serve: the client decodes all three through one ' +
'generic helper and one element type. `/searchlive` and the club shelves serve the ' +
'stored record instead.\n\n' +
'bare array of the stored record, the same projection `/searchlive` and the ' +
'multi-club shelf serve.\n\n' +
'CURRENT and UPCOMING only: the filter is on the END time, so a running event stays ' +
'listed until it is over rather than vanishing the moment it starts, and an event ' +
'that has finished is dropped — this answers what someone can still turn up to. A ' +
'room with nothing scheduled, and a room id that does not exist, both answer an ' +
'empty array; the shelf is about events, not about whether the room is real.',
parameters: [idParam('roomId', 'Room id')],
responses: {
200: json(PlayerEventBaseDto.array(), 'The room’s current and upcoming events'),
},
responses: { 200: json(PlayerEventDto.array(), 'The room’s current and upcoming events') },
}),
async (c) => {
const events = await getEventsByRoom(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
return c.json(events.map(toEventBase))
}
async (c) => c.json(await getEventsByRoom(c.env.DB, Number.parseInt(c.req.param('roomId'), 10)))
)
// Live player-event search (the "happening now" browse query) — events that have
@@ -620,62 +457,22 @@ export const eventRoutes = new Hono<App>({ strict: false })
}
)
// Bulk fetch — the events behind a list of ids the client already holds. What the
// client actually calls is the POST, with the ids in a form body
// (`Ids=101&Ids=102&Ids=103`, or `Ids=13` for one); the GET below is the same read with
// the ids in the query, kept for hand-written calls.
//
// The BASE event, like the browse feed and the room shelf: one generic helper and one
// element type decode all three on the client, so this is "the feed, filtered to these
// ids" and must not drift into the stored-record shape the by-id read serves.
//
// Answers in the order 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 either way: no envelope, no `{ ContinuationToken, Events }`.
.post(
// 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, as a form body: `Ids` ' +
'repeated once per id (`Ids=101&Ids=102&Ids=103`), or one comma-separated `Ids=1,2,3`. ' +
'A bare array of the client’s BASE event — the same projection the browse feed and ' +
'the room shelf serve, this one filtered to the requested ids.\n\n' +
'Answers in the order the ids were asked for and skips ids with no event rather ' +
'than leaving a hole, so the result may be shorter than the request. No ids at all ' +
'is an empty array, not a 400.',
requestBody: form(BulkIdsRequest, 'The event ids to look up'),
responses: {
200: json(PlayerEventBaseDto.array(), 'The events that exist, in request order'),
},
}),
async (c) => {
const events = await getEventsByIds(c.env.DB, await parseFormIds(c))
return c.json(events.map(toEventBase))
}
)
// The same read with the ids in the query (`?id=1&id=2`) — not what the client sends,
// but the shape stays identical to the POST's so the path can't answer two things.
.get(
'/api/playerevents/v1/bulk',
describeRoute({
tags: ['Events'],
summary: 'Several player events by id (query form)',
description:
'The same read as the POST on this path, with the ids in the query (`?id=1&id=2`) ' +
'rather than a form body — the client sends the POST. Identical response: a bare ' +
'array of the BASE event, in request order, skipping ids with no event.',
'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(PlayerEventBaseDto.array(), 'The events that exist, in request order'),
},
responses: { 200: json(PlayerEventDto.array(), 'The events that exist, in request order') },
}),
async (c) => {
const events = await getEventsByIds(c.env.DB, queryIds(c))
return c.json(events.map(toEventBase))
}
async (c) => c.json(await getEventsByIds(c.env.DB, queryIds(c)))
)
// RSVP. One row per player per event, so responding again replaces the previous
@@ -697,13 +494,8 @@ export const eventRoutes = new Hono<App>({ strict: false })
'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' +
'The caller is also pushed a `PlayerEventResponseChanged` frame carrying the event ' +
'and their new response row — this frame, not the invite, is what populates the ' +
'event on the client. An invitee answering Pending → Going gets it here like ' +
'anyone else.\n\n' +
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400 — 3 ' +
'Pending is the server’s own mark for an unanswered invite, not an answer a ' +
'player can give; an unknown event is a 404.',
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400; an ' +
'unknown event is a 404.',
security: AUTHED,
requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'),
responses: {
@@ -726,10 +518,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
// Going would silently inflate the count.
if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400)
const result = await setEventResponse(c.env.DB, eventId, id, type)
if (result === null) return c.body(null, 404)
await notifyResponseChanged(c, result.event, result.response)
return c.json(await eventResult(c, result.event, await getEventTags(c.env.DB, eventId)))
const updated = await setEventResponse(c.env.DB, eventId, id, type)
if (updated === null) return c.body(null, 404)
return c.json(await eventResult(c, updated, await getEventTags(c.env.DB, eventId)))
}
)
@@ -797,30 +588,20 @@ export const eventRoutes = new Hono<App>({ strict: false })
)
// Bulk invite — the "invite friends" button on an event. Adds the invited players to
// the same `event_attendee` table an RSVP writes to, as Pending (3) — an open
// invitation, not an acceptance.
// the same `event_attendee` table an RSVP writes to, as Going.
.post(
'/api/playerevents/v1/bulkInvite',
describeRoute({
tags: ['Events'],
summary: 'Invite players to an event',
description:
'Adds the invited players to the event as Pending (`Type` 3) — the same ' +
'`event_attendee` rows an RSVP writes, so an invited player shows up in ' +
'`…/responses` with an open invitation. Pending is not Going: the invitee does not ' +
'count toward `AttendeeCount` until they answer through `…/respond`. (Inviting as ' +
'Going made the invitation look already accepted on the client.)\n\n' +
'Adds the invited players to the event as Going — the same `event_attendee` rows ' +
'an RSVP writes, so an invited player shows up in `…/responses` and counts toward ' +
'`AttendeeCount` immediately, without having answered.\n\n' +
'An invite never overwrites an answer: a player who already responded keeps what ' +
'they said, so inviting someone who declined does not reset them to Pending, and ' +
'they said, so inviting someone who declined does not flip them back to Going, and ' +
're-inviting is a no-op. The caller is skipped (they are already on the list), as ' +
'are duplicate ids.\n\n' +
'Each newly invited player is sent the invitation as a Message of type 81 ' +
'`PlayerEventInvitation` from the caller — stored in their inbox ' +
'(`GET /api/messages/v2/get`) and pushed as `MessageReceived`, with the event id ' +
'on `PlayerEventId` and, as a string, in `Data`. That is all an invitee gets: no ' +
'`PlayerEventResponseChanged` is pushed on invite — that frame is what `…/respond` ' +
'sends once they answer, and is what populates the event on their client. ' +
'Players whose existing answer was left alone get nothing.\n\n' +
'The caller must be on the event themselves — its creator, or a player with a ' +
'response row of any kind. Anyone else gets 403: an invite adds attendees, so it ' +
'is not something a passer-by can do. Answers the same ' +
@@ -829,7 +610,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
security: AUTHED,
requestBody: jsonBody(PlayerEventBulkInviteRequest, 'The event and who to invite'),
responses: {
200: json(PlayerEventResultDto, 'The event (its attendee count is unchanged by an invite)'),
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'),
400: { description: 'Missing `PlayerEventId` or `InvitedPlayerIds` (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'The caller is not on the event (empty body)' },
@@ -869,7 +650,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
]
const result = await inviteToEvent(c.env.DB, eventId, invited)
// inviteToEvent only returns null when the row vanished, which the read above rules out.
await notifyInvited(c, id, result!.event, result!.added)
await notifyInvited(c, result!.event, result!.added)
return c.json(await eventResult(c, result!.event, await getEventTags(c.env.DB, eventId)))
}
)
@@ -948,9 +729,6 @@ export const eventRoutes = new Hono<App>({ strict: false })
'Deletes an event the caller created, along with its RSVPs and its tags — an event ' +
'whose attendee rows outlived it would still be counted, and its tags would still ' +
'answer `#tag` searches.\n\n' +
'Everyone who was GOING to the event (the creator included) is pushed a ' +
'`PlayerEventDeleted` frame naming the event id, so it leaves their screen; ' +
'interested, declined and still-Pending invitees are not.\n\n' +
'Creator only: anyone else gets 403, and an unknown event 404. Answers the v2 ' +
'envelope with `PlayerEvent` and `TagModifyResult` both null — the event is gone, ' +
'so there is nothing for the client to redraw from, and it reads only `Result`. ' +
@@ -974,10 +752,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
if (existing === null) return c.body(null, 404)
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
// Who to tell has to be read BEFORE the delete: it takes the RSVP rows with it.
const going = await getGoingPlayerIds(c.env.DB, eventId)
await deleteEvent(c.env.DB, eventId)
await notifyEventDeleted(c, going, eventId)
// Both payload fields are null here — the delete envelope is not the one the other
// v2 routes answer with. Nothing is left to redraw, and the client reads `Result`.
return c.json(EVENT_DELETED_RESULT)
@@ -1030,9 +805,6 @@ export const eventRoutes = new Hono<App>({ strict: false })
'The id, the creator and the attendee count are not editable: ownership doesn’t ' +
'transfer and RSVPs aren’t set by hand. Creator only — anyone else gets 403, and ' +
'an unknown event is 404. Answers the same envelope as create.\n\n' +
'Everyone who is GOING to the event (the creator included) is pushed a ' +
'`PlayerEventUpdated` frame carrying the edited event; interested, declined and ' +
'still-Pending invitees are not. The single-field edits push the same frame.\n\n' +
'The 24-hour window cap applies to what the post RESOLVES to, not to what it ' +
'carries: moving the start alone still has to leave a window that ends after it ' +
'and runs no longer than a day against the STORED end.',
@@ -1067,9 +839,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
if (eventInputRejection(input, existing) !== 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.
const tags = await getEventTags(c.env.DB, eventId)
await notifyEventUpdated(c, updated!, tags)
return c.json(await eventResult(c, updated!, tags))
return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId)))
}
)
-26
View File
@@ -227,32 +227,6 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
}),
(c) => c.json(communityBoard)
)
// Circuit chip lists — the palettes the Maker Pen's circuit board groups its chips into
// (`/api/CircuitChipLists/Favorites`, `/api/CircuitChipLists/Recent`, and so on). The path
// segment names the list; nothing here records which chips a player has used or favourited,
// so every one of them is empty.
//
// EMPTY rather than 404 for a name this server doesn't know: the client asks for whichever
// palettes its build has, and an unknown one is a palette this server has no opinion about
// rather than an error — a 404 shows as a palette that failed to load, where an empty list
// shows as one with nothing in it, which is the truth for all of them.
.get(
'/api/CircuitChipLists/:list',
describeRoute({
tags: ['Gameplay'],
summary: 'One circuit chip list',
description:
'A palette on the Maker Pen’s circuit board, named by the path (`Favorites`, ' +
'`Recent`, …). Always empty: nothing records which chips a player has used or ' +
'favourited yet. An unknown name is empty too rather than a 404 — the client asks ' +
'for whichever palettes its build has, and a 404 renders as a palette that failed ' +
'to load rather than one with nothing in it.',
parameters: [stringParam('list', 'The palette name, e.g. `Favorites`')],
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
// Player events live in their own controller (routes/events.ts) — they're D1-backed
// now, unlike the stubs around them here.
.get(
+8 -42
View File
@@ -46,7 +46,6 @@ import {
UploadImageRequest,
UploadImageResponse,
} from '../openapi'
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
import type { Context } from 'hono'
import type { App } from '../context'
@@ -73,17 +72,6 @@ const typeFolder: Record<number, string> = {
*/
const PHOTO_TAGGING_KEY = 'playerPhotoTaggingSetting'
/**
* The owner's-view clause the player photo lists share in their docs. The lists are
* public, but they read a Bearer token if one is sent, and the one player it can unlock
* anything for is the player whose list it is.
*/
const OWN_VIEW_NOTE =
'Public, but reads a Bearer token if one is sent: a player asking for THEIR OWN list ' +
'gets their private photos in it too (`Accessibility` 0 — that is where the photos they ' +
'have not shared yet live). Any other player’s token, no token, or an invalid one serves ' +
'the public list; none of those is a 401.'
/**
* The preference a player has before they have ever set one. The value is an opaque enum
* ordinal to this server (see `PhotoTaggingSettingRequest`), and 0 is what an unset .NET
@@ -222,7 +210,6 @@ export const imageRoutes = new Hono<App>({ strict: false })
200: json(UploadImageResponse, 'The stored bucket key'),
400: json(ErrorResponse, 'No file in the request'),
401: UNAUTHORIZED_RESPONSE,
413: json(ErrorResponse, 'The image exceeds the configured per-file limit'),
},
}),
async (c) => {
@@ -234,12 +221,6 @@ export const imageRoutes = new Hono<App>({ strict: false })
const candidate = body.image ?? body.file
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
const file = candidate
const limit = maxApiUploadBytes(c.env)
// parseBody has already materialized the multipart part. Reject it before arrayBuffer()
// creates another full-size allocation and before the object can consume R2 storage.
if (exceedsApiUploadLimit(file, limit)) {
return c.json({ error: `image exceeds the ${limit}-byte upload limit` }, 413)
}
// `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`),
// posted as a multipart field. It carries the metadata we record on the image
@@ -384,22 +365,15 @@ export const imageRoutes = new Hono<App>({ strict: false })
// 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 of the
// client's ImagesPlayer projection (SavedImageId/SavedImageType, not Id/Type).
//
// Public, but a token is READ if one is sent: a player asking for their own list gets
// their private photos in it too (the profile they open on themselves is where those
// live), while anyone else's token — or none — serves the public list. A bad token is
// not a 401 here; it just reads as nobody.
.get(
'/api/images/v4/player/:playerId{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'A player’s photos',
description:
'The public camera photos (`SavedImageType` 1) that player has taken, newest first — ' +
'not the outfit, room, invention or profile thumbnails filed under them. Serves the client’s ' +
'The public images that player has taken, newest first. Serves the client’s ' +
'`ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) ' +
'— the raw `SavedImage` renders blank thumbnails here.\n\n' +
OWN_VIEW_NOTE,
'— the raw `SavedImage` renders blank thumbnails here.',
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s photos') },
}),
@@ -407,8 +381,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
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 viewer = await authedId(c)
const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take, viewer)
const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take)
return c.json(images.map(toImagesPlayer))
}
)
@@ -420,9 +393,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
describeRoute({
tags: ['Images'],
summary: 'A player’s photos, sortable',
description:
'v4 plus a `sort` option. Same `ImagesPlayer` projection, and the same owner’s view — ' +
'see the notes on v4.',
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'),
@@ -435,8 +406,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
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 viewer = await authedId(c)
const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take, viewer)
const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take)
return c.json(images.map(toImagesPlayer))
}
)
@@ -450,11 +420,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
tags: ['Images'],
summary: 'A player’s photo feed',
description:
'The public camera photos 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.\n\n' +
OWN_VIEW_NOTE +
' A photo someone else took and tagged them in stays public-only either way: its ' +
'privacy is the taker’s.',
'The public images they took PLUS the ones they are tagged in, newest first — the ' +
'photo tab on a profile. Same `ImagesPlayer` projection as the player photo lists.',
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s feed') },
}),
@@ -462,8 +429,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
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 viewer = await authedId(c)
const images = await getPlayerFeed(c.env.DB, playerId, skip, take, viewer)
const images = await getPlayerFeed(c.env.DB, playerId, skip, take)
return c.json(images.map(toImagesPlayer))
}
)
+34 -778
View File
@@ -1,60 +1,25 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
canManageRoom,
canModerateRoom,
countPlayersInInstance,
getPlayerIdsInInstance,
getPresence,
getPresences,
getRoomById,
getStoredRoomInstance,
MessageType,
movePlayerToDorm,
} from '@repo/domain'
import { logger } from '@repo/hono-helpers'
// The message being reported, and whether the reporter can see it — `chat` owns both tables
// on the shared database, so its reads are used rather than restated here.
import { getMessage } from '../../../chat/src/message-db'
import { isThreadMember } from '../../../chat/src/thread-db'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
// value — the enum has no runtime dependencies.
import { KickReportCategory } from '../../../notify/src/notification-payloads'
import { NotificationType } from '../../../notify/src/notification-types'
import { authedId, authedRoles, unauthorized } from '../http'
import {
AUTHED,
BareBoolean,
CreateChatReportRequest,
CreateReportRequest,
CreateWarningRequest,
DeviceIdRequest,
form,
InstantKickRequest,
json,
JsonArray,
jsonBody,
ModerationBlockDetails,
RoomModKickRequest,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
VoteToKickReason,
VoteToKickRequest,
} from '../openapi'
import { banBlockDetails, createReport, getActiveBan, NOT_BLOCKED } from '../reports-db'
import {
countKickVotes,
getOpenVote,
isKickMajority,
isOnVoteCooldown,
recordRoomVote,
} from '../votes-db'
import { createReport } from '../reports-db'
import { createWarning } from '../warnings-db'
import type { Context } from 'hono'
import type { ModerationKickPayload } from '../../../notify/src/notification-payloads'
import type { App } from '../context'
/**
@@ -79,15 +44,6 @@ function formField(
return c.req.query(name) || undefined
}
/**
* Whether a posted `Response` is a YES. The client sends `True`/`False`; `1`/`0` are accepted
* beside them because the same field is an int elsewhere in this controller. Anything else —
* absent included — is a no, which is the safe way round: an unreadable answer must not count
* toward removing somebody.
*/
const isYesVote = (value: string | undefined): boolean =>
value !== undefined && ['true', '1', 'yes'].includes(value.trim().toLowerCase())
/** 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
@@ -102,39 +58,6 @@ const asFloat = (v: string | undefined): number | null => {
return Number.isNaN(n) ? null : n
}
/**
* Chat-report category names that are not a `KickReportCategory` member's own name. Only
* `Discriminatory` has been seen on the wire; add the others as the client is seen to send
* them, rather than guessing at an enum nobody here has a copy of.
*/
const CHAT_REPORT_CATEGORY_ALIASES: Record<string, KickReportCategory> = {
discriminatory: KickReportCategory.CoCDiscrimination,
}
/**
* The numeric category a chat report is filed under. The chat report sends its category as
* an enum NAME (`ReportCategory=Discriminatory`) where every other report sends a number,
* and `report.report_category` is numeric — it is what the staff panel labels rows from and
* what a ban's block screen names as the reason — so the name is mapped onto the same
* `KickReportCategory` codes the other reports carry. A number is taken as it comes.
*
* Null for a name that maps to nothing, which the caller files as `Unknown` with the name
* kept in the details: losing which box the reporter ticked would be worse than an odd-looking
* description.
*/
function chatReportCategory(raw: string | undefined): number | null {
if (raw === undefined) return KickReportCategory.Unknown
const name = raw.trim()
if (/^-?\d+$/.test(name)) return Number.parseInt(name, 10)
const key = name.toLowerCase()
const alias = CHAT_REPORT_CATEGORY_ALIASES[key]
if (alias !== undefined) return alias
for (const [member, value] of Object.entries(KickReportCategory)) {
if (typeof value === 'number' && member.toLowerCase() === key) return value
}
return null
}
/**
* The vote-to-kick reasons, in the order the client renders them. `ReportCategory` is the
* category the report a carried vote files: 102 hate, 101 sexual content, 103 griefing,
@@ -159,201 +82,16 @@ const VOTE_TO_KICK_REASONS = [
{ Reason: 'Not following game rules', ReportCategory: 6 },
] as const
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/**
* Eject players from the instance they're standing in — the `ModerationKick` frame (id 22)
* the client acts on to leave a room, the same one a room ban sends (`rooms`:
* `pushRoomBan`). This one only kicks: `IsBan` is false, so nothing keeps them from walking
* straight back in, and no ban row exists to lift.
*
* Sent EPHEMERALLY, unlike the ban's frame, and to the whole batch in one round-trip. A
* kick is only true of the moment it happened: queued and delivered on the player's next
* connect it would throw them out of some unrelated session hours later. A recipient who
* has already gone offline needs no kick anyway.
*
* `GameSessionId` is the instance they're being removed from — every recipient is in it,
* which is what the caller checked before this runs. `IsHostKick` is always true: this
* endpoint is the room's own staff acting, never the room majority vote-kicking (that path
* would carry `VoteKick` and false). Built against the client's recovered payload interface
* so a renamed key fails the build rather than vanishing on the wire.
*
* Best-effort — presence is already deleted by the time this runs, so a hub hiccup must
* not fail the request.
*/
async function pushInstantKick(
c: Context<App>,
playerIds: number[],
gameSessionId: number,
roomName: string,
moderatorId: number,
{ reason = '', isHostKick = true }: { reason?: string; isHostKick?: boolean } = {}
): Promise<void> {
const frame: ModerationKickPayload = {
ReportCategory: KickReportCategory.Moderator,
Duration: 0,
GameSessionId: gameSessionId,
IsHostKick: isHostKick,
Message: reason
? `You have been kicked from ${roomName}. Reason: ${reason}`
: `You have been kicked from ${roomName}.`,
PlayerIdReporter: moderatorId,
IsBan: false,
IsVoiceModAutoban: false,
IsWarning: false,
VoteKickReason: '',
TimeoutStartedAt: null,
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
playerIds,
NotificationType.ModerationKick,
{ ...frame }
)
} catch (err) {
logger.error('failed to push ModerationKick notification', {
playerIds,
gameSessionId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* What a vote-to-kick Message carries, and it is not what this once sent.
*
* `FromPlayerId` is the player being VOTED ON, not the one who called the vote. The client
* raises its prompt about whoever that names, so sending the caller put the voter's own name
* on the prompt — "kick 153?" when 153 was the one asking to kick 187. Nothing on the frame
* identifies the caller, and nothing needs to: the prompt is about the accused.
*
* `Data` is the REASON, as plain text (`Inactive in games (AFK)`) — the label the client
* offered in `voteToKickReasons` and posted back. It used to be an escaped JSON object of
* `{ PlayerId, Response, GameSessionId }`, which reached players as that JSON printed on the
* prompt where the reason belongs.
*
* It is still a STRING on the wire, which is the part that has not changed: a Message's `Data`
* is a string like every other Message's, and an object there fails the client's decoder
* outright (`expected:'String Begin Token', actual:'{'`), aborting the whole notification
* rather than dropping the field. A reason is text, so it needs no escaping to satisfy that.
*/
/**
* The Message a vote-to-kick frame carries — the same four fields as every other Message
* this server sends (see the `social` routes' `Message`), `Data` string included. A type
* alias rather than an interface: the hub's send takes an index-signature record, which
* only aliases satisfy implicitly.
*/
type VoteToKickMessage = {
FromPlayerId: number
ToPlayerId: number
Type: number
Data: string
}
/**
* Put a vote-to-kick to one player — a `MessageReceived` frame carrying a Message of type
* 5 (`VoteToKick`), the frame their client raises the vote prompt from. Resolves false when
* the hub could not be reached, which the caller reports honestly: nothing stores a vote,
* so the notification is the whole delivery.
*
* EPHEMERAL, unlike the messages the social routes send. A vote belongs to the moment it
* was called: queued for an offline player, it would raise a prompt on their next connect
* about a session that ended hours ago, and there would be nothing left to vote on.
*/
async function pushVoteToKick(c: Context<App>, message: VoteToKickMessage): Promise<boolean> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayerEphemeral(
message.ToPlayerId,
NotificationType.MessageReceived,
message
)
return true
} catch (err) {
logger.error('failed to push VoteToKick MessageReceived notification', {
toPlayerId: message.ToPlayerId,
error: err instanceof Error ? err.message : String(err),
})
return false
}
}
/**
* Carry out a vote-to-kick that has passed: the player is moved into their own dorm and gets a
* `ModerationKick` frame (id 22), exactly as the staff kicks in this controller do.
*
* It differs from those in who it says did it. `ReportCategory` is `VoteKick` (10) and
* `IsHostKick` is false — the ROOM removed them, not its host — and `PlayerIdReporter` is the
* player whose vote carried it rather than a moderator. `IsBan` is false: a vote-kick ends the
* session they are in and nothing more, so they may walk straight back in, and a room that
* wants them gone for good bans them.
*
* `VoteKickReason` carries the reason the vote was called on, which is the field the client
* has for it — unlike the staff kicks, which leave it empty and put their text in `Message`.
*
* Best-effort, and deliberately after the ballot is recorded: the vote happened and was
* counted whatever the hub does next.
*/
async function applyVoteKick(
c: Context<App>,
playerId: number,
gameSessionId: number,
reason: string,
carriedByAccountId: number
): Promise<void> {
try {
// Into their own dorm, not out of existence — see the note in `instantKick`.
await movePlayerToDorm(c.env.DB, playerId)
const instance = await getStoredRoomInstance(c.env.DB, gameSessionId)
const room = instance && (await getRoomById(c.env.DB, instance.roomId))
const roomName = typeof room?.Name === 'string' ? room.Name : 'this room'
const frame: ModerationKickPayload = {
ReportCategory: KickReportCategory.VoteKick,
Duration: 0,
GameSessionId: gameSessionId,
IsHostKick: false,
Message: `You have been kicked from ${roomName}.`,
PlayerIdReporter: carriedByAccountId,
IsBan: false,
IsVoiceModAutoban: false,
IsWarning: false,
VoteKickReason: reason,
TimeoutStartedAt: null,
}
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
[playerId],
NotificationType.ModerationKick,
{ ...frame }
)
} catch (err) {
logger.error('failed to carry out a vote kick', {
playerId,
gameSessionId,
error: err instanceof Error ? err.message : String(err),
})
}
}
// ---- Player reporting ------------------------------------------------------
export const moderationRoutes = new Hono<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). The one
// kind of block this server has is the account-wide ban — a `report` row with `banned`
// set (see `getActiveBan`), the same row matchmake refuses on — so a caller with one in
// force gets it described here, and everyone else gets the "not blocked" answer of the
// reference server's stub `ReturnModerationBlockDetails()`. This is the screen a banned
// player is shown, which is why `auth` still issues them a token: without one the client
// never gets here, and the ban reads as a failed sign-in.
// Only the caller's OWN account is consulted, not the evasion arms `resolveBan` adds
// at matchmake and login: this screen explains a ban handed to this account, and a
// player blocked for sharing a network with a banned one has no report row to show.
// In the "not blocked" answer `ReportCategory` is `Unknown` (-1) rather than 0, which
// is a real category, and `Message` is null, not the empty string that stub sends —
// the client distinguishes "no message" from a blank one. `IsVoiceModAutoban` /
// `TimeoutStartedAt` are on the DTO but unset there, so they go out with their C#
// defaults.
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
// are stored (a report row with `banned` set) and enforced at matchmake and at login,
// but this endpoint is not wired to them, so it's always the "not blocked" answer —
// the reference server's stub `ReturnModerationBlockDetails()`.
// `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category;
// `Message` is null, not the empty string that stub sends — the client distinguishes
// "no message" from a blank one. `IsVoiceModAutoban`/`TimeoutStartedAt` are on the
// DTO but left unset there, so they go out with their C# defaults.
// The newer client POSTs this with no body despite it being a pure read; it answers
// GET too, so the path is reachable from either build.
.on(
@@ -363,42 +101,29 @@ export const moderationRoutes = new Hono<App>({ strict: false })
tags: ['Moderation'],
summary: 'Whether the caller is blocked',
description:
'Ban / timeout / host-kick state for the caller. The one block this server hands ' +
'out is the account-wide ban — a `report` row with `banned` set, the same row ' +
'matchmake refuses on (login still issues a token, so the client can reach this ' +
'screen) — so a caller with one in force gets ' +
'`IsBan: true`, the `ReportCategory` the report was filed under, the fixed ' +
'`Message` “Rule violation”, and the block’s span as the pair the client reads ' +
'them as: `TimeoutStartedAt` is when the ban was handed down (`banned_at`, ' +
'falling back to the report’s `created_at` for a ban set before that column ' +
'existed) and `Duration` the ' +
'seconds from there to `ban_expires` (2147483647, the int32 max, for a permanent ' +
'ban). ' +
'`PlayerIdReporter` stays null: it names a kicking host, and the reporter is not ' +
'shown to the player they reported. Only the caller’s own account is consulted, ' +
'not the ban-evasion arms.\n\n' +
'Everyone else gets the reference server’s stub “not blocked” answer: ' +
'`ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and ' +
'`Message` is null rather than the empty string that stub sends — the client ' +
'distinguishes “no message” from a blank one. `IsVoiceModAutoban` and ' +
'`TimeoutStartedAt` are on the DTO but unset by that stub, so they carry their ' +
'defaults, as do the seven keys past the stub’s nine that the 2025 client’s decoder ' +
'names (`IsDeviceBan`, `IsWarning`, `VoteKickReason`, `AssociatedAccountUsername`, ' +
'`ShowCreatorCodeOfConduct`, `TopMessageOverride`, `BottomMessageOverride`) — ' +
'block kinds and screen dressings this server never uses. Answers GET or POST: ' +
'the newer client POSTs it with no body.',
security: AUTHED,
responses: {
200: json(ModerationBlockDetails, 'The caller’s block, or “not blocked”'),
401: UNAUTHORIZED_RESPONSE,
},
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' +
'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' +
'not wired to them, so it is always the “not blocked” answer, following the ' +
'reference server’s stub: `ReportCategory` is `Unknown` (-1) rather than 0, which ' +
'is a real category, and `Message` is null rather than the empty string that stub ' +
'sends — the client distinguishes “no message” from a blank one. ' +
'`IsVoiceModAutoban` and `TimeoutStartedAt` are on the DTO but unset by that ' +
'stub, so they carry their defaults. Answers GET or POST: the newer client POSTs ' +
'it with no body.',
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const ban = await getActiveBan(c.env.DB, id)
return c.json(ban ? banBlockDetails(ban) : NOT_BLOCKED)
}
(c) =>
c.json({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
)
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
// the client renders them in the order they arrive — and the list is grouped by the
@@ -484,10 +209,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
tags: ['Moderation'],
summary: 'Submit a player report',
description:
'Records a player report in the `report` table; nothing dedupes the rows. A report ' +
'Records a player report in the `report` table; nothing dedupes the rows, and ' +
'`moderationBlockDetails` still answers “not blocked” unconditionally. A report ' +
'is filed unbanned — a moderator converts one into an account-wide ban by setting ' +
'`banned` on the row, which is what matchmaking refuses on and what ' +
'`moderationBlockDetails` describes to the banned player.\n\n' +
'`banned` on the row, which is what matchmaking and `/connect/token` refuse on.\n\n' +
'The reporter is the caller (from the bearer token), NOT a body field. Only ' +
'`PlayerIdReported` is required; the client omits whatever it has no value for ' +
'(a report raised outside a room carries no `RoomId`), and those are stored as ' +
@@ -532,475 +257,6 @@ export const moderationRoutes = new Hono<App>({ strict: false })
}
)
// Report one chat message. Stored in the `report` table every other report uses — same
// fields, same moderation life — with `chat_message_id` set. See
// migrations/0025_report_chat_message.sql.
.post(
'/api/chatreport/createChatReport',
describeRoute({
tags: ['Moderation'],
summary: 'Report a chat message',
description:
'Files a report against one chat message. Stored as a row in the same `report` table ' +
'a player report goes to (`POST /api/PlayerReporting/v3/create`), beside the event, ' +
'invention and custom-avatar-item reports — the same submission with the same ' +
'moderation life, and a moderator converts any of them into a ban the same way. What ' +
'marks it as a chat report is `chat_message_id`; the row’s `reported_player_id` is ' +
'the message’s SENDER, read from the `message` table, since the body names no ' +
'player. `ChatThreadId` is accepted and unused: a message id is unique across ' +
'threads, so the message names its own thread and nothing stores a second copy.\n\n' +
'The reporter is the caller (from the bearer token) and must be a MEMBER of the ' +
'thread the message is in — the same gate reading the message sits behind. A ' +
'message the caller cannot see answers exactly as one that does not exist, so the ' +
'route cannot be used to probe message ids or to file reports citing conversations ' +
'the reporter was never in. A system notice (a join/leave line) has no sender to ' +
'report and is refused.\n\n' +
'`ReportCategory` arrives as an enum NAME (`Discriminatory`), unlike the numeric ' +
'one a player report sends, and is mapped onto the numeric category the table ' +
'stores (`Discriminatory` → 102). A number is stored as sent; a name that maps to ' +
'nothing is filed as 0 with the name kept at the front of the details. ' +
'`ReportDescription` is the details. Nothing dedupes the rows.\n\n' +
'Answers the `{ success, error }` envelope the other reports use, `error` an empty ' +
'string rather than null, on the rejected branches too.',
security: AUTHED,
requestBody: form(CreateChatReportRequest, 'The report'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No usable `ChatMessageId`, or a system message'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such message, or not one the caller can see'),
},
}),
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 chatMessageId = asInt(formField(body, c, 'ChatMessageId'))
if (chatMessageId === null) {
return c.json({ success: false, error: 'ChatMessageId is required' }, 400)
}
// The message supplies the reported player, and its OWN thread is the one the
// membership check runs against — not the body's `ChatThreadId`, which would let a
// caller vouch for a message by naming a thread they happen to be in.
const message = await getMessage(c.env.DB, chatMessageId)
if (message === null || !(await isThreadMember(c.env.DB, message.chatThreadId, reporterId))) {
return c.json({ success: false, error: 'No such message' }, 404)
}
// System notices are sent as a pseudo-player with a negative id (see `chat`'s
// SYSTEM_SENDER_ID); a report against one would be a report against nobody.
if (message.senderPlayerId <= 0) {
return c.json({ success: false, error: 'That message has no sender to report' }, 400)
}
const rawCategory = formField(body, c, 'ReportCategory')
const category = chatReportCategory(rawCategory)
const description = formField(body, c, 'ReportDescription') ?? null
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId: message.senderPlayerId,
reportCategory: category ?? KickReportCategory.Unknown,
details:
category === null
? [`[${rawCategory?.trim()}]`, description].filter(Boolean).join(' ')
: description,
chatMessageId,
})
return c.json({ success: true, error: '' })
}
)
// A player calling a vote to kick another. Ungated by role — anyone may start one —
// but both players have to be standing in the session the vote is called in, which is
// what stops a client putting a vote to a room it isn't in, about someone who isn't
// there. The call and every answer arrive here alike; the first ballot while no vote on
// that player is open CALLS one (`init`), and only a call is put to the room.
.post(
'/api/PlayerReporting/v3/voteToKick',
describeRoute({
tags: ['Moderation'],
summary: 'Call a vote to kick a player',
description:
'Puts a vote-to-kick to the room instance. Open to any player — no role is ' +
'required — but BOTH the caller and `PlayerId` must have a live `presence` row in ' +
'the instance `GameSessionId` names, or the call is refused with a 403. That is ' +
'the whole gate: without it a client could raise a vote in a session it is not ' +
'in, or against a player who is not there.\n\n' +
'Everyone else in that instance — the player being voted on included, since a ' +
'vote is called in front of them — gets a `MessageReceived` frame carrying a ' +
'Message of type 5 (`VoteToKick`). The caller is left out: they have voted ' +
'already, and their own `Response` is what they posted.\n\n' +
'`FromPlayerId` on that Message is the player being VOTED ON, not the caller — the ' +
'client raises its prompt about whoever it names, so the caller there asks the room ' +
'to kick the wrong player. `Data` is the `Reason` as PLAIN TEXT (`Inactive in games ' +
'(AFK)`), which is what the prompt shows; it is still a string on the wire, because ' +
"an object there fails the client’s decoder (`expected:'String Begin Token', " +
"actual:'{'`) and aborts the whole notification.\n\n" +
'The frames are EPHEMERAL: a vote belongs to the moment it was called, so an ' +
'offline player gets nothing rather than a prompt about a dead session on their ' +
'next connect.\n\n' +
'Every call RECORDS a ballot in `room_vote` — the session, the player voted on, the ' +
'voter and their answer — and then tallies. A vote CARRIES when the yes votes are ' +
'strictly more than half the players standing in the session right now (a tie is not ' +
'a majority, and the count is of the room as it is, since players leave mid-vote). ' +
'The tally counts DISTINCT voters and reads only each one’s latest ballot, so ' +
're-posting a vote cannot carry one and a voter may change their mind.\n\n' +
'When it carries, the player is kicked exactly as a staff kick does it — moved into ' +
'their own dorm, the instance they left freed, an ephemeral `ModerationKick` (id 22) — ' +
'but with `ReportCategory` `VoteKick` (10), `IsHostKick` false (the ROOM removed ' +
'them), `PlayerIdReporter` the voter whose ballot carried it, and the reason in ' +
'`VoteKickReason`. `IsBan` is false: nothing stops them rejoining. No prompt goes ' +
'out in that case — the room is not asked a question that is already answered.\n\n' +
'The call and the answers arrive on this same endpoint with the same body. A ballot ' +
'posted while no vote on `PlayerId` is open in that session CALLS one: it is stored ' +
'with `init` 1 and is the only kind that puts the prompt to the room. A vote stays ' +
'open for 60 seconds; every ballot inside that window answers it — recorded and ' +
'tallied, but relayed to nobody — and only ballots from its call on are counted, so ' +
'an expired vote’s yes ballots cannot carry the next.\n\n' +
'Calling is THROTTLED: a player who called a vote in the last 5 minutes, in any ' +
'session, is refused with a 429. Answering is never throttled. A `No` cannot call a ' +
'vote — with none open it is refused with a 409.\n\n' +
'`Response` is the caller’s own vote, recorded but never relayed. Answers the same lowercase `{ success, error }` envelope as the ' +
'write; a hub failure for any recipient is reported honestly as a 500, since ' +
'with nothing behind it the frame is the whole delivery.',
security: AUTHED,
requestBody: form(VoteToKickRequest, 'The vote'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No `PlayerId` or no `GameSessionId`'),
401: UNAUTHORIZED_RESPONSE,
403: json(SuccessErrorEnvelope, 'Either player is not in that game session'),
409: json(SuccessErrorEnvelope, 'A `No` with no vote open to answer'),
429: json(SuccessErrorEnvelope, 'The caller called a vote too recently'),
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
},
}),
async (c) => {
const voterId = await authedId(c)
if (voterId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const playerIdField = formField(body, c, 'PlayerId')
const playerId = asInt(playerIdField)
if (playerIdField === undefined || playerId === null) {
return c.json({ success: false, error: 'PlayerId is required' }, 400)
}
const gameSessionId = asInt(formField(body, c, 'GameSessionId'))
if (gameSessionId === null) {
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
}
// The prompt's own text. Absent is an empty string rather than a refusal: a vote with
// no reason given is still a vote, and the client picks from a fixed list anyway.
const reason = formField(body, c, 'Reason') ?? ''
// One read for both players. A vote may only be called by someone standing in the
// session, about someone standing in the same one — the session is read from live
// presence, never from the body, so neither side can be asserted by the client.
const presences = await getPresences<{ roomInstanceId?: number }>(c.env.DB, [
voterId,
playerId,
])
const isHere = (id: number) =>
presences.get(id)?.roomInstance?.roomInstanceId === gameSessionId
if (!isHere(voterId)) {
return c.json({ success: false, error: 'You are not in that game session!' }, 403)
}
if (!isHere(playerId)) {
return c.json({ success: false, error: 'That player is not in that game session!' }, 403)
}
// Call or answer? Nothing in the body says, so it is whether a vote on this player is
// already open here: with none, this ballot calls one.
const response = isYesVote(formField(body, c, 'Response'))
const openVote = await getOpenVote(c.env.DB, gameSessionId, playerId)
const init = openVote === null
if (init) {
// A late "no" to a vote that has expired would otherwise call a new one.
if (!response) {
return c.json({ success: false, error: 'There is no vote in progress!' }, 409)
}
// Only calling is throttled — this is what stops one player putting vote after
// vote to the room. Answering someone else's vote never is.
if (await isOnVoteCooldown(c.env.DB, voterId)) {
return c.json({ success: false, error: 'You are calling votes too quickly!' }, 429)
}
}
// The caller's own ballot, recorded before the room is asked: they have voted, and a
// vote in a session holding only them and the player they called it on should carry on
// its own rather than waiting for an answer that can never come.
const ballot = await recordRoomVote(c.env.DB, {
gameSessionId,
playerId,
voterId,
response,
init,
})
// Counted against who is standing there RIGHT NOW: a majority is of the room as it is,
// not of the room as it was when the vote was called, and players leave mid-vote. Only
// THIS vote's ballots count — from its call on.
const yesVotes = await countKickVotes(
c.env.DB,
gameSessionId,
playerId,
openVote?.id ?? ballot.id
)
const playerCount = await countPlayersInInstance(c.env.DB, gameSessionId)
if (isKickMajority(yesVotes, playerCount)) {
logger.info('vote to kick carried', {
playerId,
gameSessionId,
yesVotes,
playerCount,
carriedBy: voterId,
})
await applyVoteKick(c, playerId, gameSessionId, reason, voterId)
// The room is not asked a question that has already been answered.
return c.json({ success: true, error: '' })
}
// An answer is recorded and no more: the room was asked when the vote was called, and
// re-sending the prompt on every answer would raise it again for everyone.
if (!init) return c.json({ success: true, error: '' })
// The room votes, so the audience is everyone standing there — the player being
// voted on included; a vote is called in front of them. The caller is dropped:
// their vote is the one they just posted.
const audience = (await getPlayerIdsInInstance(c.env.DB, gameSessionId)).filter(
(id) => id !== voterId
)
// Every recipient is attempted even if an earlier one fails, so the reachable
// players still get the vote.
const results = await Promise.all(
audience.map((toPlayerId) =>
pushVoteToKick(c, {
// The player being VOTED ON — the client raises its prompt about this id, so
// the caller's own id here asks the room to kick the wrong player.
FromPlayerId: playerId,
ToPlayerId: toPlayerId,
Type: MessageType.VoteToKick,
// The reason as plain text; see the note above the Message type.
Data: reason,
})
)
)
if (results.includes(false)) {
return c.json({ success: false, error: 'Failed to deliver vote' }, 500)
}
return c.json({ success: true, error: '' })
}
)
// The kick a room's own staff hand out from the moderation menu: eject named players
// from ONE live instance. Two gates, and both matter — the caller must be able to
// moderate the room the instance belongs to, and each named player must actually be
// standing in that instance. Without the second, a creator could name any account id
// and kick a stranger out of somebody else's room.
.post(
'/api/PlayerReporting/v1/instantKick',
describeRoute({
tags: ['Moderation'],
summary: 'Kick players out of a room instance',
description:
'Ejects the named players from one live room instance. `GameSessionId` is that ' +
'instance (`roomInstanceId`); the body is JSON, unlike the form posts elsewhere in ' +
'this controller.\n\n' +
'Gated to the instance’s room: the caller must be its creator or hold a role of ' +
'Moderator (20) or above on it — anyone else with a valid token gets a 403. ' +
'Nobody who can moderate the room can be kicked out of it, and a caller cannot ' +
'kick themselves.\n\n' +
'A player is only kicked if their live `presence` row puts them in **that** ' +
'instance. Anyone else named — offline, or standing in another room — is skipped ' +
'in silence, so naming an account id cannot reach into a session the caller has ' +
'no authority over.\n\n' +
'Each kicked player is moved into their own DORM — presence rewritten, not deleted, ' +
'so their client lands where it already is instead of loading the dorm a second ' +
'time — and the instance they left frees a slot. They also get a `ModerationKick` ' +
'frame (id 22) — the frame the ' +
'client acts on to leave. It is the same frame a room ban sends, but `IsBan` is ' +
'false: this only removes them from the session they are in, and nothing stops ' +
'them rejoining. The frame is EPHEMERAL — a kick is true of the moment it ' +
'happened, and queueing one would eject the player from an unrelated session on ' +
'their next connect.\n\n' +
'Answers the same lowercase `{ success, error }` envelope the report write uses, ' +
'and says nothing about who was actually kicked — the response shape is ' +
'unverified against the real service.',
security: AUTHED,
requestBody: jsonBody(InstantKickRequest, 'The instance and the players to eject'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'Unparseable body, no `GameSessionId` or no `PlayerIds`'),
401: UNAUTHORIZED_RESPONSE,
403: json(SuccessErrorEnvelope, 'The caller cannot moderate the instance’s room'),
404: json(SuccessErrorEnvelope, 'No such game session'),
},
}),
async (c) => {
const moderatorId = await authedId(c)
if (moderatorId === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as {
GameSessionId?: unknown
PlayerIds?: unknown
} | null
if (body === null) return c.json({ success: false, error: 'Invalid request body' }, 400)
const gameSessionId = typeof body.GameSessionId === 'number' ? body.GameSessionId : Number.NaN
if (!Number.isInteger(gameSessionId)) {
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
}
const playerIds = Array.isArray(body.PlayerIds)
? body.PlayerIds.filter((id): id is number => Number.isInteger(id))
: []
if (playerIds.length === 0) {
return c.json({ success: false, error: 'PlayerIds is required' }, 400)
}
// The instance names the room, and the room carries the roles this is gated on —
// a game session with no room behind it can't authorise anything.
const instance = await getStoredRoomInstance(c.env.DB, gameSessionId)
const room = instance && (await getRoomById(c.env.DB, instance.roomId))
if (!room) return c.json({ success: false, error: 'This game session does not exist!' }, 404)
if (!canModerateRoom(room, moderatorId)) {
return c.json({ success: false, error: 'Forbidden' }, 403)
}
// One read for the batch. A player is kicked only when their LIVE presence puts
// them in this very instance: offline, expired or standing elsewhere are all the
// same "not here", and are skipped rather than refused — the client sends a list
// and one stale id in it must not sink the rest.
const presences = await getPresences<{ roomInstanceId?: number }>(c.env.DB, playerIds)
const kicked: number[] = []
for (const playerId of playerIds) {
// The room's own staff are not kickable out of their room — otherwise a
// moderator could throw the creator out of it. Nor is the caller themselves.
if (playerId === moderatorId || canModerateRoom(room, playerId)) continue
if (presences.get(playerId)?.roomInstance?.roomInstanceId !== gameSessionId) continue
// Moved to their own dorm rather than having their presence deleted: the client
// goes to the dorm either way, and with no presence to read it arrives not knowing
// where it is and loads the dorm a second time. `movePlayerToDorm` recomputes the
// fullness of the instance they left, so this one no longer has to.
await movePlayerToDorm(c.env.DB, playerId)
kicked.push(playerId)
}
if (kicked.length > 0) {
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
await pushInstantKick(c, kicked, gameSessionId, roomName, moderatorId)
}
return c.json({ success: true, error: '' })
}
)
// ONE player kicked out of ONE live instance, from the in-room moderation menu, with a
// reason. The single-player sibling of `instantKick`, but form-encoded and gated more
// narrowly: the room's owner or a co-owner, or an account holding the staff role — not
// the room's Moderator (20) tier, which `instantKick` lets through.
//
// The cross-room guard is the same and it is the point: the instance names the room, the
// room is what the caller's authority is checked against, and the target's LIVE presence
// must put them in that very instance. Naming a player standing in some other room — even
// another instance of this one — kicks nobody, so authority over one room can never reach
// a session in another.
.post(
'/api/PlayerReporting/v1/roomModKick',
describeRoute({
tags: ['Moderation'],
summary: 'Kick one player out of a room instance, with a reason',
description:
'Ejects `PlayerId` from the live room instance `GameSessionId` (a `roomInstanceId`). ' +
'Form-encoded, unlike `instantKick`’s JSON; `Reason` is optional free text shown to ' +
'the kicked player in the kick message.\n\n' +
'Gated to the instance’s room: its creator or a co-owner, or any account whose token ' +
'carries the `moderator` / `developer` role. A room Moderator (20) or Host is NOT ' +
'enough here. Anyone else with a valid token gets a 403.\n\n' +
'The player must be standing in **that** instance — their live `presence` row must ' +
'name it. Otherwise nothing happens and the call answers `success: false`, so an ' +
'account id cannot reach into a session in another room, or another instance of this ' +
'one. The caller need not be in the instance themselves. The caller cannot kick ' +
'themselves, and nobody can kick the room’s owner or a co-owner.\n\n' +
'A kick moves the player into their own DORM — presence rewritten rather than deleted, ' +
'so their client does not load the dorm twice — frees the slot they held, and sends ' +
'them an EPHEMERAL `ModerationKick` ' +
'frame (id 22) with `IsBan: false` — nothing stops them rejoining. `IsHostKick` is ' +
'true when the caller runs the room, false for a staff account acting in a room it ' +
'does not.\n\n' +
'Answers the lowercase `{ success, error }` envelope; the shape is unverified against ' +
'the real service.',
security: AUTHED,
requestBody: form(RoomModKickRequest, 'The session, the player and a reason'),
responses: {
200: json(
SuccessErrorEnvelope,
'`{ success: true, error: "" }`, or `success: false` when the player cannot be kicked'
),
400: json(SuccessErrorEnvelope, 'No `GameSessionId` or `PlayerId`'),
401: UNAUTHORIZED_RESPONSE,
403: json(SuccessErrorEnvelope, 'The caller neither runs the room nor holds a staff role'),
404: json(SuccessErrorEnvelope, 'No such game session'),
},
}),
async (c) => {
const moderatorId = await authedId(c)
if (moderatorId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const gameSessionId = asInt(formField(body, c, 'GameSessionId'))
if (gameSessionId === null) {
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
}
const playerId = asInt(formField(body, c, 'PlayerId'))
if (playerId === null) return c.json({ success: false, error: 'PlayerId is required' }, 400)
const reason = formField(body, c, 'Reason')?.trim() ?? ''
// The instance names the room, and the room carries the roles this is gated on.
const instance = await getStoredRoomInstance(c.env.DB, gameSessionId)
const room = instance && (await getRoomById(c.env.DB, instance.roomId))
if (!room) return c.json({ success: false, error: 'This game session does not exist!' }, 404)
// The room's own owners first; the token's roles are only read when that fails.
const runsRoom = canManageRoom(room, moderatorId)
if (!runsRoom) {
const roles = await authedRoles(c)
if (!roles?.some((role) => MODERATOR_ROLES.has(role))) {
return c.json({ success: false, error: 'Forbidden' }, 403)
}
}
if (playerId === moderatorId) {
return c.json({ success: false, error: 'You cannot kick yourself!' })
}
// Otherwise a co-owner, or a staffer, could throw the room's owner out of it.
if (canManageRoom(room, playerId)) {
return c.json({ success: false, error: 'You cannot kick an owner of this room!' })
}
// The cross-room guard: their LIVE presence must name this exact instance. Offline,
// expired, in another room, or in another instance of this room are all "not here".
const presence = await getPresence<{ roomInstanceId?: number }>(c.env.DB, playerId)
if (presence?.roomInstance?.roomInstanceId !== gameSessionId) {
return c.json({ success: false, error: 'That player is not in this game session!' })
}
// Into their own dorm, not out of existence — see the note in `instantKick`.
await movePlayerToDorm(c.env.DB, playerId)
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
await pushInstantKick(c, [playerId], gameSessionId, roomName, moderatorId, {
reason,
isHostKick: runsRoom,
})
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(
+43 -342
View File
@@ -1,26 +1,15 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
getPlayerIdsInInstance,
getPresence,
getProgression,
getProgressions,
MessageType,
} from '@repo/domain'
import { getProgression, getProgressions } from '@repo/domain'
import { logger } from '@repo/hono-helpers'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
// value — the enum has no runtime dependencies.
import { NotificationType } from '../../../notify/src/notification-types'
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
import { parseFormIds, queryIds } from '../http'
import {
AUTHED,
BareInteger,
BulkIdsRequest,
CheerPlayerRequest,
CheerPlayerResponse,
SetSelectedCheerRequest,
form,
idParam,
intQuery,
@@ -28,24 +17,11 @@ import {
JsonArray,
ProgressionDto,
ReputationDto,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import {
addCheer,
CheerCategory,
DAILY_CHEER_CREDIT,
getReputation,
getReputations,
isCheerCategory,
setSelectedCheer,
spendCheerCredit,
} from '../reputation-db'
import type { Context } from 'hono'
import type { Progression } from '@repo/domain'
import type { ReputationPayload } from '../../../notify/src/notification-payloads'
import type { App } from '../context'
import type { Reputation } from '../reputation-db'
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
@@ -73,160 +49,28 @@ async function pushProgression(c: Context<App>, progression: Progression): Promi
}
/**
* Push the `MessageReceived` frame that actually plays a cheer on the cheered player's
* client — a Message of type `PlayerCheer` (or its anonymous twin). That message, NOT
* `ReputationUpdate`, is what the client renders the cheer from; every reference server
* (meownet-api, DorkNet, the E12354 C# server) sends it, and a cheer that only pushes
* `ReputationUpdate` moves the counters and plays nothing.
*
* `Data` is the category given, as a string (a Message's `Data` is always a string). An
* anonymous cheer uses the anonymous type and names sender 0, so the recipient's client
* neither shows nor can look up who gave it — `Anonymous` decides nothing else.
*
* Durable, like the rest of the target's frames: the cheer is theirs whether or not they are
* connected right now. Best-effort — the cheer is already counted.
* Default reputation for an account — the fallback used with no DB. Nobody has
* earned cheers yet, so every counter is 0 and everyone has their full cheer credit.
* `SelectedCheer` is an int (0 = none selected), not null, and `IsCheerful` is true:
* the client reads it to decide whether the player may hand out cheers at all.
*/
async function pushCheerMessage(
c: Context<App>,
fromId: number,
toId: number,
category: number,
anonymous: boolean
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toId,
NotificationType.MessageReceived,
{
FromPlayerId: anonymous ? 0 : fromId,
ToPlayerId: toId,
Type: anonymous ? MessageType.PlayerCheerAnonymous : MessageType.PlayerCheer,
Data: String(category),
}
)
} catch (err) {
logger.error('failed to push PlayerCheer MessageReceived notification', {
fromId,
toId,
error: err instanceof Error ? err.message : String(err),
})
function defaultReputation(id: number) {
return {
AccountId: id,
IsCheerful: true,
Noteriety: 0,
SelectedCheer: 0,
CheerCredit: 20,
CheerGeneral: 0,
CheerHelpful: 0,
CheerCreative: 0,
CheerGreatHost: 0,
CheerSportsman: 0,
SubscriberCount: 0,
SubscribedCount: 0,
}
}
/**
* Trim a stored reputation to the fields a `ReputationUpdate` frame carries — the client's
* decoder has no `Noteriety` or subscriber counts on this payload. Nothing else changes:
* `IsCheerful` and `SelectedCheer` are the record's, exactly as the profile DTO serves them.
* (This once overrode both per send to "play" the cheer — no reference does that, and the
* client plays a cheer off the `PlayerCheer` MESSAGE, not this frame.) Built against the
* recovered interface so a renamed key fails the build rather than vanishing on the wire.
*
* `AccountId` is who the frame is ABOUT, which is not who it is sent to: a cheer's frame
* names the player being cheered and goes to everyone watching.
*/
function reputationFrame(reputation: Reputation): ReputationPayload {
const { Noteriety: _n, SubscriberCount: _sr, SubscribedCount: _sd, ...payload } = reputation
return payload
}
/**
* Push a `ReputationUpdate` frame to one player, durably — it survives them being offline
* and lands on their next connect. For the frames that report a real change to the player
* they name: their counters moved, or their credit did.
*
* Best-effort: the cheer is already stored by the time this runs, so a hub hiccup must not
* fail the request — the numbers are right on the next read either way.
*/
async function pushReputation(
c: Context<App>,
playerId: number,
frame: ReputationPayload
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
NotificationType.ReputationUpdate,
{ ...frame }
)
} catch (err) {
logger.error('failed to push ReputationUpdate notification', {
playerId,
accountId: frame.AccountId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Push the same frame to a roomful of players, EPHEMERALLY — delivered to whoever is
* connected and dropped for anyone who isn't.
*
* That is the right send for an audience frame. A cheer's effect belongs to the moment it
* happened; queueing it would play someone else's cheer at a bystander when they next log
* in, hours later and somewhere else. The people the cheer actually changed something for
* get their own durable frame instead.
*/
async function pushReputationToRoom(
c: Context<App>,
playerIds: number[],
frame: ReputationPayload
): Promise<void> {
if (playerIds.length === 0) return
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
playerIds,
NotificationType.ReputationUpdate,
{ ...frame }
)
} catch (err) {
logger.error('failed to push ReputationUpdate notification to room', {
playerIds,
accountId: frame.AccountId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Read one field of the cheer form. The client posts it form-encoded, but the same names
* also arrive as a query string on some builds, so both are accepted (same helper the
* moderation routes use on their forms).
*/
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 form field as an integer, or null when absent / not a number. */
function asInt(value: string | undefined): number | null {
if (value === undefined) return null
const n = Number.parseInt(value, 10)
return Number.isNaN(n) ? null : n
}
/**
* Parse a form field as a bool. The client sends .NET's `True`/`False`, so the match is
* case-insensitive; anything else — an absent field included — reads as false, which is the
* safe default for `Anonymous` (a cheer nobody asked to hide is a signed one).
*/
function asBool(value: string | undefined): boolean {
return value !== undefined && /^(true|1)$/i.test(value.trim())
}
/**
* The `{ Success, Message }` body a cheer answers with — PascalCase, as the reference, and
* `Message` is NULL on success rather than an empty string. That is not the same envelope
* as the lowercase `{ success, error: "" }` the reports and warnings use; don't unify them.
*/
function cheerResult(c: Context<App>, message: string | null = null) {
return c.json({ Success: message === null, Message: message })
}
/**
* 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.
@@ -246,15 +90,12 @@ export const progressionRoutes = new Hono<App>({ strict: false })
tags: ['Progression'],
summary: 'A player’s reputation',
description:
'The cheer counters shown on a player’s profile, from the `reputation` table. A ' +
'player nobody has cheered has no row and reads back all-zero. `CheerCredit` is ' +
'the odd one out — what they have left to GIVE today, out of ' +
`${DAILY_CHEER_CREDIT}; it refills lazily, so a stale window reads as full ` +
'without being reset here.',
'The cheer counters shown on a player’s profile. No cheers are stored yet, so ' +
'every player gets the same all-zero record with full cheer credit.',
parameters: [idParam('id', 'Account id')],
responses: { 200: json(ReputationDto, 'The player’s reputation') },
}),
async (c) => c.json(await getReputation(c.env.DB, Number.parseInt(c.req.param('id'), 10)))
(c) => c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
)
.get(
'/api/players/v1/progression/:id',
@@ -290,19 +131,23 @@ export const progressionRoutes = new Hono<App>({ strict: false })
}),
(c) => c.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',
describeRoute({
tags: ['Progression'],
summary: 'Reputations in bulk',
description:
'One reputation per requested id, in request order. Ids that name no account — or ' +
'that nobody has cheered — still get an all-zero record rather than being dropped: ' +
'the client renders a profile card from each entry.',
'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) => c.json(await getReputations(c.env.DB, await parseFormIds(c)))
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(
@@ -316,148 +161,7 @@ export const progressionRoutes = new Hono<App>({ strict: false })
parameters: BULK_ID_QUERY,
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
}),
async (c) => c.json(await getReputations(c.env.DB, queryIds(c)))
)
// Cheering another player: spend one of the caller's daily credits, count it against the
// target's category counter, and play it in front of the room.
//
// Nothing stores an individual cheer — this keeps a per-player counter, not a log of who
// cheered whom. So neither `RoomId` nor `Anonymous` reaches storage: both are spent
// immediately on the notification, one deciding who sees it and the other whether it is
// seen at all.
.post(
'/api/PlayerCheer/v1/create',
describeRoute({
tags: ['Progression'],
summary: 'Cheer another player',
description:
'Hands one cheer to `PlayerIdTo` in the category `CheerCategory` names (0 General, ' +
'10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative), counting it on their ' +
'`reputation` row.\n\n' +
`A player may give ${DAILY_CHEER_CREDIT} cheers per day. The credit refills lazily: ` +
'the first cheer opens a 24-hour window, and the first cheer after that window has ' +
'passed starts a fresh one at full credit — so a player who spends all day refills ' +
'24h after their FIRST cheer, not their last.\n\n' +
'The cheered player gets a durable `MessageReceived` frame carrying a Message of ' +
'type 50 (`PlayerCheer`) — 51 (`PlayerCheerAnonymous`, sender 0) when `Anonymous` — ' +
'with `Data` = the category. That message is what plays the cheer on their ' +
'client; the `ReputationUpdate` frames below only refresh the numbers.\n\n' +
'A cheer is played in front of people, so the `ReputationUpdate` frame naming the ' +
'cheered player goes to EVERYONE in the room instance the caller is standing in, ' +
'not just the two of them. The cheered player gets it durably (their counters ' +
'really moved); the rest of the room gets it only if they are connected, since ' +
'the effect belongs to the moment. The caller gets a second frame of their own ' +
'because their `CheerCredit` moved and the response body does not carry it.\n\n' +
'`Anonymous` swaps the message for its anonymous twin (type 51, sender 0) and ' +
'nothing else — the counters move the same either way.\n\n' +
'The audience comes from the caller’s live presence, not from `RoomId`, which is ' +
'accepted and unused: a client cannot aim its effect at a room it is not in. ' +
'Neither field is stored — this keeps counters, not a log of individual cheers.\n\n' +
'Refusals (no credit left, an unknown category, cheering yourself) answer 200 with ' +
'`{ Success: false, Message }` rather than an error status — the client shows the ' +
'message.',
security: AUTHED,
requestBody: form(CheerPlayerRequest, 'The cheer'),
responses: {
200: json(
CheerPlayerResponse,
'`{ Success: true, Message: null }` — see above for refusals'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const fromId = await authedId(c)
if (fromId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const toId = asInt(formField(body, c, 'PlayerIdTo'))
if (toId === null) return cheerResult(c, 'PlayerIdTo is required')
if (toId === fromId) return cheerResult(c, 'You cannot cheer yourself')
// Validated BEFORE the credit is spent: a category we can't count would otherwise
// take a cheer off the caller and give nothing to anyone.
const category = asInt(formField(body, c, 'CheerCategory'))
if (category === null || !isCheerCategory(category)) {
return cheerResult(c, 'CheerCategory is not a cheer category')
}
const remaining = await spendCheerCredit(c.env.DB, fromId)
if (remaining === null) return cheerResult(c, 'You are out of cheers for today')
const cheered = await addCheer(c.env.DB, toId, category)
// The frame that PLAYS the cheer on the cheered player's client — a Message of
// type PlayerCheer (or its anonymous twin). `ReputationUpdate` alone moves the
// numbers and shows nothing.
await pushCheerMessage(c, fromId, toId, category, asBool(formField(body, c, 'Anonymous')))
// The frame the room sees: the cheered player's record, so everyone's copy of
// their counters moves. It is ABOUT them — `AccountId` is theirs — but goes to
// everyone standing there.
const frame = reputationFrame(cheered)
// The audience is read from the GIVER's live presence, not from the body's
// `RoomId` — a client that lied about the room would otherwise play its effect in
// someone else's. A cheer given outside a room instance (from a profile screen)
// simply has no audience.
const presence = await getPresence<{ roomInstanceId?: number }>(c.env.DB, fromId)
const instanceId = presence?.roomInstance?.roomInstanceId
const audience =
instanceId === undefined
? []
: (await getPlayerIdsInInstance(c.env.DB, instanceId)).filter((id) => id !== toId)
// The cheered player is deliberately not in that list: for them this is a real
// change to their own record, so they get it durably and get it whether or not
// they were in the room — the room gets a copy that expires with the moment.
await pushReputation(c, toId, frame)
await pushReputationToRoom(c, audience, frame)
// The caller's own record, with the credit the spend just resolved rather than a
// re-read — a cheer they fired off in parallel must not make this frame report a
// credit they no longer have.
await pushReputation(
c,
fromId,
reputationFrame({ ...(await getReputation(c.env.DB, fromId)), CheerCredit: remaining })
)
return cheerResult(c)
}
)
// Pinning a cheer to the caller's own profile: the badge the client shows next to
// their name, read back as `SelectedCheer` on the reputation DTO.
.post(
'/api/PlayerCheer/v1/SetSelectedCheer',
describeRoute({
tags: ['Progression'],
summary: 'Pin a cheer to your profile',
description:
'Stores `CheerCategory` as the caller’s `SelectedCheer` (-1 `None` unpins, read ' +
'back as 0) and pushes them a `ReputationUpdate` so a second device catches up. ' +
'Same `{ Success, Message }` reply as the cheer.',
security: AUTHED,
requestBody: form(SetSelectedCheerRequest, 'The category to pin'),
responses: {
200: json(CheerPlayerResponse, '`{ Success: true, Message: null }`'),
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 category = asInt(formField(body, c, 'CheerCategory'))
if (category === null || !(category === CheerCategory.None || isCheerCategory(category))) {
return cheerResult(c, 'CheerCategory is not a cheer category')
}
const reputation = await setSelectedCheer(c.env.DB, id, category)
await pushReputation(c, id, reputationFrame(reputation))
return cheerResult(c)
}
(c) => c.json(queryIds(c).map(defaultReputation))
)
.post(
'/api/players/v1/progression/bulk',
@@ -521,26 +225,23 @@ export const progressionRoutes = new Hono<App>({ strict: false })
}
)
// The progression event running right now — the limited-time XP event the client shows
// The progression events running right now — the limited-time XP events the client shows
// a banner and a progress track for.
//
// STUB: a bare `-1`, the reference server's "no active event" value (the id of the
// active event, or -1 when there is none). The client reads it as "no event on" and
// skips the event UI entirely. That is the honest answer (nothing here runs events) and
// the safe one: a fabricated event would draw a track that never fills. No auth —
// whether an event is running is the same fact for everybody, and the client asks while
// loading.
// STUB: an empty list, which the client reads as "no event on" and skips the event UI
// entirely. That is the honest answer (nothing here runs events) and the safe one: a
// fabricated event would draw a track that never fills. No auth — whether an event is
// running is the same fact for everybody, and the client asks while loading.
.get(
'/api/progressionEvents/active',
describeRoute({
tags: ['Progression'],
summary: 'Progression event currently running (stub)',
summary: 'Progression events currently running (stub)',
description:
'The limited-time XP event in progress, as a bare event id. Always `-1` — the ' +
'“no active event” value; nothing on this server runs one — which the client reads ' +
'as “no event” and skips the event UI, where a 404 would stall the load. No auth: ' +
'it is the same answer for every player.',
responses: { 200: json(BareInteger, 'A bare `-1` — no event is running') },
'The limited-time XP events in progress. Always an empty list — nothing on this ' +
'server runs one — which the client reads as “no event” and skips the event UI, ' +
'where a 404 would stall the load. No auth: it is the same answer for every player.',
responses: { 200: json(JsonArray, 'Empty — no event is running') },
}),
(c) => c.json(-1)
(c) => c.json([])
)
+59 -163
View File
@@ -5,15 +5,10 @@ import {
acceptFriendRequest,
addFriend,
countOnlineFriends,
createNotification,
deleteNotifications,
getAccountsByIds,
getMutualFriendIds,
getNotificationsForPlayer,
getRelationshipsForPlayer,
MessageType,
MUTUAL_FRIENDS_LIMIT,
RelationshipType,
removeFriend,
sendFriendRequest,
setRelationshipFlag,
@@ -35,7 +30,6 @@ import {
json,
JsonArray,
jsonBody,
MessageDto,
MutualFriendDto,
RelationshipDto,
SendMessageRequest,
@@ -49,7 +43,6 @@ import type {
RelationshipChange,
RelationshipFlag,
RelationshipResponse,
StoredNotification,
} from '@repo/domain'
import type { App } from '../context'
@@ -57,26 +50,31 @@ import type { App } from '../context'
const HUB_INSTANCE = 'global'
/**
* Push the `MessageReceived` frame for an ALREADY-STORED message, resolving false when the
* hub could not be reached.
*
* Takes the stored record rather than building one, so the frame carries the row's `Id` and
* `SentTime` — the client matches the message it is pushed to the one it later reads from
* `GET /api/messages/v2/get` by that id, and the delete endpoint has an id to match.
* Spread into a plain record because `notifyPlayer` takes an index-signature type, which an
* interface doesn't satisfy implicitly.
* The Message a `MessageReceived` frame carries. A type alias rather than an interface:
* `notifyPlayer` takes an index-signature record, which only aliases satisfy implicitly.
*/
async function pushMessage(c: Context<App>, message: StoredNotification): Promise<boolean> {
type Message = {
FromPlayerId: number
ToPlayerId: number
Type: number
Data: string
}
/**
* Push one `MessageReceived` frame, resolving false when the hub could not be reached.
* Unlike the relationship pushes, a failure here is NOT swallowed by the caller: there
* is no message store behind this, so the notification is the whole delivery.
*/
async function pushMessage(c: Context<App>, message: Message): Promise<boolean> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
message.ToPlayerId,
NotificationType.MessageReceived,
{ ...message }
message
)
return true
} catch (err) {
logger.error('failed to push MessageReceived notification', {
notificationId: message.Id,
toPlayerId: message.ToPlayerId,
error: err instanceof Error ? err.message : String(err),
})
@@ -124,34 +122,6 @@ async function notifyBoth(
await notifyRelationship(c, otherId, change.other)
}
/**
* Store and push the MESSAGE that a friend-graph change shows the other player. The
* `RelationshipChanged` frames above only refresh relationship state; what the client
* actually surfaces — the request in their inbox with accept/decline, the "accepted your
* request" line — is a Message of the right {@link MessageType} delivered as
* `MessageReceived`, and stored so it's still there in `GET /api/messages/v2/get` for a
* player who was offline. `Data` is NULL for both — the client's Message DTO carries
* `"Data": null` on a FriendInvite (a payload-less type, unlike a DM's empty string); the
* sender id is the whole payload.
* Delivery failure is logged by `pushMessage`; the row is committed either way.
*/
async function pushFriendMessage(
c: Context<App>,
fromPlayerId: number,
toPlayerId: number,
type: MessageType.FriendInvite | MessageType.FriendRequestAccepted
): Promise<void> {
await pushMessage(
c,
await createNotification(c.env.DB, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: type,
Data: null,
})
)
}
/**
* 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
@@ -326,8 +296,8 @@ export const socialRoutes = new Hono<App>({ strict: false })
)
// A message from one player to another — the "invite me!" style prompts the client
// sends. STORED as a notification, then pushed to the recipient's hub connection from
// the stored record, so it survives a push they never hear.
// 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({
@@ -339,16 +309,15 @@ export const socialRoutes = new Hono<App>({ strict: false })
'`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' +
'Persisted first, then pushed from the stored record, so the frame carries the ' +
'stored id and the recipient reads the same message back from ' +
'`GET /api/messages/v2/get` whether or not the push reached them. 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' +
'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 still reported honestly as ' +
'a 500 with `success: false` — the message is in their inbox, but the caller ' +
'asked to send one and nothing was delivered.',
'`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: {
@@ -369,20 +338,15 @@ export const socialRoutes = new Hono<App>({ strict: false })
return c.json({ success: false, error: 'ToPlayerId is required' }, 400)
}
// Stored FIRST, then pushed from the stored record: the row is the durable copy
// and its id is what goes on the frame. A hub failure after this point leaves
// the message in the recipient's inbox, which is the point of the store — they
// read it on next login instead of losing it.
const message = await createNotification(c.env.DB, {
// The Message the notification carries. Mirrors the coach message's shape with
// a real sender and recipient; `Data` stays a string, empty included (the hub
// drops only null/undefined from the frame).
const delivered = await pushMessage(c, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0,
// `Data` stays a string, empty included — distinct from the null the types
// carrying no payload of their own send.
Data: str(body.Data) ?? '',
})
const delivered = await pushMessage(c, message)
if (!delivered) {
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
}
@@ -403,16 +367,15 @@ export const socialRoutes = new Hono<App>({ strict: false })
'The bulk form of `POST /api/messages/v2/send`: pushes the same ' +
'`MessageReceived` frame to every id in `ToPlayerIds`, each addressed to its own ' +
'recipient (`ToPlayerId` differs per frame — the payload is not shared). Same ' +
'sender rule: the caller’s bearer token, never a body field. Stored the same way ' +
'too — one notification per recipient, each with its own id.\n\n' +
'sender rule: the caller’s bearer token, never a body field. Same non-store: the ' +
'notification is the whole delivery, queued by the hub for whoever is offline.\n\n' +
'The body is JSON rather than the single send’s form encoding, so `Type` is a ' +
'number (still an unmapped Message-model type, defaulting to 0) and `Data` a ' +
'string, commonly empty. Repeated ids are delivered once.\n\n' +
'Answers the same `{ success, error }` envelope. Delivery is attempted for every ' +
'recipient even after one fails, but a hub failure for ANY of them is reported ' +
'honestly as a 500 — the envelope has no room to say which. Each recipient gets ' +
'a stored notification of their own, with its own id, so an undelivered one is ' +
'still waiting in that player’s inbox.',
'honestly as a 500 — the envelope has no room to say which, and with no store ' +
'behind it a swallowed error would be a silently dropped message.',
security: AUTHED,
requestBody: jsonBody(SendMultipleMessagesRequest, 'The message and its recipients'),
responses: {
@@ -447,19 +410,15 @@ export const socialRoutes = new Hono<App>({ strict: false })
const data = typeof body.Data === 'string' ? body.Data : ''
// Every recipient is attempted even if an earlier one fails — the reachable
// players get their message either way. One STORED message each, so every
// recipient's copy has its own id to be read and deleted by.
// players get their message either way.
const results = await Promise.all(
toPlayerIds.map(async (toPlayerId) =>
pushMessage(
c,
await createNotification(c.env.DB, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: type,
Data: data,
})
)
toPlayerIds.map((toPlayerId) =>
pushMessage(c, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: type,
Data: data,
})
)
)
if (results.includes(false)) {
@@ -484,10 +443,8 @@ export const socialRoutes = new Hono<App>({ strict: false })
'/api/relationships/v2/sendfriendrequest',
friendMutation(
'Send a friend request',
'Offer friendship to another player. A new request also lands in the target’s inbox ' +
'as a `FriendInvite` message (type 4), pushed as `MessageReceived` — that message, ' +
'not the `RelationshipChanged` frame, is what the client shows them. Re-sending an ' +
'outstanding request is a no-op and notifies nobody.'
'Offer friendship to another player. Re-sending an outstanding request is a no-op ' +
'and notifies nobody.'
),
async (c) => {
const id = await authedId(c)
@@ -496,17 +453,6 @@ export const socialRoutes = new Hono<App>({ strict: false })
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)
// A NEW request is what the target sees as a FriendInvite message (the reference
// sends it on exactly this outcome). A request that crosses one the target already
// had out auto-accepts instead — so the target hears their own request was accepted,
// as if they'd been accepted the ordinary way. A no-op re-send says nothing.
if (change.changed) {
if (change.self.RelationshipType === RelationshipType.FriendRequestSent) {
await pushFriendMessage(c, id, target, MessageType.FriendInvite)
} else if (change.self.RelationshipType === RelationshipType.Friend) {
await pushFriendMessage(c, id, target, MessageType.FriendRequestAccepted)
}
}
return c.json(change.self)
}
)
@@ -517,9 +463,8 @@ export const socialRoutes = new Hono<App>({ strict: false })
'/api/relationships/v2/acceptfriendrequest',
friendMutation(
'Accept a friend request',
'Turn a pending incoming request into a friendship. The requester is told with a ' +
'`FriendRequestAccepted` message (type 40) pushed as `MessageReceived`. Accepting ' +
'nothing pending is a no-op and notifies nobody.'
'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)
@@ -528,10 +473,6 @@ export const socialRoutes = new Hono<App>({ strict: false })
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)
// The requester is told, as a FriendRequestAccepted message from the accepter.
if (change.changed) {
await pushFriendMessage(c, id, target, MessageType.FriendRequestAccepted)
}
return c.json(change.self)
}
)
@@ -679,30 +620,10 @@ export const socialRoutes = new Hono<App>({ strict: false })
describeRoute({
tags: ['Social'],
summary: 'Direct messages',
description:
'The caller’s inbox — everything sent to them that they haven’t deleted, newest ' +
'first. The client reads this on login, which is what makes it the DURABLE half of ' +
'a message: the `MessageReceived` frame the hub pushes is best-effort (dropped ' +
'once sent rather than acked, bounded per player, and skipped whenever the hub ' +
'believes it delivered), so a message that only ever existed as a frame could be ' +
'lost with nothing left to show for it.\n\n' +
'Each entry is the same Message the frame carries, `Id` included and equal — one ' +
'message, delivered twice, which is how the client recognises the two as the ' +
'same.\n\n' +
'Auth-gated, unlike the empty list this used to answer: an inbox is the caller’s ' +
'own. Capped at the newest `MAX_NOTIFICATIONS_PER_PLAYER`, which is also what the store ' +
'keeps — the client shows an inbox, not an archive.',
security: AUTHED,
responses: {
200: json(MessageDto.array(), 'The caller’s messages, newest first'),
401: UNAUTHORIZED_RESPONSE,
},
description: 'There is no message store yet, so this is always an empty list.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getNotificationsForPlayer(c.env.DB, id))
}
(c) => c.json([])
)
// The inbox's delete button. A POST rather than a DELETE because the ids arrive as
// a JSON array body — the client batches a multi-select into one call, and its HTTP
@@ -713,43 +634,18 @@ export const socialRoutes = new Hono<App>({ strict: false })
tags: ['Social'],
summary: 'Delete messages',
description:
'Drops the given messages from the caller’s inbox — the ids being the `Id` on ' +
'each message `GET /api/messages/v2/get` served.\n\n' +
'Scoped to the CALLER’s inbox, not just to the ids: an id names a message ' +
'globally, so matching on id alone would let anyone holding one clear somebody ' +
'else’s inbox. An id that isn’t theirs, or is already gone, matches nothing — ' +
'not an error, since the client removes its rows locally and re-reads the list ' +
'either way.\n\n' +
'Auth-gated now that there is a store behind it. The ids arrive either as a bare ' +
'JSON array or under `MessageIds`; both are read. Answered 200 with an empty ' +
'body, which is what the client wants.',
security: AUTHED,
'Drops the given messages from the caller’s inbox. Nothing happens: there is no ' +
'message store behind `GET /api/messages/v2/get` (the `MessageReceived` ' +
'notification is the whole delivery — see `POST /api/messages/v2/send`), so ' +
'there are no ids to match and nothing to remove.\n\n' +
'Accepted unconditionally and answered 200 with an empty body, which is what the ' +
'client wants: it removes the rows locally and re-reads the empty list either ' +
'way. Not auth-gated, for the same reason `GET /api/messages/v2/get` isn’t — ' +
'the call reaches no state to protect.',
requestBody: jsonBody(DeleteMessagesRequest, 'The messages to delete'),
responses: {
200: { description: 'Accepted (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
responses: { 200: { description: 'Accepted (empty body)' } },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// The client batches a multi-select into one call. It posts a bare array; the
// documented `{ MessageIds }` object is accepted too rather than guessing which
// of the two a given build sends.
const body = await c.req.json<unknown>().catch(() => null)
const raw = Array.isArray(body)
? body
: Array.isArray((body as { MessageIds?: unknown } | null)?.MessageIds)
? (body as { MessageIds: unknown[] }).MessageIds
: []
const ids = raw
.map((v) => (typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : Number.NaN))
.filter((v) => Number.isInteger(v))
await deleteNotifications(c.env.DB, id, ids)
return c.body(null, 200)
}
(c) => c.body(null, 200)
)
// How many of the caller's friends are online — the friends panel's header count.
// Answered from the friend graph joined to live presence, so it agrees with the
-34
View File
@@ -60,40 +60,6 @@ export function containsSwears(value: string): boolean {
return value !== '' && filter.exists(value)
}
/**
* Where a space would be in a name if the charset allowed one: a lowercase-to-uppercase
* hop, the last capital of a run before a capitalised word, and either side of a run of
* digits. Applied in that order, so `ShitLord`, `XXFuckYou` and `Fucker123` each come
* apart at the seam a player wrote them with.
*/
const NAME_WORD_BOUNDARIES: Array<[RegExp, string]> = [
[/([a-z0-9])([A-Z])/g, '$1 $2'],
[/([A-Z]+)([A-Z][a-z])/g, '$1 $2'],
[/([A-Za-z])([0-9])/g, '$1 $2'],
]
/**
* Whether `value`, read as a NAME, contains a swear.
*
* A username or display name is letters and digits only (`nameRejection`), so it carries
* no spaces — and the filter matches whole words. Handing one to {@link containsSwears}
* as-is therefore only refuses a name that IS a swear and nothing else: `Fucker123` and
* `ShitLord` sail through. So the name is split at the boundaries a player types instead
* of a space, and the pieces are checked as words.
*
* That keeps the library's trade-off rather than reaching for substring matching, which
* is the tempting fix and the wrong one: `Scunthorpe`, `assassin`, `Classic`,
* `Cumberland` and `Shiitake` all contain a swear as a substring, and refusing someone's
* name without being able to say why is worse than missing `Bitchy`.
*/
export function nameContainsSwears(value: string): boolean {
const spaced = NAME_WORD_BOUNDARIES.reduce(
(text, [pattern, replacement]) => text.replace(pattern, replacement),
value
)
return containsSwears(spaced)
}
/** The mask `POST /api/sanitize/v1` uses when the request names no `ReplacementChar`. */
export const DEFAULT_REPLACEMENT_CHAR = '*'
+145 -4548
View File
@@ -1,6 +1,6 @@
import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, beforeEach, describe, expect, test } from 'vitest'
import { beforeAll, describe, expect, test } from 'vitest'
import {
addXp,
@@ -14,14 +14,11 @@ import {
LEVEL_REQUIRED_XP,
LEVEL_REWARDS,
MAX_LEVEL,
MessageType,
NOTIFICATION_SCHEMA_DDL,
OUTFIT_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL,
PRESENCE_TTL_SECONDS,
PROGRESSION_SCHEMA_DDL,
RELATIONSHIP_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
@@ -31,20 +28,7 @@ import {
import '../../api.app'
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
import { SCHEMA_DDL as MESSAGE_SCHEMA_DDL } from '../../../../chat/src/message-db'
import {
createThread,
postMessage,
SYSTEM_SENDER_ID,
THREAD_SCHEMA_DDL,
} from '../../../../chat/src/thread-db'
import { banEvasionMatch, resolveBan } from '../../bans-db'
import {
createCustomAvatarItem,
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
importCustomAvatarItem,
} from '../../custom-avatar-items-db'
import { customAvatarItemRowLiteral } from '../../custom-avatar-items-load'
import {
countGoing,
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
@@ -56,34 +40,17 @@ import {
banFromReport,
createReport,
getActiveBan,
getBansInForce,
getReportById,
getReportsAgainst,
getTopReported,
isPlayerBanned,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
searchReports,
} from '../../reports-db'
import {
CheerCategory,
DAILY_CHEER_CREDIT,
getCheerCredit,
getReputation,
SCHEMA_DDL as REPUTATION_SCHEMA_DDL,
spendCheerCredit,
} from '../../reputation-db'
import { charadesWordsFor } from '../../routes/gameplay'
import { SCHEMA_DDL as VOTES_SCHEMA_DDL } from '../../votes-db'
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
import type { SavedImage } from '@repo/domain'
import type { Env } from '../../context'
import type { EventTag, PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db'
import type {
InventionSaveResult,
InventionSaveV9Result,
SavedInvention,
} from '../../inventions-db'
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
@@ -110,21 +77,6 @@ const TEST_ROOMS = [
SubRooms: [{ SubRoomId: 3 }],
Roles: [{ AccountId: 42, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
},
{
// The instant kick's room. Owned by account 42 (the default test token); 43 holds
// Moderator (20) and 44 only Host (10) — the tier just below that gate. 45 is a
// CoOwner (30), for `roomModKick`, which is gated on owners rather than moderators.
RoomId: 4,
Name: 'KickRoom',
IsDorm: false,
CreatorAccountId: 42,
SubRooms: [{ SubRoomId: 4 }],
Roles: [
{ AccountId: 43, Role: 20, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 44, Role: 10, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 45, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 },
],
},
]
beforeAll(async () => {
@@ -163,10 +115,6 @@ beforeAll(async () => {
// Presence (owned by the rooms worker) — the online-friend count joins onto it.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Room instances (owned by the rooms worker) — the instant kick resolves the game
// session it is given to the room whose staff may kick from it.
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Outfit table (owned by the econ worker) — /outfits/me reads and writes slot 0.
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -179,8 +127,6 @@ beforeAll(async () => {
// 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()
// Vote-to-kick ballots (owned by the api worker) — one row per vote cast.
for (const stmt of VOTES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Platform identity links (owned by the auth worker) — the sharp arm of the
// ban-evasion resolution matches on them.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -190,21 +136,6 @@ beforeAll(async () => {
// 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()
// Reputation + cheer credit (owned by the api worker) — cheering writes both.
for (const stmt of REPUTATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Custom avatar items (owned by the api worker).
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// The message store (owned by the api worker) — the inbox reads it and every send
// writes to it.
for (const stmt of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Chat messages and thread membership (owned by the chat worker) — a chat report reads
// the reported player off the message, and gates on the reporter being in its thread.
for (const stmt of MESSAGE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of THREAD_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
@@ -259,12 +190,10 @@ describe('public endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
AmplitudeKey: '',
AmplitudeKey: 'a',
StatSigKey: 'a',
RudderStackKey: 'a',
UseRudderStack: false,
RudderStackKey: '',
UseStatSig: false,
StatSigKey: '',
StatSigEnvironment: 0,
})
})
@@ -390,360 +319,6 @@ describe('public endpoints', () => {
expect(reps.map((r) => r.AccountId)).toEqual([1, 2])
})
// Cheering: `POST /api/PlayerCheer/v1/create`. The giver comes from the token, so these
// use ids of their own (71xx) rather than the shared 42 — a spent credit is durable
// state, and the reputation reads above assert all-zero records.
const cheer = async (fields: Record<string, string>, sub = '7100') =>
exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/create`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields),
})
const reputationOf = async (id: number) =>
(await (await exports.default.fetch(`${ORIGIN}/api/playerReputation/v1/${id}`)).json()) as {
CheerCredit: number
CheerGeneral: number
CheerHelpful: number
}
test('a cheer counts on the target and spends the giver’s credit', async () => {
// The body the client posts, verbatim from the live request.
const res = await cheer({
PlayerIdTo: '7101',
CheerCategory: '0',
RoomId: '112',
Anonymous: 'False',
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Success: true, Message: null })
// The target's counter moved and nothing else did — in particular their OWN credit is
// untouched, since receiving a cheer doesn't pay for giving one.
expect(await reputationOf(7101)).toMatchObject({
CheerGeneral: 1,
CheerHelpful: 0,
CheerCredit: DAILY_CHEER_CREDIT,
})
// The giver paid, and has no counters of their own.
expect(await reputationOf(7100)).toMatchObject({
CheerGeneral: 0,
CheerCredit: DAILY_CHEER_CREDIT - 1,
})
})
test('each category counts into its own column', async () => {
for (const category of [
CheerCategory.General,
CheerCategory.Helpful,
CheerCategory.Sportmanship,
CheerCategory.GreatHost,
CheerCategory.Creative,
]) {
expect(
(await cheer({ PlayerIdTo: '7102', CheerCategory: String(category) }, '7103')).status
).toBe(200)
}
expect(await getReputation(env.DB, 7102)).toEqual({
AccountId: 7102,
IsCheerful: true,
Noteriety: 0,
SelectedCheer: 0,
CheerCredit: DAILY_CHEER_CREDIT,
CheerGeneral: 1,
CheerHelpful: 1,
CheerCreative: 1,
CheerGreatHost: 1,
CheerSportsman: 1,
SubscriberCount: 0,
SubscribedCount: 0,
})
})
test('a cheer the server can’t count is refused before it costs anything', async () => {
// Each refusal answers 200 with the reason — the client shows `Message` — and none of
// them may take a credit off the caller, which is what the closing assertion checks.
for (const [fields, Message] of [
[{ PlayerIdTo: '7105' }, 'CheerCategory is not a cheer category'],
// -1 is the enum's `None`: a real member, but not a counter.
[{ PlayerIdTo: '7105', CheerCategory: '-1' }, 'CheerCategory is not a cheer category'],
[{ PlayerIdTo: '7105', CheerCategory: '5' }, 'CheerCategory is not a cheer category'],
[{ CheerCategory: '0' }, 'PlayerIdTo is required'],
[{ PlayerIdTo: '7104', CheerCategory: '0' }, 'You cannot cheer yourself'],
] as Array<[Record<string, string>, string]>) {
const res = await cheer(fields, '7104')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Success: false, Message })
}
expect(await getCheerCredit(env.DB, 7104)).toBe(DAILY_CHEER_CREDIT)
expect(await reputationOf(7105)).toMatchObject({ CheerGeneral: 0 })
})
test('the cheer frame plays in front of the whole room instance', async () => {
// Presence is written by the `match` worker; seeded straight into the table here.
// 7108 (the giver), 7109 (the target) and 7120 (a bystander) share instance 8800;
// 7121 stands in a different instance and must hear nothing.
const standIn = async (accountId: number, roomInstanceId: number | null) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: roomInstanceId === null ? null : { roomInstanceId, roomId: 112 },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 0,
platform: 0,
appVersion: GAME_VERSION,
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
})
)
.run()
// The notify DO is stubbed to record every notifyPlayer / notifyPlayersEphemeral call
// (see vitest.config).
const cheerHub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const framesFor = async (anonymous: string) => {
await cheerHub().fetch('http://do/all', { method: 'DELETE' })
expect(
(
await cheer(
{ PlayerIdTo: '7109', CheerCategory: '10', RoomId: '112', Anonymous: anonymous },
'7108'
)
).status
).toBe(200)
return (await (await cheerHub().fetch('http://do/all')).json()) as Array<{
playerId?: number
playerIds?: number[]
ephemeral?: boolean
notificationType: string
data: Record<string, unknown>
}>
}
for (const id of [7108, 7109, 7120]) await standIn(id, 8800)
await standIn(7121, 8801)
// A signed cheer. Four sends: the PlayerCheer message that plays the cheer on the
// target's client, then the ReputationUpdate for the target durably, the rest of their
// instance ephemerally, and the giver's own credit refresh.
const all = await framesFor('False')
expect(all).toHaveLength(4)
expect(all[0]).toMatchObject({
playerId: 7109,
notificationType: 2, // NotificationType.MessageReceived
data: { FromPlayerId: 7108, ToPlayerId: 7109, Type: MessageType.PlayerCheer, Data: '10' },
})
expect(all[0]!.ephemeral).toBeFalsy()
const signed = all.slice(1)
expect(signed.every((f) => f.notificationType === 'ReputationUpdate')).toBe(true)
// `AccountId` is who the frame is ABOUT, not who it goes to — the room hears about
// 7109. The frame is 7109's RECORD: `SelectedCheer` is their pinned cheer (none), not
// the category just given, and `IsCheerful` is the profile flag — the message above
// is what plays the cheer.
const played = {
AccountId: 7109,
IsCheerful: true,
SelectedCheer: 0,
CheerHelpful: 1,
}
expect(signed[0]).toMatchObject({ playerId: 7109, data: played })
// The bystander and the giver see it; the target is not in the room list (they got
// the durable copy), and 7121 is in another instance entirely.
expect(signed[1]).toMatchObject({ playerIds: [7108, 7120], ephemeral: true, data: played })
// The giver's second frame is about THEM: their record with the spent credit.
expect(signed[2]).toMatchObject({
playerId: 7108,
data: {
AccountId: 7108,
IsCheerful: true,
SelectedCheer: 0,
CheerCredit: DAILY_CHEER_CREDIT - 1,
},
})
// An anonymous cheer reaches exactly the same people and moves the same counter —
// it just doesn't announce who gave it: the message is the anonymous type from
// sender 0. The reputation frames are the same records as before.
const allAnonymous = await framesFor('True')
expect(allAnonymous).toHaveLength(4)
expect(allAnonymous[0]).toMatchObject({
playerId: 7109,
notificationType: 2, // NotificationType.MessageReceived
data: {
FromPlayerId: 0,
ToPlayerId: 7109,
Type: MessageType.PlayerCheerAnonymous,
Data: '10',
},
})
const anonymous = allAnonymous.slice(1)
expect(anonymous[0]).toMatchObject({
playerId: 7109,
data: { AccountId: 7109, IsCheerful: true, SelectedCheer: 0, CheerHelpful: 2 },
})
expect(anonymous[1]).toMatchObject({ playerIds: [7108, 7120], data: { IsCheerful: true } })
// The frame carries only the fields the client's decoder has — no Noteriety or
// subscriber counts, which live on the profile DTO alone.
expect(Object.keys(anonymous[0]!.data).sort()).toEqual([
'AccountId',
'CheerCreative',
'CheerCredit',
'CheerGeneral',
'CheerGreatHost',
'CheerHelpful',
'CheerSportsman',
'IsCheerful',
'SelectedCheer',
])
})
test('a cheer with no room instance still reaches the player cheered', async () => {
// Cheering from a profile screen: the giver has lobby presence (roomInstance null),
// so there is no audience — but the target's own frame is not the room's to lose.
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 7130,
roomInstance: null,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 0,
platform: 0,
appVersion: GAME_VERSION,
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
})
)
.run()
const cheerHub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await cheerHub().fetch('http://do/all', { method: 'DELETE' })
expect((await cheer({ PlayerIdTo: '7131', CheerCategory: '40' }, '7130')).status).toBe(200)
const frames = (await (await cheerHub().fetch('http://do/all')).json()) as Array<{
playerId?: number
ephemeral?: boolean
data: Record<string, unknown>
}>
// Three sends — the target's cheer message and reputation, then the giver's
// reputation — all durable and all addressed: nothing was broadcast.
expect(frames.map((f) => f.playerId)).toEqual([7131, 7131, 7130])
expect(frames.some((f) => f.ephemeral)).toBe(false)
expect(frames[0]!.data).toMatchObject({
ToPlayerId: 7131,
Type: MessageType.PlayerCheer,
Data: '40',
})
expect(frames[1]!.data).toMatchObject({ AccountId: 7131, CheerCreative: 1 })
})
test('SetSelectedCheer pins a cheer to the profile and pushes the record', async () => {
const pin = async (CheerCategory: string, sub: string) =>
exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/SetSelectedCheer`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ CheerCategory }),
})
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
// 7140 has never been cheered — pinning still works, creating their row.
const res = await pin(String(CheerCategory.GreatHost), '7140')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Success: true, Message: null })
expect(await getReputation(env.DB, 7140)).toMatchObject({
SelectedCheer: CheerCategory.GreatHost,
IsCheerful: true,
CheerGreatHost: 0,
})
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
playerId?: number
data: Record<string, unknown>
}>
expect(frames).toHaveLength(1)
expect(frames[0]).toMatchObject({
playerId: 7140,
data: { AccountId: 7140, SelectedCheer: CheerCategory.GreatHost },
})
// The pin survives a cheer landing on the row, and a cheer's frame carries it.
expect((await cheer({ PlayerIdTo: '7140', CheerCategory: '0' }, '7141')).status).toBe(200)
expect(await reputationOf(7140)).toMatchObject({ CheerGeneral: 1 })
expect(await getReputation(env.DB, 7140)).toMatchObject({
SelectedCheer: CheerCategory.GreatHost,
})
// -1 (`None`) unpins, read back as 0; a made-up category is refused.
expect(await (await pin('-1', '7140')).json()).toEqual({ Success: true, Message: null })
expect(await getReputation(env.DB, 7140)).toMatchObject({ SelectedCheer: 0 })
expect(await (await pin('7', '7140')).json()).toEqual({
Success: false,
Message: 'CheerCategory is not a cheer category',
})
expect(await pin('0', '7140').then((r) => r.status)).toBe(200)
expect(
(
await exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/SetSelectedCheer`, {
method: 'POST',
body: new URLSearchParams({ CheerCategory: '0' }),
})
).status
).toBe(401)
})
test('cheering needs a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ PlayerIdTo: '7101', CheerCategory: '0' }),
})
expect(res.status).toBe(401)
})
test('the daily credit runs out and refills a day after the FIRST cheer', async () => {
// Driven through spendCheerCredit with an injected clock: burning 20 cheers over HTTP
// says nothing more than this does, and the rollover can't be tested any other way.
const start = new Date('2026-08-25T09:00:00.000Z')
const at = (hours: number) => new Date(start.getTime() + hours * 60 * 60 * 1000)
// The first spend opens the window; the credit counts down to nothing.
for (let spent = 1; spent <= DAILY_CHEER_CREDIT; spent++) {
// Spread across the window — spending inside it must not slide the deadline.
expect(await spendCheerCredit(env.DB, 7110, at(spent === 1 ? 0 : 12))).toBe(
DAILY_CHEER_CREDIT - spent
)
}
expect(await spendCheerCredit(env.DB, 7110, at(12))).toBeNull()
expect(await getCheerCredit(env.DB, 7110, at(12))).toBe(0)
// 23 hours in, still empty: the window is measured from the first cheer, not the last.
expect(await spendCheerCredit(env.DB, 7110, at(23))).toBeNull()
// A day after that first cheer it refills — lazily, on the spend itself, so nothing
// has to run on a schedule.
expect(await getCheerCredit(env.DB, 7110, at(24.5))).toBe(DAILY_CHEER_CREDIT)
expect(await spendCheerCredit(env.DB, 7110, at(24.5))).toBe(DAILY_CHEER_CREDIT - 1)
expect(await getCheerCredit(env.DB, 7110, at(25))).toBe(DAILY_CHEER_CREDIT - 1)
})
test('a player out of credit is refused, and the target keeps their counters', async () => {
// Empty 7106's credit directly, then try to cheer over HTTP.
for (let i = 0; i < DAILY_CHEER_CREDIT; i++) await spendCheerCredit(env.DB, 7106)
const res = await cheer({ PlayerIdTo: '7107', CheerCategory: '10' }, '7106')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Success: false,
Message: 'You are out of cheers for today',
})
expect(await reputationOf(7107)).toMatchObject({ CheerHelpful: 0 })
})
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)
@@ -778,6 +353,32 @@ describe('public endpoints', () => {
expect(charadesWordsFor(new Date('2026-04-01T23:59:59Z'))).toBe(april)
})
// The client POSTs this with no body, despite it being a pure read; the route answers
// GET as well, and both methods serve the same body.
test.each(['GET', 'POST'])(
'%s /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"',
async (method) => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
{ method }
)
expect(res.status).toBe(200)
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
// reference stub's empty string — the client tells "no message" from a blank one.
expect(await res.json()).toEqual({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
}
)
// A fixed list, in render order — the client shows the buttons in the order they
// arrive, so the order is part of the contract, not just the contents.
test('GET /api/PlayerReporting/v1/voteToKickReasons serves the reasons in order', async () => {
@@ -867,28 +468,6 @@ describe('public endpoints', () => {
expect(body[0]).toMatchObject({ Level: 1, XP: 0 })
})
test('the bulk profile reads answer more ids than D1 will bind at once', async () => {
// Both of these are asked about a whole friends list at once, which runs past D1's
// 100-bound-parameter cap; unchunked, the statement fails and the endpoint 500s
// rather than returning a short list.
const ids = Array.from({ length: 250 }, (_, i) => 6000 + i)
const query = ids.map((id) => `id=${id}`).join('&')
const progression = await exports.default.fetch(
`${ORIGIN}/api/players/v2/progression/bulk?${query}`
)
expect(progression.status).toBe(200)
const levels = (await progression.json()) as Array<{ PlayerId: number }>
expect(levels.map((p) => p.PlayerId)).toEqual(ids)
const reputation = await exports.default.fetch(
`${ORIGIN}/api/playerReputation/v2/bulk?${query}`
)
expect(reputation.status).toBe(200)
const reps = (await reputation.json()) as Array<{ AccountId: number }>
expect(reps.map((r) => r.AccountId)).toEqual(ids)
})
test('progression reads back the XP game rewards banked, levelled up', async () => {
// The two workers share this table; `econ` writes it when a game reward is claimed (5 XP
// at a time). Granted in one lump here to exercise a multi-level climb: 25 XP from level
@@ -935,13 +514,6 @@ describe('public endpoints', () => {
)
})
test('the config carries the room key limit', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v2`)
expect(res.status).toBe(200)
const config = (await res.json()) as { RoomKeyConfig: { MaxKeysPerRoom: number } }
expect(config.RoomKeyConfig).toEqual({ MaxKeysPerRoom: 10 })
})
test('the level rewards match the published reward table', async () => {
// Rec Room's published level-reward table, spot-checked at the points where it turns:
// consumables early, then clothing at a rising star rating (2★ = 10, 3★ = 20, 4★ = 30,
@@ -1009,683 +581,22 @@ describe('public endpoints', () => {
expect(await res.json()).toBe(true)
})
test('GET /api/customAvatarItems/v1/featured lists flagged, published items, newest first', async () => {
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
const older = await createCustomAvatarItem(
env.DB,
item('Older', 1),
new Date('2026-08-01T00:00:00Z')
)
const newer = await createCustomAvatarItem(
env.DB,
item('Newer', 1),
new Date('2026-08-02T00:00:00Z')
)
const unpublished = await createCustomAvatarItem(env.DB, item('Unpublished', 0))
const unflagged = await createCustomAvatarItem(env.DB, item('Unflagged', 1))
// Nothing flags items yet, so flag straight in the table — the unflagged one stays.
await env.DB.prepare(
`UPDATE custom_avatar_item SET data = json_set(data, '$.IsFeatured', json('true'))
WHERE custom_avatar_item_id != ?1`
)
.bind(unflagged.CustomAvatarItemId)
.run()
test('GET /api/customAvatarItems/v1/featured returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/featured`)
expect(res.status).toBe(200)
const got = (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }>
expect(got.map((i) => i.CustomAvatarItemId)).toEqual([
newer.CustomAvatarItemId,
older.CustomAvatarItemId,
])
expect(got.map((i) => i.CustomAvatarItemId)).not.toContain(unpublished.CustomAvatarItemId)
expect(got[0]).toMatchObject({ Name: 'Newer', IsFeatured: true, CurrentSaves: [] })
function item(name: string, accessibility: number) {
return {
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name,
description: '',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
}
}
})
test('GET /api/CircuitChipLists/:list is empty for any name', async () => {
// A palette on the Maker Pen's circuit board, named by the path. Nothing records which
// chips a player has used or favourited, so every one of them is empty — including names
// this server has never heard of, which the client will ask for as its build changes.
// An unknown name being a 404 would render as a palette that FAILED to load rather than
// one with nothing in it.
for (const list of [
'Favorites',
'Recent',
'All',
'SomePaletteThisServerHasNeverHeardOf',
// Path-segment oddities: a name that needs escaping, and a numeric one.
encodeURIComponent('Weird Name/With Slash'),
'42',
]) {
const res = await exports.default.fetch(`${ORIGIN}/api/CircuitChipLists/${list}`)
expect(res.status, list).toBe(200)
expect(await res.json(), list).toEqual([])
}
})
test('GET /api/inventions/v1/featureddormskins returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featureddormskins`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/customAvatarItems/v1/hot lists the published items, newest first', async () => {
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
const empty = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/hot`)
expect(empty.status).toBe(200)
expect(await empty.json()).toEqual([])
const older = await createCustomAvatarItem(
env.DB,
item('Older', 1),
new Date('2026-08-01T00:00:00Z')
)
const newer = await createCustomAvatarItem(
env.DB,
item('Newer', 1),
new Date('2026-08-02T00:00:00Z')
)
const unpublished = await createCustomAvatarItem(env.DB, item('Unpublished', 0))
test('GET /api/customAvatarItems/v1/hot returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/hot`)
expect(res.status).toBe(200)
const got = (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }>
// Unfeatured but published: the hot feed does not care about the featured flag.
expect(got.map((i) => i.CustomAvatarItemId)).toEqual([
newer.CustomAvatarItemId,
older.CustomAvatarItemId,
])
expect(got.map((i) => i.CustomAvatarItemId)).not.toContain(unpublished.CustomAvatarItemId)
expect(got[0]).toMatchObject({ Name: 'Newer', IsFeatured: false, CurrentSaves: [] })
function item(name: string, accessibility: number) {
return {
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name,
description: '',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
}
}
expect(await res.json()).toEqual([])
})
test('GET /api/customAvatarItems/v1/search filters, pages and excludes unpublished', async () => {
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
const search = async (query: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`)
expect(res.status, query).toBe(200)
return (await res.json()) as Array<{
CustomAvatarItemId: string
Name: string
OutfitType: number
PurchaseInfo: null
}>
}
// A BARE ARRAY, not the `{ Results, TotalResults }` envelope `fromCreator` uses.
expect(await search('')).toEqual([])
const made: Record<string, string> = {}
// Six published items across three outfit types, plus one unpublished and one Coach's.
// Creation times ascend with the index so "newest first" is unambiguous.
const spec: Array<[name: string, outfitType: number, accessibility: number, creator: number]> =
[
['Hat A', 0, 1, 205],
['Shirt A', 2, 1, 205],
['Shirt B', 2, 1, 205],
['Trousers A', 3, 1, 205],
['Coach Hat', 0, 1, 1],
['Hidden', 0, 0, 205],
]
for (const [i, [name, outfitType, accessibility, creator]] of spec.entries()) {
const created = await createCustomAvatarItem(
env.DB,
{
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: creator,
name,
description: '',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
},
new Date(Date.UTC(2026, 7, 1 + i))
)
made[name] = created.CustomAvatarItemId
// Every created item is a custom shirt (OutfitType 105); this test wants a spread of
// slots to filter across, so set them straight in the table.
expect(created.OutfitType).toBe(105)
await env.DB.prepare(
`UPDATE custom_avatar_item SET data = json_set(data, '$.OutfitType', ?2)
WHERE custom_avatar_item_id = ?1`
)
.bind(created.CustomAvatarItemId, outfitType)
.run()
}
// Newest first, and `Hidden` never appears: Accessibility 0 is unpublished, and this is
// the shared browse surface — its creator sees it through `fromCreator`, not here.
const all = await search('')
expect(all.map((i) => i.Name)).toEqual([
'Coach Hat',
'Trousers A',
'Shirt B',
'Shirt A',
'Hat A',
])
// `outfitTypes` repeats and acts as a whitelist — the real client sends a dozen of them.
expect((await search('?outfitTypes=2')).map((i) => i.Name)).toEqual(['Shirt B', 'Shirt A'])
expect((await search('?outfitTypes=0&outfitTypes=3')).map((i) => i.Name)).toEqual([
'Coach Hat',
'Trousers A',
'Hat A',
])
// Sending NONE means no filter, not no results: the client sends every type it can render,
// so reading an absent parameter as an empty `IN ()` would empty the store.
expect((await search('?skip=0&take=100')).map((i) => i.Name)).toEqual(all.map((i) => i.Name))
// A non-numeric value is dropped rather than becoming NaN, which would match nothing and
// quietly empty a filter the caller believes they set.
expect((await search('?outfitTypes=2&outfitTypes=nonsense')).map((i) => i.Name)).toEqual([
'Shirt B',
'Shirt A',
])
// Paging, and it is STABLE: consecutive pages must not repeat or skip a row, which the
// id tiebreak in the ordering is what guarantees when timestamps collide.
const page1 = await search('?skip=0&take=2')
const page2 = await search('?skip=2&take=2')
expect(page1.map((i) => i.Name)).toEqual(['Coach Hat', 'Trousers A'])
expect(page2.map((i) => i.Name)).toEqual(['Shirt B', 'Shirt A'])
expect(
page1.some((i) => page2.some((j) => j.CustomAvatarItemId === i.CustomAvatarItemId))
).toBe(false)
expect(await search('?skip=99&take=10')).toEqual([])
expect(await search('?take=0')).toEqual([])
// `includeCoachItems` picks a SIDE of the catalog rather than widening it: `True` is the
// Coach's stock content alone (the storefront tab), `False` the players' alone (the
// user-generated-content tab), and the two never overlap. Absent is both. The client
// capitalises its booleans, so the comparison folds case.
expect((await search('?includeCoachItems=True')).map((i) => i.Name)).toEqual(['Coach Hat'])
expect((await search('?includeCoachItems=false')).map((i) => i.Name)).toEqual([
'Trousers A',
'Shirt B',
'Shirt A',
'Hat A',
])
expect((await search('?includeCoachItems=False')).map((i) => i.Name)).not.toContain('Coach Hat')
expect((await search('?includeCoachItems=maybe')).map((i) => i.Name)).toEqual(
all.map((i) => i.Name)
)
// `creatorAccountId` is one author's published items — a player's creations list. It
// was once ignored, and every player's shelf showed the whole player-made catalog.
const spec2: Array<[name: string, creator: number]> = [['Hat C', 206]]
for (const [name, creator] of spec2) {
await createCustomAvatarItem(
env.DB,
{
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: creator,
name,
description: '',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility: 1,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
},
new Date(Date.UTC(2026, 7, 20))
)
}
expect((await search('?creatorAccountId=206')).map((i) => i.Name)).toEqual(['Hat C'])
// The query the client sends for a player's creations: theirs alone, `Hidden` still out.
expect(
(
await search(
'?creatorAccountId=205&includePurchaseInfos=True&includeCoachItems=False&skip=0' +
'&take=1000&unityAssetTarget=0&unityAssetVersion=3'
)
).map((i) => i.Name)
).toEqual(['Trousers A', 'Shirt B', 'Shirt A', 'Hat A'])
expect(await search('?creatorAccountId=1&includeCoachItems=False')).toEqual([])
expect(await search('?creatorAccountId=424242')).toEqual([])
await env.DB.prepare('DELETE FROM custom_avatar_item WHERE creator_account_id = 206').run()
// The whole storefront query the client actually sends, unchanged — the parameters that
// aren't acted on yet must be accepted rather than 400 or throw, and `includeCoachItems=True`
// narrows it to the stock content.
const real = await search(
'?outfitTypes=0&outfitTypes=2&outfitTypes=3&outfitTypes=10&outfitTypes=20&outfitTypes=100' +
'&outfitTypes=101&outfitTypes=102&outfitTypes=103&outfitTypes=200&outfitTypes=300' +
'&outfitTypes=301&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
'&take=100&unityAssetTarget=0&unityAssetVersion=3'
)
expect(real.map((i) => i.Name)).toEqual(['Coach Hat'])
// `includePurchaseInfos=True` notwithstanding: nothing prices a custom item here yet, so
// the field is null on every item and the parameter changes nothing.
expect(real.every((i) => i.PurchaseInfo === null)).toBe(true)
})
test('GET /api/customAvatarItems/v1/search matches name or description, and bounds price', async () => {
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
const search = async (query: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`)
expect(res.status, query).toBe(200)
return ((await res.json()) as Array<{ Name: string }>).map((i) => i.Name)
}
const make = async (name: string, description: string, price: number, i: number) =>
createCustomAvatarItem(
env.DB,
{
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name,
description,
price,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility: 1,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
},
new Date(Date.UTC(2026, 7, 1 + i))
)
await make('Room Hat', '', 100, 0)
// Matched on DESCRIPTION, not name — the two are searched together.
await make('Cosy Beanie', 'Warm in any ROOM', 500, 1)
// Matched case-insensitively and as a SUBSTRING, mid-word.
await make('Ballroom Shoes', '', 9000, 2)
await make('Unrelated Cap', 'nothing to do with it', 250, 3)
// A name holding LIKE metacharacters, for the escaping below.
await make('100% Wool', 'a_b', 50, 4)
// Newest first throughout, so the order also proves the query didn't disturb the ordering.
expect(await search('?searchQuery=room')).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
// Case folds both ways: SQLite's own LIKE only folds ASCII, so both sides are lowered.
expect(await search('?searchQuery=ROOM')).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
expect(await search('?searchQuery=beanie')).toEqual(['Cosy Beanie'])
expect(await search('?searchQuery=nothing%20to%20do')).toEqual(['Unrelated Cap'])
expect(await search('?searchQuery=zzzz')).toEqual([])
// Blank or absent is NO filter, not an empty result — a cleared search box must show the
// store rather than nothing.
expect(await search('?searchQuery=')).toHaveLength(5)
expect(await search('?searchQuery=%20%20')).toHaveLength(5)
// A needle of LIKE metacharacters matches them LITERALLY. Unescaped, `%` would match every
// item and `_` any single character, so a player searching for "100%" would get the lot.
expect(await search('?searchQuery=%25')).toEqual(['100% Wool'])
expect(await search('?searchQuery=a_b')).toEqual(['100% Wool'])
// Price bounds, inclusive at both ends.
expect(await search('?minPrice=250&maxPrice=9000')).toEqual([
'Unrelated Cap',
'Ballroom Shoes',
'Cosy Beanie',
])
expect(await search('?maxPrice=100')).toEqual(['100% Wool', 'Room Hat'])
expect(await search('?minPrice=9000')).toEqual(['Ballroom Shoes'])
expect(await search('?minPrice=100000')).toEqual([])
// Combined with the text search, since the client sends both together.
expect(await search('?searchQuery=room&maxPrice=500')).toEqual(['Cosy Beanie', 'Room Hat'])
// The whole query the client's user-generated-content tab actually sends. `itemTypes=-1`
// ("every item type") and the unity asset parameters are accepted and not acted on;
// `outfitTypes=105` is the custom shirt, which is what every created item is filed under,
// so the tab finds them. (It once found nothing: creation left `outfit_type` at 0.)
expect(
await search(
'?searchQuery=room&itemTypes=-1&outfitTypes=105&minPrice=0&maxPrice=10000' +
'&includePurchaseInfos=True&includeCoachItems=False&ordering=0&skip=0&take=1000' +
'&unityAssetTarget=0&unityAssetVersion=3'
)
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
// Same query with the outfit-type filter dropped matches the same, as it must.
expect(
await search(
'?searchQuery=room&itemTypes=-1&minPrice=0&maxPrice=10000&includePurchaseInfos=True' +
'&includeCoachItems=False&ordering=0&skip=0&take=1000&unityAssetTarget=0' +
'&unityAssetVersion=3'
)
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
})
test('the first-party load blanks a save’s assetbundle hashes and leaves the names alone', () => {
// The export's hashes are the PC builds'. The same stored save is served to a Quest
// pointed at the `quest/` build, which fails the client's check against a PC hash — so
// the load stores them blank. A null hash (no second bundle) stays null.
const literal = customAvatarItemRowLiteral({
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
CreatorAccountId: 9001,
CurrentSaves: [
{
BodyType: 1,
ThumbnailFileName: 'f2y1ndzuvm5ke2hjmn4cwfwfl.png',
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
UnityAssetHash: 'b1946ac92492d2347c6235b4d2611184',
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
UnityAsset2Hash: '591785b794601e212b260e25925636fd',
},
{
BodyType: 2,
ThumbnailFileName: 'div4cdxc5b7ameui0d5h8ickl.png',
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
UnityAssetHash: '',
UnityAsset2: null,
UnityAsset2Hash: null,
},
],
})
expect(JSON.parse(literal.slice(1, -1))).toEqual({
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
CreatorAccountId: 1,
CurrentSaves: [
{
BodyType: 1,
ThumbnailFileName: 'avatar/f2y1ndzuvm5ke2hjmn4cwfwfl.png',
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
UnityAssetHash: '',
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
UnityAsset2Hash: '',
},
{
BodyType: 2,
ThumbnailFileName: 'avatar/div4cdxc5b7ameui0d5h8ickl.png',
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
UnityAssetHash: '',
UnityAsset2: null,
UnityAsset2Hash: null,
},
],
})
})
test('GET /api/customAvatarItems/v1/search serves an imported first-party item as exported', async () => {
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
// A first-party item, as the official service exports it: the same record as a player's
// shirt with the base-item and filename fields NULL and a built Unity assetbundle per
// body type in `CurrentSaves`. The flat table could not hold this at all; the JSON row
// stores it verbatim and the search serves it back as-is, saves included.
const wings = {
Accessibility: 1,
BaseAvatarItemColor: null,
BaseAvatarItemId: null,
CreatedAt: '2024-09-26T21:32:48.523Z',
CreatorAccountId: 1,
CurrentSaves: [
{
AdditionalConfiguration:
'{"v":1,"d":{"n":"(BB_SkellyWings_Shoulder)","o":100,"b":0,"ir":[6,184,0],"p":"3a74ca85-fa71-44c7-9c28-aabd605908db","a":"09999705-15d4-4a84-9c0a-65cfe5bde8d7"},"ls":1}',
BodyType: 0,
CreatedAt: '2024-10-02T22:50:38.809Z',
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
CustomAvatarItemSaveId: 1083,
Description: null,
ModifiedAt: '2024-11-27T02:34:07.523Z',
OutfitType: 100,
QAState: 2,
ThumbnailFileName: 'f2y1ndzuvm5ke2hjmn4cwfwfl.png',
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
UnityAsset2Hash: '/QYNs1vk1E+GF6qhVf3eR4YUCb0z4vFVkzPIA6mqjqY=',
UnityAssetHash: 'hQjd30phdbC0U7lBMOX/YtpwxEjPwVvVJaQ/4UzKA0Y=',
UnityAssetId: '7ba365a7-cf85-4029-80a3-7d0a08ab6ce6',
},
{
AdditionalConfiguration:
'{"v":1,"d":{"n":"(FB_SkellyWings_Shoulder)","o":100,"b":1,"ir":[6,186,0],"p":"09999705-15d4-4a84-9c0a-65cfe5bde8d7","a":"3a74ca85-fa71-44c7-9c28-aabd605908db"},"ls":1}',
BodyType: 1,
CreatedAt: '2024-10-02T22:52:04.891Z',
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
CustomAvatarItemSaveId: 1085,
Description: null,
ModifiedAt: '2024-11-27T02:34:07.523Z',
OutfitType: 100,
QAState: 2,
ThumbnailFileName: 'div4cdxc5b7ameui0d5h8ickl.png',
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
UnityAsset2: '5ppm948m3znl2chvbvy88j092.assetbundle',
UnityAsset2Hash: 'ZtPbhPSKXkBD9bsmajTmO7A99Ew8tCQBYjE1THE+VUk=',
UnityAssetHash: 'TCPaQ1+T74pPBWiVChCYDyC/MNHjHbWEYk7leIqVTOU=',
UnityAssetId: '03413da3-e63c-4971-8ca9-c726bc90c0e2',
},
],
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
CustomBadgeMetadata: null,
Description:
'This item will have a fix on its connection point to the back on some body types in a future patch.',
DesignFilename: null,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: true,
ModifiedAt: '2025-02-19T05:19:28.328Z',
Name: 'Skeletal Wings',
OutfitType: 100,
PreviewOrientation: 0,
Price: 6000,
RankedEntityId: '83fe651f-15b3-46a7-8afc-adec32c35568',
RankingContext: null,
Tags: [{ TagType: 0, Value: 'export' }],
ThumbnailImageFilename: null,
}
await importCustomAvatarItem(env.DB, wings)
// A player-made shirt beside it, to prove the two kinds share one table and one shape.
const shirt = await createCustomAvatarItem(env.DB, {
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name: 'Skeleton Tee',
description: '',
price: 0,
baseAvatarItemId: 2184,
baseAvatarItemColor: '#F55C1A',
accessibility: 1,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
})
const search = async (query: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`)
expect(res.status, query).toBe(200)
return (await res.json()) as Array<Record<string, unknown>>
}
// The storefront tab's real query finds it (Coach-authored, OutfitType 100 among the
// dozen slots asked for) and serves the record exactly as exported, plus the store-side
// `PurchaseInfo` the client asked for with `includePurchaseInfos` (null until priced).
const store = await search(
'?outfitTypes=0&outfitTypes=2&outfitTypes=3&outfitTypes=10&outfitTypes=20&outfitTypes=100' +
'&outfitTypes=101&outfitTypes=102&outfitTypes=103&outfitTypes=200&outfitTypes=300' +
'&outfitTypes=301&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
'&take=100&unityAssetTarget=0&unityAssetVersion=3'
)
expect(store).toEqual([{ ...wings, PurchaseInfo: null }])
// The same query from a Quest (`unityAssetTarget=2`, Android/Oculus) serves the same
// record with its assetbundles pointed at the `quest/` builds, and nothing else moved.
const questStore = await search(
'?outfitTypes=100&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
'&take=100&unityAssetTarget=2&unityAssetVersion=3'
)
expect(questStore).toEqual([
{
...wings,
PurchaseInfo: null,
CurrentSaves: wings.CurrentSaves.map((save) => ({
...save,
UnityAsset: `quest/${save.UnityAsset}`,
UnityAsset2: `quest/${save.UnityAsset2}`,
})),
},
])
// Text search matches it by name and by description, and by its id typed whole.
expect((await search('?searchQuery=skeletal')).map((i) => i.Name)).toEqual(['Skeletal Wings'])
expect((await search('?searchQuery=connection%20point')).map((i) => i.Name)).toEqual([
'Skeletal Wings',
])
expect(
(await search('?searchQuery=83FE651F-15B3-46A7-8AFC-ADEC32C35568')).map((i) => i.Name)
).toEqual(['Skeletal Wings'])
// "skeleton" is not a substring of "skeletal": only the shirt.
expect((await search('?searchQuery=skeleton')).map((i) => i.Name)).toEqual(['Skeleton Tee'])
// The user-generated-content tab's query does not see it: wrong side of the catalog and
// wrong slot both.
expect(
(await search('?outfitTypes=105&includeCoachItems=False')).map((i) => i.CustomAvatarItemId)
).toEqual([shirt.CustomAvatarItemId])
// A player-made shirt still carries the full shape — empty saves, empty tags — so the
// client decodes both kinds through one parser.
const [tee] = await search('?searchQuery=skeleton')
expect(tee).toMatchObject({
BaseAvatarItemId: 2184,
CurrentSaves: [],
Tags: [],
CustomBadgeMetadata: null,
RankedEntityId: shirt.CustomAvatarItemId,
})
// Re-importing the same id REPLACES the record (a corrected export lands) rather than
// duplicating or being ignored.
await importCustomAvatarItem(env.DB, { ...wings, Price: 5000 })
expect((await search('?searchQuery=skeletal')).map((i) => i.Price)).toEqual([5000])
// The bulk lookup resolves it too, saves and all.
const bulk = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(await bearer('42')) },
body: new URLSearchParams([['customAvatarItemIds', wings.CustomAvatarItemId]]),
})
expect(bulk.status).toBe(200)
const [got] = (await bulk.json()) as Array<{ CurrentSaves: unknown[]; Price: number }>
expect(got.CurrentSaves).toHaveLength(2)
expect(got.Price).toBe(5000)
// A caller asking for the Quest build (`unityAssetTarget` 2, Android/Oculus) is pointed at
// the Quest builds of the same assetbundles, under `quest/` — whether the target rides on
// the query string or in the form. Target 0 (PC) and no target get the names as stored.
const bulkAssets = async (query: string, form: string[][] = []) => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk${query}`, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
...(await bearer('42')),
},
body: new URLSearchParams([['customAvatarItemIds', wings.CustomAvatarItemId], ...form]),
})
expect(res.status).toBe(200)
const [item] = (await res.json()) as Array<{
CurrentSaves: Array<{ UnityAsset: string; UnityAsset2: string | null }>
}>
return item.CurrentSaves.map((save) => [save.UnityAsset, save.UnityAsset2])
}
const stored = wings.CurrentSaves.map((save) => [save.UnityAsset, save.UnityAsset2])
const quest = stored.map((names) => names.map((name) => `quest/${name}`))
expect(await bulkAssets('')).toEqual(stored)
expect(await bulkAssets('?unityAssetTarget=0')).toEqual(stored)
expect(await bulkAssets('?unityAssetTarget=2')).toEqual(quest)
expect(await bulkAssets('', [['unityAssetTarget', '2']])).toEqual(quest)
})
test('GET /api/customAvatarItems/v2/fromCreator/:id shows unpublished items only to the creator', async () => {
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
const base = {
description: '',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
}
const pub = await createCustomAvatarItem(
env.DB,
{
...base,
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name: 'Published',
accessibility: 1,
},
new Date('2026-08-01T00:00:00Z')
)
const draft = await createCustomAvatarItem(
env.DB,
{
...base,
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name: 'Draft',
accessibility: 0,
},
new Date('2026-08-02T00:00:00Z')
)
await createCustomAvatarItem(env.DB, {
...base,
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 9,
name: 'Other',
accessibility: 0,
})
type Page = { Results: Array<{ CustomAvatarItemId: string }>; TotalResults: number }
const url = `${ORIGIN}/api/customAvatarItems/v2/fromCreator/205`
// Anonymous, or someone else: only the published (Accessibility != 0) item.
for (const headers of [{}, await bearer('9')]) {
const res = await exports.default.fetch(url, { headers })
expect(res.status).toBe(200)
const page = (await res.json()) as Page
expect(page.TotalResults).toBe(1)
expect(page.Results.map((i) => i.CustomAvatarItemId)).toEqual([pub.CustomAvatarItemId])
}
// The creator: their unpublished item too, newest first.
const own = (await (
await exports.default.fetch(url, { headers: await bearer('205') })
).json()) as Page
expect(own.TotalResults).toBe(2)
expect(own.Results.map((i) => i.CustomAvatarItemId)).toEqual([
draft.CustomAvatarItemId,
pub.CustomAvatarItemId,
])
const none = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
expect(await none.json()).toEqual({ Results: [], TotalResults: 0 })
test('GET /api/customAvatarItems/v2/fromCreator/:id returns an empty paginated result', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
})
// Nothing locks avatar items here, so the array is empty and the posted ids are never
@@ -1701,110 +612,29 @@ describe('public endpoints', () => {
expect(await res.json()).toEqual([])
})
/** Create a custom avatar item and return it, so a bulk lookup has something to find. */
async function createCustomItem(
creator: string,
metadata: Record<string, unknown>
): Promise<{ CustomAvatarItemId: string; Name: string }> {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({
Name: 'bulk item',
Description: '',
Price: 0,
BaseAvatarItemId: 2184,
BaseAvatarItemColor: '#F55C1A',
Accessibility: 1,
...metadata,
})
)
form.set('thumbnailImage', new File([new Uint8Array([1])], 'f.bin', { type: 'image/png' }))
form.set('design', new File([new Uint8Array([2])], 'f.bin', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer(creator),
body: form,
})
expect(res.status).toBe(200)
return ((await res.json()) as { Value: { CustomAvatarItemId: string; Name: string } }).Value
}
/** POST the bulk lookup with `ids` as repeated form fields, as the client binds them. */
async function bulkLookup(
ids: string[],
as = '42'
): Promise<Array<{ CustomAvatarItemId: string; Name: string }>> {
// A BARE ARRAY of the items that matched — not the `{ Results, TotalResults }` page
// the sibling custom-item reads serve. Nothing stores custom items, so every id
// misses, and a miss is an absent entry rather than an error.
test('POST /api/customAvatarItems/v1/bulk returns the matching items as an array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(await bearer(as)) },
headers: {
'content-type': 'application/x-www-form-urlencoded',
...(await bearer()),
},
// Repeated form field, as `[FromForm] List<string>` binds it.
body: new URLSearchParams(ids.map((id) => ['customAvatarItemIds', id])),
body: new URLSearchParams([
['customAvatarItemIds', 'a'],
['customAvatarItemIds', 'b'],
]),
})
expect(res.status).toBe(200)
return (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }>
}
// A BARE ARRAY of the items that matched — not the `{ Results, TotalResults }` page
// the sibling custom-item reads serve — resolved out of the `custom_avatar_item` table.
// This is what a `1.<guid>` entity in a Generic discovery row resolves through, so it
// answering `[]` (as it did while it was a stub) renders that row's items as nothing.
test('POST /api/customAvatarItems/v1/bulk resolves the posted ids against the table', async () => {
const first = await createCustomItem('205', { Name: 'bulk one' })
const second = await createCustomItem('205', { Name: 'bulk two' })
// In REQUEST order, not creation order — the client reads the array positionally.
const items = await bulkLookup([second.CustomAvatarItemId, first.CustomAvatarItemId])
expect(items.map((i) => i.CustomAvatarItemId)).toEqual([
second.CustomAvatarItemId,
first.CustomAvatarItemId,
])
expect(items[0]).toMatchObject({ Name: 'bulk two', CreatorAccountId: 205, Accessibility: 1 })
// A miss is an absent entry, not an error: the client reads the items it got back
// rather than the ids it asked for, so an unknown id must not cost it the rest.
const mixed = await bulkLookup([
'00000000-0000-0000-0000-000000000000',
first.CustomAvatarItemId,
])
expect(mixed.map((i) => i.CustomAvatarItemId)).toEqual([first.CustomAvatarItemId])
// Ids also ride comma-separated inside one field, and on the query string — the
// client's exact encoding here isn't pinned down, so all three spellings are read.
const commas = await bulkLookup([`${first.CustomAvatarItemId},${second.CustomAvatarItemId}`])
expect(commas).toHaveLength(2)
const queried = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/bulk?customAvatarItemIds=${first.CustomAvatarItemId}`,
{ method: 'POST', headers: await bearer() }
)
expect(((await queried.json()) as unknown[]).length).toBe(1)
// Over 100 ids answers EMPTY without touching the table. The client has been seen posting
// far more than a screen could draw, and empty is safe precisely because a miss here is
// already not an error. Empty rather than the first 100: the client reads the items it got
// back, not the ids it asked about, so it cannot tell a truncated batch from a batch of
// misses and would cache the difference.
const padding = Array.from({ length: 99 }, () => '00000000-0000-0000-0000-000000000000')
expect(await bulkLookup([first.CustomAvatarItemId, ...padding])).toHaveLength(1)
expect(
await bulkLookup([first.CustomAvatarItemId, second.CustomAvatarItemId, ...padding])
).toEqual([])
expect(await res.json()).toEqual([])
})
// Unpublished items are held back from everyone but their creator — the same rule the
// featured/hot feeds and the creator shelf apply, so this route can't surface an item
// the feeds hide.
test('POST /api/customAvatarItems/v1/bulk hides unpublished items from everyone but the creator', async () => {
const hidden = await createCustomItem('206', { Name: 'unpublished', Accessibility: 0 })
expect(await bulkLookup([hidden.CustomAvatarItemId], '42')).toEqual([])
const own = await bulkLookup([hidden.CustomAvatarItemId], '206')
expect(own.map((i) => i.Name)).toEqual(['unpublished'])
})
// A missing body is a 200 with an empty array rather than a 400: nothing was asked for,
// so nothing matched — the same shape as asking for ids that all miss.
test('POST /api/customAvatarItems/v1/bulk answers an empty array for an empty body', async () => {
// The ids are never parsed (nothing could match), so a missing body is still a 200
// rather than the 400 a body-reading handler would produce.
test('POST /api/customAvatarItems/v1/bulk ignores the body', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
method: 'POST',
headers: await bearer(),
@@ -1914,58 +744,15 @@ describe('public endpoints', () => {
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null)
})
test('GET /outfits/me/saved lists every saved slot, ordered by slot', async () => {
test('GET /outfits/me/saved 401s without a token, returns [] with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`)
expect(anon.status).toBe(401)
// A distinct account, so this doesn't depend on what the tests above saved for 42.
const saved = async (sub: string) => {
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
headers: await bearer(sub),
})
expect(res.status).toBe(200)
return (await res.json()) as Array<Record<string, unknown>>
}
// A player who has never saved gets [], not the empty-outfit envelope `/outfits/me`
// serves — an empty wardrobe is an empty list.
expect(await saved('4242')).toEqual([])
const outfit = (slot: number, name: string | null) => ({
DataVersion: 2,
LegacyData: {
SelectionsV1: '193a3bf9-abc0-4d78-8d63-92046908b1c5,,0',
SelectionsV2: '{"selections":[]}',
FaceFeatures: '{"ver":7}',
SkinColor: 'Dc6StLFk60u5iUTrb3_C3w',
HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg',
},
CustomizationSettings: '{"AvatarVersion":2,"AvatarBodyType":0}',
Selections: [],
Slot: slot,
Name: name,
Accessibility: 1,
ThumbnailFileName: null,
// Empty even for account 42, which saved an outfit through PUT /outfits/me above.
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
headers: await bearer(),
})
// Saved out of order, to prove the list is ordered by slot rather than by write time.
for (const [slot, name] of [
[2, 'two'],
[0, null],
] as Array<[number, string | null]>) {
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer('4242')), 'content-type': 'application/json' },
body: JSON.stringify(outfit(slot, name)),
})
}
// Slot 0 is in the list: it is the outfit being worn, but it is a saved slot too, and
// the client picks the slot it writes. Each row comes back verbatim.
expect(await saved('4242')).toEqual([outfit(0, null), outfit(2, 'two')])
// Another account's wardrobe is its own.
expect(await saved('4343')).toEqual([])
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('POST /outfits/bulk serves each account’s worn outfit, keyed by id', async () => {
@@ -2062,12 +849,12 @@ describe('public endpoints', () => {
expect(res.status).toBe(400)
})
test('GET /api/progressionEvents/active is a bare -1 (no auth)', async () => {
// -1 is the reference server's "no active event" value; the client reads it as "no event
// running" and skips the event UI. A 404 would stall its load instead.
test('GET /api/progressionEvents/active is an empty list (no auth)', async () => {
// The client reads an empty list as "no event running" and skips the event UI; a 404
// would stall its load instead.
const res = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/active`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(-1)
expect(await res.json()).toEqual([])
})
test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => {
@@ -2197,853 +984,6 @@ describe('public endpoints', () => {
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
})
test('POST /api/inventions/v9/save answers the enveloped result the client reads', async () => {
const body = {
name: '082926 13:42:46',
description: 'No description yet',
imageName: 'invention/2026-08-29/52c1e282-76f5-4974-975f-d85060884085.jpg',
hasBetaContent: false,
instantiationCost: 101,
lightsCost: 0,
chipsCost: 0,
cloudVariablesCost: 0,
aiCost: 0,
ugcVersion: 1,
creationRoomId: 398,
inventionDataFilename: '2026-08-29/cb608051-f38b-4ef2-aa8a-a26eb0195b2b.inv',
referencedInventions: [],
referencedUnityAssetIds: [],
creatorAccountRole: 255,
convertedFromInventionId: null,
displayMetadataJson: '{"0":0,"99":0}',
longDescription: '',
tagsRequest: { AutoTags: ['small'], CustomTags: null },
}
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5151')), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
expect(res.status).toBe(200)
// v9 is ENVELOPED where v6 is bare. The client checks Success and then reads
// Value.Invention.InventionId — unguarded, so a true Success with a null Value is
// the one shape that takes it down.
const result = (await res.json()) as InventionSaveV9Result
expect(result.Success).toBe(true)
expect(result.Error).toBeNull()
expect(result.error_id).toBeNull()
const value = result.Value
if (value === null) throw new Error('Value must not be null on a successful save')
expect(value.Status).toBe(0)
expect(Object.keys(value).sort()).toEqual([
'Invention',
'InventionVersion',
'Status',
'TagsResponse',
])
const saved = value.Invention
expect(saved.InventionId).toBeGreaterThan(0)
expect(saved.CreatorPlayerId).toBe(5151)
expect(saved.Name).toBe(body.name)
expect(saved.CreationRoomId).toBe(398)
expect(saved.DisplayMetadataJson).toBe('{"0":0,"99":0}')
// UgcVersion is an INVENTION field here, next to the version numbers — not a
// version one, where its twin HasBetaContent lives.
expect(saved.UgcVersion).toBe(1)
expect(saved.CurrentVersionNumber).toBe(1)
expect(saved.LatestVersionNumber).toBe(1)
// The v9 RRInvention has no nested version, no Referenced* and no IsPublished —
// the client reads publication from FirstPublishedAt.
expect(saved).not.toHaveProperty('CurrentVersion')
expect(saved).not.toHaveProperty('ReferencedInventions')
expect(saved).not.toHaveProperty('IsPublished')
expect(saved.FirstPublishedAt).toBeNull()
// Costs, the blob and the beta flag ride on the version beside it. No AICost: the
// request sends one and this DTO has nowhere to put it.
expect(value.InventionVersion).toMatchObject({
InventionId: saved.InventionId,
VersionNumber: 1,
InstantiationCost: 101,
HasBetaContent: false,
BlobName: body.inventionDataFilename,
UgcAccessibility: null,
ReferencedInventions: [],
ReferencedUnityAssetIds: [],
})
expect(value.InventionVersion).not.toHaveProperty('AICost')
// The tagsRequest is applied as `v1/settags` would have applied it, and answered
// the way settags answers: a result code and the bare tag NAMES.
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['small'] })
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${saved.InventionId}`
)
expect(await details.json()).toEqual({ Tags: [{ Tag: 'small', Type: 2 }] })
// Stored once, read by every version: the older lookup still serves the record it
// always did, nested CurrentVersion and all.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${saved.InventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({
InventionId: saved.InventionId,
IsPublished: false,
CurrentVersion: { BlobName: body.inventionDataFilename },
})
})
test('POST /api/inventions/v9/save leaves the v9-only keys off the stored record', async () => {
// The same body a v6 client sends, posted at v9: nothing is back-filled, so the
// record is the one v6 has always stored. The response still carries the full v9
// projection — those fields have defaults there, not absences.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5152')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Bare Save', inventionDataFilename: 'bare.inv' }),
})
expect(res.status).toBe(200)
const value = ((await res.json()) as InventionSaveV9Result).Value
if (value === null) throw new Error('Value must not be null on a successful save')
expect(value.Invention.UgcVersion).toBe(0)
expect(value.Invention.DisplayMetadataJson).toBeNull()
// A save always mints a version; the key is nullable only because econ's
// `v3/buyInvention` answers in this same envelope and a buy mints none.
expect(value.InventionVersion).not.toBeNull()
expect(value.InventionVersion?.HasBetaContent).toBe(false)
expect(value.TagsResponse).toEqual({ Result: 0, Tags: [] })
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${value.Invention.InventionId}`
)
const stored = (await one.json()) as SavedInvention
expect(stored).not.toHaveProperty('Tags')
expect(stored).not.toHaveProperty('UgcVersion')
expect(stored).not.toHaveProperty('ReferencedUnityAssetIds')
expect(stored.CurrentVersion).not.toHaveProperty('HasBetaContent')
})
test('POST /api/inventions/v9/save refuses through the envelope, never a bare error', async () => {
// A refusal the client can show is Success:false with a null Value — the branch
// that reads Error and nothing else. A bare `{ error }` body would deserialize to
// a null envelope and take the client down instead of failing the save.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5153')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'No Blob' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Value: null,
Success: false,
Error: 'inventionDataFilename is required',
error_id: null,
})
// Even the 401 answers the envelope: an empty body is a null envelope to the
// client, which is the crash, not a refusal.
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Anon', inventionDataFilename: 'anon.inv' }),
})
expect(anon.status).toBe(401)
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Success: false,
})
})
test('POST /api/inventions/v9/save keeps the save when a tag breaks the tag rule', async () => {
// The reply carries a tag result of its own, so the two outcomes are separate: a
// hyphen in a tag must not cost the player the build they just saved.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5154')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Tagged Badly',
inventionDataFilename: 'tagged-badly.inv',
tagsRequest: { AutoTags: ['small'], CustomTags: ['bad-tag'] },
}),
})
expect(res.status).toBe(200)
const result = (await res.json()) as InventionSaveV9Result
expect(result.Success).toBe(true)
const value = result.Value
if (value === null) throw new Error('a refused tag must not refuse the save')
expect(value.Invention.InventionId).toBeGreaterThan(0)
// Non-zero result, and the whole list dropped rather than the offending tag alone —
// the creator re-submits it through `v1/settags` and sees what took.
expect(value.TagsResponse).toEqual({ Result: 1, Tags: [] })
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${value.Invention.InventionId}`
)
expect(await details.json()).toEqual({ Tags: [] })
// The invention is on the creator's shelf regardless.
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5154'),
})
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([
value.Invention.InventionId,
])
})
test('PUT /api/inventions/v2/metadata edits only the fields that aren’t null', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5160')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Before Edit',
description: 'the original description',
imageName: 'invention/before.jpg',
inventionDataFilename: 'before-edit.inv',
longDescription: 'the original blurb',
tagsRequest: { AutoTags: ['small'], CustomTags: null },
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
expect(inventionId).toBeGreaterThan(0)
// The client sends the whole shape every time and marks what it isn't touching as
// null — so a null Name must not blank the name.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5160')), 'Content-Type': 'application/json' },
body: JSON.stringify({
InventionId: inventionId,
Name: null,
Description: 'devin test No description yet',
LongDescription: null,
ImageName: null,
TagsRequest: null,
}),
})
expect(res.status).toBe(200)
const result = (await res.json()) as InventionSaveV9Result
expect(result.Success).toBe(true)
const value = result.Value
if (value === null) throw new Error('Value must not be null on a successful edit')
// The edit answers the UPDATED invention — the client re-renders the detail page
// from it — in the same envelope the save answers.
expect(value.Invention.Description).toBe('devin test No description yet')
expect(value.Invention.Name).toBe('Before Edit')
expect(value.Invention.ImageName).toBe('invention/before.jpg')
expect(value.Invention.InventionId).toBe(inventionId)
// A null TagsRequest leaves the stored tags alone, and they are still reported: the
// list is what the invention HAS, not what this call changed.
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['small'] })
// And it stuck — including the long description, which the v9 Invention DTO has no
// key for but the record keeps.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({
Name: 'Before Edit',
Description: 'devin test No description yet',
LongDescription: 'the original blurb',
Tags: [{ Tag: 'small', Type: 2 }],
})
})
test('PUT /api/inventions/v2/metadata treats an empty string as a clear, not a null', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Clear Me',
description: 'to be cleared',
imageName: 'invention/clear-me.jpg',
inventionDataFilename: 'clear-me.inv',
longDescription: 'blurb to be cleared',
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
body: JSON.stringify({
InventionId: inventionId,
Name: null,
Description: '',
LongDescription: '',
ImageName: '',
TagsRequest: { AutoTags: ['large'], CustomTags: ['puzzle'] },
}),
})
const value = ((await res.json()) as InventionSaveV9Result).Value
if (value === null) throw new Error('Value must not be null on a successful edit')
expect(value.Invention.Description).toBe('')
expect(value.Invention.ImageName).toBe('')
// TagsRequest replaces both lists wholesale, auto first, then custom.
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['large', 'puzzle'] })
// An empty name is not how a name is cleared — nothing can draw a nameless
// invention, so it fails the same rule a save holds it to and nothing is written.
const named = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Name: '' }),
})
expect(named.status).toBe(200)
expect((await named.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Success: false,
})
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(((await one.json()) as SavedInvention).Name).toBe('Clear Me')
})
// The permission picker's own call. `Permission` is the `GeneralPermission` ladder
// number — 40 is EditAndSave — and it decides what OTHER players may do with the
// invention, up to editing, re-publishing and charging for it.
test('POST /api/inventions/v2/update sets the permission from the body', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Permission Me', inventionDataFilename: 'perm.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
expect(inventionId).toBeGreaterThan(0)
// The body verbatim as the client posts it.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
method: 'POST',
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 40 }),
})
expect(res.status).toBe(200)
const result = (await res.json()) as InventionSaveV9Result
expect(result.Success).toBe(true)
// The UPDATED invention comes back in the envelope: the client re-renders the detail
// sheet from `Value.Invention`, so a reply that omitted it would leave the old
// permission on screen.
expect(result.Value?.Invention.GeneralPermission).toBe(40)
// And it stuck.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(((await one.json()) as SavedInvention).GeneralPermission).toBe(40)
})
// THE gate. `GeneralPermission` is what lets another player edit, re-publish or charge
// for someone's build, so a write that took the caller's word for the id would let
// anyone hand themselves rights over any invention in the game. Refused in-band, the
// way `v2/metadata` refuses — this client cannot parse a bare error body — and, the
// part that matters, the stored permission must not move.
test('POST /api/inventions/v2/update is gated to the invention’s creator', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Not Yours Either', inventionDataFilename: 'nye.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const permissionNow = async () => {
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
return ((await one.json()) as SavedInvention).GeneralPermission
}
const before = await permissionNow()
// Another player, granting themselves Unlimited (100) over someone else's build.
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 100 }),
})
expect(theirs.status).toBe(200)
expect(await theirs.json()).toEqual({
Value: null,
Success: false,
Error: 'Not your invention',
error_id: null,
})
expect(await permissionNow()).toBe(before)
// An unknown invention is the same kind of answer.
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
method: 'POST',
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 987655, Permission: 40 }),
})
expect(missing.status).toBe(200)
expect((await missing.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Error: 'No such invention',
})
// A missing token is the one refusal that stays a transport failure — but it still
// answers the envelope, because an unparseable body crashes the client.
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 100 }),
})
expect(anon.status).toBe(401)
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Success: false,
})
expect(await permissionNow()).toBe(before)
})
// A patch, not a replace — and specifically NOT `v4/publish`'s reading of a null
// permission, which is UseOnly. Publishing something without naming a permission is a
// decision; editing it without naming one is not, and defaulting here would silently
// demote an invention every time the creator renamed it.
test('POST /api/inventions/v2/update leaves a null permission alone', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Keep My Rights', inventionDataFilename: 'keep.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
// Set it to Publish (60) first, so a fallback to UseOnly (20) would be visible.
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
method: 'POST',
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 60 }),
})
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
method: 'POST',
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: null, Name: 'Renamed' }),
})
expect(res.status).toBe(200)
const value = ((await res.json()) as InventionSaveV9Result).Value
expect(value?.Invention.GeneralPermission).toBe(60)
expect(value?.Invention.Name).toBe('Renamed')
})
// The same parser `v1/update`'s query param goes through, so a build that sends the
// ladder by NAME lands on the same level as one sending the number.
test('POST /api/inventions/v2/update accepts a permission named as a string', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Named Ladder', inventionDataFilename: 'named.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
for (const [sent, expected] of [
['EditAndSave', 40],
['edit_and_save', 40],
['60', 60],
] as const) {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
method: 'POST',
headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: sent }),
})
expect(res.status).toBe(200)
expect(((await res.json()) as InventionSaveV9Result).Value?.Invention.GeneralPermission).toBe(
expected
)
}
})
test('PUT /api/inventions/v2/metadata refuses another creator’s invention in-band', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5162')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Not Yours', inventionDataFilename: 'not-yours.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
// Someone else's invention and an unknown one are domain answers, not transport
// ones — the client's own status enum has NotCreator and DoesNotExist members — so
// they come back 200 in the envelope, where the message reaches a human.
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5163')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Description: 'hijacked' }),
})
expect(theirs.status).toBe(200)
expect(await theirs.json()).toEqual({
Value: null,
Success: false,
Error: 'Not your invention',
error_id: null,
})
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5162')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 987654, Description: 'nobody' }),
})
expect(missing.status).toBe(200)
expect((await missing.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Error: 'No such invention',
})
// A missing token is the one refusal that stays a transport failure — but it still
// answers the envelope, because an unparseable body crashes the client.
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Description: 'anon' }),
})
expect(anon.status).toBe(401)
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Success: false,
})
// Untouched throughout.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(((await one.json()) as SavedInvention).Description).toBe('No description yet')
})
test('PUT /api/inventions/v2/metadata keeps the edit when a tag breaks the tag rule', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5164')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Tag Trouble',
inventionDataFilename: 'tag-trouble.inv',
tagsRequest: { AutoTags: ['small'], CustomTags: null },
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5164')), 'Content-Type': 'application/json' },
body: JSON.stringify({
InventionId: inventionId,
Description: 'edited anyway',
TagsRequest: { AutoTags: ['small'], CustomTags: ['bad-tag'] },
}),
})
const value = ((await res.json()) as InventionSaveV9Result).Value
if (value === null) throw new Error('a refused tag must not refuse the edit')
// The metadata edit lands; the tags are what didn't.
expect(value.Invention.Description).toBe('edited anyway')
expect(value.TagsResponse).toEqual({ Result: 1, Tags: [] })
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${inventionId}`
)
expect(await details.json()).toEqual({ Tags: [] })
})
test('POST /api/inventions/v4/publish publishes with the permission and accessibility sent', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Publish Me', inventionDataFilename: 'publish-me.inv' }),
})
const saved = ((await save.json()) as InventionSaveV9Result).Value?.Invention
expect(saved?.FirstPublishedAt).toBeNull()
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
body: JSON.stringify({
InventionId: saved?.InventionId,
Permission: 20,
Accessibility: 1,
Price: null,
}),
})
expect(res.status).toBe(200)
const result = (await res.json()) as InventionSaveV9Result
expect(result.Success).toBe(true)
const value = result.Value
if (value === null) throw new Error('Value must not be null on a successful publish')
// Publishing narrows what everyone else gets down to what the sheet sent, and dates
// the invention — the client reads publication from FirstPublishedAt, not a flag.
expect(value.Invention.GeneralPermission).toBe(20)
expect(value.Invention.Accessibility).toBe(1)
expect(typeof value.Invention.FirstPublishedAt).toBe('string')
expect(value.Invention.Price).toBe(0)
// And it's findable now: the record the older reads serve says published, and it
// turns up in search.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${value.Invention.InventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({ IsPublished: true })
const found = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?value=Publish Me`)
expect(((await found.json()) as SavedInvention[]).map((i) => i.InventionId)).toContain(
value.Invention.InventionId
)
// Taken back out: the browse tests below assert the exact published catalogue, and
// a test that publishes something publicly is a test that changes it.
await env.DB.prepare('DELETE FROM invention WHERE id = ?1')
.bind(value.Invention.InventionId)
.run()
})
test('POST /api/inventions/v4/publish keeps an unlisted invention out of the feeds', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Quietly Published',
inventionDataFilename: 'quietly-published.inv',
creationRoomId: 4171,
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 2 }),
})
const value = ((await res.json()) as InventionSaveV9Result).Value
expect(value?.Invention.Accessibility).toBe(2)
// Unlisted is published — it is reachable by id, which is the whole point of it —
// but it is not something anyone comes across.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({
InventionId: inventionId,
IsPublished: true,
})
const found = await exports.default.fetch(
`${ORIGIN}/api/inventions/v2/search?value=Quietly Published`
)
expect(await found.json()).toEqual([])
const room = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=4171`)
expect(await room.json()).toEqual([])
})
test('POST /api/inventions/v4/publish leaves a price and a first-publish date alone', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'For Sale', inventionDataFilename: 'for-sale.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const publish = async (body: Record<string, unknown>) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, ...body }),
})
const value = ((await res.json()) as InventionSaveV9Result).Value
if (value === null) throw new Error('Value must not be null on a successful publish')
return value.Invention
}
const first = await publish({ Permission: 80, Accessibility: 1, Price: 250 })
expect(first.Price).toBe(250)
// A republish that says nothing about money must not give away something that was
// for sale, and must not re-date the first publish.
const again = await publish({ Permission: 20, Accessibility: 1, Price: null })
expect(again.Price).toBe(250)
expect(again.GeneralPermission).toBe(20)
expect(again.FirstPublishedAt).toBe(first.FirstPublishedAt)
// A negative price is dropped rather than stored.
expect((await publish({ Price: -5 })).Price).toBe(250)
// Out of the published catalogue again — see the note in the publish test above.
await env.DB.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run()
})
test('POST /api/inventions/v4/publish refuses another creator’s invention in-band', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Theirs Alone', inventionDataFilename: 'theirs-alone.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 1 }),
})
expect(theirs.status).toBe(200)
expect(await theirs.json()).toEqual({
Value: null,
Success: false,
Error: 'Not your invention',
error_id: null,
})
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 1 }),
})
expect(anon.status).toBe(401)
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({ Value: null })
// Still unpublished throughout.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({
IsPublished: false,
FirstPublishedAt: null,
})
})
test('POST /api/inventions/v2/delete removes the creator’s invention', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Delete Me',
inventionDataFilename: 'delete-me.inv',
tagsRequest: { AutoTags: ['small'], CustomTags: null },
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
expect(inventionId).toBeGreaterThan(0)
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(res.status).toBe(200)
// `Value` is null even on success — there is no invention left to redraw from.
expect(await res.json()).toEqual({ Value: null, Success: true, Error: null, error_id: null })
// Gone from the read and from the creator's shelf.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(one.status).toBe(404)
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5180'),
})
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).not.toContain(
inventionId
)
// And the row itself, tags and all, rather than a hidden record still taking the id.
const row = await env.DB.prepare('SELECT COUNT(*) AS n FROM invention WHERE id = ?1')
.bind(inventionId)
.first<{ n: number }>()
expect(row?.n).toBe(0)
// Deleting it twice is a refusal, not a second success: the id resolves to nothing.
const again = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(await again.json()).toEqual({
Value: null,
Success: false,
Error: 'No such invention',
error_id: null,
})
})
test('POST /api/inventions/v2/delete refuses anyone but the creator, in-band', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5181')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Not Yours To Bin', inventionDataFilename: 'not-yours.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
// 5182 BOUGHT it — owning a copy is still not the right to delete it.
await grantInvention(env.DB, 5182, inventionId as number)
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5182')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(theirs.status).toBe(200)
expect(await theirs.json()).toEqual({
Value: null,
Success: false,
Error: 'Not your invention',
error_id: null,
})
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5181')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 987_655 }),
})
expect(missing.status).toBe(200)
expect(await missing.json()).toEqual({
Value: null,
Success: false,
Error: 'No such invention',
error_id: null,
})
// A missing token is the one refusal that stays a transport failure — and it still
// answers the envelope rather than a bare error body.
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(anon.status).toBe(401)
expect(await anon.json()).toMatchObject({ Value: null, Success: false })
// Still there throughout.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(one.status).toBe(200)
})
test('POST /api/inventions/v2/delete leaves a buyer’s ownership row behind', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5183')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Sold Then Binned', inventionDataFilename: 'sold.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention
.InventionId as number
await grantInvention(env.DB, 5184, inventionId)
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5183')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect((await res.json()) as { Success: boolean }).toMatchObject({ Success: true })
// The purchase record is not rewritten by someone else's delete...
const owned = await env.DB.prepare(
'SELECT COUNT(*) AS n FROM inventory_invention WHERE invention_id = ?1'
)
.bind(inventionId)
.first<{ n: number }>()
expect(owned?.n).toBe(1)
// ...but with no invention row behind it, it drops out of the buyer's shelf anyway.
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5184'),
})
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).not.toContain(
inventionId
)
})
test('GET /api/inventions/v2/mine lists bought inventions alongside the caller’s own', async () => {
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
// exactly this row) and also creates one of their own.
@@ -3087,245 +1027,6 @@ describe('public endpoints', () => {
])
})
test('POST /api/customAvatarItems/v1/:id/report files a report against the item’s creator', async () => {
// 205 makes an item; 42 reports it. The creator is derived FROM the item — the client
// sends `ReportedPlayerId: null` because it does not know who made it.
const item = await createCustomItem('205', { Name: 'Reportable Hat' })
const res = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`,
{
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({
ReportCategory: 2,
Details: 'tesfsfsdf',
ReportedPlayerId: null,
}),
}
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// One row in the shared report table, marked as an item report by `custom_avatar_item_id`.
// `room_id` stays null — an item isn't tied to one room the way an event is — and the
// other two id columns stay null, which is what tells the kinds apart.
const row = await env.DB.prepare('SELECT * FROM report WHERE custom_avatar_item_id = ?1')
.bind(item.CustomAvatarItemId)
.first<Record<string, unknown>>()
expect(row).toMatchObject({
reporter_player_id: 42,
reported_player_id: 205, // the item's creator
report_category: 2,
details: 'tesfsfsdf',
custom_avatar_item_id: item.CustomAvatarItemId,
invention_id: null,
event_id: null,
room_id: null,
banned: 0, // filed unbanned, like any report
})
// A body naming SOMEONE ELSE is ignored: the reported player is read off the item either
// way. Letting a client name who a report is against would let it point one at anybody.
await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`,
{
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ ReportCategory: 1, ReportedPlayerId: 999 }),
}
)
const reported = await env.DB.prepare(
'SELECT reported_player_id FROM report WHERE custom_avatar_item_id = ?1'
)
.bind(item.CustomAvatarItemId)
.all<{ reported_player_id: number }>()
// Nothing dedupes: two reports of the same item are two rows, both against the creator.
expect(reported.results.map((r) => r.reported_player_id)).toEqual([205, 205])
// An item that does not exist is refused rather than filed against nobody — the row's
// reported player has to be someone.
const unknown = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/00000000-0000-0000-0000-000000000000/report`,
{
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ ReportCategory: 0 }),
}
)
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({ success: false, error: 'No such item' })
// Auth-gated: the reporter comes from the token, so there is no filing one signed out.
const anon = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ReportCategory: 0 }),
}
)
expect(anon.status).toBe(401)
})
test('POST /api/inventions/v1/report files a report row against the invention', async () => {
// 5150 saves an invention; 42 reports it. The creator is derived from the invention,
// so the reporter never gets to name who the report is against.
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Reportable', inventionDataFilename: 'blob' }),
})
const inventionId = ((await save.json()) as InventionSaveResult).Invention.InventionId
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Details: 'test', ReportCategory: 0 }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// One row in the shared report table, marked as an invention report by `invention_id`,
// with the reported player filled in FROM the invention. `room_id` stays null: an
// invention isn't tied to one room the way an event is, so there is nothing to read.
const row = await env.DB.prepare('SELECT * FROM report WHERE invention_id = ?1')
.bind(inventionId)
.first<Record<string, unknown>>()
expect(row).toMatchObject({
reporter_player_id: 42,
reported_player_id: 5150, // the invention's creator
report_category: 0,
details: 'test',
invention_id: inventionId,
event_id: null, // the two id columns are mutually exclusive
room_id: null,
banned: 0, // filed unbanned, like any report
})
// A body with no usable invention id, and one naming an invention that doesn't exist —
// both answer the same envelope shape as the success branch.
const noId = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ Details: 'x' }),
})
expect(noId.status).toBe(400)
expect(await noId.json()).toEqual({ success: false, error: 'InventionId is required' })
const unknown = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 999999 }),
})
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({ success: false, error: 'No such invention' })
// Auth-gated: the reporter comes from the token, so there's no filing one signed out.
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, {
method: 'POST',
body: JSON.stringify({ InventionId: inventionId }),
})
expect(anon.status).toBe(401)
})
test('POST /api/chatreport/createChatReport files a report row against the message sender', async () => {
const url = `${ORIGIN}/api/chatreport/createChatReport`
const post = async (sub: string, fields: Record<string, string>) =>
exports.default.fetch(url, {
method: 'POST',
headers: await bearer(sub),
body: new URLSearchParams(fields),
})
// 5150 says something in a DM with 42; 42 reports it. The body names no player — the
// reported one is the message's sender.
const threadId = await createThread(env.DB, [42, 5150])
const message = await postMessage(env.DB, {
chatThreadId: threadId,
senderPlayerId: 5150,
contents: '{"Type":0,"Version":1,"Data":"something awful"}',
})
const id = String(message.chatMessageId)
// The body exactly as the client posts it: the category is a NAME, not a number.
const res = await post('42', {
ChatThreadId: String(threadId),
ChatMessageId: id,
ReportCategory: 'Discriminatory',
ReportDescription: 'details',
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
const row = await env.DB.prepare('SELECT * FROM report WHERE chat_message_id = ?1')
.bind(message.chatMessageId)
.first<Record<string, unknown>>()
expect(row).toMatchObject({
reporter_player_id: 42,
reported_player_id: 5150, // the message's sender
report_category: 102, // `Discriminatory` → CoCDiscrimination
details: 'details',
chat_message_id: message.chatMessageId,
event_id: null, // the id columns are mutually exclusive
invention_id: null,
custom_avatar_item_id: null,
room_id: null,
banned: 0, // filed unbanned, like any report
})
// The thread in the body is not what vouches for the message: a wrong one changes
// nothing, and a category the server can't place is filed as 0 with its name kept.
const odd = await post('42', {
ChatThreadId: '999999',
ChatMessageId: id,
ReportCategory: 'SomethingNew',
ReportDescription: 'why',
})
expect(odd.status).toBe(200)
const oddRow = await env.DB.prepare(
'SELECT report_category, details FROM report WHERE chat_message_id = ?1 ORDER BY id DESC'
)
.bind(message.chatMessageId)
.first()
expect(oddRow).toEqual({ report_category: 0, details: '[SomethingNew] why' })
// Someone outside the thread can't report a message they can't read — and is told
// exactly what they'd be told about a message that doesn't exist.
const outsider = await post('777', { ChatMessageId: id, ReportCategory: 'Discriminatory' })
expect(outsider.status).toBe(404)
expect(await outsider.json()).toEqual({ success: false, error: 'No such message' })
const unknown = await post('42', { ChatMessageId: '999999' })
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({ success: false, error: 'No such message' })
// A system notice has no sender to file a report against.
const notice = await postMessage(env.DB, {
chatThreadId: threadId,
senderPlayerId: SYSTEM_SENDER_ID,
contents: '{"Type":1,"Version":1,"Data":"5150"}',
})
const system = await post('42', { ChatMessageId: String(notice.chatMessageId) })
expect(system.status).toBe(400)
const noId = await post('42', { ReportDescription: 'x' })
expect(noId.status).toBe(400)
expect(await noId.json()).toEqual({ success: false, error: 'ChatMessageId is required' })
// Auth-gated: the reporter comes from the token, so there's no filing one signed out.
const anon = await exports.default.fetch(url, {
method: 'POST',
body: new URLSearchParams({ ChatMessageId: id }),
})
expect(anon.status).toBe(401)
// Only the two accepted reports were filed.
const count = await env.DB.prepare(
'SELECT COUNT(*) AS n FROM report WHERE chat_message_id IS NOT NULL'
).first<{ n: number }>()
expect(count?.n).toBe(2)
})
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',
@@ -3578,82 +1279,6 @@ describe('public endpoints', () => {
expect(await miss.json()).toEqual([])
})
test('GET /api/inventions/v2/search filters and pages in SQL, and does not search tags', async () => {
const published = (id: number, name: string, description: string, tags: string[]) =>
({
InventionId: id,
ReplicationId: crypto.randomUUID(),
CreatorPlayerId: 8081,
Name: name,
Description: description,
ImageName: '',
CurrentVersionNumber: 1,
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
IsPublished: true,
HideFromPlayer: false,
CreatedAt: `2026-09-0${id - 200}T00:00:00Z`,
Tags: tags.map((Tag) => ({ Tag, Type: 2 })),
}) as unknown as SavedInvention
for (const inv of [
published(201, 'Devin Cube', 'i dont even know lol', ['small']),
published(202, 'Devin Cube 2', 'No description yet', ['small', 'dormanchor']),
published(203, 'Recflarian Flag', 'idk..... lol', ['medium']),
published(204, 'Smallest Table', 'a small table', ['medium']),
// Name holds LIKE metacharacters, for the escaping below.
published(205, '100% Cube_Thing', '', []),
]) {
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(inv))
.run()
}
const ids = async (query: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?${query}`)
expect(res.status, query).toBe(200)
return ((await res.json()) as SavedInvention[])
.map((i) => i.InventionId)
.filter((id) => id >= 201)
}
// Newest first, matched against name OR description, case-insensitively.
expect(await ids('value=cube&skip=0&take=100')).toEqual([205, 202, 201])
expect(await ids('value=CUBE&skip=0&take=100')).toEqual([205, 202, 201])
expect(await ids('value=small&skip=0&take=100')).toEqual([204])
expect(await ids('value=lol&skip=0&take=100')).toEqual([203, 201])
// Terms are ANDed, so more words narrow rather than widen.
expect(await ids(`value=${encodeURIComponent('devin cube')}&skip=0&take=100`)).toEqual([
202, 201,
])
expect(await ids(`value=${encodeURIComponent('devin flag')}&skip=0&take=100`)).toEqual([])
// LIKE metacharacters are escaped: unescaped, `%` would match everything and `_` any
// single character, so searching for "100%" would return the whole catalogue.
expect(await ids('value=%25&skip=0&take=100')).toEqual([205])
expect(await ids('value=cube_thing&skip=0&take=100')).toEqual([205])
// TAGS ARE NOT SEARCHED. The browse screen's chips send `#small`, and no name or
// description contains it, so the term matches nothing — the tag is on 201 and 202, and a
// tag search would have returned them. Deliberate for now: matching tags needs them out of
// the JSON blob and into something indexable, and doing it in memory would mean reading
// every row to answer one page.
expect(await ids(`value=${encodeURIComponent('#small')}&skip=0&take=100`)).toEqual([])
// Paged in SQL: consecutive pages neither repeat nor skip a row. The `id` tiebreak in the
// ordering is what guarantees that when two inventions share a `CreatedAt`.
const page1 = await ids('value=cube&skip=0&take=2')
const page2 = await ids('value=cube&skip=2&take=2')
expect(page1).toEqual([205, 202])
expect(page2).toEqual([201])
expect(page1.some((id) => page2.includes(id))).toBe(false)
expect(await ids('value=cube&skip=99&take=10')).toEqual([])
// Cleaned up: the feeds and the tag-filter chips are derived from EVERY published
// invention, so rows left behind here would change what those tests see.
await env.DB.prepare('DELETE FROM invention WHERE id >= 201 AND id <= 205').run()
})
test('GET /api/inventions/v1/tagfilters ranks the tags in use', async () => {
// Two published inventions tagged `furniture`, one `bed` — plus a tagged draft,
// whose tags must not leak into the public filter chips.
@@ -3821,71 +1446,17 @@ describe('public endpoints', () => {
expect(noId.status).toBe(400)
})
test('POST /api/inventions/v1/cheer persists and personaldetails reflects it', async () => {
const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('8200')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Cheerable Lamp', inventionDataFilename: 'cheerable.inv' }),
test('GET /api/inventions/v1/personaldetails/:id reports the cheer flag', async () => {
// No cheer storage yet, so nobody is ever cheering — signed in or not.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`, {
headers: await bearer('42'),
})
const invention = ((await saved.json()) as InventionSaveResult).Invention
const path = `${ORIGIN}/api/inventions/v1/cheer`
const cheer = async (value: boolean, sub = '42') =>
exports.default.fetch(path, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: invention.InventionId, Cheer: value }),
})
const personal = async (sub?: string) =>
exports.default.fetch(
`${ORIGIN}/api/inventions/v1/personaldetails/${invention.InventionId}`,
sub ? { headers: await bearer(sub) } : undefined
)
const storedCount = async (): Promise<number> => {
const row = await env.DB.prepare('SELECT data FROM invention WHERE id = ?1')
.bind(invention.InventionId)
.first<{ data: string }>()
return (JSON.parse(row!.data) as SavedInvention).CheerCount
}
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ IsCheering: false })
// The write requires a player; the read remains useful to signed-out callers.
expect(
(
await exports.default.fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: invention.InventionId, Cheer: true }),
})
).status
).toBe(401)
expect(await (await personal()).json()).toEqual({ IsCheering: false })
expect((await cheer(true)).status).toBe(200)
expect(await (await personal('42')).json()).toEqual({ IsCheering: true })
expect(await storedCount()).toBe(1)
// Repeating a state is idempotent, and a second player counts separately.
await cheer(true)
expect(await storedCount()).toBe(1)
await cheer(true, '43')
expect(await storedCount()).toBe(2)
await cheer(false)
expect(await (await personal('42')).json()).toEqual({ IsCheering: false })
expect(await (await personal('43')).json()).toEqual({ IsCheering: true })
expect(await storedCount()).toBe(1)
const unknown = await exports.default.fetch(path, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 999999, Cheer: true }),
})
expect(unknown.status).toBe(404)
const malformed = await exports.default.fetch(path, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: invention.InventionId, Cheer: 'yes' }),
})
expect(malformed.status).toBe(400)
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`)
expect(anon.status).toBe(200)
expect(await anon.json()).toEqual({ IsCheering: false })
})
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
@@ -3920,27 +1491,7 @@ describe('public endpoints', () => {
InstantiationCost: 42,
})
// `version=0` means "whichever is current" rather than a number to match, and gets the
// same version 1 back. Nothing has a version 0 — a fresh save is version 1 — so a caller
// sending it does not know which version it wants, and matching it literally would 404 an
// invention that exists.
const v0 = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=0`
)
expect(v0.status).toBe(200)
expect(await v0.json()).toMatchObject({
InventionId: Invention.InventionId,
VersionNumber: 1,
BlobName: '2026-07-12/lamp.inv',
})
// The 0 shortcut does NOT make up an invention: an unknown id still 404s at 0.
const zeroUnknown = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=999999&version=0`
)
expect(zeroUnknown.status).toBe(404)
// Only the current version exists; any other NUMBER still 404s, as does an unknown id.
// Only the current version exists; anything else 404s, as does an unknown id.
const v2 = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=2`
)
@@ -4448,1134 +1999,6 @@ describe('auth-gated endpoints', () => {
})
})
describe('custom avatar items', () => {
test('minPriceForPublicItem is a bare 100', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/minPriceForPublicItem`
)
expect(res.status).toBe(200)
expect(await res.json()).toBe(100)
})
test('POST creates an item from the multipart form and returns it', async () => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({
Name: 'custom shirt 1',
Description: 'custom shirt 2',
Price: 0,
BaseAvatarItemId: 2184,
BaseAvatarItemColor: '#F55C1A',
Accessibility: 0,
})
)
form.set(
'thumbnailImage',
new File([new Uint8Array([1, 2, 3])], 'file.bin', { type: 'image/png' })
)
form.set('design', new File([new Uint8Array([4, 5, 6])], 'file.bin', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
Value: Record<string, unknown>
Success: boolean
Error: null
error_id: null
}
expect(body.Success).toBe(true)
expect(body.Error).toBeNull()
expect(body.error_id).toBeNull()
expect(body.Value).toMatchObject({
CreatorAccountId: 205,
Name: 'custom shirt 1',
Description: 'custom shirt 2',
Price: 0,
Accessibility: 0,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: false,
BaseAvatarItemId: 2184,
BaseAvatarItemColor: '#F55C1A',
PreviewOrientation: 0,
RankingContext: null,
// Filed as a custom shirt, the slot the store's user-generated-content tab searches.
OutfitType: 105,
// The full shape a first-party item carries, empty: no built saves, no tags.
CurrentSaves: [],
Tags: [],
CustomBadgeMetadata: null,
PurchaseInfo: null,
})
const itemId = body.Value.CustomAvatarItemId as string
expect(body.Value.RankedEntityId).toBe(itemId)
expect(itemId).toMatch(/^[0-9a-f-]{36}$/)
const date = (body.Value.CreatedAt as string).slice(0, 10)
expect(body.Value.ThumbnailImageFilename).toBe(`avatar-item/${date}/${itemId}-thumb.png`)
expect(body.Value.DesignFilename).toBe(`avatar-item/${date}/${itemId}-design.png`)
expect(body.Value.CreatedAt).toBe(body.Value.ModifiedAt)
// Both uploads landed in the image bucket under those keys.
const thumb = await env.IMAGES.get(body.Value.ThumbnailImageFilename as string)
expect(new Uint8Array((await thumb!.arrayBuffer()) as ArrayBuffer)).toEqual(
new Uint8Array([1, 2, 3])
)
expect(thumb!.httpMetadata?.contentType).toBe('image/png')
const design = await env.IMAGES.get(body.Value.DesignFilename as string)
expect(new Uint8Array((await design!.arrayBuffer()) as ArrayBuffer)).toEqual(
new Uint8Array([4, 5, 6])
)
// The row is the record as JSON, with the queried fields generated off it.
const row = await env.DB.prepare(
`SELECT json_extract(data, '$.Name') AS name, creator_account_id
FROM custom_avatar_item WHERE custom_avatar_item_id = ?1`
)
.bind(body.Value.CustomAvatarItemId)
.first()
expect(row).toEqual({ name: 'custom shirt 1', creator_account_id: 205 })
})
test('PUT edits the creator’s item, leaving nulled fields alone', async () => {
const item = await createCustomAvatarItem(
env.DB,
{
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name: 'Visor',
description: 'shiny',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility: 0,
designFilename: 'd',
thumbnailImageFilename: 't',
},
new Date('2026-08-01T00:00:00Z')
)
const url = `${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}`
const res = await exports.default.fetch(url, {
method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
body: JSON.stringify({ Name: null, Description: null, Price: 100, Accessibility: 1 }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
Value: Record<string, unknown>
Success: boolean
Error: null
}
expect(body.Success).toBe(true)
expect(body.Value).toMatchObject({
CustomAvatarItemId: item.CustomAvatarItemId,
Name: 'Visor',
Description: 'shiny',
Price: 100,
Accessibility: 1,
CreatedAt: '2026-08-01T00:00:00.000Z',
})
expect(body.Value.ModifiedAt).not.toBe(item.ModifiedAt)
// Someone else can't edit it; an unknown id 404s; a bad type 400s.
const other = await exports.default.fetch(url, {
method: 'PUT',
headers: { ...(await bearer('9')), 'content-type': 'application/json' },
body: JSON.stringify({ Price: 5 }),
})
expect(other.status).toBe(403)
const missing = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/${crypto.randomUUID()}`,
{
method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
body: JSON.stringify({ Price: 5 }),
}
)
expect(missing.status).toBe(404)
const bad = await exports.default.fetch(url, {
method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
body: JSON.stringify({ Price: 'lots' }),
})
expect(bad.status).toBe(400)
expect(await bad.json()).toMatchObject({ Success: false, Value: null })
expect(await (await exports.default.fetch(url, { method: 'PUT' })).status).toBe(401)
})
test('DELETE removes the creator’s item and its bucket objects', async () => {
// Create through the endpoint so the objects really exist in the bucket.
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'Gone', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
)
form.set('thumbnailImage', new File([new Uint8Array([1])], 'file.bin', { type: 'image/png' }))
form.set('design', new File([new Uint8Array([2])], 'file.bin', { type: 'image/png' }))
const created = (await (
await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
).json()) as {
Value: { CustomAvatarItemId: string; ThumbnailImageFilename: string; DesignFilename: string }
}
const { CustomAvatarItemId, ThumbnailImageFilename, DesignFilename } = created.Value
expect(await env.IMAGES.get(ThumbnailImageFilename)).not.toBeNull()
const url = `${ORIGIN}/api/customAvatarItems/v1/${CustomAvatarItemId}`
// Not the creator → 403 and nothing changes.
const other = await exports.default.fetch(url, { method: 'DELETE', headers: await bearer('9') })
expect(other.status).toBe(403)
expect(await env.IMAGES.get(ThumbnailImageFilename)).not.toBeNull()
const res = await exports.default.fetch(url, { method: 'DELETE', headers: await bearer('205') })
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({
Success: true,
Error: null,
Value: { CustomAvatarItemId, Name: 'Gone' },
})
expect(await env.IMAGES.get(ThumbnailImageFilename)).toBeNull()
expect(await env.IMAGES.get(DesignFilename)).toBeNull()
expect(
await env.DB.prepare('SELECT 1 FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
.bind(CustomAvatarItemId)
.first()
).toBeNull()
// Gone now → 404; no token → 401.
const again = await exports.default.fetch(url, {
method: 'DELETE',
headers: await bearer('205'),
})
expect(again.status).toBe(404)
expect((await exports.default.fetch(url, { method: 'DELETE' })).status).toBe(401)
})
test('POST 400s without the files', async () => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'x', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
)
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer(),
body: form,
})
expect(res.status).toBe(400)
expect(await res.json()).toMatchObject({ Success: false, Value: null })
})
test('POST rejects either oversized file before writing anything to R2', async () => {
const previous = env.RECFLARE_MAX_API_UPLOAD_BYTES
env.RECFLARE_MAX_API_UPLOAD_BYTES = '3'
try {
const objectsBefore = (await env.IMAGES.list({ prefix: 'avatar-item/' })).objects.length
const upload = async (thumbnail: Uint8Array, design: Uint8Array) => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'bounded', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
)
form.set('thumbnailImage', new File([thumbnail], 'thumb.png', { type: 'image/png' }))
form.set('design', new File([design], 'design.png', { type: 'image/png' }))
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
}
const oversizedThumbnail = await upload(new Uint8Array(4), new Uint8Array(3))
expect(oversizedThumbnail.status).toBe(413)
expect(await oversizedThumbnail.json()).toMatchObject({
Success: false,
Error: 'thumbnailImage exceeds the 3-byte upload limit',
})
const oversizedDesign = await upload(new Uint8Array(3), new Uint8Array(4))
expect(oversizedDesign.status).toBe(413)
expect(await oversizedDesign.json()).toMatchObject({
Success: false,
Error: 'design exceeds the 3-byte upload limit',
})
// Neither rejected request may create metadata or leave one of its two objects behind.
const row = await env.DB.prepare(
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE json_extract(data, '$.Name') = 'bounded'"
).first<{ n: number }>()
expect(row?.n).toBe(0)
expect((await env.IMAGES.list({ prefix: 'avatar-item/' })).objects).toHaveLength(
objectsBefore
)
} finally {
env.RECFLARE_MAX_API_UPLOAD_BYTES = previous
}
})
test('POST 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
})
expect(res.status).toBe(401)
})
})
describe('instant kick', () => {
// The game session the kick names, and one belonging to the same room that must be
// left out of it.
const SESSION = 1013781
const OTHER_SESSION = 1013782
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
// Room instances are written by the `match` worker; seeded straight into the table
// here, the way the presence rows below are.
const seedInstance = async (roomInstanceId: number, maxCapacity = 0, isFull = false) =>
env.DB.prepare('INSERT OR REPLACE INTO room_instance (data) VALUES (?1)')
.bind(
JSON.stringify({
roomInstanceId,
ownerAccountId: 42,
roomId: 4,
subRoomId: 4,
location: '',
dataBlob: '',
eventId: 0,
photonRegionId: 'us',
photonRoomId: `photon-${roomInstanceId}`,
name: '',
maxCapacity,
isFull,
isPrivate: false,
isInProgress: false,
roomCode: '',
roomInstanceType: 0,
clubId: 0,
EncryptVoiceChat: false,
matchmakingPolicy: 0,
allowNewUsers: true,
joinDisabled: false,
gameVersion: GAME_VERSION,
createdAt: new Date().toISOString(),
})
)
.run()
const standIn = async (accountId: number, roomInstanceId: number) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId, roomId: 4 },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 0,
platform: 0,
appVersion: GAME_VERSION,
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
})
)
.run()
const isPresent = async (accountId: number) =>
(await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE account_id = ?1')
.bind(accountId)
.first<{ n: number }>())!.n === 1
// A kick no longer deletes presence — it moves the player into their own dorm — so
// "kicked" is "no longer standing in that session", not "gone".
const inSession = async (accountId: number, roomInstanceId: number) =>
(await env.DB.prepare(
`SELECT COUNT(*) AS n FROM presence
WHERE account_id = ?1 AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
)
.bind(accountId, roomInstanceId)
.first<{ n: number }>())!.n === 1
/** The instance a player's presence says they are standing in. */
const presenceInstance = async (accountId: number) =>
JSON.parse(
(await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
.bind(accountId)
.first<{ data: string }>())!.data
).roomInstance as { roomInstanceId: number; roomInstanceType: number; isPrivate: boolean }
const isFull = async (roomInstanceId: number) =>
(await env.DB.prepare('SELECT is_full AS full FROM room_instance WHERE id = ?1')
.bind(roomInstanceId)
.first<{ full: number }>())!.full === 1
const kick = async (body: unknown, sub = '42') =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const frames = async () =>
(await (await hub().fetch('http://do/all')).json()) as Array<{
playerIds?: number[]
ephemeral?: boolean
notificationType: number
data: Record<string, unknown>
}>
test('the room’s creator kicks a player out of the session they name', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
// A full two-player instance: 205 is kicked, 206 stays.
await seedInstance(SESSION, 2, true)
await standIn(205, SESSION)
await standIn(206, SESSION)
const res = await kick({ GameSessionId: SESSION, PlayerIds: [205] })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// Out of the session — and into their own DORM, rather than having their presence
// deleted: with nothing to read the client lands not knowing where it is and loads the
// dorm a second time. 206 is untouched.
expect(await inSession(205, SESSION)).toBe(false)
expect(await isPresent(205)).toBe(true)
const dorm = await presenceInstance(205)
// The OFFLINE dorm: the sentinel instance id, not a `room_instance` row. A kicked player
// is not joining a session, and minting one per kick would put live dorm sessions on the
// books that nothing ever joins.
expect(dorm.roomInstanceId).toBe(-2)
expect(dorm.roomInstanceType).toBe(2) // RoomInstanceType.Dormroom
expect(dorm.isPrivate).toBe(true)
expect(await inSession(206, SESSION)).toBe(true)
// The instance lost a player, so it is no longer full.
expect(await isFull(SESSION)).toBe(false)
// One EPHEMERAL ModerationKick, addressed to the kicked player only. `IsBan` is
// false — this ejects them from the session and nothing more.
expect(await frames()).toEqual([
{
playerIds: [205],
ephemeral: true,
notificationType: 22, // NotificationType.ModerationKick
data: {
ReportCategory: -1, // KickReportCategory.Moderator
Duration: 0,
GameSessionId: SESSION,
IsHostKick: true,
Message: 'You have been kicked from KickRoom.',
PlayerIdReporter: 42,
IsBan: false,
IsVoiceModAutoban: false,
IsWarning: false,
VoteKickReason: '',
TimeoutStartedAt: null,
},
},
])
})
// The gate that stops a creator kicking a stranger out of somebody else's session by
// naming their account id.
test('a player who is not in that session is skipped in silence', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await seedInstance(SESSION)
await seedInstance(OTHER_SESSION)
await standIn(207, OTHER_SESSION)
// 207 stands in another instance; 208 is offline entirely.
const res = await kick({ GameSessionId: SESSION, PlayerIds: [207, 208] })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
expect(await isPresent(207)).toBe(true)
expect(await frames()).toEqual([])
})
test('a room moderator may kick; a host, a stranger and no token may not', async () => {
await seedInstance(SESSION)
await standIn(209, SESSION)
// 43 holds Moderator (20) on the room.
expect((await kick({ GameSessionId: SESSION, PlayerIds: [209] }, '43')).status).toBe(200)
expect(await inSession(209, SESSION)).toBe(false)
await standIn(209, SESSION)
// 44 is only a Host (10), and 99 holds nothing at all.
for (const sub of ['44', '99']) {
const res = await kick({ GameSessionId: SESSION, PlayerIds: [209] }, sub)
expect(res.status, sub).toBe(403)
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
}
expect(await inSession(209, SESSION)).toBe(true)
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ GameSessionId: SESSION, PlayerIds: [209] }),
})
expect(anon.status).toBe(401)
})
// Otherwise a moderator could throw the room's own creator out of it.
test('the room’s staff — and the caller — cannot be kicked', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await seedInstance(SESSION)
await standIn(42, SESSION)
await standIn(43, SESSION)
// 43 (a moderator) names the creator, a fellow moderator and themselves.
const res = await kick({ GameSessionId: SESSION, PlayerIds: [42, 43] }, '43')
expect(res.status).toBe(200)
expect(await inSession(42, SESSION)).toBe(true)
expect(await inSession(43, SESSION)).toBe(true)
expect(await frames()).toEqual([])
})
test('an unknown session 404s, and the body must name a session and players', async () => {
await seedInstance(SESSION)
const unknown = await kick({ GameSessionId: 999999, PlayerIds: [205] })
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({
success: false,
error: 'This game session does not exist!',
})
for (const [body, error] of [
[{ PlayerIds: [205] }, 'GameSessionId is required'],
[{ GameSessionId: 'nope', PlayerIds: [205] }, 'GameSessionId is required'],
[{ GameSessionId: SESSION }, 'PlayerIds is required'],
[{ GameSessionId: SESSION, PlayerIds: [] }, 'PlayerIds is required'],
[{ GameSessionId: SESSION, PlayerIds: ['205'] }, 'PlayerIds is required'],
] as Array<[unknown, string]>) {
const res = await kick(body)
expect(res.status, error).toBe(400)
expect(await res.json()).toEqual({ success: false, error })
}
// A body that isn't JSON at all is the same shape, not a crash.
const broken = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
method: 'POST',
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
body: 'not json',
})
expect(broken.status).toBe(400)
expect(await broken.json()).toEqual({ success: false, error: 'Invalid request body' })
})
})
describe('room mod kick', () => {
// A session of KickRoom (room 4, owned by 42, co-owned by 45), a second session of the
// same room, and a session of RecCenter (room 2, owned by 1) — somebody else's room.
const SESSION = 1074859
const SAME_ROOM_SESSION = 1074860
const OTHER_ROOM_SESSION = 1074861
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const seedInstance = async (
roomInstanceId: number,
roomId: number,
maxCapacity = 0,
isFull = false
) =>
env.DB.prepare('INSERT OR REPLACE INTO room_instance (data) VALUES (?1)')
.bind(
JSON.stringify({
roomInstanceId,
ownerAccountId: 42,
roomId,
subRoomId: roomId,
maxCapacity,
isFull,
gameVersion: GAME_VERSION,
createdAt: new Date().toISOString(),
})
)
.run()
const standIn = async (accountId: number, roomInstanceId: number, roomId = 4) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId, roomId },
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
})
)
.run()
// A kick no longer deletes presence — it moves the player into their own dorm — so
// "kicked" is "no longer standing in that session", not "gone".
const inSession = async (accountId: number, roomInstanceId: number) =>
(await env.DB.prepare(
`SELECT COUNT(*) AS n FROM presence
WHERE account_id = ?1 AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
)
.bind(accountId, roomInstanceId)
.first<{ n: number }>())!.n === 1
/** The instance a player's presence says they are standing in. */
const presenceInstance = async (accountId: number) =>
JSON.parse(
(await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
.bind(accountId)
.first<{ data: string }>())!.data
).roomInstance as { roomInstanceId: number; roomInstanceType: number; isPrivate: boolean }
// The client's form body.
const modKick = async (fields: Record<string, string>, sub = '42', roles?: string[]) =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
method: 'POST',
headers: {
...(await bearer(sub, roles)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
const frames = async () =>
(await (await hub().fetch('http://do/all')).json()) as Array<{
playerIds?: number[]
ephemeral?: boolean
data: Record<string, unknown>
}>
test('the room’s owner kicks a player out of the session they are in, with a reason', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
// A full one-seat instance that 205 is filling.
await seedInstance(SESSION, 4, 1, true)
await standIn(205, SESSION)
// The client's body, verbatim.
const res = await modKick({ PlayerId: '205', GameSessionId: String(SESSION), Reason: 'test' })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// Out of the session and into their own dorm — not deleted.
expect(await inSession(205, SESSION)).toBe(false)
const kickedTo = await presenceInstance(205)
expect(kickedTo.roomInstanceId).toBe(-2) // the offline dorm sentinel
expect(kickedTo.roomInstanceType).toBe(2) // Dormroom
const full = await env.DB.prepare('SELECT is_full AS f FROM room_instance WHERE id = ?1')
.bind(SESSION)
.first<{ f: number }>()
expect(full!.f).toBe(0)
expect(await frames()).toEqual([
expect.objectContaining({
playerIds: [205],
ephemeral: true,
data: expect.objectContaining({
GameSessionId: SESSION,
IsHostKick: true,
IsBan: false,
PlayerIdReporter: 42,
Message: 'You have been kicked from KickRoom. Reason: test',
}),
}),
])
})
// The whole reason for the presence check: authority over one room must never reach a
// session somewhere else.
test('a player who is not standing in that exact session is not kicked', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await seedInstance(SESSION, 4)
await seedInstance(SAME_ROOM_SESSION, 4)
await seedInstance(OTHER_ROOM_SESSION, 2)
// In another instance of the SAME room, in a different room entirely, and offline.
await standIn(206, SAME_ROOM_SESSION)
await standIn(207, OTHER_ROOM_SESSION, 2)
for (const playerId of ['206', '207', '208']) {
const res = await modKick({ PlayerId: playerId, GameSessionId: String(SESSION) }, '42')
expect(res.status, playerId).toBe(200)
expect(await res.json(), playerId).toEqual({
success: false,
error: 'That player is not in this game session!',
})
}
expect(await inSession(206, SAME_ROOM_SESSION)).toBe(true)
expect(await inSession(207, OTHER_ROOM_SESSION)).toBe(true)
// Naming the OTHER room's session instead doesn't help: 42 has no authority there.
const cross = await modKick(
{ PlayerId: '207', GameSessionId: String(OTHER_ROOM_SESSION) },
'42'
)
expect(cross.status).toBe(403)
expect(await inSession(207, OTHER_ROOM_SESSION)).toBe(true)
expect(await frames()).toEqual([])
})
test('a co-owner or a staff role may kick; a room moderator, a host and a stranger may not', async () => {
await seedInstance(SESSION, 4)
// 45 co-owns the room.
await standIn(209, SESSION)
expect(
await (await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, '45')).json()
).toEqual({ success: true, error: '' })
expect(await inSession(209, SESSION)).toBe(false)
// 999 has no role on the room but carries the staff `moderator` role — and is not the
// host, so the frame says so.
await hub().fetch('http://do/all', { method: 'DELETE' })
await standIn(209, SESSION)
const staff = await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, '999', [
'gameClient',
'moderator',
])
expect(await staff.json()).toEqual({ success: true, error: '' })
expect(await inSession(209, SESSION)).toBe(false)
expect((await frames())[0].data).toMatchObject({ IsHostKick: false, PlayerIdReporter: 999 })
// 43 is the room's Moderator (20) — enough for instantKick, not for this. 44 is a Host,
// 99 holds nothing.
await standIn(209, SESSION)
for (const sub of ['43', '44', '99']) {
const res = await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, sub)
expect(res.status, sub).toBe(403)
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
}
expect(await inSession(209, SESSION)).toBe(true)
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `PlayerId=209&GameSessionId=${SESSION}`,
})
expect(anon.status).toBe(401)
})
test('nobody kicks themselves or an owner of the room', async () => {
await seedInstance(SESSION, 4)
await standIn(42, SESSION)
await standIn(45, SESSION)
expect(
await (await modKick({ PlayerId: '42', GameSessionId: String(SESSION) }, '42')).json()
).toEqual({ success: false, error: 'You cannot kick yourself!' })
// A co-owner cannot throw out the owner, and staff cannot throw out a co-owner.
expect(
await (await modKick({ PlayerId: '42', GameSessionId: String(SESSION) }, '45')).json()
).toEqual({ success: false, error: 'You cannot kick an owner of this room!' })
expect(
await (
await modKick({ PlayerId: '45', GameSessionId: String(SESSION) }, '999', ['moderator'])
).json()
).toEqual({ success: false, error: 'You cannot kick an owner of this room!' })
expect(await inSession(42, SESSION)).toBe(true)
expect(await inSession(45, SESSION)).toBe(true)
})
test('an unknown session 404s, and the body must name a session and a player', async () => {
const unknown = await modKick({ PlayerId: '205', GameSessionId: '999999' })
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({
success: false,
error: 'This game session does not exist!',
})
for (const [fields, error] of [
[{ PlayerId: '205' }, 'GameSessionId is required'],
[{ PlayerId: '205', GameSessionId: 'nope' }, 'GameSessionId is required'],
[{ GameSessionId: String(SESSION) }, 'PlayerId is required'],
] as Array<[Record<string, string>, string]>) {
const res = await modKick(fields)
expect(res.status, error).toBe(400)
expect(await res.json()).toEqual({ success: false, error })
}
})
})
describe('vote to kick', () => {
// Two live sessions, so a vote called in one can be checked against a player in the
// other. The gate is presence alone; `room_instance` is read only to name the room in a
// carried vote's kick frame.
const SESSION = 1014079
const OTHER_SESSION = 1014080
// The tally tests get sessions of their own: ballots are appended and never pruned, so
// votes cast by the tests above would otherwise be counted into theirs.
const TALLY_SESSION = 1014081
const REVOTE_SESSION = 1014082
const TIE_SESSION = 1014083
// Every test calls votes as 42, and calling is throttled per caller — with the ballots of
// the test before still there, each call after the first would be refused with a 429, or
// land as an answer to a vote still open from earlier.
beforeEach(async () => {
await env.DB.prepare('DELETE FROM room_vote').run()
})
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const standIn = async (accountId: number, roomInstanceId: number) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId, roomId: 4 },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 0,
platform: 0,
appVersion: GAME_VERSION,
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
})
)
.run()
const isPresent = async (accountId: number) =>
(await env.DB.prepare(
"SELECT COUNT(*) AS n FROM presence WHERE json_extract(data, '$.accountId') = ?1"
)
.bind(accountId)
.first<{ n: number }>())!.n === 1
// A carried vote moves the player into their own dorm rather than deleting their presence,
// so "kicked" is "no longer standing in that session".
const inSession = async (accountId: number, roomInstanceId: number) =>
(await env.DB.prepare(
`SELECT COUNT(*) AS n FROM presence
WHERE json_extract(data, '$.accountId') = ?1
AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
)
.bind(accountId, roomInstanceId)
.first<{ n: number }>())!.n === 1
/** Every ballot cast against one player in one session, oldest first. */
const ballots = async (gameSessionId: number, playerId: number) =>
(
await env.DB.prepare(
'SELECT * FROM room_vote WHERE game_session_id = ?1 AND player_id = ?2 ORDER BY id'
)
.bind(gameSessionId, playerId)
.all<{
voter_id: number
response: number
player_id: number
voted_at: string
init: number
}>()
).results
// The body the client posts: `PlayerId=205&Response=True&Reason=…&GameSessionId=…`.
const vote = async (fields: Record<string, string>, sub = '42') =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields),
})
const frames = async () =>
(await (await hub().fetch('http://do/all')).json()) as Array<{
playerId?: number
ephemeral?: boolean
notificationType: number
data: Record<string, unknown>
}>
const FIELDS = {
PlayerId: '205',
Response: 'True',
Reason: 'Inactive in games (AFK)',
GameSessionId: String(SESSION),
}
test('the vote goes to everyone in the session except the caller', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
// 42 calls the vote, 205 is voted on, 206 is a bystander; 207 stands elsewhere.
await standIn(42, SESSION)
await standIn(205, SESSION)
await standIn(206, SESSION)
await standIn(207, OTHER_SESSION)
const res = await vote(FIELDS)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// One frame each for 205 and 206 — the player voted on gets it too (the vote is
// called in front of them), the caller does not, and 207 is in another session.
//
// `FromPlayerId` is the player being VOTED ON (205), NOT the caller (42): the client
// raises its prompt about whoever that names, so the caller there asks the room to kick
// the wrong player. `Data` is the REASON as plain text — it is what the prompt shows,
// and it used to be a JSON blob of the ids, which players saw printed on the prompt. It
// is still a string on the wire: an object fails the client's decoder
// (`expected:'String Begin Token', actual:'{'`) and takes the whole notification with it.
const message = {
ephemeral: true,
notificationType: 2, // NotificationType.MessageReceived
data: {
FromPlayerId: 205,
Type: MessageType.VoteToKick,
Data: 'Inactive in games (AFK)',
},
}
const sent = await frames()
expect(sent).toHaveLength(2)
expect(sent).toContainEqual({
...message,
playerId: 205,
data: { ...message.data, ToPlayerId: 205 },
})
expect(sent).toContainEqual({
...message,
playerId: 206,
data: { ...message.data, ToPlayerId: 206 },
})
})
test('a vote with no reason still goes out, with an empty Data', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await standIn(42, SESSION)
await standIn(205, SESSION)
await standIn(206, SESSION)
const { Reason: _reason, ...noReason } = FIELDS
expect((await vote(noReason)).status).toBe(200)
const sent = await frames()
expect(sent).toHaveLength(2)
expect(sent.every((f) => f.data.Data === '')).toBe(true)
// Still about the player voted on, reason or no reason.
expect(sent.every((f) => f.data.FromPlayerId === 205)).toBe(true)
})
// A vote is recorded whether or not it carries — the row is the record of who asked.
test('the caller’s ballot is recorded', async () => {
await standIn(42, SESSION)
await standIn(205, SESSION)
await standIn(206, SESSION)
const before = await ballots(SESSION, 205)
expect((await vote(FIELDS)).status).toBe(200)
const after = await ballots(SESSION, 205)
expect(after).toHaveLength(before.length + 1)
expect(after.at(-1)).toMatchObject({ voter_id: 42, response: 1, player_id: 205 })
expect(typeof after.at(-1)!.voted_at).toBe('string')
// `Response=False` is recorded as a no rather than dropped.
expect((await vote({ ...FIELDS, Response: 'False' }, '206')).status).toBe(200)
expect((await ballots(SESSION, 205)).at(-1)).toMatchObject({ voter_id: 206, response: 0 })
})
// The point of the tally: enough of the room says yes and the player goes, without any
// moderator being involved.
test('a majority of the room carries the vote and kicks the player', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
// Three in the session: 42 calls it on 210, 211 is the other voter.
for (const id of [42, 210, 211]) await standIn(id, TALLY_SESSION)
const fields = { ...FIELDS, PlayerId: '210', GameSessionId: String(TALLY_SESSION) }
// One yes of three is not a majority — the room is asked instead.
expect((await vote(fields)).status).toBe(200)
expect(await inSession(210, TALLY_SESSION)).toBe(true)
expect((await frames()).every((f) => f.notificationType === 2)).toBe(true)
// The second yes carries it: 2 of 3 is strictly more than half.
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await vote(fields, '211')).status).toBe(200)
// Kicked exactly as a staff kick does it — out of the session and into their own dorm,
// their presence rewritten rather than deleted...
expect(await inSession(210, TALLY_SESSION)).toBe(false)
expect(await isPresent(210)).toBe(true)
expect(await inSession(210, -2)).toBe(true) // the offline dorm sentinel
// ...and ONE ephemeral ModerationKick, no vote prompt: the question is answered.
const sent = await frames()
expect(sent).toHaveLength(1)
expect(sent[0]).toMatchObject({
playerIds: [210],
ephemeral: true,
notificationType: 22, // NotificationType.ModerationKick
data: {
ReportCategory: 10, // KickReportCategory.VoteKick — the ROOM removed them
IsHostKick: false,
IsBan: false, // nothing stops them coming back
Duration: 0,
GameSessionId: TALLY_SESSION,
PlayerIdReporter: 211, // whose ballot carried it
VoteKickReason: 'Inactive in games (AFK)',
},
})
})
// Otherwise one player could carry a vote alone by posting it repeatedly.
test('re-posting a vote cannot carry it, and a voter may change their mind', async () => {
for (const id of [42, 212, 213]) await standIn(id, REVOTE_SESSION)
const fields = { ...FIELDS, PlayerId: '212', GameSessionId: String(REVOTE_SESSION) }
// Three yes votes from one voter is still one voter.
for (let i = 0; i < 3; i++) expect((await vote(fields)).status).toBe(200)
expect(await ballots(REVOTE_SESSION, 212)).toHaveLength(3)
expect(await inSession(212, REVOTE_SESSION)).toBe(true)
// 213 says yes — 2 of 3 — and it carries.
expect((await vote(fields, '213')).status).toBe(200)
expect(await inSession(212, REVOTE_SESSION)).toBe(false)
})
test('a tie is not a majority', async () => {
// Four in the session, so two yes votes are exactly half.
for (const id of [42, 214, 215, 216]) await standIn(id, TIE_SESSION)
const fields = { ...FIELDS, PlayerId: '214', GameSessionId: String(TIE_SESSION) }
expect((await vote(fields)).status).toBe(200)
expect((await vote(fields, '215')).status).toBe(200)
expect(await inSession(214, TIE_SESSION)).toBe(true)
// The third yes is more than half, and carries.
expect((await vote(fields, '216')).status).toBe(200)
expect(await inSession(214, TIE_SESSION)).toBe(false)
})
test('both players have to be standing in the session', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await standIn(42, OTHER_SESSION)
await standIn(205, SESSION)
// The caller is somewhere else — a vote can't be called into a session you're not in.
const away = await vote(FIELDS)
expect(away.status).toBe(403)
expect(await away.json()).toEqual({
success: false,
error: 'You are not in that game session!',
})
// And with the caller present, the player voted on has to be there too — offline,
// or standing elsewhere, both refuse.
await standIn(42, SESSION)
await standIn(205, OTHER_SESSION)
const elsewhere = await vote(FIELDS)
expect(elsewhere.status).toBe(403)
expect(await elsewhere.json()).toEqual({
success: false,
error: 'That player is not in that game session!',
})
expect((await vote({ ...FIELDS, PlayerId: '208' })).status).toBe(403)
// Nothing was put to the room on any of those.
expect(await frames()).toEqual([])
})
test('the body must name a player and a session, and the call needs a token', async () => {
await standIn(42, SESSION)
await standIn(205, SESSION)
for (const [fields, error] of [
[{ Response: 'True', GameSessionId: String(SESSION) }, 'PlayerId is required'],
[{ PlayerId: 'nope', GameSessionId: String(SESSION) }, 'PlayerId is required'],
[{ PlayerId: '205', Response: 'True' }, 'GameSessionId is required'],
[{ PlayerId: '205', GameSessionId: 'nope' }, 'GameSessionId is required'],
] as Array<[Record<string, string>, string]>) {
const res = await vote(fields)
expect(res.status, error).toBe(400)
expect(await res.json()).toEqual({ success: false, error })
}
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(FIELDS),
})
expect(anon.status).toBe(401)
})
// A vote called with nobody else there is a no-op rather than an error: the caller and
// the player voted on are both here, so the gate passes, and there is simply no room
// to put it to.
test('a session holding only the two of them sends nothing', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await standIn(42, SESSION)
await standIn(205, SESSION)
await env.DB.prepare('DELETE FROM presence WHERE account_id NOT IN (42, 205)').run()
const res = await vote(FIELDS)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// 205 is still in the session, so they still hear it — only the caller is dropped.
expect((await frames()).map((f) => f.playerId)).toEqual([205])
})
// The client posts the call and every answer alike; only the call raises the prompt.
test('the first ballot calls the vote, and answers to it put nothing to the room', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
for (const id of [42, 205, 206, 207, 208]) await standIn(id, SESSION)
expect((await vote(FIELDS)).status).toBe(200)
expect(await frames()).toHaveLength(4)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await vote({ ...FIELDS, Response: 'False' }, '206')).status).toBe(200)
expect((await vote(FIELDS, '207')).status).toBe(200)
expect(await frames()).toEqual([])
expect((await ballots(SESSION, 205)).map((b) => [b.voter_id, b.init])).toEqual([
[42, 1],
[206, 0],
[207, 0],
])
})
test('a player may not call vote after vote', async () => {
for (const id of [42, 205, 206, 207, 208]) await standIn(id, SESSION)
expect((await vote(FIELDS)).status).toBe(200)
// Another vote, on another player — still a CALL, so still refused.
const again = await vote({ ...FIELDS, PlayerId: '206' })
expect(again.status).toBe(429)
expect(await again.json()).toEqual({
success: false,
error: 'You are calling votes too quickly!',
})
// Answering someone else's vote is never throttled...
expect((await vote({ ...FIELDS, PlayerId: '206' }, '207')).status).toBe(200)
expect((await vote({ ...FIELDS, PlayerId: '206' })).status).toBe(200)
// ...and neither is re-voting in their own.
expect((await vote(FIELDS)).status).toBe(200)
// Once the cooldown has passed, they may call again.
await env.DB.prepare(
"UPDATE room_vote SET voted_at = '2000-01-01T00:00:00.000Z' WHERE voter_id = 42 AND init = 1"
).run()
expect((await vote({ ...FIELDS, PlayerId: '208' })).status).toBe(200)
})
test('a "no" cannot call a vote', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await standIn(42, SESSION)
await standIn(205, SESSION)
await standIn(206, SESSION)
const res = await vote({ ...FIELDS, Response: 'False' })
expect(res.status).toBe(409)
expect(await res.json()).toEqual({ success: false, error: 'There is no vote in progress!' })
expect(await ballots(SESSION, 205)).toEqual([])
expect(await frames()).toEqual([])
})
// Otherwise yes ballots from a vote that failed would carry the next one on the same player.
test('an expired vote’s ballots do not count toward the next', async () => {
for (const id of [42, 217, 218, 219]) await standIn(id, TALLY_SESSION)
const fields = { ...FIELDS, PlayerId: '217', GameSessionId: String(TALLY_SESSION) }
// 42 calls it: one yes of four. Then the vote expires.
expect((await vote(fields)).status).toBe(200)
await env.DB.prepare("UPDATE room_vote SET voted_at = '2000-01-01T00:00:00.000Z'").run()
// 218 calls a fresh one — counting 42's old yes, that would be two of four... and 219's
// yes would make three and carry it. Without it, 219 makes two of four: a tie.
expect((await vote(fields, '218')).status).toBe(200)
expect((await vote(fields, '219')).status).toBe(200)
expect(await inSession(217, TALLY_SESSION)).toBe(true)
expect((await ballots(TALLY_SESSION, 217)).map((b) => b.init)).toEqual([1, 1, 0])
})
})
describe('player reports', () => {
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
@@ -5734,398 +2157,6 @@ describe('player reports', () => {
test('banFromReport returns null for an unknown report', async () => {
expect(await banFromReport(env.DB, 999_999)).toBeNull()
})
// The audit trail 0020 added: who handed the ban down, and when. `banned_at` is not
// bookkeeping — it is what the client's block screen counts the ban from (see
// `banBlockDetails`), which is why a lift has to clear it along with the flag.
test('banFromReport records the acting moderator and the instant it landed', async () => {
await submit({ PlayerIdReported: '216' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 216)
// An unactioned report carries neither.
expect(row).toMatchObject({ banned_by_player_id: null, banned_at: null })
const before = Date.now()
const banned = await banFromReport(env.DB, row!.id, { bannedBy: 9001 })
expect(banned!.banned_by_player_id).toBe(9001)
expect(Date.parse(banned!.banned_at!)).toBeGreaterThanOrEqual(before)
// Stamped by the write, so it is the ban's own time rather than the report's.
expect(banned!.banned_at).not.toBe(row!.created_at)
// A lift clears all four together. Leaving `banned_at` behind on an unbanned row
// would read as a ban to anything checking the timestamp.
const lifted = await banFromReport(env.DB, row!.id, { banned: false })
expect(lifted).toMatchObject({
banned: 0,
ban_expires: null,
banned_by_player_id: null,
banned_at: null,
})
})
// The moderation READS — searchReports / getTopReported / getBansInForce. No endpoint
// on this worker calls them: they back the staff panel `www` serves (`/api/staff/*`),
// which imports them, because the panel is a recflare addition with no counterpart in
// the real service while the SQL belongs with the table. Tested here, where the table
// and its migrations live.
describe('moderation reads', () => {
test('searchReports filters, orders newest-first and counts the full match', async () => {
const target = 3300
await createReport(env.DB, {
reporterPlayerId: 3301,
reportedPlayerId: target,
reportCategory: 101,
details: 'one',
})
await createReport(env.DB, {
reporterPlayerId: 3302,
reportedPlayerId: target,
reportCategory: 102,
details: 'two',
})
await createReport(env.DB, { reporterPlayerId: 3301, reportedPlayerId: 3399 })
const byPlayer = await searchReports(env.DB, { reportedPlayerId: target })
expect(byPlayer.total).toBe(2)
// `ORDER BY id DESC` — the id is AUTOINCREMENT, so it is already chronological
// and is the primary key, where `created_at` is unindexed.
expect(byPlayer.reports.map((r) => r.details)).toEqual(['two', 'one'])
expect((await searchReports(env.DB, { reporterPlayerId: 3302 })).total).toBe(1)
expect(
(await searchReports(env.DB, { reportedPlayerId: target, reportCategory: 101 })).total
).toBe(1)
// `take` pages the ROWS; `total` stays the unpaged count of the same predicate, so
// a pager can say "3–4 of 9" without asking twice.
const page = await searchReports(env.DB, { reportedPlayerId: target }, { take: 1 })
expect(page.reports).toHaveLength(1)
expect(page.total).toBe(2)
})
test('searchReports filters on the ban FLAG, expired bans included', async () => {
const report = await createReport(env.DB, {
reporterPlayerId: 3311,
reportedPlayerId: 3310,
})
// Banned, and the ban has since run out — so it is not in force, but it IS a
// report that was actioned, which is what a moderator reviewing their own work
// is looking for.
await banFromReport(env.DB, report.id, { banExpires: '2020-01-01T00:00:00.000Z' })
expect((await searchReports(env.DB, { reportedPlayerId: 3310, banned: true })).total).toBe(1)
expect((await searchReports(env.DB, { reportedPlayerId: 3310, banned: false })).total).toBe(0)
// An omitted filter is BOTH, not false.
expect((await searchReports(env.DB, { reportedPlayerId: 3310 })).total).toBe(1)
})
test('searchReports windows on created_at, with an exclusive upper bound', async () => {
const report = await createReport(env.DB, {
reporterPlayerId: 3321,
reportedPlayerId: 3320,
})
const filed = '2024-06-15T12:00:00.000Z'
await env.DB.prepare('UPDATE report SET created_at = ?2 WHERE id = ?1')
.bind(report.id, filed)
.run()
const inWindow = await searchReports(env.DB, {
reportedPlayerId: 3320,
from: '2024-06-01T00:00:00.000Z',
to: '2024-07-01T00:00:00.000Z',
})
expect(inWindow.total).toBe(1)
// `from` is inclusive, `to` exclusive — which is what makes day-boundary windows
// composable rather than double-counting the row on the seam.
expect((await searchReports(env.DB, { reportedPlayerId: 3320, from: filed })).total).toBe(1)
expect((await searchReports(env.DB, { reportedPlayerId: 3320, to: filed })).total).toBe(0)
})
test('getTopReported ranks distinct reporters ahead of raw count', async () => {
// Four reports from one person, against one player…
for (let i = 0; i < 4; i++) {
await createReport(env.DB, { reporterPlayerId: 3331, reportedPlayerId: 3330 })
}
// …and three reports from three different people, against another.
for (const reporter of [3341, 3342, 3343]) {
await createReport(env.DB, { reporterPlayerId: reporter, reportedPlayerId: 3340 })
}
const rows = await getTopReported(env.DB, { minReports: 3 })
const feud = rows.find((r) => r.playerId === 3330)
const real = rows.find((r) => r.playerId === 3340)
expect(feud).toMatchObject({ reports: 4, distinctReporters: 1 })
expect(real).toMatchObject({ reports: 3, distinctReporters: 3 })
// Fewer reports, but from more people — so ahead. One player filing twenty
// reports about someone they are feuding with must not top the queue.
expect(rows.indexOf(real!)).toBeLessThan(rows.indexOf(feud!))
})
test('getTopReported windows, thresholds, and flags a ban in force', async () => {
for (const reporter of [3351, 3352, 3353]) {
await createReport(env.DB, { reporterPlayerId: reporter, reportedPlayerId: 3350 })
}
// Backdate them all a year, so only an all-time read sees them. The default
// window is 30 days precisely so the queue is who is a problem NOW — an account
// that collected reports last year and stopped would otherwise sit at the top
// forever.
await env.DB.prepare('UPDATE report SET created_at = ?1 WHERE reported_player_id = 3350')
.bind(new Date(Date.now() - 365 * 86_400_000).toISOString())
.run()
expect(
(await getTopReported(env.DB, { minReports: 1 })).map((r) => r.playerId)
).not.toContain(3350)
const allTime = await getTopReported(env.DB, { sinceDays: null, minReports: 1 })
expect(allTime.map((r) => r.playerId)).toContain(3350)
expect(allTime.find((r) => r.playerId === 3350)?.bannedNow).toBe(false)
// A ban in force is decided in the same statement, so the queue can skip the
// ones already dealt with.
const [row] = await getReportsAgainst(env.DB, 3350)
await banFromReport(env.DB, row!.id)
const banned = await getTopReported(env.DB, { sinceDays: null, minReports: 1 })
expect(banned.find((r) => r.playerId === 3350)?.bannedNow).toBe(true)
})
// The standing-bans list exists to catch MISTAKES, so it is ordered by when the ban
// landed: the one most likely to be wrong is the one just handed down. Ordering by
// severity instead would bury a fresh one-day ban under every permanent ban ever
// issued.
test('getBansInForce puts the most recently handed-down ban first', async () => {
const first = await createReport(env.DB, { reporterPlayerId: 3371, reportedPlayerId: 3370 })
const second = await createReport(env.DB, { reporterPlayerId: 3373, reportedPlayerId: 3372 })
const third = await createReport(env.DB, { reporterPlayerId: 3375, reportedPlayerId: 3374 })
// Handed down oldest-first, and deliberately mixed in severity: the permanent one
// is banned FIRST, so an order by "longest lasting" would float it to the top.
await banFromReport(env.DB, first.id)
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
.bind(first.id, '2024-01-01T00:00:00.000Z')
.run()
await banFromReport(env.DB, second.id, { banExpires: '2999-01-01T00:00:00.000Z' })
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
.bind(second.id, '2024-06-01T00:00:00.000Z')
.run()
await banFromReport(env.DB, third.id, { banExpires: '2999-01-01T00:00:00.000Z' })
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
.bind(third.id, '2025-01-01T00:00:00.000Z')
.run()
const ordered = (await getBansInForce(env.DB))
.map((b) => b.id)
.filter((id) => [first.id, second.id, third.id].includes(id))
expect(ordered).toEqual([third.id, second.id, first.id])
})
// A ban set before 0020 added `banned_at` has only its report's date to sort by —
// the same fallback the block screen uses. Without the coalesce every such row would
// sort as NULL: all at one end, in no order at all.
test('getBansInForce falls back to created_at for a ban with no banned_at', async () => {
const old = await createReport(env.DB, { reporterPlayerId: 3381, reportedPlayerId: 3380 })
const recent = await createReport(env.DB, { reporterPlayerId: 3383, reportedPlayerId: 3382 })
// The pre-migration shape: banned, with no record of when.
await env.DB.prepare(
'UPDATE report SET banned = 1, banned_at = NULL, created_at = ?2 WHERE id = ?1'
)
.bind(old.id, '2023-01-01T00:00:00.000Z')
.run()
await banFromReport(env.DB, recent.id)
const ordered = (await getBansInForce(env.DB))
.map((b) => b.id)
.filter((id) => [old.id, recent.id].includes(id))
// The undated one sorts by its report's date, so it lands behind today's ban
// rather than at an arbitrary end of the list.
expect(ordered).toEqual([recent.id, old.id])
})
test('getBansInForce lists in-force bans only, and getReportById reads one row', async () => {
const permanent = await createReport(env.DB, {
reporterPlayerId: 3361,
reportedPlayerId: 3360,
})
const expired = await createReport(env.DB, {
reporterPlayerId: 3363,
reportedPlayerId: 3362,
})
await banFromReport(env.DB, permanent.id, { bannedBy: 9002 })
await banFromReport(env.DB, expired.id, { banExpires: '2020-01-01T00:00:00.000Z' })
const ids = (await getBansInForce(env.DB)).map((b) => b.id)
expect(ids).toContain(permanent.id)
// A ban that has served its time is not in force, though the row stays as the
// record that it happened — the same rule `getActiveBan` applies.
expect(ids).not.toContain(expired.id)
const one = await getReportById(env.DB, permanent.id)
expect(one).toMatchObject({ id: permanent.id, banned: 1, banned_by_player_id: 9002 })
expect(await getReportById(env.DB, 999_999)).toBeNull()
})
})
// What the banned player is TOLD. The block screen reads this; it's the same row
// matchmake and login refuse on, described rather than merely enforced.
describe('moderationBlockDetails', () => {
// All sixteen keys the 2025 client's decoder names, every one at its "none" value.
const NOT_BLOCKED = {
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsHostKick: false,
Message: null,
PlayerIdReporter: null,
IsBan: false,
IsVoiceModAutoban: false,
IsDeviceBan: false,
IsWarning: false,
VoteKickReason: null,
TimeoutStartedAt: null,
AssociatedAccountUsername: null,
ShowCreatorCodeOfConduct: false,
TopMessageOverride: null,
BottomMessageOverride: null,
}
const details = async (method: string, sub: string) => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
{ method, headers: await bearer(sub) }
)
expect(res.status).toBe(200)
return res.json()
}
// The client POSTs this with no body, despite it being a pure read; the route
// answers GET as well, and both methods serve the same body.
test.each(['GET', 'POST'])(
'%s reports "not blocked" for an unbanned player',
async (method) => {
// A report against them that nobody acted on is not a block.
await submit({ PlayerIdReported: '220' }, await bearer())
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
// reference stub's empty string — the client tells "no message" from a blank one.
expect(await details(method, '220')).toEqual(NOT_BLOCKED)
}
)
test('401s without a bearer token', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
)
expect(res.status).toBe(401)
})
// Duration and TimeoutStartedAt are a pair in the client — the block runs from the
// start for the duration. The start is `banned_at`, the instant the ban was handed
// down (see 0020_report_ban_audit.sql), NOT the report's created_at: the two can be
// months apart. A permanent ban runs for the largest span the int holds.
test('describes a permanent ban', async () => {
await submit(
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
await bearer()
)
const [row] = await getReportsAgainst(env.DB, 221)
const banned = await banFromReport(env.DB, row!.id)
expect(await details('POST', '221')).toEqual({
...NOT_BLOCKED,
ReportCategory: 102,
Duration: 2_147_483_647,
IsBan: true,
// A fixed message — the report's `details` are the reporter's words, and
// the reporter is not shown to the player they reported (PlayerIdReporter
// stays null).
Message: 'Rule violation',
TimeoutStartedAt: banned!.banned_at,
})
})
// A timed ban's Duration is the seconds from the start to the expiry, so the pair
// sums to `ban_expires` — not the seconds left as of the request.
test('describes a timed ban as its banned_at plus the span to expiry', async () => {
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 222)
// The expiry is set relative to NOW, which is what a moderator handing down a
// one-hour ban means — and now is `banned_at`, not the report's created_at.
const banExpires = new Date(Date.now() + 3600 * 1000)
const banned = await banFromReport(env.DB, row!.id, {
banExpires: banExpires.toISOString(),
})
// `Duration` is asserted as a range below, so it is left out of the object match
// — NOT_BLOCKED carries a 0 for it, which would win over the real value.
const { Duration: _duration, ...unblocked } = NOT_BLOCKED
const body = (await details('GET', '222')) as Record<string, unknown>
expect(body).toMatchObject({
...unblocked,
ReportCategory: 103,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: banned!.banned_at,
})
// Within a second of the hour: `banned_at` is stamped by the write, so the span
// is the hour minus however long the write took.
expect(body.Duration).toBeGreaterThan(3595)
expect(body.Duration).toBeLessThanOrEqual(3600)
})
// The whole point of `banned_at`: a ban applied to an OLD report used to be
// described as having started when the report was filed, so a 7-day ban on a
// month-old report told the player their block began a month ago — and the duration,
// measured from there, came out as already served. Both halves of the pair now
// start from the ban.
test('counts a ban from when it was handed down, not from when the report was filed', async () => {
await submit({ PlayerIdReported: '224', ReportCategory: '102' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 224)
// Backdate the report a month, as if it had sat in the queue.
const filed = new Date(Date.now() - 30 * 86_400_000).toISOString()
await env.DB.prepare('UPDATE report SET created_at = ?2 WHERE id = ?1')
.bind(row!.id, filed)
.run()
const banned = await banFromReport(env.DB, row!.id, {
banExpires: new Date(Date.now() + 7 * 86_400_000).toISOString(),
})
const body = (await details('GET', '224')) as Record<string, unknown>
expect(body.TimeoutStartedAt).toBe(banned!.banned_at)
expect(body.TimeoutStartedAt).not.toBe(filed)
// Seven days, not the negative-then-clamped span the report's date would give.
expect(body.Duration).toBeGreaterThan(7 * 86_400 - 5)
expect(body.Duration).toBeLessThanOrEqual(7 * 86_400)
})
// A row banned BEFORE 0020 added the column carries no `banned_at`, and must keep
// reading exactly as it used to rather than falling back to "now" — which would
// silently restart every standing ban the moment this shipped.
test('falls back to created_at for a ban with no recorded banned_at', async () => {
await submit({ PlayerIdReported: '225', ReportCategory: '101' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 225)
await env.DB.prepare(
`UPDATE report SET banned = 1, ban_expires = ?2, banned_at = NULL WHERE id = ?1`
)
.bind(row!.id, new Date(Date.parse(row!.created_at) + 3600 * 1000).toISOString())
.run()
expect(await details('GET', '225')).toEqual({
...NOT_BLOCKED,
ReportCategory: 101,
Duration: 3600,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: row!.created_at,
})
})
// A ban that has served its time is not a block, even though the row still says
// `banned = 1` — the same rule `getActiveBan` applies for matchmake and login.
test('reports "not blocked" once a ban has expired', async () => {
await submit({ PlayerIdReported: '223' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 223)
await banFromReport(env.DB, row!.id, { banExpires: '2020-01-01T00:00:00.000Z' })
expect(await details('GET', '223')).toEqual(NOT_BLOCKED)
})
})
})
describe('player warnings', () => {
@@ -6776,26 +2807,6 @@ describe('images', () => {
expect(res.status).toBe(400)
})
test('POST /api/images/v4/uploadsaved rejects an oversized image before storing it', async () => {
const previous = env.RECFLARE_MAX_API_UPLOAD_BYTES
env.RECFLARE_MAX_API_UPLOAD_BYTES = '3'
try {
const objectsBefore = (await env.IMAGES.list()).objects.length
const fd = new FormData()
fd.append('image', new File([new Uint8Array(4)], 'large.png', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer('42'),
body: fd,
})
expect(res.status).toBe(413)
expect(await res.json()).toEqual({ error: 'image exceeds the 3-byte upload limit' })
expect((await env.IMAGES.list()).objects).toHaveLength(objectsBefore)
} finally {
env.RECFLARE_MAX_API_UPLOAD_BYTES = previous
}
})
test('GET /api/images/v4/room/:id returns a public room feed, filtered/sorted/paginated', async () => {
// Seed images in room 54: two public (one with more cheers, of different
// types), one private (hidden), and one in another room (excluded).
@@ -6887,10 +2898,6 @@ describe('images', () => {
}),
// Unrelated to 700 → in neither.
seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }),
// 700's outfit thumbnail: an image under their id, but not a photo → in neither,
// not even in their own view (207 is private, as the client stores such things).
seed({ Id: 200, PlayerId: 700, Type: 2, CreatedAt: '2026-06-01T00:00:00.000Z' }),
seed({ Id: 207, PlayerId: 700, Type: 3, Accessibility: 0 }),
])
// The lists serve the client's ImagesPlayer projection: the id and type are
@@ -6942,44 +2949,6 @@ describe('images', () => {
expect(
await (await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/424242`)).json()
).toEqual([])
// The owner's view: 700 asking for their own list gets the private 203 too, on v4,
// v5 and the feed alike. 203 has the seed's default CreatedAt (January), so it sorts
// last — the private photo takes its place in the order, it isn't pinned anywhere.
const own = await bearer('700')
for (const path of ['v4/player/700', 'v5/player/700?sort=0']) {
const list = (await (
await exports.default.fetch(`${ORIGIN}/api/images/${path}`, { headers: own })
).json()) as Array<ImagesPlayer & { Accessibility: number }>
expect(list.map((i) => i.SavedImageId)).toEqual([202, 201, 203])
expect(list[2]?.Accessibility).toBe(0)
}
const ownFeed = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700`, { headers: own })
).json()) as ImagesPlayer[]
expect(ownFeed.map((i) => i.SavedImageId)).toEqual([204, 202, 201, 203])
// Being tagged in someone else's private photo does NOT surface it in the feed —
// the taker decides its privacy, not the tag.
await seed({ Id: 206, PlayerId: 999, TaggedPlayerIds: [700], Accessibility: 0 }).run()
const ownFeedAgain = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700`, { headers: own })
).json()) as ImagesPlayer[]
expect(ownFeedAgain.map((i) => i.SavedImageId)).not.toContain(206)
// Anyone else's token — a friend, staff — is the public list, same as no token.
const other = await bearer('999', ['developer', 'moderator'])
const asOther = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`, { headers: other })
).json()) as ImagesPlayer[]
expect(asOther.map((i) => i.SavedImageId)).toEqual([202, 201])
// A garbage token isn't a 401 on a public list; it reads as nobody.
const junk = await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`, {
headers: { Authorization: 'Bearer not.a.jwt' },
})
expect(junk.status).toBe(200)
expect(((await junk.json()) as ImagesPlayer[]).map((i) => i.SavedImageId)).toEqual([202, 201])
})
})
@@ -7243,11 +3212,8 @@ describe('relationships', () => {
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). The target ALSO gets the
// request itself as a FriendInvite (4) message — that is what the client shows.
const sent = await sentNotifications()
expect(sent).toHaveLength(3)
expect(sent.slice(0, 2)).toEqual([
// type: the sender Sent (1), the recipient Received (2).
expect(await sentNotifications()).toEqual([
{
playerId: 770,
notificationType: 1,
@@ -7259,34 +3225,6 @@ describe('relationships', () => {
data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 },
},
])
expect(sent[2]?.playerId).toBe(771)
expect(sent[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
expect(sent[2]?.data).toMatchObject({ FromPlayerId: 770, ToPlayerId: 771, Type: 4, Data: null })
// The message is stored under the same id, so an offline target finds it on login.
const frameId = (sent[2]?.data as unknown as { Id: number }).Id
expect(frameId).toBeGreaterThan(0)
const inbox = (await (
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, { headers: await bearer('771') })
).json()) as Array<{ Id: number; FromPlayerId: number; Type: number }>
expect(inbox.find((m) => m.Id === frameId)).toMatchObject({ FromPlayerId: 770, Type: 4 })
})
test('a re-sent request says nothing; a crossing request tells the requester it was accepted', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '772', 773)
await resetNotifications()
// Re-sending an outstanding request is a no-op: nothing is pushed at all.
await mutate('/api/relationships/v2/sendfriendrequest', '772', 773)
expect(await sentNotifications()).toEqual([])
// The crossing request auto-accepts: both hear Friend, and the ORIGINAL requester
// (772) is told their request was accepted rather than asked anything.
await mutate('/api/relationships/v2/sendfriendrequest', '773', 772)
const sent = await sentNotifications()
expect(sent).toHaveLength(3)
expect(sent.slice(0, 2).every((n) => n.notificationType === 1)).toBe(true)
expect(sent[2]?.playerId).toBe(772)
expect(sent[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
expect(sent[2]?.data).toMatchObject({ FromPlayerId: 773, ToPlayerId: 772, Type: 40 })
})
test('accepting notifies both players as Friend', async () => {
@@ -7294,13 +3232,8 @@ describe('relationships', () => {
await resetNotifications()
await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780)
const all = await sentNotifications()
expect(all).toHaveLength(3)
// The requester also gets the acceptance as a FriendRequestAccepted (40) message.
expect(all[2]?.playerId).toBe(780)
expect(all[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
expect(all[2]?.data).toMatchObject({ FromPlayerId: 781, ToPlayerId: 780, Type: 40, Data: null })
const sent = all.slice(0, 2)
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([
@@ -7347,10 +3280,8 @@ describe('relationships', () => {
await mutate('/api/relationships/v2/sendfriendrequest', '821', 820)
const sent = await sentNotifications()
expect(sent).toHaveLength(3)
for (const n of sent.slice(0, 2)) expect(n.data.RelationshipType).toBe(3)
// ...and 820, whose request this crossed, is told it was accepted.
expect(sent[2]).toMatchObject({ playerId: 820, notificationType: 2, data: { Type: 40 } })
expect(sent).toHaveLength(2)
for (const n of sent) expect(n.data.RelationshipType).toBe(3)
})
})
@@ -7448,17 +3379,7 @@ describe('messages', () => {
type Sent = {
playerId: number
notificationType: number
// The frame carries the STORED message, so `Id` and `SentTime` come off the row.
data: {
Id: number
FromPlayerId: number
ToPlayerId: number
SentTime: string
Type: number
Data: string | null
RoomId: number | null
PlayerEventId: number | null
}
data: { FromPlayerId: number; ToPlayerId: number; Type: number; Data: string }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const pushed = async (): Promise<Sent[]> =>
@@ -7476,60 +3397,26 @@ describe('messages', () => {
// NotificationType.MessageReceived — the same frame the Coach broadcast uses.
const MESSAGE_RECEIVED = 2
/** One player's inbox, as the client reads it on login. */
const inboxOf = async (playerId: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, {
headers: await bearer(playerId),
})
).json()) as Array<{
Id: number
FromPlayerId: number
ToPlayerId: number
SentTime: string
Type: number
Data: string | null
}>
const del = async (body: unknown, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/messages/v3/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
})
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: '' })
const sent = await pushed()
expect(sent).toHaveLength(1)
// Delivered to the recipient, not the sender.
expect(sent[0]?.playerId).toBe(2)
expect(sent[0]?.notificationType).toBe(MESSAGE_RECEIVED)
// FromPlayerId is the token's subject, not a body field. The frame carries the
// STORED message, so it has the row's Id and SentTime on it too.
expect(sent[0]?.data).toMatchObject({
FromPlayerId: 42,
ToPlayerId: 2,
Type: 10,
Data: '',
RoomId: null,
PlayerEventId: null,
})
const frameId = sent[0]!.data.Id
expect(frameId).toBeGreaterThan(0)
// ...and the recipient reads the same message, under the same id, from their inbox.
const inbox = await inboxOf('2')
expect(inbox[0]).toMatchObject({ Id: frameId, FromPlayerId: 42, Type: 10, Data: '' })
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).toMatchObject({
expect((await pushed())[0]?.data).toEqual({
FromPlayerId: 42,
ToPlayerId: 2,
Type: 0,
@@ -7569,17 +3456,18 @@ describe('messages', () => {
expect(await res.json()).toEqual({ success: true, error: '' })
// Each frame is addressed to its own recipient; the sender is the token's subject.
const sent = await pushed()
expect(sent.map((n) => n.playerId)).toEqual([205, 206])
expect(sent.map((n) => n.notificationType)).toEqual([MESSAGE_RECEIVED, MESSAGE_RECEIVED])
expect(sent[0]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 205, Type: 20, Data: 'hi' })
expect(sent[1]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 206, Type: 20, Data: 'hi' })
// One STORED message each, with ids of their own — not one message shared.
const ids = sent.map((n) => n.data.Id)
expect(new Set(ids).size).toBe(2)
expect((await inboxOf('205'))[0]).toMatchObject({ Id: ids[0], Data: 'hi' })
expect((await inboxOf('206'))[0]).toMatchObject({ Id: ids[1], Data: 'hi' })
expect(await pushed()).toEqual([
{
playerId: 205,
notificationType: MESSAGE_RECEIVED,
data: { FromPlayerId: 42, ToPlayerId: 205, Type: 20, Data: 'hi' },
},
{
playerId: 206,
notificationType: MESSAGE_RECEIVED,
data: { FromPlayerId: 42, ToPlayerId: 206, Type: 20, Data: 'hi' },
},
])
})
test('POST /api/messages/v1/sendMultiple defaults Type and Data, and de-duplicates ids', async () => {
@@ -7588,7 +3476,7 @@ describe('messages', () => {
const sent = await pushed()
expect(sent).toHaveLength(1)
expect(sent[0]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' })
expect(sent[0]?.data).toEqual({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' })
})
test('POST /api/messages/v1/sendMultiple 400s with no usable recipient, pushing nothing', async () => {
@@ -7606,66 +3494,19 @@ describe('messages', () => {
expect(await pushed()).toEqual([])
})
test('GET /api/messages/v2/get serves the caller’s inbox, newest first', async () => {
// Two messages to 9600, one to somebody else — an inbox is the caller's own.
await send({ ToPlayerId: '9600', Data: 'first' }, await bearer('42'))
await send({ ToPlayerId: '9600', Data: 'second' }, await bearer('43'))
await send({ ToPlayerId: '9601', Data: 'not yours' }, await bearer('42'))
const inbox = await inboxOf('9600')
expect(inbox.map((m) => m.Data)).toEqual(['second', 'first'])
expect(inbox[0]).toMatchObject({ FromPlayerId: 43, ToPlayerId: 9600, Type: 0 })
// Every message carries the full Message model, ids and all.
expect(Date.parse(inbox[0].SentTime)).not.toBeNaN()
expect(inbox.every((m) => m.Id > 0)).toBe(true)
// The other player's message is not in it.
expect(inbox.some((m) => m.Data === 'not yours')).toBe(false)
})
test('GET /api/messages/v2/get is auth-gated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`)
expect(res.status).toBe(401)
})
test('POST /api/messages/v3/delete removes only the caller’s messages', async () => {
await send({ ToPlayerId: '9610', Data: 'keep' }, await bearer('42'))
await send({ ToPlayerId: '9610', Data: 'drop' }, await bearer('42'))
await send({ ToPlayerId: '9611', Data: 'someone else' }, await bearer('42'))
const before = await inboxOf('9610')
const drop = before.find((m) => m.Data === 'drop')!
const theirs = (await inboxOf('9611'))[0]
// The caller deletes their own message, and takes a swing at another player's id
// in the same call — an id names a message globally, so this must not reach it.
const res = await del({ MessageIds: [drop.Id, theirs.Id] }, await bearer('9610'))
expect(res.status).toBe(200)
expect(await res.text()).toBe('')
expect((await inboxOf('9610')).map((m) => m.Data)).toEqual(['keep'])
expect((await inboxOf('9611')).map((m) => m.Data)).toEqual(['someone else'])
})
test('POST /api/messages/v3/delete takes a bare array, and shrugs off the rest', async () => {
await send({ ToPlayerId: '9620', Data: 'bare' }, await bearer('42'))
const message = (await inboxOf('9620'))[0]
// The client posts a bare array; the documented `{ MessageIds }` object works too.
expect((await del([message.Id], await bearer('9620'))).status).toBe(200)
expect(await inboxOf('9620')).toEqual([])
// An unknown id, an empty list and a missing body are all an empty 200 — the client
// removes its rows locally and re-reads the list either way.
for (const body of [{ MessageIds: [1787377235629] }, { MessageIds: [] }, {}, []]) {
const res = await del(body, await bearer('9620'))
test('POST /api/messages/v3/delete accepts anything with an empty 200', async () => {
// No message store, so no id can be real and nothing is gated — an unknown id, an
// empty list and a missing body all land the same way.
for (const body of [{ MessageIds: [1787377235629] }, { MessageIds: [] }, {}]) {
const res = await exports.default.fetch(`${ORIGIN}/api/messages/v3/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
expect(res.status).toBe(200)
expect(await res.text()).toBe('')
}
})
test('POST /api/messages/v3/delete is auth-gated', async () => {
expect((await del({ MessageIds: [1] })).status).toBe(401)
})
})
describe('mutual friends', () => {
@@ -7806,16 +3647,6 @@ describe('player events', () => {
return { ...rest, ImageName: imageName, State: 0 }
}
/**
* The client's BASE event behind an envelope's event — what the browse feed, the room
* shelf and the bulk read all serve. The envelope minus `Tags`, plus a null
* `BroadcastingRoomInstanceId`; `ImageName` is already `""` on the envelope.
*/
const asBase = (event: PlayerEventEnvelope): Record<string, unknown> => {
const { Tags: _tags, ...rest } = event
return { ...rest, BroadcastingRoomInstanceId: null }
}
// 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: PlayerEventEnvelope
@@ -8041,10 +3872,10 @@ describe('player events', () => {
expect(body.Result).toBe(0)
expect(body.PlayerEvent.Name).toBe('Enveloped')
// The tags ride inline on the event AND in TagModifyResult. Inline they take the
// caller's build shape — this token names no build, so the 2023 bare names (a newer
// build gets the PascalCase `{ Tag, Type }` pairs). TagModifyResult is names to every
// build.
expect(body.PlayerEvent.Tags).toEqual(['music'])
// caller's build shape — this token names no build, so the 2023 `{ Tag, Type }` pairs
// (PascalCase: not the lowercase pairs the v1 read's `tags` serves). TagModifyResult
// is names to every build.
expect(body.PlayerEvent.Tags).toEqual([{ Tag: 'music', Type: 0 }])
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['music'] })
// No `State`, and the broadcast instance is present and null.
expect(body.PlayerEvent).not.toHaveProperty('State')
@@ -8053,7 +3884,7 @@ describe('player events', () => {
test('the v2 envelope shapes PlayerEvent.Tags per the caller’s build', async () => {
// Rec Room reshaped this field without minting a new path, so one endpoint owes two
// shapes: the 2023 build parses bare names, anything newer `{ Tag, Type }` pairs.
// shapes: the 2023 build parses `{ Tag, Type }` pairs, the 2025 build bare names.
// Serving either to the wrong build empties the event's chips instead of erroring.
// A tag of this test's own: the `#tag` search tests assert exact result sets, and the
// four events below would join any set they share a tag with.
@@ -8073,18 +3904,16 @@ describe('player events', () => {
return (await res.json()) as PlayerEventResult
}
// Newer than 20230414 gets the pairs; that build, an older one, and a token naming
// no build at all are all the 2023 client and get names. Builds are date-stamped, so
// they compare as strings.
expect((await created('20250718.01')).PlayerEvent.Tags).toEqual(PAIRS)
expect((await created('20230414')).PlayerEvent.Tags).toEqual(NAMES)
expect((await created('20220101')).PlayerEvent.Tags).toEqual(NAMES)
// Newer than 20230414 is the 2025 client; that build, an older one, and a token naming
// no build at all are all the 2023 client. Builds are date-stamped, so they compare as
// strings.
expect((await created('20250718.01')).PlayerEvent.Tags).toEqual(NAMES)
expect((await created('20230414')).PlayerEvent.Tags).toEqual(PAIRS)
expect((await created('20220101')).PlayerEvent.Tags).toEqual(PAIRS)
const legacy = await created()
expect(legacy.PlayerEvent.Tags).toEqual(NAMES)
// Only the inline field moves: TagModifyResult carries names to both builds — it
// really is a List<string> on the client, so the pairs must never land there.
expect(legacy.PlayerEvent.Tags).toEqual(PAIRS)
// Only the inline field moves: TagModifyResult carries names to both builds.
expect(legacy.TagModifyResult).toEqual({ Result: 0, Tags: NAMES })
expect((await created('20250718.01')).TagModifyResult).toEqual({ Result: 0, Tags: NAMES })
// The gate is on the ENVELOPE, not on the create: the read and the field edits answer
// the same shape, so a client that made an event and one opening it cold agree.
@@ -8097,8 +3926,8 @@ describe('player events', () => {
})
).json()) as PlayerEventResult
).PlayerEvent.Tags
expect(await read('20250718.01')).toEqual(PAIRS)
expect(await read()).toEqual(NAMES)
expect(await read('20250718.01')).toEqual(NAMES)
expect(await read()).toEqual(PAIRS)
})
test('GET /api/playerevents/v2/:eventId serves the same envelope as the write', async () => {
@@ -8218,61 +4047,7 @@ describe('player events', () => {
expect(await (await get(path)).json()).toEqual(asRecord(upcoming))
})
test('POST /api/playerevents/v1/bulk answers the requested ids as base events', async () => {
// What the client sends: `Ids` repeated once per id, form-urlencoded.
const body = new URLSearchParams()
for (const id of [clubEvent.PlayerEventId, 999999, upcoming.PlayerEventId]) {
body.append('Ids', String(id))
}
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body,
})
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,
])
// A bare array — no envelope — of the BASE event, the same projection the browse feed
// and the room shelf serve. Not the stored record: no `State`.
const entry = events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)!
expect(entry).toEqual(asBase(upcoming))
expect(Object.keys(entry)).toHaveLength(17)
expect(Object.hasOwn(entry, 'State')).toBe(false)
})
test('POST /api/playerevents/v1/bulk reads a single id and the comma-separated form', async () => {
const bulk = async (raw: string): Promise<PlayerEvent[]> => {
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: raw,
})
expect(res.status).toBe(200)
return (await res.json()) as PlayerEvent[]
}
// The raw one-id body the client sends for a single event.
expect((await bulk(`Ids=${upcoming.PlayerEventId}`)).map((e) => e.PlayerEventId)).toEqual([
upcoming.PlayerEventId,
])
// …and the comma-separated spelling the other bulk POSTs take.
expect(
(await bulk(`Ids=${clubEvent.PlayerEventId},${upcoming.PlayerEventId}`)).map(
(e) => e.PlayerEventId
)
).toEqual([clubEvent.PlayerEventId, upcoming.PlayerEventId])
// Nothing to look up is an empty array, not every event and not a 400.
expect(await bulk('')).toEqual([])
expect(await bulk('Ids=')).toEqual([])
})
test('GET /api/playerevents/v1/bulk answers the same shape as the POST', async () => {
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}`
)
@@ -8283,8 +4058,6 @@ describe('player events', () => {
clubEvent.PlayerEventId,
upcoming.PlayerEventId,
])
// The same base projection the POST serves: one path, one shape.
expect(events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)).toEqual(asBase(upcoming))
// No ids is an empty list, not every event.
expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([])
@@ -8476,15 +4249,9 @@ describe('player events', () => {
expect(events.map((e) => e.PlayerEventId)).not.toContain(finished.PlayerEventId)
expect(events.map((e) => e.PlayerEventId)).not.toContain(elsewhere.PlayerEventId)
// The BASE event, 17 keys — the same projection the browse feed and the bulk read
// serve, since the client decodes all three through one helper and one element type.
// Not the stored record (`/searchlive` and the club shelves keep that), and not the
// single-club envelope.
expect(events[0]).toEqual(asBase(running))
expect(Object.keys(events[0]!)).toHaveLength(17)
expect(Object.hasOwn(events[0]!, 'State')).toBe(false)
// An event created with no banner reads `""` here, never the record's null.
expect(events[0]!.ImageName).toBe('')
// A bare array of the STORED record, like `/searchlive` and the multi-club shelf —
// not the base projection the browse feed serves, and not the single-club envelope.
expect(events[0]).toEqual(asRecord(running, null))
// A room with nothing scheduled, and a room id nothing knows about, are both empty.
expect(await (await get('/api/playerevents/v1/room/999999')).json()).toEqual([])
@@ -8691,7 +4458,7 @@ describe('player events', () => {
).toBe(401)
})
test('POST /api/playerevents/v1/bulkInvite adds invitees as Pending without overwriting answers', async () => {
test('POST /api/playerevents/v1/bulkInvite adds invitees as Going without overwriting answers', async () => {
const event = await create({ RoomId: 3, Name: 'Invite Test', StartTime: at(HOUR) })
const id = event.PlayerEventId
@@ -8707,9 +4474,8 @@ describe('player events', () => {
expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
// Only the creator: an invite is Pending, not Going, so the two newly invited don't
// count until they answer — and 43 keeps their decline.
expect(body.PlayerEvent.AttendeeCount).toBe(1)
// The creator plus the two newly invited — 43 keeps their decline, so isn't counted.
expect(body.PlayerEvent.AttendeeCount).toBe(3)
const responses = (await (await get(`/api/playerevents/v1/${id}/responses`)).json()) as Array<{
PlayerId: number
@@ -8718,10 +4484,10 @@ describe('player events', () => {
expect(
responses.sort((a, b) => a.PlayerId - b.PlayerId).map((r) => [r.PlayerId, r.Type])
).toEqual([
[2, 3],
[2, 0],
[42, 0],
[43, 2],
[187, 3],
[187, 0],
])
// Re-inviting is a no-op, not a reset: 43 still declines and the count holds.
@@ -8730,23 +4496,14 @@ describe('player events', () => {
{ PlayerEventId: id, InvitedPlayerIds: [187, 43] },
'42'
)
expect(((await again.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
expect(((await again.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(3)
// An empty list is a no-op that still answers the event.
const none = await post('/api/playerevents/v1/bulkInvite', {
PlayerEventId: id,
InvitedPlayerIds: [],
})
expect(((await none.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
// Accepting the invite is an ordinary respond: Pending becomes Going and counts.
const accepted = await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
expect(((await accepted.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(2)
// Pending is the server's mark, not an answer a player can give.
expect(
(await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 3 }, '187')).status
).toBe(400)
expect(((await none.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(3)
})
test('POST /api/playerevents/v1/bulkInvite notifies only the players it actually added', async () => {
@@ -8764,57 +4521,17 @@ describe('player events', () => {
data: Record<string, Record<string, unknown>>
}>
// ONE frame, to the one player who gained a row: the invitation Message. No
// PlayerEventResponseChanged — a Pending row is not a response; that frame is the
// responder's, sent by `respond` once they answer. 43 kept their answer, so nothing
// One frame, to the one player who gained a row. 43 kept their answer, so nothing
// changed for them and nothing is pushed.
expect(sent).toHaveLength(1)
expect(sent[0]!.playerId).toBe(2)
expect(sent[0]!.notificationType).toBe(2) // MessageReceived
// A PlayerEventInvitation (81) Message from the inviter, naming the event on
// PlayerEventId and as a string in Data.
expect(sent[0]!.data).toMatchObject({
FromPlayerId: 42,
ToPlayerId: 2,
Type: 81,
Data: String(id),
PlayerEventId: id,
})
// Stored under the same id, so an offline invitee finds it on login.
const frameId = (sent[0]!.data as unknown as { Id: number }).Id
expect(frameId).toBeGreaterThan(0)
const inbox = (await (
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, { headers: await bearer('2') })
).json()) as Array<{ Id: number; Type: number; PlayerEventId: number | null }>
expect(inbox.find((m) => m.Id === frameId)).toMatchObject({ Type: 81, PlayerEventId: id })
})
test('POST /api/playerevents/v1/respond pushes PlayerEventResponseChanged to the responder', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const event = await create({ RoomId: 3, Name: 'RSVP Frames', StartTime: at(HOUR) })
const id = event.PlayerEventId
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [2] })
// The invitee accepts: THIS is where their response frame comes from — the frame
// that populates the event on their client — carrying their row as it now stands.
await hub.fetch('http://do/all', { method: 'DELETE' })
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
const sent = (await (await hub.fetch('http://do/all')).json()) as Array<{
playerId: number
notificationType: number
data: Record<string, Record<string, unknown>>
}>
expect(sent).toHaveLength(1)
expect(sent[0]!.playerId).toBe(2)
expect(sent[0]!.notificationType).toBe(83) // PlayerEventResponseChanged
// BOTH nested objects are present — the client dereferences them without a null
// guard, so a missing one is a NullReferenceException rather than a blank field.
// The event carries the count as recomputed AFTER the answer.
expect(sent[0]!.data.PlayerEvent).toMatchObject({
playerEventId: id,
name: 'RSVP Frames',
name: 'Invite Frames',
attendeeCount: 2,
})
expect(sent[0]!.data.PlayerEventResponse).toEqual({
@@ -8824,106 +4541,6 @@ describe('player events', () => {
CreatedAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/),
Type: 0,
})
// Changing the answer pushes again, with the same row id and the new Type — the
// upsert updates in place, so the frame's id matches what the RSVP list serves.
await hub.fetch('http://do/all', { method: 'DELETE' })
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '2')
const again = (await (await hub.fetch('http://do/all')).json()) as typeof sent
expect(again).toHaveLength(1)
expect(again[0]!.data.PlayerEventResponse).toMatchObject({
PlayerEventResponseId: sent[0]!.data.PlayerEventResponse!.PlayerEventResponseId,
Type: 2,
})
expect(again[0]!.data.PlayerEvent).toMatchObject({ attendeeCount: 1 })
})
test('a deleted event’s id is never reused by a later create', async () => {
// Ids used to be MAX(id) + 1 over the table, so deleting the newest event handed its
// id to the next one created — which then inherited everything still filed under
// that id outside the event's own rows (an invitation message, the client's cache).
const first = await create({ RoomId: 3, Name: 'First', StartTime: at(HOUR) })
expect(
(await post(`/api/playerevents/v2/delete/${first.PlayerEventId}`, undefined)).status
).toBe(200)
const second = await create({ RoomId: 3, Name: 'Second', StartTime: at(HOUR) })
expect(second.PlayerEventId).toBeGreaterThan(first.PlayerEventId)
// And the replacement starts with its own guest list: just its creator.
const responses = (await (
await get(`/api/playerevents/v1/${second.PlayerEventId}/responses`)
).json()) as Array<{ PlayerId: number }>
expect(responses.map((r) => r.PlayerId)).toEqual([42])
})
test('editing an event pushes PlayerEventUpdated to everyone Going, and only them', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const event = await create({ RoomId: 3, Name: 'Before', StartTime: at(HOUR) })
const id = event.PlayerEventId
// 2 is Going, 43 is Interested, 44 declined, 45 is invited and still Pending.
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '44')
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [45] })
const frames = async () =>
(await (await hub.fetch('http://do/all')).json()) as Array<{
playerId: number
notificationType: number
data: Record<string, unknown>
}>
// The whole-event update: the creator (Going from create) and 2 hear about it, with
// the event as it now stands in the camelCase projection PlayerEventCreated uses.
await hub.fetch('http://do/all', { method: 'DELETE' })
const res = await post(`/api/playerevents/v2/${id}`, { Name: 'After' })
expect(res.status).toBe(200)
const sent = await frames()
expect(sent.map((s) => [s.playerId, s.notificationType])).toEqual([
[2, 81], // PlayerEventUpdated
[42, 81],
])
for (const frame of sent) {
expect(frame.data).toMatchObject({ playerEventId: id, name: 'After', attendeeCount: 2 })
}
// A single-field edit pushes the same frame to the same audience.
await hub.fetch('http://do/all', { method: 'DELETE' })
const renamed = await exports.default.fetch(`${ORIGIN}/api/playerevents/v2/${id}/name`, {
method: 'PUT',
headers: { ...(await bearer('42')), 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ name: 'Renamed' }),
})
expect(renamed.status).toBe(200)
const again = await frames()
expect(again.map((s) => [s.playerId, s.notificationType])).toEqual([
[2, 81],
[42, 81],
])
expect(again[0]!.data).toMatchObject({ name: 'Renamed' })
})
test('deleting an event pushes PlayerEventDeleted to everyone who was Going, and only them', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const event = await create({ RoomId: 3, Name: 'Doomed', StartTime: at(HOUR) })
const id = event.PlayerEventId
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [45] })
await hub.fetch('http://do/all', { method: 'DELETE' })
const res = await post(`/api/playerevents/v2/delete/${id}`, undefined)
expect(res.status).toBe(200)
const sent = (await (await hub.fetch('http://do/all')).json()) as Array<{
playerId: number
notificationType: number
data: Record<string, unknown>
}>
// The audience is read before the RSVP rows are deleted with the event; the frame
// is just the id, there being no event left to carry.
expect(sent).toEqual([
{ playerId: 2, notificationType: 82, data: { PlayerEventId: id } }, // PlayerEventDeleted
{ playerId: 42, notificationType: 82, data: { PlayerEventId: id } },
])
})
test('POST /api/playerevents/v1/bulkInvite is gated on the caller being on the event', async () => {
@@ -9190,9 +4807,11 @@ describe('player events', () => {
const path = `/api/playerevents/v2/${event.PlayerEventId}/tags`
// A bare JSON array, not an object — and a replace, not a merge, so `meetup` goes.
// Names, since this token names no build (a newer build would get the pairs).
const tagged = await edited(await putJson(path, ['tag1', '#Class']))
expect(tagged.Tags).toEqual(['class', 'tag1'])
expect(tagged.Tags).toEqual([
{ Tag: 'class', Type: 0 },
{ Tag: 'tag1', Type: 0 },
])
// The envelope's TagModifyResult reports the same set the client redraws chips from.
const body = (await (await putJson(path, ['workshops'])).json()) as PlayerEventResult
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['workshops'] })
@@ -9295,10 +4914,8 @@ describe('openapi', () => {
)
)
expect([...documented].sort()).toEqual([
'DELETE /api/customAvatarItems/v1/{id}',
'DELETE /api/images/v1/deletesaved',
'DELETE /api/playerevents/v2/delete/{eventId}',
'GET /api/CircuitChipLists/{list}',
'GET /api/PlayerReporting/v1/moderationBlockDetails',
'GET /api/PlayerReporting/v1/voteToKickReasons',
'GET /api/activities/charades/v1/words/{activity}',
@@ -9314,8 +4931,6 @@ describe('openapi', () => {
'GET /api/customAvatarItems/v1/isCreationAllowedForAccount',
'GET /api/customAvatarItems/v1/isCreationEnabled',
'GET /api/customAvatarItems/v1/isRenderingEnabled',
'GET /api/customAvatarItems/v1/minPriceForPublicItem',
'GET /api/customAvatarItems/v1/search',
'GET /api/customAvatarItems/v2/fromCreator/{accountId}',
'GET /api/equipment/v2/getUnlocked',
'GET /api/gameconfigs/v1/all',
@@ -9331,7 +4946,6 @@ describe('openapi', () => {
'GET /api/inventions/v1',
'GET /api/inventions/v1/details',
'GET /api/inventions/v1/featured',
'GET /api/inventions/v1/featureddormskins',
'GET /api/inventions/v1/fromcreators',
'GET /api/inventions/v1/fulllineageowner',
'GET /api/inventions/v1/personaldetails/{inventionId}',
@@ -9390,44 +5004,29 @@ describe('openapi', () => {
'GET /outfits/me',
'GET /outfits/me/saved',
'GET /voice/config',
'POST /api/PlayerCheer/v1/SetSelectedCheer',
'POST /api/PlayerCheer/v1/create',
'POST /api/PlayerReporting/v1/deviceId',
'POST /api/PlayerReporting/v1/hile',
'POST /api/PlayerReporting/v1/instantKick',
'POST /api/PlayerReporting/v1/moderationBlockDetails',
'POST /api/PlayerReporting/v1/referee',
'POST /api/PlayerReporting/v1/roomModKick',
'POST /api/PlayerReporting/v3/create',
'POST /api/PlayerReporting/v3/voteToKick',
'POST /api/avatar/v1/lockeditems/bulk',
'POST /api/avatar/v2/gifts/generate',
'POST /api/chatreport/createChatReport',
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
'POST /api/customAvatarItems/v1',
'POST /api/customAvatarItems/v1/bulk',
'POST /api/customAvatarItems/v1/{id}/report',
'POST /api/gamesight/event',
'POST /api/images/v1/cheer',
'POST /api/images/v4/uploadsaved',
'POST /api/images/v5/cheered/bulk',
'POST /api/inventions/v1/cheer',
'POST /api/inventions/v1/report',
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/update',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v2/delete',
'POST /api/inventions/v2/update',
'POST /api/inventions/v4/publish',
'POST /api/inventions/v6/save',
'POST /api/inventions/v9/save',
'POST /api/messages/v1/friendOnlineStatus',
'POST /api/messages/v1/sendMultiple',
'POST /api/messages/v2/send',
'POST /api/messages/v3/delete',
'POST /api/playerReputation/v1/bulk',
'POST /api/playerReputation/v2/bulk',
'POST /api/playerevents/v1/bulk',
'POST /api/playerevents/v1/bulkInvite',
'POST /api/playerevents/v1/report',
'POST /api/playerevents/v1/respond',
@@ -9453,8 +5052,6 @@ describe('openapi', () => {
'POST /api/v1/progression/bulk',
'POST /outfits/bulk',
'POST /statsigUserProperties',
'PUT /api/customAvatarItems/v1/{id}',
'PUT /api/inventions/v2/metadata',
'PUT /api/playerevents/v2/{eventId}/accessibility',
'PUT /api/playerevents/v2/{eventId}/description',
'PUT /api/playerevents/v2/{eventId}/name',
-21
View File
@@ -1,21 +0,0 @@
import { intVar } from '@repo/hono-helpers'
import type { App } from './context'
/** Safe fallback when the deployment does not configure an API upload ceiling. */
export const DEFAULT_MAX_API_UPLOAD_BYTES = 64 * 1024 * 1024
/**
* Resolve the per-file ceiling shared by API-owned image uploads. A non-positive
* setting does not disable the protection: public upload routes must always remain
* bounded, so invalid values fall back to the safe default.
*/
export function maxApiUploadBytes(env: App['Bindings']): number {
const configured = intVar(env.RECFLARE_MAX_API_UPLOAD_BYTES, DEFAULT_MAX_API_UPLOAD_BYTES)
return configured > 0 ? configured : DEFAULT_MAX_API_UPLOAD_BYTES
}
/** Whether a parsed multipart file is safe to copy into memory and persist to R2. */
export function exceedsApiUploadLimit(file: File, limit: number): boolean {
return file.size > limit
}
-178
View File
@@ -1,178 +0,0 @@
/**
* Vote-to-kick ballots on the shared `recflare` D1 database.
*
* One row per vote cast: who was voted on, in which game session, by whom, and which way.
* Append-only like the `report` and `warning` tables (see reports-db.ts), so a vote is a
* record of what happened rather than a tally that gets rewritten — the tally is computed
* from the rows.
*
* The rows outlive the vote, but not by much: the `rooms` worker's cron deletes ballots
* older than 5 minutes (its `ROOM_VOTE_RETENTION_MS`), which is the longest anything here
* looks back — `VOTE_CALL_COOLDOWN_MS`. Nothing deletes them when a session ends, and
* nothing needs to: a ballot is keyed to its `game_session_id`, and instance ids are not
* reused, so an old session's votes can never be counted into a new one's. Raising the
* cooldown means raising that retention too.
*
* The `api` worker owns this schema/migration (migrations/0023_room_vote.sql and
* 0026_room_vote_init.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/0023_room_vote.sql + 0026_room_vote_init.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS room_vote (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_session_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
response INTEGER NOT NULL,
voter_id INTEGER NOT NULL,
voted_at TEXT NOT NULL,
init INTEGER NOT NULL DEFAULT 0
)`,
// The tally's own query: every ballot cast against one player in one session.
`CREATE INDEX IF NOT EXISTS idx_room_vote_subject ON room_vote (game_session_id, player_id)`,
// The caller throttle's query: the last vote one player CALLED.
`CREATE INDEX IF NOT EXISTS idx_room_vote_caller ON room_vote (voter_id, voted_at) WHERE init = 1`,
]
/**
* How long a vote stays open once it is called. Every ballot posted inside it is an answer to
* THAT vote; the first one posted after it is a new vote, with a prompt of its own.
*/
export const VOTE_WINDOW_MS = 60_000
/** How long a player must wait after calling a vote before they may call another, anywhere. */
export const VOTE_CALL_COOLDOWN_MS = 5 * 60_000
/**
* One cast ballot. `response` is 1 for yes (kick them) and 0 for no. `init` is 1 on the ballot
* that CALLED the vote — the first cast against this player in this session once no vote was
* open — and 0 on every answer to it. The client posts both to the same endpoint and nothing
* in the body tells them apart, so the row is where a vote's start is recorded.
*/
export interface RoomVoteRow {
id: number
game_session_id: number
player_id: number
response: number
voter_id: number
voted_at: string
init: number
}
/** A ballot as cast — the timestamp is the table's. */
export interface NewRoomVote {
gameSessionId: number
/** The player being voted on. */
playerId: number
voterId: number
/** True to kick them. */
response: boolean
/** True when this ballot calls the vote rather than answering one. */
init: boolean
}
/** Record one cast ballot, returning the stored row. */
export async function recordRoomVote(db: D1Database, input: NewRoomVote): Promise<RoomVoteRow> {
const row = await db
.prepare(
`INSERT INTO room_vote (game_session_id, player_id, response, voter_id, voted_at, init)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
RETURNING *`
)
.bind(
input.gameSessionId,
input.playerId,
input.response ? 1 : 0,
input.voterId,
new Date().toISOString(),
input.init ? 1 : 0
)
.first<RoomVoteRow>()
// RETURNING always yields the inserted row.
return row!
}
/**
* The ballot that called the vote currently open on this player in this session — the latest
* `init` row, if it was cast inside `VOTE_WINDOW_MS` — or null when no vote is open.
*/
export async function getOpenVote(
db: D1Database,
gameSessionId: number,
playerId: number,
now = Date.now()
): Promise<RoomVoteRow | null> {
return db
.prepare(
`SELECT * FROM room_vote
WHERE game_session_id = ?1 AND player_id = ?2 AND init = 1 AND voted_at > ?3
ORDER BY id DESC LIMIT 1`
)
.bind(gameSessionId, playerId, new Date(now - VOTE_WINDOW_MS).toISOString())
.first<RoomVoteRow>()
}
/**
* Whether `voterId` called a vote — any vote, in any session — within `VOTE_CALL_COOLDOWN_MS`.
* Only CALLING is throttled: answering a vote someone else called is never refused.
*/
export async function isOnVoteCooldown(
db: D1Database,
voterId: number,
now = Date.now()
): Promise<boolean> {
const row = await db
.prepare(`SELECT 1 FROM room_vote WHERE voter_id = ?1 AND init = 1 AND voted_at > ?2 LIMIT 1`)
.bind(voterId, new Date(now - VOTE_CALL_COOLDOWN_MS).toISOString())
.first()
return row !== null
}
/**
* How many DISTINCT players have voted to kick this player out of this session.
*
* Distinct voters, not rows, and only each voter's LATEST ballot: the table is append-only,
* so a player who votes twice leaves two rows, and counting rows would let one person carry
* a vote on their own by posting it repeatedly. Counting their latest also lets someone
* change their mind — a yes followed by a no is a no.
*
* `MAX(id)` picks the latest rather than `MAX(voted_at)`: two ballots cast in the same
* millisecond carry the same timestamp, and the id is the order they were actually recorded
* in.
*
* Only ballots from `sinceId` on count — the id of the `init` row that called THIS vote. A
* vote that expired without carrying leaves its yes ballots behind, and without the bound
* they would carry the next vote called on the same player.
*/
export async function countKickVotes(
db: D1Database,
gameSessionId: number,
playerId: number,
sinceId: number
): Promise<number> {
const row = await db
.prepare(
`SELECT COUNT(*) AS yes FROM (
SELECT response FROM room_vote
WHERE game_session_id = ?1 AND player_id = ?2
AND id IN (
SELECT MAX(id) FROM room_vote
WHERE game_session_id = ?1 AND player_id = ?2 AND id >= ?3
GROUP BY voter_id
)
) WHERE response = 1`
)
.bind(gameSessionId, playerId, sinceId)
.first<{ yes: number }>()
return row?.yes ?? 0
}
/**
* Whether `yesVotes` carries a vote in a session holding `playerCount` players: STRICTLY more
* than half. A tie is not a majority, and a session whose presence count has somehow reached
* zero can never carry one — `0 > 0` is false — rather than every vote passing unopposed.
*/
export const isKickMajority = (yesVotes: number, playerCount: number): boolean =>
playerCount > 0 && yesVotes > playerCount / 2
-3
View File
@@ -382,8 +382,5 @@
"CloudRegion": "us",
"CrcCheckEnabled": false,
"EnableServerTracingAfterDisconnect": false
},
"RoomKeyConfig": {
"MaxKeysPerRoom": 10
}
}
+3 -17
View File
@@ -925,7 +925,7 @@
},
{
"Key": "Statsig.Enabled",
"Value": "false",
"Value": "true",
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
@@ -3992,7 +3992,7 @@
{
"Key": "Econ.Store.Categories",
"Value": "{\"Categories\":[{\"Name\":\"Featured\",\"Depth\":0,\"Icon\":1,\"DiscoveryPageSource\":\"StoreFeatured\",\"ClickAction\":1},{\"Name\":\"Appearance\",\"Depth\":0,\"Icon\":2,\"DiscoveryPageSource\":\"StoreClothing\",\"ClickAction\":1},{\"Name\":\"Clothing\",\"Depth\":1,\"DiscoveryPageSource\":\"StoreAppearance\",\"ClickAction\":1},{\"Name\":\"Tops\",\"Depth\":2,\"ClickAction\":3,\"Filters\":512,\"StoreCarouselType\":8,\"SortCriteria\":1},{\"Name\":\"Bottoms\",\"Depth\":2,\"ClickAction\":3,\"Filters\":8388608,\"StoreCarouselType\":17,\"SortCriteria\":1},{\"Name\":\"Footwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":16777216,\"StoreCarouselType\":18,\"SortCriteria\":1},{\"Name\":\"Accessories\",\"Depth\":1,\"DiscoveryPageSource\":\"StoreAccessories\",\"ClickAction\":1},{\"Name\":\"Headwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":64,\"StoreCarouselType\":7,\"SortCriteria\":1},{\"Name\":\"Eyewear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":256,\"StoreCarouselType\":19,\"SortCriteria\":1},{\"Name\":\"Earwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":33554432,\"StoreCarouselType\":20,\"SortCriteria\":1},{\"Name\":\"Neckwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":67108864,\"StoreCarouselType\":21,\"SortCriteria\":1},{\"Name\":\"Shoulders & Back\",\"Depth\":2,\"ClickAction\":3,\"Filters\":134217728,\"StoreCarouselType\":22,\"SortCriteria\":1},{\"Name\":\"Waist\",\"Depth\":2,\"ClickAction\":3,\"Filters\":268435456,\"StoreCarouselType\":23,\"SortCriteria\":1},{\"Name\":\"Hands\",\"Depth\":2,\"ClickAction\":3,\"Filters\":1024,\"StoreCarouselType\":10,\"SortCriteria\":1},{\"Name\":\"Styling\",\"Depth\":1,\"DiscoveryPageSource\":\"StoreStyling\",\"ClickAction\":1},{\"Name\":\"Hairstyles\",\"Depth\":2,\"ClickAction\":3,\"Filters\":128,\"StoreCarouselType\":24,\"SortCriteria\":1},{\"Name\":\"Facial Hair\",\"Depth\":2,\"ClickAction\":3,\"Filters\":536870912,\"StoreCarouselType\":25,\"SortCriteria\":1},{\"Name\":\"Hair Dye\",\"Depth\":2,\"ClickAction\":3,\"Filters\":131072,\"StoreCarouselType\":6,\"SortCriteria\":1},{\"Name\":\"Equipment Skins\",\"Depth\":2,\"ClickAction\":3,\"Filters\":262144,\"StoreCarouselType\":5,\"SortCriteria\":1},{\"Name\":\"Inventions\",\"Depth\":0,\"Icon\":3,\"DiscoveryPageSource\":\"StoreInventions\",\"ClickAction\":1,\"Filters\":0,\"StoreCarouselType\":0},{\"Name\":\"Consumables\",\"Depth\":0,\"Icon\":4,\"DiscoveryPageSource\":\"StoreConsumables\",\"ClickAction\":1,\"Filters\":0,\"StoreCarouselType\":0}]}",
"ActiveExperiments": "null",
"ActiveExperiments": "revenue_2025q1_appearance_improve_conversion\r\nrevenue_2025q3_trending_by_payersegment",
"StartTime": null,
"EndTime": null
},
@@ -4614,7 +4614,7 @@
},
{
"Key": "RoomieAI.System.Enabled",
"Value": "False",
"Value": "True",
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
@@ -6060,19 +6060,5 @@
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
},
{
"Key": "DataCollection.Enabled",
"Value": "false",
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
},
{
"Key": "Backtrace.SampleRate",
"Value": "0",
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
}
]
-14
View File
@@ -2716,19 +2716,5 @@
"Value": "false",
"StartTime": null,
"EndTime": null
},
{
"Key": "DataCollection.Enabled",
"Value": "false",
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
},
{
"Key": "Backtrace.SampleRate",
"Value": "0",
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
}
]
-15
View File
@@ -25,13 +25,6 @@ export default defineConfig({
// 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.
//
// The ephemeral sends land in the same list, tagged `ephemeral`:
// notifyPlayerEphemeral carries `playerId` like the durable send,
// notifyPlayersEphemeral carries `playerIds` for the whole batch. Durable
// and ephemeral differ in whether an offline recipient gets the frame
// later, which is a thing worth asserting (a cheer's effect is broadcast to
// a room this way, a vote-to-kick is put to each player in it).
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
@@ -40,14 +33,6 @@ export default defineConfig({
this.sent.push({ playerId, notificationType, data })
return { delivered: 0, queued: true }
}
async notifyPlayerEphemeral(playerId, notificationType, data) {
this.sent.push({ playerId, ephemeral: true, notificationType, data })
return { delivered: 0 }
}
async notifyPlayersEphemeral(playerIds, notificationType, data) {
this.sent.push({ playerIds, ephemeral: true, notificationType, data })
return { delivered: 0 }
}
async broadcast() { return { delivered: 0 } }
async fetch(request) {
if (request.method === 'DELETE') {
+5 -6
View File
@@ -66,15 +66,14 @@
],
"upload_source_maps": true,
"observability": {
"enabled": false
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown", // overridden during deployment
"DOMAIN": "rec.example.com", // base domain; overridden during deployment
// Per-file ceiling for saved images and custom-avatar-item files. Multipart parsing
// happens first, but this prevents oversized files from being copied into another
// ArrayBuffer and persisted to R2. Invalid/non-positive values fall back to 64 MiB.
"RECFLARE_MAX_API_UPLOAD_BYTES": "67108864"
"DOMAIN": "rec.example.com" // base domain; overridden during deployment
}
}
@@ -1,15 +0,0 @@
-- Expose the account's Rec Room Plus flag (`hasPlus` in the JSON blob) as an indexed
-- generated column, so "every Plus member" is an index walk rather than a scan of every
-- account's JSON. The reader is the operator's periodic token reload
-- (`just reload-plus`, packages/tools/src/cmd/admin.cmd.ts), which credits the
-- `balance` row of each Plus member in one statement.
--
-- The index is PARTIAL: it holds only the rows where the flag is set, so it is as small
-- as the subscriber list and never grows with the account table. A query must say
-- exactly `has_plus = 1` to use it. `json_extract` yields 1 for JSON true, 0 for false
-- and NULL when the key is absent, so neither a revoked nor a never-granted account is
-- in it. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts (and its mirror
-- in apps/econ/src/avatar-db.ts).
ALTER TABLE account ADD COLUMN has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1;
@@ -1,37 +0,0 @@
-- Record the roles a linked identity held in its platform's community, for the one
-- platform that has any: `PlatformType.Discord` (101), written by the website's benefits
-- claim from the member record Discord itself served
-- (`GET /users/@me/guilds/{guild}/member`). Steam and Meta links leave it NULL — there is
-- no such thing there — so the column is nullable and has no default.
--
-- Until now the claim asked Discord for the member's roles, tested them against the
-- configured qualifying list, and threw the answer away; all that survived was `hasPlus`,
-- which records "held A qualifying role once" and not WHICH one. That is the question an
-- operator has to answer by hand in Discord for every player who writes in — and the only
-- way to tell a supporter from a booster from staff after the fact, or to see that a tier
-- was granted under a role that has since been retired.
--
-- A JSON ARRAY of role id snowflakes (`["1077…001","1077…002"]`), not one id: a member
-- holds as many roles as they hold, and the claim qualifies on ANY of them, so storing a
-- single one would have to pick arbitrarily. Snowflakes stay STRINGS — one exceeds 2^53,
-- so a JSON number would round and stop matching the real role. The list is the whole
-- member record's roles, not just the qualifying ones: it is a snapshot of who they were
-- in the guild, and filtering it to the config of the day would make it unreadable the
-- moment the config changed.
--
-- It is a SNAPSHOT, taken at claim time and refreshed only when the same player claims
-- again. Nothing sweeps it: reading a member's roles needs that member's own OAuth token
-- (this design deliberately holds no bot token — see apps/www/src/discord.ts), so the
-- server cannot re-read them unprompted. Read it as "the roles they held when they
-- claimed", the same tense `hasPlus` carries, and never as "the roles they hold".
--
-- No index. Nothing queries BY role yet; when something does, it will want
-- `json_each(role)` and an expression index built for that query, not a plain one over
-- the array text, which could only serve an exact-whole-array match. Kept in sync with
-- PLATFORM_SCHEMA_DDL in src/platform-db.ts.
--
-- Safe to run before or after the deploy that ships it: an added nullable column is
-- invisible to the currently-deployed code, which names its columns explicitly and never
-- SELECTs *.
ALTER TABLE platform_account ADD COLUMN role TEXT;
+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.13.5",
"hono": "4.12.27",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.128.0"
"wrangler": "4.105.0"
}
}
+71 -187
View File
@@ -21,8 +21,6 @@ import {
subRoomDataBlob,
updateAccount,
verifyPassword,
withDatabase,
writeAuditLog,
} from '@repo/domain'
import {
intVar,
@@ -49,7 +47,6 @@ import {
PlatformIdsRequest,
PlatformType,
RestrictionDto,
roleFilter,
roleLookup,
TokenRequest,
TokenResponse,
@@ -66,7 +63,7 @@ import { verifySteamTicket } from './steam-ticket'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
import type { App, Env } from './context'
import type { App } from './context'
import type { PlatformLink } from './platform-db'
/** OAuth scopes granted by `/connect/token`. */
@@ -74,18 +71,19 @@ const TOKEN_SCOPE =
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
/**
* The `error_description` a SIGNUP is refused with when it shares an identity with a banned
* account (see bans-db's linked arms). A fixed
* sentence, because `www`'s shared auth-messages table keys on this exact string to put a
* real sentence in front of a player — anything varying would fall through to the generic
* "you could not be signed in". Keep the two in sync. Deliberately vague: the account
* being refused may be an innocent housemate of a banned player, so telling them "this
* account is banned" would be a lie, and naming the account we matched them to would hand
* out somebody else's moderation record.
*
* Only `create_account` is refused this way. SIGNING IN is never refused for a ban, its own
* or a linked one — see the token grant: the client needs a token to reach the block screen,
* and `match` is what holds a blocked player to their dorm.
* The `error_description` a banned account's grant is refused with. A fixed sentence,
* never interpolated with the expiry, because `www`'s shared auth-messages table keys on
* this exact string to put a real sentence in front of a player — anything varying would
* fall through to the generic "you could not be signed in". Keep the two in sync.
*/
const BANNED_DESCRIPTION = 'this account is banned'
/**
* The refusal when it is not THIS account that is banned but one it shares an identity
* with (see bans-db's linked arms). Deliberately a different, vaguer sentence: the
* account being refused may be an innocent housemate of a banned player, so telling them
* "this account is banned" would be a lie, and naming the account we matched them to
* would hand out somebody else's moderation record.
*/
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
@@ -205,51 +203,6 @@ async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Whether one account row holds a role. Absent/false both mean no role. */
function holdsRole(account: Account, role: 'developer' | 'moderator'): boolean {
return (role === 'developer' ? account.isDeveloper : account.isModerator) === true
}
/**
* The ONE-player lookup (`/role/{role}/{id}`): a BARE JSON boolean — the client reads the body
* as a bool, not as an object — or an empty 404 for a player this server has no account for,
* mirroring the reference API. An id that is not a number is answered as unknown (404) rather
* than as a 400: `/role/developer/nonsense` is just an id that matches nothing.
*/
async function roleAnswer(c: Context<App>, role: 'developer' | 'moderator', id: string) {
logger.info(`${role} role lookup`, { id })
const accountId = Number.parseInt(id, 10)
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
if (!account) return c.body(null, 404)
return c.json(holdsRole(account, role))
}
/**
* The BULK form (`/role/{role}?id=1&id=2`): WHICH of the players asked about hold the role, as
* an array of ints. A different question from the single lookup and so a different shape —
* never that route's boolean.
*
* A FILTER, so nothing here is an error: an id that names no account, or names one without the
* role, is left out, and a query naming no ids answers `[]`. A 404 would be wrong — the
* question "which of these are developers" has the answer "none of them", not "no such thing".
*
* Answered in the order the query asked, de-duplicated, so a caller can read the result
* against its own list. One round trip however many ids are named: `getAccountsByIds` splits
* the read across statements when it exceeds D1's bound-parameter limit.
*/
async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator') {
// `queries` and not `query`: the ids arrive as a REPEATED parameter, and `query` would
// collapse them to the first and silently answer about one player out of the set.
const asked = c.req.queries('id') ?? []
const ids = [...new Set(asked.map((v) => Number.parseInt(v, 10)).filter(Number.isInteger))]
logger.info(`${role} role filter`, { asked: asked.length, ids: ids.length })
if (ids.length === 0) return c.json([])
const accounts = await getAccountsByIds(c.env.DB, ids)
const holders = new Set(accounts.filter((a) => holdsRole(a, role)).map((a) => a.accountId))
return c.json(ids.filter((id) => holders.has(id)))
}
/**
* The role names beyond `gameClient` for an account's token `role` claim. Base roles
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
@@ -655,25 +608,19 @@ const app = new Hono<App>()
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
'client reads it as a claim beside `role`, and it is absent for everyone else.',
'',
'**Bans.** A BLOCKED account still gets a token — every grant, including a refresh,',
'and whether the ban is its own or reaches it through the evasion arms.',
'A ban is a `report` row with `banned` set (the `api` worker owns that table); it',
'lifts on its own when `ban_expires` passes, and never if that is null. The token',
'is what lets the client reach `api`’s `/api/PlayerReporting/v1/moderationBlockDetails`',
'and show the player the block screen that explains the ban; the ban itself is',
'enforced by `match`, which refuses every matchmake but the caller’s own dorm, so a',
'token gets them as far as that screen and no further.',
'**Bans.** Once the grant has resolved an account, a BANNED account is refused a',
'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a',
'`report` row with `banned` set (the `api` worker owns that table); it lifts on its',
'own when `ban_expires` passes, and never if that is null.',
'',
'Ban EVASION — an account sharing a PROVEN platform identity (a `platform_account`',
'link) or an IP (`signupIp`/`lastLoginIp`, or the address this request came from)',
'with a banned one — signs in the same way, and is stopped at matchmaking like any',
'other blocked account. Those two arms are the operator’s `BAN_EVASION_MATCH` knob',
'(`ip`, `platform`, or `off`).',
'',
'What IS refused here (`invalid_grant`) is a `create_account` carrying either link,',
'refused BEFORE it mints anything: making a NEW account to evade with is a different',
'act from signing into one. The description is deliberately vague — the signup',
'refused may belong to a housemate of the banned player rather than to them.',
'The refusal follows the player, not just the account: it also catches an account',
'that shares a PROVEN platform identity (a `platform_account` link) or an IP',
'(`signupIp`/`lastLoginIp`, or the address this request came from) with a banned',
'one, and a `create_account` carrying either is refused BEFORE it mints anything.',
'Those two arms are the operator’s `BAN_EVASION_MATCH` knob (`ip`, `platform`, or',
'`off`); the ban on the account itself is always enforced. A linked match answers a',
'deliberately vaguer description than a direct one — the account refused may belong',
'to a housemate of the banned player rather than to them.',
].join('\n'),
requestBody: form(
TokenRequest,
@@ -686,7 +633,7 @@ const app = new Hono<App>()
[
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
'or an account sharing a banned one’s device or network',
'or a banned account',
].join(' ')
),
500: json(
@@ -842,9 +789,9 @@ const app = new Hono<App>()
// via create_account or /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
// A banned player's next move is a new account, so the evasion arms are checked
// BEFORE one is minted — against the only identity a signup has, the IP it came
// from and the platform identity it just proved. Refusing after the fact (as the
// A banned player's next move is a new account, so the ban is checked BEFORE
// one is minted — against the only identity a signup has, the IP it came from
// and the platform identity it just proved. Refusing after the fact (as the
// shared check below would) still refuses the token, but leaves the account
// row behind and burns a slot off both signup caps, so the evader gets to keep
// making them.
@@ -1040,36 +987,28 @@ const app = new Hono<App>()
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
// EVERY account gets its token, banned or not — a ban of its own, or a link to
// somebody else's through the evasion arms. The client needs one to reach `api`'s
// moderationBlockDetails, which is where a banned player is TOLD they are banned
// (category, time left, "Rule violation"); refused here they would only ever see a
// sign-in that fails, with nothing saying why. The ban is enforced by `match`, which
// refuses every matchmake but the caller's own dorm, so a token gets them as far as
// the block screen and no further.
// A banned player gets no token — and with no token every other worker is shut to
// them, so this is the outer wall of a ban; matchmaking's refusal is the inner
// one, which still has to exist because a token issued before the ban stays valid
// until it expires.
//
// That includes ban EVASION, which this used to refuse. An evader signing in lands
// in their dorm and can enter no room — the same place the ban leaves them — and
// refusing the token instead only ever produced a client stuck on a failed login.
// `create_account` is still refused before it mints anything (above): that is making
// a NEW account to evade with, which is a different act from signing into one.
//
// Logged either way, and the arm is logged with it: "banned" and "shares a network
// with somebody banned" are very different things to be reading in a log.
//
// Deliberately AFTER the credential checks — a wrong password is still "invalid
// account_id or password", so this can't be used to probe whether an account exists
// or is banned without knowing it.
// Checked once here, after the grant has resolved an account, so it covers every
// grant: password, cached_login and a refresh_token redeemed by a client that has
// been running since before the ban. Deliberately AFTER the credential checks —
// a wrong password is still "invalid account_id or password", so this can't be
// used to probe whether an account exists or is banned without knowing it.
//
// The request's own IP and proven identity are passed alongside the account, so a
// ban also reaches an old, clean account logged into from the banned player's device
// or network — the stored ips alone would only catch that on the SECOND login.
// ban also reaches an old, clean account logged into from the banned player's
// device or network — the stored ips alone would only catch that on the SECOND
// login. create_account was already refused before it minted anything (above);
// this still runs for it, so a signup that raced one is refused too.
const ban = await resolveBan(c.env.DB, Number(accountId), {
identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (ban) {
logger.info('token issued to a blocked account', {
logger.info('token refused: player banned', {
accountId,
grantType,
via: ban.via,
@@ -1077,6 +1016,13 @@ const app = new Hono<App>()
reportId: ban.ban.id,
banExpires: ban.ban.ban_expires,
})
return c.json(
{
error: 'invalid_grant',
error_description: ban.via === 'account' ? BANNED_DESCRIPTION : BLOCKED_DESCRIPTION,
},
400
)
}
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
@@ -1094,9 +1040,8 @@ const app = new Hono<App>()
// 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), and its Plus flag into `rn.plus`. One read of the just-resolved
// account serves both; they thus refresh on every login and every refresh_token
// grant.
// /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.
@@ -1111,70 +1056,12 @@ const app = new Hono<App>()
jwtSecret,
accountRoles(roleAccount),
accountPrivileges(roleAccount),
version,
// Rec Room Plus, off the same account read as the roles above — `econ` decides the
// CampusCard and the subscriber discount from this claim alone, so it never has to
// load the account. It therefore refreshes on every login and every refresh_token
// grant, and only then: a player who claims Plus on the website keeps a token that
// says otherwise until they sign in again.
roleAccount?.hasPlus === true
version
)
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
// grant_type=refresh_token). A refresh grant thus rotates its token.
const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId))
// A STAFF sign-in is an audit event in its own right. What these accounts then do is
// recorded against them on the `audit_log` table (`notify` files every `/internal/*`
// call there); this is the row saying the session those actions came from began — and
// from what device, build and IP, which is the first thing anyone asks when an action
// further down the log looks wrong. The flags themselves are the trigger, so an account
// granted the role between logins starts being recorded the next time it signs in.
//
// A real sign-in only: `refresh_token` renews the SAME session every TOKEN_TTL_SECONDS,
// and recording those would bury the logins under hourly repeats that say nothing new
// about who came in or from where.
//
// Written after the token is minted, so the row means a login that actually happened,
// and caught rather than awaited into the response: a failed audit insert must not turn
// a staffer's successful sign-in into a 500. It leaves a loud line in the worker log
// instead — the one thing a gap in an audit trail should never do quietly.
const staffRoles =
roleAccount && grantType !== 'refresh_token'
? (['developer', 'moderator'] as const).filter((role) => holdsRole(roleAccount, role))
: []
if (staffRoles.length > 0) {
try {
await writeAuditLog(c.env.DB, {
playerId: Number(accountId),
action: 'staff_login',
// The IP leads: it is the first thing anyone asks of a staff sign-in, and the one
// field here that says WHERE the session came from rather than what it could do.
// Empty outside the Cloudflare edge — recorded as the empty string it is rather
// than omitted, so a row can't be read as "we didn't record one".
//
// Then: which powers the session carries, HOW they signed in, and the same
// unverified-but-recorded device/build context the account row keeps — kept here
// too so the row stands on its own months later, after the account's
// `lastLoginIp` and device have been overwritten by every login since.
data: {
ip: clientIp,
roles: staffRoles,
grantType,
username: roleAccount?.username ?? null,
deviceId,
deviceClass,
version: version ?? null,
},
})
} catch (err) {
logger.error('could not write a staff_login audit log row', {
accountId,
grantType,
error: err instanceof Error ? err.message : String(err),
})
}
}
return c.json({
access_token: accessToken,
expires_in: TOKEN_TTL_SECONDS,
@@ -1271,29 +1158,26 @@ const app = new Hono<App>()
// is off by default and only an operator grants it (via `runx admin grant-developer`,
// which sets the account's isDeveloper flag); it also rides in the token's `role`
// claim (see accountRoles).
.get('/role/developer/:id', describeRoute(roleLookup('developer')), (c) =>
roleAnswer(c, 'developer', c.req.param('id'))
)
// The BULK form — `/role/developer?id=2&id=3` answers WHICH of those players are developers,
// as an array of ints. A route of its own rather than an optional `:id?` segment, and a
// different answer shape from the single lookup above: both are kept because the client asks
// both ways (the path form is the 2023 one).
.get('/role/developer', describeRoute(roleFilter('developer')), (c) =>
roleFilterAnswer(c, 'developer')
)
.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)
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
if (!account) return c.body(null, 404)
return c.json(account.isDeveloper === true)
})
// Moderator role lookup, mirroring developer (bare boolean, 404 for unknown player).
// Operator-granted only (via `runx admin grant-moderator`); the flag also rides in
// the token's `role` claim.
.get('/role/moderator/:id', describeRoute(roleLookup('moderator')), (c) =>
roleAnswer(c, 'moderator', c.req.param('id'))
)
// Moderator's bulk form, kept in step with developer's above.
.get('/role/moderator', describeRoute(roleFilter('moderator')), (c) =>
roleFilterAnswer(c, 'moderator')
)
.get('/role/moderator/:id', describeRoute(roleLookup('moderator')), async (c) => {
const { id } = c.req.param()
logger.info('moderator role lookup', { id })
const accountId = Number.parseInt(id, 10)
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
if (!account) return c.body(null, 404)
return c.json(account.isModerator === true)
})
// @guess Oculus nonce. The client asks for this before a Meta login; the exact shape
// it expects hasn't been observed, so this mints a fresh 64-char hex nonce (the length
@@ -1352,4 +1236,4 @@ app.get(
)
)
export default withDatabase<Env>(app)
export default app
+18 -50
View File
@@ -1,8 +1,6 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import { PlatformType } from '@repo/domain/src/enums'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
@@ -51,13 +49,24 @@ export function form(schema: z.ZodType, description: string): OpenAPIV3_1.Reques
}
/**
* PlatformType, the client's platform enum — the single source for the schema and
* description below. It lives in `@repo/domain` rather than here because the link table
* (`platform-db`) and the website's benefits claim both need the values, and neither has
* any business importing this module's zod/hono-openapi dependencies. Re-exported so
* `import { PlatformType } from './openapi'` keeps working alongside the schemas.
* 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 { PlatformType } from '@repo/domain/src/enums'
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 —
@@ -239,9 +248,7 @@ export function roleLookup(role: 'developer' | 'moderator') {
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.\n\n` +
`This is the ONE-player form. \`/role/${role}?id=\` is a different question with a ` +
`different answer shape — which of several players hold the role, as an array.`,
`in the access token's \`role\` claim, so the client rarely needs this route.`,
parameters: [
{
name: 'id',
@@ -258,45 +265,6 @@ export function roleLookup(role: 'developer' | 'moderator') {
}
}
/**
* The BULK form: `/role/{role}?id=1&id=2` answers which of those players hold the role, as an
* array of ints. Not the single lookup's boolean — a different question, so a different shape;
* the two live side by side because the client asks both ways.
*/
export function roleFilter(role: 'developer' | 'moderator') {
return {
tags: ['Roles'],
summary: `Which of these players have the ${role} role`,
description:
`The subset of the accounts named by \`?id=\` that hold the ${role} role, as an ARRAY ` +
`OF INTS — not a boolean, and not an object. Ids are repeated (\`?id=1&id=2\`), not ` +
`comma-joined.\n\n` +
`It is a FILTER, so a player who does not hold the role is simply absent from the ` +
`answer, as is an id matching no account — neither is an error, and a query naming no ` +
`ids answers \`[]\`. Results keep the order the query asked in, and a repeated id ` +
`yields at most one entry.\n\n` +
`The role is off by default and granted only by an operator via ` +
`\`runx admin grant-${role}\`.`,
parameters: [
{
name: 'id',
in: 'query' as const,
required: false,
description:
'An account id to test, repeated once per player. Ids that are non-numeric, unknown ' +
'or lack the role are left out of the answer rather than erroring.',
style: 'form' as const,
explode: true,
schema: { type: 'array' as const, items: { type: 'string' as const } },
example: ['2', '42'],
},
],
responses: {
200: json(z.int().array(), `The ids, of those asked for, that hold the ${role} role`),
},
}
}
/** 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'),
+11 -180
View File
@@ -19,36 +19,15 @@
* 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.
*
* Not every link is a LOGIN, though. The table is the account's set of external
* identities, and some are stored for what they entitle the player to rather than for
* entry: `PlatformType.Discord` (101) is written by the website's benefits claim, from an
* OAuth2 code exchange Discord itself vouched for. Nobody signs in with it —
* `verifyPlatformProof` answers `unsupported` for anything but Steam and Meta, so a
* `cached_login` naming platform 101 is refused — and the picker must not offer it
* either. See {@link CACHED_LOGIN_PLATFORMS}, which is what keeps those two in step.
*
* That entitlement is also the one thing a link carries beyond the identity itself: the
* `role` column (migration 0010) records the Discord roles the claim read, so what a
* player was granted Plus FOR survives the claim instead of collapsing into a bare
* `hasPlus`. The `www` worker's daily cron re-reads them through a bot token when one is
* configured (apps/www/src/discord-roles.ts). See {@link PlatformLink.roles}.
*/
import { PlatformType } from '@repo/domain/src/enums'
/**
* Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill, plus
* 0010's `role` column — which is an ALTER on prod and a plain column here, since this
* builds the table from nothing).
*/
/** 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,
role TEXT,
PRIMARY KEY (platform, platform_id, account_id)
)`,
// The picker's lookup: "which accounts does this identity open?". Covered by the
@@ -85,38 +64,6 @@ export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (ac
WHERE json_extract(data, '$.platformId') IS NOT NULL
AND json_extract(data, '$.platformId') <> ''`
/**
* The platforms a cached login can actually be redeemed for — the ones
* `verifyPlatformProof` can prove, which is Steam (a Steam-signed ticket) and Meta/Oculus
* (a Meta-validated nonce).
*
* This is the picker's filter, and it exists to keep a promise the picker's own API
* documentation makes: "an entry here is always redeemable by a `cached_login` grant
* (both read the same table)". Once the table began holding identities that are NOT
* credentials — Discord, from the website's benefits claim — listing every row would have
* broken that promise in two ways at once. The client would be offered an account it can
* never log into (the grant refuses platform 101 outright), and, worse, the picker is
* PUBLIC and unauthenticated: `GET /cachedlogin/forplatformid/101/<snowflake>` would have
* told anyone which RecFlare account a given Discord user owns, and the bulk route would
* have done it for a list of them at once. A Discord id is trivially readable by anyone in
* a shared server, so that is a deanonymisation of every player who claimed benefits.
*
* Adding a platform here means asserting `verifyPlatformProof` can prove it. Filtering
* happens in the two picker reads only — {@link isPlatformIdentityLinked},
* {@link countAccountsForPlatformIdentity} and {@link getLinksForAccount} deliberately see
* every link, because they answer "is this identity taken / whose is it", which is exactly
* the question the benefits claim's once-only guard asks about a Discord id.
*/
export const CACHED_LOGIN_PLATFORMS: readonly number[] = [PlatformType.Steam, PlatformType.Oculus]
/**
* `IN (…)` fragment for the allowlist, so the filter is applied by the query rather than
* in JS. Placeholders are numbered from ?2 because the one caller binds the platform id
* as ?1 — explicit indices rather than bare `?`, which SQLite would number by position
* and quietly renumber the moment another parameter is added ahead of it.
*/
const CACHED_LOGIN_FILTER = `platform IN (${CACHED_LOGIN_PLATFORMS.map((_, i) => `?${i + 2}`).join(', ')})`
/** One account ↔ platform identity link. */
export interface PlatformLink {
accountId: number
@@ -124,19 +71,6 @@ export interface PlatformLink {
platformId: string
/** ISO-8601 time the link was made. */
linkedAt: string
/**
* The roles this identity held in its platform's community when the link was last
* written — Discord role id snowflakes, kept as strings (one exceeds 2^53). Empty for
* every platform that has no such thing, which is all of them but Discord.
*
* A SNAPSHOT, not a live read. The benefits claim writes it from the member record the
* player's own OAuth token served, and the `www` worker's daily cron re-reads every
* Discord link through a bot token when the operator has configured one
* (apps/www/src/discord-roles.ts) — so with a bot it trails the guild by at most a day,
* and without one it says what they held when they claimed. Stored as a JSON array in
* the `role` column (migration 0010).
*/
roles: string[]
}
interface LinkRow {
@@ -144,39 +78,10 @@ interface LinkRow {
platform: number
platformId: string
linkedAt: string
/** The `role` column: a JSON array of role ids, or NULL for a link that has none. */
role: string | null
}
const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS platformId,
linked_at AS linkedAt, role FROM platform_account`
/**
* A stored `role` array back into role ids. Tolerant by design — every failure mode is
* "this link has no roles recorded", which is the truth for every Steam and Meta link and
* the honest answer for a row written before 0010. A NULL, a value that isn't JSON, or a
* JSON value that isn't an array of strings all land there rather than throwing: the roles
* are a record, and no caller's correctness depends on them, so a bad one must not take
* down the login picker that reads the same rows.
*/
const parseRoles = (raw: string | null): string[] => {
if (raw === null || raw === '') return []
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((r): r is string => typeof r === 'string') : []
} catch {
return []
}
}
/** One row as the rest of the codebase sees it. */
const toLink = (row: LinkRow): PlatformLink => ({
accountId: row.accountId,
platform: row.platform,
platformId: row.platformId,
linkedAt: row.linkedAt,
roles: parseRoles(row.role),
})
linked_at AS linkedAt FROM platform_account`
/**
* Link a verified platform identity to an account. Idempotent — re-logging in on the
@@ -185,89 +90,24 @@ const toLink = (row: LinkRow): PlatformLink => ({
*
* Callers must pass an identity the platform itself proved. Nothing in here can tell
* a verified id from a spoofed one.
*
* `roles` records what that identity held in its platform's community — Discord role ids,
* from the benefits claim's member read. Omitting it (every login path: Steam and Meta
* have no such thing) leaves the column NULL rather than writing an empty array, so
* "never had roles" stays distinguishable in the table from "claimed holding none". An
* EMPTY array is a real value and is stored as one.
*
* Unlike the rest of the row, roles are REFRESHED on a repeat link: they're a snapshot of
* a membership that moves, and the later claim is the fresher reading. That is also why
* the update is separate from the insert rather than an upsert — the insert's
* `meta.changes` is what tells a new link from an existing one, and an
* `ON CONFLICT DO UPDATE` reports a change either way, which would make every re-login
* look like a first link to {@link linkLoginIdentity}'s primary-identity write. The second
* statement only runs on the Discord path, and only when the row was already there.
*/
export async function linkPlatformIdentity(
db: D1Database,
accountId: number,
platform: number,
platformId: string,
roles?: readonly string[]
): Promise<boolean> {
if (platformId === '') return false
const role = roles === undefined ? null : JSON.stringify([...roles])
const res = await db
.prepare(
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at, role)
VALUES (?1, ?2, ?3, ?4, ?5)`
)
.bind(accountId, platform, platformId, new Date().toISOString(), role)
.run()
if (res.meta.changes > 0) return true
if (roles !== undefined) {
await setPlatformLinkRoles(db, accountId, platform, platformId, roles)
}
return false
}
/**
* Overwrite the roles recorded on an EXISTING link. The daily sweep's write
* (apps/www/src/discord-roles.ts), and the second half of {@link linkPlatformIdentity}'s
* re-claim. Returns whether a link matched: the sweep reads links and writes them back, so
* a false here means the row went away in between, which is worth a log line and nothing
* more.
*/
export async function setPlatformLinkRoles(
db: D1Database,
accountId: number,
platform: number,
platformId: string,
roles: readonly string[]
platformId: string
): Promise<boolean> {
if (platformId === '') return false
const res = await db
.prepare(
`UPDATE platform_account SET role = ?4
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(accountId, platform, platformId, JSON.stringify([...roles]))
.bind(accountId, platform, platformId, new Date().toISOString())
.run()
return res.meta.changes > 0
}
/**
* Every link on one platform, oldest first — the sweep's read, which walks all of them.
*
* Unfiltered by {@link CACHED_LOGIN_PLATFORMS} on purpose — its one caller asks about
* Discord, the platform that filter exists to exclude. It is not a lookup by identity, so
* the privacy argument for the filter (see {@link getLinksForPlatformId}) doesn't apply:
* nothing here is reachable from a request.
*/
export async function getLinksForPlatform(
db: D1Database,
platform: number
): Promise<PlatformLink[]> {
const { results } = await db
.prepare(`${SELECT_LINK} WHERE platform = ?1 ORDER BY linked_at, account_id`)
.bind(platform)
.all<LinkRow>()
return results.map(toLink)
}
/**
* 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
@@ -279,16 +119,13 @@ export async function getLinksForPlatformIdentity(
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
// Asking about a platform nobody can log in from yields nothing at all, rather than a
// list the grant would refuse — see CACHED_LOGIN_PLATFORMS.
if (!CACHED_LOGIN_PLATFORMS.includes(platform)) 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.map(toLink)
return results
}
/**
@@ -301,17 +138,11 @@ export async function getLinksForPlatformId(
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
// Matches on any platform a cached login can be redeemed for — but only those. This is
// the route a bare id takes, so without the filter a Discord snowflake posted here
// would resolve its account even though naming platform 101 explicitly would not.
const { results } = await db
.prepare(
`${SELECT_LINK} WHERE platform_id = ?1 AND ${CACHED_LOGIN_FILTER}
ORDER BY linked_at, account_id`
)
.bind(platformId, ...CACHED_LOGIN_PLATFORMS)
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
.bind(platformId)
.all<LinkRow>()
return results.map(toLink)
return results
}
/** Every platform identity linked to an account (a player's PC and headset, say). */
@@ -323,7 +154,7 @@ export async function getLinksForAccount(
.prepare(`${SELECT_LINK} WHERE account_id = ?1 ORDER BY linked_at, platform`)
.bind(accountId)
.all<LinkRow>()
return results.map(toLink)
return results
}
/**
+64 -486
View File
@@ -5,7 +5,6 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../auth.app'
import {
AUDIT_LOG_SCHEMA_DDL,
GAME_VERSION,
getAccountsByDeviceId,
hashPassword,
@@ -22,9 +21,7 @@ import {
createReport,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
} from '../../../../api/src/reports-db'
import { PlatformType } from '../../openapi'
import {
countAccountsForPlatformIdentity,
getLinksForAccount,
linkPlatformIdentity,
PLATFORM_BACKFILL_SQL,
@@ -71,9 +68,6 @@ beforeAll(async () => {
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()
// The audit trail a staff sign-in is recorded on. Owned by the `api` worker, whose
// migrations don't run here, so build it from the mirrored DDL.
for (const stmt of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Seed the accounts the credential-login tests use, each with LOGIN_PASSWORD set.
const hash = await hashPassword(LOGIN_PASSWORD)
@@ -93,8 +87,8 @@ beforeAll(async () => {
IsDorm: false,
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
})
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
// the token grant reads it for the evasion arms.
// Report table (owned by the api worker) — a banned account is refused a token, and
// a ban is a report row with `banned` set.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
@@ -427,106 +421,6 @@ describe('auth worker routes', () => {
])
})
// A Discord link is an external identity, not a credential — `www`'s benefits claim
// writes one so a claimed Discord user can't claim again on a second account. It must
// stay invisible to BOTH picker routes, for two independent reasons:
//
// - Every entry the picker lists is promised to be redeemable by a `cached_login`
// grant, and that grant refuses platform 101 outright (verifyPlatformProof answers
// `unsupported`). Listing one offers the client an account it can never log into.
// - These routes are PUBLIC and unauthenticated, and a Discord snowflake is readable by
// anyone sharing a server with its owner. Answering here would turn the login picker
// into a lookup from "Discord user" to "their RecFlare account", for every player who
// ever claimed benefits.
//
// The bare-id route is checked too, and it is the easier one to miss: it matches on ANY
// platform, so it would resolve the snowflake even though naming 101 explicitly did not.
test('never lists a Discord link in either cached-login picker', async () => {
const discordId = '308994132968210433'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 31399, username: 'DiscordClaimer', hasPlus: true }))
.run()
await linkPlatformIdentity(env.DB, 31399, PlatformType.Discord, discordId)
// Named explicitly…
const named = await exports.default.fetch(
`${ORIGIN}/cachedlogin/forplatformid/${PlatformType.Discord}/${discordId}`
)
expect(named.status).toBe(200)
expect(await named.json()).toEqual([])
// …and via the bare id, which matches across platforms.
const bare = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/any/${discordId}`)
expect(await bare.json()).toEqual([])
// …and through the bulk friends-resolution route, which takes bare ids only.
const bulk = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformids`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ id: discordId }).toString(),
})
expect(await bulk.json()).toEqual([])
// The link is still really there — this is a filtered READ, not a failed write.
await expect(
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, discordId)
).resolves.toBe(1)
})
// The `role` column (migration 0010): what the benefits claim read about a member, kept
// so "why does this account have Plus" is answerable later. Exercised through the helper
// rather than the claim route, which runs through discord.com.
test('records the roles a claimed Discord identity held, and refreshes them on a re-claim', async () => {
const discordId = '308994132968210434'
const supporter = '1077000000000000001'
const booster = '1077000000000000002'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 31400, username: 'RoleClaimer', hasPlus: true }))
.run()
await linkPlatformIdentity(env.DB, 31400, PlatformType.Discord, discordId, [supporter])
const first = await getLinksForAccount(env.DB, 31400)
// Snowflakes come back as STRINGS — parsed as numbers they'd round past 2^53 and stop
// matching the real role.
expect(first.map((l) => l.roles)).toEqual([[supporter]])
// A re-claim is the same row (INSERT OR IGNORE, so `linkedAt` is the first claim's)
// with a FRESH role list: a membership moves, and this reading is the later one.
await linkPlatformIdentity(env.DB, 31400, PlatformType.Discord, discordId, [supporter, booster])
const second = await getLinksForAccount(env.DB, 31400)
expect(second).toHaveLength(1)
expect(second[0]?.roles).toEqual([supporter, booster])
expect(second[0]?.linkedAt).toBe(first[0]?.linkedAt)
// Losing every role still stores the reading. Empty is a fact about the claim, not an
// absence of one — and it is what distinguishes this from a Steam link, below.
await linkPlatformIdentity(env.DB, 31400, PlatformType.Discord, discordId, [])
expect((await getLinksForAccount(env.DB, 31400))[0]?.roles).toEqual([])
const stored = await env.DB.prepare(
'SELECT role FROM platform_account WHERE account_id = 31400'
).first<{ role: string | null }>()
expect(stored?.role).toBe('[]')
})
// Every login path omits the argument, because Steam and Meta have no such thing. The
// column stays NULL there rather than becoming `[]`, so the table can still tell "never
// had roles" from "claimed holding none".
test('leaves the role column null for a link with no roles to record', async () => {
const steamId = '76561197962463299'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 31401, username: 'NoRoles' }))
.run()
await linkPlatformIdentity(env.DB, 31401, PlatformType.Steam, steamId)
const row = await env.DB.prepare(
'SELECT role FROM platform_account WHERE account_id = 31401'
).first<{ role: string | null }>()
expect(row?.role).toBeNull()
// A NULL reads back as no roles rather than throwing — which is also how a row written
// before 0010 reads.
expect((await getLinksForAccount(env.DB, 31401))[0]?.roles).toEqual([])
})
// The 20250424.01 build POSTs the picker lookup with a platform-attestation form body
// instead of GETting it. Nothing reads that body yet, so both methods must answer the
// same list — otherwise the newer client's login screen comes up empty.
@@ -683,9 +577,6 @@ describe('auth worker routes', () => {
expect(payload.role).not.toContain('junior')
// No privileges to carry, so the claim is absent rather than an empty array.
expect(payload['rn.privilege']).toBeUndefined()
// Same for Plus: omitted rather than `false`, so a non-subscriber's token is
// byte-for-byte what it was before `rn.plus` existed.
expect(payload['rn.plus']).toBeUndefined()
expect(payload.scope).toContain('rn.api')
})
@@ -724,64 +615,6 @@ describe('auth worker routes', () => {
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
})
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
// the website's Discord benefits claim sets. `econ` decides the CampusCard and the
// subscriber discount from this claim ALONE and never reads the account, so if this
// stops being stamped, Plus silently stops existing for everyone.
//
// It is a CLAIM, not a scope: `scope` is a fixed list the client parses, and this is
// ours. And it is not a role — the `developer` role does not confer Plus.
test('POST /connect/token stamps rn.plus for a hasPlus account', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 93,
username: 'PlusPlayer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
hasPlus: true,
})
)
.run()
const payload = await tokenFor(`account_id=93&password=${LOGIN_PASSWORD}`)
expect(payload['rn.plus']).toBe(true)
expect(payload.scope).not.toContain('rn.plus')
// Plus is not an elevated role, and does not come with one.
expect(payload.role).not.toContain('developer')
})
// The flag is read at LOGIN, so signing in again is what activates it — the website's
// claim page and `runx admin grant-plus` both say so, and this is the mechanism behind it.
//
// This also pins that `hasPlus` stands ALONE: the flag is set here by raw SQL, exactly as
// `runx admin grant-plus` sets it, with no Discord app configured, no OAuth exchange and
// no `platform_account` link anywhere. An operator must be able to grant Plus outright.
test('rn.plus refreshes on the next login after hasPlus is set, with no Discord link', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 94,
username: 'LateClaimer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
})
)
.run()
// Before claiming: no Plus.
expect((await tokenFor(`account_id=94&password=${LOGIN_PASSWORD}`))['rn.plus']).toBeUndefined()
// The website's claim writes the flag…
await env.DB.prepare(
"UPDATE account SET data = json_set(data, '$.hasPlus', json('true')) WHERE account_id = 94"
).run()
// …and the NEXT token carries it. The one already in the player's hands does not,
// which is exactly why they have to sign in again.
expect((await tokenFor(`account_id=94&password=${LOGIN_PASSWORD}`))['rn.plus']).toBe(true)
// Nothing linked a Discord identity to this account, and Plus does not care.
const links = await getLinksForAccount(env.DB, 94)
expect(links.filter((l) => l.platform === PlatformType.Discord)).toEqual([])
})
test('POST /connect/token stamps the junior role for an isJunior account', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
@@ -922,74 +755,6 @@ describe('auth worker routes', () => {
expect(row!.data).not.toContain('2.0')
})
// The grant the live client actually logs in with, and the one path whose device
// recording had no test — `create_account` and the password grant were both pinned,
// cached_login only happened to work. The body is the shape the 2025 client posts (see
// the capture the device id below comes from): device_id alongside the platform
// attestation, on every sign-in.
//
// Nothing verifies a device id — the client picks it — so it is recorded and never
// authorized on. It is stored for ban EVASION to read later: an account logging in from
// the same install as a banned one. See `getAccountsByDeviceId`.
test('POST /connect/token cached_login stores the login device on the account', async () => {
const metaId = '27061366730201234'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 5151,
username: 'CachedDevicePlayer',
platform: 1,
platformId: metaId,
})
)
.run()
await linkPlatformIdentity(env.DB, 5151, 1, metaId)
const deviceId = '69640e6ae1b54ae5b0ca8eeb4a8872ec6cf8fd88'
const res = await metaLogin(
`grant_type=cached_login&account_id=5151&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}` +
`&device_id=${deviceId}&device_class=2&isInitialLogin=true&locale=en`,
true
)
expect(res.status).toBe(200)
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(5151)
.first<{ data: string }>()
const account = JSON.parse(row!.data) as { deviceId: string; deviceClass: number }
expect(account.deviceId).toBe(deviceId)
expect(account.deviceClass).toBe(2)
// And findable by it, which is the whole reason it is kept.
expect((await getAccountsByDeviceId(env.DB, deviceId)).map((a) => a.accountId)).toContain(5151)
})
// A refresh is NOT a login: the client redeems a stored token and re-attests nothing,
// so there is no device to believe and the grant records none. Pinned because the
// alternative is worse than it looks — trusting a device_id posted alongside a refresh
// would let a client move an account's recorded device without ever proving anything,
// which is exactly the signal ban evasion is meant to read.
test('POST /connect/token refresh_token leaves the recorded device alone', async () => {
const first = await postToken(
`grant_type=password&username=Player77&password=${LOGIN_PASSWORD}` +
'&device_id=dev-77-real&device_class=2'
)
expect(first.status).toBe(200)
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${first.json.refresh_token as string}` +
'&device_id=dev-77-spoofed&device_class=9'
)
expect(refreshed.status).toBe(200)
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(77)
.first<{ data: string }>()
const account = JSON.parse(row!.data) as { deviceId: string; deviceClass: number }
expect(account.deviceId).toBe('dev-77-real')
expect(account.deviceClass).toBe(2)
})
test('POST /connect/token refreshes the stored device on a credential login', async () => {
// Account 42 was seeded with no device; a later login records the one it came from.
const res = await postToken(
@@ -1454,80 +1219,6 @@ describe('auth worker routes', () => {
expect((await exports.default.fetch(`${ORIGIN}/role/moderator/99999`)).status).toBe(404)
})
// `/role/{role}?id=` is a BULK FILTER — which of these players hold the role, as an array
// of ints — not the single lookup's boolean. Different question, different shape.
test('GET /role/developer?id= answers which of the players are developers', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
.run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4244, username: 'DevPlayer2', isDeveloper: true }))
.run()
// INTS, not strings, and not a boolean.
const one = await exports.default.fetch(`${ORIGIN}/role/developer?id=4242`)
expect(one.status).toBe(200)
expect(await one.json()).toEqual([4242])
// The subset that holds the role: 42 exists without the flag and 99999 not at all, and
// neither is an error — a filter answers "none of them", never 404.
const many = await exports.default.fetch(
`${ORIGIN}/role/developer?id=42&id=4242&id=99999&id=4244`
)
expect(many.status).toBe(200)
expect(await many.json()).toEqual([4242, 4244])
// Ordered as asked, so a caller can read the answer against its own list.
const reversed = await exports.default.fetch(`${ORIGIN}/role/developer?id=4244&id=4242`)
expect(await reversed.json()).toEqual([4244, 4242])
// A repeated id yields at most one entry.
const repeated = await exports.default.fetch(`${ORIGIN}/role/developer?id=4242&id=4242`)
expect(await repeated.json()).toEqual([4242])
})
test('GET /role/developer?id= answers [] rather than erroring', async () => {
// No ids, only unknown ids, only un-flagged ids, and a non-numeric id all answer an empty
// array with a 200 — nothing about this question is a 400 or a 404.
for (const query of ['', '?id=99999', '?id=42', '?id=nonsense', '?id=']) {
const res = await exports.default.fetch(`${ORIGIN}/role/developer${query}`)
expect(res.status, query).toBe(200)
expect(await res.json(), query).toEqual([])
}
})
// The repeated parameter is the whole point: reading only the first would silently answer
// about one player out of the set.
test('GET /role/developer?id= reads every repeated id, not just the first', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
.run()
const res = await exports.default.fetch(`${ORIGIN}/role/developer?id=42&id=4242`)
expect(await res.json()).toEqual([4242])
})
test('GET /role/moderator?id= filters on the moderator flag alone', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4343, username: 'ModPlayer', isModerator: true }))
.run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
.run()
// The two roles are independent flags: a developer is not implicitly a moderator.
const res = await exports.default.fetch(`${ORIGIN}/role/moderator?id=4242&id=4343&id=42`)
expect(await res.json()).toEqual([4343])
})
// The single lookup keeps its own shape — the bulk form must not have changed it.
test('GET /role/developer/:id is still a bare boolean, not an array', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
.run()
expect(await (await exports.default.fetch(`${ORIGIN}/role/developer/4242`)).json()).toBe(true)
expect(await (await exports.default.fetch(`${ORIGIN}/role/developer/42`)).json()).toBe(false)
expect((await exports.default.fetch(`${ORIGIN}/role/developer/99999`)).status).toBe(404)
})
test('unknown path returns 404', async () => {
const res = await exports.default.fetch(`${ORIGIN}/nope`)
expect(res.status).toBe(404)
@@ -1558,9 +1249,7 @@ describe('auth worker routes', () => {
'GET /eac/challenge',
'GET /oculus/nonce',
'GET /privileges/me/restrictions',
'GET /role/developer',
'GET /role/developer/{id}',
'GET /role/moderator',
'GET /role/moderator/{id}',
'POST /account/me/changepassword',
'POST /cachedlogin/forplatformid/{platform}/{id}',
@@ -1638,35 +1327,38 @@ describe('CORS', () => {
})
})
// A banned account is still issued a token: the game client needs one to reach the api
// worker's moderationBlockDetails, which is where the player is shown WHY they are
// blocked. The ban is a `report` row with `banned` set (the api worker owns that table)
// and is enforced by matchmaking, which refuses every matchmake for a banned player — so
// the token gets them to the block screen and no further.
// A banned account is refused a token at all — the outer wall of a ban, since with no
// token every other worker is shut to it. The ban is a `report` row with `banned` set
// (the api worker owns that table); matchmaking enforces the same ban on tokens issued
// before it was handed down.
describe('banned accounts', () => {
test('POST /connect/token issues a token to a banned account', async () => {
test('POST /connect/token refuses a password grant from a banned account', async () => {
await seedAccount(6101, 'BannedPlayer')
await banAccount(6101)
const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6101')
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
// The exact sentence www's shared auth-messages table keys on to put a real
// message in front of the player — changing it silently downgrades that to the
// generic "you could not be signed in".
expect(res.json.error_description).toBe('this account is banned')
})
test('POST /connect/token issues a token to a banned account logging in by username', async () => {
test('POST /connect/token refuses a username login from a banned account', async () => {
await seedAccount(6102, 'BannedByName')
await banAccount(6102)
const res = await postToken(
`grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}`
)
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6102')
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this account is banned')
})
// A client that was already signed in when the ban landed refreshes as normal — its
// next matchmake is what refuses it, and moderationBlockDetails says why.
test('POST /connect/token refreshes a banned account’s session', async () => {
// A client that was already signed in when the ban landed still holds a valid refresh
// token; redeeming it must not renew the session.
test('POST /connect/token refuses to refresh a banned account’s session', async () => {
await seedAccount(6103, 'BannedLater')
const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`)
expect(login.status).toBe(200)
@@ -1676,12 +1368,13 @@ describe('banned accounts', () => {
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
)
expect(refreshed.status).toBe(200)
expect(decodePayload(refreshed.json.access_token as string).sub).toBe('6103')
expect(refreshed.status).toBe(400)
expect(refreshed.json.error_description).toBe('this account is banned')
})
// A ban does not loosen the credential check: a wrong password on a banned account is
// the ordinary bad-credential refusal.
// The ban check runs AFTER the credential check, so a wrong password on a banned
// account still answers the ordinary bad-credential refusal — it can't be used to
// find out whether an account exists or is banned without knowing its password.
test('a wrong password on a banned account is still a credential refusal', async () => {
await seedAccount(6104, 'BannedWrongPw')
await banAccount(6104)
@@ -1691,13 +1384,23 @@ describe('banned accounts', () => {
expect(res.json.error_description).toBe('invalid account_id or password')
})
test('a ban that has not expired yet still issues a token', async () => {
// A timed ban lifts itself when its expiry passes; nothing clears the flag.
test('an expired ban lets the account sign in again', async () => {
await seedAccount(6105, 'ServedTime')
await banAccount(6105, '2020-01-01T00:00:00.000Z')
const res = await postToken(`account_id=6105&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6105')
})
test('a ban that has not expired yet still refuses the login', async () => {
await seedAccount(6106, 'StillServing')
await banAccount(6106, new Date(Date.now() + 3_600_000).toISOString())
const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6106')
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this account is banned')
})
// A report is not a ban until a moderator converts it.
@@ -1720,11 +1423,9 @@ describe('banned accounts', () => {
})
})
// The ban follows the player past the account it was written on. SIGNING IN is not where
// that is enforced: a linked account gets its token like any other, and `match` holds it to
// its own dorm — refusing the token only ever left the client stuck on a failed login. What
// IS refused here is a SIGNUP carrying either link, before it mints anything: making a new
// account to evade with is a different act from signing into one. See the api worker's
// The ban follows the player past the account it was written on: a login from an account
// that shares a proven platform identity or an IP with a banned one is refused, and a
// signup carrying either is refused before it mints anything. See the api worker's
// bans-db.ts for the arms and the BAN_EVASION_MATCH knob.
describe('ban evasion at the token endpoint', () => {
/** Seed a loginable account carrying the IPs it signed up / last logged in from. */
@@ -1744,38 +1445,38 @@ describe('ban evasion at the token endpoint', () => {
const login = (id: number, ip?: string) =>
postToken(`account_id=${id}&password=${LOGIN_PASSWORD}`, ip)
test('an account sharing a banned account’s platform identity still signs in', async () => {
test('an account sharing a banned account’s platform identity cannot log in', async () => {
await account(6301, 'EvaderOne')
await linkPlatformIdentity(env.DB, 6301, 0, 'steam-tokenevader')
await banAccount(6301)
await account(6302, 'EvaderTwo')
await linkPlatformIdentity(env.DB, 6302, 0, 'steam-tokenevader')
// A token, like anyone else: the client has to load in before it can be told anything,
// and `match` is what keeps this account out of every room but its dorm.
const res = await login(6302)
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6302')
expect(res.status).toBe(400)
// A vaguer sentence than a direct ban: this account may belong to somebody else.
expect(res.json.error_description).toBe('this device or network is blocked')
})
test('an account sharing a banned account’s IP still signs in', async () => {
test('an account sharing a banned account’s IP cannot log in', async () => {
await account(6303, 'SameHouseBanned', { signupIp: '203.0.113.30' })
await banAccount(6303)
await account(6304, 'SameHouseClean', { signupIp: '203.0.113.30' })
expect((await login(6304)).status).toBe(200)
const res = await login(6304)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this device or network is blocked')
})
// The address the request arrives from counts, so a SIGNUP from the banned network is
// caught on the first attempt rather than the second.
test('the request’s own IP is matched at signup even when the account has none stored', async () => {
// The address the request arrives from counts, so an account that never logged in
// from the banned network before is caught on the first attempt rather than the second.
test('the request’s own IP is matched even when the account has none stored', async () => {
await account(6305, 'BannedAtHome', { signupIp: '203.0.113.31' })
await banAccount(6305)
await account(6306, 'CleanElsewhere')
expect((await postToken('grant_type=create_account', '203.0.113.31')).status).toBe(400)
// Signing in from that same network is not refused — only minting a new account is.
expect((await login(6306, '203.0.113.31')).status).toBe(200)
expect((await login(6306, '203.0.113.31')).status).toBe(400)
// The same account from any other network signs in normally.
expect((await login(6306, '198.51.100.31')).status).toBe(200)
})
@@ -1804,9 +1505,8 @@ describe('ban evasion at the token endpoint', () => {
expect(res.status).toBe(200)
})
// The knob an operator reaches for when the IP arm locks out real players. It governs the
// linked arms wherever they are applied — at signup here, and at every matchmake in `match`.
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the platform one', async () => {
// The knob an operator reaches for when the IP arm locks out real players.
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the direct ban', async () => {
const original = env.BAN_EVASION_MATCH
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
await linkPlatformIdentity(env.DB, 6320, 0, 'steam-knobevader')
@@ -1817,141 +1517,19 @@ describe('ban evasion at the token endpoint', () => {
try {
env.BAN_EVASION_MATCH = 'platform'
// Signup from the banned IP is open again, since that arm is off...
expect((await login(6321)).status).toBe(200)
expect((await login(6322)).status).toBe(400)
// And signup from that network is open again.
expect((await postToken('grant_type=create_account', '203.0.113.50')).status).toBe(200)
// ...while the platform arm still refuses one from the banned headset.
expect(
(await postToken('grant_type=create_account&platform=0&platform_id=steam-knobevader'))
.status
).toBe(400)
// Signing in is not what the knob gates: every one of these gets a token whatever it
// says — the housemate, the evader, and the banned account itself.
for (const arms of ['platform', 'off']) {
env.BAN_EVASION_MATCH = arms
for (const id of [6320, 6321, 6322]) {
expect((await login(id)).status, `${arms}/${id}`).toBe(200)
}
}
env.BAN_EVASION_MATCH = 'off'
expect((await login(6322)).status).toBe(200)
// The banned account itself is refused whatever the knob says.
const banned = await login(6320)
expect(banned.status).toBe(400)
expect(banned.json.error_description).toBe('this account is banned')
} finally {
env.BAN_EVASION_MATCH = original
}
})
})
// A staff sign-in is recorded on the `audit_log` table — the same table `notify` files
// every `/internal/*` call on. What those accounts then do is attributed to them; this is
// the row that says the session it came from began, and from what device, build and IP.
describe('staff login audit', () => {
interface AuditRow {
player_id: number
action: string
data: string | null
date: string
}
const staffLogins = async (accountId: number): Promise<AuditRow[]> => {
const { results } = await env.DB.prepare(
`SELECT player_id, action, data, date FROM audit_log
WHERE player_id = ?1 AND action = 'staff_login' ORDER BY audit_log_id`
)
.bind(accountId)
.all<AuditRow>()
return results
}
/** Seed an account with the given flags and a known password. */
const seedAccount = async (accountId: number, data: Record<string, unknown>) =>
env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
username: `Staff${accountId}`,
passwordHash: await hashPassword(LOGIN_PASSWORD),
...data,
})
)
.run()
test('a login by an isModerator account is recorded, with the context of the sign-in', async () => {
await seedAccount(9601, { username: 'ModPlayer', isModerator: true })
const res = await postToken(
`grant_type=password&account_id=9601&password=${LOGIN_PASSWORD}` +
'&device_id=dev-mod&device_class=2&ver=20230414',
'203.0.113.9'
)
expect(res.status).toBe(200)
const rows = await staffLogins(9601)
expect(rows).toHaveLength(1)
expect(Number.isNaN(Date.parse(rows[0].date))).toBe(false)
const data = JSON.parse(rows[0].data!) as Record<string, unknown>
expect(data).toMatchObject({
ip: '203.0.113.9',
roles: ['moderator'],
grantType: 'password',
username: 'ModPlayer',
deviceId: 'dev-mod',
deviceClass: 2,
version: '20230414',
})
// The IP LEADS the payload: it is the first thing anyone asks of a staff sign-in, and
// the reader of a raw row should not have to hunt for it.
expect(Object.keys(data)[0]).toBe('ip')
})
// Both flags on one account record both — the row says which powers the session carries,
// not merely that it carries some.
test('an account holding both flags records both roles', async () => {
await seedAccount(9602, { isDeveloper: true, isModerator: true })
expect((await postToken(`account_id=9602&password=${LOGIN_PASSWORD}`)).status).toBe(200)
const rows = await staffLogins(9602)
expect(rows).toHaveLength(1)
expect((JSON.parse(rows[0].data!) as { roles: string[] }).roles).toEqual([
'developer',
'moderator',
])
})
// The flags are the whole trigger. An ordinary player signing in is not an audit event,
// and recording every login would bury the ones that are.
test('an ordinary login records nothing', async () => {
await seedAccount(9603, {})
expect((await postToken(`account_id=9603&password=${LOGIN_PASSWORD}`)).status).toBe(200)
expect(await staffLogins(9603)).toHaveLength(0)
})
// A refresh renews the SAME session every TOKEN_TTL_SECONDS. Recording those would bury
// the actual sign-ins under hourly repeats saying nothing new about who came in or from
// where — so the login is recorded once, and the refreshes after it are not.
test('a refresh_token grant is not a login and is not recorded', async () => {
await seedAccount(9604, { isDeveloper: true })
const first = await postToken(`account_id=9604&password=${LOGIN_PASSWORD}`)
expect(first.status).toBe(200)
expect(await staffLogins(9604)).toHaveLength(1)
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${first.json.refresh_token as string}`
)
expect(refreshed.status).toBe(200)
expect(await staffLogins(9604)).toHaveLength(1)
})
// The flag is read at LOGIN, exactly as `rn.plus` and the token's `role` claim are, so
// granting the role starts the trail at their next sign-in rather than retroactively.
test('an account granted the role starts being recorded on its next login', async () => {
await seedAccount(9605, {})
expect((await postToken(`account_id=9605&password=${LOGIN_PASSWORD}`)).status).toBe(200)
expect(await staffLogins(9605)).toHaveLength(0)
await env.DB.prepare(
`UPDATE account SET data = json_set(data, '$.isModerator', json('true')) WHERE account_id = ?1`
)
.bind(9605)
.run()
expect((await postToken(`account_id=9605&password=${LOGIN_PASSWORD}`)).status).toBe(200)
expect(await staffLogins(9605)).toHaveLength(1)
})
})
+4 -6
View File
@@ -9,11 +9,6 @@
// history separate from the rooms worker's migrations on the same database. The
// "local" placeholder is replaced with the real id from RECFLARE_D1 (see .env) at
// deploy time.
//
// It also WRITES one table it doesn't own: a sign-in by an account holding the
// isDeveloper/isModerator flag is recorded on `audit_log` as a `staff_login`
// (apps/api/migrations/0024_audit_log.sql, owned by the `api` worker). That is the row
// saying the session every later audited action came from began, and from what IP.
"d1_databases": [
{
"binding": "DB",
@@ -48,7 +43,10 @@
],
"upload_source_maps": true,
"observability": {
"enabled": false
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
},
// The signup caps (MAX_ACCOUNTS_PER_PLATFORM_ID, MAX_ACCOUNTS_PER_IP) are deliberately
// NOT set here. They're injected at deploy time from the gitignored .env
+2 -2
View File
@@ -16,7 +16,7 @@
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"hono": "4.13.5",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
},
"devDependencies": {
@@ -25,6 +25,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.128.0"
"wrangler": "4.105.0"
}
}
+4 -1
View File
@@ -16,7 +16,10 @@
],
"upload_source_maps": true,
"observability": {
"enabled": false
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
+2 -2
View File
@@ -15,8 +15,8 @@ route of its own, bundled rather than an asset, because its file is named differ
its path.
Objects are keyed by prefix — `sigs/` (anti-cheat signatures), `room/` (saved room
scenes, and room images by their bare `ImageName`), `invention/` (invention data), `data/`
(generic client uploads), `avatar/` (custom avatar item assetbundles) — and served as `application/octet-stream`; the worker never interprets what it hands back.
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.
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.13.5",
"hono": "4.12.27",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.128.0"
"wrangler": "4.105.0"
}
}
+2 -28
View File
@@ -329,30 +329,6 @@ const app = new Hono<App>()
(c) => serveAsset(c, `data/${c.req.param('id')}`)
)
// Custom avatar item assetbundles by name. A first-party custom avatar item (see the
// `api` worker's `custom_avatar_item`) is rendered from its `CurrentSaves`, each of
// which names a built Unity assetbundle by bare filename (`UnityAsset`, e.g.
// `anx442dm1a79kp9n4kugkbgd0.assetbundle`) that the client fetches from `/avatar/`.
// Streamed from R2 under `avatar/`. The rest of the path is matched so a foldered
// upload would resolve the same way as the other blob prefixes.
.get(
'/avatar/:asset{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve a custom avatar item assetbundle',
description: [
'Streams the object stored under `avatar/<asset>` — the built Unity assetbundle the',
'client downloads to render a first-party custom avatar item on one body type. The',
'name comes from a save’s `UnityAsset` (or `UnityAsset2`) in the item’s `CurrentSaves`',
'(see the `api` worker), a bare filename such as `anx442dm1a79kp9n4kugkbgd0.assetbundle`.',
'The worker does not interpret the bytes.',
].join(' '),
parameters: [keyParam('asset', 'The assetbundle filename.', true), ...CONDITIONAL_HEADERS],
responses: assetResponses('The assetbundle'),
}),
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
)
// 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(
@@ -367,13 +343,11 @@ app.get(
description: [
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
'signatures, saved room scenes, invention data, generic client uploads and custom',
'avatar item assetbundles — out of',
'signatures, saved room scenes, invention data and generic client uploads — out of',
'the shared `recflare-cdn` R2 bucket, plus the JSON config files the client reads',
'from `/config/`.',
'',
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`, `avatar/`) and',
'served as',
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
'are unauthenticated — a caller needs the exact key, which only comes from an',
'authenticated call to another worker.',
-16
View File
@@ -271,21 +271,6 @@ describe('cdn endpoints', () => {
expect(res.status).toBe(404)
})
test('GET /avatar/:asset streams the custom avatar item assetbundle from R2', async () => {
// A bare filename — what a first-party item's `CurrentSaves[].UnityAsset` names.
const name = 'anx442dm1a79kp9n4kugkbgd0.assetbundle'
await env.CDN_ASSETS.put(`avatar/${name}`, new Uint8Array([7, 8, 9]))
const res = await exports.default.fetch(`${ORIGIN}/avatar/${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([7, 8, 9]))
})
test('GET /avatar/:asset 404s when the assetbundle is absent', async () => {
const res = await exports.default.fetch(`${ORIGIN}/avatar/missing.assetbundle`)
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)
@@ -308,7 +293,6 @@ describe('cdn endpoints', () => {
)
expect([...documented].sort()).toEqual([
'GET /',
'GET /avatar/{asset}',
'GET /config/LoadingScreenTipData',
'GET /config/{name}',
'GET /data/{id}',
+4 -4
View File
@@ -6,7 +6,7 @@
"RoomNames": [],
"Context": 0,
"InputType": 0,
"Visibility": 1,
"Visibility": 0,
"AllowCycling": true,
"RestrictToNewUsers": false,
"ImageName": "tip.jpg",
@@ -34,7 +34,7 @@
"RoomNames": [],
"Context": 0,
"InputType": 0,
"Visibility": 1,
"Visibility": 0,
"AllowCycling": true,
"RestrictToNewUsers": false,
"ImageName": "tip.jpg",
@@ -48,7 +48,7 @@
"RoomNames": [],
"Context": 0,
"InputType": 0,
"Visibility": 1,
"Visibility": 0,
"AllowCycling": true,
"RestrictToNewUsers": false,
"ImageName": "tip.jpg",
@@ -139,4 +139,4 @@
"PlatformMask": 239,
"CreatedAt": "2019-02-28T18:21:25Z"
}
]
]
+4 -1
View File
@@ -52,7 +52,10 @@
],
"upload_source_maps": true,
"observability": {
"enabled": false
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
-12
View File
@@ -1,12 +0,0 @@
-- Give a thread its kind. `chat_thread_type` is the client's ChatThreadType enum
-- (0 Player · 1 Club · 2 Party), which 0002 left off because every thread this server
-- served was a plain player conversation and src/thread-db.ts answered a constant 0.
-- Party threads (`POST /thread/party`) are type 2, so the value now varies per row and
-- has to be stored. Generated from src/thread-db.ts (THREAD_SCHEMA_DDL) — keep in sync.
--
-- Defaulted 0 (Player), which is what every existing row is: the column is backfilled by
-- the default, not by an UPDATE.
--
-- No index: it is read alongside the thread row that is already being fetched by primary
-- key or by the membership join, never selected on.
ALTER TABLE message_thread ADD COLUMN chat_thread_type INTEGER NOT NULL DEFAULT 0;
+2 -3
View File
@@ -17,12 +17,11 @@
},
"dependencies": {
"@2toad/profanity": "3.3.0",
"@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.13.5",
"hono": "4.12.27",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -34,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.128.0"
"wrangler": "4.105.0"
}
}
+21 -323
View File
@@ -2,7 +2,6 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withDatabase } from '@repo/domain'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
@@ -17,7 +16,6 @@ import {
ChatResult,
ChatThreadDto,
ChatThreadWithMessagesDto,
CreatePartyChatResponse,
CreateThreadRequest,
CreateThreadResponse,
FavoriteThreadRequest,
@@ -25,8 +23,8 @@ import {
json,
messageCountParam,
NOT_A_MEMBER_RESPONSE,
PartyChatThread,
PartyInviteSettings,
PartyThread,
RenameThreadRequest,
SendMessageRequest,
SendMessageResponse,
@@ -38,16 +36,11 @@ import {
} from './openapi'
import {
addThreadMember,
ChatThreadType,
createThread,
getOrCreateThreadWithMembers,
getPartyThreadForPlayer,
getThreadForPlayer,
getThreadMemberIds,
getThreadMeta,
getThreadsForPlayer,
isThreadMember,
joinedChatContents,
leftChatContents,
markThreadRead,
postMessage,
@@ -61,7 +54,6 @@ import {
import type { Context } from 'hono'
import type { App, Env } from './context'
import type { ChatMessage } from './message-db'
import type { ChatThread } from './thread-db'
/**
* Resolve the account id from a Bearer token. Returns `null` when the header is
@@ -115,25 +107,6 @@ const CHAT_PLAYER_ALREADY_ON_THREAD = 4
*/
const PARTY_INVITE_LIFETIME_MINUTES = 60
/**
* Whether a party is still open to newcomers — `GET /thread/party` joins the caller only
* inside this window, measured from the thread's `created_at`.
*
* It is the invite lifetime above, deliberately the same number rather than a second one:
* a player joins a party by holding its id in `LatestPartyChat`, which is what an invite
* puts there, so the join is the redemption of that invite and can't outlive it. A party
* older than the window still belongs to the people already on it — this gates JOINING,
* not reading, so nobody's own party expires out from under them.
*
* Fails CLOSED on a `created_at` that won't parse: no timestamp, no join. Nothing writes
* one that can't, and the alternative is an unbounded join window on a corrupt row.
*/
function isPartyJoinable(createdAt: string, now = Date.now()): boolean {
const opened = Date.parse(createdAt)
if (Number.isNaN(opened)) return false
return now - opened <= PARTY_INVITE_LIFETIME_MINUTES * 60_000
}
/**
* Who may start a chat with a player — the client's `ChatPrivacy` enum, served numerically
* like every other enum on this build. `Friends` is what a fresh account reports, and what
@@ -161,20 +134,6 @@ const CHAT_PRIVACY_NAMES = ['Friends', 'Favorites', 'NoOne'] as const
const DM_PRIVACY_KEY = 'directMessagePrivacySetting'
const GROUP_PRIVACY_KEY = 'groupChatPrivacySetting'
/**
* Where a player's CURRENT party lives: the thread id of the party they most recently
* opened, written by `POST /thread/party` and read back by the GET on the same path.
*
* It is a player setting rather than a column because the party is a property of the
* PLAYER, not of the thread — "which party am I in" has one answer per person, and a
* player is in exactly one at a time. The settings bag is already read and written per
* player here, the same way the two privacy settings are.
*
* Nothing clears it: a party the player has left, or one that no longer exists, is
* filtered out on the read instead, which also covers an id written by something else.
*/
const LATEST_PARTY_CHAT_KEY = 'LatestPartyChat'
/**
* A `ChatPrivacy` out of whatever was stored or posted — the member name as the client
* sends it (case-insensitively), or the ordinal as the GET serves it, since a value that
@@ -241,87 +200,16 @@ async function writeChatPrivacy(
accountId: number,
settings: Partial<Record<typeof DM_PRIVACY_KEY | typeof GROUP_PRIVACY_KEY, ChatPrivacyValue>>
): Promise<void> {
const patch: Record<string, string> = {}
const merged: Record<string, string> = { ...(await getPlayerSettings(env, accountId)) }
for (const [key, value] of Object.entries(settings)) {
if (value !== undefined) patch[key] = CHAT_PRIVACY_NAMES[value]
if (value !== undefined) merged[key] = CHAT_PRIVACY_NAMES[value]
}
await mergePlayerSettings(env, accountId, patch)
}
/**
* Merge keys into the player's settings map, the way the `playersettings` worker's own PUT
* does. Never a whole-map write: the bag holds every setting the player has (OOBE state,
* tutorial mask, …) and storing one key on its own would wipe the rest. Values are strings,
* which is what that worker stores and what its GET serves back.
*/
async function mergePlayerSettings(
env: Env,
accountId: number,
patch: Record<string, string>
): Promise<void> {
const merged: Record<string, string> = { ...(await getPlayerSettings(env, accountId)), ...patch }
await env.RECFLARE_PLAYER_SETTINGS.put(`player:${accountId}`, JSON.stringify(merged))
}
/**
* The thread id in the player's `LatestPartyChat` setting, or null when they have no party
* — nothing stored, or something stored that isn't a positive integer (the bag's values are
* strings, and this one could have been written by hand).
*/
async function readLatestPartyChatId(env: Env, accountId: number): Promise<number | null> {
const stored = (await getPlayerSettings(env, accountId)) ?? {}
const id = Number.parseInt(String(stored[LATEST_PARTY_CHAT_KEY] ?? ''), 10)
return Number.isNaN(id) || id <= 0 ? null : id
}
/** The hub is a single global Durable Object instance, as every worker addresses it. */
const HUB_INSTANCE = 'global'
/**
* Push a thread's most recent message to everyone on it. This is how a NEW thread
* announces itself.
*
* The client has exactly two chat channels, `ChatMessageReceived` and `PlayerLeftChat`,
* and both carry a MESSAGE: there is no "a thread was opened" or "you were added" frame to
* send. So a conversation someone gains access to stays invisible on their client until a
* message arrives on it — which is why `/thread/withmembers` used to go unnoticed until the
* sender typed something, the thread having been created with only its "started a chat"
* notice and that notice never having left the database.
*
* Sending the notice fixes that without inventing anything: the message being pushed is one
* that genuinely exists on the thread. A no-op for a thread with nothing in it.
*/
async function pushThreadLatestMessage(c: Context<App>, chatThreadId: number): Promise<void> {
const [latest] = await getThreadMessages(c.env.DB, chatThreadId, { limit: 1 })
if (latest === undefined) return
await pushChatMessage(c, latest)
}
/**
* Announce that a player is now on a thread: post the `Player <@U…> joined` notice and push
* it to the whole thread.
*
* The exact shape of the leave route's goodbye, and for the same reasons. Everyone is told,
* not just the player who joined: a roster change is the thread's business — the others
* need to know who they are talking to — and there is no roster channel to say it on, so
* the notice is the message AND the signal. The new member is a member by the time this
* runs, so the same push is what puts the conversation on their screen.
*
* Call it AFTER the membership row exists, or the joiner is left out of the fan-out.
*/
async function announceJoin(
c: Context<App>,
chatThreadId: number,
playerId: number
): Promise<void> {
const notice = await postMessage(c.env.DB, {
chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
contents: joinedChatContents(playerId),
})
await pushChatMessage(c, notice)
}
/**
* Push ChatMessageReceived to everyone in the thread once a message lands, so the
* conversation updates live instead of on the next poll.
@@ -415,33 +303,6 @@ function toSentChatMessage(message: ChatMessage) {
}
}
/**
* A thread in the PascalCase shape `POST /thread/party` answers — the client's
* CreatePartyChat formatter, which is its own and reads none of the camelCase keys the
* thread payloads carry. Ten wire keys; see {@link PartyChatThread} for why the CLR
* type's other three never appear.
*
* `Messages` and `LatestMessage` are both present here, unlike the camelCase pair which
* carries one or the other, and `ChatThreadName` goes out NULL when unnamed rather than
* as the empty string the camelCase projections must send.
*/
function toPartyChatThread(thread: ChatThread, messages: ChatMessage[]) {
return {
ChatThreadId: thread.chatThreadId,
ChatThreadType: thread.chatThreadType,
LastReadMessageId: thread.lastReadMessageId,
Messages: messages.map(toSentChatMessage),
LatestMessage: thread.latestMessage === null ? null : toSentChatMessage(thread.latestMessage),
PlayerIds: thread.playerIds,
// This formatter takes the null; only the camelCase projections have to send ''.
ChatThreadName: thread.chatThreadName === '' ? null : thread.chatThreadName,
SnoozedUntil: thread.snoozedUntil,
IsFavorited: thread.isFavorited,
// No thread on this table carries a club — club chat lives in the `clubs` worker.
ClubId: null,
}
}
/**
* 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
@@ -746,12 +607,7 @@ const app = new Hono<App>()
const members = [...new Set([id, ...(await memberIds(c))])]
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
const { chatThreadId, created } = await getOrCreateThreadWithMembers(c.env.DB, members, id)
// A thread nobody has been told about is a thread nobody sees. Push its opening
// notice the moment it exists, rather than leaving the conversation to surface on
// whatever message happens to follow — there may not be one: this route is also
// called with an empty `messageContents`.
if (created) await pushThreadLatestMessage(c, chatThreadId)
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
const contents = (await formField(c, 'messageContents'))?.trim()
const posted =
@@ -807,74 +663,21 @@ const app = new Hono<App>()
}
)
// The caller's current party (`/thread/party?maxCount=1&mode=0`) — the client's
// GetPartyChat.
//
// TWO paths, cheapest first:
//
// 1. ALREADY IN A PARTY — one D1 query (`getPartyThreadForPlayer`: the membership join,
// filtered to party threads, newest first) answers it outright. This is the common
// case, every read after the first, and it touches the settings KV not at all.
// 2. NOT IN ONE YET — only then is the caller's `LatestPartyChat` player setting read.
// The POST below writes that key for the player who OPENED the party; the client
// writes it (through `playersettings`) for a player pulled into someone else's. Such
// a player holds the key but no membership row — nothing has added them — so a
// membership-only read answered them "no party", which is the bug the join fixes.
// The read puts them on the thread and then serves it: `GET /thread/party` is how
// you enter a party, not merely how you look at one.
//
// Reaching path 2 means the caller is in NO party, so they cannot already be a member of
// the thread the key names — which is why the join here needs no membership check of its
// own, and why the age gate below can be unconditional.
//
// On path 2 the thread is checked to exist, to be a party, and to be YOUNGER THAN THE
// INVITE LIFETIME before anyone is added to it, so a key naming a DM, a thread that is
// gone, or an hours-old party can't produce a membership row in it.
//
// The age check gates JOINING only — path 1 never reaches it, so a party keeps being
// served to the players already on it however old it is, rather than going dark on them
// after an hour. `LatestPartyChat` is the caller's own player setting, which the client
// can PUT to anything through the `playersettings` worker, so the key IS the invite here
// and the window is what keeps it from being a permanent one. Tighten further (an invite
// the party actually issued) if parties ever need to be closed outright.
//
// SAME PATH, DIFFERENT BODY from the POST: this one is the BARE thread, with no
// `{ ChatThread, ChatResult }` wrapper around it. Same ten-key PascalCase projection
// inside, so the two share `toPartyChatThread` — but nothing else, which is why these
// are two handlers rather than one verb-agnostic one.
//
// No party answers `{}`: the client parses that as a thread with everything at its
// default, which reads as "no party", where a 404 or a null body would fail its
// deserializer.
//
// `maxCount` and `mode` are accepted and ignored. `maxCount=1` is most likely the
// number of party chats wanted, which is already what a single-thread body serves; if
// it turns out to size `Messages` instead, ignoring it only ever serves MORE history
// than asked for, where guessing wrong the other way would truncate the party's
// messages to one. `mode` is unknown.
// The party thread (`/thread/party?maxCount=1&mode=0`). STUB: the response shape is
// unknown — it hasn't been observed off a live client — so this answers an empty
// object, which parses as "no party" rather than failing the client's deserializer the
// way a 404 or a bare array would. `maxCount` and `mode` are accepted and ignored.
// Replace the body once the real shape is captured.
.get(
'/thread/party',
describeRoute({
tags: ['Threads'],
summary: 'The caller’s current party thread (GetPartyChat)',
summary: 'The caller’s party thread (stub)',
description: [
'The party the caller is currently in: the newest party thread they are a member of,',
'answered from a single query. Failing that, the thread named by their own',
'`LatestPartyChat` player setting — which `POST /thread/party` writes for the player',
'who opened the party and the client writes for a player who joins someone else’s.',
'',
'That second path JOINS: a caller who is not on any party yet is ADDED to the thread',
'their key names and then served it, which is how a player pulled into someone else’s',
'party enters it — they hold the key but no membership row, and a membership-only read',
'answers them "no party". The thread must exist, be a party, and be younger than the',
'60-minute invite lifetime before anyone is added, so a key naming a DM, a deleted',
'thread or a stale party answers `{}` and writes nothing. The age gate is on JOINING',
'only — a player already on a party is served it however old it is. A join posts a',
'"Player <@U…> joined" notice and pushes it to the party, so the people already in it',
'see who arrived.',
'',
'The BARE thread, unlike the POST on the same path, which wraps the same projection in',
'`{ ChatThread, ChatResult }`. `maxCount` and `mode` are accepted and ignored.',
'STUB — the response shape has not been observed off a live client, so this answers an',
'empty object `{}`, which parses as "no party" rather than failing the client’s',
'deserializer the way a 404 or a bare array would. `maxCount` and `mode` are accepted and',
'ignored. Replace the body once the real shape is captured.',
].join(' '),
security: AUTHED,
parameters: [
@@ -882,121 +685,26 @@ const app = new Hono<App>()
name: 'maxCount',
in: 'query',
required: false,
description: 'Page size the client sends (1). Accepted and ignored',
description: 'Page size the client sends (1). Ignored by the stub',
schema: { type: 'integer' },
},
{
name: 'mode',
in: 'query',
required: false,
description: 'Unknown mode selector the client sends (0). Accepted and ignored',
description: 'Unknown mode selector the client sends (0). Ignored by the stub',
schema: { type: 'integer' },
},
],
responses: {
200: json(PartyChatThread, 'The caller’s party, or `{}` when they have none'),
200: json(PartyThread, 'Always `{}` — the stub carries no party'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
// Path 1: already in a party. One query, no settings read.
let thread = await getPartyThreadForPlayer(c.env.DB, id)
// Path 2: not in one — the key is the only thing that can name a party to join.
if (thread === null) {
const chatThreadId = await readLatestPartyChatId(c.env, id)
if (chatThreadId === null) return c.json({})
// Checked BEFORE the join: a gone thread, one of some other type, or a party
// too old to still be taking people isn't something to put anybody on —
// whoever wrote the key.
const meta = await getThreadMeta(c.env.DB, chatThreadId)
if (meta === null || meta.chatThreadType !== ChatThreadType.Party) return c.json({})
if (!isPartyJoinable(meta.createdAt)) return c.json({})
await addThreadMember(c.env.DB, chatThreadId, id)
// The same announcement the add-member route makes: the party learns someone
// walked in. It is also usually a party's FIRST message — one opens empty.
await announceJoin(c, chatThreadId, id)
thread = await getThreadForPlayer(c.env.DB, chatThreadId, id)
if (thread === null) throw new Error(`party thread ${chatThreadId} vanished after join`)
}
const messages = await getThreadMessages(c.env.DB, thread.chatThreadId, {
limit: DEFAULT_THREAD_MESSAGE_COUNT,
})
return c.json(toPartyChatThread(thread, messages))
}
)
// Open a party — the client's CreatePartyChat. A thread of type 2
// (`ChatThreadType.Party`) holding only the caller, which the client then fills by
// inviting people onto it (`POST /thread/{id}/member/{playerId}`). The one place a
// thread is opened with a single member: every other create refuses a roster of just
// yourself, because a DM with nobody in it is a mistake, whereas a party you are so far
// the only member of is exactly what starting one looks like.
//
// Takes NO query params and NO body — the client posts to the bare path.
//
// Always a NEW party, never a fetch-or-create: a party is a session, not a standing
// conversation with a set of people, so resolving to the one you left this morning
// would hand the invitees its history.
//
// It opens EMPTY — no system "started a chat" notice, unlike every other new thread
// here. The observed response carries `Messages: []` with a null `LatestMessage`, so a
// notice would be a message the reference doesn't post.
//
// The body is the PascalCase `{ ChatThread, ChatResult }` wrapper, bare — no
// `{ success, error, value }` envelope — and the thread inside it is its own
// projection: ten keys, both `Messages` and `LatestMessage`, a null `ChatThreadName`,
// and a `ClubId` that exists nowhere else. See `toPartyChatThread`.
.post(
'/thread/party',
describeRoute({
tags: ['Threads'],
summary: 'Open a party thread for the caller (CreatePartyChat)',
description: [
'The client’s CreatePartyChat. Opens a thread of type 2 (Party) whose only member is',
'the caller — the client fills it by inviting players on afterwards. No query params',
'and no body. Always a new party, never a fetch-or-create: a party is a session rather',
'than a standing conversation, so an old one would hand the invitees its history. The',
'only create that accepts a roster of just the caller, and the only one that opens with',
'no messages at all — no “started a chat” notice, matching the observed',
'`Messages: []`. Records the new thread as the caller’s `LatestPartyChat` player',
'setting, which is where `GET /thread/party` looks for it. Answers the bare PascalCase',
'`{ ChatThread, ChatResult }` wrapper, whose thread is a projection of its own — not the',
'camelCase shape the other thread routes serve.',
].join(' '),
security: AUTHED,
responses: {
200: json(CreatePartyChatResponse, 'The new party thread, empty, with ChatResult 0'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = await createThread(c.env.DB, [id], null, undefined, ChatThreadType.Party)
// This is what makes the party findable: the GET resolves the caller's current
// party through this key and nothing else. Written before the response, so a
// client that opens the party and immediately re-reads it can't miss it.
await mergePlayerSettings(c.env, id, { [LATEST_PARTY_CHAT_KEY]: String(chatThreadId) })
const thread = await getThreadForPlayer(c.env.DB, chatThreadId, id)
if (thread === null) throw new Error(`party thread ${chatThreadId} vanished after creation`)
// Read the messages back rather than assuming []: the party is empty as it is
// created, but the projection shouldn't be the thing that says so.
const messages = await getThreadMessages(c.env.DB, chatThreadId, {
limit: DEFAULT_THREAD_MESSAGE_COUNT,
})
return c.json({
ChatThread: toPartyChatThread(thread, messages),
ChatResult: CHAT_SUCCESS,
})
return c.json({})
}
)
@@ -1179,11 +887,7 @@ const app = new Hono<App>()
// rather than a lonely thread.
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
const { chatThreadId, created } = await getOrCreateThreadWithMembers(c.env.DB, members, id)
// The reported bug: this opened the thread silently, so the other player saw
// nothing until the first message landed. The opening notice is what tells them.
if (created) await pushThreadLatestMessage(c, chatThreadId)
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`)
@@ -1398,10 +1102,7 @@ const app = new Hono<App>()
'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. On success a',
'"Player <@U…> joined" system notice is posted and pushed to the whole thread: the',
'existing members because the roster changed, the new one because that push is what',
'puts the conversation on their screen.',
'Idempotent — re-adding an existing member changes nothing.',
].join(' '),
{
parameters: [
@@ -1431,9 +1132,6 @@ const app = new Hono<App>()
}
await addThreadMember(c.env.DB, chatThreadId, playerId)
// Everyone hears about it — the existing members because the roster changed under
// them, the new one because this is what puts the conversation on their screen.
await announceJoin(c, chatThreadId, playerId)
return c.json(CHAT_SUCCESS)
}
)
@@ -1558,4 +1256,4 @@ app.get(
)
)
export default withDatabase<Env>(app)
export default app
+5 -52
View File
@@ -86,9 +86,7 @@ const threadBase = {
chatThreadName: z
.string()
.describe('Empty for DMs and unnamed groups — never null (the client dereferences it)'),
chatThreadType: z
.int()
.describe('The ChatThreadType enum, numeric: 0 Player (DMs and groups) · 1 Club · 2 Party'),
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(),
}
@@ -235,56 +233,11 @@ export const ChatPrivacySettings = z.object({
})
/**
* A thread in the PascalCase spelling the two `/thread/party` routes serve — the client's
* CreatePartyChat and GetPartyChat — the THIRD projection of a thread in this worker, and deliberately not
* unified with the two camelCase ones ({@link ChatThreadDto}, {@link
* ChatThreadWithMessagesDto}): the client has a separate formatter for this response, and
* a camelCase body decodes to a thread with every field at its default.
*
* Ten wire keys, off the client's own formatter. Its CLR type declares thirteen fields:
* two are `[IgnoreDataMember]` and one is a plain field rather than an auto-property, so
* none of the three ever serialises — don't add them back.
*
* Differences from the camelCase DTOs beyond the casing:
* - `Messages` and `LatestMessage` are BOTH present, where the camelCase pair carries one
* or the other. A party opens empty, so they come back `[]` and null.
* - `ChatThreadName` is NULL for an unnamed thread, not the empty string the camelCase
* projections have to send (the client dereferences that one unchecked; this formatter
* takes the null).
* - `ClubId` exists only here — null for a party, and for everything this worker serves:
* club chat lives in the `clubs` worker and nothing on this table carries a club.
*
* `GET /thread/party` serves this BARE; the POST wraps it in {@link
* CreatePartyChatResponse}. The GET also answers `{}` for a caller with no party, which
* decodes to a thread with every field at its default — the client reads that as no party,
* where a 404 or a null body would fail its deserializer.
* `GET /thread/party` — STUB. The real shape hasn't been observed off a live client, so
* the route answers an empty object and this schema says so rather than guessing at
* fields. Fill both in together once the real response is captured.
*/
export const PartyChatThread = z.object({
ChatThreadId: z.int(),
ChatThreadType: z.int().describe('The ChatThreadType enum: 0 Player · 1 Club · 2 Party'),
LastReadMessageId: z.int().describe('0 for a party that was just opened'),
Messages: z
.array(SentChatMessage)
.describe('Empty for a party just opened — nothing is posted into it'),
LatestMessage: SentChatMessage.nullable().describe('Null while the thread has no messages'),
PlayerIds: z.array(z.int()).describe('Just the caller, until players are invited on'),
ChatThreadName: z.string().nullable().describe('NULL when unnamed — not the empty string'),
SnoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
IsFavorited: z.boolean(),
ClubId: z.int().nullable().describe('Always null here — this worker serves no club threads'),
})
/**
* `POST /thread/party` — the client's CreatePartyChat. A bare two-key wrapper, PascalCase
* like the thread inside it, with no `{ success, error, value }` envelope around it.
*
* `ChatResult` is the same twenty-member enum {@link ChatResult} records, served
* numerically; the create either works or fails the request, so it is always 0 here.
*/
export const CreatePartyChatResponse = z.object({
ChatThread: PartyChatThread,
ChatResult: ChatResult,
})
export const PartyThread = z.object({}).describe('Stub — always empty; the real shape is unknown')
/** `GET /` — the liveness probe. */
export const ServiceStatus = z.object({
+5 -588
View File
@@ -12,10 +12,7 @@ import {
SCHEMA_DDL,
} from '../../message-db'
import {
addThreadMember,
ChatThreadType,
createThread,
joinedChatContents,
findThreadWithMembers,
getThreadForPlayer,
getThreadsForPlayer,
@@ -397,107 +394,8 @@ describe('GET /settings/partyinvite', () => {
})
})
// The GET serves the caller's CURRENT party — the thread named by their `LatestPartyChat`
// player setting — as the BARE thread, where the POST wraps the same projection.
describe('GET /thread/party', () => {
async function settings(playerId: number): Promise<Record<string, string>> {
return (
(await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
`player:${playerId}`,
'json'
)) ?? {}
)
}
it('answers the party named by LatestPartyChat, bare — no ChatResult wrapper', async () => {
const caller = 883201
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, {
headers: await bearer(caller),
})
expect(res.status).toBe(200)
// The whole body is the thread: no `ChatThread`/`ChatResult` keys around it.
expect(await res.json()).toEqual({
ChatThreadId: ChatThread.ChatThreadId,
ChatThreadType: ChatThreadType.Party,
LastReadMessageId: 0,
Messages: [],
LatestMessage: null,
PlayerIds: [caller],
ChatThreadName: null,
SnoozedUntil: null,
IsFavorited: false,
ClubId: null,
})
})
it('POST records the thread id in the caller’s LatestPartyChat setting', async () => {
const caller = 883202
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ OobeState: 'Complete' })
)
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
// Merged, not overwritten: the bag holds every other setting the player has.
expect(await settings(caller)).toEqual({
OobeState: 'Complete',
LatestPartyChat: String(ChatThread.ChatThreadId),
})
})
it('follows the setting to the newest party after a second one is opened', async () => {
const caller = 883203
await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) })
const second = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await second.json()) as { ChatThread: { ChatThreadId: number } }
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
const body = (await res.json()) as { ChatThreadId: number }
expect(body.ChatThreadId).toBe(ChatThread.ChatThreadId)
})
it('carries the party’s members and messages once it has them', async () => {
const caller = 883204
const guest = 883205
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await addThreadMember(env.DB, ChatThread.ChatThreadId, guest)
const posted = await postMessage(env.DB, {
chatThreadId: ChatThread.ChatThreadId,
senderPlayerId: caller,
contents: '{"Type":0,"Version":1,"Data":"regroup at the bridge"}',
})
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
const body = (await res.json()) as {
PlayerIds: number[]
Messages: Array<{ ChatMessageId: number; Contents: string }>
LatestMessage: { ChatMessageId: number } | null
}
expect(body.PlayerIds).toEqual([caller, guest])
// PascalCase messages here too — the camelCase spelling is the other projections'.
expect(body.Messages).toHaveLength(1)
expect(body.Messages[0]?.ChatMessageId).toBe(posted.chatMessageId)
expect(body.LatestMessage?.ChatMessageId).toBe(posted.chatMessageId)
})
it('answers {} for a player who has never opened one', async () => {
it('answers an empty object', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, {
headers: await bearer(883001),
})
@@ -505,339 +403,12 @@ describe('GET /thread/party', () => {
expect(await res.json()).toEqual({})
})
it('JOINS a caller who holds the key but isn’t on the thread yet', async () => {
// How a player enters someone else's party: the client points their
// LatestPartyChat at it, and they have no membership row until this read.
const host = 883210
const guest = 883211
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${guest}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, guest)).toBe(false)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
const body = (await res.json()) as { ChatThreadId: number; PlayerIds: number[] }
// They're on the thread now, and the body they get back says so.
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, guest)).toBe(true)
expect(body.ChatThreadId).toBe(ChatThread.ChatThreadId)
expect(body.PlayerIds).toEqual([host, guest])
// The host sees them too — one thread, one roster.
expect((await getThreadForPlayer(env.DB, ChatThread.ChatThreadId, host))?.playerIds).toEqual([
host,
guest,
])
})
it('re-joining is a no-op — the roster doesn’t grow on every read', async () => {
const caller = 883212
await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) })
for (let i = 0; i < 3; i++) {
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
}
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { PlayerIds: number[] }).PlayerIds).toEqual([caller])
})
it('re-joins a caller who left, while their key still names the party', async () => {
// Leaving doesn't clear the key, so the next read walks them back in. Ending a
// party is what drops the key (`match`'s POST /player/logout).
const caller = 883206
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await removeThreadMember(env.DB, ChatThread.ChatThreadId, caller)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
ChatThread.ChatThreadId
)
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, caller)).toBe(true)
})
it('serves the party from D1 alone, with no LatestPartyChat key at all', async () => {
// The fast path: a player already on a party is answered from the membership join,
// so the settings KV is never read. Proven by deleting the key the POST wrote.
const caller = 883230
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.delete(`player:${caller}`)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
ChatThread.ChatThreadId
)
})
it('serves the NEWEST party when the caller is a member of several', async () => {
// Membership outlives a party — nothing removes the row when one ends — so the
// party you are in is the most recent one you are on.
const caller = 883231
await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) })
const second = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await second.json()) as { ChatThread: { ChatThreadId: number } }
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
ChatThread.ChatThreadId
)
})
it('prefers the party the caller is ON over one their key merely names', async () => {
// The consequence of checking D1 first: while a membership row survives, a key
// pointing somewhere else is not consulted. Switching parties means leaving the old
// one (DELETE /thread/{id}/leave), not just repointing the key.
const caller = 883232
const host = 883233
const mine = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const other = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const own = (await mine.json()) as { ChatThread: { ChatThreadId: number } }
const theirs = (await other.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ LatestPartyChat: String(theirs.ChatThread.ChatThreadId) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
own.ChatThread.ChatThreadId
)
expect(await isThreadMember(env.DB, theirs.ChatThread.ChatThreadId, caller)).toBe(false)
})
/** Age a party by rewriting its `created_at` — what the join window is measured from. */
async function openedMinutesAgo(chatThreadId: number, minutes: number): Promise<void> {
await env.DB.prepare('UPDATE message_thread SET created_at = ?2 WHERE chat_thread_id = ?1')
.bind(chatThreadId, new Date(Date.now() - minutes * 60_000).toISOString())
.run()
}
it('refuses to join a party older than the 60-minute invite lifetime', async () => {
const host = 883220
const latecomer = 883221
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await openedMinutesAgo(ChatThread.ChatThreadId, 61)
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${latecomer}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(latecomer) })
expect(await res.json()).toEqual({})
// Refused, not quietly joined: no membership row was written.
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, latecomer)).toBe(false)
})
it('still joins a party inside the window', async () => {
const host = 883222
const guest = 883223
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await openedMinutesAgo(ChatThread.ChatThreadId, 59)
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${guest}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
expect(((await res.json()) as { PlayerIds: number[] }).PlayerIds).toEqual([host, guest])
})
it('keeps serving an aged party to the players already on it', async () => {
// The window gates JOINING only — a party doesn't go dark on its own members.
const host = 883224
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await openedMinutesAgo(ChatThread.ChatThreadId, 240)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(host) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
ChatThread.ChatThreadId
)
})
it('refuses to join a party whose created_at won’t parse', async () => {
// Fails closed: no timestamp, no join.
const host = 883225
const stranger = 883226
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.DB.prepare('UPDATE message_thread SET created_at = ?2 WHERE chat_thread_id = ?1')
.bind(ChatThread.ChatThreadId, 'not-a-timestamp')
.run()
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${stranger}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(stranger) })
expect(await res.json()).toEqual({})
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, stranger)).toBe(false)
})
it('answers {} when the setting names a thread that isn’t a party, joining nobody', async () => {
// The type check runs BEFORE the join, so a key pointed at someone else's DM can't
// put the caller in it.
const caller = 883207
const dm = await createThread(env.DB, [883208, 883213])
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ LatestPartyChat: String(dm) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(await res.json()).toEqual({})
expect(await isThreadMember(env.DB, dm, caller)).toBe(false)
})
it('answers {} when the setting names a thread that doesn’t exist', async () => {
const caller = 883214
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ LatestPartyChat: '99999' })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(await res.json()).toEqual({})
expect(await isThreadMember(env.DB, 99999, caller)).toBe(false)
})
it('answers {} for an unparseable stored id', async () => {
const caller = 883209
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ LatestPartyChat: 'not-an-id' })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(await res.json()).toEqual({})
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`)
expect(res.status).toBe(401)
})
})
// The POST on the same path is not a stub: it opens a real thread, of type Party, with
// only the caller on it — and answers the client's own PascalCase CreatePartyChat shape,
// which is neither of the two camelCase thread projections.
describe('POST /thread/party', () => {
it('answers the PascalCase { ChatThread, ChatResult } wrapper for an empty party', async () => {
const caller = 883101
const res = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { ChatThread: { ChatThreadId: number }; ChatResult: number }
// Every wire key, exactly — a party opens empty, unnamed and with no club, and the
// client reads none of the camelCase spellings the other thread routes serve.
expect(body).toEqual({
ChatThread: {
ChatThreadId: body.ChatThread.ChatThreadId,
ChatThreadType: ChatThreadType.Party,
LastReadMessageId: 0,
Messages: [],
LatestMessage: null,
PlayerIds: [caller],
ChatThreadName: null,
SnoozedUntil: null,
IsFavorited: false,
ClubId: null,
},
ChatResult: 0,
})
// And it's a real thread: it reads back through the normal thread routes.
const stored = await getThreadForPlayer(env.DB, body.ChatThread.ChatThreadId, caller)
expect(stored?.chatThreadType).toBe(ChatThreadType.Party)
expect(stored?.playerIds).toEqual([caller])
})
it('posts no “started a chat” notice into the party', async () => {
const caller = 883105
const res = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await res.json()) as { ChatThread: { ChatThreadId: number } }
expect(await getThreadMessages(env.DB, ChatThread.ChatThreadId)).toEqual([])
})
it('opens a NEW party every call rather than resolving to the last one', async () => {
const caller = 883102
const first = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const second = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const a = (await first.json()) as { ChatThread: { ChatThreadId: number } }
const b = (await second.json()) as { ChatThread: { ChatThreadId: number } }
expect(b.ChatThread.ChatThreadId).not.toBe(a.ChatThread.ChatThreadId)
})
it('keeps party threads out of the DM fetch-or-create', async () => {
// A party the caller invited someone onto has the same roster as their DM would.
const caller = 883103
const other = 883104
const res = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await res.json()) as { ChatThread: { ChatThreadId: number } }
await addThreadMember(env.DB, ChatThread.ChatThreadId, other)
const dm = await SELF.fetch(`${ORIGIN}/thread/withmembers`, {
method: 'POST',
headers: { ...(await bearer(caller)), 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ ids: String(other) }),
})
const opened = (await dm.json()) as { chatThreadId: number; chatThreadType: number }
expect(opened.chatThreadId).not.toBe(ChatThread.ChatThreadId)
expect(opened.chatThreadType).toBe(ChatThreadType.Player)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST' })
expect(res.status).toBe(401)
})
})
describe('GET /thread/chatPrivacySetting', () => {
it('reports Friends for both settings by default, keyed to the caller', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, {
@@ -1420,28 +991,11 @@ describe('ChatMessageReceived push', () => {
}
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
true
)
// TWO waves over one channel, because that channel is all the client has: the
// thread's opening notice, which is what makes the new conversation appear at all,
// and then the message itself. Each goes to all three members.
const notice = sent.filter((n) => (n.data as { senderPlayerId: number }).senderPlayerId === -5)
const message = sent.filter(
(n) => (n.data as { senderPlayerId: number }).senderPlayerId === caller
)
for (const wave of [notice, message]) {
expect(wave.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
}
expect(notice[0]!.data).toEqual({
chatMessageId: expect.any(Number),
chatThreadId: chatThread.chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
timeSent: expect.any(String),
contents: startedChatContents(caller),
moderationState: 0,
})
expect(message[0]!.data).toEqual({
expect(sent[0]!.data).toEqual({
chatMessageId: chatThread.latestMessage.chatMessageId,
chatThreadId: chatThread.chatThreadId,
senderPlayerId: caller,
@@ -1451,144 +1005,8 @@ describe('ChatMessageReceived push', () => {
})
})
it('pushes the opening notice even when no message is sent', async () => {
// The thread is real whether or not anything was said in it, and a thread nobody
// was told about is one nobody sees — the client has no "thread opened" channel.
const caller = 886004
await send(caller, 'ids=886005&messageContents=')
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886005])
expect((sent[0]!.data as { senderPlayerId: number }).senderPlayerId).toBe(SYSTEM_SENDER_ID)
expect((sent[0]!.data as { contents: string }).contents).toBe(startedChatContents(caller))
})
it('pushes nothing when the thread already existed and nothing was said', async () => {
// Second call on the same pair: no new thread, no message — nothing to announce.
await send(886006, 'ids=886007&messageContents=')
await hub.getByName('global').takeSent()
await send(886006, 'ids=886007&messageContents=')
expect(await hub.getByName('global').takeSent()).toEqual([])
})
async function withMembers(caller: number, body: string): Promise<Response> {
return SELF.fetch(`${ORIGIN}/thread/withmembers`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
it('POST /thread/withmembers announces the thread it opens', async () => {
// The reported bug: this opened the conversation silently, so the other player saw
// nothing until the first message arrived.
const caller = 886010
const other = 886011
const res = await withMembers(caller, `ids=${other}`)
const { chatThreadId } = (await res.json()) as { chatThreadId: number }
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, other])
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
true
)
expect(sent[0]!.data).toEqual({
chatMessageId: expect.any(Number),
chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
timeSent: expect.any(String),
contents: startedChatContents(caller),
moderationState: 0,
})
})
it('POST /thread/withmembers pushes nothing when it resolves to an existing thread', async () => {
// Fetch-or-create: the second call opens nothing, so there is nothing to announce
// — re-announcing would ping both players every time the screen is opened.
const caller = 886012
await withMembers(caller, 'ids=886013')
await hub.getByName('global').takeSent()
await withMembers(caller, 'ids=886013')
expect(await hub.getByName('global').takeSent()).toEqual([])
})
it('adding a member announces the join to the WHOLE thread', async () => {
const caller = 886014
const existing = 886015
const added = 886016
const res = await withMembers(caller, `ids=${existing}`)
const { chatThreadId } = (await res.json()) as { chatThreadId: number }
await hub.getByName('global').takeSent()
await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/member/${added}`, {
method: 'POST',
headers: await bearer(caller),
})
// Everyone: the two who were already there because the roster changed under them,
// and the new member because this is what puts the thread on their screen.
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, existing, added])
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
true
)
expect(sent[0]!.data).toMatchObject({
chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
contents: joinedChatContents(added),
})
// The notice is a real message on the thread, not just a frame — the mirror of the
// "left" one, and it is the thread's newest.
const [newest] = await getThreadMessages(env.DB, chatThreadId, { limit: 1 })
expect(newest?.contents).toBe(joinedChatContents(added))
})
it('joining a party announces it to the party', async () => {
const host = 886017
const guest = 886018
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${guest}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
await hub.getByName('global').takeSent()
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
// The host hears about it too — that is the point of announcing a join.
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([host, guest])
expect(sent[0]!.data).toMatchObject({
chatThreadId: ChatThread.ChatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
contents: joinedChatContents(guest),
})
})
it('announces a party join once, not on every subsequent read', async () => {
// The join happens once; reading your own party afterwards is not a roster change.
const host = 886019
const guest = 886020
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${guest}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
await hub.getByName('global').takeSent()
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
it('pushes nothing when there is no message to push', async () => {
await send(886004, 'ids=886005&messageContents=')
expect(await hub.getByName('global').takeSent()).toEqual([])
})
})
@@ -2211,7 +1629,6 @@ describe('openapi', () => {
'GET /thread/{id}',
'GET /thread/{id}/message',
'POST /thread',
'POST /thread/party',
'POST /thread/withmembers',
'POST /thread/{id}',
'POST /thread/{id}/favorite',
+52 -145
View File
@@ -31,7 +31,6 @@ export const THREAD_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS message_thread (
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_thread_name TEXT,
chat_thread_type INTEGER NOT NULL DEFAULT 0,
latest_message_id INTEGER,
created_at TEXT NOT NULL
)`,
@@ -71,36 +70,23 @@ export interface ChatThread {
*/
chatThreadName: string
/**
* Which kind of conversation this is — the client's `ChatThreadType`: 0 Player,
* 1 Club, 2 Party. Player covers both DMs and group chats; nothing here distinguishes
* the two. The field has to be present whatever its value: the client deserializes it
* as a non-nullable int and drops the whole response when it's missing.
* 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: ChatThreadTypeValue
chatThreadType: number
snoozedUntil: string | null
isFavorited: boolean
}
/**
* The client's `ChatThreadType` enum, stored on the thread and served numerically like
* every other enum on this build. A plain conversation — DM or group — is `Player`;
* `Party` is what `POST /thread/party` opens. Nothing here serves `Club` yet: club chat
* lives in the `clubs` worker, and the member is here so a stored 1 renders as itself
* rather than being read as a player thread.
*/
export const ChatThreadType = {
Player: 0,
Club: 1,
Party: 2,
} as const
export type ChatThreadTypeValue = (typeof ChatThreadType)[keyof typeof ChatThreadType]
/** 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
chat_thread_type: number
player_ids: string | null
last_read_message_id: number | null
snoozed_until: string | null
@@ -133,24 +119,30 @@ function toThread(row: ThreadRow): ChatThread {
lastReadMessageId: row.last_read_message_id ?? 0,
// Null in the column means "unnamed"; the client dereferences it unchecked.
chatThreadName: row.chat_thread_name ?? '',
chatThreadType: row.chat_thread_type as ChatThreadTypeValue,
chatThreadType: CHAT_THREAD_TYPE_DEFAULT,
snoozedUntil: row.snoozed_until,
isFavorited: row.is_favorited !== 0,
}
}
/**
* The shared projection behind every rendered thread — the list, the single read and the
* party read. Membership LEADS the join, so it is the authorization check as well as the
* query: a row only exists for a thread `me` is in. The inner ordered subquery around
* group_concat is what makes `playerIds` come back sorted rather than in row order.
* The thread list as it renders for one player, newest conversation first — the
* `?MessageCount=N` page of the thread endpoint.
*
* Callers append their own WHERE (and ORDER/LIMIT) and bind `?1` onwards from there.
* 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.
*/
const THREAD_SELECT = `SELECT
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,
t.chat_thread_type,
(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,
@@ -165,22 +157,7 @@ const THREAD_SELECT = `SELECT
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`
/**
* The thread list as it renders for one player, newest conversation first — the
* `?MessageCount=N` page of the thread endpoint.
*
* Membership scopes the query — see {@link THREAD_SELECT}.
*/
export async function getThreadsForPlayer(
db: D1Database,
playerId: number,
{ limit = 50 }: { limit?: number } = {}
): Promise<ChatThread[]> {
const { results } = await db
.prepare(
`${THREAD_SELECT}
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`
@@ -198,7 +175,24 @@ export async function getThreadForPlayer(
): Promise<ChatThread | null> {
const row = await db
.prepare(
`${THREAD_SELECT}
`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)
@@ -206,65 +200,6 @@ export async function getThreadForPlayer(
return row === null ? null : toThread(row)
}
/**
* The party a player is already in, or null — the newest party thread carrying a
* membership row for them, rendered exactly as {@link getThreadForPlayer} renders one.
*
* The fast path behind `GET /thread/party`: a player already on a party is answered from
* ONE D1 query, with no player-settings read at all. `LatestPartyChat` is consulted only
* when this comes back null — that is, only for a player who has yet to JOIN a party.
*
* Newest first (by thread id, which is monotonic) because a player can hold membership in
* parties they never formally left: the one they are in is the most recent one they are on.
*/
export async function getPartyThreadForPlayer(
db: D1Database,
playerId: number
): Promise<ChatThread | null> {
const row = await db
.prepare(
`${THREAD_SELECT}
WHERE me.player_id = ?1 AND t.chat_thread_type = ?2
ORDER BY t.chat_thread_id DESC
LIMIT 1`
)
.bind(playerId, ChatThreadType.Party)
.first<ThreadRow>()
return row === null ? null : toThread(row)
}
/** What a thread IS, without any of what's in it. See {@link getThreadMeta}. */
export interface ThreadMeta {
chatThreadType: ChatThreadTypeValue
/** ISO-8601 UTC, as `created_at` stores it. */
createdAt: string
}
/**
* A thread's kind and age, or null when there is no such thread — the one read here that
* does NOT go through membership.
*
* It exists for the party join (`GET /thread/party`), which has to know a thread is real,
* is a party, and is still young enough to join BEFORE it puts the caller on it; every
* other read is membership-scoped, and a caller joining a party is by definition not a
* member yet. It answers these two fields and nothing else — no name, no roster, no
* messages — precisely so it can't become a way to read a thread you aren't in.
*/
export async function getThreadMeta(
db: D1Database,
chatThreadId: number
): Promise<ThreadMeta | null> {
const row = await db
.prepare(
'SELECT chat_thread_type, created_at FROM message_thread WHERE chat_thread_id = ?1'
)
.bind(chatThreadId)
.first<{ chat_thread_type: number; created_at: string }>()
return row === null
? null
: { chatThreadType: row.chat_thread_type as ChatThreadTypeValue, createdAt: row.created_at }
}
/**
* Whether a player may read or post to a thread. Every thread-scoped route gates on
* this before touching messages.
@@ -308,19 +243,6 @@ export function leftChatContents(playerId: number): string {
return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> left` })
}
/**
* The counterpart notice when someone is pulled onto a thread or walks into a party:
* `Player <@U14922080> joined`. Same `<@U…>` mention token as the other two.
*
* It carries the roster change as a MESSAGE because that is the only way to carry one: the
* client has no join/leave channel, only `ChatMessageReceived` and `PlayerLeftChat`, both
* of which take a message. So the notice is both what the thread shows and what tells
* everyone — the new member's client included — that the roster moved.
*/
export function joinedChatContents(playerId: number): string {
return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> joined` })
}
/**
* Rename a thread. An empty name clears it back to unnamed, which renders as the member
* list rather than a blank title.
@@ -347,24 +269,19 @@ export async function setThreadName(
*
* 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.
*
* `type` is the thread's kind and defaults to `Player`, which covers DMs and group chats
* alike; a party opens as `Party`.
*/
export async function createThread(
db: D1Database,
playerIds: number[],
name: string | null = null,
startedBy?: number,
type: ChatThreadTypeValue = ChatThreadType.Player
startedBy?: number
): Promise<number> {
const row = await db
.prepare(
`INSERT INTO message_thread (chat_thread_name, chat_thread_type, created_at)
VALUES (?1, ?2, ?3)
`INSERT INTO message_thread (chat_thread_name, created_at) VALUES (?1, ?2)
RETURNING chat_thread_id`
)
.bind(name, type, new Date().toISOString())
.bind(name, new Date().toISOString())
.first<{ chat_thread_id: number }>()
if (row === null) throw new Error('failed to create chat thread')
@@ -405,33 +322,27 @@ export async function createThread(
* 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.
*
* Matching is also scoped to one `type`: a party whose roster happens to be the people
* you are opening a DM with is a different conversation, and handing it back would drop
* the DM into the party.
*/
export async function findThreadWithMembers(
db: D1Database,
playerIds: number[],
type: ChatThreadTypeValue = ChatThreadType.Player
playerIds: number[]
): Promise<number | null> {
const members = [...new Set(playerIds)]
if (members.length === 0) return null
// ?1 is the member count, ?2 the thread type; ?3… are the ids themselves.
const placeholders = members.map((_, i) => `?${i + 3}`).join(', ')
// ?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
WHERE t.chat_thread_type = ?2
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, type, ...members)
.bind(members.length, ...members)
.first<{ chat_thread_id: number }>()
return row?.chat_thread_id ?? null
}
@@ -441,20 +352,16 @@ export async function findThreadWithMembers(
* 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.
*
* `created` says which happened. The caller needs it: a thread that was just opened has to
* be PUSHED to its members, or it sits on the server unseen until somebody posts to it —
* the client has no "you were added to a thread" channel, so the opening notice going out
* over the socket is the only thing that makes a new conversation appear.
*/
export async function getOrCreateThreadWithMembers(
db: D1Database,
playerIds: number[],
startedBy: number
): Promise<{ chatThreadId: number; created: boolean }> {
const existing = await findThreadWithMembers(db, playerIds)
if (existing !== null) return { chatThreadId: existing, created: false }
return { chatThreadId: await createThread(db, playerIds, null, startedBy), created: true }
): 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. */
+4 -1
View File
@@ -51,7 +51,10 @@
},
"upload_source_maps": true,
"observability": {
"enabled": false
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.13.5",
"hono": "4.12.27",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.128.0"
"wrangler": "4.105.0"
}
}
+29 -147
View File
@@ -8,7 +8,6 @@ import {
ClubMembershipType,
ClubVisibility,
countClubsByCreator,
countClubSubscribers,
createClub,
createClubAnnouncement,
deleteClub,
@@ -20,9 +19,6 @@ import {
getClubsByMember,
getHomeClub,
getMembership,
getOrCreateSubscriptionClub,
getSubscriptionClub,
getSubscriptionMemberships,
glyphLength,
joinClub,
leaveClub,
@@ -34,9 +30,7 @@ import {
setClubAdditionalImage,
setHomeClub,
setMemberType,
subscribeToClub,
updateClub,
withDatabase,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
@@ -68,26 +62,19 @@ import {
ModifyClubRequest,
NullEnvelope,
SubscriberCountResponse,
SubscribeRequest,
SubscriptionDetailsResponse,
SubscriptionErrorEnvelope,
SubscriptionIdEnvelope,
SubscriptionMembershipsResponse,
UNAUTHORIZED_RESPONSE,
} from './openapi'
import type { Context } from 'hono'
import type { App, Env } from './context'
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, plus subscriptions. A creator's subscribers are the members of their
* SUBSCRIPTION CLUB (`ClubType` 1, one per account, reached only through
* `/subscription/*`): subscribing is joining it. Everything is D1-backed (the shared
* `recflare` database). A subscription club is provisioned by the first
* `/subscription/details/:accountId` read or `POST /subscription/:accountId` for that account.
* 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.
*/
@@ -117,6 +104,7 @@ async function authedId(c: Context<App>): Promise<number | null> {
*/
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
@@ -322,56 +310,33 @@ const app = new Hono<App>()
}
)
// The caller's subscriptions — the subscription clubs they're a member of, one row
// each with the creator, the club and its subscriber count; the subscription-club
// counterpart of `/club/mine/member`. The client calls it on the clubs host with no
// /club prefix, and has been seen calling it WITHOUT an auth header: a missing or
// invalid token answers `[]` rather than 401, since the client chokes on null and
// this is not worth failing a launch over.
// 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',
describeRoute({
tags: ['Subscriptions'],
summary: 'The caller’s subscriptions',
summary: 'The caller’s club-subscription memberships',
description: [
'The subscription clubs the caller is a member of (`POST /subscription/{accountId}`),',
'oldest first — the creator, the club and its subscriber count; the subscription-club',
'counterpart of `/club/mine/member`. A creator is a member of their own subscription',
'club, so it is listed for them too. The client has been seen calling this without',
'an auth header, so a missing or invalid token answers `[]` rather than 401 (the',
'client chokes on null).',
'A real client endpoint with no backing implementation yet. The client calls it on',
'the clubs host at `/subscription/mine/member` (no `/club` prefix) and sends no auth',
'header, so it isn’t gated. Always `[]` — no subscription memberships (the client',
'chokes on null).',
].join(' '),
security: AUTHED,
responses: {
200: json(
SubscriptionMembershipsResponse,
'The caller’s subscriptions; `[]` when unauthenticated'
),
},
responses: { 200: json(JsonArray, 'Always empty for now') },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.json([])
return c.json(await getSubscriptionMemberships(c.env.DB, id))
}
(c) => c.json([])
)
// Subscription details for an account: their subscription club and its subscriber
// count. This is where subscription clubs come from — the first read for an account
// that has none PROVISIONS it (named after the creator, with them as its Creator-tier
// member), so the client's "subscribe" button always has a club to post to. Only a
// real account gets one; an unknown id reads as club 0. Public.
// Subscription details for an account (numeric id) — simulated: no club, no subs.
.get(
'/subscription/details/:accountId{[0-9]+}',
describeRoute({
tags: ['Subscriptions'],
summary: 'Subscription details for an account',
description: [
'The account’s subscription club and its subscriber count (the creator not counted).',
'The first read for an account with no subscription club PROVISIONS one — a',
'`ClubType` 1 club named after the creator — so `clubId` is only 0 for an id that',
'isn’t an account. Public.',
].join(' '),
description: 'Simulated — no subscription club, no subscribers.',
parameters: [
{
name: 'accountId',
@@ -381,17 +346,14 @@ const app = new Hono<App>()
schema: { type: 'string' },
},
],
responses: { 200: json(SubscriptionDetailsResponse, 'The account’s subscription details') },
responses: { 200: json(SubscriptionDetailsResponse, 'Zeroed subscription details') },
}),
async (c) => {
const accountId = Number.parseInt(c.req.param('accountId'), 10)
const club = await getOrCreateSubscriptionClub(c.env.DB, accountId)
return c.json({
accountId,
clubId: club?.ClubId ?? 0,
subscriberCount: club ? await countClubSubscribers(c.env.DB, club.ClubId) : 0,
(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
@@ -419,85 +381,13 @@ const app = new Hono<App>()
(c) => c.json({})
)
// Subscribe to a creator: the caller joins the SUBSCRIPTION CLUB of the account
// `accountId` — the `ClubType` 1 club provisioned for them (here too, if nobody has
// read their `details` yet) — becoming a Member of it outright. Idempotent:
// resubscribing answers the existing membership's id. Refused for one's own club and
// for a banned account; an id that isn't an account is a 404. The `roomId` form field
// (the room the subscribe was posted from) is accepted and unused. The envelope is the
// reference's, with `error` NULL on success rather than the club routes' `""`; `value`
// is the membership row's id, which is a guess at what the reference's opaque id was.
.post(
'/subscription/:accountId{[0-9]+}',
describeRoute({
tags: ['Subscriptions'],
summary: 'Subscribe to a creator',
description: [
'The caller joins the subscription club of the account `accountId` as a Member,',
'provisioning the club first if that account has none yet. Idempotent: subscribing',
'again answers the existing membership’s id. Refused for the caller’s own club and',
'for a banned account; an id that isn’t an account is a 404. `roomId` is accepted',
'and unused. Note the envelope is the reference’s — `error` and `errorId` are null',
'on success, not `""`.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'The creator to subscribe to (digits only)',
schema: { type: 'string' },
},
],
requestBody: form(SubscribeRequest, 'The room the subscribe was posted from'),
responses: {
200: json(SubscriptionIdEnvelope, 'The subscription’s id'),
400: json(
SubscriptionErrorEnvelope,
'The caller’s own club, or the caller is banned from it'
),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No such account' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const club = await getOrCreateSubscriptionClub(
c.env.DB,
Number.parseInt(c.req.param('accountId'), 10)
)
if (club === null) return c.notFound()
const result = await subscribeToClub(c.env.DB, club.ClubId, id)
if (result === null) return c.notFound()
const refusal: Record<string, string | undefined> = {
notSubscriptionClub: 'That is not a subscription club.',
own: 'You cannot subscribe to yourself.',
banned: 'You cannot subscribe to this creator.',
}
const message = refusal[result.outcome]
if (message !== undefined) {
return c.json({ value: null, success: false, errorId: null, error: message }, 400)
}
return c.json({ value: result.membershipId ?? 0, success: true, errorId: null, error: null })
}
)
// Subscriber count for an account: their subscription club's members, the creator
// not counted. Public, a bare JSON integer; an account with no subscription club yet
// (nobody has read its `details` or subscribed) has 0 — this read doesn't provision one.
// Subscriber count for an account. No club subscriptions yet → 0.
.get(
'/subscription/subscriberCount/:accountId{[0-9]+}',
describeRoute({
tags: ['Subscriptions'],
summary: 'Subscriber count for an account',
description: [
'How many subscribers the account’s subscription club has, its creator not counted.',
'Public. A bare JSON integer; an account with no subscription club has 0.',
].join(' '),
description: 'No club subscriptions yet, so this is always 0. A bare JSON integer.',
parameters: [
{
name: 'accountId',
@@ -507,15 +397,9 @@ const app = new Hono<App>()
schema: { type: 'string' },
},
],
responses: { 200: json(SubscriberCountResponse, 'The subscriber count') },
responses: { 200: json(SubscriberCountResponse, 'Always 0') },
}),
async (c) => {
const club = await getSubscriptionClub(
c.env.DB,
Number.parseInt(c.req.param('accountId'), 10)
)
return c.json(club ? await countClubSubscribers(c.env.DB, club.ClubId) : 0)
}
(c) => c.json(0)
)
// The player's clubs that have unread announcements (MyClubsWithUnread-
@@ -1682,9 +1566,7 @@ app.get(
'membership (join / ask-to-join / leave, with the ban and pending tiers),',
'search, announcements, the club gallery and clubhouse room, and each player’s home',
'club. Everything is D1-backed on the shared `recflare` database; the',
'A creator’s subscribers are the members of their subscription club (`ClubType` 1,',
'one per account, provisioned by the first `details` read or subscribe for that account);',
'the `/subscription/*` routes read and join those.',
'`/subscription/*` routes are stubs, since there are no subscription clubs yet.',
'',
'Most writes answer the `{ error, success, value }` envelope with HTTP 200, and the',
'ones the client re-renders a club screen from carry the club’s FULL details as',
@@ -1707,4 +1589,4 @@ app.get(
)
)
export default withDatabase<Env>(app)
export default app
+4 -55
View File
@@ -245,52 +245,11 @@ export const ClubSearchResponse = z.object({
TotalClubs: z.int().describe('How many clubs matched, not the page size'),
})
/**
* `GET /subscription/mine/member` — one of the caller's subscriptions (recovered from
* the client's decoder: `AccountId` int, `ClubId` long, `SubscriberCount` int).
*/
export const SubscriptionMembershipDto = z.object({
AccountId: z.int().describe('The creator subscribed to (the subscription club’s creator)'),
ClubId: z.int().describe('Their subscription club'),
SubscriberCount: z.int().describe('How many subscribers that club has, its creator not counted'),
})
/** `GET /subscription/mine/member` — the caller's subscriptions, oldest first. */
export const SubscriptionMembershipsResponse = z.array(SubscriptionMembershipDto)
/** `GET /subscription/details/:accountId` — an account's subscription club and its size. */
/** `GET /subscription/details/:accountId` — simulated: no club, no subscribers. */
export const SubscriptionDetailsResponse = z.object({
accountId: z.int(),
clubId: z
.int()
.describe(
'The account’s subscription club, provisioned by this read or a subscribe; 0 only for a non-account'
),
subscriberCount: z.int().describe('Its subscribers, the creator not counted'),
})
/**
* `POST /subscription/:accountId` — the reference's envelope, which differs from the club
* one: `error` (and `errorId`) are NULL on success rather than `""`. `value` is the
* subscription's id.
*/
export const SubscriptionIdEnvelope = z.object({
value: z
.int()
.describe(
'The subscription’s id — the club membership row (the existing one when already subscribed)'
),
success: z.boolean(),
errorId: z.null(),
error: z.null(),
})
/** A rejected subscribe: the same envelope, carrying the message. */
export const SubscriptionErrorEnvelope = z.object({
value: z.null(),
success: z.boolean().describe('Always false'),
errorId: z.null(),
error: z.string().describe('The message shown to the player'),
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. */
@@ -299,9 +258,7 @@ export const CategoryTags = z.array(z.string())
/** `GET /subscription/subscriberCount/:accountId` — a bare JSON integer. */
export const SubscriberCountResponse = z
.int()
.describe(
'The account’s subscription club’s subscribers, its creator not counted; 0 without a club'
)
.describe('Always 0 — there are no club subscriptions yet')
/**
* `GET /club/:clubId/hasDisabledClubChat` — a bare JSON boolean, like the other
@@ -360,14 +317,6 @@ export const HomeClubRequest = z.object({
clubId: z.string().describe('The club to make home; the caller must be a member of it'),
})
/** `POST /subscription/:accountId` — the room the player is subscribing from. */
export const SubscribeRequest = z.object({
roomId: z
.string()
.optional()
.describe('The room the subscribe was posted from. Accepted and unused — nothing stores 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'),
+16 -244
View File
@@ -84,256 +84,29 @@ describe('clubs endpoints', () => {
expect(res.status).toBe(404)
})
test('GET /subscription/mine/member returns an empty array without a token', async () => {
// The client calls this on the clubs host with no /club prefix and no auth.
const res = await exports.default.fetch(`${ORIGIN}/subscription/mine/member`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /subscription/details/:subscription returns an empty object', async () => {
const res = await exports.default.fetch(`${ORIGIN}/subscription/details/rrplus`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
})
describe('subscriptions', () => {
// A creator's subscribers are the members of their SUBSCRIPTION CLUB (ClubType 1).
// Nothing provisions those yet, so the tests seed them the way the eventual
// provisioner will: a ClubType 1 club with the creator as its Creator-tier member.
let clubId = 0
const CREATOR = 8800
beforeAll(async () => {
const idRow = await env.DB.prepare(
'SELECT COALESCE(MAX(club_id), 0) + 1 AS next FROM club'
).first<{
next: number
}>()
clubId = idRow?.next ?? 1
await env.DB.prepare('INSERT INTO club (data) VALUES (?1)')
.bind(
JSON.stringify({
ClubId: clubId,
Name: 'Player8800 subscribers',
Description: '',
Category: '',
Visibility: 1,
Joinability: 0,
AllowJuniors: true,
MainImageName: '',
ClubType: 1,
ClubhouseRoomId: null,
CreatorAccountId: CREATOR,
IsRRO: false,
MinLevel: 0,
State: 0,
MemberCount: 1,
CreatedAt: '2026-07-01T00:00:00Z',
})
)
.run()
await env.DB.prepare(
'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, ?2, ?3, ?4)'
)
.bind(clubId, CREATOR, 100, '2026-07-01T00:00:00Z')
.run()
})
test('GET /subscription/details/:accountId returns simulated details', async () => {
const res = await exports.default.fetch(`${ORIGIN}/subscription/details/2`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 })
})
type Envelope = { value: number | null; success: boolean; errorId: null; error: string | null }
type Membership = { AccountId: number; ClubId: number; SubscriberCount: number }
type Details = { accountId: number; clubId: number; subscriberCount: number }
const subscribe = async (sub: string, id: number | string = CREATOR, body = 'roomId=78') =>
exports.default.fetch(`${ORIGIN}/subscription/${id}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const details = async (accountId: number): Promise<Details> => {
const res = await exports.default.fetch(`${ORIGIN}/subscription/details/${accountId}`)
expect(res.status).toBe(200)
return (await res.json()) as Details
}
const count = async (accountId: number): Promise<number> => {
const res = await exports.default.fetch(`${ORIGIN}/subscription/subscriberCount/${accountId}`)
expect(res.status).toBe(200)
return (await res.json()) as number
}
const mine = async (headers: Record<string, string> = {}): Promise<Membership[]> => {
const res = await exports.default.fetch(`${ORIGIN}/subscription/mine/member`, { headers })
expect(res.status).toBe(200)
return (await res.json()) as Membership[]
}
test('an id that isn’t an account reads as club 0 and never gets one', async () => {
expect(await details(2)).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 })
expect(await details(2)).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 })
expect(await count(2)).toBe(0)
const { results } = await env.DB.prepare(
'SELECT club_id FROM club WHERE creator_account_id = 2'
).all()
expect(results).toEqual([])
})
test('the first details read for an account provisions its subscription club', async () => {
// Account 9101 exists (seeded) but has no subscription club: the count read
// doesn't make one …
expect(await count(9101)).toBe(0)
expect(
await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 9101').first()
).toBeNull()
// … the details read does, and every read after agrees on it.
const first = await details(9101)
expect(first.clubId).toBeGreaterThan(0)
expect(first).toEqual({ accountId: 9101, clubId: first.clubId, subscriberCount: 0 })
expect(await details(9101)).toEqual(first)
expect(await count(9101)).toBe(0)
// A ClubType 1 club named after the creator, Private, Open, with the creator as its
// Creator-tier (and only) member; out of the creator's own club lists and search.
const club = (await env.DB.prepare('SELECT data FROM club WHERE club_id = ?1')
.bind(first.clubId)
.first<{ data: string }>())!
expect(JSON.parse(club.data)).toMatchObject({
ClubId: first.clubId,
Name: 'Player9101',
ClubType: 1,
Visibility: 0,
Joinability: 0,
CreatorAccountId: 9101,
MemberCount: 1,
})
const { results: members } = await env.DB.prepare(
'SELECT account_id, membership_type FROM club_member WHERE club_id = ?1'
)
.bind(first.clubId)
.all()
expect(members).toEqual([{ account_id: 9101, membership_type: 100 }])
expect(await mine(await bearer('9101'))).toEqual([
{ AccountId: 9101, ClubId: first.clubId, SubscriberCount: 0 },
])
const created = await exports.default.fetch(`${ORIGIN}/club/mine/created`, {
headers: await bearer('9101'),
})
expect(await created.json()).toEqual([])
const search = await exports.default.fetch(`${ORIGIN}/club/search?query=Player9101`)
expect(((await search.json()) as { Clubs: unknown[] }).Clubs).toEqual([])
// Subscribing to the creator lands in the provisioned club.
expect((await subscribe('9100', 9101)).status).toBe(200)
expect(await details(9101)).toEqual({
accountId: 9101,
clubId: first.clubId,
subscriberCount: 1,
})
})
test('a fresh subscription club has its creator as a member but no subscribers', async () => {
expect(await details(CREATOR)).toEqual({ accountId: CREATOR, clubId, subscriberCount: 0 })
expect(await count(CREATOR)).toBe(0)
// The creator is a Creator-tier member of their own club, so it's listed for them.
expect(await mine(await bearer(String(CREATOR)))).toEqual([
{ AccountId: CREATOR, ClubId: clubId, SubscriberCount: 0 },
])
})
test('GET /subscription/mine/member answers [] without a token', async () => {
// The client has been seen calling this with no auth header: `[]`, not 401 (it
// chokes on null). A garbage token is the same; so is a player with none.
expect(await mine()).toEqual([])
expect(await mine({ Authorization: 'Bearer nope' })).toEqual([])
expect(await mine(await bearer('8801'))).toEqual([])
})
test('POST /subscription/:accountId joins the creator’s subscription club and the reads follow', async () => {
// Auth-gated.
const anon = await exports.default.fetch(`${ORIGIN}/subscription/${CREATOR}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'roomId=78',
})
expect(anon.status).toBe(401)
// The reference envelope: `error`/`errorId` null on success, `value` an id.
const first = await subscribe('8801')
expect(first.status).toBe(200)
const created = (await first.json()) as Envelope
expect(created).toMatchObject({ success: true, errorId: null, error: null })
expect(created.value).toBeGreaterThan(0)
// The subscriber is a plain Member of the club; the club's count moved.
const row = await env.DB.prepare(
'SELECT club_member_id, membership_type FROM club_member WHERE club_id = ?1 AND account_id = 8801'
)
.bind(clubId)
.first<{ club_member_id: number; membership_type: number }>()
expect(row).toEqual({ club_member_id: created.value, membership_type: 10 })
// Idempotent: subscribing again (from another room, in the other casing) answers
// the same id and nothing doubles.
const again = await subscribe('8801', CREATOR, 'RoomId=99')
expect(again.status).toBe(200)
expect(((await again.json()) as Envelope).value).toBe(created.value)
// A second subscriber; every read agrees, and the creator isn't counted.
expect((await subscribe('8802', CREATOR, '')).status).toBe(200)
expect(await count(CREATOR)).toBe(2)
expect(await details(CREATOR)).toEqual({ accountId: CREATOR, clubId, subscriberCount: 2 })
expect(await mine(await bearer('8801'))).toEqual([
{ AccountId: CREATOR, ClubId: clubId, SubscriberCount: 2 },
])
expect(await mine(await bearer('8802'))).toEqual([
{ AccountId: CREATOR, ClubId: clubId, SubscriberCount: 2 },
])
// The club's MemberCount counts the creator too, and the subscription club stays
// out of the ordinary my-clubs lists.
const club = await env.DB.prepare('SELECT data FROM club WHERE club_id = ?1')
.bind(clubId)
.first<{ data: string }>()
expect((JSON.parse(club?.data ?? '{}') as { MemberCount: number }).MemberCount).toBe(3)
const listed = await exports.default.fetch(`${ORIGIN}/club/mine/member`, {
headers: await bearer('8801'),
})
expect(await listed.json()).toEqual([])
})
test('POST /subscription/:accountId refuses the creator and the banned, 404s a non-account', async () => {
const refused = async (res: Response, error: string): Promise<void> => {
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ value: null, success: false, errorId: null, error })
}
await refused(await subscribe(String(CREATOR)), 'You cannot subscribe to yourself.')
// A ban sticks.
await env.DB.prepare(
'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, 8804, -1, ?2)'
)
.bind(clubId, '2026-07-01T00:00:00Z')
.run()
await refused(await subscribe('8804'), 'You cannot subscribe to this creator.')
// An id that isn't an account is a 404 and mints no club; a non-numeric id
// doesn't match the route.
expect((await subscribe('8801', 999999)).status).toBe(404)
expect(
await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 999999').first()
).toBeNull()
expect((await subscribe('8801', 'abc')).status).toBe(404)
})
test('subscribing to an account with no club yet provisions it', async () => {
// Account 9100 exists (seeded) and nobody has read its details.
expect(
await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 9100').first()
).toBeNull()
const res = await subscribe('8801', 9100)
expect(res.status).toBe(200)
expect(((await res.json()) as Envelope).success).toBe(true)
const d = await details(9100)
expect(d.clubId).toBeGreaterThan(0)
expect(d).toEqual({ accountId: 9100, clubId: d.clubId, subscriberCount: 1 })
expect(await mine(await bearer('8801'))).toContainEqual({
AccountId: 9100,
ClubId: d.clubId,
SubscriberCount: 1,
})
})
test('GET /subscription/subscriberCount/:id returns 0', async () => {
const res = await exports.default.fetch(`${ORIGIN}/subscription/subscriberCount/2`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
})
test('GET /announcements/v2/mine/unread returns []', async () => {
@@ -1668,7 +1441,6 @@ describe('clubs endpoints', () => {
'POST /club/{clubId}/join',
'POST /club/{clubId}/leave',
'POST /club/{clubId}/members/leave',
'POST /subscription/{accountId}',
'PUT /club/home/me',
'PUT /club/{clubId}/additionalimage/{index}',
'PUT /club/{clubId}/clubhouse',
+4 -1
View File
@@ -30,7 +30,10 @@
],
"upload_source_maps": true,
"observability": {
"enabled": false
"logs": {
"enabled": true,
"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
+2 -2
View File
@@ -18,7 +18,7 @@
"@repo/hono-helpers": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.13.5",
"hono": "4.12.27",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -30,6 +30,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.128.0"
"wrangler": "4.105.0"
}
}
-20
View File
@@ -114,26 +114,6 @@ const app = new Hono<App>()
(c) => c.json(catalog)
)
// Pending purchases the client asks the server to reconcile — platform transactions
// it started but never saw finish. Nothing is ever recorded here, so there is never
// anything pending and the answer is always an empty list. Accepts GET or POST since
// the client may use either.
.on(
['GET', 'POST'],
'/purchase/v1/cleanuppending',
describeRoute({
tags: ['Purchase'],
summary: 'Reconcile pending purchases (no-op)',
description: [
'Always `[]` — no purchase is ever recorded, so nothing can be left pending. A 404',
'here makes the client treat the call as an error, so the empty list is served',
'instead. Accepts GET or POST.',
].join(' '),
responses: { 200: json(JsonArray, 'Always empty (nothing pending)') },
}),
(c) => c.json([])
)
// Current purchase campaigns (limited-time offers/promos). None exist, and
// an empty list is the client's "no active campaigns" state.
.get(
@@ -43,14 +43,6 @@ describe('commerce endpoints', () => {
expect(skus[0]).toHaveProperty('skuId')
})
it('GET|POST /purchase/v1/cleanuppending returns []', async () => {
for (const method of ['GET', 'POST']) {
const res = await SELF.fetch(`${ORIGIN}/purchase/v1/cleanuppending`, { method })
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
}
})
it('GET /purchasecampaign/allcurrent/v2 returns []', async () => {
const res = await SELF.fetch(`${ORIGIN}/purchasecampaign/allcurrent/v2`)
expect(res.status).toBe(200)
@@ -86,11 +78,9 @@ describe('commerce endpoints', () => {
expect([...documented].sort()).toEqual([
'GET /',
'GET /api/catalog/v1/all',
'GET /purchase/v1/cleanuppending',
'GET /purchase/v1/hasspentmoney',
'GET /purchasecampaign/allcurrent/v2',
'GET /reminder/currentTokenBundles/v2',
'POST /purchase/v1/cleanuppending',
'POST /purchase/v1/initiatepurchase',
])
+4 -4
View File
@@ -5,11 +5,11 @@
"compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"],
"upload_source_maps": true,
"cache": {
"enabled": true
},
"observability": {
"enabled": false
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment

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