Compare commits

..

1 Commits

Author SHA1 Message Date
Devin Zuczek 7eab298ba1 [rooms] role refactor 2026-09-09 17:28:24 -04:00
242 changed files with 161557 additions and 681650 deletions
+1 -53
View File
@@ -62,20 +62,8 @@ RECFLARE_DOMAIN=rec.example.com
# ...PER_IP counts accounts per signup IP — coarse, since a household, NAT or campus
# network shares one address. This is the one to raise (or zero out) if real players
# report being locked out.
# ...PER_DEVICE_ID counts accounts per client device id (the game's per-install hash,
# sent as `device_id`) — narrower than an IP and unmoved by a VPN, which is what the
# IP cap alone can't stop. Client-chosen, so it catches the stock client, not a script.
# Signups that post no device id (the website's) aren't counted.
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
# RECFLARE_MAX_ACCOUNTS_PER_IP=3
# RECFLARE_MAX_ACCOUNTS_PER_DEVICE_ID=3
# Whether accounts can be created from the WEBSITE (`auth` and `www`) — a password
# account with no Steam/Meta identity, which nothing but the caps above can limit, and a
# VPN or Tor exit resets the IP one. `on` or `off`; unset is OFF. Signing in to an
# existing password account is never affected, nor is creating one by launching the game.
# Turning it on also needs the Turnstile keypair in the Secrets Store (see www).
# RECFLARE_PASSWORD_SIGNUP=off
# How far a ban reaches beyond the account it was handed to (`match` and `auth`), as a
# comma-separated list out of `ip` and `platform` — or `off` for neither. Unset means
@@ -117,8 +105,7 @@ RECFLARE_DOMAIN=rec.example.com
# it came through the room list, a club's clubhouse, or a party. Substitution is a single
# hop (2=3,3=2 swaps the two rooms), a requested subroom is dropped in favour of the
# substitute's default one, and a target that doesn't exist leaves the original room in
# place. Only client builds newer than 20230414 are switched; the 2023 client and older
# enter the room they asked for. Following a friend or joining a specific instance is unaffected — those join a
# place. Following a friend or joining a specific instance is unaffected — those join a
# live instance, which is already in whichever room it was created in.
# RECFLARE_ROOM_REDIRECTS=2=MyHub
@@ -147,11 +134,6 @@ RECFLARE_DOMAIN=rec.example.com
# 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
# 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
@@ -167,15 +149,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.
@@ -215,28 +188,3 @@ RECFLARE_DOMAIN=rec.example.com
# 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). The website also pays the same box ONCE, on the spot, the first time an
# account links its Discord, so a new supporter isn't waiting for the next run. Unset — or
# parsing to no roles — leaves the cron a logged no-op and the first-link gift off.
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
+13 -117
View File
@@ -51,7 +51,7 @@ Supported client builds live in `SUPPORTED_GAME_VERSIONS`
- `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.
- `20260323.14`, `20250424.01`, `20231207`, `20230616` — alpha.
- `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,
@@ -115,24 +115,15 @@ inconsistency here without checking the client first.
`StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so
`…/saves` is real history and `publish_save` doubles as restore-a-save. There
is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path.
`GET …/saves/:saveId` is the detail behind a list row, under the same gate, and a
PASCALCASE row like the list serves — NOT the camelCase projection the room save's
response uses (served that, the client showed a save with every value missing). It
differs from the list row only in how it names bundles: each `UnitySubAssets` entry is
`{ UnityAssetId, UnityAsset, UnityAssetHash }`, the main bundle is lifted to top-level
`UnityAsset`/`UnityAssetHash`, and there is no `Tags`. Three shapes of one save; keep
them straight.
- The save reads (`rooms`: `…/saves`, `…/saves/no_unity_assets` and `…/saves/:saveId`) are
auth-gated and readable by the room's creator, a CO-OWNER, anyone whose live `presence`
row puts them in that room, or STAFF (a `developer`/`moderator` token). They list
unpublished staged saves, so they aren't public; but a co-owner opens the save history from
the room's settings without being in the room (creator-only showed them the buttons and a
list that 403'd), and a visitor resolves which version an instance is running from this
list, so managers-only locks them out of loading the room. The presence grant expires with
the presence row. The client reads `…/saves/no_unity_assets` BEFORE it matchmakes a private
instance, when the caller has no presence in the room yet, so presence alone cannot let a
non-owner create one: a developer creating a private instance of a room they hold no role
on was 403'd there until staff were let through.
`GET …/saves/:saveId` is the detail behind a list row, under the same gate, but in the
CAMELCASE projection the room save's response uses — not the PascalCase rows the list
serves. Three shapes of one save; keep them straight.
- Both save reads (`rooms`: `…/saves` and `…/saves/:saveId`) are auth-gated and readable by
the room's CREATOR or by anyone whose live `presence` row puts them in that room — not by
co-owners as such (a co-owner passes only by standing there). They list unpublished
staged saves, so they aren't public; but a visitor resolves which version an instance is
running from this list, so creator-only locks them out of loading the room. The grant
expires with the presence row.
- A room save writes ONLY to the subroom and its save row — never to the room. Everything
the body carries describes that one revision: `Description` is the save comment shown in
`…/saves`, and `PersistenceVersion`/`InventionUsage` describe the scene just saved (the
@@ -196,110 +187,15 @@ inconsistency here without checking the client first.
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.
the field. Bites the vote-to-kick message (`api`: `POST /api/PlayerReporting/v3/voteToKick`,
whose `Data` carries `{ PlayerId, Response, GameSessionId }` — `PlayerId` a string inside
it, as the reference relays it) and, the same way, a chat message's `Contents`.
- 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`.
- A ban's `Duration` (`api`: `/api/PlayerReporting/v1/moderationBlockDetails`, and the
`ModerationKick` frame `www`'s staff ban sends) is the seconds LEFT as of the response — the
client counts it down from receipt. It is NOT the ban's full span paired with
`TimeoutStartedAt` (start + duration): served that way, the screen read correctly only in
the kick frame, sent at the instant the span and the remainder coincide, and every later
sign-in showed the whole ban still to run. `TimeoutStartedAt` stays null for a ban; where
other implementations set it at all, it names the start of a voice-chat timeout. Other
implementations with timed bans keep their set-time private and serve the remainder on
every read; `banned_at` here is the staff panel's audit trail, not the client's.
- An outfit is served to OTHER players (`api`: `POST /outfits/bulk`) with the selections the
wearer does not own stripped (`restrictOutfitToOwned` in `@repo/domain`); the owner's
`GET /outfits/me` stays verbatim. Nothing on the save path checks ownership. A legacy
selection entry (`SelectionsV1`/`OutfitSelections`, `;`-delimited) is
`<AvatarItemDesc>,<extra>,<bodyPart>` with the descriptor's empty trailing fields dropped,
which is why `5cd08cfb-…,,1` and `40528de7-…,51ef8d39-…,0b2395e1-…,,0` are both one entry;
a v2 selection (`SelectionsV2`/`OutfitSelectionsV2`, JSON-in-a-string) names the item as
`PrefabGuid` and a custom shirt as `UgcOutfitData.CustomAvatarItemId`. Ownership is decided
on the BASE ASSET (first descriptor field, which is `PrefabGuid`) — not the colour — because
the v2 forms carry no colour and the forms must agree; the free default catalog and the
custom-clothing base items count as owned. `CustomizationSettings` has only been observed
as its header (`AvatarVersion`, `AvatarBodyType`); any v2-shaped selections inside it are
stripped wherever they sit. A document is re-encoded only when something was removed.
- 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 -83
View File
@@ -51,88 +51,6 @@ bun runx admin grant-developer --account 1 --revoke
bun runx admin grant-moderator --username alice --remote
```
### `grant-studio` — grant or revoke Rec Room Studio upload access
Sets the account's `hasStudio` flag, which `auth` stamps into the token as the
`betastudio` role — the claim RecFlare Studio reads as permission to upload. Staff
normally manage this from the website (`/settings/recroomstudio`); this is the
operator's way in. Takes effect on the player's next Studio sign-in or refresh.
```sh
bun runx admin grant-studio --username alice
bun runx admin grant-studio --account 42 --revoke
```
### `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
@@ -147,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)
+4 -11
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`
+2 -20
View File
@@ -71,7 +71,7 @@ preview:
deploy *args:
bun turbo deploy "$@"
# Apply D1 migrations (every worker with a `migrate` script). Defaults to --remote; pass `-- --local`
# Apply D1 migrations (rooms + auth own them). Defaults to --remote; pass `-- --local`
# for the dev db. Scope with -F, e.g. `just migrate -F rooms`.
[group('2. local dev')]
[positional-arguments]
@@ -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 "$@"
-1
View File
@@ -44,7 +44,6 @@ These game builds are supported:
| ------------- | --------------------- | --------------------------------------------------------------------------------------- |
| `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 |
| `20260323.14` | | Alpha |
| `20250424.01` | | Alpha |
| `20231207` | | Alpha |
| `20230616` | | Alpha |
+38 -38
View File
@@ -20,41 +20,41 @@ A small `ns` worker itself serves this discovery document at the
apex/`ns` host and isn't listed within it. Each implemented worker has its own
`README.md` under `apps/<name>/` documenting its routes.
| Service | Subdomain | Worker | Notes |
| --------------------- | ----------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Accounts | `accounts` | `accounts` | Player accounts & profile reads/writes (D1) |
| AI | `ai` | `ai` | Game AI access check (always refuses — no model runs here) |
| API | `api` | `api` | Core Game API — config, social, avatar, rooms, image uploads (D1, R2) |
| Auth | `auth` | `auth` | OAuth tokens, plus Studio device login (`POST /connect/deviceauthorization`, device-code grant on `/connect/token`); (D1) |
| BugReporting | `bugreporting` | — | Not yet implemented |
| Cards | `cards` | `cards` | Stub — deploys and answers, no card endpoints yet |
| CDN | `cdn` | `cdn` | Binary CDN — room data, and Studio room bundles at `/unityasset/{filename}` (R2, D1 lookup). The editor's `studiocdn` host is this same app on a combined deploy |
| Chat | `chat` | `chat` | Player chat service (not in room) |
| Clubs | `clubs` | `clubs` | Clubs, not yet implemented |
| CMS | `cms` | — | Not yet implemented |
| Commerce | `commerce` | `commerce` | Store / purchase endpoints |
| Data | `data` | — | Not yet implemented |
| DataCollection | `datacollection` | `datacollection` | Client telemetry / analytics sink |
| Discovery | `discovery` | `discovery` | Discovery page layouts (static assets) |
| Econ | `econ` | `econ` | Economy & avatar endpoints (separate from `api`) |
| GameLogs | `gamelogs` | — | Not yet implemented |
| Geo | `geo` | — | Not yet implemented |
| Images | `img` | `img` | Image storage & signed delivery (R2) |
| Leaderboard | `leaderboard` | `leaderboard` | Per-room stat leaderboards, one board per stat channel (D1) |
| 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) |
| Moderation | `moderation` | `moderation` | Stub — the reporting routes still live in `api`; see note below |
| Notifications | `notify` | `notify` | Real-time notifications over SignalR/WebSockets (Durable Object) |
| PlatformNotifications | `platformnotifications` | `platformnotifications` | Stub — deploys and answers, no notification endpoints yet |
| PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) |
| RoomComments | `roomcomments` | `roomcomments` | Notes pinned in a room’s scene — read & create (D1) |
| RoomieIntegrations | `roomieintegrations` | — | Not yet implemented |
| Rooms | `rooms` | `rooms` | Room storage & queries; seeds the Dorm & Orientation rooms (D1) |
| Storage | `storage` | — | Room uploader |
| Strings | `strings` | — | Not yet implemented |
| StringsCDN | `strings-cdn` | — | Not yet implemented |
| Studio | `studio` | `studio` | `GET /cloud-builds/for-room` lists that room's cloud builds. `POST /cloud-builds/from-editor` stores a locally built Windows and Android asset-bundle pair as a finished cloud build on the latest save, and sets `BecameRRStudioRoomAt` the first time. The game loads those bundles from **Rooms** (`CurrentSave.UnitySubAssets`, `GET …/unityasset`) and **CDN** `/unityasset/{filename}`. `GET /collaboration/owners-in-room` lists co-owners whose presence is in that room and subroom. Device login stays on **Auth**; `GET /account/me` stays on **Accounts**; the approval page is **WWW** `/device` |
| Thorn | `thorn` | — | Not yet implemented |
| Videos | `videos` | — | Not yet implemented |
| WWW | `www` | `www` | Website, including the Studio sign-in page at `/device` |
| Service | Subdomain | Worker | Notes |
| --------------------- | ----------------------- | ----------------------- | --------------------------------------------------------------------- |
| Accounts | `accounts` | `accounts` | Player accounts & profile reads/writes (D1) |
| AI | `ai` | `ai` | Game AI access check (always refuses — no model runs here) |
| API | `api` | `api` | Core Game API — config, social, avatar, rooms, image uploads (D1, R2) |
| Auth | `auth` | `auth` | OAuth token issuance (`/connect/token`); (D1) |
| BugReporting | `bugreporting` | — | Not yet implemented |
| Cards | `cards` | `cards` | Stub — deploys and answers, no card endpoints yet |
| CDN | `cdn` | `cdn` | Binary CDN — room data (R2) |
| Chat | `chat` | `chat` | Player chat service (not in room) |
| Clubs | `clubs` | `clubs` | Clubs, not yet implemented |
| CMS | `cms` | — | Not yet implemented |
| Commerce | `commerce` | `commerce` | Store / purchase endpoints |
| Data | `data` | — | Not yet implemented |
| DataCollection | `datacollection` | `datacollection` | Client telemetry / analytics sink |
| Discovery | `discovery` | `discovery` | Discovery page layouts (static assets) |
| Econ | `econ` | `econ` | Economy & avatar endpoints (separate from `api`) |
| GameLogs | `gamelogs` | — | Not yet implemented |
| Geo | `geo` | — | Not yet implemented |
| Images | `img` | `img` | Image storage & signed delivery (R2) |
| Leaderboard | `leaderboard` | `leaderboard` | Per-room stat leaderboards, one board per stat channel (D1) |
| 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) |
| Moderation | `moderation` | `moderation` | Stub — the reporting routes still live in `api`; see note below |
| Notifications | `notify` | `notify` | Real-time notifications over SignalR/WebSockets (Durable Object) |
| PlatformNotifications | `platformnotifications` | `platformnotifications` | Stub — deploys and answers, no notification endpoints yet |
| PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) |
| RoomComments | `roomcomments` | `roomcomments` | Notes pinned in a room’s scene — read & create (D1) |
| RoomieIntegrations | `roomieintegrations` | — | Not yet implemented |
| Rooms | `rooms` | `rooms` | Room storage & queries; seeds the Dorm & Orientation rooms (D1) |
| Storage | `storage` | — | Room uploader |
| Strings | `strings` | — | Not yet implemented |
| StringsCDN | `strings-cdn` | — | Not yet implemented |
| Studio | `studio` | — | Not yet implemented |
| Thorn | `thorn` | — | Not yet implemented |
| Videos | `videos` | — | Not yet implemented |
| WWW | `www` | — | Website/Panel |
+37 -72
View File
@@ -4,7 +4,6 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
createAccount,
DEFAULT_USERNAME_CHANGES,
defaultAccount,
getAccount,
getAccountByUsername,
@@ -29,7 +28,6 @@ import {
BannerImageRequest,
BioRequest,
BioResponse,
BulkIdsRequest,
CreateAccountRequest,
CreateAccountResult,
DisplayNameRequest,
@@ -53,7 +51,6 @@ import {
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 } from './context'
@@ -81,6 +78,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 = 3
/**
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
* On success `value` is the updated account; on error `error` carries the message
@@ -102,69 +102,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).
@@ -349,12 +286,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',
-10
View File
@@ -212,16 +212,6 @@ export const UsernameRequest = z.object({
),
})
/**
* `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'),
})
export const EmailRequest = z.object({
email: z
.string()
@@ -97,48 +97,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)
@@ -694,7 +652,6 @@ describe('auth-gated endpoints', () => {
'GET /accountprivacysettings/{id}',
'GET /emojiConfig/whitelistedEmojis',
'GET /parentalcontrol/me',
'POST /account/bulk',
'POST /account/create',
'POST /account/me/email',
'POST /account/me/phone',
+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
+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
-8
View File
@@ -10,11 +10,3 @@ declare namespace Cloudflare {
mainModule: MainModule
}
}
// Vite serves a `?raw` import as the file's text. Used by the tests to read a data
// migration (0032, the custom-item price cap) as a STRING and run its UPDATE against the
// schema they build from SCHEMA_DDL, which the migration files never touch.
declare module '*.sql?raw' {
const content: string
export default content
}
+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).
--
-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;
@@ -1,32 +0,0 @@
-- Restore the UNIQUE pair index on `relationship`.
--
-- Generated from packages/domain/src/relationships-db.ts (SCHEMA_DDL) — keep in sync.
-- Migration 0001 declared `idx_relationship` and 0030 counts on it staying ("it is the
-- pair constraint"), but the deployed table did not have it: an audit of sqlite_master
-- against every worker's migrations found it and the two `room_instance` unique indexes
-- (rooms 0022) missing, with every other declared index present. Every relationship
-- write is select-then-insert (`upsertPair`, `setRelationshipFlag`), so this index is the
-- only thing that stops a replayed or concurrent request from filing a pair twice —
-- which shows up as a doubled friend entry, with `findPair` reading whichever twin the
-- planner returns.
--
-- A UNIQUE index refuses to build over duplicates, so any are merged first: the oldest
-- row of each (requester_id, target_id) group survives, carrying the MAX of every field
-- across the group — for the flags that is an OR, and for `relationship_type` it is the
-- furthest-along state (3 Friend > 2 Received > 1 Sent > 0 None). Then the rest go. Both
-- statements are no-ops on a table with no duplicates.
UPDATE relationship SET
relationship_type = (SELECT MAX(r.relationship_type) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
requester_favorited = (SELECT MAX(r.requester_favorited) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
requester_ignored = (SELECT MAX(r.requester_ignored) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
requester_muted = (SELECT MAX(r.requester_muted) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
target_favorited = (SELECT MAX(r.target_favorited) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
target_ignored = (SELECT MAX(r.target_ignored) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
target_muted = (SELECT MAX(r.target_muted) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id)
WHERE id IN (SELECT MIN(id) FROM relationship GROUP BY requester_id, target_id HAVING COUNT(*) > 1);
DELETE FROM relationship
WHERE id NOT IN (SELECT MIN(id) FROM relationship GROUP BY requester_id, target_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_relationship ON relationship (requester_id, target_id);
@@ -1,18 +0,0 @@
-- Player-made custom shirts are capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE` in
-- src/custom-avatar-items-db.ts): the create and the edit now refuse a price above it, and
-- this lowers every existing player-made row that is already over it to exactly the cap.
--
-- Only PLAYER-MADE rows are touched, told apart by `BaseAvatarItemId`: a player-made shirt
-- is built on a base item, while a first-party item (imported from the official export,
-- Coach-authored) has it NULL and is priced from the storefront dump, where several sit
-- well above 1000. Those keep their prices.
--
-- `ModifiedAt` is bumped as the edit endpoint bumps it, since the record did change.
-- `price` is a generated column off `data`, so rewriting the JSON is the whole update.
UPDATE custom_avatar_item
SET data = json_set(data,
'$.Price', 1000,
'$.ModifiedAt', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
WHERE json_extract(data, '$.BaseAvatarItemId') IS NOT NULL
AND price > 1000;
@@ -1,11 +0,0 @@
-- Inventions are capped at 1000 tokens (`MAX_INVENTION_PRICE` in src/inventions-db.ts): the
-- three writes a creator has (`v3/publish`, `v4/publish`, `v1/updateprice`) now refuse a
-- price above it, and this lowers every existing row already over it to exactly the cap.
-- Drafts included — a draft's price is what its next publish would list it at.
--
-- `ModifiedAt` is left alone, as `v1/updateprice` leaves it: a reprice is not an edit of
-- the invention. `price` is not a generated column here, so the JSON is the whole update.
UPDATE invention
SET data = json_set(data, '$.Price', 1000)
WHERE json_extract(data, '$.Price') > 1000;
-16
View File
@@ -1,16 +0,0 @@
-- Reporting a saved IMAGE (`POST /api/images/v1/{id}/report`) reuses the report table, as
-- the event, invention, custom-avatar-item and chat-message reports beside it do: same
-- fields, same moderation life. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `image_id` names the reported image (`image.id`). NULL on every other kind of report, and
-- mutually exclusive with `event_id`, `invention_id`, `custom_avatar_item_id` and
-- `chat_message_id`.
--
-- The request has no body, so the row is three facts: the reporter (the caller), the image,
-- and `reported_player_id` — the image's `PlayerId`, who took it, read from the image.
-- `report_category` stays 0 and `details` NULL: the client sends neither.
--
-- NOT indexed, like the other kind columns: written on every report of this kind and read by
-- nothing. Add an index with the query that needs it.
ALTER TABLE report ADD COLUMN image_id INTEGER;
@@ -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(', ')}`
)
}
-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
}
+120 -303
View File
@@ -1,90 +1,42 @@
/**
* 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.
* Custom avatar items — player-designed items built on a base catalog item — on the
* shared `recflare` D1 database. One column per field of the client's `CustomAvatarItem`
* DTO, so a row maps straight onto the response.
*
* 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.
* The two uploads that accompany a 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`; the two filename columns hold
* those bucket keys, which the `img` worker serves back by key.
*
* `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`).
* The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.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). */
/** Schema DDL (mirror of migrations/0015_custom_avatar_item.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
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 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.
*/
/** The client's `CustomAvatarItem` record (PascalCase, as served). */
export interface CustomAvatarItem {
CustomAvatarItemId: string
CreatorAccountId: number
@@ -95,52 +47,19 @@ export interface CustomAvatarItem {
ForceCannotPublish: boolean
IsFeatured: boolean
IsRecRoomApproved: boolean
BaseAvatarItemId: number | null
BaseAvatarItemColor: string | null
DesignFilename: string | null
ThumbnailImageFilename: string | null
BaseAvatarItemId: number
BaseAvatarItemColor: string
DesignFilename: string
ThumbnailImageFilename: string
CreatedAt: string
ModifiedAt: string
PreviewOrientation: number
RankingContext: null
OutfitType: number
CurrentSaves: CustomAvatarItemSave[]
Tags: CustomAvatarItemTag[]
CustomBadgeMetadata: unknown
RankedEntityId: string
CurrentSaves: never[]
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
/**
* The most tokens a PLAYER-MADE item may sell for, inclusive. Enforced on the create and
* the edit (`POST`/`PUT /api/customAvatarItems/v1`), the only two writes a player has; the
* client has no ceiling of its own — its `minPriceForPublicItem` is a floor. First-party
* items are not bound by it: their prices come off the storefront dump and several sit
* above it (migrations/0032_custom_avatar_item_price_cap.sql clamps only the player-made
* rows, told apart by `BaseAvatarItemId`).
*/
export const MAX_PLAYER_ITEM_PRICE = 1000
/** 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. */
@@ -154,98 +73,84 @@ export interface CreateCustomAvatarItemInput {
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 }
custom_avatar_item_id: string
creator_account_id: number
name: string
description: string
price: number
accessibility: number
force_cannot_publish: number
is_featured: number
is_rec_room_approved: number
base_avatar_item_id: number
base_avatar_item_color: string
design_filename: string
thumbnail_image_filename: string
created_at: string
modified_at: string
preview_orientation: number
outfit_type: number
}
function toDto(row: Row): CustomAvatarItem {
return parseCustomAvatarItem(row.data)
return {
CustomAvatarItemId: row.custom_avatar_item_id,
CreatorAccountId: row.creator_account_id,
Name: row.name,
Description: row.description,
Price: row.price,
Accessibility: row.accessibility,
ForceCannotPublish: row.force_cannot_publish === 1,
IsFeatured: row.is_featured === 1,
IsRecRoomApproved: row.is_rec_room_approved === 1,
BaseAvatarItemId: row.base_avatar_item_id,
BaseAvatarItemColor: row.base_avatar_item_color,
DesignFilename: row.design_filename,
ThumbnailImageFilename: row.thumbnail_image_filename,
CreatedAt: row.created_at,
ModifiedAt: row.modified_at,
PreviewOrientation: row.preview_orientation,
RankingContext: null,
OutfitType: row.outfit_type,
CurrentSaves: [],
PurchaseInfo: null,
}
}
/** Inserts a new player-made custom avatar item and returns it as the client's DTO. */
/** Inserts a new 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`
`INSERT INTO custom_avatar_item (
custom_avatar_item_id, creator_account_id, name, description, price, accessibility,
base_avatar_item_id, base_avatar_item_color, design_filename, thumbnail_image_filename,
created_at, modified_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
RETURNING *`
)
.bind(
input.customAvatarItemId,
input.creatorAccountId,
input.name,
input.description,
input.price,
input.accessibility,
input.baseAvatarItemId,
input.baseAvatarItemColor,
input.designFilename,
input.thumbnailImageFilename,
ts
)
.bind(JSON.stringify(stored))
.first<Row>()
if (!row) throw new Error('custom_avatar_item import returned no row')
if (!row) throw new Error('custom_avatar_item insert returned no row')
return toDto(row)
}
@@ -273,8 +178,6 @@ export interface UgcPurchasableItem {
* 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 {
@@ -282,7 +185,7 @@ export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPur
ItemId: item.CustomAvatarItemId,
Name: item.Name,
Description: item.Description,
ImageName: item.ThumbnailImageFilename ?? item.CurrentSaves[0]?.ThumbnailFileName ?? '',
ImageName: item.ThumbnailImageFilename,
RoomId: roomId,
Price: item.Price,
PurchaseCurrencyId: null,
@@ -299,66 +202,13 @@ export async function getCustomAvatarItems(
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})`)
.prepare(`SELECT * 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]))
const byId = new Map(results.map((r) => [r.custom_avatar_item_id, toDto(r)]))
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
@@ -371,7 +221,7 @@ export async function listFeaturedCustomAvatarItems(
): Promise<CustomAvatarItem[]> {
const { results } = await db
.prepare(
`SELECT data FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0
`SELECT * FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
)
.bind(limit)
@@ -391,7 +241,7 @@ export async function listHotCustomAvatarItems(
): Promise<CustomAvatarItem[]> {
const { results } = await db
.prepare(
`SELECT data FROM custom_avatar_item WHERE accessibility != 0
`SELECT * FROM custom_avatar_item WHERE accessibility != 0
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
)
.bind(limit)
@@ -401,10 +251,9 @@ export async function listHotCustomAvatarItems(
/**
* 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.
* `econ` worker attributes a self-buy or an anonymous gift to.
*/
export { COACH_ACCOUNT_ID }
export const COACH_ACCOUNT_ID = 1
/** What `GET /api/customAvatarItems/v1/search` narrows the catalog by. */
export interface CustomAvatarItemSearch {
@@ -415,21 +264,8 @@ export interface CustomAvatarItemSearch {
* 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.
*/
/** Whether items authored by the Coach — this server's stock content — are included. */
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. */
@@ -450,23 +286,15 @@ export const SEARCH_MAX_TAKE = 200
* 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.
* `searchQuery` matches an item's NAME or its DESCRIPTION, case-insensitively, as a substring.
* 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
@@ -490,13 +318,8 @@ export async function searchCustomAvatarItems(
// 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})`
`(lower(name) LIKE ${pattern} ESCAPE '\\' OR lower(description) LIKE ${pattern} ESCAPE '\\')`
)
}
@@ -504,14 +327,9 @@ export async function searchCustomAvatarItems(
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) {
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)}`)
@@ -519,7 +337,7 @@ export async function searchCustomAvatarItems(
const offset = bind(skip)
const { results } = await db
.prepare(
`SELECT data FROM custom_avatar_item WHERE ${where.join(' AND ')}
`SELECT * FROM custom_avatar_item WHERE ${where.join(' AND ')}
ORDER BY created_at DESC, custom_avatar_item_id
LIMIT ${limit} OFFSET ${offset}`
)
@@ -542,7 +360,7 @@ export async function listCustomAvatarItemsByCreator(
): Promise<{ Results: CustomAvatarItem[]; TotalResults: number }> {
const { results } = await db
.prepare(
`SELECT data FROM custom_avatar_item
`SELECT * FROM custom_avatar_item
WHERE creator_account_id = ?1 AND (accessibility != 0 OR ?2)
ORDER BY created_at DESC, custom_avatar_item_id`
)
@@ -561,9 +379,8 @@ export interface UpdateCustomAvatarItemInput {
}
/**
* 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).
* Applies a partial edit to one item, bumping `modified_at`. 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(
@@ -574,14 +391,14 @@ export async function updateCustomAvatarItem(
): 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)
`UPDATE custom_avatar_item SET
name = COALESCE(?2, name),
description = COALESCE(?3, description),
price = COALESCE(?4, price),
accessibility = COALESCE(?5, accessibility),
modified_at = ?6
WHERE custom_avatar_item_id = ?1
RETURNING data`
RETURNING *`
)
.bind(
id,
@@ -601,7 +418,7 @@ export async function deleteCustomAvatarItem(
id: string
): Promise<CustomAvatarItem | null> {
const row = await db
.prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING data')
.prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING *')
.bind(id)
.first<Row>()
return row ? toDto(row) : null
@@ -613,7 +430,7 @@ export async function getCustomAvatarItem(
id: string
): Promise<CustomAvatarItem | null> {
const row = await db
.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
.prepare('SELECT * 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
}
+43 -193
View File
@@ -18,7 +18,6 @@
*/
import {
Accessibility,
glyphLength,
MAX_EVENT_DESCRIPTION_LENGTH,
MAX_EVENT_DURATION_MS,
@@ -26,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 (
@@ -59,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
)`,
]
/**
@@ -74,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)
}
/**
@@ -191,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
@@ -207,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[]
@@ -237,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,
@@ -640,11 +620,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,
@@ -682,51 +665,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.
@@ -750,7 +728,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)
@@ -760,22 +738,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
@@ -867,31 +829,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.
@@ -954,72 +897,6 @@ export async function getEventsByCreator(
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/**
* One entry of a profile's `Responses` — the event beside the player's RSVP to it. The
* event is the 17-key BASE shape (the client's v1 PlayerEvent), not the stored record.
*/
export interface PlayerEventWithResponse {
PlayerEvent: PlayerEventBase
PlayerEventResponse: PlayerEventResponse
}
/** What anyone may see of a player's events — `GET /api/playerevents/v1/all/{playerId}`. */
export interface PlayerPublicEvents {
Created: PlayerEventBase[]
Responses: PlayerEventWithResponse[]
}
/**
* A player's PUBLIC events, as their profile shows them to anyone: the ones they created,
* and the ones they are attending — answered Going; a maybe, a decline and an unanswered
* invitation are nobody else's business — each soonest first. An event's creator holds a
* Going row for it, so their own events appear under both keys.
*
* Only `Accessibility` Public events are served, and FINISHED ones are left out, as the
* browse feed and the room shelf do: this is what the player is going to, not a history.
*/
export async function getPublicEventsByPlayer(
db: D1Database,
playerId: number,
now = Date.now()
): Promise<PlayerPublicEvents> {
const at = eventTime(now)
const [created, responses] = await db.batch<EventRow & Partial<EventAttendeeRow>>([
db
.prepare(
`SELECT data FROM event
WHERE creator_player_id = ?1 AND end_time >= ?2
AND json_extract(data, '$.Accessibility') = ?3`
)
.bind(playerId, at, Accessibility.Public),
db
.prepare(
`SELECT e.data AS data, a.rowid AS id, a.event_id, a.player_id, a.status, a.responded_at
FROM event_attendee a
JOIN event e ON e.id = a.event_id
WHERE a.player_id = ?1 AND a.status = ?2 AND e.end_time >= ?3
AND json_extract(e.data, '$.Accessibility') = ?4`
)
.bind(playerId, EVENT_RESPONSE.going, at, Accessibility.Public),
])
return {
Created: created.results
.map((r) => JSON.parse(r.data) as PlayerEvent)
.sort(bySoonest)
.map(toEventBase),
Responses: responses.results
.map((r) => ({
event: JSON.parse(r.data) as PlayerEvent,
response: toEventResponse(r as EventAttendeeRow),
}))
.sort((a, b) => bySoonest(a.event, b.event))
.map(({ event, response }) => ({
PlayerEvent: toEventBase(event),
PlayerEventResponse: response,
})),
}
}
/**
* The events belonging to a set of clubs — the events shelf on a club's page, soonest
* first. Selected on the indexed club_id column. An empty id list is an empty shelf
@@ -1079,35 +956,9 @@ function bySoonest(a: PlayerEvent, b: PlayerEvent): number {
return a.StartTime.localeCompare(b.StartTime) || a.PlayerEventId - b.PlayerEventId
}
/**
* Best attended first — the count of players who answered Going (`AttendeeCount`, the
* number the client shows), which is exactly what the sort is meant to rank. Interested
* and Can't-go don't count, as they don't toward the displayed count either. Ties fall
* back to soonest first so the order stays stable across pages.
*/
function byAttendance(a: PlayerEvent, b: PlayerEvent): number {
return b.AttendeeCount - a.AttendeeCount || bySoonest(a, b)
}
/**
* The orders the search serves, by the `sort` the client sends: `StartTime` (soonest
* first, the default) and `Attendance` (most Going replies first).
*/
export type EventSort = 'StartTime' | 'Attendance'
/**
* Reads the `sort` query into an {@link EventSort}, case-insensitively. Anything
* unrecognised — or nothing at all — is the default order, soonest first, rather than
* a 400: the browse screen has always sent this param and been served regardless.
*/
export function parseEventSort(raw: string | undefined): EventSort {
return raw?.toLowerCase() === 'attendance' ? 'Attendance' : 'StartTime'
}
/**
* Event search — the browse query on the player-events screen. Term by term, an empty
* query browsing everything upcoming; paginated via skip/take, in the order `sort`
* names ({@link EventSort}): soonest first by default, or most Going replies first.
* query browsing everything upcoming; paginated via skip/take, soonest first.
*
* A term is matched one of two ways, and the `#` decides which:
*
@@ -1128,8 +979,7 @@ export async function searchEvents(
db: D1Database,
query: string,
skip: number,
take: number,
sort: EventSort = 'StartTime'
take: number
): Promise<PlayerEvent[]> {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
// A `#` prefix makes a term a tag; the rest are matched against the text. A bare `#`
@@ -1161,5 +1011,5 @@ export async function searchEvents(
)
}
return events.sort(sort === 'Attendance' ? byAttendance : bySoonest).slice(skip, skip + take)
return events.sort(bySoonest).slice(skip, skip + take)
}
+25 -155
View File
@@ -18,19 +18,14 @@
* into the caller's own list, and to rank the "top today" feed by what players actually
* picked up today. See @repo/domain's inventory-invention-db.ts.
*/
import {
getAccountByUsername,
getInventionAcquisitionCounts,
getOwnedInventionIds,
} from '@repo/domain'
import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domain'
/**
* 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 "is this in the store"
* 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 NULL when the key is
* missing, which fails a `= 1` or `= 0` test either way.
* 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
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS invention (
@@ -44,13 +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,
@@ -326,20 +314,11 @@ export function toInventionV9(invention: SavedInvention): InventionV9Dto {
Name: invention.Name,
Description: invention.Description,
ImageName: invention.ImageName,
// A record saved through `v6/save` stores no `UgcVersion`, and the client's byte
// defaults to 0 — a generation no client writes (every `v9/save` sends 1). The 2025
// client lists nothing that declares 0: these were on the wire and invisible.
UgcVersion: invention.UgcVersion ?? 1,
UgcVersion: invention.UgcVersion ?? 0,
CurrentVersionNumber: invention.CurrentVersionNumber,
// One save, one version: the newest is the current one.
LatestVersionNumber: invention.CurrentVersionNumber,
// A published invention cannot be private. Everything published through
// `v3/publish` still stores the 0 a save mints (see INVENTION_ACCESSIBILITY), which
// this DTO — it has no `IsPublished` — would read as Private; serve it as Public.
Accessibility:
invention.IsPublished && invention.Accessibility === INVENTION_ACCESSIBILITY.private
? INVENTION_ACCESSIBILITY.public
: invention.Accessibility,
Accessibility: invention.Accessibility,
ForceCannotPublish: false,
ModifiedAt: invention.ModifiedAt,
CreatedAt: invention.CreatedAt,
@@ -596,19 +575,8 @@ export async function getInventionsByCreator(
* purchase, they are still on the shelf of the player who paid for them. An owned id
* with no invention row left (deleted) simply drops out. Newest first, like the other
* invention lists; not paginated.
*
* Each row is the stored record WITH the client's `RRInvention` laid over it
* ({@link toInventionV9}). The 2025 client decodes this list into that flat DTO, and a
* key the record lacks defaults silently: no `LatestVersionNumber` reads as a current
* version newer than the latest, no `UgcVersion` as generation 0 whatever was saved, and
* a published record's stored `Accessibility` 0 as Private. The stored keys that DTO has
* no member for (`CurrentVersion`, `IsPublished`, `Tags`, …) stay on the row: its decoder
* drops them, and the older client still reads them.
*/
export async function getMyInventions(
db: D1Database,
playerId: number
): Promise<ListedInvention[]> {
export async function getMyInventions(db: D1Database, playerId: number): Promise<SavedInvention[]> {
const [created, ownedIds] = await Promise.all([
getInventionsByCreator(db, playerId),
getOwnedInventionIds(db, playerId),
@@ -617,51 +585,9 @@ export async function getMyInventions(
const byId = new Map<number, SavedInvention>()
for (const invention of [...created, ...bought]) byId.set(invention.InventionId, invention)
return [...byId.values()]
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
.map(withClientInvention)
}
/** A stored record with the client's flat `RRInvention` laid over it — see {@link getMyInventions}. */
type ListedInvention = Omit<SavedInvention, keyof InventionV9Dto> & InventionV9Dto
function withClientInvention(invention: SavedInvention): ListedInvention {
return { ...invention, ...toInventionV9(invention) }
}
/** The most creators one `v1/fromcreators` call may name — D1 caps a statement's binds. */
const MAX_PORTFOLIO_CREATORS = 50
/**
* A creator's portfolio (`v1/fromcreators?id=…`) — the shelf on their profile, and the
* "from creators you follow" row. Newest first, paged in SQL via skip/take.
*
* Only PUBLISHED inventions (minus hidden ones), whoever asks — the creator included. A
* creator's drafts are on `v2/mine` and nowhere else; this is what they MADE and put out,
* not what they bought or are still working on. Unlisted ones ARE here: unlisted means
* "not in the store", and a portfolio is not the store.
*/
export async function getInventionsByCreators(
db: D1Database,
creatorPlayerIds: number[],
skip: number,
take: number
): Promise<ListedInvention[]> {
const creators = [...new Set(creatorPlayerIds)].slice(0, MAX_PORTFOLIO_CREATORS)
const limit = Math.max(take, 0)
if (creators.length === 0 || limit === 0) return []
const { results } = await db
.prepare(
`SELECT data FROM invention
WHERE creator_player_id IN (${creators.map((_, i) => `?${i + 3}`).join(', ')})
AND is_published = 1 AND hide_from_player = 0
ORDER BY json_extract(data, '$.CreatedAt') DESC, id DESC
LIMIT ?1 OFFSET ?2`
)
.bind(limit, Math.max(skip, 0), ...creators)
.all<InventionRow>()
return results.map((r) => withClientInvention(JSON.parse(r.data) as SavedInvention))
return [...byId.values()].sort(
(a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId
)
}
/**
@@ -731,14 +657,6 @@ export async function ownsAllInventions(
* 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.
*
* A value starting with `@` IS special: `@djdevin` is a search for that player's inventions —
* the public ones, the same set `v1/fromcreators` lists for their portfolio — resolved by
* username, case-insensitively, through the indexed `username_lower`. Any terms after the
* `@name` narrow that player's inventions as text, so `@djdevin sofa` works. A username nobody
* has matches nothing rather than falling back to a text search for "@djdevin": a player who
* typed an `@` meant a person, and a list of inventions whose descriptions mention them is not
* what they asked for.
*/
export async function searchInventions(
db: D1Database,
@@ -755,20 +673,11 @@ export async function searchInventions(
/** 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)}`
const terms = value
for (const term of value
.trim()
.toLowerCase()
.split(/[\s+]+/)
.filter(Boolean)
if (terms[0]?.startsWith('@')) {
const username = terms.shift()!.slice(1)
const creator = username ? await getAccountByUsername(db, username) : null
if (!creator) return []
where.push(`creator_player_id = ${bind(creator.accountId)}`)
}
for (const term of terms) {
.filter(Boolean)) {
const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`)
const pattern = bind(`%${escaped}%`)
where.push(
@@ -942,20 +851,17 @@ export const INVENTION_PERMISSION = {
} as const
/**
* Whether a PUBLISHED invention is in the store, which `v4/publish` sets and nothing before
* it did. The publish sheet sends one of two values, and both publish:
* 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.
*
* - `public` (1) — published and in the store: search and the browse feeds list it.
* - `unlisted` (2) — published but NOT in the store. It is still published, so it is on the
* creator's portfolio (`v1/fromcreators`) and reachable by id; it just isn't for browsing.
* 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.
*
* `private` (0) is what a save mints and what `v2/unpublish` puts back — the state of an
* invention that isn't published at all. Whether anyone else can see an invention is
* `IsPublished`; this field only decides, of the published ones, which the store shows.
*
* Every record published through the older `v3/publish` also still carries 0, since that
* endpoint names no accessibility. So the store filter excludes `unlisted` by name rather
* than requiring `public` — the latter would read all of those as private and drop them.
* 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,
@@ -964,12 +870,9 @@ export const INVENTION_ACCESSIBILITY = {
} as const
/**
* The "is this in the store" test search and the browse feeds share: published, not hidden
* by moderation, and not unlisted. A creator's portfolio deliberately does NOT apply the
* last term (see `getInventionsByCreators`), nor do the by-id reads.
*
* `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.
* 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.
*/
const VISIBLE_IN_FEEDS = [
'is_published = 1',
@@ -1079,42 +982,9 @@ export async function publishInvention(
return updated
}
/**
* Unpublish an invention (`v2/unpublish`) — back to what a save mints: not published, and
* `Accessibility` private, so the next publish sets both afresh. Buyers keep it (`v2/mine`
* lists a bought invention whatever its state), and `FirstPublishedAt` stays: it dates the
* FIRST publish, which still happened. The permission and price are left for the next
* publish to restate or keep. Returns the updated invention, or null when there's no such row.
*/
export async function unpublishInvention(
db: D1Database,
inventionId: number
): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
const updated: SavedInvention = {
...invention,
IsPublished: false,
Accessibility: INVENTION_ACCESSIBILITY.private,
}
await writeInvention(db, updated)
return updated
}
/**
* The most tokens an invention may sell for, inclusive. Enforced on every price a player
* can send — `v3/publish`, `v4/publish` and `v1/updateprice` — by refusing the write; the
* client has no ceiling of its own. The same number as the custom-shirt cap
* (`MAX_PLAYER_ITEM_PRICE`), kept separate because they are separate policies that happen
* to agree. migrations/0033_invention_price_cap.sql lowered the rows already over it.
*/
export const MAX_INVENTION_PRICE = 1000
/**
* Set an invention's price (`v1/updateprice`). Returns the updated invention, or
* null when there's no such row; the caller rejects negative prices and ones over
* {@link MAX_INVENTION_PRICE}.
* null when there's no such row; the caller rejects negative prices.
*/
export async function setInventionPrice(
db: D1Database,
+54 -207
View File
@@ -234,23 +234,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.
@@ -361,11 +344,7 @@ export const CheerPlayerResponse = z.object({
export const CreateCustomAvatarItemMetadata = z.object({
Name: z.string(),
Description: z.string().optional(),
Price: z
.number()
.int()
.optional()
.describe('Tokens, 0–1000 (MAX_PLAYER_ITEM_PRICE); 0 when left out'),
Price: z.number().int().optional(),
BaseAvatarItemId: z.number().int(),
BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'),
Accessibility: z.number().int().optional(),
@@ -378,36 +357,7 @@ export const CreateCustomAvatarItemRequest = z.object({
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.
*/
/** The client's `CustomAvatarItem` record. */
export const CustomAvatarItemDto = z.object({
CustomAvatarItemId: z.string(),
CreatorAccountId: z.number().int(),
@@ -418,19 +368,16 @@ export const CustomAvatarItemDto = z.object({
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(),
BaseAvatarItemId: z.number().int(),
BaseAvatarItemColor: z.string(),
DesignFilename: z.string(),
ThumbnailImageFilename: z.string(),
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(),
CurrentSaves: z.array(z.unknown()),
PurchaseInfo: z.null(),
})
@@ -438,7 +385,7 @@ export const CustomAvatarItemDto = z.object({
export const UpdateCustomAvatarItemRequest = z.object({
Name: z.string().nullable().optional(),
Description: z.string().nullable().optional(),
Price: z.number().int().nullable().optional().describe('Tokens, 0–1000 (MAX_PLAYER_ITEM_PRICE)'),
Price: z.number().int().nullable().optional(),
Accessibility: z.number().int().nullable().optional(),
})
@@ -567,7 +514,7 @@ export const InventionV9Dto = z.object({
Name: z.string(),
Description: z.string(),
ImageName: z.string(),
UgcVersion: z.int().describe('The UGC format the blob was written in; 1 when the save named none'),
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(),
@@ -704,43 +651,6 @@ export const UpdateInventionMetadataRequest = z.object({
.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.
@@ -760,20 +670,12 @@ export const PublishInventionRequest = z.object({
.int()
.nullable()
.optional()
.describe('Public 1 publishes it into the store; Unlisted 2 publishes it but keeps it out of search and the feeds'),
.describe('Private 0, Public 1, Unlisted 2. Unlisted stays out of browse and search'),
Price: z
.int()
.nullable()
.optional()
.describe(
'Price in tokens, at most 1000 (MAX_INVENTION_PRICE); null leaves it as it is, a ' +
'negative one is ignored, and one over the cap refuses the publish'
),
})
/** `POST /api/inventions/v2/unpublish` JSON body — the id and nothing else. */
export const UnpublishInventionRequest = z.object({
InventionId: z.int().describe('The invention to unpublish; the caller must have created it'),
.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. */
@@ -797,7 +699,7 @@ export const InventionDeleteResult = z.object({
/** `POST /api/inventions/v1/updateprice` JSON body. */
export const UpdatePriceRequest = z.object({
InventionId: z.int(),
Price: z.int().describe('Tokens, 0–1000 (MAX_INVENTION_PRICE)'),
Price: z.int().describe('Must be >= 0'),
})
/** `POST /api/inventions/v6/save` JSON body — camelCase, unlike the read shapes. */
@@ -860,7 +762,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(),
@@ -891,13 +793,6 @@ 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. */
@@ -1017,19 +912,14 @@ export const OutfitsBulkRequest = z.object({
/**
* `POST /outfits/bulk` — the worn outfit of each account asked for, keyed by account id.
*
* The key is the id as a STRING (a JSON object key always is) and the value is the stored
* outfit `GET /outfits/me` serves, less any selection naming an item the wearer does not own
* (see `restrictOutfitToOwned` in @repo/domain) — this is what other players dress them
* from. An account with nothing saved is ABSENT from the map rather than present with a
* null — a map expresses "no outfit" by not carrying the key.
* The key is the id as a STRING (a JSON object key always is) and the value is the same
* stored outfit `GET /outfits/me` serves. An account with nothing saved is ABSENT from the
* map rather than present with a null — a map expresses "no outfit" by not carrying the key.
*/
export const OutfitsBulkResponse = z.object({
OutfitsByAccountId: z
.record(z.string(), StoredOutfit)
.describe(
'Keyed by account id as a string. Accounts with no saved outfit are omitted. ' +
'Selections naming items the wearer does not own are stripped'
),
.describe('Keyed by account id as a string. Accounts with no saved outfit are omitted'),
})
/**
@@ -1167,16 +1057,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'
),
})
@@ -1194,7 +1083,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'),
}),
})
@@ -1282,9 +1171,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. */
@@ -1357,17 +1244,6 @@ export const PlayerEventsAll = z.object({
),
})
/**
* `GET /api/playerevents/v1/all/{playerId}` — a player's public events. Both keys carry
* the 17-key base event; a `Responses` entry pairs it with the player's RSVP.
*/
export const PlayerPublicEvents = z.object({
Created: z.array(PlayerEventBaseDto).describe('Public events the player created'),
Responses: z
.array(z.object({ PlayerEvent: PlayerEventBaseDto, PlayerEventResponse: PlayerEventResponseDto }))
.describe('Public events the player is attending, each beside their Going RSVP'),
})
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
export const PlayerEventsPage = z.object({
ContinuationToken: z.string().describe('Empty = no next page'),
@@ -1391,9 +1267,8 @@ 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
* `Duration` as the seconds LEFT as of the request (`ban_expires` minus now; int32 max for
* a permanent ban) — the client counts it down from receipt, so it is never the full span.
* `TimeoutStartedAt` stays null for a ban. Otherwise it is the "not blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`:
* 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`
@@ -1413,7 +1288,7 @@ export const ModerationBlockDetails = z.object({
Duration: z
.int()
.describe(
'Seconds LEFT on the block as of this request (the client counts it down from receipt); 2147483647 (int32 max) for a permanent ban; 0 when not blocked'
'Length of the block in seconds from `TimeoutStartedAt`; 2147483647 (int32 max) for a permanent ban; 0 when not blocked'
),
GameSessionId: z.int(),
IsHostKick: z.boolean().describe('Always false — no host kick is ever recorded here'),
@@ -1433,7 +1308,7 @@ export const ModerationBlockDetails = z.object({
.string()
.nullable()
.describe(
'Always null — the client does not pair it with `Duration` for a ban (it names the start of a voice-chat timeout, which this server never hands out)'
'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'),
@@ -1471,27 +1346,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
@@ -1517,18 +1371,12 @@ export const CreateWarningRequest = z.object({
* 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'),
PlayerId: z.string().describe('Account id of the player being voted on'),
Response: z.string().describe('The caller’s own vote, e.g. `True`'),
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'
),
.describe('A `voteToKickReasons` label, e.g. `Inactive in games (AFK)`'),
GameSessionId: z.string().describe('The room instance both players are standing in'),
})
@@ -1543,18 +1391,6 @@ export const InstantKickRequest = z.object({
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'),
@@ -1601,6 +1437,29 @@ export const SavedImageDto = z.object({
CommentCount: z.int(),
})
/**
* `GET /api/images/v6` — an image's metadata by bucket key. A third projection of the same
* row: renamed like `ImagesPlayer` (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`)
* but carrying `ClubId`, and with no nullable fields — `RoomId`, `PlayerEventId` and
* `ClubId` are 0 where the row holds null, `Description` is `""`. Don't unify it with the
* other two.
*/
export const ImageMetadataDto = z.object({
SavedImageId: z.int(),
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
PlayerId: z.int(),
RoomId: z.int().describe('0 when the photo was not taken in a room'),
PlayerEventId: z.int().describe('0 when it belongs to no event'),
ClubId: z.int().describe('Always 0 — nothing here associates an image with a club'),
Description: z.string().describe('Empty string, never null'),
Accessibility: z.int(),
AccessibilityLocked: z.boolean(),
SavedImageType: z.int().describe('1 = share camera, 3 = room, 4 = profile, …'),
CreatedAt: z.string(),
CheerCount: z.int(),
CommentCount: z.int(),
})
/**
* The client's `ImagesPlayer` projection — the same record with `Id` → `SavedImageId`,
* `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed
@@ -1656,21 +1515,9 @@ export const UploadImageResponse = z.object({
ImageName: z.string().describe('The bucket key; the img worker serves the object by it'),
})
/** `POST /api/images/v1/deletesaved` JSON body. */
/** `DELETE /api/images/v1/deletesaved` JSON body. */
export const DeleteImageRequest = z.object({ ImageName: z.string() })
/** `POST /api/images/v2/modifyaccessibility` JSON body. */
export const ModifyImageAccessibilityRequest = z.object({
ImageName: z.string(),
Accessibility: z.int().describe('0 private, 1 public'),
})
/** `POST /api/images/v1/modifydescription` JSON body. */
export const ModifyImageDescriptionRequest = z.object({
ImageName: z.string(),
Description: z.string().nullable().describe('The new caption; null or empty clears it'),
})
/**
* `POST /api/images/v5/cheered/bulk` form body — the saved-image ids to report cheer state
* for, as a REPEATED `id` field (`id=651&id=570&…`), one value per id. The client sends a
+17 -388
View File
@@ -7,28 +7,17 @@
* 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,
* 0025_report_chat_message.sql and 0034_report_image.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.
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql and
* 0017_report_custom_avatar_item.sql, applied under its own `migrations_table` so it
* doesn't clash with the other workers' migrations that share the database).
*
* A reported player EVENT, 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`. A reported IMAGE is the fifth: `image_id`, with
* the player who took it as the reported player — see `POST /api/images/v1/{id}/report`.
* The five 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.
* `POST /api/customAvatarItems/v1/{id}/report`. The three id columns are mutually exclusive;
* a row with none of them is an ordinary player report. They are three columns rather than
* one polymorphic id because the keys differ in TYPE: two numbers and a guid.
*
* 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
@@ -43,12 +32,10 @@
/**
* 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 +
* 0034_report_image.sql).
* 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql).
*
* None of `event_id`, `invention_id`, `custom_avatar_item_id`, `chat_message_id` or `image_id` is
* indexed: each is written on every report of its kind and read by nothing — no query here filters on any of them,
* None of `event_id`, `invention_id` or `custom_avatar_item_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.
@@ -69,13 +56,7 @@ export const SCHEMA_DDL: string[] = [
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,
image_id INTEGER
custom_avatar_item_id TEXT
)`,
`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)`,
@@ -122,45 +103,6 @@ export interface ReportRow {
* 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. The staff panel's audit
* trail and its ordering (`getBansInForce`); the client's block screen does NOT count
* from it (see `banBlockDetails`). Rows written before 0020_report_ban_audit.sql carry
* NULL and fall back to `created_at` where a date is needed.
*/
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
/**
* The saved image (photo) this report is against, or NULL for any other kind — mutually
* exclusive with the four id columns above. See `POST /api/images/v1/{id}/report`: the
* request has no body, so `reported_player_id` is the image's `PlayerId` (who took it),
* read from the image, and there is no category or description to store.
*/
image_id: number | null
}
/**
@@ -184,10 +126,6 @@ export interface NewReport {
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
/** Set only when reporting an IMAGE; never set alongside the four above. */
imageId?: number | null
}
/** Record a submitted report, returning the stored row (with its assigned id). */
@@ -197,8 +135,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, image_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
event_id, invention_id, custom_avatar_item_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
RETURNING *`
)
.bind(
@@ -213,9 +151,7 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
new Date().toISOString(),
input.eventId ?? null,
input.inventionId ?? null,
input.customAvatarItemId ?? null,
input.chatMessageId ?? null,
input.imageId ?? null
input.customAvatarItemId ?? null
)
.first<ReportRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
@@ -223,11 +159,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')
@@ -285,17 +217,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`; the staff panel shows
* and sorts by it. 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).
@@ -303,303 +224,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 LEFT, so a
* ban with no end gets the largest value the field holds — 68 years, which other
* implementations also send and the client's screen renders as permanent.
*/
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` is the seconds LEFT on the ban as of `now` — the time from the request to
* `ban_expires`, rounded up so a ban with a second to run never reads 0 — or
* `PERMANENT_BAN_DURATION` when there is no expiry. The client counts it down from the
* moment it receives it, with no reference to when the ban began: other implementations
* with timed bans keep the instant they set the block PRIVATE and serve `Duration` minus
* the elapsed time on every read.
* This once sent the FULL span from `banned_at` instead, on the theory that the client
* pairs it with `TimeoutStartedAt` (start + duration = end). It does not: the screen read
* correctly only at the instant the ban was handed down — when the full span IS the
* remainder, so the mid-session `ModerationKick` looked right — and every later sign-in
* showed the whole ban still to run.
*
* `TimeoutStartedAt` stays null. Other implementations don't send it for a ban; where it is
* set at all it names the start of a voice-chat timeout, which is what the name is about.
* `banned_at` is the audit trail and the staff panel's, not the client's.
*
* `now` is a parameter so the caller can pass the same instant it resolved the ban with
* (`getActiveBan`), and a ban that was in force at the read can't come out as 0 left.
*
* 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, now: Date = new Date()) {
const duration =
ban.ban_expires === null
? PERMANENT_BAN_DURATION
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - now.getTime()) / 1000))
return {
...NOT_BLOCKED,
ReportCategory: ban.report_category,
Duration: duration,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: null,
}
}
+15 -29
View File
@@ -26,8 +26,6 @@
* 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 (
@@ -188,22 +186,15 @@ export async function getCheerCredits(
): 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 }>()
const placeholders = playerIds.map((_, i) => `?${i + 2}`).join(', ')
const { results } = await db
.prepare(
`SELECT player_id, cheers_left FROM player_cheer
WHERE created > ?1 AND player_id IN (${placeholders})`
)
)
for (const row of pages.flatMap((page) => page.results)) {
credits.set(row.player_id, row.cheers_left)
}
.bind(windowCutoff(now), ...playerIds)
.all<{ player_id: number; cheers_left: number }>()
for (const row of results) credits.set(row.player_id, row.cheers_left)
return credits
}
@@ -228,20 +219,15 @@ export async function getReputations(
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>()
)
),
const placeholders = accountIds.map((_, i) => `?${i + 1}`).join(', ')
const [{ results }, credits] = await Promise.all([
db
.prepare(`SELECT * FROM reputation WHERE account_id IN (${placeholders})`)
.bind(...accountIds)
.all<ReputationRow>(),
getCheerCredits(db, accountIds, now),
])
const stored = new Map(pages.flatMap((page) => page.results).map((r) => [r.account_id, r]))
const stored = new Map(results.map((r) => [r.account_id, r]))
return accountIds.map((id) => {
const credit = credits.get(id) ?? DAILY_CHEER_CREDIT
const row = stored.get(id)
+69 -424
View File
@@ -6,34 +6,23 @@ import {
getOutfit,
getOutfits,
getOutfitsByAccounts,
getOwnedOutfitItemsByAccounts,
inventionDescriptionRejection,
inventionLongDescriptionRejection,
inventionNameRejection,
inventionTagRejection,
MAX_BULK_OUTFIT_ACCOUNTS,
outfitItemBase,
restrictOutfitToOwned,
setOutfit,
} from '@repo/domain'
// The free catalog, as the econ worker bundles it: what `GET /api/avatar/v4/items` hands
// every player before their purchases, and the base items custom clothing is built on.
// Read here so the bulk outfit read knows which worn items need no purchase.
import defaultAvatarItems from '../../../econ/static/default-avatar-items.json'
import defaultBaseAvatarItems from '../../../econ/static/default-base-avatar-items.json'
import {
createCustomAvatarItem,
deleteCustomAvatarItem,
getCustomAvatarItem,
getCustomAvatarItems,
isQuestAssetTarget,
listCustomAvatarItemsByCreator,
listFeaturedCustomAvatarItems,
listHotCustomAvatarItems,
MAX_PLAYER_ITEM_PRICE,
searchCustomAvatarItems,
toQuestCustomAvatarItem,
updateCustomAvatarItem,
} from '../custom-avatar-items-db'
import { authedId, unauthorized } from '../http'
@@ -42,7 +31,6 @@ import {
deleteInvention,
getFeaturedInventions,
getInventionById,
getInventionsByCreators,
getInventionsByIds,
getInventionsByRoom,
getInventionTagFilters,
@@ -54,7 +42,6 @@ import {
inventionDeleteResult,
inventionSaveV9Failure,
isInventionCheered,
MAX_INVENTION_PRICE,
normalizeInventionTags,
ownsAllInventions,
parsePermissionLevel,
@@ -65,7 +52,6 @@ import {
setInventionTags,
toSaveResult,
toSaveResultV9,
unpublishInvention,
updateInvention,
} from '../inventions-db'
import {
@@ -93,7 +79,6 @@ import {
InventionReportRequest,
InventionSaveResult,
InventionSaveV9Result,
InventionV9Dto,
InventionVersionDto,
json,
JsonArray,
@@ -117,10 +102,8 @@ import {
SuccessValueEnvelope,
TagFilters,
UNAUTHORIZED_RESPONSE,
UnpublishInventionRequest,
UpdateCustomAvatarItemRequest,
UpdateInventionMetadataRequest,
UpdateInventionRequest,
UpdatePriceRequest,
} from '../openapi'
import { createReport } from '../reports-db'
@@ -142,19 +125,6 @@ import type { InventionTag, SavedInvention } from '../inventions-db'
*/
const BULK_CUSTOM_AVATAR_ITEM_CAP = 100
/**
* The base assets every player may wear without buying anything: the default catalog econ
* hands everyone (`GET /api/avatar/v4/items` prepends purchases to it) and the base items
* custom clothing is built on. Folded into each wearer's owned set when `POST /outfits/bulk`
* strips unowned selections — see `restrictOutfitToOwned` for why ownership is decided on
* the base asset.
*/
const FREE_ITEM_BASES: ReadonlySet<string> = new Set(
[...defaultAvatarItems, ...defaultBaseAvatarItems].map((item) =>
outfitItemBase(item.AvatarItemDesc)
)
)
/**
* 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
@@ -179,22 +149,6 @@ async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
.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
}
/**
* 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 —
@@ -205,7 +159,9 @@ async function bulkUnityAssetTarget(c: Context<App>): Promise<string | undefined
async function creatorsInventionResult(
c: Context<App>,
inventionId: number
): Promise<{ invention: SavedInvention } | { rejection: string; status: 400 | 401 | 403 | 404 }> {
): 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 }
@@ -261,10 +217,7 @@ function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: num
})
return rejected
? { tags: [], tagResult: INVENTION_TAG_RESULT.rejected }
: {
tags: normalizeInventionTags(autoTags, customTags),
tagResult: INVENTION_TAG_RESULT.success,
}
: { tags: normalizeInventionTags(autoTags, customTags), tagResult: INVENTION_TAG_RESULT.success }
}
/**
@@ -274,7 +227,8 @@ function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: num
* 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 }
| { rejection: string }
| { invention: SavedInvention; tags: InventionTag[]; tagResult: number }
/**
* The invention save both `v6/save` and `v9/save` run through. v9 sends everything v6 does
@@ -399,8 +353,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,
@@ -496,21 +449,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'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' +
'error_id }` envelope.\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.\n\n' +
'`Price` is capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE`); one above it, or below ' +
'0, is refused with a 400 rather than clamped.',
'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, or a `Price` outside 0–1000'
),
400: json(CustomAvatarItemResponse, 'Missing or malformed metadata / files'),
401: UNAUTHORIZED_RESPONSE,
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
},
@@ -536,13 +483,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
if (typeof meta.BaseAvatarItemId !== 'number') return fail('BaseAvatarItemId is required')
if (typeof meta.BaseAvatarItemColor !== 'string')
return fail('BaseAvatarItemColor is required')
if (meta.Price !== undefined && meta.Price !== null) {
if (typeof meta.Price !== 'number' || !Number.isInteger(meta.Price))
return fail('Price must be an integer')
if (meta.Price < 0) return fail('Price must be >= 0')
if (meta.Price > MAX_PLAYER_ITEM_PRICE)
return fail(`Price must be <= ${MAX_PLAYER_ITEM_PRICE}`)
}
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)
@@ -611,14 +551,13 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'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. ' +
'`Price` is bound the same way as on the create: 0 to 1000 tokens, or a 400.',
'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, or a `Price` outside 0–1000'),
400: json(CustomAvatarItemResponse, 'Malformed body'),
401: UNAUTHORIZED_RESPONSE,
403: json(CustomAvatarItemResponse, 'Not the creator'),
404: json(CustomAvatarItemResponse, 'No such item'),
@@ -661,9 +600,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
if (patch.name !== null && patch.name?.trim() === '')
return fail(400, 'Name must not be blank')
if (patch.price !== null && patch.price < 0) return fail(400, 'Price must be >= 0')
if (patch.price !== null && patch.price > MAX_PLAYER_ITEM_PRICE)
return fail(400, `Price must be <= ${MAX_PLAYER_ITEM_PRICE}`)
const item = await updateCustomAvatarItem(c.env.DB, itemId, patch)
if (!item) return fail(404, 'No such item')
@@ -701,13 +637,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
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)
// The row is gone; the objects follow. A missing key is a no-op for R2.
await c.env.IMAGES.delete([item.ThumbnailImageFilename, item.DesignFilename])
return c.json({ Value: item, Success: true, Error: null, error_id: null })
}
)
@@ -750,21 +681,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'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.',
'`includeCoachItems=false` leaves out this server’s stock content.',
'`itemTypes`, `ordering`, `unityAssetTarget` and `unityAssetVersion` are accepted and',
'NOT yet acted on — nothing records purchase or wear counts to rank by, no per-target',
'asset variants are stored, and custom avatar items are the only item type there is.',
'`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,',
'whatever it says.',
].join(' '),
@@ -815,26 +735,9 @@ export const avatarRoutes = new Hono<App>({ strict: false })
name: 'includeCoachItems',
in: 'query',
required: false,
description:
'true: only the Coach’s stock items; false: only player-made items; absent: both',
description: 'Include the Coach’s stock items (default true)',
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') },
}),
@@ -847,11 +750,9 @@ export const avatarRoutes = new Hono<App>({ strict: false })
?.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
// before comparing; anything that isn't recognisably false leaves the default alone.
const includeCoachItems = c.req.query('includeCoachItems')?.toLowerCase() !== 'false'
const int = (name: string): number | undefined => {
const raw = c.req.query(name)
@@ -860,22 +761,16 @@ export const avatarRoutes = new Hono<App>({ strict: false })
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
await searchCustomAvatarItems(c.env.DB, {
searchQuery: c.req.query('searchQuery'),
outfitTypes,
includeCoachItems,
minPrice: int('minPrice'),
maxPrice: int('maxPrice'),
skip: int('skip'),
take: int('take'),
})
)
}
)
@@ -933,10 +828,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'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.',
@@ -962,15 +853,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
// 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
items.filter((item) => item.Accessibility !== 0 || item.CreatorAccountId === id)
)
}
)
@@ -1113,11 +997,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
// id, and a list would make it match up the order itself. An account with nothing saved is
// left out of the map — see `getOutfitsByAccounts`.
//
// This is how OTHER players see an outfit, so it is served with the selections the wearer
// does not own stripped: nothing on the save path checks ownership, and an edited save
// would otherwise dress a player in items they never bought for the whole room to see.
// The owner's own `GET /outfits/me` stays verbatim.
//
// `UnityAssetTarget` / `UnityAssetVersion` name the baked-asset build the client would
// like the outfits for. Nothing here bakes assets, so both are accepted and ignored.
.post(
@@ -1129,12 +1008,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'The worn outfit (slot 0) of each account in `AccountIds`, keyed by account id — the ' +
'call the client makes to dress a room full of players in one request.\n\n' +
'A MAP rather than a list: the client looks each player up by id. The key is the id ' +
'as a string, and the value is the stored outfit `GET /outfits/me` serves, LESS any ' +
'selection naming an item the wearer does not own: a base asset that is neither in ' +
'the free default catalog nor on any item they bought, or a custom item they ' +
'neither bought nor made. The save path checks nothing, and this read is what ' +
'everyone else dresses the player from. An outfit that loses nothing is served ' +
'exactly as saved. An account with nothing saved in slot 0 is ' +
'as a string, and the value is the same stored outfit `GET /outfits/me` serves, ' +
'handed back exactly as it was saved. An account with nothing saved in slot 0 is ' +
'ABSENT from the map rather than carrying a null — a map says “no outfit” by not ' +
'having the key, and inventing one for a player who has never saved would dress them ' +
'in something they never chose.\n\n' +
@@ -1171,21 +1046,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
const outfits = await getOutfitsByAccounts(c.env.DB, accountIds, CURRENT_OUTFIT_SLOT)
// What each wearer may wear, read for the accounts that HAVE an outfit: a selection
// naming an item the wearer never bought is dropped before anyone else dresses them
// in it. The free catalog counts as owned; see `restrictOutfitToOwned`.
const owned = await getOwnedOutfitItemsByAccounts(
c.env.DB,
[...outfits.keys()],
FREE_ITEM_BASES
)
const OutfitsByAccountId: Record<string, unknown> = {}
for (const [accountId, outfit] of outfits) {
const wearable = owned.get(accountId)
OutfitsByAccountId[String(accountId)] = wearable
? restrictOutfitToOwned(outfit, wearable)
: outfit
}
for (const [accountId, outfit] of outfits) OutfitsByAccountId[String(accountId)] = outfit
return c.json({ OutfitsByAccountId })
}
)
@@ -1523,18 +1385,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
summary: 'Publish an invention',
description:
'What puts an invention into search and the feeds. Sets the permission other ' +
'players get (defaulting to UseOnly) and its price. Another GET that writes. A ' +
'price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the whole publish with a ' +
'400 rather than being clamped or dropped.',
'players get (defaulting to UseOnly) and its price. Another GET that writes.',
security: AUTHED,
parameters: [
intQuery('inventionId', 'Invention id; required'),
stringQuery('permissionLevel', 'A name like `useonly`, or the raw number'),
intQuery('price', 'Price in tokens, at most 1000; negative is ignored'),
intQuery('price', 'Price in tokens; negative is ignored'),
],
responses: {
200: json(InventionSaveResult, 'The published invention, in the save envelope'),
400: json(ErrorResponse, 'A price over 1000'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s invention'),
404: { description: 'No such invention' },
@@ -1546,8 +1405,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const permissionLevel = c.req.query('permissionLevel')
const price = Number.parseInt(c.req.query('price') ?? '', 10)
if (price > MAX_INVENTION_PRICE)
return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400)
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel:
@@ -1567,12 +1424,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
summary: 'Set an invention’s price',
description:
'Unlike update/publish, this one POSTs a JSON body. Creator only; a negative price ' +
'is rejected, as is one over 1000 tokens (`MAX_INVENTION_PRICE`).',
'is rejected.',
security: AUTHED,
requestBody: jsonBody(UpdatePriceRequest, 'The invention and its new price'),
responses: {
200: json(InventionSaveResult, 'The repriced invention, in the save envelope'),
400: json(ErrorResponse, 'Unparseable body, or a price below 0 or over 1000'),
400: json(ErrorResponse, 'Unparseable body, or a price below 0'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s invention'),
404: { description: 'No such invention' },
@@ -1588,8 +1445,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const price = typeof body.Price === 'number' ? body.Price : Number.NaN
if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400)
if (price > MAX_INVENTION_PRICE)
return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400)
const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
@@ -1749,35 +1604,35 @@ export const avatarRoutes = new Hono<App>({ strict: false })
)
// Inventions by particular creators (`?id=207&id=…`) — what the client fills a creator's
// portfolio, and the "from creators you follow" row, from.
// shelf, and the "from creators you follow" row, from.
//
// Published only, for every caller — a creator's own drafts are `v2/mine`'s alone.
// STUB: an empty list for now. It is the honest answer rather than a placeholder, since
// the client reads it as "this creator has published nothing" and renders an empty
// shelf, where a 404 would read as a row that failed to load. When it becomes real it is
// a filter on the invention table's creator column, the same feed shape as `toptoday`
// and `featured` above — `id` is repeatable, and `skip`/`take` page it.
.get(
'/api/inventions/v1/fromcreators',
describeRoute({
tags: ['Inventions'],
summary: 'Inventions by particular creators',
summary: 'Inventions by particular creators (stub)',
description:
'The inventions of the accounts named by `id` (repeatable), newest first — a ' +
'creator’s portfolio, and the "from creators you follow" row. Only PUBLISHED ' +
'inventions are listed, the caller’s own included: unpublished ones are served by ' +
'`v2/mine` alone. No `id` is an empty array.',
parameters: [intQuery('id', 'Creator account id; repeatable'), ...pageParams(100)],
responses: {
200: json(InventionDto.extend(InventionV9Dto.shape).array(), 'The creators’ inventions'),
},
'The published inventions of the accounts named by `id` (repeatable), newest first — ' +
'a creator’s shelf, and the "from creators you follow" row. STUB: always an empty ' +
'array for now, which the client renders as "nothing published" rather than as a ' +
'failed load. `id`, `skip` and `take` are accepted and, for the moment, ignored.',
parameters: [
intQuery('id', 'Creator account id; repeatable. Accepted and ignored by the stub'),
...pageParams(100),
],
responses: { 200: json(InventionDto.array(), 'Empty — nothing is served here yet') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getInventionsByCreators(c.env.DB, inventionIdQuery(c), skip, take))
}
(c) => c.json([])
)
// Invention search/browse: published inventions matching `value` (matched against
// name + description; absent → browse everything published; `@username` → that
// player's published inventions), newest first. Paginated via skip/take (take
// defaults to 100). Returns a bare array.
// 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.
@@ -1790,16 +1645,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'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. A `value` starting with `@` names a player: `@djdevin` lists that ' +
'player’s PUBLISHED inventions (the same set `v1/fromcreators` serves), resolved by ' +
'username case-insensitively, with any further terms narrowing them as text; an ' +
'unknown username is an empty array. Tags are NOT searched: a `#tag` term from the ' +
'browse screen’s filter chips is treated as text and matches nothing.',
'request. Tags are NOT searched: a `#tag` term from the browse screen’s filter ' +
'chips is treated as text and matches nothing.',
parameters: [
stringQuery(
'value',
'Search text; absent browses everything; `@username` is a player’s inventions'
),
stringQuery('value', 'Search text; absent browses everything'),
...pageParams(100),
],
responses: { 200: json(InventionDto.array(), 'The matching inventions') },
@@ -1828,10 +1677,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'invention listed even if it has since been unpublished or hidden. Not paginated.',
security: AUTHED,
responses: {
200: json(
InventionDto.extend(InventionV9Dto.shape).array(),
'The caller’s inventions — the stored record with the newer client’s flat DTO laid over it'
),
200: json(InventionDto.array(), 'The caller’s inventions'),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -1947,49 +1793,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return c.json({ success: true, error: '' })
}
)
// The newer client's cheer: the same body as `v1/cheer` and the same write, at a new
// path. It answers the `{ Value, Success, Error, error_id }` envelope the other v2+
// invention routes use, refusals in-band — an ASSUMPTION: only the request has been
// observed, not what the client reads off the reply.
.post(
'/api/inventions/v2/cheer',
describeRoute({
tags: ['Inventions'],
summary: 'Cheer or un-cheer an invention (v2)',
description:
'`v1/cheer` for the newer client: persists the caller’s cheer state and resyncs ' +
'the invention’s `CheerCount`; repeating the same state is idempotent. Answers ' +
'the `{ Value, Success, Error, error_id }` envelope with `Value` null, and a ' +
'refusal — a bad body, an unknown invention — is `Success: false` with a message ' +
'rather than an error status.',
security: AUTHED,
requestBody: jsonBody(InventionCheerRequest, 'The invention and new cheer state'),
responses: {
200: json(InventionDeleteResult, 'The envelope, `Value` null either way'),
401: json(InventionDeleteResult, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const playerId = await authedId(c)
if (playerId === null) return c.json(inventionDeleteResult('Unauthorized'), 401)
const body = await c.req
.json<{ InventionId?: unknown; Cheer?: unknown }>()
.catch(() => ({}) as Record<string, unknown>)
const inventionId = body.InventionId
if (
typeof inventionId !== 'number' ||
!Number.isInteger(inventionId) ||
typeof body.Cheer !== 'boolean'
) {
return c.json(inventionDeleteResult('InventionId and Cheer are required'))
}
if ((await getInventionById(c.env.DB, inventionId)) === null) {
return c.json(inventionDeleteResult('No such invention'))
}
await setInventionCheer(c.env.DB, playerId, inventionId, body.Cheer)
return c.json(inventionDeleteResult())
}
)
// 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.
@@ -2223,7 +2026,9 @@ export const avatarRoutes = new Hono<App>({ strict: false })
for (const rejection of [
name === undefined ? null : inventionNameRejection(name),
description === undefined ? null : inventionDescriptionRejection(description),
longDescription === undefined ? null : inventionLongDescriptionRejection(longDescription),
longDescription === undefined
? null
: inventionLongDescriptionRejection(longDescription),
]) {
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
}
@@ -2250,112 +2055,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// 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
@@ -2368,15 +2067,13 @@ export const avatarRoutes = new Hono<App>({ strict: false })
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 whether it ' +
'is in the store — 1 (Public) lists it in search and the feeds, 2 (Unlisted) ' +
'publishes it onto the creator’s portfolio and reachable by id but keeps it out ' +
'of both. A null `Price` leaves the price alone rather ' +
'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. A price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the ' +
'publish in-band.\n\n' +
'Publishing is undone by `v2/unpublish`, and re-publishing doesn’t re-date the first ' +
'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,
@@ -2413,8 +2110,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const int = (key: string): number | undefined =>
typeof body[key] === 'number' && Number.isInteger(body[key]) ? body[key] : undefined
const price = int('Price')
if (price !== undefined && price > MAX_INVENTION_PRICE)
return c.json(inventionSaveV9Failure(`Price must be <= ${MAX_INVENTION_PRICE}`))
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel: int('Permission'),
@@ -2427,56 +2122,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// Unpublish an invention: `v4/publish` undone. Same body shape as `v2/delete` — the id
// and nothing else — and creator only.
//
// The reply is the `v9/save` envelope carrying the UPDATED invention, as `v4/publish`
// answers. That is an ASSUMPTION: what the client reads off this response has not been
// observed, only the request.
.post(
'/api/inventions/v2/unpublish',
describeRoute({
tags: ['Inventions'],
summary: 'Unpublish an invention',
description:
'Creator only. Takes the invention back out of search, the feeds and the ' +
'creator’s portfolio: `IsPublished` goes false and `Accessibility` back to 0, the ' +
'state a save mints, so the next `v4/publish` sets both afresh. Players who ' +
'already bought it keep it, the price and permission are left as they are, and ' +
'the first-publish date is not cleared.\n\n' +
'Answers the enveloped result `v4/publish` answers, carrying the unpublished ' +
'invention. Refusals are `Success: false` with a null `Value`.',
security: AUTHED,
requestBody: jsonBody(UnpublishInventionRequest, 'The invention to unpublish'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the unpublished invention under `Value`, or `Success: false` ' +
'with `Error` when it 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
)
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionSaveV9Failure(gate.rejection), 401)
: c.json(inventionSaveV9Failure(gate.rejection))
}
const unpublished = await unpublishInvention(c.env.DB, gate.invention.InventionId)
if (unpublished === null) return c.json(inventionSaveV9Failure('No such invention'))
return c.json(toSaveResultV9(unpublished, unpublished.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.
+47 -262
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,13 +21,10 @@ import {
getEventsByIds,
getEventsByRoom,
getEventTags,
getGoingPlayerIds,
getLiveEvents,
getPublicEventsByPlayer,
inviteToEvent,
isEventResponseType,
parseEventBody,
parseEventSort,
parseEventTags,
parseEventTime,
searchEvents,
@@ -65,7 +62,6 @@ import {
PlayerEventsPage,
PlayerEventTagsRequest,
PlayerEventTimeRequest,
PlayerPublicEvents,
stringQuery,
SuccessErrorEnvelope,
TagFilters,
@@ -74,11 +70,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'
@@ -113,84 +105,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
@@ -200,100 +120,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),
})
}
}
}
@@ -301,13 +151,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.
@@ -315,7 +164,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)
}
/**
@@ -331,7 +180,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>
@@ -351,9 +199,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)))
}
}
@@ -449,31 +295,6 @@ export const eventRoutes = new Hono<App>({ strict: false })
}
)
// Another player's events, as their profile shows them: PUBLIC events only, and under
// `Responses` only the ones they are attending (Going). Public. Unlike the caller's own
// `/all` above, both keys carry the client's 17-key base event, and a `Responses` entry
// is a PAIR — the event beside the RSVP record.
.get(
'/api/playerevents/v1/all/:playerId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'A player’s public events',
description:
'The events on a player’s profile: the PUBLIC ones they created (`Created`) and ' +
'the PUBLIC ones they are attending (`Responses`), each soonest first. Public (no ' +
'auth); finished events are left out, and a player with none — or an unknown id — ' +
'gets two empty lists.\n\n' +
'Events are the client’s 17-key BASE event, as on the browse feed. A `Responses` ' +
'entry is a pair, `{ PlayerEvent, PlayerEventResponse }`, and only Going RSVPs ' +
'(`Type` 0) are listed — a maybe, a decline or an unanswered invitation is not ' +
'attending. A creator is Going to their own event, so it appears under both keys.',
parameters: [idParam('playerId', 'Player id')],
responses: { 200: json(PlayerPublicEvents, 'The player’s public events') },
}),
async (c) =>
c.json(await getPublicEventsByPlayer(c.env.DB, Number.parseInt(c.req.param('playerId'), 10)))
)
// The tag filter chips on the player-events browse screen. Static: these are the
// categories the client offers when creating an event, so the list doesn't depend on
// what's stored. `TrendingFilters` is null even in the reference — it needs
@@ -628,15 +449,14 @@ export const eventRoutes = new Hono<App>({ strict: false })
'match and the two kinds combine, so `#workshops trigonometry` is the ' +
'workshops-tagged events whose text also mentions trigonometry.\n\n' +
'Events that have already finished are left out — a name match on something that ' +
'ended last month is noise on a browse screen. Ordered by `sort` and paginated via ' +
'ended last month is noise on a browse screen. Soonest first, paginated via ' +
'skip/take. A bare array.',
parameters: [
stringQuery('query', 'Search terms; `#tag` matches a tag, anything else the text'),
stringQuery(
'sort',
'`StartTime` (soonest first, the default) or `Attendance` (most Going replies ' +
'first — the event’s `AttendeeCount` — ties soonest first). Case-insensitive; ' +
'any other value is served as `StartTime`'
'Accepted and echoed by the client as `StartTime`, which is the only order ' +
'served (soonest first); any other value sorts the same way'
),
...pageParams(50),
],
@@ -645,15 +465,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
return c.json(
await searchEvents(
c.env.DB,
c.req.query('query') ?? '',
skip,
take,
parseEventSort(c.req.query('sort'))
)
)
return c.json(await searchEvents(c.env.DB, c.req.query('query') ?? '', skip, take))
}
)
@@ -734,13 +546,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: {
@@ -763,10 +570,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)))
}
)
@@ -834,30 +640,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 ' +
@@ -866,7 +662,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)' },
@@ -906,7 +702,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)))
}
)
@@ -985,9 +781,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`. ' +
@@ -1011,10 +804,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)
@@ -1067,9 +857,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.',
@@ -1104,9 +891,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)))
}
)
+37 -239
View File
@@ -5,21 +5,17 @@ import {
createImage,
deleteImage,
getCheeredImageIds,
getImageById,
getImageByName,
getImagesByIds,
getImagesByPlayer,
getImagesByRoom,
getPlayerFeed,
getSlideshowImages,
mergePlayerSettings,
readPlayerSettings,
SavedImageType,
setImageAccessibility,
setImageCheer,
setImageDescription,
SLIDESHOW_LIMIT,
SLIDESHOW_MAX_LIMIT,
toImageMetadata,
toImagesPlayer,
} from '@repo/domain'
@@ -33,26 +29,23 @@ import {
ErrorResponse,
form,
idParam,
ImageMetadataDto,
ImagesPlayerDto,
intQuery,
json,
JsonArray,
jsonBody,
ModifyImageAccessibilityRequest,
ModifyImageDescriptionRequest,
pageParams,
PhotoTaggingSettingRequest,
PhotoTaggingSettingResponse,
SavedImageDto,
SlideshowResponse,
stringQuery,
SuccessErrorEnvelope,
SuccessResponse,
UNAUTHORIZED_RESPONSE,
UploadImageRequest,
UploadImageResponse,
} from '../openapi'
import { createReport } from '../reports-db'
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
import type { Context } from 'hono'
@@ -80,17 +73,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
@@ -103,7 +85,10 @@ async function getPlayerSettings(
env: App['Bindings'],
accountId: number
): Promise<Record<string, string> | null> {
return readPlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId).catch(() => null)
return env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
`player:${accountId}`,
'json'
).catch(() => null)
}
/** The caller's stored photo-tagging preference, or the default when they have none. */
@@ -119,17 +104,19 @@ async function readPhotoTaggingSetting(env: App['Bindings'], accountId: number):
*
* The write MERGES, as the `playersettings` worker's own PUT does: the map holds every
* setting the player has (OOBE state, tutorial mask, …), so storing this one on its own
* would wipe the rest. Re-posting the value already stored writes nothing — the client
* does that freely, and KV writes are the cost.
* would wipe the rest. Read-modify-write on KV isn't atomic, but the same is true there,
* and racing writers here means one player toggling two of their own options at once.
*/
async function writePhotoTaggingSetting(
env: App['Bindings'],
accountId: number,
setting: number
): Promise<void> {
await mergePlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId, {
[PHOTO_TAGGING_KEY]: String(setting),
})
const stored = (await getPlayerSettings(env, accountId)) ?? {}
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${accountId}`,
JSON.stringify({ ...stored, [PHOTO_TAGGING_KEY]: String(setting) })
)
}
/**
@@ -310,9 +297,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
// Delete one of the caller's saved images ({ ImageName }). Auth-gated. Looks the
// image up by name, refuses unless the caller took it (PlayerId), then removes the
// metadata row (and its cheers) and the object from R2. 404 for an unknown image,
// 403 for someone else's. A POST, not a DELETE: the client posts the JSON body to
// this path when a player deletes a photo from their gallery.
.post(
// 403 for someone else's.
.delete(
'/api/images/v1/deletesaved',
describeRoute({
tags: ['Images'],
@@ -352,99 +338,6 @@ export const imageRoutes = new Hono<App>({ strict: false })
}
)
// Make one of the caller's photos public or private ({ ImageName, Accessibility }).
// Auth-gated and owner-only, like deletesaved; additionally refuses an image whose
// accessibility moderation has locked. Answers the same `{ success: true }` ack.
.post(
'/api/images/v2/modifyaccessibility',
describeRoute({
tags: ['Images'],
summary: 'Make one of the caller’s photos public or private',
description:
'Looks the image up by name and refuses unless the caller took it (or its ' +
'accessibility is locked), then sets `Accessibility` on the stored record: 0 ' +
'private, 1 public.',
security: AUTHED,
requestBody: jsonBody(ModifyImageAccessibilityRequest, 'The image and its new accessibility'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: json(ErrorResponse, 'No ImageName, or Accessibility not 0 or 1'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s image, or its accessibility is locked'),
404: { description: 'No image by that name' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as {
ImageName?: unknown
Accessibility?: unknown
} | null
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
const accessibility = body?.Accessibility
if (accessibility !== 0 && accessibility !== 1) {
return c.json({ error: 'Accessibility must be 0 or 1' }, 400)
}
const image = await getImageByName(c.env.DB, imageName)
if (!image) return c.notFound()
if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403)
if (image.AccessibilityLocked) return c.json({ error: 'Accessibility is locked' }, 403)
await setImageAccessibility(c.env.DB, image, accessibility)
return c.json({ success: true })
}
)
// Set the caption on one of the caller's photos ({ ImageName, Description }).
// Auth-gated and owner-only, like deletesaved. An empty or null Description clears
// the caption (stored as null, the same as an upload without one).
.post(
'/api/images/v1/modifydescription',
describeRoute({
tags: ['Images'],
summary: 'Set the caption on one of the caller’s photos',
description:
'Looks the image up by name and refuses unless the caller took it, then sets ' +
'`Description` on the stored record. Null or empty clears it.',
security: AUTHED,
requestBody: jsonBody(ModifyImageDescriptionRequest, 'The image and its new caption'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: json(ErrorResponse, 'No ImageName, or Description not a string'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s image'),
404: { description: 'No image by that name' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as {
ImageName?: unknown
Description?: unknown
} | null
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
const raw = body?.Description ?? null
if (raw !== null && typeof raw !== 'string') {
return c.json({ error: 'Description must be a string' }, 400)
}
const description = raw === null || raw.trim() === '' ? null : raw
const image = await getImageByName(c.env.DB, imageName)
if (!image) return c.notFound()
if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403)
await setImageDescription(c.env.DB, image, description)
return c.json({ success: true })
}
)
// A room's photo feed — the public images taken in that room. `sort` orders the
// feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType
// (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array.
@@ -480,22 +373,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') },
}),
@@ -503,8 +389,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))
}
)
@@ -516,9 +401,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'),
@@ -531,8 +414,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))
}
)
@@ -546,11 +428,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') },
}),
@@ -558,17 +437,15 @@ 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))
}
)
// Global slideshow feed — the most recent public ShareCamera photos (Accessibility 1,
// Type 1) across all rooms, newest first, each joined to its creator's username and
// room name. Public (no auth): it only surfaces already-public images and backs the
// anonymous homepage slideshow. Private photos (Accessibility 0) never appear — the
// feed is anonymous, so there is no viewer it could be unlocked for. Returns `{ Images, ValidTill }`,
// Global slideshow feed — the most recent publicly-listable ShareCamera photos
// (Accessibility 0 or 1, Type 1) across all rooms, newest first, each joined to its
// creator's username and room name. Public (no auth): it only surfaces already-public
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
// Serves 10 by default and never more than SLIDESHOW_MAX_LIMIT (100): it's public and
// unauthenticated, so an unclamped `take` would let anyone ask for the whole image
@@ -643,24 +520,23 @@ export const imageRoutes = new Hono<App>({ strict: false })
}
)
// Image metadata by filename, or 404 when there's no metadata row for that name.
//
// Serves the RAW 13-key `SavedImage`, the same type the client decodes `v6/:id` into.
// This once answered a projection of its own (`SavedImageId`/`SavedImageType`, a
// `ClubId`, no `TaggedPlayerIds`, nulls as 0/""): the client's DTO has `Id` and `Type`
// and no `ClubId`, so it was silently dropping most of that.
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
// there's no metadata row for that name.
.get(
'/api/images/v6',
describeRoute({
tags: ['Images'],
summary: 'Image metadata by filename',
description:
'An image’s stored `SavedImage` record for a bucket key — the raw record, as ' +
'`v6/{id}` and `v5/bulk` serve, with `Description`, `RoomId` and `PlayerEventId` ' +
'null where the row holds null. 404s when the object exists but has no metadata row.',
'An image’s metadata for a bucket key. 404s when the object exists but has no ' +
'metadata row.\n\n' +
'Its own projection: renamed like the player lists (`SavedImageId`/`SavedImageType`, ' +
'no `TaggedPlayerIds`) but carrying `ClubId`, and with nothing nullable — `RoomId`, ' +
'`PlayerEventId` and `ClubId` read 0 where the row holds null, `Description` reads ' +
'`""`. Three shapes of one row; keep them straight.',
parameters: [stringQuery('name', 'The image name (bucket key); required')],
responses: {
200: json(SavedImageDto, 'The image record'),
200: json(ImageMetadataDto, 'The image’s metadata'),
400: json(ErrorResponse, 'No name given'),
404: { description: 'No metadata for that name' },
},
@@ -669,85 +545,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
const name = c.req.query('name') ?? ''
if (name === '') return c.json({ error: 'name is required' }, 400)
const image = await getImageByName(c.env.DB, name)
return image ? c.json(image) : c.notFound()
}
)
// One image by id — the client opening a single photo it holds only the id of.
//
// Serves the RAW 13-key `SavedImage` (`Id`/`Type`, `TaggedPlayerIds`, nulls left null),
// which is the type the client decodes this route into — the same record `v6?name=`
// serves, and `v5/bulk` a list of. NOT the `ImagesPlayer` projection of the player lists.
//
// Public, or the caller's own: ids are sequential, so anyone else's private photo is a
// 404 here, the same as an id that doesn't exist.
.get(
'/api/images/v6/:id{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'One image by id',
description:
'The stored `SavedImage` record for an image id — the raw record, as `v5/bulk` ' +
'serves, with `Description`, `RoomId` and `PlayerEventId` null where the row holds ' +
'null. A private image is served only to the player who took it; to anyone else ' +
'it is a 404, like an unknown id.',
parameters: [idParam('id', 'Image id')],
responses: {
200: json(SavedImageDto, 'The image record'),
404: { description: 'No such image, or not the caller’s to see' },
},
}),
async (c) => {
const image = await getImageById(c.env.DB, Number.parseInt(c.req.param('id'), 10))
if (image === null) return c.notFound()
if (image.Accessibility !== 1 && image.PlayerId !== (await authedId(c))) return c.notFound()
return c.json(image)
}
)
// Report a saved image. Stored in the `report` table the player, event, invention, item
// and chat reports use — same moderation life — with `image_id` set. See
// migrations/0034_report_image.sql.
//
// The request has NO body: the path names the image and the token names the reporter,
// and that is the whole report. The reported player is whoever took the image, read from
// the image. The `{ success, error: "" }` reply is the envelope the other reports use,
// which is an ASSUMPTION here: what the client does with this response has not been
// observed.
.post(
'/api/images/v1/:id{[0-9]+}/report',
describeRoute({
tags: ['Images', 'Moderation'],
summary: 'Report a photo',
description:
'Files a report against a saved image, as a row in the same `report` table the ' +
'player, event, invention, custom-avatar-item and chat reports go to. There is no ' +
'request body: the row is the reporter (the caller), the image (`image_id`), and ' +
'`reported_player_id` — the image’s `PlayerId`, who took it. No category or ' +
'description is stored, since none is sent. Nothing dedupes the rows, and an ' +
'unknown image is refused rather than filed against nobody.',
security: AUTHED,
parameters: [idParam('id', 'Image id')],
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such image'),
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const imageId = Number.parseInt(c.req.param('id'), 10)
const image = await getImageById(c.env.DB, imageId)
if (image === null) return c.json({ success: false, error: 'No such image' }, 404)
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId: image.PlayerId,
imageId,
})
return c.json({ success: true, error: '' })
return image ? c.json(toImageMetadata(image)) : c.notFound()
}
)
+113 -428
View File
@@ -2,23 +2,17 @@ import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
canManageRoom,
canModerateRoom,
countPlayersInInstance,
deletePresence,
getPlayerIdsInInstance,
getPresence,
getPresences,
getRoomById,
getStoredRoomInstance,
MessageType,
movePlayerToDorm,
refreshInstanceFullness,
} 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'
@@ -27,7 +21,6 @@ import { authedId, authedRoles, unauthorized } from '../http'
import {
AUTHED,
BareBoolean,
CreateChatReportRequest,
CreateReportRequest,
CreateWarningRequest,
DeviceIdRequest,
@@ -37,25 +30,18 @@ import {
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, getActiveBan } 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'
import type { ReportRow } from '../reports-db'
/**
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
@@ -79,15 +65,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 +79,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,
@@ -187,17 +131,14 @@ async function pushInstantKick(
playerIds: number[],
gameSessionId: number,
roomName: string,
moderatorId: number,
{ reason = '', isHostKick = true }: { reason?: string; isHostKick?: boolean } = {}
moderatorId: number
): 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}.`,
IsHostKick: true,
Message: `You have been kicked from ${roomName}.`,
PlayerIdReporter: moderatorId,
IsBan: false,
IsVoiceModAutoban: false,
@@ -221,23 +162,27 @@ async function pushInstantKick(
}
/**
* What a vote-to-kick Message carries, and it is not what this once sent.
* What a vote-to-kick Message's `Data` says, BEFORE it is serialized. It reaches the
* client as an escaped JSON string, never as a nested object — a Message's `Data` is a
* string on the wire like every other Message's, and the client's decoder rejects an
* object outright: `expected:'String Begin Token', actual:'{'`, which aborts the whole
* notification rather than dropping the field. Serialize it with {@link voteToKickData}.
*
* `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.
* `PlayerId` is the account id as a STRING — the reference passes the posted form field
* straight through, and this mirrors it verbatim.
*
* `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.
* `Response` is the empty string even though the caller posted their own vote: the frame
* is the PROMPT put to everyone else, so it carries no answer yet. The caller's `Response`
* is theirs alone and is not relayed.
*/
interface VoteToKickData {
PlayerId: string
Response: string
GameSessionId: number
}
/** Serialize a {@link VoteToKickData} into the escaped JSON string `Data` carries. */
const voteToKickData = (data: VoteToKickData): string => JSON.stringify(data)
/**
* The Message a vote-to-kick frame carries — the same four fields as every other Message
@@ -280,60 +225,67 @@ async function pushVoteToKick(c: Context<App>, message: VoteToKickMessage): Prom
}
/**
* 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.
* `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.
*/
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 PERMANENT_BAN_DURATION = 2_147_483_647
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'
/**
* 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.
*/
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 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),
})
/**
* The block details for a ban in force — the `report` row a moderator set `banned` on.
*
* `Duration` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
* start for the duration. The start is the report's `created_at` — nothing records when
* the ban itself was handed down, and the report is the record the ban rests on — 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. 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.
*/
function banBlockDetails(ban: ReportRow) {
const startedAt = Date.parse(ban.created_at)
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: ban.created_at,
}
}
@@ -368,11 +320,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
'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 `Duration` as the seconds LEFT on the ban as of ' +
'this request — the client counts it down from when it receives it, so it is ' +
'`ban_expires` minus now, rounded up, never the ban’s full span (2147483647, the ' +
'int32 max, for a permanent ban). `TimeoutStartedAt` stays null: the client does ' +
'not pair it with `Duration` for a ban, and no reference server sends it. ' +
'`Message` “Rule violation”, and the block’s span as the pair the client reads ' +
'them as: `TimeoutStartedAt` is the report’s `created_at` 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' +
@@ -395,11 +346,8 @@ export const moderationRoutes = new Hono<App>({ strict: false })
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// One instant for both: the ban resolved as in force at `now` is described as of
// `now`, so it can't come out with 0 left.
const now = new Date()
const ban = await getActiveBan(c.env.DB, id, now)
return c.json(ban ? banBlockDetails(ban, now) : NOT_BLOCKED)
const ban = await getActiveBan(c.env.DB, id)
return c.json(ban ? banBlockDetails(ban) : NOT_BLOCKED)
}
)
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
@@ -534,91 +482,10 @@ 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.
// there. Nothing tallies the votes yet: this relays the prompt and no more.
.post(
'/api/PlayerReporting/v3/voteToKick',
describeRoute({
@@ -634,37 +501,17 @@ export const moderationRoutes = new Hono<App>({ strict: false })
'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" +
'`Data` is an ESCAPED JSON STRING — `"{\\"PlayerId\\":\\"205\\",…}"`, not a nested ' +
'object. A Message’s `Data` is a string on the wire, and an object there fails the ' +
"client’s decoder outright (`expected:'String Begin Token', actual:'{'`), " +
'aborting the notification rather than dropping the field. Inside it, `PlayerId` ' +
'is the account id as a STRING, as the reference relays it, and `Response` is ' +
'empty — the frame is the question, not an answer.\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 ' +
'Nothing is stored — no tally, no report row, and `Reason` is accepted and ' +
'unused. Answers the same lowercase `{ success, error }` envelope as the report ' +
'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,
@@ -674,8 +521,6 @@ export const moderationRoutes = new Hono<App>({ strict: false })
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'),
},
}),
@@ -684,6 +529,8 @@ export const moderationRoutes = new Hono<App>({ strict: false })
if (voterId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
// Kept as posted for the frame — `Data.PlayerId` goes out as the string the
// reference relays — but parsed here to check it against presence.
const playerIdField = formField(body, c, 'PlayerId')
const playerId = asInt(playerIdField)
if (playerIdField === undefined || playerId === null) {
@@ -694,10 +541,6 @@ export const moderationRoutes = new Hono<App>({ strict: false })
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.
@@ -714,61 +557,6 @@ export const moderationRoutes = new Hono<App>({ strict: false })
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.
@@ -781,13 +569,15 @@ export const moderationRoutes = new Hono<App>({ strict: false })
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,
FromPlayerId: voterId,
ToPlayerId: toPlayerId,
Type: MessageType.VoteToKick,
// The reason as plain text; see the note above the Message type.
Data: reason,
// An escaped JSON STRING, not a nested object — see VoteToKickData.
Data: voteToKickData({
PlayerId: playerIdField,
Response: '',
GameSessionId: gameSessionId,
}),
})
)
)
@@ -821,10 +611,8 @@ export const moderationRoutes = new Hono<App>({ strict: false })
'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 ' +
'Each kicked player loses their presence row (they read offline at once and the ' +
'instance frees a slot) and gets 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 ' +
@@ -884,15 +672,14 @@ export const moderationRoutes = new Hono<App>({ strict: false })
// 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)
await deletePresence(c.env.DB, playerId)
kicked.push(playerId)
}
if (kicked.length > 0) {
// The instance just lost players — recompute its fullness so a full room opens
// back up, exactly as the `match` worker does when someone logs out.
await refreshInstanceFullness(c.env.DB, gameSessionId)
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
await pushInstantKick(c, kicked, gameSessionId, roomName, moderatorId)
}
@@ -901,108 +688,6 @@ export const moderationRoutes = new Hono<App>({ strict: false })
}
)
// 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(
+11 -14
View File
@@ -16,7 +16,6 @@ import { NotificationType } from '../../../notify/src/notification-types'
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
import {
AUTHED,
BareInteger,
BulkIdsRequest,
CheerPlayerRequest,
CheerPlayerResponse,
@@ -521,25 +520,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 `0` — the id of the active event, or 0 when there is none (this once
// served -1 for the same meaning). 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 `0` — 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 `0` — 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(0)
(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
+172 -2854
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,
@@ -15,7 +15,6 @@ import {
LEVEL_REWARDS,
MAX_LEVEL,
MessageType,
NOTIFICATION_SCHEMA_DDL,
OUTFIT_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL,
PRESENCE_TTL_SECONDS,
@@ -24,7 +23,6 @@ import {
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
setOutfit,
SUBROOM_SCHEMA_DDL,
SUPPORTED_GAME_VERSIONS,
} from '@repo/domain'
@@ -32,47 +30,25 @@ 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 {
grantCustomAvatarItem,
INVENTORY_CUSTOM_SCHEMA_DDL,
} from '../../../../econ/src/inventory-custom-db'
import { grantItem, INVENTORY_SCHEMA_DDL } from '../../../../econ/src/inventory-db'
import priceCapSql from '../../../migrations/0032_custom_avatar_item_price_cap.sql?raw'
import inventionPriceCapSql from '../../../migrations/0033_invention_price_cap.sql?raw'
import { banEvasionMatch, resolveBan } from '../../bans-db'
import {
createCustomAvatarItem,
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
getCustomAvatarItem,
importCustomAvatarItem,
MAX_PLAYER_ITEM_PRICE,
} from '../../custom-avatar-items-db'
import { customAvatarItemRowLiteral } from '../../custom-avatar-items-load'
import {
countGoing,
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
getEventAttendees,
getEventResponse,
} from '../../events-db'
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL, MAX_INVENTION_PRICE } from '../../inventions-db'
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
import {
banFromReport,
createReport,
getActiveBan,
getBansInForce,
getReportById,
getReportsAgainst,
getTopReported,
isPlayerBanned,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
searchReports,
} from '../../reports-db'
import {
CheerCategory,
@@ -83,7 +59,6 @@ import {
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'
@@ -122,8 +97,7 @@ const TEST_ROOMS = [
},
{
// 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.
// Moderator (20) and 44 only Host (10) — the tier just below that gate.
RoomId: 4,
Name: 'KickRoom',
IsDorm: false,
@@ -132,7 +106,6 @@ const TEST_ROOMS = [
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 },
],
},
]
@@ -179,10 +152,6 @@ beforeAll(async () => {
// 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()
// Bought items and bought custom items (owned by the econ worker) — /outfits/bulk strips
// what a wearer doesn't own.
for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTORY_CUSTOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Inventions table (owned by the api worker) — invention save/mine use it.
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -193,8 +162,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()
@@ -210,15 +177,6 @@ beforeAll(async () => {
// 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
@@ -881,28 +839,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
@@ -949,13 +885,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,
@@ -1039,8 +968,7 @@ describe('public endpoints', () => {
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`
'UPDATE custom_avatar_item SET is_featured = 1 WHERE custom_avatar_item_id != ?1'
)
.bind(unflagged.CustomAvatarItemId)
.run()
@@ -1190,12 +1118,9 @@ describe('public endpoints', () => {
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)
// Nothing sets outfit_type on creation yet, so set it straight in the table.
await env.DB.prepare(
`UPDATE custom_avatar_item SET data = json_set(data, '$.OutfitType', ?2)
WHERE custom_avatar_item_id = ?1`
'UPDATE custom_avatar_item SET outfit_type = ?2 WHERE custom_avatar_item_id = ?1'
)
.bind(created.CustomAvatarItemId, outfitType)
.run()
@@ -1243,67 +1168,20 @@ describe('public endpoints', () => {
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)
)
// The client capitalises its booleans (`includeCoachItems=True`), so the comparison folds
// case; only a recognisable "false" turns the stock content off.
expect((await search('?includeCoachItems=True')).map((i) => i.Name)).toContain('Coach Hat')
expect((await search('?includeCoachItems=false')).map((i) => i.Name)).not.toContain('Coach Hat')
// `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.
// The whole query the client actually sends, unchanged — the parameters that aren't acted
// on yet must be accepted rather than 400 or throw.
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'])
expect(real.map((i) => i.Name)).toEqual(all.map((i) => i.Name))
// `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)
@@ -1376,18 +1254,17 @@ describe('public endpoints', () => {
// 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.)
// The whole query the client actually sends. `itemTypes` and the unity asset parameters are
// accepted and not acted on; `outfitTypes=105` matches nothing, so this is empty — which is
// the filter working, not the search failing.
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.
).toEqual([])
// Same query with the outfit-type filter dropped: the rest of it does match.
expect(
await search(
'?searchQuery=room&itemTypes=-1&minPrice=0&maxPrice=10000&includePurchaseInfos=True' +
@@ -1397,245 +1274,6 @@ describe('public endpoints', () => {
).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 = {
@@ -2067,172 +1705,6 @@ describe('public endpoints', () => {
expect(bad.status).toBe(400)
})
test('POST /outfits/bulk strips the selections a wearer does not own', async () => {
const bulk = async (ids: number[]) => {
const res = await exports.default.fetch(`${ORIGIN}/outfits/bulk`, {
method: 'POST',
headers: { ...(await bearer('42')), 'content-type': 'application/json' },
body: JSON.stringify({ AccountIds: ids, UnityAssetTarget: null, UnityAssetVersion: null }),
})
expect(res.status).toBe(200)
return ((await res.json()) as { OutfitsByAccountId: Record<string, unknown> })
.OutfitsByAccountId
}
const save = async (sub: string, outfit: unknown) => {
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'content-type': 'application/json' },
body: JSON.stringify(outfit),
})
expect(res.status).toBe(200)
}
// Base assets: one from the free default catalog, the base item custom shirts are built
// on, one account 301 BOUGHT (in a colour — the inventory key is the whole descriptor),
// and one nobody owns.
const free = '5cd08cfb-c729-4c30-96d9-6a99bb934d91'
const base = 'c5d70cb4-71dd-4fe4-b719-34fe2073c611'
const bought = 'aaaaaaaa-1111-4222-8333-444444444444'
const unowned = 'bbbbbbbb-1111-4222-8333-444444444444'
await grantItem(env.DB, 301, {
AvatarItemType: 0,
AvatarItemDesc: `${bought},51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,`,
PlatformMask: -1,
FriendlyName: 'Bought Hat',
Tooltip: '',
Rarity: 1,
})
// Custom items: one 301 made, one they bought from 999, one of 999's they never bought.
const custom = async (customAvatarItemId: string, creatorAccountId: number) =>
createCustomAvatarItem(env.DB, {
customAvatarItemId,
creatorAccountId,
name: customAvatarItemId,
description: '',
price: 0,
baseAvatarItemId: 2184,
baseAvatarItemColor: '#F55C1A',
accessibility: 1,
designFilename: 'd.png',
thumbnailImageFilename: 't.png',
})
const made = (await custom('cccccccc-1111-4222-8333-444444444444', 301)).CustomAvatarItemId
const boughtCustom = (await custom('dddddddd-1111-4222-8333-444444444444', 999))
.CustomAvatarItemId
const unownedCustom = (await custom('eeeeeeee-1111-4222-8333-444444444444', 999))
.CustomAvatarItemId
await grantCustomAvatarItem(env.DB, 301, boughtCustom)
const selection = (PrefabGuid: string, BodyPart: number, customId?: string) => ({
PrefabGuid,
CombinationGuid: '',
BodyPart,
...(customId === undefined
? {}
: {
UgcOutfitData: {
BaseAvatarItemColor: { r: 0, g: 0, b: 0, a: 0 },
CustomAvatarItemId: customId,
},
}),
})
// Guid case is not identity — the client is not consistent about it — so the bought
// base rides upper-cased in the v2 list.
const v2 = {
selections: [
selection(free, 1),
selection(bought.toUpperCase(), 0),
selection(unowned, 2),
selection(base, 3, made),
selection(base, 4, boughtCustom.toUpperCase()),
selection(base, 5, unownedCustom),
],
}
// Only the header of `CustomizationSettings` has been observed; whatever selections it
// carries are found wherever they sit, by the same rule.
const settings = {
AvatarVersion: 2,
AvatarBodyType: 0,
Body: { Items: [selection(free, 1), selection(unowned, 2)] },
}
const envelope = (
SelectionsV1: string,
SelectionsV2: string,
CustomizationSettings: string
) => ({
DataVersion: 2,
LegacyData: {
SelectionsV1,
SelectionsV2,
FaceFeatures: '{"ver":7}',
SkinColor: 'skin',
HairColor: 'hair',
},
CustomizationSettings,
Selections: [],
Slot: 0,
Name: '',
Accessibility: 1,
ThumbnailFileName: null,
})
const saved = envelope(
`${free},,1;${bought},51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,,0;${unowned},,2;${base},,3`,
JSON.stringify(v2),
JSON.stringify(settings)
)
await save('301', saved)
// Account 303 wears only free things: served byte-for-byte as saved.
const allFree = envelope(
`${free},,1;${base},,0`,
JSON.stringify({ selections: [selection(free, 1)] }),
JSON.stringify({ AvatarVersion: 2, AvatarBodyType: 0 })
)
await save('303', allFree)
// Account 302 saved through econ's flat shape (`/api/avatar/v3/saved/set`), which lands
// in the same table: the flat fields are stripped the same way.
const flat = {
Slot: 0,
PreviewImageName: null,
OutfitSelections: `${free},,1;${unowned},,0`,
OutfitSelectionsV2: JSON.stringify({
selections: [selection(free, 1), selection(unowned, 0)],
}),
FaceFeatures: '{"ver":6}',
SkinColor: 'skin',
HairColor: 'hair',
CustomAvatarItems: [{ CustomAvatarItemId: unownedCustom }],
}
await setOutfit(env.DB, 302, flat)
const outfits = await bulk([301, 302, 303])
// 301: the unowned base is gone from every form, the unbought custom item too; the
// free, bought (either case), made and bought-custom selections stay. The documents
// are re-encoded only because something was removed from them.
expect(outfits['301']).toEqual(
envelope(
`${free},,1;${bought},51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,,0;${base},,3`,
JSON.stringify({ selections: v2.selections.filter((_, i) => i !== 2 && i !== 5) }),
JSON.stringify({ ...settings, Body: { Items: [selection(free, 1)] } })
)
)
expect(outfits['302']).toEqual({
...flat,
OutfitSelections: `${free},,1`,
OutfitSelectionsV2: JSON.stringify({ selections: [selection(free, 1)] }),
CustomAvatarItems: [],
})
expect(outfits['303']).toEqual(allFree)
// The owner's own read is untouched: what they saved is what they get back.
const mine = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
headers: await bearer('301'),
})
expect(await mine.json()).toEqual(saved)
})
test('PUT /outfits/me 400s on an unparseable body', async () => {
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
@@ -2242,12 +1714,12 @@ describe('public endpoints', () => {
expect(res.status).toBe(400)
})
test('GET /api/progressionEvents/active is a bare 0 (no auth)', async () => {
// 0 is the "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(0)
expect(await res.json()).toEqual([])
})
test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => {
@@ -2299,35 +1771,6 @@ describe('public endpoints', () => {
expect(await res.json()).toEqual([])
})
test('GET /api/inventions/v2/mine serves a v3-published invention as public', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5149')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Published the old way', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}`,
{ headers: await bearer('5149') }
)
// `v3/publish` names no accessibility, so the record keeps the 0 a save mints. The
// 2025 client has no `IsPublished` to read, only `Accessibility` — 0 is Private.
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5149'),
})
expect((await mine.json()) as SavedInvention[]).toMatchObject([
{ InventionId: Invention.InventionId, IsPublished: true, Accessibility: 1 },
])
// Published, so it would otherwise turn up in the feeds the later tests count.
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5149')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: Invention.InventionId }),
})
})
test('POST /api/inventions/v6/save persists the invention and lists it in mine', async () => {
const body = {
name: '071126 13:10:50',
@@ -2397,19 +1840,6 @@ describe('public endpoints', () => {
expect(mine.status).toBe(200)
const list = (await mine.json()) as SavedInvention[]
expect(list.map((i) => i.InventionId)).toContain(saved.InventionId)
// The 2025 client decodes this list into its flat `RRInvention`, so every key of
// that DTO is present even on a v6 record that stores none of them — and the keys
// the older client reads are still there beside them.
expect(list.find((i) => i.InventionId === saved.InventionId)).toMatchObject({
UgcVersion: 1,
LatestVersionNumber: 1,
ForceCannotPublish: false,
IsRecRoomApproved: false,
DisplayMetadataJson: null,
Accessibility: 0,
IsPublished: false,
CurrentVersion: { VersionNumber: 1 },
})
// The saved invention is fetchable by id via the v1 lookup.
const one = await exports.default.fetch(
@@ -2529,7 +1959,7 @@ describe('public endpoints', () => {
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(1)
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.
@@ -2724,158 +2154,6 @@ describe('public endpoints', () => {
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',
@@ -3013,7 +2291,7 @@ describe('public endpoints', () => {
.run()
})
test('POST /api/inventions/v4/publish keeps an unlisted invention out of the store, and v2/unpublish undoes it', async () => {
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' },
@@ -3033,8 +2311,8 @@ describe('public endpoints', () => {
const value = ((await res.json()) as InventionSaveV9Result).Value
expect(value?.Invention.Accessibility).toBe(2)
// Unlisted is published — on the creator's portfolio and reachable by id — but it is
// not in the store: search and the feeds leave it out.
// 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}`
)
@@ -3048,39 +2326,6 @@ describe('public endpoints', () => {
expect(await found.json()).toEqual([])
const room = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=4171`)
expect(await room.json()).toEqual([])
const portfolio = async (): Promise<number[]> =>
(
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=5171`)
).json()) as SavedInvention[]
).map((i) => i.InventionId)
expect(await portfolio()).toEqual([inventionId])
// Unpublishing is creator only, and puts back what a save mints: not published,
// Accessibility 0 — off the portfolio, still on the creator's own shelf.
const unpublish = async (who: string): Promise<InventionSaveV9Result> =>
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/unpublish`, {
method: 'POST',
headers: { ...(await bearer(who)), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
).json()) as InventionSaveV9Result
expect((await unpublish('5199')).Success).toBe(false)
expect(await portfolio()).toEqual([inventionId])
const undone = await unpublish('5171')
expect(undone.Success).toBe(true)
expect(undone.Value?.Invention.Accessibility).toBe(0)
expect(await portfolio()).toEqual([])
const stored = (await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5171'),
})
).json()) as SavedInvention[]
const shelved = stored.find((i) => i.InventionId === inventionId)
expect(shelved).toMatchObject({ IsPublished: false, Accessibility: 0 })
expect(shelved?.FirstPublishedAt).not.toBeNull()
})
test('POST /api/inventions/v4/publish leaves a price and a first-publish date alone', async () => {
@@ -3115,20 +2360,6 @@ describe('public endpoints', () => {
// A negative price is dropped rather than stored.
expect((await publish({ Price: -5 })).Price).toBe(250)
// One over the cap refuses the publish in-band, and the price stands.
const over = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Price: 1001 }),
})
expect(over.status).toBe(200)
expect(await over.json()).toMatchObject({
Success: false,
Value: null,
Error: 'Price must be <= 1000',
})
expect((await publish({ Price: 1000 })).Price).toBe(1000)
// 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()
})
@@ -3498,103 +2729,6 @@ describe('public endpoints', () => {
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',
@@ -3847,55 +2981,6 @@ describe('public endpoints', () => {
expect(await miss.json()).toEqual([])
})
test('GET /api/inventions/v2/search with `@username` lists that player’s published inventions', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 8082, username: 'DJDevin' }))
.run()
const invention = (id: number, creator: number, name: string): SavedInvention =>
({
InventionId: id,
ReplicationId: crypto.randomUUID(),
CreatorPlayerId: creator,
Name: name,
Description: '',
ImageName: '',
CurrentVersionNumber: 1,
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
IsPublished: true,
HideFromPlayer: false,
CreatedAt: `2026-07-${String(id - 100).padStart(2, '0')}T00:00:00Z`,
}) as unknown as SavedInvention
for (const inv of [
invention(111, 8082, 'Devin Sofa'),
invention(112, 8082, 'Devin Lamp'),
// Another creator mentioning the name is NOT this player's invention.
{ ...invention(113, 8083, 'Tribute to @djdevin'), Description: 'by @djdevin' },
// Unpublished and hidden ones stay private, even to a search for the creator.
{ ...invention(114, 8082, 'Devin Draft'), IsPublished: false },
{ ...invention(115, 8082, 'Devin Hidden'), HideFromPlayer: true },
]) {
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(inv))
.run()
}
const ids = async (value: string): Promise<number[]> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v2/search?value=${encodeURIComponent(value)}&skip=0&take=100`
)
expect(res.status).toBe(200)
return ((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
}
// The player's published inventions, newest first; the username is case-insensitive.
expect(await ids('@djdevin')).toEqual([112, 111])
expect(await ids('@DJDevin')).toEqual([112, 111])
// Further terms narrow within that player's inventions.
expect(await ids('@djdevin lamp')).toEqual([112])
// An unknown player is nothing — not a text search for the literal "@nobody".
expect(await ids('@nobody')).toEqual([])
expect(await ids('@')).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[]) =>
({
@@ -4139,50 +3224,6 @@ describe('public endpoints', () => {
expect(noId.status).toBe(400)
})
test('POST /api/inventions/v2/cheer is v1/cheer in the newer client’s envelope', async () => {
const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('8210')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Cheerable Sofa', inventionDataFilename: 'cheerable2.inv' }),
})
const { InventionId } = ((await saved.json()) as InventionSaveResult).Invention
const cheer = async (body: unknown, sub?: string) =>
exports.default.fetch(`${ORIGIN}/api/inventions/v2/cheer`, {
method: 'POST',
headers: { ...(sub ? await bearer(sub) : {}), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const stored = async (): Promise<number> =>
(
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=${InventionId}`)
).json()) as SavedInvention
).CheerCount
const ok = { Value: null, Success: true, Error: null, error_id: null }
const anonymous = await cheer({ InventionId, Cheer: true })
expect(anonymous.status).toBe(401)
expect(await anonymous.json()).toMatchObject({ Value: null, Success: false })
expect(await (await cheer({ InventionId, Cheer: true }, '44')).json()).toEqual(ok)
expect(await (await cheer({ InventionId, Cheer: true }, '44')).json()).toEqual(ok)
expect(await stored()).toBe(1)
const personal = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/personaldetails/${InventionId}`,
{ headers: await bearer('44') }
)
expect(await personal.json()).toEqual({ IsCheering: true })
expect(await (await cheer({ InventionId, Cheer: false }, '44')).json()).toEqual(ok)
expect(await stored()).toBe(0)
// Refusals stay in the envelope, at 200.
const unknown = await cheer({ InventionId: 999999, Cheer: true }, '44')
expect(unknown.status).toBe(200)
expect(await unknown.json()).toMatchObject({ Success: false, Error: 'No such invention' })
expect(await (await cheer({ InventionId }, '44')).json()).toMatchObject({ Success: false })
})
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',
@@ -4499,21 +3540,6 @@ describe('public endpoints', () => {
expect(await search()).toEqual([Invention.InventionId])
// A price over the cap refuses the whole publish, leaving the invention as it was.
const over = await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&price=1001`,
{ headers: await bearer('2121') }
)
expect(over.status).toBe(400)
expect(await over.json()).toEqual({ error: 'Price must be <= 1000' })
expect(
(await env.DB.prepare(
`SELECT json_extract(data, '$.Price') AS price FROM invention WHERE id = ?1`
)
.bind(Invention.InventionId)
.first<{ price: number }>())!.price
).toBe(250)
// Publishing with no permissionLevel defaults to UseOnly, and price to 0.
const other = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -4566,142 +3592,26 @@ describe('public endpoints', () => {
expect(
(await updateprice({ InventionId: Invention.InventionId, Price: 10 }, '9999')).status
).toBe(403)
// Capped at MAX_INVENTION_PRICE, inclusive: one over is refused and the price stands.
expect(MAX_INVENTION_PRICE).toBe(1000)
const over = await updateprice({ InventionId: Invention.InventionId, Price: 1001 })
expect(over.status).toBe(400)
expect(await over.json()).toEqual({ error: 'Price must be <= 1000' })
const atCap = await updateprice({ InventionId: Invention.InventionId, Price: 1000 })
expect(atCap.status).toBe(200)
expect(((await atCap.json()) as InventionSaveResult).Invention.Price).toBe(1000)
})
// The cap arrived after creators had priced inventions. Migration 0033 lowers every row
// over it to exactly the cap, published or not, and leaves the rest alone. Tests build the
// schema from SCHEMA_DDL rather than the migrations, so the file's UPDATE is run by hand.
test('migration 0033 lowers existing invention prices to the cap', async () => {
const make = async (name: string, price: number): Promise<number> => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('1213')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }),
})
const id = ((await save.json()) as InventionSaveResult).Invention.InventionId
// Written straight into the row: no endpoint will store this any more.
await env.DB.prepare(
`UPDATE invention SET data = json_set(data, '$.Price', ?2) WHERE id = ?1`
)
.bind(id, price)
.run()
return id
}
const dear = await make('Dear Lamp', 25000)
const fair = await make('Fair Lamp', 1000)
const cheap = await make('Cheap Lamp', 50)
test('GET /api/inventions/v1/fromcreators is an empty feed for now', async () => {
// A stub: the client renders an empty array as "this creator has published nothing",
// where a 404 would read as a row that failed to load.
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/fromcreators?id=207&skip=0&take=100`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
const statements = inventionPriceCapSql
.split('\n')
.filter((line) => !line.startsWith('--'))
.join('\n')
.split(';')
.map((sql) => sql.trim())
.filter(Boolean)
expect(statements).toHaveLength(1)
await env.DB.prepare(statements[0]!).run()
const price = async (id: number): Promise<number> =>
(await env.DB.prepare(
`SELECT json_extract(data, '$.Price') AS price FROM invention WHERE id = ?1`
)
.bind(id)
.first<{ price: number }>())!.price
expect(await price(dear)).toBe(1000)
expect(await price(fair)).toBe(1000)
expect(await price(cheap)).toBe(50)
})
test('GET /api/inventions/v1/fromcreators lists a creator’s public inventions', async () => {
const json = async (creator: string, path: string, body: unknown): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(creator)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const save = async (creator: string, name: string): Promise<number> => {
const res = await json(creator, '/api/inventions/v9/save', {
name,
inventionDataFilename: 'portfolio.inv',
ugcVersion: 1,
})
const id = ((await res.json()) as InventionSaveV9Result).Value?.Invention.InventionId
if (id === undefined) throw new Error('save failed')
return id
}
const publish = (creator: string, id: number, accessibility: number): Promise<Response> =>
json(creator, '/api/inventions/v4/publish', {
InventionId: id,
Permission: 20,
Accessibility: accessibility,
})
const draft = await save('5301', 'Portfolio Draft')
const listed = await save('5301', 'Portfolio Public')
const unlisted = await save('5301', 'Portfolio Unlisted')
const legacy = await save('5301', 'Portfolio Legacy')
const other = await save('5302', 'Other Creator')
await publish('5301', listed, 1)
await publish('5301', unlisted, 2)
await publish('5302', other, 1)
// `v3/publish` names no accessibility, so the record keeps the 0 a save mints.
await exports.default.fetch(`${ORIGIN}/api/inventions/v3/publish?inventionId=${legacy}`, {
headers: await bearer('5301'),
})
const shelf = async (query: string): Promise<SavedInvention[]> =>
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?${query}`)
).json()) as SavedInvention[]
// Published is what lists an invention here, whatever its accessibility: the draft
// is not on the shelf, the Unlisted one is — and the `v3/publish` one, which stores
// the 0 a save mints, is served as Public.
const mine = await shelf('id=5301&skip=0&take=100')
expect(mine.map((i) => i.InventionId)).toEqual([legacy, unlisted, listed])
expect(mine.map((i) => i.Accessibility)).toEqual([1, 2, 1])
// The creator gets the same shelf as anyone: their drafts are `v2/mine`'s alone.
const own = (await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=5301`, {
headers: await bearer('5301'),
})
).json()) as SavedInvention[]
expect(own.map((i) => i.InventionId)).not.toContain(draft)
// `id` repeats, newest first across creators, and skip/take page it.
expect((await shelf('id=5301&id=5302')).map((i) => i.InventionId)).toEqual([
other,
legacy,
unlisted,
listed,
])
expect((await shelf('id=5301&id=5302&skip=1&take=1')).map((i) => i.InventionId)).toEqual([
legacy,
])
// No creator, or one with nothing public, is an empty shelf rather than a 404.
expect(await shelf('')).toEqual([])
expect(await shelf('id=999999')).toEqual([])
// Published, so they would otherwise turn up in the feeds the later tests count.
for (const [creator, id] of [
['5301', listed],
['5301', unlisted],
['5301', legacy],
['5302', other],
] as const) {
await json(creator, '/api/inventions/v2/delete', { InventionId: id })
}
// The params are accepted and ignored, including a repeated `id` and none at all.
expect(
await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=1&id=2`)
).json()
).toEqual([])
expect(
await (await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators`)).json()
).toEqual([])
})
test('GET /api/inventions/v1/toptoday + v1/featured serve the invention feeds', async () => {
@@ -4996,16 +3906,11 @@ describe('custom avatar items', () => {
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.
OutfitType: 0,
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`)
@@ -5023,10 +3928,8 @@ describe('custom avatar items', () => {
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`
'SELECT name, creator_account_id FROM custom_avatar_item WHERE custom_avatar_item_id = ?1'
)
.bind(body.Value.CustomAvatarItemId)
.first()
@@ -5099,150 +4002,6 @@ describe('custom avatar items', () => {
expect(await (await exports.default.fetch(url, { method: 'PUT' })).status).toBe(401)
})
// A player-made item may not sell for more than MAX_PLAYER_ITEM_PRICE (1000) tokens. The
// client has no ceiling of its own (`minPriceForPublicItem` is a floor), so both writes a
// player has enforce it — refusing, not clamping, so the creator sees what was set.
test('the price is capped at 1000 tokens on create and edit', async () => {
expect(MAX_PLAYER_ITEM_PRICE).toBe(1000)
const create = async (Price: unknown) => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'Pricey', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff', Price })
)
form.set('thumbnailImage', new File([new Uint8Array([1])], 't.png', { type: 'image/png' }))
form.set('design', new File([new Uint8Array([2])], 'd.png', { type: 'image/png' }))
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
}
const over = await create(1001)
expect(over.status).toBe(400)
expect(await over.json()).toMatchObject({
Success: false,
Value: null,
Error: 'Price must be <= 1000',
})
expect((await create(-1)).status).toBe(400)
expect((await create(10.5)).status).toBe(400)
// Nothing was written for the refused ones.
expect(
await env.DB.prepare(
`SELECT count(*) AS n FROM custom_avatar_item WHERE name_lower = 'pricey'`
)
.first<{ n: number }>()
.then((r) => r!.n)
).toBe(0)
// The cap itself is allowed, inclusive; a missing price is still 0.
const atCap = await create(1000)
expect(atCap.status).toBe(200)
const { Value } = (await atCap.json()) as {
Value: { CustomAvatarItemId: string; Price: number }
}
expect(Value.Price).toBe(1000)
expect(
((await (await create(undefined)).json()) as { Value: { Price: number } }).Value.Price
).toBe(0)
// The edit is bound the same way, and a refused edit leaves the row as it was.
const url = `${ORIGIN}/api/customAvatarItems/v1/${Value.CustomAvatarItemId}`
const edit = async (Price: number) =>
exports.default.fetch(url, {
method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
body: JSON.stringify({ Name: null, Description: null, Price, Accessibility: null }),
})
const tooMuch = await edit(1001)
expect(tooMuch.status).toBe(400)
expect(await tooMuch.json()).toMatchObject({ Success: false, Error: 'Price must be <= 1000' })
expect((await edit(-1)).status).toBe(400)
expect((await getCustomAvatarItem(env.DB, Value.CustomAvatarItemId))!.Price).toBe(1000)
const lowered = await edit(999)
expect(lowered.status).toBe(200)
expect(((await lowered.json()) as { Value: { Price: number } }).Value.Price).toBe(999)
})
// The cap arrived after players had already priced shirts. Migration 0032 lowers every
// PLAYER-MADE row over it to exactly the cap and leaves first-party items — priced from the
// storefront dump, several above 1000 — alone; the two are told apart by `BaseAvatarItemId`.
// Tests build the schema from SCHEMA_DDL rather than the migrations, so the file's UPDATE
// is run here by hand.
test('migration 0032 lowers existing player-made prices to the cap', async () => {
const shirt = (name: string, price: number) =>
createCustomAvatarItem(
env.DB,
{
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name,
description: '',
price,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility: 1,
designFilename: 'd',
thumbnailImageFilename: 't',
},
new Date('2026-08-01T00:00:00Z')
)
const dear = await shirt('Dear', 5000)
const fair = await shirt('Fair', 1000)
const cheap = await shirt('Cheap', 50)
// A first-party item over the cap: no base item, Coach-authored.
const firstPartyId = crypto.randomUUID()
await importCustomAvatarItem(env.DB, {
CustomAvatarItemId: firstPartyId,
RankedEntityId: firstPartyId,
CreatorAccountId: 1,
Name: 'Studio Wings',
Description: '',
Price: 6000,
Accessibility: 1,
OutfitType: 100,
BaseAvatarItemId: null,
BaseAvatarItemColor: null,
DesignFilename: null,
ThumbnailImageFilename: null,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: true,
PreviewOrientation: 0,
RankingContext: null,
CreatedAt: '2024-09-26T21:32:48.523Z',
ModifiedAt: '2025-02-19T05:19:28.328Z',
CurrentSaves: [],
Tags: [],
CustomBadgeMetadata: null,
})
const statements = priceCapSql
.split('\n')
.filter((line) => !line.startsWith('--'))
.join('\n')
.split(';')
.map((sql) => sql.trim())
.filter(Boolean)
expect(statements).toHaveLength(1)
await env.DB.prepare(statements[0]!).run()
const price = async (id: string) => (await getCustomAvatarItem(env.DB, id))!
expect((await price(dear.CustomAvatarItemId)).Price).toBe(1000)
// The lowered row is marked modified, as an edit would mark it; the untouched ones aren't.
expect((await price(dear.CustomAvatarItemId)).ModifiedAt).not.toBe(dear.ModifiedAt)
expect(await price(fair.CustomAvatarItemId)).toMatchObject({
Price: 1000,
ModifiedAt: fair.ModifiedAt,
})
expect(await price(cheap.CustomAvatarItemId)).toMatchObject({
Price: 50,
ModifiedAt: cheap.ModifiedAt,
})
expect((await price(firstPartyId)).Price).toBe(6000)
})
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()
@@ -5345,7 +4104,7 @@ describe('custom avatar items', () => {
// 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'"
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE name = 'bounded'"
).first<{ n: number }>()
expect(row?.n).toBe(0)
expect((await env.IMAGES.list({ prefix: 'avatar-item/' })).objects).toHaveLength(
@@ -5426,24 +4185,6 @@ describe('instant kick', () => {
.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)
@@ -5475,19 +4216,9 @@ describe('instant kick', () => {
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)
// Presence is deleted, so they read offline at once — and only theirs is.
expect(await isPresent(205)).toBe(false)
expect(await isPresent(206)).toBe(true)
// The instance lost a player, so it is no longer full.
expect(await isFull(SESSION)).toBe(false)
@@ -5537,7 +4268,7 @@ describe('instant kick', () => {
// 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)
expect(await isPresent(209)).toBe(false)
await standIn(209, SESSION)
// 44 is only a Host (10), and 99 holds nothing at all.
@@ -5546,7 +4277,7 @@ describe('instant kick', () => {
expect(res.status, sub).toBe(403)
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
}
expect(await inSession(209, SESSION)).toBe(true)
expect(await isPresent(209)).toBe(true)
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
method: 'POST',
@@ -5566,8 +4297,8 @@ describe('instant kick', () => {
// 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 isPresent(42)).toBe(true)
expect(await isPresent(43)).toBe(true)
expect(await frames()).toEqual([])
})
@@ -5603,250 +4334,11 @@ describe('instant kick', () => {
})
})
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.
// other. Nothing reads `room_instance` here — the gate is presence alone.
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')
@@ -5866,40 +4358,6 @@ describe('vote to kick', () => {
)
.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`, {
@@ -5937,20 +4395,17 @@ describe('vote to kick', () => {
// 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.
// `Data` is an ESCAPED JSON STRING, not a nested object: an object there fails the
// client's decoder (`expected:'String Begin Token', actual:'{'`) and takes the whole
// notification with it. `PlayerId` inside it is a STRING, as the reference relays it,
// and `Response` is empty because the frame is the question, not an answer.
const message = {
ephemeral: true,
notificationType: 2, // NotificationType.MessageReceived
data: {
FromPlayerId: 205,
FromPlayerId: 42,
Type: MessageType.VoteToKick,
Data: 'Inactive in games (AFK)',
Data: `{"PlayerId":"205","Response":"","GameSessionId":${SESSION}}`,
},
}
const sent = await frames()
@@ -5967,109 +4422,6 @@ describe('vote to kick', () => {
})
})
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)
@@ -6137,80 +4489,6 @@ describe('vote to kick', () => {
// 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', () => {
@@ -6372,235 +4650,6 @@ describe('player reports', () => {
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', () => {
@@ -6652,9 +4701,10 @@ describe('player reports', () => {
expect(res.status).toBe(401)
})
// `Duration` is the seconds LEFT — the client counts it down from receipt. A permanent
// ban runs for the largest span the int holds, and `TimeoutStartedAt` stays null: the
// client does not pair it with `Duration` for a ban.
// Duration and TimeoutStartedAt are a pair in the client — the block runs from the
// start for the duration. The start is the report's created_at (nothing records when
// the ban itself landed), and 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' },
@@ -6672,78 +4722,26 @@ describe('player reports', () => {
// the reporter is not shown to the player they reported (PlayerIdReporter
// stays null).
Message: 'Rule violation',
TimeoutStartedAt: row!.created_at,
})
})
// A fresh timed ban: the seconds left are (within the write's latency) the whole ban.
test('describes a timed ban as the seconds left as of the request', async () => {
// 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 report’s created_at plus the span to expiry', async () => {
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 222)
const banExpires = new Date(Date.now() + 3600 * 1000)
const banExpires = new Date(Date.parse(row!.created_at) + 3600 * 1000)
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,
expect(await details('GET', '222')).toEqual({
...NOT_BLOCKED,
ReportCategory: 103,
Duration: 3600,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: null,
TimeoutStartedAt: row!.created_at,
})
// Within a second of the hour: however long the write took has already elapsed.
expect(body.Duration).toBeGreaterThan(3595)
expect(body.Duration).toBeLessThanOrEqual(3600)
})
// The case the bug lived in: a player signing in AFTER the ban was handed down. The
// client counts `Duration` down from the moment it receives it, so it must be what is
// left NOW — not the ban's full span from `banned_at`, which read as the whole ban
// still to run at every sign-in (and only looked right in the mid-session kick frame,
// sent at the instant the span and the remainder are the same).
test('counts down: a ban handed down a day ago has a day less to run', async () => {
await submit({ PlayerIdReported: '224', ReportCategory: '102' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 224)
// A seven-day ban handed down yesterday: six days left.
const banned = await banFromReport(env.DB, row!.id, {
banExpires: new Date(Date.now() + 6 * 86_400_000).toISOString(),
})
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
.bind(row!.id, new Date(Date.now() - 86_400_000).toISOString())
.run()
const body = (await details('GET', '224')) as Record<string, unknown>
expect(body.IsBan).toBe(true)
expect(body.Duration).toBeGreaterThan(6 * 86_400 - 5)
expect(body.Duration).toBeLessThanOrEqual(6 * 86_400)
// The start is not on the wire at all: null, not `banned_at`.
expect(body.TimeoutStartedAt).toBeNull()
expect(banned!.banned_at).not.toBeNull()
})
// A row banned BEFORE 0020 added `banned_at` reads the same as any other: the
// remainder needs only `ban_expires`.
test('reads a ban with no recorded banned_at the same way', 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.now() + 3600 * 1000).toISOString())
.run()
const { Duration: _duration, ...unblocked } = NOT_BLOCKED
const body = (await details('GET', '225')) as Record<string, unknown>
expect(body).toMatchObject({
...unblocked,
ReportCategory: 101,
IsBan: true,
Message: 'Rule violation',
})
expect(body.Duration).toBeGreaterThan(3595)
expect(body.Duration).toBeLessThanOrEqual(3600)
})
// A ban that has served its time is not a block, even though the row still says
@@ -6906,11 +4904,11 @@ describe('images', () => {
// A metadata row was created, and it's readable by name via /api/images/v6.
const meta = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
).json()) as { ImageName: string; PlayerId: number; Id: number; CheerCount: number }
).json()) as { ImageName: string; PlayerId: number; SavedImageId: number; CheerCount: number }
expect(meta.ImageName).toBe(ImageName)
expect(meta.PlayerId).toBe(42)
// `Id`, not `SavedImageId` — v6 serves the raw record, unlike the player lists.
expect(typeof meta.Id).toBe('number')
// `SavedImageId`, not `Id` — v6 renames like the player lists do.
expect(typeof meta.SavedImageId).toBe('number')
expect(meta.CheerCount).toBe(0)
})
@@ -6957,37 +4955,6 @@ describe('images', () => {
})
})
// The feed is anonymous, so a private photo (Accessibility 0) must never reach it —
// there is no viewer it could be unlocked for. It once accepted 0 alongside 1.
test('GET /api/images/v1/slideshow leaves private photos out', async () => {
const pub = await createImage(env.DB, {
imageName: 'slidepublic.jpg',
playerId: 42,
accessibility: 1,
})
const priv = await createImage(env.DB, {
imageName: 'slideprivate.jpg',
playerId: 42,
accessibility: 0,
})
// A non-photo (room thumbnail) that happens to be public stays out too.
const thumb = await createImage(env.DB, {
imageName: 'slidethumb.jpg',
playerId: 42,
type: 3,
accessibility: 1,
})
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow?take=100`)
expect(res.status).toBe(200)
const ids = ((await res.json()) as { Images: Array<{ SavedImageId: number }> }).Images.map(
(i) => i.SavedImageId
)
expect(ids).toContain(pub.Id)
expect(ids).not.toContain(priv.Id)
expect(ids).not.toContain(thumb.Id)
})
// The feed is public and unauthenticated, so `take` is clamped rather than trusted:
// without the cap a single anonymous request could pull the whole image table through
// the two joins behind it.
@@ -7149,13 +5116,6 @@ describe('images', () => {
// The setting is per-player.
expect(await (await read('710')).text()).toBe('2')
// Re-posting the stored value writes nothing to KV: the raw value is seeded with
// whitespace JSON.stringify never produces, and it survives the PUT untouched.
const padded = '{ "Recroom.OOBE": "77", "playerPhotoTaggingSetting": "1" }'
await env.RECFLARE_PLAYER_SETTINGS.put('player:712', padded)
expect(await (await write('712', { Setting: 1 })).text()).toBe('1')
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:712', 'text')).toBe(padded)
// A body with no readable Setting leaves the stored value alone rather than writing 0
// — and answers what the player still has.
expect(await (await write('710', { Nothing: true })).text()).toBe('2')
@@ -7255,7 +5215,7 @@ describe('images', () => {
expect(entries.filter((e) => e.IsCheered)).toHaveLength(1)
})
test('GET /api/images/v6 serves the raw SavedImage by name', async () => {
test('GET /api/images/v6 serves the metadata projection, nothing nullable', async () => {
const img = await createImage(env.DB, {
imageName: 'v6shape.jpg',
playerId: 7301,
@@ -7263,98 +5223,24 @@ describe('images', () => {
})
const res = await exports.default.fetch(`${ORIGIN}/api/images/v6?name=v6shape.jpg`)
expect(res.status).toBe(200)
// The client's 13-key DTO: `Id` and `Type`, `TaggedPlayerIds`, nulls left null — the
// same record `v6/:id` serves.
expect(await res.json()).toEqual({
Id: img.Id,
SavedImageId: img.Id,
ImageName: 'v6shape.jpg',
Description: null,
PlayerId: 7301,
RoomId: null,
PlayerEventId: null,
// Nulls on the row come out as 0 / "" — the client's DTO has no null to put there.
RoomId: 0,
PlayerEventId: 0,
ClubId: 0,
Description: '',
Accessibility: 1,
AccessibilityLocked: false,
Type: 1,
SavedImageType: 1,
CreatedAt: img.CreatedAt,
TaggedPlayerIds: [],
CheerCount: 0,
CommentCount: 0,
})
})
test('POST /api/images/v1/:id/report files a bodiless report against the photo’s author', async () => {
const img = await createImage(env.DB, { imageName: 'reported.jpg', playerId: 7310 })
const report = async (id: number, sub?: string) =>
exports.default.fetch(`${ORIGIN}/api/images/v1/${id}/report`, {
method: 'POST',
headers: sub ? await bearer(sub) : undefined,
})
expect((await report(img.Id)).status).toBe(401)
const unknown = await report(99999999, '7311')
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({ success: false, error: 'No such image' })
const res = await report(img.Id, '7311')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
const { results } = await env.DB.prepare('SELECT * FROM report WHERE image_id = ?1')
.bind(img.Id)
.all()
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
reporter_player_id: 7311,
reported_player_id: 7310,
image_id: img.Id,
report_category: 0,
details: null,
event_id: null,
invention_id: null,
custom_avatar_item_id: null,
chat_message_id: null,
})
})
test('GET /api/images/v6/:id serves the raw SavedImage, private ones to their owner only', async () => {
const img = await createImage(env.DB, { imageName: 'v6byid.jpg', playerId: 7302 })
const get = async (id: number, sub?: string) =>
exports.default.fetch(
`${ORIGIN}/api/images/v6/${id}`,
sub ? { headers: await bearer(sub) } : undefined
)
// The 13-key record, nulls left null.
const res = await get(img.Id)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Id: img.Id,
ImageName: 'v6byid.jpg',
Description: null,
PlayerId: 7302,
RoomId: null,
PlayerEventId: null,
Accessibility: 1,
AccessibilityLocked: false,
Type: 1,
CreatedAt: img.CreatedAt,
TaggedPlayerIds: [],
CheerCount: 0,
CommentCount: 0,
})
expect((await get(99999999)).status).toBe(404)
// Made private, it is its owner's alone — ids are sequential.
await env.DB.prepare(
"UPDATE image SET data = json_set(data, '$.Accessibility', 0) WHERE id = ?1"
)
.bind(img.Id)
.run()
expect((await get(img.Id)).status).toBe(404)
expect((await get(img.Id, '7303')).status).toBe(404)
expect((await get(img.Id, '7302')).status).toBe(200)
})
test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => {
expect((await exports.default.fetch(`${ORIGIN}/api/images/v6`)).status).toBe(400)
expect(
@@ -7386,20 +5272,23 @@ describe('images', () => {
const meta = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
).json()) as {
Type: number
SavedImageType: number
RoomId: number
Accessibility: number
PlayerEventId: number | null
TaggedPlayerIds: number[]
PlayerEventId: number
ClubId: number
Description: string
}
expect(meta.Type).toBe(1)
expect(meta.SavedImageType).toBe(1)
expect(meta.RoomId).toBe(777)
expect(meta.Accessibility).toBe(2)
// v6 serves the stored record as it is: the tagged players ride along, a "none"
// event is null rather than 0, and there is no `ClubId`.
expect(meta.TaggedPlayerIds).toEqual([5, 6])
expect(meta.PlayerEventId).toBeNull()
expect(meta).not.toHaveProperty('ClubId')
// v6 carries no TaggedPlayerIds — the upload still records them, which the stored row
// below proves. Nothing on this projection is nullable: a "none" event reads 0, not
// null, and the club (which nothing here sets) reads 0 too.
expect(meta).not.toHaveProperty('TaggedPlayerIds')
expect(meta.PlayerEventId).toBe(0)
expect(meta.ClubId).toBe(0)
expect(meta.Description).toBe('')
// The tagged players and the null event id, as actually stored.
const row = await env.DB.prepare('SELECT data FROM image WHERE image_name = ?1')
@@ -7410,7 +5299,7 @@ describe('images', () => {
PlayerEventId: number | null
}
expect(stored.TaggedPlayerIds).toEqual([5, 6])
// playerEventId 0 means "none" → stored as null.
// playerEventId 0 means "none" → stored as null, and serialized back out as 0.
expect(stored.PlayerEventId).toBeNull()
})
@@ -7440,7 +5329,7 @@ describe('images', () => {
expect(JSON.parse(row!.data).profileImage).toBe(ImageName)
})
test('POST /api/images/v1/deletesaved removes the owner’s image (row + cheers + R2)', async () => {
test('DELETE /api/images/v1/deletesaved removes the owner’s image (row + cheers + R2)', async () => {
const ImageName = 'sharecamera/2026-07-17/delete-me.jpg'
await env.IMAGES.put(ImageName, new Uint8Array([1, 2, 3]))
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
@@ -7468,7 +5357,7 @@ describe('images', () => {
const del = (headers: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
method: 'POST',
method: 'DELETE',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ ImageName }),
})
@@ -7480,7 +5369,7 @@ describe('images', () => {
// Unknown image → 404.
const unknown = await exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
method: 'POST',
method: 'DELETE',
headers: { 'Content-Type': 'application/json', ...(await bearer('42')) },
body: JSON.stringify({ ImageName: 'sharecamera/nope.jpg' }),
})
@@ -7496,113 +5385,6 @@ describe('images', () => {
expect(cheers!.n).toBe(0)
})
/** Seed one image row owned by the default bearer account (42). */
const seedOwnedImage = async (
Id: number,
ImageName: string,
extra: Record<string, unknown> = {}
) =>
env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
.bind(
JSON.stringify({
Id,
Type: 1,
Accessibility: 1,
AccessibilityLocked: false,
ImageName,
Description: null,
PlayerId: 42,
TaggedPlayerIds: [],
RoomId: null,
PlayerEventId: null,
CreatedAt: new Date().toISOString(),
CheerCount: 0,
CommentCount: 0,
...extra,
})
)
.run()
const postImageJson = (path: string, headers: Record<string, string>, body: unknown) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
})
test('POST /api/images/v2/modifyaccessibility flips the owner’s image and gates on the owner', async () => {
const ImageName = 'sharecamera/2026-09-13/flip-me.jpg'
await seedOwnedImage(8101, ImageName)
const path = '/api/images/v2/modifyaccessibility'
// No token → 401; a different account → 403; the image is untouched.
expect((await postImageJson(path, {}, { ImageName, Accessibility: 0 })).status).toBe(401)
expect(
(await postImageJson(path, await bearer('43'), { ImageName, Accessibility: 0 })).status
).toBe(403)
expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(1)
// Unknown image → 404; a bad accessibility or no name → 400.
const owner = await bearer('42')
expect(
(await postImageJson(path, owner, { ImageName: 'sharecamera/nope.jpg', Accessibility: 0 }))
.status
).toBe(404)
expect((await postImageJson(path, owner, { ImageName, Accessibility: 7 })).status).toBe(400)
expect((await postImageJson(path, owner, { Accessibility: 0 })).status).toBe(400)
// Owner → 200 and the record is now private; back to 1 makes it public again.
const res = await postImageJson(path, owner, { ImageName, Accessibility: 0 })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(0)
expect((await postImageJson(path, owner, { ImageName, Accessibility: 1 })).status).toBe(200)
expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(1)
// A locked image refuses even its owner.
const locked = 'sharecamera/2026-09-13/locked.jpg'
await seedOwnedImage(8102, locked, { AccessibilityLocked: true, Accessibility: 0 })
expect((await postImageJson(path, owner, { ImageName: locked, Accessibility: 1 })).status).toBe(
403
)
expect((await getImageByName(env.DB, locked))!.Accessibility).toBe(0)
})
test('POST /api/images/v1/modifydescription sets the owner’s caption and gates on the owner', async () => {
const ImageName = 'sharecamera/2026-09-13/caption-me.jpg'
await seedOwnedImage(8103, ImageName)
const path = '/api/images/v1/modifydescription'
// No token → 401; a different account → 403; the caption is untouched.
expect((await postImageJson(path, {}, { ImageName, Description: 'x' })).status).toBe(401)
expect(
(await postImageJson(path, await bearer('43'), { ImageName, Description: 'x' })).status
).toBe(403)
expect((await getImageByName(env.DB, ImageName))!.Description).toBeNull()
// Unknown image → 404; a non-string description or no name → 400.
const owner = await bearer('42')
expect(
(await postImageJson(path, owner, { ImageName: 'sharecamera/nope.jpg', Description: 'x' }))
.status
).toBe(404)
expect((await postImageJson(path, owner, { ImageName, Description: 5 })).status).toBe(400)
expect((await postImageJson(path, owner, { Description: 'x' })).status).toBe(400)
// Owner → 200 and the caption is stored; the rest of the record is untouched.
const res = await postImageJson(path, owner, { ImageName, Description: 'tet' })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
const after = await getImageByName(env.DB, ImageName)
expect(after!.Description).toBe('tet')
expect(after!.Accessibility).toBe(1)
expect(after!.PlayerId).toBe(42)
// An empty description clears the caption back to null.
expect((await postImageJson(path, owner, { ImageName, Description: '' })).status).toBe(200)
expect((await getImageByName(env.DB, ImageName))!.Description).toBeNull()
})
test('POST /api/images/v4/uploadsaved 401s without a bearer token', async () => {
const fd = new FormData()
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' }))
@@ -7733,10 +5515,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
@@ -7788,44 +5566,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])
})
})
@@ -8089,11 +5829,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,
@@ -8105,34 +5842,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 () => {
@@ -8140,13 +5849,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([
@@ -8193,10 +5897,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)
})
})
@@ -8294,17 +5996,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[]> =>
@@ -8322,60 +6014,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,
@@ -8415,17 +6073,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 () => {
@@ -8434,7 +6093,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 () => {
@@ -8452,66 +6111,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', () => {
@@ -8887,10 +6499,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')
@@ -8899,7 +6511,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.
@@ -8919,18 +6531,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.
@@ -8943,8 +6553,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 () => {
@@ -9130,7 +6740,9 @@ describe('player events', () => {
upcoming.PlayerEventId,
])
// The same base projection the POST serves: one path, one shape.
expect(events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)).toEqual(asBase(upcoming))
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([])
@@ -9162,69 +6774,6 @@ describe('player events', () => {
expect(await search('?skip=1&take=1')).toEqual([all[1]])
})
test('GET /api/playerevents/v1/search?sort=Attendance ranks by Going replies', async () => {
const search = async (qs: string): Promise<PlayerEvent[]> =>
(await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[]
const respond = (id: number, type: number, sub: string): Promise<Response> =>
post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: type }, sub)
// Three events sharing a searchable word, created soonest-first so the default
// order is the OPPOSITE of the attendance order — the sort has to do something.
const quiet = await create({ RoomId: 3, Name: 'Sortable Quiet', StartTime: at(2 * HOUR) })
const busy = await create({ RoomId: 3, Name: 'Sortable Busy', StartTime: at(3 * HOUR) })
const packed = await create({ RoomId: 3, Name: 'Sortable Packed', StartTime: at(4 * HOUR) })
// Every creator is Going from create (1 each). `busy` gains one more Going; `packed`
// gains two. Interested and Can't-go on `quiet` don't move it: only a Yes counts.
for (const [id, type, sub] of [
[busy.PlayerEventId, 0, '43'],
[packed.PlayerEventId, 0, '43'],
[packed.PlayerEventId, 0, '44'],
[quiet.PlayerEventId, 1, '43'],
[quiet.PlayerEventId, 1, '44'],
[quiet.PlayerEventId, 2, '45'],
] as const) {
expect((await respond(id, type, sub)).status).toBe(200)
}
// Default and `StartTime`: soonest first.
const soonest = [quiet.PlayerEventId, busy.PlayerEventId, packed.PlayerEventId]
expect((await search('?query=sortable')).map((e) => e.PlayerEventId)).toEqual(soonest)
expect((await search('?query=sortable&sort=StartTime')).map((e) => e.PlayerEventId)).toEqual(
soonest
)
// `Attendance`: most Going first (3, 2, 1), case-insensitively.
const byAttendance = await search('?query=sortable&sort=Attendance')
expect(byAttendance.map((e) => e.PlayerEventId)).toEqual([
packed.PlayerEventId,
busy.PlayerEventId,
quiet.PlayerEventId,
])
expect(byAttendance.map((e) => e.AttendeeCount)).toEqual([3, 2, 1])
expect((await search('?query=sortable&sort=attendance')).map((e) => e.PlayerEventId)).toEqual(
byAttendance.map((e) => e.PlayerEventId)
)
// A tie in attendance falls back to soonest first: another 1-Going event, later
// than `quiet`, lands after it.
const late = await create({ RoomId: 3, Name: 'Sortable Late', StartTime: at(5 * HOUR) })
expect((await search('?query=sortable&sort=Attendance')).map((e) => e.PlayerEventId)).toEqual([
packed.PlayerEventId,
busy.PlayerEventId,
quiet.PlayerEventId,
late.PlayerEventId,
])
// Paging walks the sorted order, and an unknown sort is the default, not a 400.
expect(await search('?query=sortable&sort=Attendance&skip=1&take=1')).toEqual([byAttendance[1]])
const unknown = await get('/api/playerevents/v1/search?query=sortable&sort=Popularity')
expect(unknown.status).toBe(200)
expect(((await unknown.json()) as PlayerEvent[]).map((e) => e.PlayerEventId)).toEqual([
...soonest,
late.PlayerEventId,
])
})
test('GET /api/playerevents/v1 serves the browse feed as listings', async () => {
const res = await get('/api/playerevents/v1')
expect(res.status).toBe(200)
@@ -9445,77 +6994,6 @@ describe('player events', () => {
expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId])
})
test('GET /api/playerevents/v1/all/:playerId serves a player’s PUBLIC events and Going RSVPs', async () => {
const respond = (id: number, type: number, sub: string): Promise<Response> =>
post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: type }, sub)
interface Profile {
Created: PlayerEvent[]
Responses: Array<{
PlayerEvent: Record<string, unknown>
PlayerEventResponse: Record<string, unknown>
}>
}
const profile = async (playerId: number | string): Promise<Profile> => {
// No token: the read is public.
const res = await get(`/api/playerevents/v1/all/${playerId}`)
expect(res.status).toBe(200)
return (await res.json()) as Profile
}
// 610 hosts a public event, a private one and a finished one; 611 is Going to the
// first two, Interested in a third public one, and went to the finished one.
const open = await create({ RoomId: 3, Name: 'Profile Open', StartTime: at(2 * HOUR) }, '610')
const closed = await create(
{ RoomId: 3, Name: 'Profile Closed', StartTime: at(3 * HOUR), Accessibility: 0 },
'610'
)
const maybe = await create({ RoomId: 3, Name: 'Profile Maybe', StartTime: at(HOUR) }, '610')
const over = await create(
{ RoomId: 3, Name: 'Profile Over', StartTime: at(-3 * HOUR), EndTime: at(-2 * HOUR) },
'610'
)
await respond(open.PlayerEventId, 0, '611')
await respond(closed.PlayerEventId, 0, '611')
await respond(maybe.PlayerEventId, 1, '611')
await respond(over.PlayerEventId, 0, '611')
// The attendee: exactly the public event they are Going to, as the pair.
const guest = await profile(611)
expect(guest.Created).toEqual([])
expect(guest.Responses).toHaveLength(1)
const [entry] = guest.Responses
expect(Object.keys(entry).sort()).toEqual(['PlayerEvent', 'PlayerEventResponse'])
expect(entry.PlayerEventResponse).toEqual({
PlayerEventResponseId: expect.any(Number),
PlayerEventId: open.PlayerEventId,
PlayerId: 611,
CreatedAt: expect.any(String),
Type: 0,
})
// The 17-key base event: no `State`, `ImageName` a string, the broadcast key present.
expect(Object.keys(entry.PlayerEvent)).toHaveLength(17)
expect(entry.PlayerEvent).toMatchObject({
PlayerEventId: open.PlayerEventId,
Name: 'Profile Open',
AttendeeCount: 2,
Accessibility: 1,
ImageName: '',
BroadcastingRoomInstanceId: null,
})
expect(entry.PlayerEvent).not.toHaveProperty('State')
// The host: their public unfinished events, soonest first, under both keys.
const host = await profile(610)
const ids = [maybe.PlayerEventId, open.PlayerEventId]
expect(host.Created.map((e) => e.PlayerEventId)).toEqual(ids)
expect(Object.keys(host.Created[0])).toHaveLength(17)
expect(host.Responses.map((r) => r.PlayerEvent.PlayerEventId)).toEqual(ids)
// Nobody's events, and a non-numeric id doesn't match.
expect(await profile(999999)).toEqual({ Created: [], Responses: [] })
expect((await get('/api/playerevents/v1/all/abc')).status).toBe(404)
})
test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => {
const respond = async (body: unknown, sub = '42'): Promise<Response> =>
post('/api/playerevents/v1/respond', body, sub)
@@ -9671,7 +7149,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
@@ -9687,9 +7165,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
@@ -9698,10 +7175,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.
@@ -9710,23 +7187,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 () => {
@@ -9744,57 +7212,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({
@@ -9804,106 +7232,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 () => {
@@ -10170,9 +7498,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'] })
@@ -10228,8 +7558,6 @@ describe('player events', () => {
expect((await putForm(path, { name: 'n'.repeat(64) })).status).toBe(200)
})
// One create, fourteen gated calls, and a read. The default 5s budget is what
// this crossed on CI; the checks themselves are the same creator gate as above.
test('the single-field edits are creator-only, like the whole-event update', async () => {
const event = await create({ RoomId: 5, Name: 'Guarded' })
const id = event.PlayerEventId
@@ -10250,7 +7578,7 @@ describe('player events', () => {
// Nothing moved.
expect(await (await get(`/api/playerevents/v1/${id}`)).json()).toEqual(asRecord(event, null))
}, 20_000)
})
})
describe('openapi', () => {
@@ -10278,6 +7606,7 @@ 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',
@@ -10309,7 +7638,6 @@ describe('openapi', () => {
'GET /api/images/v5/cheered/bulk',
'GET /api/images/v5/player/{playerId}',
'GET /api/images/v6',
'GET /api/images/v6/{id}',
'GET /api/inventions/v1',
'GET /api/inventions/v1/details',
'GET /api/inventions/v1/featured',
@@ -10335,7 +7663,6 @@ describe('openapi', () => {
'GET /api/playerReputation/v2/bulk',
'GET /api/playerevents/v1',
'GET /api/playerevents/v1/all',
'GET /api/playerevents/v1/all/{playerId}',
'GET /api/playerevents/v1/bulk',
'GET /api/playerevents/v1/club/{clubId}',
'GET /api/playerevents/v1/clubs',
@@ -10380,22 +7707,16 @@ describe('openapi', () => {
'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/v1/deletesaved',
'POST /api/images/v1/modifydescription',
'POST /api/images/v1/{id}/report',
'POST /api/images/v2/modifyaccessibility',
'POST /api/images/v4/uploadsaved',
'POST /api/images/v5/cheered/bulk',
'POST /api/inventions/v1/cheer',
@@ -10403,10 +7724,7 @@ describe('openapi', () => {
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/update',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v2/cheer',
'POST /api/inventions/v2/delete',
'POST /api/inventions/v2/unpublish',
'POST /api/inventions/v2/update',
'POST /api/inventions/v4/publish',
'POST /api/inventions/v6/save',
'POST /api/inventions/v9/save',
-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
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"OutfitSelections": "1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,0;d0a9262f-5504-46a7-bb10-7507503db58e,,1",
"FaceFeatures": "{\"ver\":-1,\"eyeId\":\"AjGMoJhEcEehacRZjUMuDg\",\"eyePos\":{\"x\":0.0,\"y\":0.0},\"eyeScl\":0.0,\"mouthId\":\"FrZBRanXEEK29yKJ4jiMjg\",\"mouthPos\":{\"x\":0.0,\"y\":0.0},\"mouthScl\":0.0,\"hairPrimaryColorId\":\"\",\"hairSecondaryColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"hairPatternId\":\"\",\"beardColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"beardSecondaryColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"beardPatternId\":\"\",\"faceShapeId\":\"yR4oYZr_AUSynXCgwS2lGw\",\"bodyShapeId\":\"bY1RGIph0kiAxbd6Shn9tQ\",\"useHatAnchorParams\":true,\"useHelmetHair\":1,\"hideEars\":true,\"hatAnchorParams\":{\"NormalizedPosition\":{\"x\":0.5,\"y\":0.5},\"HemisphereOffsets\":{\"x\":0.0,\"y\":0.0,\"z\":0.0},\"HemisphereRotations\":{\"x\":0.0,\"y\":0.0,\"z\":0.0}},\"baseAvatarType\":\"\"}",
"SkinColor": "Xac-W_R330KfOz-pQla9qg",
"HairColor": "0e_jaaObREWTf1AorAZ95g"
}
+2582
View File
@@ -0,0 +1,2582 @@
[
{
"AvatarItemDesc": "0088603e-ec3b-4478-8694-e6fb1989b3f2,,,",
"AvatarItemId": 620,
"AvatarItemType": 0,
"FriendlyName": "Angled Bob Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "04e348b7-107f-47b4-9331-ae3b9fa184ca,,,",
"AvatarItemId": 1980,
"AvatarItemType": 0,
"FriendlyName": "Braided Bun Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,,,",
"AvatarItemId": 1004,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,cypMpMaVeUuRjm4FLH4czQ,PIjEKhXiCUqtA8FBhLBcmA,",
"AvatarItemId": 1883,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Teal)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,LTXy6g4xe0abuFzOLpVDUg,PIjEKhXiCUqtA8FBhLBcmA,",
"AvatarItemId": 1881,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Dark Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,tMsCUhDslkmdNBfogeSz7w,PIjEKhXiCUqtA8FBhLBcmA,",
"AvatarItemId": 1884,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,whWKAUSGp0qprsJomq9Nwg,PIjEKhXiCUqtA8FBhLBcmA,",
"AvatarItemId": 1882,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,",
"AvatarItemId": 2337,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Beige)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,_T4zEki_wk-UIPdhSaBr_Q",
"AvatarItemId": 2341,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,5z3jZhYvq0CGmdJ9IcUUsg",
"AvatarItemId": 2340,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Brown)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,EgEoJzG7A0aJ92qfInbX7g",
"AvatarItemId": 2342,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,NETGR8iysUyxcZM_oGmrwQ",
"AvatarItemId": 2344,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,OlfQDuz1NUOr88AL2n0jXg",
"AvatarItemId": 2343,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,VudVdOAjZUibHmtFeNkvSw",
"AvatarItemId": 2339,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "0753d7a4-8247-4fca-a6fc-359c26086140,,,",
"AvatarItemId": 993,
"AvatarItemType": 0,
"FriendlyName": "Fonzie Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "09177621-9ecd-4f6a-b6a5-64490139141d,,,",
"AvatarItemId": 81,
"AvatarItemType": 0,
"FriendlyName": "Flat Top Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "0abb6b08-20ce-444f-879e-0d1344df096c,,,",
"AvatarItemId": 1620,
"AvatarItemType": 0,
"FriendlyName": "Round Earrings",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "0d1ae26e-aaa4-4914-ad70-c9ab8cfabc61,,,",
"AvatarItemId": 1979,
"AvatarItemType": 0,
"FriendlyName": "Box Braids with Cuff Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,,,",
"AvatarItemId": 1740,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,6fmUy748hU2dgwgaijSkUA,rL9p5JY83Eyxj00Dieg7oA,",
"AvatarItemId": 1739,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,buuNFd8sK0eap78TL1gMNQ,-UFHTNvJs0CGDStb0PFkbQ,",
"AvatarItemId": 1741,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,dBR1uNgk206eVoWntVzVAg,rL9p5JY83Eyxj00Dieg7oA,",
"AvatarItemId": 1738,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Brown)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,LIgagNf4gk68EYQdlDruxg,rL9p5JY83Eyxj00Dieg7oA,",
"AvatarItemId": 1736,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,zPGCmxM_b0yTt7tqMaomcw,rL9p5JY83Eyxj00Dieg7oA,",
"AvatarItemId": 1737,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Tan)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,,,",
"AvatarItemId": 25,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 983,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 29,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Light Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 31,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 30,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,724c79a3-822c-422f-9462-a5374ee0211c,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 26,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (White))",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 669,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 670,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "193a3bf9-abc0-4d78-8d63-92046908b1c5,,,",
"AvatarItemId": 991,
"AvatarItemType": 0,
"FriendlyName": "Emo Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "1940c740-695d-4481-a802-bb11b6561635,",
"AvatarItemId": 2116,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Untucked Tee",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "1a71064b-794f-40fa-9109-8ad36602b6e1,,,",
"AvatarItemId": 95,
"AvatarItemType": 0,
"FriendlyName": "Shagg Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "1d27b674-f9e2-4ffc-9d8c-a58a1be06457,,,",
"AvatarItemId": 986,
"AvatarItemType": 0,
"FriendlyName": "Afro Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,,",
"AvatarItemId": 998,
"AvatarItemType": 0,
"FriendlyName": "Ponytail Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "21599b51-c50f-43d8-ac5f-62c30cd02ca5,,,",
"AvatarItemId": 994,
"AvatarItemType": 0,
"FriendlyName": "Lori Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,,,",
"AvatarItemId": 1021,
"AvatarItemType": 0,
"FriendlyName": "Tennis Skirt (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,758752bd-db2f-43d2-b580-55b3e1efffd5,b75ef67d-00c3-4ac1-9b72-212032460294,",
"AvatarItemId": 682,
"AvatarItemType": 0,
"FriendlyName": "Tennis Skirt (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,c5deba2a-6e35-4b13-8e94-8ba5457f39df,b75ef67d-00c3-4ac1-9b72-212032460294,",
"AvatarItemId": 681,
"AvatarItemType": 0,
"FriendlyName": "Tennis Skirt (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,,,",
"AvatarItemId": 1022,
"AvatarItemType": 0,
"FriendlyName": "Zipper Dress (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,6d703981-2734-4c45-8983-cdd5f328902f,cfabdefe-0890-436e-b2a3-b5c712e22955,",
"AvatarItemId": 255,
"AvatarItemType": 0,
"FriendlyName": "Zipper Dress (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,830be2fa-60a5-48cc-931f-34b670eae4bd,cfabdefe-0890-436e-b2a3-b5c712e22955,",
"AvatarItemId": 254,
"AvatarItemType": 0,
"FriendlyName": "Zipper Dress (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a,cfabdefe-0890-436e-b2a3-b5c712e22955,",
"AvatarItemId": 253,
"AvatarItemType": 0,
"FriendlyName": "Zipper Dress (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,",
"AvatarItemId": 2345,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Turquoise)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,0p_eXn2gXUKrshSEmrQx6Q",
"AvatarItemId": 2350,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,3cPbhpDX-EqDQy6eN1E5Ug",
"AvatarItemId": 2346,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,fAmEEm6ziUO2ZXTscQWtSg",
"AvatarItemId": 2351,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,gEuE-X67b0-q5C0Tv11_nA",
"AvatarItemId": 2348,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,gk_mQ-jsakmxhZYYNlRl_w",
"AvatarItemId": 2349,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "241506f6-bf88-4b46-b5fe-513a225421f4,,,",
"AvatarItemId": 417,
"AvatarItemType": 0,
"FriendlyName": "Half Up Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "24a240f4-1574-420b-b898-a7e91f170759,,,",
"AvatarItemId": 582,
"AvatarItemType": 0,
"FriendlyName": "Back Bun Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2822aa50-4344-434d-b46f-fd9443f865b9,,,",
"AvatarItemId": 1981,
"AvatarItemType": 0,
"FriendlyName": "Curly Bob Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2cb4f372-3372-4583-8b57-c4e3988e3c28,,,",
"AvatarItemId": 999,
"AvatarItemType": 0,
"FriendlyName": "Punky Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,,,",
"AvatarItemId": 1009,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Plaid, Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,0iSsaY-HgkmLaRHCn5vEdw,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1014,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,55901f12-d5b5-4fa8-b4c8-e479689ee39d,f600037d-c9c0-43fa-b45b-02f456f9dd5f,",
"AvatarItemId": 674,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Denim)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,bf82f2f6-9af8-431e-a296-0890dea48ba7,d015cae7-a905-49e4-8823-6dec069689a6,",
"AvatarItemId": 672,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Argyle)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,EfdMcnfHt0mr0PQ_maaYOg,DRJcNhkqvkKFEaZpOguR6w,",
"AvatarItemId": 1016,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Flowers, Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,FAviMCQ_EE2Mpt6QPo5OEw,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1015,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,jGj28vhq8EGwP2RuM074aQ,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1012,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,kmj5zOjcwku_WWKroCeiVQ,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1011,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,MFrcSQ1DYUm8imvy4ypgvw,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1013,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e7cbdb0-cf22-4938-964f-acca57d221b7,",
"AvatarItemId": 2878,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE Earmold - Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e7cbdb0-cf22-4938-964f-acca57d221b7,aEsXeTI_SEOclbm82n17tg",
"AvatarItemId": 2880,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE Earmold - Gray)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e7cbdb0-cf22-4938-964f-acca57d221b7,pZDvYABTN06KdJ5Ck60XLA",
"AvatarItemId": 2879,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE Earmold - Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "36b2ccf4-4f42-4dc4-aeaf-f3d10b2a6d30,,,",
"AvatarItemId": 1982,
"AvatarItemType": 0,
"FriendlyName": "Etched Fade Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "37f7b94a-92ba-4f5e-b94c-de00acfa4722,,,",
"AvatarItemId": 1983,
"AvatarItemType": 0,
"FriendlyName": "Etched Flattop Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,,,",
"AvatarItemId": 6,
"AvatarItemType": 0,
"FriendlyName": "Headband (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 11,
"AvatarItemType": 0,
"FriendlyName": "Headband (Grey)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 663,
"AvatarItemType": 0,
"FriendlyName": "Headband (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 12,
"AvatarItemType": 0,
"FriendlyName": "Headband (Light Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,018a5c07-e956-457d-a540-a5e2cd68da09,",
"AvatarItemId": 667,
"AvatarItemType": 0,
"FriendlyName": "Headband (Orange, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 13,
"AvatarItemType": 0,
"FriendlyName": "Headband (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 16,
"AvatarItemType": 0,
"FriendlyName": "Headband (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,6dd95046-acf8-42fe-ab78-80a334096a9d,56a92c8d-af53-413e-929e-4a9a3cfad780,",
"AvatarItemId": 668,
"AvatarItemType": 0,
"FriendlyName": "Headband (Red, White, Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 664,
"AvatarItemType": 0,
"FriendlyName": "Headband (Cream)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,018a5c07-e956-457d-a540-a5e2cd68da09,",
"AvatarItemId": 20,
"AvatarItemType": 0,
"FriendlyName": "Headband (Red, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 7,
"AvatarItemType": 0,
"FriendlyName": "Headband (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,018a5c07-e956-457d-a540-a5e2cd68da09,",
"AvatarItemId": 666,
"AvatarItemType": 0,
"FriendlyName": "Headband (Pink, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 14,
"AvatarItemType": 0,
"FriendlyName": "Headband (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,018a5c07-e956-457d-a540-a5e2cd68da09,",
"AvatarItemId": 665,
"AvatarItemType": 0,
"FriendlyName": "Headband (Blue, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 15,
"AvatarItemType": 0,
"FriendlyName": "Headband (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 10,
"AvatarItemType": 0,
"FriendlyName": "Headband (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "45eaab67-19c2-4601-8f80-3565a4dceba4,,,",
"AvatarItemId": 1199,
"AvatarItemType": 0,
"FriendlyName": "Pompadour Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "45f5e714-8a5f-4385-a97f-675066167011,,,",
"AvatarItemId": 112,
"AvatarItemType": 0,
"FriendlyName": "Seventies Stache",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "48fdfbc6-fca4-424c-aa57-3fcea41e23dc,,,",
"AvatarItemId": 1742,
"AvatarItemType": 0,
"FriendlyName": "Leather Necklace (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "48fdfbc6-fca4-424c-aa57-3fcea41e23dc,gsHBUqA1gE2XNTPA3VvBLQ,FwjsTo0nb0uNU6l8XvllNA,",
"AvatarItemId": 1745,
"AvatarItemType": 0,
"FriendlyName": "Leather Necklace (Brown)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "48fdfbc6-fca4-424c-aa57-3fcea41e23dc,JUQn0_PLIkytCrkAg0FflQ,FwjsTo0nb0uNU6l8XvllNA,",
"AvatarItemId": 1744,
"AvatarItemType": 0,
"FriendlyName": "Leather Necklace (Tan)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "4d507dfa-4a99-4ac0-8537-229e9dc0eb4a,,,",
"AvatarItemId": 1017,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Tank Top (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "4eaaad39-aa44-4791-8b67-2eafa8305850,,,",
"AvatarItemId": 1977,
"AvatarItemType": 0,
"FriendlyName": "Afro Puffs Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "5beeb4c4-f276-4eae-87aa-9302e45b05b7,,,",
"AvatarItemId": 1480,
"AvatarItemType": 0,
"FriendlyName": "Cornrows Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "5cd08cfb-c729-4c30-96d9-6a99bb934d91,,,",
"AvatarItemId": 124,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Sash",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,,,",
"AvatarItemId": 149,
"AvatarItemType": 0,
"FriendlyName": "Slim Blazer (Teal)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,0f36bb97-c61b-4281-929f-ff1d0d11be86,",
"AvatarItemId": 468,
"AvatarItemType": 0,
"FriendlyName": "Slim Blazer (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,cd5d7285-202d-42d0-b93f-04245875793e,0f36bb97-c61b-4281-929f-ff1d0d11be86,",
"AvatarItemId": 469,
"AvatarItemType": 0,
"FriendlyName": "Slim Blazer (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,,,",
"AvatarItemId": 131,
"AvatarItemType": 0,
"FriendlyName": "Long Scarf (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
"AvatarItemId": 461,
"AvatarItemType": 0,
"FriendlyName": "Long Scarf (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,6dd95046-acf8-42fe-ab78-80a334096a9d,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
"AvatarItemId": 460,
"AvatarItemType": 0,
"FriendlyName": "Long Scarf (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,d6edbc00-3c1d-4f49-8412-3ef8c7c5f4c2,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
"AvatarItemId": 459,
"AvatarItemType": 0,
"FriendlyName": "Long Scarf (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,,,",
"AvatarItemId": 193,
"AvatarItemType": 0,
"FriendlyName": "Collared Dress (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,6564acf1-4d70-4f92-92ac-08e2b76dbb6b,2c8924aa-68f8-4912-9759-18992f72f08a,",
"AvatarItemId": 196,
"AvatarItemType": 0,
"FriendlyName": "Collared Dress (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,d66aa400-aa5a-4539-a25d-5f8ce94dc281,2c8924aa-68f8-4912-9759-18992f72f08a,",
"AvatarItemId": 195,
"AvatarItemType": 0,
"FriendlyName": "Collared Dress (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,f750de46-3758-4f7d-9709-0a84b1027009,2c8924aa-68f8-4912-9759-18992f72f08a,",
"AvatarItemId": 194,
"AvatarItemType": 0,
"FriendlyName": "Collared Dress (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6e48363d-cc62-40a8-87f9-a69fae90c002,,,",
"AvatarItemId": 1978,
"AvatarItemType": 0,
"FriendlyName": "Box Braid Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,,,",
"AvatarItemId": 1024,
"AvatarItemType": 0,
"FriendlyName": "Wrist Tape (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 297,
"AvatarItemType": 0,
"FriendlyName": "Wrist Tape (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 296,
"AvatarItemType": 0,
"FriendlyName": "Wrist Tape (Cream)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "77d3c585-4928-4471-a425-89036efe7299,,,",
"AvatarItemId": 1001,
"AvatarItemType": 0,
"FriendlyName": "Spiky Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "79b90274-6eec-4664-acfb-4a123334661e,,,",
"AvatarItemId": 997,
"AvatarItemType": 0,
"FriendlyName": "Pig Tails Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,,,",
"AvatarItemId": 1020,
"AvatarItemType": 0,
"FriendlyName": "T-Shirt",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 230,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,48abd952-214f-48b2-a8f1-1146f6f69aa2,b78008e8-abbd-4ece-be34-9a911f721fcc,",
"AvatarItemId": 677,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Zebra)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 227,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,9c03f381-7357-4d0f-8cda-8737d4c43d25,",
"AvatarItemId": 679,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Rainbow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,6d703981-2734-4c45-8983-cdd5f328902f,a0271cd0-e172-4d3f-aa2f-9806f21a82d2,",
"AvatarItemId": 676,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Camo)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 228,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,b292eb4b-07e3-4a48-99b5-3c6587a1e02e,",
"AvatarItemId": 678,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Dots)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 229,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7dd6f7b0-7ba0-429f-a04f-e32d3a79ee61,,,",
"AvatarItemId": 585,
"AvatarItemType": 0,
"FriendlyName": "Short Wavy Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "849cabc9-db40-4448-b923-defe4e700072",
"AvatarItemId": 5954,
"AvatarItemType": 0,
"FriendlyName": "Bride of Frank Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,,,",
"AvatarItemId": 198,
"AvatarItemType": 0,
"FriendlyName": "Stoll Dress (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,64850553-cdfe-455a-ac00-dafbe63d613e,91a451c1-b285-4c48-b14d-59ded8cc006f,",
"AvatarItemId": 199,
"AvatarItemType": 0,
"FriendlyName": "Stoll Dress (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,761a3193-60f0-4190-80c7-285b8192e794,91a451c1-b285-4c48-b14d-59ded8cc006f,",
"AvatarItemId": 200,
"AvatarItemType": 0,
"FriendlyName": "Stoll Dress (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,a819f49b-6c7a-49d3-9e6a-d9d79ef5019f,91a451c1-b285-4c48-b14d-59ded8cc006f,",
"AvatarItemId": 201,
"AvatarItemType": 0,
"FriendlyName": "Stoll Dress (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "880a3cc0-7407-4b61-b759-f9dd890fe9e5,,,",
"AvatarItemId": 987,
"AvatarItemType": 0,
"FriendlyName": "Bob Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "88b6ddeb-a455-460d-91d9-a4569ef6903c,,,",
"AvatarItemId": 1621,
"AvatarItemType": 0,
"FriendlyName": "Square Earrings ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8aa79563-ace1-4ba7-ad0c-f3210a78142f,,,",
"AvatarItemId": 1018,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Shirt (V-Neck, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8aa79563-ace1-4ba7-ad0c-f3210a78142f,95e4cc30-cb68-473d-a395-feadf5b51512,05f0ee6e-c824-470e-9178-5ed576c6fe0c,",
"AvatarItemId": 675,
"AvatarItemType": 0,
"FriendlyName": "Rec Room T-Shirt (V-Neck, Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8b9f1413-e786-4a30-946c-9292f207875a,,,",
"AvatarItemId": 91,
"AvatarItemType": 0,
"FriendlyName": "Pulp Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8c35c804-e8d5-49d2-8d5a-ea19fb70bfa6,,,",
"AvatarItemId": 1478,
"AvatarItemType": 0,
"FriendlyName": "Pencil Bun Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8d10cc78-6b00-45f3-affb-205e9cc5b03f,,,",
"AvatarItemId": 1006,
"AvatarItemType": 0,
"FriendlyName": "Beard (Close)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "92302d9d-c527-418c-ac5d-1fa869727505,,,",
"AvatarItemId": 996,
"AvatarItemType": 0,
"FriendlyName": "Part Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "95ab7a7c-c35d-4da5-9955-0921064470b6,,,",
"AvatarItemId": 83,
"AvatarItemType": 0,
"FriendlyName": "Gekko Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9b5bde11-7408-4798-9fcb-c7ec175444df,,,",
"AvatarItemId": 1619,
"AvatarItemType": 0,
"FriendlyName": "Hoop Earrings",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9bf5d259-7774-4cbe-a90f-7f188cc0dce7,,,",
"AvatarItemId": 361,
"AvatarItemType": 0,
"FriendlyName": "Thick Goatee",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9bf75b23-84e9-436b-bbb8-4180ccd86b94,,,",
"AvatarItemId": 1985,
"AvatarItemType": 0,
"FriendlyName": "Wolf Cut Hair) ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c20fdd5-acf1-426c-b85a-2220ceee9472,,,",
"AvatarItemId": 1987,
"AvatarItemType": 0,
"FriendlyName": "Short Curly Twists Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,,,",
"AvatarItemId": 150,
"AvatarItemType": 0,
"FriendlyName": "Button Top (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,49f5864f-9d40-497c-88c8-e87f64d41d74,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
"AvatarItemId": 151,
"AvatarItemType": 0,
"FriendlyName": "Button Top (Tan)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,c5deba2a-6e35-4b13-8e94-8ba5457f39df,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
"AvatarItemId": 153,
"AvatarItemType": 0,
"FriendlyName": "Button Top (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,e0397982-c2c2-4733-9a40-46e18675b5af,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
"AvatarItemId": 152,
"AvatarItemType": 0,
"FriendlyName": "Button Top (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9d9fadb6-97eb-480e-a224-4e0179082071,,,",
"AvatarItemId": 995,
"AvatarItemType": 0,
"FriendlyName": "Meatball Buns Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,",
"AvatarItemId": 2328,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,hgLq0WyUAUSl2sboUrpTKQ",
"AvatarItemId": 2330,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,JzLPlVNG20G9mxG_4PF8Fg",
"AvatarItemId": 2332,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (Navy)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,M5uFQQpoQE-5CKMJnDv_LA",
"AvatarItemId": 2331,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,VuMDnpnK50mzgUMwJ4F6Jw",
"AvatarItemId": 2333,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,",
"AvatarItemId": 2359,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,0zldWzi4H0OQwsWPt6gl-w",
"AvatarItemId": 2364,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,a0A1q_e1q06qquiu2KaZhA",
"AvatarItemId": 2361,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,M_BhFve7SkC7IJoymLiCLg",
"AvatarItemId": 2363,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,rwESUxIrQEKA2hdQpcaPaA",
"AvatarItemId": 2362,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a12f724f-4a73-4ab8-aad4-6bfc662b4dd6,,,",
"AvatarItemId": 1002,
"AvatarItemType": 0,
"FriendlyName": "Undercut Long Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a52353bf-8693-45fb-a04a-19cf62a06482",
"AvatarItemId": 5955,
"AvatarItemType": 0,
"FriendlyName": "Curlers Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a6cbfe76-534a-4655-a8a8-3fed13d001c7,,,",
"AvatarItemId": 726,
"AvatarItemType": 0,
"FriendlyName": "Bald Top Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b148cb1e-df81-442f-aea6-ab1727aad00e,,,",
"AvatarItemId": 990,
"AvatarItemType": 0,
"FriendlyName": "Chunky Afro Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,,,",
"AvatarItemId": 1772,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Black) ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,Aex0Zh1RQ0u9oTDFF1cFIg,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1781,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,El0s9F6cfkywcKOkqwbJtQ,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1779,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Grey)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,J9cK9BKtHUinG-DwAoPi3g,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1774,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Dreen)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,N0fBxjrCekO-HUqmc3MufQ,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1775,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,oNoEl-nDqk-HLsfz1538oA,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1776,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,pQ_3mZI7yEaxMw9n_Fr4Sg,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1778,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,TpFWTdEW2kqtpvc0sPv-dw,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1777,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,-VF3K4miGE28LxMgqVehRQ,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1773,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,vxZVeH2b_E6XlqyqCW0hBA,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1780,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,3a1oW3_MY0e4lDN2V1X4ig,za8Mz14Ww0KtC6wavUPXdg,",
"AvatarItemId": 1771,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Nonbinary Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,9jB8U3yxjE6hanEZgMdvtA",
"AvatarItemId": 2202,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - AAPI (AAPI Heritage Month)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,D_Xmo0rOzkS-kgq1CYXt3g,tnCJp2eDI0SwjVfJMhk3LQ,",
"AvatarItemId": 1458,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Trans Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,DIeasJUIj0KNetAzPzuIrg,o3Q2gADhh06dgaipkkdMsA,",
"AvatarItemId": 1757,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Bi Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,EkA09Hj2m0u3c5STynn3Bw",
"AvatarItemId": 2887,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - ANAM (Autism & Neurodiversity Awareness Month)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,G6Udm6kMdE28vmRY5KPanA,Fw79pqDPP0C8H07AnTY-Vg,",
"AvatarItemId": 1756,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Lesbian Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,MfF0wizDjUa7RUE-AVJpvA",
"AvatarItemId": 2194,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Intersex Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,PIwziHk1BUuYkAf6kuK6wA",
"AvatarItemId": 2059,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - BHM (Black History Month)\t",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,PRLBrEEZhUumm3VnYVJP6Q",
"AvatarItemId": 2192,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Aromantic Pride)\t",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,sf962XJ0tkis0svkMkZCXQ,4bI7QRvdnEansPkwx8Di9A,",
"AvatarItemId": 1791,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Pan Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,sxUE0iOSZEmezm54T7xI3Q,tlpa7195x0CkmSjpR1RArQ,",
"AvatarItemId": 1457,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Rainbow Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,tm7Cnd3LzkqWHA1JMrgoeA,nbLr3c3lOkiWhNg9IYbt6Q,",
"AvatarItemId": 1770,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Ace Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,Xt3Ndm8mC0qsLClPIbgwUw",
"AvatarItemId": 2193,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Genderfluid Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b8e485d4-b5c2-480e-908c-d9b500375759,",
"AvatarItemId": 2881,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (Cochlear Implant - Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b8e485d4-b5c2-480e-908c-d9b500375759,7ZRNVpxhWk-j8R3oj0oVXg",
"AvatarItemId": 2883,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (Cochlear Implant - Gray)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b8e485d4-b5c2-480e-908c-d9b500375759,8WJI7RAuaEy4KvQiQhoKBw",
"AvatarItemId": 2882,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (Cochlear Implant - Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,",
"AvatarItemId": 2352,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Beige)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,irw2uj4c2Eil0ndPCTqodQ",
"AvatarItemId": 2355,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,pPZ3sDw3B06mKiYsAiAt9w",
"AvatarItemId": 2358,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,sO8e2epLrUud243Xi9odXg",
"AvatarItemId": 2357,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,s-QTVT5eTECKHL9FnHFCAQ",
"AvatarItemId": 2354,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,UnHAtXkd2kCdamzw6B3Mvg",
"AvatarItemId": 2356,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Turquoise)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c45ed7b8-99bd-4a4b-a9ff-e16edf5d7a18,,,",
"AvatarItemId": 583,
"AvatarItemType": 0,
"FriendlyName": "High Pony Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c6c08eb5-381a-4193-9722-80da95d62abe,,,",
"AvatarItemId": 1008,
"AvatarItemType": 0,
"FriendlyName": "Business Tie (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,,,",
"AvatarItemId": 1005,
"AvatarItemType": 0,
"FriendlyName": "Glasses (Teal)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,5H0I1anlkkuUYIM28_ZOlA,l_iJLV14q0eNgOsOI1u0pw,",
"AvatarItemId": 1879,
"AvatarItemType": 0,
"FriendlyName": "Glasses (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,9tZq7Xvfj0WhhLBAuAacxA,l_iJLV14q0eNgOsOI1u0pw,",
"AvatarItemId": 1878,
"AvatarItemType": 0,
"FriendlyName": "Glasses (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,BqNgpjP7a0qmm3I3DNbKLA,l_iJLV14q0eNgOsOI1u0pw,",
"AvatarItemId": 1880,
"AvatarItemType": 0,
"FriendlyName": "Glasses (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c855dcc3-96cb-470d-b159-d37a025a47d1,,,",
"AvatarItemId": 1200,
"AvatarItemType": 0,
"FriendlyName": "Dutch Braid Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "cc96f8a5-bc5b-4f89-83b7-ecd53905ada7,,,",
"AvatarItemId": 1007,
"AvatarItemType": 0,
"FriendlyName": "Beard (Thick)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "CTcrvbo3OEepIV4oW8bx4w,,,",
"AvatarItemId": 727,
"AvatarItemType": 0,
"FriendlyName": "Receding Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,,,",
"AvatarItemId": 1019,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Shirt (Crew Neck, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,941c046e-4e95-49f8-a7d7-19071fcc3c94,0440f08f-ef1d-49d8-942b-523056e8bb45,703ff56b-560d-4ff4-8c63-d195e879a328",
"AvatarItemId": 571,
"AvatarItemType": 0,
"FriendlyName": "Alpaca Shirt",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": -1,
"ToolTip": ""
},
{
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,",
"AvatarItemId": 1749,
"AvatarItemType": 0,
"FriendlyName": "Rec Room T-Shirt (Crew Neck, Orange)\t",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,0c496f32-0011-4c4d-9778-59446f70c032",
"AvatarItemId": 565,
"AvatarItemType": 0,
"FriendlyName": "Rec Room T-Shirt (Crew Neck, Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,3JrW3FFUvEe6HZ9VjOVjMw,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1971,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,jeEWPFvzaEGmXaUrxTFvwg,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1974,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,NjCLn5A5h0-V8jB72etCNQ,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1973,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,OT5APcpbpEiMNNah8n4Txw,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1972,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,t2fOeVWloUOKDlIaf4Sxnw,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1970,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d7730a9e-78a1-4356-bc09-6b066615850b,,,",
"AvatarItemId": 1477,
"AvatarItemType": 0,
"FriendlyName": "Afro Updo Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d8280c0c-d803-4513-be10-a0ba96d8821e,,,",
"AvatarItemId": 1000,
"AvatarItemType": 0,
"FriendlyName": "Flowhawk Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d84c0ff9-8fbe-4ed8-abf3-7996e81888ab,,,",
"AvatarItemId": 556,
"AvatarItemType": 0,
"FriendlyName": "Large Afro Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "da4e7b34-2095-4a9e-801e-4f409039e0dd,,,",
"AvatarItemId": 989,
"AvatarItemType": 0,
"FriendlyName": "Buzz Cut Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,,,",
"AvatarItemId": 143,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Blue, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,05ac07e1-67f0-486c-abf5-a62866475abb,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 144,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Black, Cream)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,0ffad843-d6c9-425a-8686-7217009c867e,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 148,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Green, Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,272fe8eb-5061-4729-a7a8-414ff667a82f,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 145,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Grey, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,6f2e74bf-1e95-463d-97db-d5d1a53b2c28,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 147,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Black, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,9374bf66-2ee5-493b-8439-efce4b201904,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 146,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Grey, Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "e286863c-2967-4d00-b837-b49487b9484a,,,",
"AvatarItemId": 992,
"AvatarItemType": 0,
"FriendlyName": "Fauxhawk Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "e36bcd98-7e85-43fa-89f8-57e4ec33823a,,,",
"AvatarItemId": 988,
"AvatarItemType": 0,
"FriendlyName": "Bob with Bangs Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "e5b83dfc-b2e1-4dcb-a4ab-9d3a4c8a34ae,,,",
"AvatarItemId": 584,
"AvatarItemType": 0,
"FriendlyName": "Long Wavy Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ea254491-116c-41c4-a9ca-1fa1fca76374,",
"AvatarItemId": 2875,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE - Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ea254491-116c-41c4-a9ca-1fa1fca76374,1n-ekJos-EeViiz_zG4jEg",
"AvatarItemId": 2876,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE - Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "eb9611c6-bb50-41a2-93e9-7f959815a846,,,",
"AvatarItemId": 587,
"AvatarItemType": 0,
"FriendlyName": "Dreads Long Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ec474314-fd79-441d-852b-d8999274fe57,,,",
"AvatarItemId": 1986,
"AvatarItemType": 0,
"FriendlyName": "Wavy Middle Part Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,,,",
"AvatarItemId": 1023,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 288,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Grey)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 684,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 289,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Light Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 685,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,5rLtRfNWO0qT7X-0sJkUXg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1859,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Lime)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 287,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Gold)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 292,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Cream)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 683,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Ai85jH88H06ze74VdWolfw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1863,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Pure Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 687,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 686,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Dg3DxHr8GU2e190TGveXqw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1858,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Baby Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,DiJGFIO-5Ue8rkEhvF5-Mg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1860,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Sienna)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 294,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,fWxn_AarpESryNA9UN8wvA,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1857,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Teal)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,M0nOQl44DkG_GjTXzAEQCQ,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1862,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,NRjJuinZ4ki8qaEPvP84tw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1864,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Mint)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,sEKIlMfbJ0eED6VtcXcQrw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1856,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Army Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Sg5yKkqJEEulQeUvRhFgJg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1861,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Prussian Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,x3kGXCD0UEWkjfXqu_kuxg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1855,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Z2L6AqDdcUqxiN1mVw-X9g,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1865,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Light Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "edc7888c-dc42-48ab-8815-884d227760af,,,",
"AvatarItemId": 1984,
"AvatarItemType": 0,
"FriendlyName": "Short Locs Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "f9dd08f8-16d3-4c39-af4f-89f7bb6e80d3,,,",
"AvatarItemId": 1003,
"AvatarItemType": 0,
"FriendlyName": "Undercut Short Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "fcfcaf63-deb4-45f7-b711-c051c9ea45cb,,,",
"AvatarItemId": 390,
"AvatarItemType": 0,
"FriendlyName": "Top Bun Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,,,",
"AvatarItemId": 1459,
"AvatarItemType": 0,
"FriendlyName": "Winged Hat - Pride (Rainbow Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,knXPidb-Rkayfc3kSHfZeQ,1yMyo6oTjU-VAygoeWaohQ,",
"AvatarItemId": 1460,
"AvatarItemType": 0,
"FriendlyName": "Winged Hat - Pride (Trans Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "feb8b50b-d3da-4e45-b4d0-69aeeb5cfeb3,",
"AvatarItemId": 2334,
"AvatarItemType": 0,
"FriendlyName": "Headdress (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "feb8b50b-d3da-4e45-b4d0-69aeeb5cfeb3,1WBsT99QM0anps6ZoWE59Q",
"AvatarItemId": 2336,
"AvatarItemType": 0,
"FriendlyName": "Headdress (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ffdda4d3-a768-4335-a883-aa3f9dc897bf,",
"AvatarItemId": 2995,
"AvatarItemType": 0,
"FriendlyName": "Trihawk Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ffea7a65-613f-4835-921e-6dd15f357b7e,,,",
"AvatarItemId": 621,
"AvatarItemType": 0,
"FriendlyName": "Long Bangs Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "oYvOo47DJE-DEPlpn5xNUw,whs9olNz9Uani7Gou31mvw,y6aASPUwDkas0W5Yl27ydQ,",
"AvatarItemId": 1450,
"AvatarItemType": 0,
"FriendlyName": "Belt Bag (Rainbow Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "-twtjyBdQ02EAdOfBGTiEw,,,",
"AvatarItemId": 728,
"AvatarItemType": 0,
"FriendlyName": "Van Dyke Beard",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
}
]
+4 -1
View File
@@ -66,7 +66,10 @@
],
"upload_source_maps": true,
"observability": {
"enabled": false
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
+10 -23
View File
@@ -5,19 +5,16 @@ authenticates players and issues the JWTs every other worker verifies.
## Routes
| Method | Path | Description |
| ------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| GET | `/eac/challenge` | EAC handshake; a constant, JSON-quoted, as text |
| GET | `/cachedlogin/forplatformid/:platform/:id` | Accounts linked to a platform id, for the login screen |
| POST | `/cachedlogin/forplatformids` | Bulk cached-login lookup (friends resolution) |
| POST | `/connect/deviceauthorization` | Studio device login: mint a user code and point the browser at WWW `/device` |
| POST | `/connect/device/approve` | Website approves that code for the signed-in account |
| POST | `/connect/device/deny` | Website refuses it |
| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token. Studio polls this with the device-code grant |
| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) |
| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean |
| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean |
| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) |
| Method | Path | Description |
| ------ | ------------------------------------------ | ------------------------------------------------------ |
| GET | `/eac/challenge` | EAC handshake; a constant, JSON-quoted, as text |
| GET | `/cachedlogin/forplatformid/:platform/:id` | Accounts linked to a platform id, for the login screen |
| POST | `/cachedlogin/forplatformids` | Bulk cached-login lookup (friends resolution) |
| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token |
| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) |
| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean |
| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean |
| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) |
## API documentation
@@ -48,10 +45,6 @@ route without documenting it fails rather than silently shipping an incomplete s
identity `platform_auth` proves.
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
30-day TTL; platform and platform id come from what was stored at issue time.
- **`urn:ietf:params:oauth:grant-type:device_code`** — Rec Room Studio polling a
code from `POST /connect/deviceauthorization`. While the player has not approved
it on the website, the response is HTTP 400 `authorization_pending`. Studio
treats `expires_in` on the token as seconds.
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
the account by `username` or numeric `account_id` and requires the matching password
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
@@ -62,12 +55,6 @@ Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `
claim, so developer/moderator powers refresh on every login and every refresh grant.
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
`betastudio` is a separate claim. RecFlare Studio treats it as permission to upload,
and developer does not include it. It is stamped from the account's `hasStudio` flag.
Staff add and remove people at `/settings/recroomstudio` on the website (or
`runx admin grant-studio`). A player already signed into Studio keeps the old token
until the next sign-in or refresh.
### Verifiable platforms: Steam and Meta
Only an identity we can _prove_ is ever bound to an account, so any grant that
@@ -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;
@@ -1,19 +0,0 @@
-- Device-login grants (owned by the auth worker). Named generically: Rec Room
-- Studio is the first device to use it, but any device-code login lands here. Studio
-- posts POST /connect/deviceauthorization here, then polls /connect/token with
-- grant_type=urn:ietf:params:oauth:grant-type:device_code. The browser page that
-- approves the code lives on www (`/device`); this table is only the handshake.
--
-- The device_code is a bearer secret until it is consumed, so only its SHA-256
-- is stored. The user_code is the short code the player sees and types, so it
-- has to be stored as itself. Kept in sync with STUDIO_DEVICE_SCHEMA_DDL in
-- src/studio-device.ts.
CREATE TABLE IF NOT EXISTS device_grant (
device_code_hash TEXT PRIMARY KEY,
user_code TEXT NOT NULL UNIQUE,
account_id INTEGER,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
@@ -1,15 +0,0 @@
-- Expose the account's influencer flag (`isInfluencer` in the JSON blob) as an indexed
-- generated column, so "every influencer" is an index walk rather than a scan of every
-- account's JSON. The reader is econ's `GET /api/influencerpartnerprogram/influencers`,
-- which the client asks for the whole list on every launch so it can badge influencers
-- wherever they turn up.
--
-- Same construction as `has_plus` (0009): a PARTIAL index holding only the rows where the
-- flag is set, so it is as small as the influencer list. A query must say exactly
-- `is_influencer = 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 is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1;
+77 -479
View File
@@ -4,7 +4,6 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { z } from 'zod'
import {
countAccountsByDeviceId,
countAccountsBySignupIp,
createAccount,
GAME_VERSION,
@@ -22,10 +21,8 @@ import {
subRoomDataBlob,
updateAccount,
verifyPassword,
writeAuditLog,
} from '@repo/domain'
import {
flagVar,
intVar,
logger,
withCleanSpec,
@@ -38,16 +35,11 @@ import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
import { banEvasionMatch, resolveBan } from '../../api/src/bans-db'
import { buildEndpoints } from '../../ns/src/endpoints'
import { verifyMetaNonce } from './meta-nonce'
import {
CachedLogin,
ChangePasswordRequest,
ChangePasswordResponse,
DeviceAuthorizationRequest,
DeviceAuthorizationResponse,
DeviceDecisionRequest,
DeviceDecisionResponse,
FakeCachedLogin,
form,
json,
@@ -55,7 +47,6 @@ import {
PlatformIdsRequest,
PlatformType,
RestrictionDto,
roleFilter,
roleLookup,
TokenRequest,
TokenResponse,
@@ -69,18 +60,10 @@ import {
} from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket'
import {
approveDeviceGrant,
beginDeviceGrant,
denyDeviceGrant,
DEVICE_CODE_GRANT,
pollDeviceGrant,
studioClientMatches,
} from './studio-device'
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`. */
@@ -88,8 +71,8 @@ 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
* The `error_description` a grant is refused with when the caller's account is not itself
* banned but shares an identity with one that is (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
@@ -97,9 +80,7 @@ const TOKEN_SCOPE =
* 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.
* A DIRECTLY banned account is not refused here at all — see the token grant.
*/
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
@@ -131,46 +112,24 @@ const FAKE_OCULUS_CACHED_LOGIN = {
* Signup caps, enforced on create_account only (never on login — an existing account
* always stays reachable, however many accounts its owner has since accumulated).
*
* Three independent arms, because they fail in different ways:
* Two independent arms, because they fail in opposite ways:
* - Per verified platform id (a Steam-proven SteamID64). The sharp one: it can't be
* spoofed and can't be reset by changing networks. Only binds on a platform
* create_account — the password/anonymous path has no platform identity to count,
* so the other two arms are all that stands between it and bulk signup.
* so the IP arm is the only thing standing between it and bulk signup.
* - Per signup IP. Coarse: households, NAT and shared campus/mobile networks put many
* legitimate players behind one address, so this WILL be the arm that produces false
* positives. It counts `signupIp` (immutable), so an abuser can't reset their own
* count by hopping networks — but a VPN gives them a fresh counter per exit, which
* is exactly how one player made 150 accounts, six per address, in a weekend.
* - Per client device id. Narrower than an IP (a household shares an address, not an
* install) and stable across VPN hops, which is the hole in the IP arm it covers. It
* counts the last-seen `deviceId`, which a login moves; see the field's notes in
* accounts-db for why an immutable copy isn't worth keeping. Client-chosen and
* unverified, so it catches a stock client, not a script that fabricates ids. Skipped
* when the signup posts no `device_id` (the website's don't).
* count by hopping networks.
*
* These are the defaults. An operator overrides any arm with the matching worker var
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`
* in wrangler.jsonc `vars`), and setting one to 0 disables that arm entirely — which a
* small private server that trusts everyone it invites will want, and which the IP arm in
* particular is worth reaching for if a shared network is being locked out.
* These are the defaults. An operator overrides either arm with the matching worker var
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` in wrangler.jsonc `vars`), and
* setting one to 0 disables that arm entirely — which a small private server that trusts
* everyone it invites will want, and which the IP arm in particular is worth reaching for
* if a shared network is being locked out.
*/
const DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID = 3
const DEFAULT_MAX_ACCOUNTS_PER_IP = 3
const DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID = 3
/**
* Whether a `create_account` with no platform — a password account, which is what the
* website mints — is accepted at all (`PASSWORD_SIGNUP`; see context.ts). Off by default:
* such an account has no identity but the address it came from, so the caps above are
* the only thing limiting it, and a VPN or Tor exit resets the IP arm every N accounts.
* That is how one player made 150 accounts in a weekend. A game-client signup always
* carries a Steam or Meta identity and is untouched by this switch.
*
* Refused as `invalid_grant` with this description, which `www` translates for the form
* (see its auth-messages.ts) — keep the two in step.
*/
const DEFAULT_PASSWORD_SIGNUP = false
const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled'
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
const ORIENTATION_ROOM_ID = 13
@@ -241,78 +200,22 @@ 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
* client gates the screen-share feature on it and nothing grants it per-account, so it
* is unconditional (even with no account resolved). The rest are the operator-granted
* extras, plus `junior` off the account's own `isJunior` flag.
*
* `betastudio` rides on the account's `hasStudio` flag, and developer does not imply it.
* RecFlare Studio treats that exact claim as Full access, which is what lets the editor
* upload; staff set the flag from the website. `influencer` rides on `isInfluencer`
* (`runx admin grant-influencer`) and lives only in the token — no `/role/*` lookup
* answers it. Order is stable so tokens are deterministic: screenshare, developer,
* moderator, junior, betastudio, then influencer.
* extras, plus `junior` off the account's own `isJunior` flag. Order is stable so
* tokens are deterministic.
*/
function accountRoles(
account: Pick<
Account,
'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio' | 'isInfluencer'
> | null
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior'> | null
): string[] {
const roles = ['screenshare']
if (!account) return roles
if (account.isDeveloper) roles.push('developer')
if (account.isModerator) roles.push('moderator')
if (account.isJunior) roles.push('junior')
if (account.hasStudio) roles.push('betastudio')
if (account.isInfluencer) roles.push('influencer')
return roles
}
@@ -338,120 +241,6 @@ function accountPlatform(account: Pick<Account, 'platform'>): number {
return account.platform ?? 0
}
/**
* The browser page Studio opens lives on WWW, not on this worker. `ns` is what
* told the editor where WWW is, so the URI has to be built from the same domain
* and the same subdomain override or the tab opens on a host the player doesn't have.
*/
function studioVerificationUri(env: Env, userCode: string): { uri: string; complete: string } {
const www = buildEndpoints(env.DOMAIN, env.SUBDOMAINS).WWW
const uri = `${www}/device`
return { uri, complete: `${uri}?user_code=${encodeURIComponent(userCode)}` }
}
function oauthError(
c: Context<App>,
error: string,
error_description: string,
status: 400 | 500 = 400
) {
return c.json({ error, error_description }, status)
}
/**
* Issue the same token body every other grant returns, for an account that has
* already been authenticated — here, because the player approved a Studio code
* on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`).
*/
async function issueSession(c: Context<App>, accountId: number, grantType: string) {
const clientIp = c.req.header('cf-connecting-ip') ?? ''
const ban = await resolveBan(c.env.DB, accountId, {
identity: { ip: clientIp },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (ban) {
logger.info('token issued to a blocked account', {
accountId,
grantType,
via: ban.via,
bannedAccountId: ban.bannedAccountId,
reportId: ban.ban.id,
})
}
const jwtSecret = await c.env.JWT_SECRET.get()
if (jwtSecret === '') {
logger.error('refusing to issue token: JWT_SECRET is empty')
return oauthError(c, 'server_error', 'token signing is not configured', 500)
}
const roleAccount = await getAccount(c.env.DB, accountId)
if (!roleAccount) return oauthError(c, 'invalid_grant', 'account no longer exists')
const accessToken = await generateToken(
String(accountId),
roleAccount.platformId ?? '',
accountPlatform(roleAccount),
jwtSecret,
accountRoles(roleAccount),
accountPrivileges(roleAccount),
GAME_VERSION,
roleAccount.hasPlus === true
)
const refreshToken = await issueRefreshToken(c.env.DB, accountId)
await setLastLoginTime(c.env.DB, accountId, new Date().toISOString())
await setLoginContext(c.env.DB, accountId, { ip: clientIp })
const staffRoles = (['developer', 'moderator'] as const).filter((role) =>
holdsRole(roleAccount, role)
)
if (staffRoles.length > 0) {
try {
await writeAuditLog(c.env.DB, {
playerId: accountId,
action: 'staff_login',
data: {
ip: clientIp,
roles: staffRoles,
grantType,
username: roleAccount.username ?? null,
deviceId: '',
deviceClass: 0,
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,
token_type: 'Bearer',
refresh_token: refreshToken,
scope: TOKEN_SCOPE,
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
})
}
async function decideDeviceCode(c: Context<App>, decide: typeof approveDeviceGrant) {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const userCode = typeof body.user_code === 'string' ? body.user_code : ''
const result = await decide(c.env.DB, userCode, id)
if (result.kind === 'ok') return c.json({ ok: true as const })
if (result.kind === 'expired')
return oauthError(c, 'expired_token', 'that code has expired. Start the login in Studio again.')
if (result.kind === 'used') return oauthError(c, 'invalid_grant', 'that code was already used.')
return oauthError(c, 'invalid_grant', 'that code was not found.')
}
/**
* Project a linked account into the client's CachedLogin DTO — the account-picker
* entry on the login screen. The client posts the chosen `accountId` back as a
@@ -758,83 +547,6 @@ const app = new Hono<App>()
}
)
// Studio editor device login. The POST the editor makes is
// `https://auth.<domain>/connect/deviceauthorization` — Auth is service enum 0,
// and the path is the literal `connect/deviceauthorization`. The browser half
// of the handshake is the verification_uri, which points at WWW `/device`.
.post(
'/connect/deviceauthorization',
describeRoute({
tags: ['Token'],
summary: 'Start a Rec Room Studio device login',
description: [
'RFC 8628 device authorization, called by Rec Room Studio (not the game).',
'The editor POSTs `client_id=recroom.studio` and its embedded client secret',
'to the Auth host. The response `verification_uri` is the website (`WWW /device`);',
'`verification_uri_complete` is that page with `?user_code=` and is what Studio',
'opens in the browser. Studio then polls `POST /connect/token` with',
'`grant_type=urn:ietf:params:oauth:grant-type:device_code`.',
].join(' '),
requestBody: form(DeviceAuthorizationRequest, '`client_id` and `client_secret`'),
responses: {
200: json(DeviceAuthorizationResponse, 'Codes and the WWW page to open'),
400: json(OAuthError, 'client_id or client_secret was not the Studio editor'),
},
}),
async (c) => {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
if (!studioClientMatches(body)) {
return oauthError(c, 'invalid_client', 'unknown studio client')
}
const grant = await beginDeviceGrant(c.env.DB)
const { uri, complete } = studioVerificationUri(c.env, grant.userCode)
return c.json({
device_code: grant.deviceCode,
user_code: grant.userCode,
verification_uri: uri,
verification_uri_complete: complete,
expires_in: grant.expiresIn,
interval: grant.interval,
})
}
)
.post(
'/connect/device/approve',
describeRoute({
tags: ['Token'],
summary: 'Approve a Studio device code',
description:
'Called by the website (`WWW /device`) with the signed-in player’s bearer token and the `user_code` Studio is showing. The editor’s next poll of `/connect/token` then receives that account’s token.',
security: [{ bearerAuth: [] }],
requestBody: form(DeviceDecisionRequest, 'The user_code from the editor'),
responses: {
200: json(DeviceDecisionResponse, 'The code is approved'),
400: json(OAuthError, 'Unknown, expired, or already used code'),
401: { description: 'Missing or invalid bearer token (empty body)' },
},
}),
(c) => decideDeviceCode(c, approveDeviceGrant)
)
.post(
'/connect/device/deny',
describeRoute({
tags: ['Token'],
summary: 'Deny a Studio device code',
description:
'Same caller as approve. Studio’s next poll fails with `access_denied` instead of signing in.',
security: [{ bearerAuth: [] }],
requestBody: form(DeviceDecisionRequest, 'The user_code from the editor'),
responses: {
200: json(DeviceDecisionResponse, 'The code is denied'),
400: json(OAuthError, 'Unknown, expired, or already used code'),
401: { description: 'Missing or invalid bearer token (empty body)' },
},
}),
(c) => decideDeviceCode(c, denyDeviceGrant)
)
// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
.post(
'/connect/token',
@@ -847,13 +559,9 @@ const app = new Hono<App>()
'',
'**`create_account`** — mints a new account with an auto-assigned random username',
'(players do not pick one initially) and places it in the Orientation room. A posted',
'`password` becomes the login credential. Without a `platform` this is a PASSWORD',
'account (what the website makes), accepted only while `PASSWORD_SIGNUP` is on —',
'off by default, refused as `invalid_grant` "password signup is disabled". Subject',
'to three independent signup caps,',
'per verified platform id, per signup IP and per client `device_id`',
'(`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`;',
'any disabled by setting it to 0). If it asserts a',
'`password` becomes the login credential. Subject to two independent signup caps,',
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
'must verify.',
'',
@@ -893,32 +601,26 @@ const app = new Hono<App>()
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant. `junior` rides along for an',
'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped',
'from the account’s `hasStudio` flag (staff set it on the website). RecFlare Studio',
'treats that claim as permission to upload; `developer` does not confer it. A junior',
'also carries',
'gate the client reads, not a privilege anyone is granted. A junior also carries',
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
'client reads it as a claim beside `role`, and it is absent for everyone else.',
'',
'**Bans.** 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.',
'**Bans.** A BANNED account still gets a token — every grant, including a refresh.',
'A ban is a `report` row with `banned` set (the `api` worker owns that table); it',
'lifts on its own when `ban_expires` passes, and never if that is null. The 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.',
'enforced by `match`, which refuses every matchmake for a banned player, so a token',
'gets them as far as that screen and no further.',
'',
'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.',
'What IS refused here (`invalid_grant`) is ban EVASION: 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, which is refused BEFORE it mints anything. Such',
'an account has no ban of its own for the block screen to describe, so there is',
'nothing to let it in for. Those two arms are the operator’s `BAN_EVASION_MATCH`',
'knob (`ip`, `platform`, or `off`). The description is deliberately vague — the',
'account refused may belong to a housemate of the banned player rather than to them.',
].join('\n'),
requestBody: form(
TokenRequest,
@@ -949,32 +651,6 @@ const app = new Hono<App>()
// form body.
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
// Studio's poll. Handled before the password fallback, which would otherwise
// treat this grant_type as a username/password login and 400 it.
if (grantType === DEVICE_CODE_GRANT) {
if (!studioClientMatches(body)) {
return oauthError(c, 'invalid_client', 'unknown studio client')
}
const deviceCode = typeof body.device_code === 'string' ? body.device_code : ''
const polled = await pollDeviceGrant(c.env.DB, deviceCode)
if (polled.kind === 'pending') {
return oauthError(
c,
'authorization_pending',
'the player has not approved this login yet'
)
}
if (polled.kind === 'denied') {
return oauthError(c, 'access_denied', 'the player denied this login')
}
if (polled.kind === 'expired') {
return oauthError(c, 'expired_token', 'the device code expired')
}
if (polled.kind !== 'approved') {
return oauthError(c, 'invalid_grant', 'unknown device code')
}
return issueSession(c, polled.accountId, DEVICE_CODE_GRANT)
}
// `platform`/`platform_id` come from the body for a fresh login; a refresh
// grant overrides them below with what was stored when the token was issued.
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
@@ -1024,7 +700,7 @@ const app = new Hono<App>()
// - cached_login authenticates purely by platform identity.
// - create_account that asserts a platform: we won't bind an identity we can't
// prove. (create_account with NO platform is the password-account path —
// binds no platformId, and is open only while PASSWORD_SIGNUP says so.)
// allowed, but binds no platformId.)
//
// A password grant is NOT gated: the password already proved who it is. It posts
// its platform proof too, and if that verifies we LINK the identity to the account
@@ -1113,17 +789,6 @@ const app = new Hono<App>()
// via create_account or /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
// The password-account path (no platform) is a switch, off by default: see
// DEFAULT_PASSWORD_SIGNUP. Checked first, before anything is read or minted —
// a closed door costs no D1 round trip and burns no cap slot.
if (!platformAsserted && !flagVar(c.env.PASSWORD_SIGNUP, DEFAULT_PASSWORD_SIGNUP)) {
logger.info('signup refused: password signup is disabled', { ip: clientIp })
return c.json(
{ error: 'invalid_grant', error_description: PASSWORD_SIGNUP_DISABLED_DESCRIPTION },
400
)
}
// 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
@@ -1155,7 +820,7 @@ const app = new Hono<App>()
// Signup caps. Checked before minting anything, so a rejected signup leaves no
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
// identity is unknown (no verified platform id / no client IP / no device id) — an unattributable
// identity is unknown (no verified platform id / no client IP) — an unattributable
// signup can't be counted against anyone, and lumping them together would lock out
// real players. The disabled check comes first so a disabled arm costs no D1 read.
const maxPerPlatformId = intVar(
@@ -1163,10 +828,6 @@ const app = new Hono<App>()
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
)
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
const maxPerDeviceId = intVar(
c.env.MAX_ACCOUNTS_PER_DEVICE_ID,
DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID
)
if (
maxPerPlatformId > 0 &&
verifiedPlatformId !== null &&
@@ -1201,20 +862,6 @@ const app = new Hono<App>()
400
)
}
if (
maxPerDeviceId > 0 &&
deviceId !== '' &&
(await countAccountsByDeviceId(c.env.DB, deviceId)) >= maxPerDeviceId
) {
logger.info('signup rejected: per-device account limit', { deviceId, ip: clientIp })
return c.json(
{
error: 'invalid_grant',
error_description: 'too many accounts created from this device',
},
400
)
}
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
// a Meta-validated nonce). A password/anonymous create_account (no platform)
@@ -1340,36 +987,33 @@ 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 DIRECTLY banned account still gets its token. The client needs one to reach
// `api`'s moderationBlockDetails, which is where the player is TOLD they are banned
// (category, time left, "Rule violation") — refused here, they would only ever see
// a failed sign-in. The ban is enforced by matchmaking instead, which refuses every
// matchmake for a banned player, so the token gets them as far as the block screen
// and no further. Logged, so the operator can see a banned player signing in.
//
// 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.
// Ban EVASION is still refused here: an account that merely shares a device or
// network with a banned one has no ban of its own for that screen to describe, so
// there is nothing to let it in for — and letting it in is exactly what the evader
// wants. 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', {
if (ban && ban.via !== 'account') {
logger.info('token refused: ban evasion', {
accountId,
grantType,
via: ban.via,
@@ -1377,6 +1021,15 @@ const app = new Hono<App>()
reportId: ban.ban.id,
banExpires: ban.ban.ban_expires,
})
return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400)
}
if (ban) {
logger.info('token issued to banned account', {
accountId,
grantType,
reportId: ban.ban.id,
banExpires: ban.ban.ban_expires,
})
}
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
@@ -1423,58 +1076,6 @@ const app = new Hono<App>()
// 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,
@@ -1571,29 +1172,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
-22
View File
@@ -26,17 +26,6 @@ export type Env = SharedHonoEnv & {
// read them through `intVar`, never as a bare number.
MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number
MAX_ACCOUNTS_PER_IP?: string | number
MAX_ACCOUNTS_PER_DEVICE_ID?: string | number
/**
* Whether `create_account` accepts a signup with NO platform identity — the password
* account the website makes (`www` `/api/signup`), or anything else that posts the
* grant without a `platform`. `on`/`off` (or true/false, 1/0); unset means OFF, so a
* server opens web signup deliberately. Read through `flagVar`. The `www` worker reads
* the same knob to hide its form and refuse its endpoint, so the two agree for free.
* Platform signups (Steam, Meta) are never governed by this — they always have an
* identity to authenticate with later, and the caps above are what limit them.
*/
PASSWORD_SIGNUP?: string | number | boolean
/**
* Which linked arms a ban is enforced through, as a comma-separated list out of `ip`
* and `platform` — or `off` for neither. Unset means BOTH: a ban reaches the accounts
@@ -49,17 +38,6 @@ export type Env = SharedHonoEnv & {
* Read through `banEvasionMatch`; the `match` worker reads the same knob.
*/
BAN_EVASION_MATCH?: string
/**
* Base domain, e.g. `recflare.net`. Injected for every worker at deploy time
* (`--var DOMAIN`). Studio's device-login response points the browser at
* `www.<domain>/device`, so auth has to know the same domain `ns` advertises.
*/
DOMAIN: string
/**
* The same `SUBDOMAINS` JSON `ns` applies. A `www` override has to move the
* verification URI with the host the editor was told WWW lives on.
*/
SUBDOMAINS: string
}
/** Variables can be extended */
+4 -99
View File
@@ -105,16 +105,7 @@ export const FakeCachedLogin = CachedLogin.extend({
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
export const OAuthError = z.object({
error: z.enum([
'invalid_grant',
'invalid_request',
'invalid_client',
'server_error',
// RFC 8628. Studio polls until it sees something other than authorization_pending.
'authorization_pending',
'expired_token',
'access_denied',
]),
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
error_description: z.string(),
})
@@ -137,17 +128,8 @@ export const TokenResponse = z.object({
*/
export const TokenRequest = z.object({
grant_type: z
.enum([
'create_account',
'cached_login',
'refresh_token',
'password',
'urn:ietf:params:oauth:grant-type:device_code',
])
.describe(
'Anything unrecognised (including absent) is treated as a password grant, except the ' +
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization'
),
.enum(['create_account', 'cached_login', 'refresh_token', 'password'])
.describe('Anything unrecognised (including absent) is treated as a password grant'),
account_id: z.string().optional().describe('Numeric account id, as a string'),
username: z
.string()
@@ -174,15 +156,6 @@ export const TokenRequest = z.object({
'Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
),
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
client_id: z.string().optional().describe('Studio device grant: `recroom.studio`'),
client_secret: z
.string()
.optional()
.describe('Studio device grant: the secret embedded in the editor'),
device_code: z
.string()
.optional()
.describe('Studio device-code grant: the device_code from /connect/deviceauthorization'),
device_id: z
.string()
.optional()
@@ -198,33 +171,6 @@ export const TokenRequest = z.object({
),
})
/** `POST /connect/deviceauthorization` form body. Studio's editor sends exactly these two. */
export const DeviceAuthorizationRequest = z.object({
client_id: z.string().describe('`recroom.studio`'),
client_secret: z.string().describe('The secret compiled into Rec Room Studio'),
})
/** RFC 8628 response. Property names match Studio's `DeviceAuthorizationResponse`. */
export const DeviceAuthorizationResponse = z.object({
device_code: z.string(),
user_code: z.string().describe('Shown in the editor; also on verification_uri_complete'),
verification_uri: z.string().describe('WWW `/device`, without the code'),
verification_uri_complete: z
.string()
.describe('The URL Studio opens in the browser, `verification_uri?user_code=`'),
expires_in: z.int().describe('Seconds until the code dies. Studio treats this as seconds'),
interval: z.int().describe('Seconds Studio waits between polls'),
})
/** `POST /connect/device/approve` and `/connect/device/deny` form body. */
export const DeviceDecisionRequest = z.object({
user_code: z.string().describe('The code Studio is showing, spaces and hyphens ignored'),
})
export const DeviceDecisionResponse = z.object({
ok: z.literal(true),
})
/** `POST /account/me/changepassword` form body. */
export const ChangePasswordRequest = z.object({
newPassword: z.string().describe('Required; empty is rejected'),
@@ -293,9 +239,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',
@@ -312,45 +256,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'),
+9 -127
View File
@@ -27,28 +27,17 @@
* `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
@@ -124,19 +113,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 +120,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 +132,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
@@ -288,7 +170,7 @@ export async function getLinksForPlatformIdentity(
)
.bind(platform, platformId)
.all<LinkRow>()
return results.map(toLink)
return results
}
/**
@@ -311,7 +193,7 @@ export async function getLinksForPlatformId(
)
.bind(platformId, ...CACHED_LOGIN_PLATFORMS)
.all<LinkRow>()
return results.map(toLink)
return results
}
/** Every platform identity linked to an account (a player's PC and headset, say). */
@@ -323,7 +205,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
}
/**
-211
View File
@@ -1,211 +0,0 @@
/**
* Rec Room Studio's device-login handshake.
*
* The editor (RecRoom.Studio.Editor.Core, `MKLLKHBNNLP`) POSTs
* `connect/deviceauthorization` to the **Auth** service — service enum value 0,
* the host `ns` advertises as `Auth` — with `client_id=recroom.studio` and the
* client secret embedded in that assembly. It then opens `verification_uri_complete`
* in the browser and polls `POST connect/token` on the same Auth host with
* `grant_type=urn:ietf:params:oauth:grant-type:device_code` until the code is
* approved or `expires_in` seconds pass.
*
* `GET account/me` after a successful poll is a different service: enum 11,
* **Accounts**. This module does not serve that.
*
* The page the editor opens is **WWW** `/device`. Auth only mints the codes and,
* once that page has approved one, the token.
*/
/** What Studio posts as `client_id` on the device grant. */
export const STUDIO_CLIENT_ID = 'recroom.studio'
/**
* The matching `client_secret` compiled into the Studio editor. It is not a
* server secret — anyone with the editor binary has it — but checking it keeps
* a random POST from minting codes.
*/
export const STUDIO_CLIENT_SECRET = '54oc5C4BH7jpqp42KhChkNZOLP0U3yTx'
/** `grant_type` Studio polls with. RFC 8628. */
export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
/** How long the player has to approve, in seconds. Studio reads this as seconds. */
export const DEVICE_TTL_SECONDS = 10 * 60
/** Minimum poll spacing Studio is told to use, in seconds. */
export const DEVICE_POLL_INTERVAL_SECONDS = 5
/** Unambiguous alphabet. Studio pretty-prints a code only when it is exactly 6 chars. */
const USER_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const USER_CODE_LENGTH = 8
export const STUDIO_DEVICE_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS device_grant (
device_code_hash TEXT PRIMARY KEY,
user_code TEXT NOT NULL UNIQUE,
account_id INTEGER,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)`,
]
type GrantStatus = 'pending' | 'approved' | 'denied' | 'consumed'
export interface DeviceGrant {
userCode: string
deviceCode: string
expiresIn: number
interval: number
}
export type DevicePoll =
| { kind: 'pending' }
| { kind: 'denied' }
| { kind: 'expired' }
| { kind: 'invalid' }
| { kind: 'approved'; accountId: number }
interface GrantRow {
account_id: number | null
status: GrantStatus
expires_at: number
}
function nowSeconds(): number {
return Math.floor(Date.now() / 1000)
}
async function sha256(value: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
}
function randomUserCode(): string {
const bytes = crypto.getRandomValues(new Uint8Array(USER_CODE_LENGTH))
let code = ''
for (const byte of bytes) code += USER_CODE_ALPHABET[byte % USER_CODE_ALPHABET.length]
return code
}
function randomDeviceCode(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
}
/** Strip spaces and hyphens and uppercase, so a code typed as Studio displays it still matches. */
export function normalizeUserCode(raw: string): string {
return raw.replace(/[\s-]/g, '').toUpperCase()
}
export function studioClientMatches(body: Record<string, unknown>): boolean {
return body.client_id === STUDIO_CLIENT_ID && body.client_secret === STUDIO_CLIENT_SECRET
}
export async function beginDeviceGrant(db: D1Database): Promise<DeviceGrant> {
const now = nowSeconds()
const deviceCode = randomDeviceCode()
const deviceCodeHash = await sha256(deviceCode)
for (let attempt = 0; attempt < 5; attempt++) {
const userCode = randomUserCode()
try {
await db
.prepare(
`INSERT INTO device_grant
(device_code_hash, user_code, account_id, status, created_at, expires_at)
VALUES (?1, ?2, NULL, 'pending', ?3, ?4)`
)
.bind(deviceCodeHash, userCode, now, now + DEVICE_TTL_SECONDS)
.run()
return {
userCode,
deviceCode,
expiresIn: DEVICE_TTL_SECONDS,
interval: DEVICE_POLL_INTERVAL_SECONDS,
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!message.includes('UNIQUE')) throw err
}
}
throw new Error('could not allocate a studio device code')
}
async function readByHash(db: D1Database, deviceCode: string): Promise<GrantRow | null> {
return db
.prepare(`SELECT account_id, status, expires_at FROM device_grant WHERE device_code_hash = ?1`)
.bind(await sha256(deviceCode))
.first<GrantRow>()
}
/**
* One poll from Studio. An approved code is consumed exactly once: two polls
* racing each other cannot both walk away with a session.
*/
export async function pollDeviceGrant(db: D1Database, deviceCode: string): Promise<DevicePoll> {
const row = await readByHash(db, deviceCode)
if (!row) return { kind: 'invalid' }
if (row.expires_at <= nowSeconds()) return { kind: 'expired' }
if (row.status === 'pending') return { kind: 'pending' }
if (row.status === 'denied') return { kind: 'denied' }
if (row.status !== 'approved' || row.account_id == null) return { kind: 'invalid' }
const consumed = await db
.prepare(
`UPDATE device_grant SET status = 'consumed'
WHERE device_code_hash = ?1 AND status = 'approved' AND expires_at > ?2`
)
.bind(await sha256(deviceCode), nowSeconds())
.run()
if ((consumed.meta.changes ?? 0) < 1) return { kind: 'invalid' }
return { kind: 'approved', accountId: row.account_id }
}
export type DeviceDecision =
{ kind: 'ok' } | { kind: 'missing' } | { kind: 'expired' } | { kind: 'used' }
async function decide(
db: D1Database,
userCode: string,
accountId: number,
status: 'approved' | 'denied'
): Promise<DeviceDecision> {
const code = normalizeUserCode(userCode)
if (code === '') return { kind: 'missing' }
const updated = await db
.prepare(
`UPDATE device_grant
SET status = ?2, account_id = ?3
WHERE user_code = ?1 AND status = 'pending' AND expires_at > ?4`
)
.bind(code, status, accountId, nowSeconds())
.run()
if ((updated.meta.changes ?? 0) > 0) return { kind: 'ok' }
const row = await db
.prepare(`SELECT status, expires_at, account_id FROM device_grant WHERE user_code = ?1`)
.bind(code)
.first<{ status: GrantStatus; expires_at: number; account_id: number | null }>()
if (!row) return { kind: 'missing' }
if (row.expires_at <= nowSeconds()) return { kind: 'expired' }
// Approving again from the same account is the player double-clicking Allow.
if (status === 'approved' && row.status === 'approved' && row.account_id === accountId)
return { kind: 'ok' }
return { kind: 'used' }
}
export function approveDeviceGrant(
db: D1Database,
userCode: string,
accountId: number
): Promise<DeviceDecision> {
return decide(db, userCode, accountId, 'approved')
}
export function denyDeviceGrant(
db: D1Database,
userCode: string,
accountId: number
): Promise<DeviceDecision> {
return decide(db, userCode, accountId, 'denied')
}
+28 -656
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,
@@ -14,7 +13,6 @@ import {
SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
updateAccount,
} from '@repo/domain'
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
@@ -32,11 +30,6 @@ import {
PLATFORM_SCHEMA_DDL,
} from '../../platform-db'
import { consumeRefreshToken, issueRefreshToken, REFRESH_SCHEMA_DDL } from '../../refresh-db'
import {
STUDIO_CLIENT_ID,
STUDIO_CLIENT_SECRET,
STUDIO_DEVICE_SCHEMA_DDL,
} from '../../studio-device'
import type { Env } from '../../context'
@@ -77,9 +70,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)
@@ -102,7 +92,6 @@ beforeAll(async () => {
// 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.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of STUDIO_DEVICE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
/**
@@ -480,60 +469,6 @@ describe('auth worker routes', () => {
).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.
@@ -688,8 +623,6 @@ describe('auth worker routes', () => {
expect(payload.role).not.toContain('developer')
expect(payload.role).not.toContain('moderator')
expect(payload.role).not.toContain('junior')
// Studio upload access is a whitelist, not a default role.
expect(payload.role).not.toContain('betastudio')
// 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
@@ -731,91 +664,6 @@ describe('auth worker routes', () => {
.run()
const payload = await tokenFor(`account_id=91&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
// developer is a different grant. It does not set the account's hasStudio flag.
expect(payload.role).not.toContain('betastudio')
})
// RecFlare Studio treats the role claim `betastudio` as permission to upload.
// It is stamped from the account's hasStudio flag on every mint path — password,
// refresh, and the Studio device grant — and a refresh after a revoke drops it. A
// token that kept the role for the rest of the day would leave a removed player
// uploading.
test('POST /connect/token stamps betastudio only while the account has hasStudio', async () => {
// 9811/9812, not 9601–9605: those ids belong to the staff-login audit tests,
// which insert with OR IGNORE and then count `staff_login` rows.
await seedAccount(9811, 'StudioPlayer')
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).not.toContain(
'betastudio'
)
await updateAccount(env.DB, 9811, { hasStudio: true })
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).toEqual([
'gameClient',
'screenshare',
'betastudio',
])
// developer and moderator stay in front of betastudio, so the claim order is stable.
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 9812,
username: 'StudioStaff',
passwordHash: await hashPassword(LOGIN_PASSWORD),
isDeveloper: true,
isModerator: true,
hasStudio: true,
})
)
.run()
expect((await tokenFor(`account_id=9812&password=${LOGIN_PASSWORD}`)).role).toEqual([
'gameClient',
'screenshare',
'developer',
'moderator',
'betastudio',
])
const client = `client_id=${STUDIO_CLIENT_ID}&client_secret=${STUDIO_CLIENT_SECRET}`
const started = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: client,
})
expect(started.status).toBe(200)
const codes = (await started.json()) as { device_code: string; user_code: string }
const session = await postToken(
`grant_type=password&username=StudioPlayer&password=${LOGIN_PASSWORD}`
)
expect(session.status).toBe(200)
const approved = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${session.json.access_token as string}`,
},
body: `user_code=${codes.user_code}`,
})
expect(approved.status).toBe(200)
const signedIn = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(signedIn.status).toBe(200)
expect(decodePayload(signedIn.json.access_token as string).role).toEqual([
'gameClient',
'screenshare',
'betastudio',
])
// Revoke between issue and refresh. The refresh re-reads the account.
const login = await postToken(`account_id=9811&password=${LOGIN_PASSWORD}`)
await updateAccount(env.DB, 9811, { hasStudio: false })
await updateAccount(env.DB, 9812, { hasStudio: false })
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(login.json.refresh_token as string)}`
)
expect(refreshed.status).toBe(200)
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
})
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
@@ -895,29 +743,6 @@ describe('auth worker routes', () => {
expect(payload.scope).not.toContain('rn.privilege')
})
test('POST /connect/token stamps the influencer role for an isInfluencer account', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 95,
username: 'InfluencerPlayer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
isInfluencer: true,
})
)
.run()
const payload = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(
expect.arrayContaining(['gameClient', 'screenshare', 'influencer'])
)
expect(payload.role).not.toContain('developer')
// Off the flag, off the token — no other flag implies it.
await updateAccount(env.DB, 95, { isInfluencer: false })
const revoked = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
expect(revoked.role).not.toContain('influencer')
})
test('POST /connect/token 400s when no account_id is posted (never defaults to 1)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' })
expect(res.status).toBe(400)
@@ -1039,74 +864,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(
@@ -1152,100 +909,6 @@ describe('auth worker routes', () => {
expect(other.status).toBe(200)
})
test('POST /connect/token caps the accounts created from one device', async () => {
// One install, many networks: the VPN-hopping signup the IP cap can't see. Each
// signup posts a fresh IP so only the device arm can be what refuses it.
const deviceId = 'dev-capped-install'
for (let i = 0; i < 3; i++) {
const ok = await postToken(
`grant_type=create_account&platform_id=steam-devcap${i}&device_id=${deviceId}&device_class=2`,
`198.51.100.${40 + i}`
)
expect(ok.status).toBe(200)
}
// The 4th signup from that device is refused — the cap is 3.
const capped = await postToken(
`grant_type=create_account&platform_id=steam-devcap3&device_id=${deviceId}&device_class=2`,
'198.51.100.43'
)
expect(capped.status).toBe(400)
expect(capped.json.error).toBe('invalid_grant')
expect(capped.json.error_description).toMatch(/device/)
// Another device is unaffected, and a signup that posts no device id (the website's)
// can't be attributed to one, so the arm skips it rather than lumping it in.
const other = await postToken(
'grant_type=create_account&platform_id=steam-devcap4&device_id=dev-other-install&device_class=2',
'198.51.100.44'
)
expect(other.status).toBe(200)
const deviceless = await postToken(
'grant_type=create_account&platform_id=steam-devcap5',
'198.51.100.45'
)
expect(deviceless.status).toBe(200)
// The capped device can still LOG IN to what it has — the cap is on signup only.
const login = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}&device_id=${deviceId}&device_class=2`,
'198.51.100.46'
)
expect(login.status).toBe(200)
})
test('PASSWORD_SIGNUP=off refuses a no-platform create_account and mints nothing', async () => {
// The switch the website's signup hangs off. Every other create_account test here
// runs with it ON (see vitest.config.ts); this one is the closed door.
const original = env.PASSWORD_SIGNUP
try {
env.PASSWORD_SIGNUP = 'off'
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
const refused = await postToken(
'grant_type=create_account&password=hunter2&device_id=dev-web&device_class=2',
'198.51.100.60'
)
expect(refused.status).toBe(400)
expect(refused.json.error).toBe('invalid_grant')
expect(refused.json.error_description).toBe('password signup is disabled')
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
expect(after?.n).toBe(before?.n)
// Logging in is not what the switch gates: an existing password account still gets
// its token, whatever the switch says.
const login = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}`,
'198.51.100.60'
)
expect(login.status).toBe(200)
// A platform signup is a different door: it still reaches platform verification
// (and fails THERE, on the missing ticket) rather than being refused by the switch.
const steam = await postToken(
'grant_type=create_account&platform=0&platform_id=76561198000000001',
'198.51.100.60'
)
expect(steam.status).toBe(400)
expect(steam.json.error_description).not.toBe('password signup is disabled')
// Unset is off too — the default an operator has to opt out of.
delete env.PASSWORD_SIGNUP
const unset = await postToken('grant_type=create_account&password=hunter2', '198.51.100.61')
expect(unset.status).toBe(400)
expect(unset.json.error_description).toBe('password signup is disabled')
// And `on` reopens it.
env.PASSWORD_SIGNUP = 'on'
const open = await postToken('grant_type=create_account&password=hunter2', '198.51.100.62')
expect(open.status).toBe(200)
} finally {
env.PASSWORD_SIGNUP = original
}
})
test('the signup caps come from vars, and 0 disables an arm', async () => {
// The cap an operator actually runs is the `MAX_ACCOUNTS_PER_IP` var; the constant in
// auth.app.ts is only the fallback. `env` is shared by every test in this file, so the
@@ -1267,24 +930,6 @@ describe('auth worker routes', () => {
} finally {
env.MAX_ACCOUNTS_PER_IP = original
}
// The device arm reads its own var the same way.
const originalDevice = env.MAX_ACCOUNTS_PER_DEVICE_ID
try {
env.MAX_ACCOUNTS_PER_DEVICE_ID = 1
const device = '&device_id=dev-var-install&device_class=2'
const first = await postToken(`grant_type=create_account&platform_id=steam-dvar0${device}`)
expect(first.status).toBe(200)
const capped = await postToken(`grant_type=create_account&platform_id=steam-dvar1${device}`)
expect(capped.status).toBe(400)
expect(capped.json.error_description).toMatch(/device/)
env.MAX_ACCOUNTS_PER_DEVICE_ID = 0
const uncapped = await postToken(`grant_type=create_account&platform_id=steam-dvar2${device}`)
expect(uncapped.status).toBe(200)
} finally {
env.MAX_ACCOUNTS_PER_DEVICE_ID = originalDevice
}
})
test('POST /connect/token does not cap logins, only signups', async () => {
@@ -1683,155 +1328,11 @@ 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)
})
test('Studio device login is approved on the website and polled on auth', async () => {
const client = `client_id=${STUDIO_CLIENT_ID}&client_secret=${STUDIO_CLIENT_SECRET}`
const bad = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'client_id=recroom.studio&client_secret=nope',
})
expect(bad.status).toBe(400)
const started = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: client,
})
expect(started.status).toBe(200)
const codes = (await started.json()) as {
device_code: string
user_code: string
verification_uri: string
verification_uri_complete: string
expires_in: number
interval: number
}
// The editor opens this in a browser. It is the website, not the auth API.
expect(codes.verification_uri).toBe('https://www.rec.example.com/device')
expect(codes.verification_uri_complete).toBe(
`https://www.rec.example.com/device?user_code=${codes.user_code}`
)
expect(codes.expires_in).toBeGreaterThan(0)
expect(codes.interval).toBeGreaterThan(0)
const pending = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(pending.status).toBe(400)
expect(pending.json.error).toBe('authorization_pending')
const session = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}`
)
expect(session.status).toBe(200)
const approved = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${session.json.access_token as string}`,
},
body: `user_code=${codes.user_code}`,
})
expect(approved.status).toBe(200)
const signedIn = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(signedIn.status).toBe(200)
expect(typeof signedIn.json.access_token).toBe('string')
expect(signedIn.json.expires_in).toBe(TOKEN_TTL_SECONDS)
// Studio treats expires_in as seconds, same as the game client.
const devicePayload = decodePayload(signedIn.json.access_token as string)
expect(devicePayload.sub).toBe('42')
// Player42 has no hasStudio flag, so a Studio login is still Limited.
expect(devicePayload.role).not.toContain('betastudio')
const again = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(again.status).toBe(400)
expect(again.json.error).toBe('invalid_grant')
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
@@ -1857,16 +1358,11 @@ 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}',
'POST /cachedlogin/forplatformids',
'POST /connect/device/approve',
'POST /connect/device/deny',
'POST /connect/deviceauthorization',
'POST /connect/token',
])
@@ -2022,12 +1518,12 @@ 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
// bans-db.ts for the arms and the BAN_EVASION_MATCH knob.
// 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. Unlike the banned account
// itself, such an account has no ban of its own for the block screen to describe, so
// there is nothing to let it in for. See the api worker's bans-db.ts for the arms and
// the BAN_EVASION_MATCH knob.
describe('ban evasion at the token endpoint', () => {
/** Seed a loginable account carrying the IPs it signed up / last logged in from. */
const account = async (id: number, name: string, ips: Record<string, string> = {}) => {
@@ -2046,38 +1542,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)
})
@@ -2106,8 +1602,7 @@ 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`.
// 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 platform one', async () => {
const original = env.BAN_EVASION_MATCH
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
@@ -2119,141 +1614,18 @@ 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 signs in whatever the knob says — its ban is
// enforced at matchmake, and the knob only governs the linked arms.
expect((await login(6320)).status).toBe(200)
} 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
View File
@@ -8,10 +8,6 @@ export default defineConfig({
miniflare: {
bindings: {
ENVIRONMENT: 'VITEST',
// Password (no-platform) signup is OFF by default, and most create_account tests
// post no `platform` — they'd all test the closed door. The switch itself is
// covered by a test that flips it off and back.
PASSWORD_SIGNUP: 'on',
},
},
}),
+5 -11
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
@@ -57,10 +55,6 @@
// constants in src/auth.app.ts.
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown", // overridden during deployment
// Overridden at deploy time, same as `ns`. The device-login response builds
// the browser URL from these (`https://www.<DOMAIN>/device`).
"DOMAIN": "rec.example.com",
"SUBDOMAINS": "{}"
"SENTRY_RELEASE": "unknown" // overridden during deployment
}
}
+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
+3 -10
View File
@@ -15,20 +15,13 @@ 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.
`/unityasset/{filename}` is the exception: a Rec Room Studio room bundle is stored under
`studio/<roomId>/` (the game's own `unity/<roomId>/` layout, under another prefix), its key
recorded as the filename of a `unity_asset` row (the rooms worker owns that table; the
studio worker writes the rows), and the route serves only a filename that table records.
`HEAD` on that path is 200 when a bundle is stored under the name.
scenes, and room images by their bare `ImageName`), `invention/` (invention data) — and
served as `application/octet-stream`; the worker never interprets what it hands back.
Reads are unauthenticated: a caller needs the exact key, which only comes from an
authenticated call to another worker.
This worker only reads. Uploads go through `storage`, which writes the same bucket, and
images are served by `img`. Studio room bundles are uploaded by the `studio` worker into
the same bucket. The worker binds the shared D1 database to resolve those filenames and
does not migrate it.
images are served by `img`.
## API documentation
+4 -148
View File
@@ -329,126 +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')}`)
)
// Rec Room Studio room bundles. The filename is the one a room save or
// `GET …/unityasset` advertises — the R2 key itself (`studio/<roomId>/…`), as
// `unity_asset.filename` records it. Only a recorded filename is served, so this
// checks the table before streaming the key. Reads stay unauthenticated, same as
// `/avatar/` and `/room/`. HEAD is the editor's "already uploaded?" check: 200
// exists, 404 does not.
.on(
'HEAD',
'/unityasset/:filename{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Check that a Rec Room Studio room bundle exists',
description: [
'200 when `filename` names a stored Studio bundle, 404 otherwise. The',
'Studio editor treats this HEAD as “already uploaded?” before it downloads the bytes',
'with GET. No body.',
].join(' '),
parameters: [keyParam('filename', 'The bundle filename from the room save.', true)],
responses: {
200: { description: 'The bundle exists (no body)' },
400: { description: 'The filename contains `..` (no body)' },
404: { description: 'No single stored bundle has this filename' },
},
}),
(c) => studioUnityAssetHead(c.env, c.req.param('filename'))
)
.get(
'/unityasset/:filename{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve a Rec Room Studio room bundle',
description: [
'Streams the Studio asset bundle stored for `filename`. The name comes from a',
'room save’s `UnitySubAssets` (or from `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`',
'on the rooms worker) and is the R2 key the studio upload stored the bytes under.',
'Only a filename recorded in `unity_asset` is served: anything else is a 404, as is',
'a missing table. The worker does not interpret the bytes.',
].join(' '),
parameters: [
keyParam('filename', 'The bundle filename from the room save.', true),
...CONDITIONAL_HEADERS,
],
responses: assetResponses('The asset bundle'),
}),
(c) => serveUnityAsset(c)
)
/**
* The R2 key for a Studio bundle filename — the filename itself, once `unity_asset` says
* a build is stored under it. Null when no build is, or the table has not been migrated
* yet (the rooms worker owns it).
*/
async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> {
try {
// Inlined rather than imported so this worker does not take a dependency on the
// domain package.
const row = await db
.prepare(`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`)
.bind(filename)
.first<{ stored: number }>()
return row ? filename : null
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.toLowerCase().includes('no such table')) return null
throw err
}
}
/** GET `/unityasset/:filename` — the bytes, with the same range handling as other blobs. */
async function serveUnityAsset(c: Context<App>) {
const name = c.req.param('filename') ?? ''
if (name.includes('..')) return c.body(null, 400)
const key = await unityAssetR2Key(c.env.DB, name)
if (!key) return c.notFound()
return serveAsset(c, key)
}
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
if (name.includes('..')) return new Response(null, { status: 400 })
const key = await unityAssetR2Key(env.DB, name)
if (!key) return new Response(null, { status: 404 })
const head = await env.CDN_ASSETS.head(key)
if (!head) return new Response(null, { status: 404 })
const headers = new Headers()
head.writeHttpMetadata(headers)
headers.set('etag', head.httpEtag)
headers.set('accept-ranges', 'bytes')
headers.set('cache-control', CACHE_CONTROL)
headers.set('access-control-allow-origin', '*')
const response = new Response(null, { status: 200, headers, encodeBody: 'manual' })
response.headers.set('content-length', String(head.size))
return response
}
// 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(
@@ -463,18 +343,14 @@ 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. `/unityasset/{filename}` is a Studio room',
'bundle: the filename is the R2 key, streamed once `unity_asset` records it.',
'`HEAD` on that path is 200 when a bundle is stored under the name.',
'authenticated call to another worker.',
'',
'This worker only READS. Uploads go through the `storage` worker, which writes the',
'same bucket, and images are served by `img` rather than from here.',
@@ -494,24 +370,4 @@ app.get(
)
)
/**
* What the Studio HEAD needs from the environment. Narrower than `Env` on purpose: the
* `mono` worker mounts this app with its own (superset) Env, which has no `ASSETS`
* binding, and the wrapper below must stay mountable there.
*/
type StudioAssetEnv = Pick<Env, 'DB' | 'CDN_ASSETS'>
export default {
fetch(
request: Request,
env: StudioAssetEnv,
executionCtx: ExecutionContext
): Response | Promise<Response> {
const url = new URL(request.url)
if (request.method === 'HEAD' && url.pathname.startsWith('/unityasset/')) {
const filename = decodeURIComponent(url.pathname.slice('/unityasset/'.length))
return studioUnityAssetHead(env, filename)
}
return app.fetch(request, env, executionCtx)
},
}
export default app
-5
View File
@@ -6,11 +6,6 @@ export type Env = SharedHonoEnv & {
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
// signed by `auth` verify here.
JWT_SECRET: SecretsStoreSecret
// Shared `recflare` database, read-only here. Studio bundle downloads check that
// `unity_asset` records the requested filename (which is the R2 key) before
// streaming it. The rooms worker owns that table's migration; this binding has no
// migrations_dir.
DB: D1Database
// R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and
// room build data under `room/<name>`.
CDN_ASSETS: R2Bucket
-66
View File
@@ -271,69 +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 and HEAD /unityasset/:filename stream a stored studio bundle', async () => {
await env.DB.prepare('DROP TABLE IF EXISTS unity_asset').run()
const absent = await exports.default.fetch(`${ORIGIN}/unityasset/not-stored.assetbundle`)
expect(absent.status).toBe(404)
// The rooms worker's table (UNITY_ASSET_SCHEMA_DDL in @repo/domain), inlined like
// the route's own lookup is.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS unity_asset (
unity_asset_id TEXT NOT NULL,
target INTEGER NOT NULL,
version INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'main',
filename TEXT NOT NULL,
hash TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, target, version, kind)
)`
).run()
// The filename IS the R2 key.
const filename = 'studio/20930/abc111.windows.main.assetbundle'
await env.DB.prepare(
`INSERT INTO unity_asset (unity_asset_id, target, version, kind, filename, hash)
VALUES ('asset-a', 0, 1, 'main', ?1, 'n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo=')`
)
.bind(filename)
.run()
await env.CDN_ASSETS.put(filename, new Uint8Array([1, 2, 3, 4]))
// A key in the bucket that no row records is not served.
await env.CDN_ASSETS.put('studio/20930/unrecorded.assetbundle', new Uint8Array([9]))
const res = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('application/octet-stream')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
const head = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, { method: 'HEAD' })
expect(head.status).toBe(200)
expect(head.headers.get('content-length')).toBe('4')
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
expect(
(await exports.default.fetch(`${ORIGIN}/unityasset/studio/20930/unrecorded.assetbundle`))
.status
).toBe(404)
expect((await exports.default.fetch(`${ORIGIN}/unityasset/a..b`)).status).toBe(400)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
@@ -356,15 +293,12 @@ describe('cdn endpoints', () => {
)
expect([...documented].sort()).toEqual([
'GET /',
'GET /avatar/{asset}',
'GET /config/LoadingScreenTipData',
'GET /config/{name}',
'GET /data/{id}',
'GET /invention/{dataBlob}',
'GET /room/{dataBlob}',
'GET /sigs/{sigName}',
'GET /unityasset/{filename}',
'HEAD /unityasset/{filename}',
])
// Every operation carries a summary — a path present but undescribed is not
+4 -12
View File
@@ -32,17 +32,6 @@
"directory": "./static",
"run_worker_first": true
},
// Shared `recflare` DB, read so `/unityasset/:filename` can resolve a Studio
// bundle's public filename to the R2 key the studio worker stored. Studio owns
// the migration (`apps/studio/migrations`); this binding deliberately has no
// migrations_dir, so deploying cdn does not try to create those tables.
"d1_databases": [
{
"binding": "DB",
"database_name": "recflare",
"database_id": "local"
}
],
// CDN binaries (signature blobs + room build data) are stored as R2 objects
// and streamed back by key. Keys are prefixed `sigs/` and `room/`.
"r2_buckets": [
@@ -63,7 +52,10 @@
],
"upload_source_maps": true,
"observability": {
"enabled": false
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
-1
View File
@@ -17,7 +17,6 @@
},
"dependencies": {
"@2toad/profanity": "3.3.0",
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
+7 -5
View File
@@ -2,7 +2,6 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { mergePlayerSettings as mergeStoredPlayerSettings, readPlayerSettings } from '@repo/domain'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
@@ -202,7 +201,10 @@ async function getPlayerSettings(
env: Env,
accountId: number
): Promise<Record<string, string> | null> {
return readPlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId).catch(() => null)
return env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
`player:${accountId}`,
'json'
).catch(() => null)
}
/**
@@ -249,15 +251,15 @@ async function writeChatPrivacy(
* 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. Nothing is written when
* the patch changes no value — the client re-posts its privacy settings freely.
* 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> {
await mergeStoredPlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId, patch)
const merged: Record<string, string> = { ...(await getPlayerSettings(env, accountId)), ...patch }
await env.RECFLARE_PLAYER_SETTINGS.put(`player:${accountId}`, JSON.stringify(merged))
}
/**
@@ -956,17 +956,6 @@ describe('PUT /thread/chatPrivacySetting', () => {
})
})
// Re-posting the stored value writes nothing to KV — writes are the cost, and the client
// posts these freely. The raw value is seeded with whitespace JSON.stringify never
// produces; surviving the PUT untouched means no write happened.
it('does not write KV when the value is already stored', async () => {
const padded = '{ "Recroom.OOBE": "77", "directMessagePrivacySetting": "Favorites" }'
await env.RECFLARE_PLAYER_SETTINGS.put('player:887010', padded)
const res = await put(887010, { directMessagePrivacySetting: 'Favorites' })
expect(await res.json()).toMatchObject({ directMessagePrivacySetting: 1 })
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:887010', 'text')).toBe(padded)
})
it('accepts the enum by ordinal too, and is case-insensitive about the name', async () => {
expect(await (await put(887005, { directMessagePrivacySetting: '2' })).json()).toMatchObject({
directMessagePrivacySetting: 2,
+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
+27 -174
View File
@@ -8,7 +8,6 @@ import {
ClubMembershipType,
ClubVisibility,
countClubsByCreator,
countClubSubscribers,
createClub,
createClubAnnouncement,
deleteClub,
@@ -20,10 +19,6 @@ import {
getClubsByMember,
getHomeClub,
getMembership,
getOrCreateSubscriptionClub,
getPublicClubsByMember,
getSubscriptionClub,
getSubscriptionMemberships,
glyphLength,
joinClub,
leaveClub,
@@ -35,7 +30,6 @@ import {
setClubAdditionalImage,
setHomeClub,
setMemberType,
subscribeToClub,
updateClub,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
@@ -68,11 +62,7 @@ import {
ModifyClubRequest,
NullEnvelope,
SubscriberCountResponse,
SubscribeRequest,
SubscriptionDetailsResponse,
SubscriptionErrorEnvelope,
SubscriptionIdEnvelope,
SubscriptionMembershipsResponse,
UNAUTHORIZED_RESPONSE,
} from './openapi'
@@ -83,11 +73,8 @@ 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-
@@ -686,35 +570,6 @@ const app = new Hono<App>()
}
)
// The clubs shown on another player's profile: the PUBLIC clubs the account is a
// member of. Public — a private club is left out for everyone, the account's own
// owner included (they read theirs from /club/mine/member). An unknown account
// simply has no clubs.
.get(
'/account/:accountId{[0-9]+}/clubs',
describeRoute({
tags: ['Clubs'],
summary: 'The public clubs an account is a member of',
description: [
'The account’s memberships from `club_member`, oldest club first, PUBLIC clubs only',
'(private clubs, subscription clubs and pending/denied/banned rows excluded). Public',
'(no auth). An unknown account has `[]`.',
].join(' '),
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'Account id (digits only)',
schema: { type: 'string' },
},
],
responses: { 200: json(ClubDto.array(), 'The account’s public clubs') },
}),
async (c) =>
c.json(await getPublicClubsByMember(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
)
// Club search / browse. Public, non-subscription clubs; `category` filters to that
// category, `query` matches the name or description, `sort` picks the order (1 =
// newest, 2 = by name, default = most members first), and `count` caps the page
@@ -1711,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',
+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 -303
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 () => {
@@ -369,64 +142,6 @@ describe('clubs endpoints', () => {
expect(await res.json()).toEqual([])
})
test('GET /account/:id/clubs lists only the PUBLIC clubs the account is a member of', async () => {
const club = (ClubId: number, Visibility: number, ClubType = 0) => ({
ClubId,
Name: `Club ${ClubId}`,
Description: '',
Category: 'Social',
Visibility,
Joinability: 0,
AllowJuniors: true,
MainImageName: '',
ClubType,
ClubhouseRoomId: null,
CreatorAccountId: 4500,
IsRRO: false,
MinLevel: 0,
State: 0,
MemberCount: 2,
CreatedAt: `2026-07-0${ClubId - 9100}T00:00:00Z`,
})
// 9101 public, 9102 private, 9103 public but only a pending request, 9104 a
// public subscription club, 9105 public.
const clubs = [club(9101, 1), club(9102, 0), club(9103, 1), club(9104, 1, 1), club(9105, 1)]
const insertClub = env.DB.prepare('INSERT INTO club (data) VALUES (?1)')
const insertMember = env.DB.prepare(
'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, 4501, ?2, ?3)'
)
await env.DB.batch([
...clubs.map((c) => insertClub.bind(JSON.stringify(c))),
...clubs.map((c) =>
insertMember.bind(c.ClubId, c.ClubId === 9103 ? 1 : 10, '2026-07-10T00:00:00Z')
),
])
const pending = await env.DB.prepare(
'SELECT membership_type FROM club_member WHERE club_id = 9103 AND account_id = 4501'
).first<{ membership_type: number }>()
expect(pending?.membership_type).toBeLessThan(10)
// No auth needed; the private, pending and subscription clubs are left out.
const res = await exports.default.fetch(`${ORIGIN}/account/4501/clubs`)
expect(res.status).toBe(200)
const listed = (await res.json()) as Array<{ ClubId: number; Visibility: number }>
expect(listed.map((c) => c.ClubId)).toEqual([9101, 9105])
expect(listed[0]).toMatchObject({ Name: 'Club 9101', Visibility: 1, MemberCount: 2 })
// The member themself gets the same public-only list here.
const own = await exports.default.fetch(`${ORIGIN}/account/4501/clubs`, {
headers: await bearer('4501'),
})
expect(((await own.json()) as Array<{ ClubId: number }>).map((c) => c.ClubId)).toEqual([
9101, 9105,
])
// An account with no memberships has no clubs; a non-numeric id doesn't match.
const none = await exports.default.fetch(`${ORIGIN}/account/4599/clubs`)
expect(await none.json()).toEqual([])
expect((await exports.default.fetch(`${ORIGIN}/account/abc/clubs`)).status).toBe(404)
})
test('the my-clubs lists exclude subscription clubs (ClubType 1)', async () => {
// A subscription club the player both created and is a member of. It's reached
// through /subscription/*, so it must not show up among their clubs.
@@ -1704,7 +1419,6 @@ describe('clubs endpoints', () => {
'DELETE /club/{clubId}',
'DELETE /club/{clubId}/additionalimage/{index}',
'DELETE /club/{clubId}/clubhouse',
'GET /account/{accountId}/clubs',
'GET /announcements/club/{clubId}',
'GET /announcements/v2/mine/unread',
'GET /announcements/v2/subscription/mine/unread',
@@ -1727,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
+4 -1
View File
@@ -9,7 +9,10 @@
"enabled": true
},
"observability": {
"enabled": false
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
+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
+9 -9
View File
@@ -3,7 +3,7 @@
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Headwear",
"sectionType": 4,
"sectionSubType": "Items_Headwear",
"source": "UnifiedAlgorithmicList",
"source": "AlgorithmicList",
"sourceMetadata": "headwearitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Headwear\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 7,\r\n \"categoryUriNames\": \"HatsItems\",\r\n \"itemCount\": 5\r\n}"
},
@@ -11,7 +11,7 @@
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Tops",
"sectionType": 4,
"sectionSubType": "Items_Tops",
"source": "UnifiedAlgorithmicList",
"source": "AlgorithmicList",
"sourceMetadata": "topsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Tops\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 8,\r\n \"categoryUriNames\": \"TorsoItems\",\r\n \"itemCount\": 5\r\n}"
},
@@ -19,7 +19,7 @@
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Bottoms",
"sectionType": 4,
"sectionSubType": "Items_Bottoms",
"source": "UnifiedAlgorithmicList",
"source": "AlgorithmicList",
"sourceMetadata": "bottomsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Bottoms\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 17,\r\n \"categoryUriNames\": \"BottomsItems\",\r\n \"itemCount\": 5\r\n}"
},
@@ -27,7 +27,7 @@
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Footwear",
"sectionType": 4,
"sectionSubType": "Items_Footwear",
"source": "UnifiedAlgorithmicList",
"source": "AlgorithmicList",
"sourceMetadata": "footwearitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Footwear\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 18,\r\n \"categoryUriNames\": \"ShoesItems\",\r\n \"itemCount\": 5\r\n}"
},
@@ -35,7 +35,7 @@
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Waist",
"sectionType": 4,
"sectionSubType": "Items_Waist",
"source": "UnifiedAlgorithmicList",
"source": "AlgorithmicList",
"sourceMetadata": "waistitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Waist\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 23,\r\n \"categoryUriNames\": \"WaistItems\",\r\n \"itemCount\": 5\r\n}"
},
@@ -43,7 +43,7 @@
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Hands",
"sectionType": 4,
"sectionSubType": "Items_Hands",
"source": "UnifiedAlgorithmicList",
"source": "AlgorithmicList",
"sourceMetadata": "handsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Hands\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 10,\r\n \"categoryUriNames\": \"GlovesItems\",\r\n \"itemCount\": 5\r\n}"
},
@@ -51,7 +51,7 @@
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Shoulder",
"sectionType": 4,
"sectionSubType": "Items_Shoulder",
"source": "UnifiedAlgorithmicList",
"source": "AlgorithmicList",
"sourceMetadata": "shoulderitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Shoulders & Back\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 22,\r\n \"categoryUriNames\": \"ShoulderItems\",\r\n \"itemCount\": 5\r\n}"
},
@@ -59,7 +59,7 @@
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Hairstyles",
"sectionType": 4,
"sectionSubType": "Items_Hairstyles",
"source": "UnifiedAlgorithmicList",
"source": "AlgorithmicList",
"sourceMetadata": "hairitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Hairstyles\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 24,\r\n \"categoryUriNames\": \"HeadHairItems\",\r\n \"itemCount\": 5\r\n}"
},
@@ -67,7 +67,7 @@
"id": "StoreItemCarousel_UnifiedAlgorithmicList_FacialHair",
"sectionType": 4,
"sectionSubType": "Items_FacialHair",
"source": "UnifiedAlgorithmicList",
"source": "AlgorithmicList",
"sourceMetadata": "facialhairitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Facial Hair\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 25,\r\n \"categoryUriNames\": \"FacialHairItems\",\r\n \"itemCount\": 5\r\n}"
}
+4 -1
View File
@@ -26,7 +26,10 @@
},
"upload_source_maps": true,
"observability": {
"enabled": false
"logs": {
"enabled": true,
"head_sampling_rate": 1 // 100%
}
},
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
+4 -69
View File
@@ -25,11 +25,7 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
| GET | `/api/avatar/v2` | ✓ | The player's own avatar |
| POST | `/api/avatar/v2/set` | ✓ | Save the player's avatar |
| GET | `/api/checklist/v1/current` | ✓ | NUX checklist (stub `[]`) |
| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | The caller's item wishlist |
| GET | `/api/itemWishlists/v1/wishlist/:accountId` | ✓ | Another player's item wishlist |
| POST | `/api/itemWishlists/v1/wishlist/add` | ✓ | Wish for an item (form, enveloped) |
| POST | `/api/itemWishlists/v1/wishlist/remove` | ✓ | Un-wish an item (a POST, not DELETE) |
| POST | `/api/itemWishlists/v1/isonwishlist/bulk` | ✓ | Per-player "has it wished for" booleans |
| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | Item wishlist (stub `[]`) |
| GET | `/api/avatar/v3/saved` | ✓ | Saved outfits |
| POST | `/api/avatar/v3/saved/set` | ✓ | Save an outfit into a slot |
| GET | `/api/avatar/v2/gifts` | ✓ | Pending (unopened) gift boxes |
@@ -43,7 +39,6 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
| POST | `/api/settings/v2/set` | ✓ | Persist settings (accept-and-ack) |
| GET | `/api/consumables/v2/getUnlocked` | ✓ | Unlocked consumables |
| POST | `/api/consumables/v1/consume` | ✓ | Consume an owned consumable |
| POST | `/api/consumables/v1/updateActive` | ✓ | Mark a consumable active (stub, empty) |
| GET | `/api/storefronts/v4/balance/:currencyType` | ✓ | Currency balance |
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
@@ -54,12 +49,7 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 5 XP + gift box |
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
| GET | `/api/roomkeys/v1/room` | | The keys a room has listed |
| POST | `/api/roomkeys/v1/create` | ✓ | List a key for a room (owner/co-owner) |
| PUT | `/api/roomkeys/v1/updateAll` | ✓ | Edit one key (owner/co-owner) |
| GET | `/api/storefronts/v1/buyRoomKey` | ✓ | Buy a room key; pays the room owner |
| POST | `/api/storefronts/v1/PurchaseRoomKeyWithCurrency` | ✓ | Buy a room key with its room currency |
| POST | `/api/roomCurrencies/v2/purchase` | ✓ | Buy room currency with tokens (offer) |
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | ~ | Gold year for `developer`s, else `{}` |
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
@@ -497,16 +487,6 @@ failure can't leave a box promising XP nobody was credited.
- **Deliberately smaller than a level.** The first level costs 10 XP, so a single action
can't be a level-up — it takes two rewards to reach level 2, and the early levels are paced
by the hourly cooldown rather than cleared in one match.
- **A NEW activity pays 250 tokens on top** (`NEW_ACTIVITY_BONUS_TOKENS`): the first claim
ever in a `giftContext`, whichever reward type asked for it — `isNewActivity` counts the
player's rows in that context right after the claim and is true when the claim's own row
is the only one. Counting after the write is what stops two asks landing together
(`FirstActivityOfDay` and `PostGameActivity` for the same match) from both paying: a race
can skip the bonus, never double it. The tokens are credited to the RecCenterTokens
balance (`StorefrontBalanceUpdate` with the resulting total) and arrive in a **second box**
of their own — `GiftContext` 51 (`GameRewardsTokens`), `Currency` 250, no XP — after the
reward's, so the player sees what they were for. The contextless `''` bucket is not an
activity and never pays it.
- **The response stays `[]`.** It's what the client already accepts, and the reward is
delivered as a box, so there's nothing to put in the body. The reference answers its own
(different) flow with `{ error, success, value: null }`, not a list of rewards.
@@ -579,50 +559,6 @@ is our cap.
`GET /api/gamerewards/v1/pending` stays `[]`: with rewards claimed on request, nothing sits
waiting to be collected.
## Discord supporter gift (`src/discord-role-gift.ts`)
A cron (`triggers.crons` in `wrangler.jsonc`) hands every account holding a mapped Discord
role that role's RecCenterTokens in a gift box, every time it fires. The map is the
`DISCORD_ROLE_TOKENS` var — `RECFLARE_DISCORD_ROLE_TOKENS` in the root `.env`, see
`.env.example` — as `<roleId>=<tokens>` pairs separated by commas:
```
928457923857943795=2500,2938479238479234=10000
```
- **The schedule is the cadence.** It ships weekly (Monday 05:00 UTC); make it daily by
changing the cron line. There is no ledger and no notion of a period in the code — every
run pays everyone it finds, so running it twice pays twice, and a hand run is an extra
gift on purpose.
- **One box per account per run, the highest amount wins.** An account holding both roles
above gets one box of 10,000, not two boxes or 12,500, so the map reads as tiers however
Discord stacks the roles. A role named twice keeps its last amount; an entry that isn't
`digits=positive integer` is logged and skipped, not fatal. No roles at all — unset, empty,
all rejected — leaves the cron a logged no-op.
- **Roles come from `platform_account.role`**, the snapshot `www`'s benefits claim writes and
its daily role sweep (04:30 UTC, with a bot token) refreshes. This cron never asks Discord
anything, which is why the default fires half an hour after that sweep. A player who has
never claimed on the website has no link and is never paid, whatever they hold in the guild.
- **The grant is econ's usual three moves**: seed the signup grant (`STARTING_TOKENS`), credit
the balance, store a token box from the Coach (context 0, `AvatarItemType` null), then
push `StorefrontBalanceUpdate` with the resulting total and `GiftPackageReceivedImmediate`
for the box. An offline player finds the box on their next `GET /api/avatar/v2/gifts`.
- A grant that fails partway is logged with the account, role and amount and is not retried —
a retry that also failed partway is how a balance gets credited twice.
- **The first link is paid on demand.** `www`'s benefits claim calls `grantDiscordRoleGift`
— one account, the roles Discord just served, the same map and box — the first time an
account links its Discord, so a new supporter gets one box now rather than after the next
run. `www` decides "first" from the link table; this module keeps no ledger either way, and
the cron pays that account again on its next run like everyone else.
- **A whole run can be paid by hand.** `www`'s staff panel ("Bonus reward!", developer-only,
`POST /api/staff/discord-roles/supporter-gift`) calls `grantDiscordRoleGifts` with its own
bindings — the same map, audience and box as the cron — and answers with the run's summary.
No ledger here either: it pays everyone again on top of the schedule, which is the point.
One D1 read for the links, then a handful of writes and two hub calls per box, sequentially.
A cron invocation has a fixed subrequest budget (50 free, 1000 paid); a community whose
supporters outgrow that is the point to batch the run.
## Bindings
| Binding | Type | Notes |
@@ -632,7 +568,6 @@ supporters outgrow that is the point to batch the run.
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
| `STARTING_TOKENS` | var | Optional; new-player token grant (default in balance-db) |
| `DISCORD_ROLE_TOKENS` | var | Optional; Discord role → token gift per cron run (above) |
Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no code change.
@@ -647,8 +582,8 @@ Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no cod
publish; per-item weighting and a multi-catalog pool would both need a manifest of the
storefronts, which the ASSETS binding can't enumerate.
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
- Several routes (equipment, room consumables/currencies) are empty-list stubs pending
their own stores.
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are
empty-list stubs pending their own stores.
- Game rewards pay a flat 5 XP; there is no daily XP cap beyond the hourly cooldown (the
reference caps activity XP per day in `daily_xp_ledgers`).
- The level-reward table and the served config's `GiftRarity` disagree in places (see the
@@ -1,51 +0,0 @@
-- Room currencies — a room's own money.
--
-- A creator mints one for their room ("MySpecialTokens"), hands it out for doing things in
-- there, and sells things for it: finish the obstacle course, earn 50 tokens, spend them on a
-- custom shirt at the shop by the door. Play money for one room, meaning nothing outside it.
--
-- A row here is the CURRENCY — its name, its coin art, how fast the room may hand it out.
-- What a player has actually earned is `room_balance` (migration 0019). Owned by the `econ`
-- worker; generated from src/room-currency-db.ts (ROOM_CURRENCY_SCHEMA_DDL) — keep in sync.
--
-- These are NOT balances and must never reach the `balance` table, which is keyed by
-- (account_id, currency_type) alone. That key cannot say WHICH room's currency a row is, so
-- one room's tokens would spend in every other room — see CurrencyType in src/balance-db.ts,
-- where RoomCurrency is left out of the spendable set for exactly this reason.
--
-- Every currency here is CurrencyType 300 (RoomCurrency) — that is what the type MEANS — and
-- is told apart from the others by its own `currency_id`, so the type is not a column.
--
-- `currency_id` is a GUID rather than an autoincrement: it is how the client names this
-- currency everywhere afterwards, and a room's currencies are minted independently of every
-- other room's, so an id that says nothing about ordering is the honest one.
--
-- `limit_amount`, not `limit` — LIMIT is a SQL keyword, and a column that has to be quoted at
-- every use is a column that will eventually not be. It holds the most of this currency the
-- room may award PER DAY: a faucet rate, not a cap on what a player may hold. Nothing enforces
-- it yet — no day's awards are tracked anywhere — so it is stored and served and otherwise
-- inert.
--
-- `shape` and `color` are the client's own indices into its coin art (the create body sends
-- `Shape=0&Color=19`); nothing interprets them, they are stored and served back. `image_name`
-- is separate and stays empty until a room can upload custom coin art — the client's model
-- carries both, so the column is here rather than in a later migration.
--
-- NOTE: `image_name` was NOT NULL DEFAULT '' here, and 0018 makes it nullable — the client
-- serves null, not an empty string, for a currency with no custom coin art. This file is left
-- as it shipped; the current shape of the table is 0017 + 0018.
CREATE TABLE IF NOT EXISTS room_currency (
currency_id TEXT PRIMARY KEY,
room_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
limit_amount INTEGER NOT NULL,
shape INTEGER NOT NULL,
color INTEGER NOT NULL,
image_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
modified_at TEXT NOT NULL
);
-- Every read is "this room's currencies" — the whole access pattern.
CREATE INDEX IF NOT EXISTS idx_room_currency_room ON room_currency (room_id);
@@ -1,45 +0,0 @@
-- Make `room_currency.image_name` nullable, and turn the empty strings already stored into
-- nulls. Owned by the `econ` worker; 0017 created the table with `image_name TEXT NOT NULL
-- DEFAULT ''`. Generated from src/room-currency-db.ts (ROOM_CURRENCY_SCHEMA_DDL, which mirrors
-- the table as it stands AFTER this migration) — keep in sync.
--
-- The client's own model has `ImageName` as a nullable string and serves NULL for a currency
-- with no custom coin art; an empty string is a different value to its decoder, so a currency
-- created before this read back as having art named "". Nothing can upload coin art yet, so
-- every row is affected and every one of them should be null.
--
-- A rebuild rather than an ALTER: SQLite cannot drop a NOT NULL constraint in place. This is
-- the standard four-step swap — build the new shape, copy across, drop the old, rename — and
-- the index goes with the dropped table, so it is recreated at the end rather than left to be
-- noticed later by a slow query. `NULLIF(image_name, '')` is the conversion: it maps '' to
-- NULL and leaves a real name alone, so a row that somehow already carries art keeps it.
--
-- Safe to re-run in spirit but not in fact — it is a migration, applied once, and the rename
-- would fail on a second pass because `room_currency_new` no longer exists.
CREATE TABLE room_currency_new (
currency_id TEXT PRIMARY KEY,
room_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
limit_amount INTEGER NOT NULL,
shape INTEGER NOT NULL,
color INTEGER NOT NULL,
image_name TEXT,
created_at TEXT NOT NULL,
modified_at TEXT NOT NULL
);
INSERT INTO room_currency_new (
currency_id, room_id, name, description, limit_amount, shape, color, image_name, created_at, modified_at
)
SELECT
currency_id, room_id, name, description, limit_amount, shape, color, NULLIF(image_name, ''), created_at, modified_at
FROM room_currency;
DROP TABLE room_currency;
ALTER TABLE room_currency_new RENAME TO room_currency;
-- Recreated because dropping the old table took its index with it. Same index 0017 defined:
-- every read is "this room's currencies".
CREATE INDEX IF NOT EXISTS idx_room_currency_room ON room_currency (room_id);
@@ -1,28 +0,0 @@
-- How much of a room's own money each player has earned — the other half of room currencies
-- (the currencies themselves are `room_currency`, migration 0017). Written by
-- `POST /api/roomcurrencies/v1/awardCurrency/bulk`, which is how a room pays a player for
-- finishing its obstacle course. Owned by the `econ` worker; generated from
-- src/room-currency-db.ts (ROOM_BALANCE_SCHEMA_DDL) — keep in sync.
--
-- Keyed by CURRENCY, not by currency type, and that difference is the whole point: the
-- account-scoped `balance` table's (account_id, currency_type) key cannot say WHICH room's
-- currency a row is, so one room's tokens would spend in every other room. Room currencies
-- are left out of that table for exactly this reason (see CurrencyType in src/balance-db.ts)
-- and live here instead, keyed by the `currency_id` that names one room's currency uniquely.
--
-- The room is not a column: `room_currency` already says which room a currency belongs to,
-- and repeating it here would be a second place for that to be wrong.
--
-- A missing row is a balance of zero. Nothing is inserted until a player is first awarded
-- some, so a room's currency costs a row per player who has actually earned it rather than
-- one per player who has walked in.
CREATE TABLE IF NOT EXISTS room_balance (
currency_id TEXT NOT NULL,
player_id INTEGER NOT NULL,
amount INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (currency_id, player_id)
);
-- "What does this player hold across the room?" walks the room's currencies, so the reverse
-- lookup earns its own index rather than a scan of every holder of every currency.
CREATE INDEX IF NOT EXISTS idx_room_balance_player ON room_balance (player_id);
@@ -1,19 +0,0 @@
-- The purchase offers a room sells its own currency through — "5 SuperTokens for 500 Rec
-- Center Tokens" — served by `GET /api/roomcurrencies/v1/getPurchaseOffersBatch?ids=`. Owned
-- by the `econ` worker; 0017 created the table. Generated from src/room-currency-db.ts
-- (ROOM_CURRENCY_SCHEMA_DDL, which mirrors the table as it stands after 0017 + 0018 + this)
-- — keep in sync.
--
-- A JSON column rather than a table of its own. Offers are a small, ordered list that is only
-- ever read, written and replaced AS A WHOLE, for one currency at a time — the client shows
-- them as that currency's shop and reorders them together. A table would buy per-offer
-- queries nothing asks for, and cost a join on the one read that matters.
--
-- Each offer holds `CurrencyPurchaseOfferId`, `Order`, `Name`, `CurrencyAmount`, `Price` and
-- `ModifiedAt`. It does NOT hold `CurrencyId`: the row it lives on already says which currency
-- these are offers for, and the read projects it back in. Storing it would be a second place
-- for that to be wrong.
--
-- NULL, not '[]', for a currency with no offers — a currency nobody has built a shop for has
-- no list, and the read treats null and an unparseable value alike as "no offers".
ALTER TABLE room_currency ADD COLUMN purchase_offers TEXT;
@@ -1,41 +0,0 @@
-- Room consumables — the things a room sells.
--
-- The other half of a room's economy: `room_currency` (migration 0017) is the money a room
-- mints and hands out, and this is the shop that takes it back — a Health Potion for 25
-- tokens, a custom shirt for 500. A row here is the LISTING: its name, its picture, what it
-- costs and in which of the room's currencies. Written by
-- `PUT /api/roomconsumables/v1/roomConsumable` and read by
-- `GET /api/roomconsumables/v1/roomConsumable/room/{roomId}`. Owned by the `econ` worker;
-- generated from src/room-consumable-db.ts (ROOM_CONSUMABLE_SCHEMA_DDL) — keep in sync.
--
-- `room_consumable_id` is a GUID rather than an autoincrement, like a room currency's: it is
-- how the client names this listing afterwards, and a room's consumables are created
-- independently of every other room's.
--
-- The write body's `PriceAndCurrency` object is COLLAPSED here into `price` and
-- `purchase_currency_id`. It is one price in one currency, and a nested object would buy
-- nothing but a JSON blob to query through. Note the client READS them flat, so the write
-- shape and the read shape genuinely differ — do not unify them.
--
-- `purchase_currency_id` points at a `room_currency` and is nullable (`Guid?` in the client's
-- model), for a listing that names no currency. Nothing resolves or validates it yet: a
-- consumable priced in a currency that has since been deleted still lists, at a price nobody
-- can pay. `image_name` is nullable for the same reason — a listing with no picture. `price`
-- is not: a listing always has one, even if it is zero.
--
-- Nothing BUYS a consumable yet. There is no purchase endpoint, so
-- `maximum_count_per_purchase` is stored and served and read by nothing.
CREATE TABLE IF NOT EXISTS room_consumable (
room_consumable_id TEXT PRIMARY KEY,
room_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
image_name TEXT,
price INTEGER NOT NULL,
purchase_currency_id TEXT,
modified_at TEXT NOT NULL,
maximum_count_per_purchase INTEGER NOT NULL DEFAULT 0
);
-- Every read is "this room's shop" — the whole access pattern.
CREATE INDEX IF NOT EXISTS idx_room_consumable_room ON room_consumable (room_id);
@@ -1,26 +0,0 @@
-- What players own of a room's consumables — the other side of a room's shop. `room_consumable`
-- (migration 0021) is what a room sells; this is who has one. Written by
-- `POST /api/roomconsumables/v1/roomConsumable/awardBulk`. Owned by the `econ` worker;
-- generated from src/room-consumable-db.ts (ROOM_INVENTORY_SCHEMA_DDL) — keep in sync.
--
-- Keyed by CONSUMABLE, not by room: `room_consumable` already says which room a listing
-- belongs to, and repeating it here would be a second place for that to be wrong. It is the
-- same reasoning as `room_balance` (0019), which holds a currency id rather than a room id.
--
-- A missing row is a quantity of zero. Nothing is inserted until a player is first given one,
-- so a room's shop costs a row per player who actually owns something rather than one per
-- player who has looked at it.
--
-- `consumable_id` is not a foreign key — nothing in this database is — but the award route
-- checks the listing exists before writing: an inventory row naming nothing would be a thing a
-- player owns that cannot be described.
CREATE TABLE IF NOT EXISTS room_inventory (
player_id INTEGER NOT NULL,
consumable_id TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (player_id, consumable_id)
);
-- "What does this player own?" is the read the client makes on entering a room, so the player
-- half of the key earns its own index for the lookups that start there.
CREATE INDEX IF NOT EXISTS idx_room_inventory_player ON room_inventory (player_id);
@@ -1,20 +0,0 @@
-- Owned CUSTOM avatar items, owned by the `econ` worker. One row per (account, item): the
-- custom avatar items a player has bought through `POST /api/items/bulkpurchase` (a line whose
-- `ItemPurchaseMethodId` is a `Guid`, the item's `CustomAvatarItemId`) and read back by
-- `GET /econ/customAvatarItems/v1/owned`, which serves each owned item's full record.
--
-- Only the id is stored: the item itself lives in `custom_avatar_item`, whose schema the `api`
-- worker owns (apps/api/migrations/0022_custom_avatar_item_json.sql) on this same database,
-- and copying its record here would leave two rows to keep in step — the same split as
-- `inventory_invention` (0008). Ownership is boolean, so the pair is the primary key and a
-- second purchase is refused rather than charged twice. The creator is NOT listed here: they
-- own their item through `CreatorAccountId`, and the buy path refuses to sell them their own.
--
-- Kept in sync with INVENTORY_CUSTOM_SCHEMA_DDL in src/inventory-custom-db.ts.
CREATE TABLE IF NOT EXISTS inventory_custom (
account_id INTEGER NOT NULL,
custom_avatar_item_id TEXT NOT NULL,
acquired_at TEXT NOT NULL,
PRIMARY KEY (account_id, custom_avatar_item_id)
);
-36
View File
@@ -1,36 +0,0 @@
-- Room keys — a room's own admission tickets.
--
-- A creator lists a key for their room (`POST /api/roomkeys/v1/create`, form-encoded
-- `Type=Key&RoomId=1162&Name=my%20key&Description=…&Price=50`): a named, priced thing a
-- player can hold to get through a door the room locks. This is the LISTING — its name, its
-- price, which room it opens. Nothing SELLS a key yet, so there is no table of who holds one.
-- Owned by the `econ` worker; generated from src/room-key-db.ts (ROOM_KEY_SCHEMA_DDL) — keep
-- in sync.
--
-- `room_key_id` is an AUTOINCREMENT rather than a GUID, unlike a room currency's or a
-- consumable's: the client's own model (`LocalRoomKeyCreated`'s payload, which names a key by
-- a numeric `RoomKeyId`) says so. `replication_id` is the GUID beside it that the same
-- payload carries; it is minted here.
--
-- `key_type` is the body's `Type`, stored as the enum NAME it arrives as (`Key`) and served
-- back as its ordinal (`Type: 0`); only `Key` has been observed.
--
-- `price` is the body's `Price`, and `purchase_currency_id` the `room_currency` it is charged
-- in — nullable, `Guid?` in the client's model, and the create body names none, so it is null
-- until an edit can set it. `image_name` is '' in the row and served as a null `ImageName`
-- (what the client reads) until a key can carry art.
CREATE TABLE IF NOT EXISTS room_key (
room_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
replication_id TEXT NOT NULL,
room_id INTEGER NOT NULL,
key_type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
price INTEGER NOT NULL,
purchase_currency_id TEXT,
image_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
-- Every read is "this room's keys" — the whole access pattern.
CREATE INDEX IF NOT EXISTS idx_room_key_room ON room_key (room_id);
@@ -1,16 +0,0 @@
-- Who HOLDS a room key — one row per (key, player).
--
-- Written by `POST /api/roomkeys/v1/awardbulk`, which awards the CALLER the keys its body
-- names. A key is held or it isn't — there is no quantity — so the pair is the primary key
-- and awarding a key a player already holds changes nothing (`awarded_at` keeps the first
-- award). `room_key_id` is `room_key.room_key_id`. Owned by the `econ` worker; generated
-- from src/room-key-db.ts (ROOM_KEY_SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS room_key_player (
room_key_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
awarded_at TEXT NOT NULL,
PRIMARY KEY (room_key_id, account_id)
);
-- "This player's keys" — the primary key only serves lookups that lead with the key.
CREATE INDEX IF NOT EXISTS idx_room_key_player_account ON room_key_player (account_id);
@@ -1,20 +0,0 @@
-- An invention's `NumDownloads` is the number of players who have it: the rows in
-- `inventory_invention` that name it. `grantInvention` now re-derives it on every grant
-- (packages/domain/src/inventory-invention-db.ts); every purchase before that left it at
-- the 0 a save mints, so this backfills the inventions already bought.
--
-- The `invention` table is the `api` worker's (same database). `ModifiedAt` is left alone:
-- being bought is not an edit of the invention.
-- Backs that per-invention count; the primary key leads with account_id.
-- Generated from INVENTORY_INVENTION_SCHEMA_DDL — keep in sync.
CREATE INDEX IF NOT EXISTS idx_inventory_invention_invention
ON inventory_invention (invention_id);
UPDATE invention
SET data = json_set(
data,
'$.NumDownloads',
(SELECT COUNT(*) FROM inventory_invention WHERE invention_id = invention.id)
)
WHERE id IN (SELECT invention_id FROM inventory_invention);
-34
View File
@@ -1,34 +0,0 @@
-- Item wishlists, owned by the `econ` worker. One row per item a player has wished for —
-- what the store's heart toggle writes (`POST /api/itemWishlists/v1/wishlist/add|remove`,
-- form-encoded `purchasableItemId=1534&customAvatarItemId=`) and the wishlist page reads
-- (`GET /api/itemWishlists/v1/wishlist/me|{accountId}`, a bare array of entries).
--
-- `wishlist_item_id` is a GUID: the client's `WishlistItemId` is a `Guid`, not an int, so the
-- row's key has to be one. `player_id` is the account the list belongs to.
--
-- A row names ONE item in one of two ways — a catalog item by `purchasable_item_id` (the
-- storefront's `PurchasableItemId`) or a player-made/first-party custom avatar item by
-- `custom_avatar_item_id` (its GUID, stored lowercase) — so exactly one of the two is set.
-- On the wire the client's `PurchasableItemId` is a plain int, so a custom-item row is served
-- as `PurchasableItemId: 0` with the GUID beside it; the column is still NULL here so the two
-- kinds of row can't be confused.
--
-- The two partial unique indexes make a wish boolean: a second `add` of the same item finds
-- the existing row rather than making a twin. `idx_wishlist_player` is the read's access
-- pattern (one player's whole list).
--
-- Kept in sync with WISHLIST_SCHEMA_DDL in src/wishlist-db.ts.
CREATE TABLE IF NOT EXISTS wishlist (
wishlist_item_id TEXT PRIMARY KEY,
player_id INTEGER NOT NULL,
purchasable_item_id INTEGER,
custom_avatar_item_id TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_wishlist_player ON wishlist (player_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_purchasable
ON wishlist (player_id, purchasable_item_id) WHERE purchasable_item_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_custom
ON wishlist (player_id, custom_avatar_item_id) WHERE custom_avatar_item_id IS NOT NULL;
+1 -5
View File
@@ -15,13 +15,9 @@ export const SCHEMA_DDL: string[] = [
data TEXT NOT NULL,
avatar TEXT,
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL,
has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL,
is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`,
`CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1`,
`CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1`,
]
/** The stored avatar payload — opaque JSON the client sets and reads back. */
+47 -142
View File
@@ -1,13 +1,4 @@
import { BalancePlatform } from '../../notify/src/notification-payloads'
import { CurrencyType } from './currency'
import type { GiftContent } from '@repo/domain'
// The currency vocabulary and the Plus reload SQL live in the import-free `currency.ts` so
// the Node CLI in @repo/tools can share them; re-exported here so this stays the module
// everything else reads balances from.
export { CurrencyType, DEFAULT_STARTING_TOKENS, PLUS_MEMBERS_SQL, plusReloadSql } from './currency'
export type { CurrencyTypeValue } from './currency'
/**
* Currency balances on the shared `recflare` D1 database.
@@ -23,6 +14,46 @@ export type { CurrencyTypeValue } from './currency'
* it doesn't clash with the auth/rooms migration histories on the same database.
*/
/**
* The currencies the client knows about (its `CurrencyType` enum, obfuscated
* `GKPEKOLBBJL` — which lists every member below except `RoomInventoryItem`). The client sends
* these ints in the balance/storefront paths — `/api/storefronts/v4/balance/2` is
* RecCenterTokens — so the values are fixed by the client, not by us.
*
* What each one is:
* - `Invalid` (0): the enum's zero value. Never a real balance; a request for it is
* a client bug or a probe, and `isSpendable` rejects it.
* - `LaserTagTickets` (1): earned in the Laser Tag activity, spent in its own store.
* - `RecCenterTokens` (2): THE general-purpose currency — what players mean by
* "tokens", earned everywhere and spent in the avatar/gift-drop storefronts. This
* is the only one the client fetches on load, and the only one we grant at signup.
* - `LostSkullsGold` (100) / `DraculaSilver` (101): per-activity currencies for the
* Isle of Lost Skulls and Rise of Jumbo quests. Earned and spent inside those
* activities only.
* - `RecRoyaleSeason1` (200): a season currency for Rec Royale; legacy, no live faucet.
* - `RoomCurrency` (300) / `RoomInventoryItem` (301): NOT global balances. These are
* scoped to a specific room and served by the `/api/roomcurrencies/*` and
* `/api/roomconsumables/*` endpoints, whose rows are keyed by room as well as by
* account. They must never be stored in this (account, currency) table — a single
* row here couldn't say WHICH room's currency it is, so a player's coins in one
* room would spend in every other. `isSpendable` rejects them for that reason.
* - `ProgressionEvent` (400): an XP/progression counter the client models as a
* currency. Not spendable.
*/
export const CurrencyType = {
Invalid: 0,
LaserTagTickets: 1,
RecCenterTokens: 2,
LostSkullsGold: 100,
DraculaSilver: 101,
RecRoyaleSeason1: 200,
RoomCurrency: 300,
RoomInventoryItem: 301,
ProgressionEvent: 400,
} as const
export type CurrencyTypeValue = (typeof CurrencyType)[keyof typeof CurrencyType]
/**
* The account-scoped currencies this table stores. Everything else in `CurrencyType`
* is either not a balance (Invalid, ProgressionEvent) or is room-scoped and belongs to
@@ -39,6 +70,13 @@ const SPENDABLE: readonly number[] = [
/** Whether a currency is an account-scoped balance this table may hold. */
export const isSpendable = (currencyType: number): boolean => SPENDABLE.includes(currencyType)
/**
* The signup grant, in RecCenterTokens, when the `STARTING_TOKENS` var is unset.
* An operator overrides it in wrangler.jsonc `vars`; 0 is a valid setting and means
* players start broke.
*/
export const DEFAULT_STARTING_TOKENS = 10_000
/**
* What a player starts with, granted lazily the first time their balances are touched
* (see `ensureStartingBalances`). Currencies absent here start at 0.
@@ -201,136 +239,3 @@ export async function spendCurrency(
.run()
return meta.changes > 0
}
/** One token box to hand over: whose, how many, and the stored box. See {@link payTokenGifts}. */
export interface TokenGift {
accountId: number
/** Signed: positive credits, negative debits (guarded — see below), zero only boxes. */
amount: number
/** The box as stored in `received_gift`, serialized as-is. */
content: GiftContent
}
/** One box handed over by {@link payTokenGifts}: the resulting balance and the box's id. */
export interface PaidTokenGift extends TokenGift {
balance: number
giftId: number
}
/**
* How many accounts one round of {@link payTokenGifts} writes at once. Every statement
* binds three values per account, and D1 allows 100 bound parameters a statement.
*/
export const TOKEN_GIFT_CHUNK = 30
/**
* Hand RecCenterTokens in a gift box to MANY accounts at once — the staff drops, the room
* gift and the Discord supporter cron, which all pay a crowd. Paid one account at a time
* (seed, credit, re-read, box: five D1 round trips each) a full server was ~1,250 queries
* in series, past D1's 1,000-queries-an-invocation cap somewhere around 200 players, and
* died partway with the first players paid and the rest not. This pays
* {@link TOKEN_GIFT_CHUNK} accounts a round in two batches of multi-row statements.
*
* Per account it is exactly the one-player grant: the signup grant seeded first (a
* never-touched balance must not start from the gift alone), the balance moved, then a box
* stored. A credit is an upsert; a debit is `amount >= ?` guarded in the UPDATE itself, so
* nobody is overdrawn, and an account that can't cover it is SKIPPED — no box — rather than
* failing the rest. `paid` keeps the order of `gifts`; `skipped` is the rest. A duplicate
* account among `gifts` is paid once, the first entry's amount: a multi-row upsert may not
* name one key twice.
*
* Sends NOTHING: the caller builds the balance frame (the RESULTING total, `balance`, into
* the -2 bucket) and the box's announcement from the rows returned, and should push them in
* one hub call. Nothing here is transactional across rounds: a round that throws leaves the
* earlier ones paid, which the caller reports.
*/
export async function payTokenGifts(
db: D1Database,
gifts: readonly TokenGift[],
startingTokens: number
): Promise<{ paid: PaidTokenGift[]; skipped: number[] }> {
const grants = startingBalances(startingTokens)
const paid: PaidTokenGift[] = []
const skipped: number[] = []
const seen = new Set<number>()
const unique = gifts.filter((g) => !seen.has(g.accountId) && seen.add(g.accountId))
for (let i = 0; i < unique.length; i += TOKEN_GIFT_CHUNK) {
const chunk = unique.slice(i, i + TOKEN_GIFT_CHUNK)
const credits = chunk.filter((g) => g.amount > 0)
const debits = chunk.filter((g) => g.amount < 0)
const holds = chunk.filter((g) => g.amount === 0)
// Seed the signup grant, then move the money: one batch, one transaction. A debit's
// guard is per row, so a chunk of debits is one UPDATE per distinct amount.
const statements: D1PreparedStatement[] = [
db
.prepare(
`INSERT OR IGNORE INTO balance (account_id, currency_type, amount) VALUES ${chunk
.flatMap(() => grants.map(() => '(?, ?, ?)'))
.join(', ')}`
)
.bind(
...chunk.flatMap((g) => grants.flatMap((b) => [g.accountId, b.currencyType, b.amount]))
),
]
if (credits.length > 0) {
statements.push(
db
.prepare(
`INSERT INTO balance (account_id, currency_type, amount) VALUES ${credits
.map(() => '(?, ?, ?)')
.join(', ')}
ON CONFLICT (account_id, currency_type) DO UPDATE SET amount = amount + excluded.amount
RETURNING account_id, amount`
)
.bind(...credits.flatMap((g) => [g.accountId, CurrencyType.RecCenterTokens, g.amount]))
)
}
for (const amount of new Set(debits.map((g) => g.amount))) {
const ids = debits.filter((g) => g.amount === amount).map((g) => g.accountId)
statements.push(
db
.prepare(
`UPDATE balance SET amount = amount - ?1
WHERE currency_type = ?2 AND amount >= ?1
AND account_id IN (${ids.map(() => '?').join(', ')})
RETURNING account_id, amount`
)
.bind(-amount, CurrencyType.RecCenterTokens, ...ids)
)
}
if (holds.length > 0) {
statements.push(
db
.prepare(
`SELECT account_id, amount FROM balance
WHERE currency_type = ?1 AND account_id IN (${holds.map(() => '?').join(', ')})`
)
.bind(CurrencyType.RecCenterTokens, ...holds.map((g) => g.accountId))
)
}
const [, ...moved] = await db.batch<{ account_id: number; amount: number }>(statements)
// RETURNING comes back in no promised order; key it, then walk the chunk in its order.
const balances = new Map(moved.flatMap((r) => r.results).map((r) => [r.account_id, r.amount]))
const debited = chunk.filter((g) => balances.has(g.accountId))
for (const g of chunk) if (!balances.has(g.accountId)) skipped.push(g.accountId)
if (debited.length === 0) continue
const createdAt = new Date().toISOString()
const { results: boxes } = await db
.prepare(
`INSERT INTO received_gift (account_id, data, created_at) VALUES ${debited
.map(() => '(?, ?, ?)')
.join(', ')} RETURNING id, account_id`
)
.bind(...debited.flatMap((g) => [g.accountId, JSON.stringify(g.content), createdAt]))
.all<{ id: number; account_id: number }>()
const giftIds = new Map(boxes.map((r) => [r.account_id, r.id]))
for (const g of debited) {
paid.push({ ...g, balance: balances.get(g.accountId)!, giftId: giftIds.get(g.accountId)! })
}
}
return { paid, skipped }
}
+27 -39
View File
@@ -3,9 +3,9 @@ import { CatalogKind } from './catalog-load'
import type { CatalogKindValue } from './catalog-load'
/**
* The item CATALOG on the shared `recflare` D1 database — every avatar item, consumable and
* equipment skin the game knows about, one row each, loaded by a CLI rather than written at
* runtime. Nothing here is per-player: ownership lives in `inventory` and
* The item CATALOG on the shared `recflare` D1 database — every avatar item and every
* equipment skin the game knows about, one row each, loaded once by migration rather than
* written at runtime. Nothing here is per-player: ownership lives in `inventory` and
* `equipment`, and this table only says what a thing IS.
*
* It exists to be QUERIED. The catalogs were previously reachable only by parsing a whole
@@ -14,16 +14,15 @@ import type { CatalogKindValue } from './catalog-load'
* nothing can be searched by name or filtered by tag at all. A row with indexes answers those
* in one statement.
*
* ALL kinds share one table because they share most of a record — a display name, a tooltip, a
* BOTH kinds share one table because they share most of a record — a display name, a tooltip, a
* rarity, a platform mask, a thumbnail — and because the interesting queries ("what is called
* X", "what is this thing the player owns") run across both. `kind` discriminates, and the
* columns each kind alone carries are nullable and empty on the other. Read a row through
* {@link toCatalogAvatarItem}/{@link toCatalogSkin} rather than serving it raw: the client's
* two DTOs share no key order and differ in what they omit.
*
* Where the rows come from: the served 2025 general store (`static/storefronts/sf3-2025.json`)
* and `static/db/skins.json`, loaded by `runx catalog load`. A listed row's `catalog_id` is
* its store `PurchasableItemId`.
* Where the rows come from: `static/db/avatar-items.json` and `static/db/skins.json`, both
* captured from the reference and loaded by `runx catalog load`.
*
* The migration builds the TABLE ONLY — it holds no rows. The catalog changes as the game's
* item list changes, and that is not a schema change: a migration per refresh would mean a
@@ -43,15 +42,12 @@ export {
buildCatalogLoad,
CATALOG_INSERT_COLUMNS,
CatalogKind,
listingKind,
type AvatarItemCapture,
type CatalogCollision,
type CatalogKindValue,
type CatalogLoad,
type CatalogLoadRow,
type CatalogValue,
type SkinCapture,
type StoreListing,
type StorefrontDump,
} from './catalog-load'
/**
@@ -70,11 +66,12 @@ export {
* avatar items and repeated on 9 more (id 9503 alone covers five unrelated developer items), so
* it can neither key nor reliably find anything.
*
* `catalog_id` is the small NUMERIC handle the site uses where a key would be unwieldy. For a
* row the store lists it is the listing's own `PurchasableItemId` — the number `sf3-2025.json`
* sells it under — and for a skin the store never listed it is handed out by the loader from
* `CATALOG_ID_BASE`, well above any real one. It is unique where set, and the migration that
* adds it is 0016, since 0015 was already applied.
* `catalog_id` is the small NUMERIC handle the site uses where a key would be unwieldy, and is
* what a generated storefront lists a row under as its `PurchasableItemId`. It is assigned by
* the loader from `CATALOG_ID_BASE` (10000, clear of every captured storefront's own ids) and
* renumbered by every load, so it identifies a row only within one load — see the field's own
* note. It is unique where set, and the migration that adds it is 0016, since 0015 was already
* applied.
*
* Nullability is load-bearing rather than incidental: `tooltip` is genuinely NULL on some rows
* of BOTH kinds and `""` on others, and the client's DTOs serve the distinction through, so the
@@ -116,14 +113,13 @@ export interface CatalogRow {
item_key: string
/**
* A small numeric handle for the row — for the surfaces that need to name an item as a
* number rather than as a comma-laden `AvatarItemDesc` or a guid. For a listed item it is
* the store's `PurchasableItemId`, so the number resolves a row here and a listing in
* `sf3-2025.json` alike.
* number rather than as a comma-laden `AvatarItemDesc` or a guid, and the
* `PurchasableItemId` a generated storefront lists it under.
*
* Still not an identity: an unlisted skin's number is a load-order surrogate, and a store
* could renumber. Never store it, never treat it as what an item IS — `item_key` is that,
* and it is what the inventory holds. NULL only between a row existing and a load numbering
* it.
* A LOAD-ORDER SURROGATE, not an identity: `runx catalog load` assigns it, so it is stable
* only until the next load. Never store it, never reference it across a load, never treat it
* as what an item IS — `item_key` is that, and it is what the inventory holds. NULL only
* between a row existing and a load numbering it.
*/
catalog_id: number | null
kind: string
@@ -149,14 +145,6 @@ export interface CatalogRow {
*
* `AvatarItemId` is nullable and `Tooltip` may be null; both are true of the captured data and
* the client reads them that way. Do not tighten either to keep a projection simple.
*
* `CreatedAt` and `ThumbnailImage` are OPTIONAL, never null: the capture this record mirrors
* carried a date on most rows and simply had no key on the rest — the permanent hair dyes
* among them — and never `null`. A row loaded from a store listing has no date at all, and
* serving it as `"CreatedAt": null` on every record (as `lockeditems/bulk` did once the
* loader moved to the store) broke the dorm mirror's hair dyes: the client resolves locked
* hair colours through that route, and a null where it expects a date costs it the whole
* response, not the field. Leave the key out, as the capture did.
*/
export interface CatalogAvatarItem {
AvatarItemDesc: string
@@ -168,8 +156,8 @@ export interface CatalogAvatarItem {
TagList: string | null
AvatarItemId: number | null
IsBaseAvatarItem: boolean
CreatedAt?: string
ThumbnailImage?: string
CreatedAt: string | null
ThumbnailImage: string | null
}
/**
@@ -209,9 +197,8 @@ export function toCatalogAvatarItem(row: CatalogRow): CatalogAvatarItem {
TagList: row.tag_list,
AvatarItemId: row.avatar_item_id,
IsBaseAvatarItem: row.is_base_avatar_item === 1,
// Absent rather than null when the row has none — see the record's note.
...(row.created_at !== null ? { CreatedAt: row.created_at } : {}),
...(row.thumbnail_image !== null ? { ThumbnailImage: row.thumbnail_image } : {}),
CreatedAt: row.created_at,
ThumbnailImage: row.thumbnail_image,
}
}
@@ -257,10 +244,11 @@ export async function getCatalogItem(db: D1Database, itemKey: string): Promise<C
}
/**
* One catalog row by its numeric handle — a store `PurchasableItemId` for a listed item.
* One catalog row by its numeric handle.
*
* Fine for a request that looked the number up moments ago; never for anything stored, since
* an unlisted skin's number is reassigned by every load.
* Only meaningful WITHIN a load: the number is reassigned every time the catalog is loaded, so
* a caller holding one from before a reload will get a different item or nothing at all. Fine
* for a request that looked the number up moments ago; never for anything stored.
*/
export async function getCatalogItemById(
db: D1Database,
+130 -352
View File
@@ -10,15 +10,6 @@
* The mapping lives beside the schema it fills (rather than in the CLI) so that a column added
* to the table and a column added to the loader cannot drift apart — a test pins that they
* agree, and the loader renders values POSITIONALLY, so a mismatch is a silent mis-load.
*
* WHERE THE ROWS COME FROM. The 2025 general store as this worker serves it, and nothing else:
* `static/storefronts/sf3-2025.json`, the live game's storefront 3 dumped
* (`static/db/Watch_EnumValue_3.json`) and written out by `runx storefront build` with its
* captured sale stripped and the skins the store never listed (`static/db/skins.json`)
* appended as listings of their own. Loading the served file is what makes a row here and a
* listing there one record: same asset id, same number, always. An earlier `avatar-items.json`
* capture used to be the source and a storefront was generated FROM the table with invented
* ids; the dump made both unnecessary.
*/
/** What a catalog row IS — the discriminator, and what says which id `item_key` holds. */
@@ -27,78 +18,33 @@ export const CatalogKind = {
AvatarItem: 'avatar_item',
/** An equipment skin: a re-skin of a held prefab. Its `item_key` is the `ModificationGuid`. */
Skin: 'skin',
/** A consumable: food, a potion, a KO icon. Its `item_key` is the `ConsumableItemDesc`. */
Consumable: 'consumable',
} as const
export type CatalogKindValue = (typeof CatalogKind)[keyof typeof CatalogKind]
/** One price on a store listing. `StorefrontSaleData` is a discount the dump captured live. */
export interface StoreListingPrice {
CurrencyType: number
Price: number
StorefrontSaleData: {
SalePercent: number
SaleStartDate: string | null
SaleEndDate: string | null
} | null
}
/**
* A store listing's `GiftDrop` as the 2025 dump records it — the client's own `GiftDrop` class.
* Only the members the loader reads are typed; the rest ride along untouched.
* The capture's avatar-item record (`static/db/avatar-items.json`).
*
* Exactly ONE of the four item ids is set on a listing that carries an item: `AvatarItemDesc`,
* `ConsumableItemDesc`, `EquipmentModificationGuid` or `CustomAvatarItemId`. A query box
* (`IsQuery`) and a token bundle (`Currency` > 0) carry none.
* Everything from `TagList` down is absent on some rows — the 22 permanent hair dyes carry
* only the first six fields — so those are optional rather than nullable. The distinction
* matters: a missing key and a null value both land as NULL, but only one of them is a field
* the capture actually recorded.
*/
export interface StoreListingGiftDrop {
GiftDropId: number
export interface AvatarItemCapture {
AvatarItemDesc: string
AvatarItemType: number
PlatformMask: number
FriendlyName: string
Tooltip: string | null
TagList: string | null
ConsumableItemDesc: string
AvatarItemDesc: string
CustomAvatarItemId: string | null
AvatarItemType: number | null
EquipmentPrefabName: string
EquipmentModificationGuid: string
IsQuery: boolean
Rarity: number
Currency: number
CurrencyType: number
AvatarItemId: number
ThumbnailImageName: string | null
[extra: string]: unknown
TagList?: string | null
AvatarItemId?: number
IsBaseAvatarItem?: boolean
CreatedAt?: string
ThumbnailImage?: string | null
}
/** One listing of the store dump — a `StoreItems[]` entry, as the client reads it. */
export interface StoreListing {
PurchasableItemId: number
GiftDrop: StoreListingGiftDrop
Prices: StoreListingPrice[]
SubscriberPrices: StoreListingPrice[] | null
[extra: string]: unknown
}
/** One storefront page — the dump (`static/db/Watch_EnumValue_3.json`) or the served file. */
export interface StorefrontDump {
StoreItems: StoreListing[]
NextUpdate: string
StorefrontType: number
SubscriberDiscountPercent: number
[extra: string]: unknown
}
/**
* The capture's skin record (`static/db/skins.json`). Every field is present on every row.
*
* A thin capture: every `Rarity` is 0 or 1, every `UnlockedLevel` 0, every `ThumbnailImage`
* empty, and where a guid is also in the store the store's name and rarity are the real ones
* and disagree with these on every row. So it is read by `runx storefront build` ALONE, for
* the skins the store does not list — see {@link unlistedSkinListing} — and the loader never
* sees it.
*/
/** The capture's skin record (`static/db/skins.json`). Every field is present on every row. */
export interface SkinCapture {
PrefabName: string
ModificationGuid: string
@@ -112,146 +58,16 @@ export interface SkinCapture {
}
/**
* What a store listing SELLS — the catalog kind it loads as, or why it loads as nothing.
* The first `catalog_id` a load hands out.
*
* `custom_avatar_item` listings are first-party UGC: they live in the `custom_avatar_item`
* table (`just cai-load`), not here. `query` is a loot box and `currency` a token bundle —
* neither is an item. `unknown` carries no id at all (two expo items the dump prices at
* nothing). `consumable_pack` is assigned by the load rather than by {@link listingKind}: a
* consumable the store sells singly AND as a pack ("Disco Dance Break", "… 3-Pack") is one
* consumable listed twice, and the catalog is of things, not of listings.
* The catalog needs ids that cannot be confused with any captured storefront's, because a
* generated storefront lists a row under its `catalog_id` DIRECTLY — one number, no second
* numbering and no arithmetic between them. Every real captured `PurchasableItemId` is 2764 or
* below (one sf3 outlier at 20756767 aside), so numbering from 1 would have collided with sf3's
* own head-on and the same id would mean two different items depending on which storefront the
* client read it from. Starting at 10000 puts the whole catalog somewhere nothing else uses.
*/
export type ListingKind =
CatalogKindValue | 'custom_avatar_item' | 'query' | 'currency' | 'unknown' | 'consumable_pack'
/** Classify one listing by which of its item ids is set. */
export function listingKind(listing: StoreListing): ListingKind {
const drop = listing.GiftDrop
if (drop.AvatarItemDesc !== '') return CatalogKind.AvatarItem
if (drop.ConsumableItemDesc !== '') return CatalogKind.Consumable
if (drop.EquipmentModificationGuid !== '') return CatalogKind.Skin
if (drop.CustomAvatarItemId !== null && drop.CustomAvatarItemId !== '') {
return 'custom_avatar_item'
}
if (drop.IsQuery) return 'query'
if (drop.Currency > 0) return 'currency'
return 'unknown'
}
/**
* The dump's listings with its verbatim repeats collapsed — twenty listings appear twice, byte
* for byte. One copy is kept, in first position; a repeated id whose copies DIFFER is refused,
* since order would then decide what the id means. The storefront file is written from this,
* and the catalog load reads that file, so neither sees a repeat as a duplicate item.
*/
export function collapseRepeatedListings(listings: StoreListing[]): {
listings: StoreListing[]
repeats: number
} {
const byId = new Map<number, string>()
const kept: StoreListing[] = []
let repeats = 0
for (const listing of listings) {
const json = JSON.stringify(listing)
const first = byId.get(listing.PurchasableItemId)
if (first === undefined) {
byId.set(listing.PurchasableItemId, json)
kept.push(listing)
} else if (first === json) {
repeats++
} else {
throw new Error(
`PurchasableItemId ${listing.PurchasableItemId} is listed twice with different contents`
)
}
}
return { listings: kept, repeats }
}
/**
* The first `PurchasableItemId` given to a skin the store does NOT list.
*
* Every other number in `sf3-2025.json` is the game's own. The skins the 2025 store never sold
* still have to be listed — the weekly gift pool and the discovery rows draw skins from the
* store, and a `catalog` row IS a listing — so they are appended with numbers of their own,
* from here, in `skins.json` order. The store's ids top out below 40 000; a million is clear of
* anything a fresher dump could bring.
*/
export const UNLISTED_SKIN_ID_BASE = 1_000_000
/** The rarity an unlisted skin is sold at: the top tier, where the store puts most of its own. */
export const UNLISTED_SKIN_RARITY = 50
/**
* What a GOLD skin costs — any skin whose name carries `(Gold)`, thirteen of them, all from the
* capture. A prestige price, and the same for a subscriber: no discount, no sale.
*/
export const GOLD_SKIN_PRICE = 5_000_000
/** Whether a listing is a gold skin — a skin whose name says `(Gold)`. */
export const isGoldSkin = (listing: StoreListing): boolean =>
listing.GiftDrop.EquipmentModificationGuid !== '' &&
listing.GiftDrop.FriendlyName.includes('(Gold)')
/** RecCenterTokens — the currency the store sells in. */
const CURRENCY_TYPE_TOKENS = 2
/**
* A skin the store does not list, AS a listing — the dump's own skin shape, so the client reads
* it like the rest. Rarity {@link UNLISTED_SKIN_RARITY} and priced from it: the store's own
* rarity-50 skins run 1000-6000 tokens, and {@link PRICE_BY_RARITY} puts this in the middle.
* The capture's name and tooltip. `ThumbnailImageName` is `""` — as it is on EVERY skin the
* served store lists, the game's own included (see `runx storefront build`).
*/
export function unlistedSkinListing(skin: SkinCapture, id: number): StoreListing {
const price = priceForRarity(UNLISTED_SKIN_RARITY)
const priced = (p: number): StoreListingPrice => ({
CurrencyType: CURRENCY_TYPE_TOKENS,
Price: p,
StorefrontSaleData: null,
})
return {
GiftDrop: {
GiftDropId: id,
FriendlyName: skin.FriendlyName,
Tooltip: skin.Tooltip ?? '',
TagList: '',
ConsumableItemDesc: '',
AvatarItemDesc: '',
CustomAvatarItemId: null,
AvatarItemType: null,
EquipmentPrefabName: skin.PrefabName,
EquipmentModificationGuid: skin.ModificationGuid,
IsQuery: false,
QueryRedirectContext: null,
QueryRedirectTag: null,
QueryRedirectRarity: null,
Unique: false,
SubscribersOnly: false,
Rarity: UNLISTED_SKIN_RARITY,
Context: 1003,
Currency: 0,
CurrencyType: 0,
ItemCount: 1,
ItemSetId: null,
ItemSetFriendlyName: '',
AvatarItemId: 0,
EquipmentItemId: 0,
ThumbnailImageName: '',
AvatarItemInfo: null,
},
PurchasableItemId: id,
Type: 0,
Prices: [priced(price)],
SubscriberPrices: [priced(subscriberPriceFor(price))],
IsFeatured: false,
NewUntil: null,
AvailableAt: null,
AvailableUntil: null,
CanBeGifted: true,
OnlyAvailableThroughCv2: false,
}
}
export const CATALOG_ID_BASE = 10_000
/** The columns a load writes, in the order {@link toCatalogInsertRow} returns values. */
export const CATALOG_INSERT_COLUMNS = [
@@ -278,7 +94,7 @@ export type CatalogValue = string | number | boolean | null | undefined
/** One row of a load, plus enough to name it in a collision report. */
export interface CatalogLoadRow {
key: string
/** Its `catalog_id`: the listing's `PurchasableItemId`. */
/** Its `catalog_id`: 1-based position in this load, also present inside `values`. */
id: number
label: string
values: CatalogValue[]
@@ -291,185 +107,126 @@ export interface CatalogCollision {
dropped: string
}
/** What {@link buildCatalogLoad} produced, and what it left out. */
export interface CatalogLoad {
rows: CatalogLoadRow[]
collisions: CatalogCollision[]
/** Listings that load as nothing, counted by {@link ListingKind}. */
skipped: Partial<Record<ListingKind, number>>
}
/**
* The store records no platform mask — every listing is for every platform, which is what the
* `platform_mask` column's `-1` default means.
*/
const ALL_PLATFORMS = -1
/**
* A listed avatar item. `is_base_avatar_item` and `created_at` are NULL because a store listing
* records neither; a NULL there is "not recorded", which is true.
*/
function avatarItemRow(listing: StoreListing): CatalogLoadRow {
const drop = listing.GiftDrop
function avatarItemRow(i: AvatarItemCapture): Omit<CatalogLoadRow, 'id'> {
return {
key: drop.AvatarItemDesc,
id: listing.PurchasableItemId,
label: `${drop.FriendlyName} (avatar item)`,
key: i.AvatarItemDesc,
label: `${i.FriendlyName} (avatar item)`,
values: [
drop.AvatarItemDesc,
listing.PurchasableItemId,
i.AvatarItemDesc,
// Filled in by buildCatalogLoad once the de-duplicated order is known.
null,
CatalogKind.AvatarItem,
drop.FriendlyName,
drop.Tooltip,
drop.Rarity,
ALL_PLATFORMS,
drop.ThumbnailImageName,
drop.AvatarItemType ?? 0,
// 0 is the dump's "none"; the column's is NULL.
drop.AvatarItemId || null,
null,
drop.TagList,
null,
i.FriendlyName,
i.Tooltip,
i.Rarity,
i.PlatformMask,
i.ThumbnailImage,
i.AvatarItemType,
i.AvatarItemId,
i.IsBaseAvatarItem ?? false,
i.TagList,
i.CreatedAt,
null,
null,
],
}
}
/** A listed consumable: the shared columns only, keyed by its `ConsumableItemDesc`. */
function consumableRow(listing: StoreListing): CatalogLoadRow {
const drop = listing.GiftDrop
function skinRow(s: SkinCapture): Omit<CatalogLoadRow, 'id'> {
return {
key: drop.ConsumableItemDesc,
id: listing.PurchasableItemId,
label: `${drop.FriendlyName} (consumable)`,
key: s.ModificationGuid,
label: `${s.FriendlyName} (skin, ${s.PrefabName})`,
values: [
drop.ConsumableItemDesc,
listing.PurchasableItemId,
CatalogKind.Consumable,
drop.FriendlyName,
drop.Tooltip,
drop.Rarity,
ALL_PLATFORMS,
drop.ThumbnailImageName,
s.ModificationGuid,
// Filled in by buildCatalogLoad once the de-duplicated order is known.
null,
null,
null,
drop.TagList,
null,
null,
null,
],
}
}
/** A listed skin: what the listing carries. */
function listedSkinRow(listing: StoreListing): CatalogLoadRow {
const drop = listing.GiftDrop
return {
key: drop.EquipmentModificationGuid,
id: listing.PurchasableItemId,
label: `${drop.FriendlyName} (skin, ${drop.EquipmentPrefabName})`,
values: [
drop.EquipmentModificationGuid,
listing.PurchasableItemId,
CatalogKind.Skin,
drop.FriendlyName,
drop.Tooltip,
drop.Rarity,
ALL_PLATFORMS,
drop.ThumbnailImageName,
s.FriendlyName,
s.Tooltip,
s.Rarity,
s.PlatformMask,
s.ThumbnailImage,
null,
null,
null,
null,
null,
drop.EquipmentPrefabName,
null,
s.PrefabName,
s.UnlockedLevel,
],
}
}
/**
* Turn the served store into the rows a load writes, de-duplicated on `item_key`.
* Turn both captures into the rows a load writes, de-duplicated on `item_key`.
*
* `item_key` is unique across ALL kinds, so a repeat is a defect in the source rather than
* `item_key` is unique across BOTH kinds, so a repeat is a defect in the capture rather than
* something the table should model. First occurrence wins and the rest are RETURNED rather
* than dropped on the floor: the caller has to report them, because a collision that vanishes
* quietly is the exact failure the single key exists to prevent. (The dump's verbatim repeats
* are collapsed by {@link collapseRepeatedListings} before the served file is written, so they
* never get here; a consumable's PACK is counted, not reported — see {@link ListingKind}.)
* quietly is the exact failure the single key exists to prevent.
*
* Rows come out in STORE ORDER, one per listing that sells a catalog item, and `catalog_id` is
* the listing's `PurchasableItemId` — a real, stable number, the one the client buys by. A
* store id naming two different keys is refused outright: it cannot be resolved by order, and
* nothing in the dump does it.
* Each surviving row is also numbered — `catalog_id`, from {@link CATALOG_ID_BASE} upward in
* capture order, the small numeric handle the site uses in place of a comma-laden desc or a
* guid, and the `PurchasableItemId` a generated storefront lists it under. It is assigned here
* rather than by the database so the caller can render it into the same INSERT, and it is a
* LOAD-ORDER surrogate: the next load renumbers, and nothing may store it.
*/
export function buildCatalogLoad(listings: StoreListing[]): CatalogLoad {
export function buildCatalogLoad(
avatarItems: AvatarItemCapture[],
skins: SkinCapture[]
): { rows: CatalogLoadRow[]; collisions: CatalogCollision[] } {
const seen = new Map<string, string>()
const idOwner = new Map<number, string>()
const rows: CatalogLoadRow[] = []
const collisions: CatalogCollision[] = []
const skipped: Partial<Record<ListingKind, number>> = {}
/** Keep the row, or report it as a repeat of one already kept. */
const push = (row: CatalogLoadRow): void => {
const idAt = CATALOG_INSERT_COLUMNS.indexOf('catalog_id')
for (const row of [...avatarItems.map(avatarItemRow), ...skins.map(skinRow)]) {
const kept = seen.get(row.key)
if (kept !== undefined) {
collisions.push({ key: row.key, kept, dropped: row.label })
return
}
const owner = idOwner.get(row.id)
if (owner !== undefined) {
throw new Error(`catalog_id ${row.id} names two items: ${owner} and ${row.label}`)
continue
}
seen.set(row.key, row.label)
idOwner.set(row.id, row.label)
rows.push(row)
// Numbered AFTER de-duplication and from {@link CATALOG_ID_BASE}, so a load's ids are
// exactly BASE..BASE+rows.length-1 with no gaps — a dropped duplicate must not burn a
// number.
const id = CATALOG_ID_BASE + rows.length
row.values[idAt] = id
rows.push({ ...row, id })
}
for (const listing of listings) {
const kind = listingKind(listing)
switch (kind) {
case CatalogKind.AvatarItem:
push(avatarItemRow(listing))
break
case CatalogKind.Consumable:
// The pack of a consumable already listed singly: one thing, one row.
if (seen.has(listing.GiftDrop.ConsumableItemDesc)) {
skipped.consumable_pack = (skipped.consumable_pack ?? 0) + 1
break
}
push(consumableRow(listing))
break
case CatalogKind.Skin:
push(listedSkinRow(listing))
break
default:
skipped[kind] = (skipped[kind] ?? 0) + 1
}
}
return { rows, collisions, skipped }
return { rows, collisions }
}
/**
* Rarities that mark the developer/unreleased tier.
* Rarities that are NOT sold, and so never appear in a generated storefront.
*
* `-1`. The 2025 store lists a handful of such items and this server serves that store
* verbatim, so they CAN be bought; the tier is kept out of the discovery rows the `lists`
* worker draws from the catalog, which is the one place this is still read.
* `-1` is the developer/unreleased tier. The items carrying it stay in the `catalog` table —
* that is a record of what EXISTS — but a storefront is a record of what is for SALE, and an
* item listed in one can be bought: `findStoreItem` resolves a purchase against the catalog
* file itself, so listing them at any price would put them on sale.
*
* Lives here rather than in the storefront generator because two things need the same answer:
* the generator, which omits them, and anything that hands the client a PurchasableItem id,
* which must not name one the storefront never listed. A client asked to resolve an id no
* storefront sells renders nothing, indistinguishably from an id it failed to parse.
*/
export const UNSELLABLE_RARITIES: readonly number[] = [-1]
/** Whether an item of this rarity is outside the developer tier. */
/** Whether an item of this rarity may appear in a storefront. */
export const isSellableRarity = (rarity: number): boolean => !UNSELLABLE_RARITIES.includes(rarity)
/**
* What an item costs by rarity, in RecCenterTokens — for the things the store dump does not
* price: the skins it never listed ({@link unlistedSkinListing}) and a first-party custom
* avatar item it does not list (`apps/api/scripts/price-custom-avatar-items.ts`). Everything
* the dump lists carries its real price, verbatim.
* What a catalog item costs, by rarity — the pricing every surface that sells a catalog row
* must agree on.
*
* Shared rather than living in the storefront generator alone because a purchase is CHECKED
* against the price the client was shown: `priceCheck` compares the posted `RequestedPrice`
* with the catalog's, and a server that priced a buy differently from the file it listed would
* refuse every purchase as "Price has changed". One table, both sides.
*
* The tiers 0/10/30/50 were specified; 20 sits between its neighbours at 700 (sf3's own
* rarity-20 items cluster at 400-600, but 600 is taken by rarity 10 here, and two tiers sharing
* a price makes the rarity invisible). {@link UNSELLABLE_RARITIES} is priced by nothing — those
* items are not sold at all.
*/
export const PRICE_BY_RARITY: Record<number, number> = {
0: 150,
@@ -480,31 +237,52 @@ export const PRICE_BY_RARITY: Record<number, number> = {
}
/**
* What a rarity absent from {@link PRICE_BY_RARITY} costs — the bottom tier, never free. A
* floor rather than a skip because an unpriced item silently vanishing is harder to notice
* than one that turns up cheap.
* What a rarity absent from {@link PRICE_BY_RARITY} costs — the bottom tier, never free.
*
* Only reachable if a future capture introduces a rarity nobody has priced. A floor rather than
* a skip because an unpriced item silently vanishing from the store is harder to notice than
* one that turns up cheap; a rarity meant to be unsellable belongs in
* {@link UNSELLABLE_RARITIES}, where a load reports it.
*/
export const DEFAULT_PRICE = 150
/** What one item of this rarity costs, in RecCenterTokens. */
/** What one catalog row costs, in RecCenterTokens. */
export const priceForRarity = (rarity: number): number => PRICE_BY_RARITY[rarity] ?? DEFAULT_PRICE
/**
* What Rec Room Plus takes off, in percent — the same number the econ worker's own
* `subscriberFloor` allows, which is what makes a subscriber's discounted `RequestedPrice` land
* inside the band rather than through the floor.
* What Rec Room Plus takes off, in percent. The same number the server's own `subscriberFloor`
* allows, which is what makes a subscriber's discounted `RequestedPrice` land inside the band
* rather than through the floor.
*/
export const SUBSCRIBER_DISCOUNT_PERCENT = 10
/** The subscriber price for a regular one. Floored, matching the worker's `subscriberFloor`. */
/** The subscriber price for a regular one. Floored, matching the server's `subscriberFloor`. */
export const subscriberPriceFor = (regular: number): number =>
Math.floor((regular * (100 - SUBSCRIBER_DISCOUNT_PERCENT)) / 100)
/**
* The last client build served the 2023 general store.
* The last client build treated as the 2023-era one, as a build number and as the ISO date the
* item catalogue records `CreatedAt` in.
*
* The econ worker compares a caller's token `rn.ver` to this to pick which storefront FILE
* they get for storefront 3: `sf3.json` (the 2023 store, frozen) up to and including this
* build, `sf3-2025.json` (the 2025 store dump) after it.
* One constant in two forms because two things key off the same moment: the econ worker decides
* which storefront FILE a caller is served by comparing their token's `rn.ver` to the number,
* and the storefront generator decides which ITEMS go in the 2023 file by comparing each item's
* `CreatedAt` to the date. They must not drift — a build served the old store but a store built
* to a different date would sell items that build has never heard of.
*/
export const LEGACY_CLIENT_BUILD = 20_230_414
/** {@link LEGACY_CLIENT_BUILD} as `YYYY-MM-DD`, for comparing against a `CreatedAt`. */
export const LEGACY_CLIENT_BUILD_DATE = '2023-04-14'
/**
* Whether an item existed by the time of {@link LEGACY_CLIENT_BUILD} — i.e. whether the 2023
* store should sell it.
*
* An item with NO `CreatedAt` is treated as too new and left out: three catalogue rows carry
* none, and there is no way to show they predate the cutoff. Excluding is the conservative
* direction — the 2023 store missing three items nobody noticed is better than it offering
* something that build cannot render.
*/
export const existedByLegacyBuild = (createdAt: string | null | undefined): boolean =>
typeof createdAt === 'string' && createdAt.slice(0, 10) < LEGACY_CLIENT_BUILD_DATE
-11
View File
@@ -24,17 +24,6 @@ export type Env = SharedHonoEnv & {
* read it through `intVar`, never as a bare number.
*/
STARTING_TOKENS?: string | number
/**
* The Discord supporter gift (see discord-role-gift.ts): Discord ROLE ids mapped to the
* RecCenterTokens a holder is boxed each time the cron fires, `<roleId>=<tokens>` pairs
* separated by commas, e.g. `928457923857943795=2500,2938479238479234=10000`. Role ids
* are snowflakes — all digits, never a role's name — kept as strings because one
* exceeds 2^53. One box per account per run: a holder of several mapped roles is paid
* the HIGHEST amount. Optional: unset, or parsing to no roles, leaves the cron off.
* Roles are read from `platform_account.role`, the snapshot `www`'s claim and daily
* sweep maintain, so this only ever pays players who have claimed on the website.
*/
DISCORD_ROLE_TOKENS?: string
}
/** Variables can be extended */

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