mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 07:28:53 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aef4cc0139 |
@@ -1,21 +1,12 @@
|
||||
---
|
||||
name: weekly-challenge-config
|
||||
description: Read and author the `Config` rule tree a weekly challenge carries — the full node-type enum, event types, event variables, named scene constants, the shared-scene traps, and where the generator in apps/econ/src/challenge-rotation.ts emits them from
|
||||
description: Read and author the `Config` rule tree in apps/econ/static/weekly-challenge.json — the full node-type enum, event types, event variables, named scene constants, and the shared-scene traps
|
||||
---
|
||||
|
||||
# The weekly-challenge `Config` rule tree
|
||||
|
||||
Reference for reading and writing the `Config` field of a weekly challenge (served by
|
||||
`GET /api/challenge/v2/getCurrent`).
|
||||
|
||||
**Rotations are generated, so there are two places a tree comes from.** Normally
|
||||
`apps/econ/src/challenge-rotation.ts` emits it: a week is five (room, kind) pairs drawn from
|
||||
`CHALLENGE_ROOMS` with the week's seed, and the tree is built by `configFor` from one of the
|
||||
three idioms below. Adding variety means adding a room or a kind there, not hand-writing a
|
||||
tree. The other place is `apps/econ/static/weekly-challenge.json`: a non-empty `Challenges`
|
||||
array in that file PINS the week to a hand-authored rotation and skips generation, which is
|
||||
how a one-off debug or event week gets served. Both end up as the same `Config` string on
|
||||
the wire, and everything below applies to both.
|
||||
Reference for reading and writing the `Config` field of a challenge in
|
||||
`apps/econ/static/weekly-challenge.json` (served by `GET /api/challenge/v2/getCurrent`).
|
||||
|
||||
**The server never evaluates these rules.** The client reads the tree, watches its own
|
||||
gameplay, and posts the tree back to `/api/challenge/v2/updateProgress` with its verdict.
|
||||
@@ -52,16 +43,13 @@ Author the tree as an object and stringify it into the field — don't hand-esca
|
||||
bun -e 'const t={ct:0,ipc:false,wc:[{ct:6,vs:[2]}]}; console.log(JSON.stringify(JSON.stringify(t)))'
|
||||
```
|
||||
|
||||
To read this week's back (the generated rotation, or the pinned file if one is in place):
|
||||
To read one back:
|
||||
|
||||
```sh
|
||||
bun -e 'const {buildRotation}=await import("./apps/econ/src/challenge-rotation.ts");
|
||||
for (const x of buildRotation(new Date()).Challenges)
|
||||
console.log(x.ChallengeId, x.Description, "\n ", JSON.parse(x.Config))'
|
||||
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json");
|
||||
for (const x of c.Challenges) console.log(x.ChallengeId, x.Description, "\n ", JSON.parse(x.Config))'
|
||||
```
|
||||
|
||||
Pass a date to look at any other week — the rotation is a pure function of which week it is.
|
||||
|
||||
## Node types (`ct`) — the full enum
|
||||
|
||||
**(lib)** `ChallengeTypes`. Every node carries one. Bold rows are the ones the captured
|
||||
@@ -305,12 +293,10 @@ On `updateProgress` the client posts the same tree back with its own progress wr
|
||||
it: **`cc`** on a counter is the current count (`…,"t":5,"cc":1`), and **`c`** (`"c":true`)
|
||||
marks a node it now considers satisfied.
|
||||
|
||||
Neither belongs in an authored tree — they are progress, not definition. The server
|
||||
stores the posted tree per player (`challenge_status.config`; see
|
||||
`apps/econ/src/challenge-db.ts`) and `getCurrent` serves it back in place of the authored
|
||||
tree, which is how a half-finished challenge survives a session — but it still evaluates
|
||||
none of it: the counting is the client's. Don't author `cc`/`c`, and don't try to read
|
||||
progress out of the tree you author.
|
||||
Neither belongs in `weekly-challenge.json` — they are progress, not definition. The server
|
||||
echoes the posted `Config` back untouched and never persists it (`challenge_status` stores
|
||||
only the completion flag; see `apps/econ/src/challenge-db.ts`), so the running count lives
|
||||
only in the client. Don't author `cc`/`c`, and don't try to read progress out of one.
|
||||
|
||||
This is also the cheapest way to decode an unfamiliar tree: serve it, play the activity, and
|
||||
watch which node grows a `cc`.
|
||||
@@ -336,30 +322,7 @@ all worth checking before trusting a lib-only field:
|
||||
- **`SpawnableToolTypes` re-rolls per build**, so `ct:5` and any `t_t` comparison is pinned
|
||||
to one client version.
|
||||
|
||||
## Adding to the generator
|
||||
|
||||
This is the usual way a new challenge ships: the week picks from `CHALLENGE_ROOMS` in
|
||||
`apps/econ/src/challenge-rotation.ts`, so a room added there starts appearing in rotations
|
||||
on its own.
|
||||
|
||||
1. **A new room** — add an entry with its `UnitySceneId`(s) from
|
||||
`apps/rooms/static/ImportRooms.json`. A scene no room on this server hosts can never be
|
||||
completed and nothing will tell you. Check the shared-scene table above and record the
|
||||
extra rooms in `shares`. Set `kinds` conservatively: `win` reads the `won` variable, so
|
||||
only rooms where winning is a real outcome; `ai` is quests. **Append, never insert** —
|
||||
`ChallengeId` is the candidate's index, so inserting renumbers every challenge after it.
|
||||
2. **A new kind** — add it to `ChallengeKind` and give it a branch in all three of
|
||||
`configFor` (the tree), `copyFor` (the strings) and `nameFor` (the slug). The compiler
|
||||
will point at the two you forget. Build the tree from an idiom below; the copy is
|
||||
generated from the same inputs so it can't drift out of step with the tree.
|
||||
3. Keep the target constants (`GAMES_TARGET`, `AI_TARGET`) as the single source for both the
|
||||
tree and the copy.
|
||||
|
||||
## Authoring a pinned rotation
|
||||
|
||||
For a one-off week: put challenges in `apps/econ/static/weekly-challenge.json` and the file
|
||||
takes over completely — generation is skipped, and its `Gift`, window and `ChallengeMapId`
|
||||
are served as written.
|
||||
## Authoring a new challenge
|
||||
|
||||
1. Pick the idiom: one-shot (`ct:0` root, add the `won` predicate if winning is required),
|
||||
counted (`ct:1` root with `t`), or buffered/streak (`ct:2` root, `rc` on the child).
|
||||
@@ -375,24 +338,21 @@ are served as written.
|
||||
client renders the strings and evaluates the tree independently, so a mismatch ships a
|
||||
challenge that advances somewhere the text never mentions.
|
||||
6. Leave `Complete: false`; `getCurrent` stamps it per caller.
|
||||
7. Set a `ChallengeMapId` that no recent week has used — a new map id is what resets stored
|
||||
completions, and generated weeks are `1000 + weekIndex`, so stay well clear of that range.
|
||||
7. Bump `ChallengeMapId` if this is a new rotation — ids only need to be unique within one,
|
||||
and a new map id is what resets stored completions.
|
||||
8. Keep `ServerTime` inside `StartAt`…`EndAt`, or the client renders the rotation as expired.
|
||||
A pinned file is static, so its clock has to be frozen there; a generated week doesn't,
|
||||
because its window is really the current one.
|
||||
|
||||
Sanity check that every tree in this week's rotation parses, pinned or generated:
|
||||
Sanity check the file parses and every tree parses:
|
||||
|
||||
```sh
|
||||
bun -e 'const {buildRotation}=await import("./apps/econ/src/challenge-rotation.ts");
|
||||
const c=buildRotation(new Date());
|
||||
c.Challenges.forEach(x => JSON.parse(x.Config)); console.log("ok", c.ChallengeMapId, c.Challenges.length)'
|
||||
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json");
|
||||
c.Challenges.forEach(x => JSON.parse(x.Config)); console.log("ok", c.Challenges.length)'
|
||||
```
|
||||
|
||||
Then `bun vitest run apps/econ` — `src/test/integration/api.test.ts` builds the same rotation
|
||||
and asserts `getCurrent` against it, and walks two years of generated weeks checking every
|
||||
tree. Note the gift threshold follows the rotation size (`CHALLENGES_REQUIRED_FOR_GIFT`
|
||||
clamps to what you publish), so a pinned rotation of three or fewer asks for all of them.
|
||||
Then `bun vitest run apps/econ` — `src/test/integration/api.test.ts` imports the file and
|
||||
asserts `getCurrent` against it. Note the gift threshold follows the rotation size
|
||||
(`CHALLENGES_REQUIRED_FOR_GIFT` clamps to what you publish), so a rotation of three or fewer
|
||||
asks for all of them.
|
||||
|
||||
## Credits
|
||||
|
||||
|
||||
+5
-110
@@ -37,27 +37,6 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# spliced in at deploy time. Required to deploy any worker.
|
||||
# RECFLARE_SECRETS_STORE=00000000-0000-0000-0000-000000000000
|
||||
|
||||
# OPTIONAL: run the workers on a libSQL server (Turso) INSTEAD of the D1 database above. Both
|
||||
# are SQLite; the schema, the migrations and the workers are the same, and the D1 database
|
||||
# stays bound (and RECFLARE_D1 still required) so switching back is unsetting this line and
|
||||
# redeploying. Leave both unset to use D1, which is the default.
|
||||
#
|
||||
# The URL is the one Turso shows for the database (`turso db show <name> --url`), as-is:
|
||||
#
|
||||
# RECFLARE_LIBSQL_DB_URL=libsql://<database>-<org>.turso.io
|
||||
#
|
||||
# The token (`turso db tokens create <name>`) is the one entry in this file that IS a secret.
|
||||
# It is never passed as a var: `just deploy` puts it as an encrypted worker secret, and
|
||||
# `just migrate` / `runx admin --remote` read it from here to reach the database directly.
|
||||
#
|
||||
# RECFLARE_LIBSQL_DB_AUTH_TOKEN=eyJ...
|
||||
#
|
||||
# `just dev` and the tests ignore both and stay on the local D1. Setting the URL changes what
|
||||
# `just migrate` and every `--remote` operator command talk to — the libSQL database, not D1 —
|
||||
# so it is one switch for the whole deployment: the workers, their migrations and the tooling
|
||||
# all move together, which is the only sane state for fifteen workers sharing one database.
|
||||
# To carry existing data over, see "Using Turso instead of D1" in DEPLOYING.md.
|
||||
|
||||
# --- Server tuning (all optional; the shown value is the built-in default) ---
|
||||
# Everything below is passed to the workers as a variable, named without the RECFLARE_
|
||||
# prefix: RECFLARE_STARTING_TOKENS becomes STARTING_TOKENS. Every worker gets every knob —
|
||||
@@ -108,17 +87,6 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
|
||||
# RECFLARE_MAX_CLUBS_PER_ACCOUNT=10
|
||||
|
||||
# Largest binary the storage worker accepts in one upload, in bytes. The default is
|
||||
# 64 MiB. The endpoint rejects larger files with HTTP 413 before writing to R2. This
|
||||
# value must be a positive integer; zero or an invalid value restores the default.
|
||||
# RECFLARE_MAX_UPLOAD_BYTES=67108864
|
||||
|
||||
# Largest file the API worker accepts through either saved-image upload or either
|
||||
# custom-avatar-item file field, in bytes. The default is 64 MiB PER FILE. Larger
|
||||
# parsed files receive HTTP 413 before arrayBuffer() or an R2 write. This complements
|
||||
# RECFLARE_MAX_UPLOAD_BYTES, which protects the separate storage worker.
|
||||
# RECFLARE_MAX_API_UPLOAD_BYTES=67108864
|
||||
|
||||
# Rooms to switch out at matchmake time (`match`), as comma-separated <fromRoomId>=<to>
|
||||
# pairs, where <to> is a room id or room name. This is how a stock RRO room is replaced
|
||||
# with your own: 2=MyHub sends everyone who matchmakes into the Rec Center (room 2) to the
|
||||
@@ -140,26 +108,12 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# RECFLARE_PHOTON_VOICE_APP_ID=
|
||||
# RECFLARE_PHOTON_CHAT_APP_ID=
|
||||
|
||||
# The pool of Tachyon servers sessions are spread across (`match`, GET
|
||||
# /player/connection-info): a COMMA-SEPARATED list of `host:port` entries, one of which
|
||||
# the client is handed as `voiceConnectionInfo`. EMPTY unless you set it — no separate
|
||||
# voice server. Not a secret (the client receives the address in the clear), like the
|
||||
# Photon ids above. One entry is the ordinary single-server case:
|
||||
# The Tachyon voice server (`match`, GET /player/connection-info): the `host:port` the
|
||||
# client is handed as `voiceConnectionInfo`, and its id as `voiceServerId`. EMPTY unless
|
||||
# you set them — no separate voice server. Set both or neither; like the Photon ids they
|
||||
# are not secrets (the client receives them in the clear).
|
||||
# RECFLARE_TACHYON_HOST_PORT=127.0.0.1:7777
|
||||
#
|
||||
# List several and each room instance is assigned one for its lifetime, so everybody in
|
||||
# a session lands on the same server while different sessions spread across the pool.
|
||||
# The id the client displays (`voiceServerId`) is generated from an entry's POSITION —
|
||||
# `tachyon-1`, `tachyon-2`, … — so listing one address twice models two server slots on
|
||||
# one box, and inserting an entry renames every server after it. The five below are a
|
||||
# mock pool (RFC 5737 documentation addresses, which answer nothing): tachyon-1/-2 share
|
||||
# a host, as do tachyon-4/-5.
|
||||
# RECFLARE_TACHYON_HOST_PORT=198.51.100.10:7777,198.51.100.10:7778,198.51.100.11:7777,203.0.113.20:7777,203.0.113.20:7778
|
||||
#
|
||||
# The pool SANDBOX accounts (the account's `isSandbox` flag) are served from instead,
|
||||
# same format. Its ids are generated as `dev-1`, `dev-2`, …. Unset, sandbox accounts
|
||||
# use the pool above.
|
||||
# RECFLARE_TACHYON_HOST_PORT_SANDBOX=198.51.100.12:7777,198.51.100.12:7778
|
||||
# RECFLARE_TACHYON_NAME=server-1
|
||||
|
||||
# The Photon region every session is pinned to (`match`). Unlike the app ids above this
|
||||
# does default, to `us` (us-east1) — an instance stamped with an empty region is one the
|
||||
@@ -175,15 +129,6 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# raising it later does NOT top up existing players.
|
||||
# RECFLARE_STARTING_TOKENS=10000
|
||||
|
||||
# The most RecCenterTokens one gift from the staff card on a player's profile can carry
|
||||
# (`www`). A guard against typos — a gift can't be taken back — not a limit on how much
|
||||
# staff can give in total.
|
||||
# RECFLARE_MAX_TOKEN_GIFT=10000
|
||||
|
||||
# The same guard for one XP gift from that staff card (`www`). Every level to the top costs
|
||||
# 15,090 XP in all (level 1 to 50).
|
||||
# RECFLARE_MAX_XP_GIFT=100
|
||||
|
||||
# Signup on the website is configured OUTSIDE this file: it's guarded by a Cloudflare
|
||||
# Turnstile widget, and both of that widget's keys live in the shared Secrets Store
|
||||
# (RECFLARE_SECRETS_STORE above), alongside JWT_SECRET — not as vars, not as worker secrets.
|
||||
@@ -196,53 +141,3 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# Setting them both is what opens web signup; with either missing it stays closed. See
|
||||
# DEPLOYING.md. Accounts are still created by the game either way, and both `auth` account
|
||||
# caps above apply regardless.
|
||||
|
||||
# The website's benefits claim (www `/claim`): a player proves a role in your Discord and
|
||||
# gets Rec Room Plus. FOUR settings, and ALL FOUR are required — with any missing, the claim
|
||||
# stays closed, `/api/config` reports `benefitsEnabled: false`, and the page and its nav link
|
||||
# never appear. That is the usual reason "I set the secrets and nothing shows up".
|
||||
#
|
||||
# The two CREDENTIALS live in the Secrets Store, like the Turnstile pair above:
|
||||
#
|
||||
# wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_ID \
|
||||
# --scopes workers --remote
|
||||
# wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_SECRET \
|
||||
# --scopes workers --remote
|
||||
#
|
||||
# The two IDS are plain vars, and go HERE — they are not secrets, and setting the secrets
|
||||
# alone is not enough. Both are Discord snowflakes: all digits, no letters, copied with
|
||||
# Developer Mode on (right-click the server or role -> Copy ID). Ids, not names.
|
||||
#
|
||||
# ROLE_IDS is a list and ANY one of them qualifies, so several tiers can share the benefit.
|
||||
# SEPARATE THEM WITH COMMAS AND NO SPACES: these knobs are passed to wrangler as `--var`
|
||||
# flags that are word-split, so a value containing a space silently breaks the deploy. (The
|
||||
# worker itself also accepts whitespace, which is fine in wrangler.jsonc but not here.)
|
||||
# RECFLARE_DISCORD_GUILD_ID=1077000000000000000
|
||||
# RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002
|
||||
#
|
||||
# Also add https://<your domain>/claim to the app's Redirects in the Discord developer
|
||||
# portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT
|
||||
# sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
|
||||
#
|
||||
# OPTIONAL: the daily role refresh (www cron) re-reads claimed members' roles through a
|
||||
# bot token, so `platform_account.role` tracks the guild instead of the day they claimed.
|
||||
# Add a bot user to the same Discord app, invite it to the server (no permissions needed),
|
||||
# and store its token in the Secrets Store:
|
||||
#
|
||||
# wrangler secrets-store secret create <store-id> --name DISCORD_BOT_TOKEN \
|
||||
# --scopes workers --remote
|
||||
#
|
||||
# The binding has to resolve for `www` to deploy, so create it with a placeholder even if
|
||||
# you don't want the sweep — it then does nothing. It never touches hasPlus.
|
||||
|
||||
# OPTIONAL: a token gift for Discord supporters (`econ` cron). Maps Discord ROLE ids to the
|
||||
# RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as
|
||||
# `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in
|
||||
# apps/econ/wrangler.jsonc ships weekly (Monday 05:00 UTC); change that line for daily. ONE
|
||||
# box per member per run — a member holding several mapped roles is paid the HIGHEST amount
|
||||
# among them, not the sum, so the map reads as tiers. Same rules as ROLE_IDS above:
|
||||
# snowflakes, not names; commas, no spaces. Roles are read from the snapshot the benefits
|
||||
# claim and the daily sweep above keep, so it reaches only players who have claimed on the
|
||||
# website, and is only as fresh as that sweep (with a bot, the morning's; without one, the
|
||||
# day they claimed). Unset — or parsing to no roles — leaves the cron a logged no-op.
|
||||
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
|
||||
|
||||
@@ -37,10 +37,6 @@
|
||||
- Syncpack ensures dependency version consistency
|
||||
- Turborepo enables parallel task execution and caching
|
||||
- Workers configured via `wrangler.jsonc` with environment variables
|
||||
- The shared `recflare` database is D1 by default, or a libSQL server (Turso) when
|
||||
`LIBSQL_DB_URL` is set: `withDatabase` (`packages/domain/src/libsql-db.ts`) wraps each
|
||||
worker's default export and swaps `env.DB` for an adapter with the D1 API, so query code
|
||||
only ever sees `D1Database`. Migrations and `runx` tooling follow the same switch.
|
||||
- Each worker has `context.ts` for typed environment bindings
|
||||
- Integration tests in `src/test/integration/`
|
||||
- Workers use `nodejs_compat` compatibility flag
|
||||
@@ -48,20 +44,6 @@
|
||||
- Changesets manage versions and changelogs
|
||||
</architecture>
|
||||
|
||||
<game-clients>
|
||||
Supported client builds live in `SUPPORTED_GAME_VERSIONS`
|
||||
(`packages/domain/src/presence-db.ts`); `GAME_VERSION` is the default the stack targets.
|
||||
|
||||
- `20230414` — default, official. Manifest `7859140924515540835` (2023).
|
||||
- `20250718.01` — beta, official. Manifest `1151455856673601091`; reaches this server via
|
||||
the [patch-2025](https://github.com/recflare/patch-2025) patch.
|
||||
- `20250424.01`, `20231207`, `20230616` — alpha.
|
||||
|
||||
Builds are date-stamped (`YYYYMMDD[.NN]`) so they order as plain strings; several
|
||||
surfaces gate on "newer than `20230414`" (econ storefront catalog, `api` event tags,
|
||||
`rooms` featured rooms) rather than on an explicit list.
|
||||
</game-clients>
|
||||
|
||||
<code-style>
|
||||
- Use tabs for indentation, spaces for alignment
|
||||
- Type imports use `import type`
|
||||
@@ -169,110 +151,6 @@ inconsistency here without checking the client first.
|
||||
served under TWO names, `result` (what the client reads) and `errorCode` (what this
|
||||
server has always sent); they are the same number and must never disagree, which is why
|
||||
everything answers through `matchmakeResult` rather than building the envelope by hand.
|
||||
- What PLAYS a cheer on the cheered player's client is a `MessageReceived` frame carrying a
|
||||
Message of type 50 `PlayerCheer` (51 `PlayerCheerAnonymous`, `FromPlayerId` 0, when the
|
||||
body says `Anonymous`) with `Data` = the category as a string — the same frame every
|
||||
reference server (meownet-api, DorkNet, E12354) sends. `ReputationUpdate` alone refreshes
|
||||
the counters and shows nothing: the cheer "worked" server-side and nobody saw it. The
|
||||
`ReputationUpdate` frames the cheer (`api`: `POST /api/PlayerCheer/v1/create`) sends are
|
||||
the RECORD, trimmed — `IsCheerful` (a profile flag, always true) and `SelectedCheer` (the
|
||||
cheer pinned via `POST /api/PlayerCheer/v1/SetSelectedCheer`, stored on `reputation`) come
|
||||
off the row exactly as the DTO serves them. This server once overrode both per frame to
|
||||
"play" the cheer; no reference does, and it played nothing.
|
||||
- A cheer is a thing that happens in FRONT of people, so the `ReputationUpdate` naming the
|
||||
cheered player goes to everyone in the room instance, not just the two players. The
|
||||
cheered player gets it durably (their counters moved); the rest of the room gets it
|
||||
ephemerally. The audience comes from the giver's live `presence` row, NOT the body's
|
||||
`RoomId`, which is accepted and unused. Neither `RoomId` nor `Anonymous` is stored.
|
||||
- The cheer's reply is `{ Success, Message }` — PascalCase, with `Message` NULL on success.
|
||||
That is NOT the lowercase `{ success, error: "" }` envelope the reports and warnings use;
|
||||
the two live side by side in the same worker and must not be unified.
|
||||
- A Message's `Data` (every `MessageReceived` frame) is a STRING on the wire, so a payload
|
||||
with structure to it goes in ESCAPED — `"Data": "{\"PlayerId\":\"205\"}"`, never a nested
|
||||
object. An object there does not degrade: the client's decoder rejects it outright
|
||||
(`expected:'String Begin Token', actual:'{'`) and loses the whole notification, not just
|
||||
the field. Bites a chat message's `Contents` the same way.
|
||||
- The vote-to-kick frame (`api`: `POST /api/PlayerReporting/v3/voteToKick`) carries the player
|
||||
being VOTED ON as the Message's `FromPlayerId` — not the caller, whom nothing on the frame
|
||||
names — because the client raises its prompt about whoever that field names. Sending the
|
||||
voter asks the room to kick the player who called the vote. Its `Data` is the posted
|
||||
`Reason` as PLAIN TEXT (`Inactive in games (AFK)`), which is the text the prompt shows: it
|
||||
once carried an escaped `{ PlayerId, Response, GameSessionId }`, and players saw that JSON
|
||||
printed where the reason belongs. `Data` is still a string on the wire — that part of the
|
||||
rule above holds; a reason simply needs no escaping to satisfy it.
|
||||
- Leaderboard `Rank` (`leaderboard`: `GetRanks`, `GetNearbyScores`, `GetPlayerRank`) is
|
||||
0-BASED — the client adds one before it draws, so a `Rank` of 1 shows in game as second
|
||||
place and the top of a board must be 0. Its own slice says the same: it asks for the first
|
||||
ten rows as `RankStart` 0, `RankEnd` 9, both inclusive, so reading them as 1-based also
|
||||
serves nine rows starting at the runner-up. The unranked sentinel stays a big number
|
||||
(99999) precisely because 0 is now a real rank, first place.
|
||||
- A custom avatar item (`api`: `custom_avatar_item`, every `/api/customAvatarItems/*` read) is ONE
|
||||
record for two kinds of thing. A player-made shirt has `BaseAvatarItemId`/`BaseAvatarItemColor`
|
||||
and a design PNG (`DesignFilename`, `ThumbnailImageFilename`) and an empty `CurrentSaves`; a
|
||||
FIRST-PARTY item (imported from the official export, Coach-authored, its own `OutfitType`) has
|
||||
those four NULL and is rendered from `CurrentSaves` — one built Unity assetbundle per
|
||||
`BodyType`, which the client picks by the wearer's body. The row is the record as JSON, so an
|
||||
import is the export verbatim except that `CreatorAccountId` is forced to 1, the Coach — which
|
||||
is what files it as stock content and whom a sale pays — and each save's `ThumbnailFileName`
|
||||
is put under `avatar/`, where this server serves it from (`just cai-load` loads it;
|
||||
`apps/api/scripts/import-custom-avatar-items.ts` writes a migration; re-importing an id
|
||||
replaces it). The export's `Price` is mostly 0, and a purchase charges the record's `Price`,
|
||||
so first-party prices are written into the export from the storefront dump
|
||||
(`apps/econ/static/db/Watch_EnumValue_3.json`, joined on
|
||||
`AvatarItemInfo.DownloadableAvatarItemId`) by `apps/api/scripts/price-custom-avatar-items.ts`
|
||||
before loading. Serve both through one shape: the shirt carries
|
||||
empty `CurrentSaves`/`Tags` and null `CustomBadgeMetadata`, never a missing key. The saves
|
||||
name their assetbundles and thumbnails by bare filename; storing the record does nothing
|
||||
about serving those files.
|
||||
- An assetbundle is built PER UNITY TARGET, and the client names the one it wants as
|
||||
`unityAssetTarget` on every custom-avatar-item read (0 PC/Windows, 2 Android/Oculus — the
|
||||
rest unobserved; a query param on the GETs, a FORM field on `POST …/v1/bulk`). The row stores
|
||||
the bare PC names; a caller asking for 2 is served `UnityAsset`/`UnityAsset2` under `quest/`
|
||||
(`toQuestCustomAvatarItem`), fetched from the cdn as `/avatar/quest/<name>.assetbundle`.
|
||||
Every read a Quest renders from has to make the switch — `api` search and bulk, AND `econ`
|
||||
`GET /econ/customAvatarItems/v1/owned`, which the client reads FIRST and renders worn items
|
||||
from: with only `api` switched the Quest kept downloading PC bundles. The load BLANKS each
|
||||
save's `UnityAssetHash`/`UnityAsset2Hash` (`""`, null stays null): the export's are the PC
|
||||
builds' hashes, one stored save serves both targets, and the client refuses a download that
|
||||
fails the hash it was given but skips the check on an empty one.
|
||||
- Buying a custom avatar item goes through the ordinary bag (`econ`: `POST /api/items/bulkpurchase`)
|
||||
as a line whose `ItemPurchaseMethodId` is `{ Type: 1, Guid: <CustomAvatarItemId> }`, beside
|
||||
the `Type: 0, NumberId` catalog lines. It is a SALE between players: the price leaves the buyer
|
||||
in the bag's one debit and is credited to the item's `CreatorAccountId` (pushed to them as a
|
||||
`StorefrontBalanceUpdate` carrying their resulting total), ownership is a row in
|
||||
`inventory_custom`, and the entry's `Data.CustomAvatarItem` is the whole item record. The item
|
||||
still comes in a gift box, like any purchase: its `GiftPackage` names it by
|
||||
`CustomAvatarItemId` with `AvatarItemDesc`/`EquipmentPrefabName`/etc. empty — without a box
|
||||
the purchase shows the player nothing. `BypassGiftPackages` skips it as it does any line.
|
||||
A custom line may carry a `Gift` block: ownership and the box go to `ToPlayerId` (announced
|
||||
with `GiftPackageReceivedImmediate`), the buyer still pays and the creator is still paid, and
|
||||
the owned/creator refusals are checked against the RECEIVER — so a creator may gift their own. `GET /econ/customAvatarItems/v1/owned` is that table
|
||||
joined to the item record PLUS everything the caller created (drafts included): a creator
|
||||
owns their own through `CreatorAccountId`, and the bag refuses to sell it to them.
|
||||
- A chat report (`api`: `POST /api/chatreport/createChatReport`) is form-encoded and sends
|
||||
`ReportCategory` as an enum NAME (`Discriminatory`), where every other report sends the
|
||||
number. It names no player: the reported one is the message's sender, read from `message`.
|
||||
The name is mapped onto the numeric `KickReportCategory` the `report` table stores
|
||||
(`chatReportCategory`); only `Discriminatory` has been observed, so an unmapped name is
|
||||
filed as 0 with the name kept in the details — extend the alias table as more are seen.
|
||||
The reply is the `{ success, error: "" }` envelope the other reports use, which is an
|
||||
ASSUMPTION here: what the client does with this response has not been observed.
|
||||
- A friend request is SHOWN to the target as a Message of type 4 `FriendInvite` (`api`:
|
||||
`/api/relationships/v2/sendfriendrequest`), and an acceptance to the requester as type 40
|
||||
`FriendRequestAccepted` (`acceptfriendrequest`, and the crossing-request auto-accept),
|
||||
each stored and pushed as `MessageReceived`. The `RelationshipChanged` frames only
|
||||
refresh relationship state: sent alone, the request "worked" server-side and the target
|
||||
saw nothing. Same pattern as the cheer.
|
||||
- A gift box's `AvatarItemType` (`econ`: the stored box in `GET /api/avatar/v2/gifts`, `buyItem`'s
|
||||
`BalanceUpdates[].Data`, `bulkpurchase`'s `GiftPackage`, and the `GiftPackageReceived*` frame;
|
||||
`www`'s staff boxes; `api`'s `gifts/generate`) must be NULL for anything that is not an avatar
|
||||
item — a consumable, a skin, a box, tokens, XP — and a NUMBER (the type, or 0) when the box holds
|
||||
one, whether a baked item by `AvatarItemDesc` or a custom item by `CustomAvatarItemId`. The
|
||||
client routes the box on that field before it reads what else the box names, so a 0 sends it
|
||||
looking for avatar item type 0 and it fails with "can't find avatar item". The 2025 store lists
|
||||
every non-avatar drop with `AvatarItemType: null` for this reason; `boxAvatarItemType` applies
|
||||
it on every surface, so don't coalesce it with `?? 0` anywhere a box is built. This holds for
|
||||
every worker that mints a box, not only `econ`.
|
||||
- Accessibility is sent as the `RoomAccessibility` enum NAME on
|
||||
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
||||
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
||||
|
||||
@@ -51,76 +51,6 @@ bun runx admin grant-developer --account 1 --revoke
|
||||
bun runx admin grant-moderator --username alice --remote
|
||||
```
|
||||
|
||||
### `reload-plus` — credit every Plus subscriber's tokens
|
||||
|
||||
Also exposed directly as `just reload-plus`.
|
||||
|
||||
```sh
|
||||
just reload-plus 1000 --dry-run # list subscribers and their balances, change nothing
|
||||
just reload-plus 1000 --remote # +1000 RecCenterTokens to each subscriber, production
|
||||
```
|
||||
|
||||
Adds `<amount>` RecCenterTokens (currency type 2) to the `balance` row of every account
|
||||
whose `hasPlus` flag is set — the flag `grant-plus` and the website's Discord claim write.
|
||||
Subscribers are found through the indexed `has_plus` column on `account` (auth migration
|
||||
0009), so the reload is one statement however many accounts there are. Nothing schedules
|
||||
it: run it when the subscription's tokens are due, and note that running it twice credits
|
||||
twice.
|
||||
|
||||
A subscriber who has never loaded the game has no balance row yet. They get one holding
|
||||
their signup grant **plus** the reload, so the grant is not lost — the grant amount is
|
||||
`RECFLARE_STARTING_TOKENS` from the environment or `.env`, falling back to econ's default
|
||||
of 10000. Prints every credited account with its resulting balance.
|
||||
|
||||
This command targets the `balance` table, so it runs under `apps/econ`'s wrangler config;
|
||||
with `--local` that is econ's dev D1 state, which needs both `balance` and a migrated
|
||||
`account` table in it.
|
||||
|
||||
### `cai-load` — load the first-party custom avatar items
|
||||
|
||||
```sh
|
||||
bun runx admin cai-load # apps/econ/static/db/2025-1-cai.json → local
|
||||
bun runx admin cai-load --remote
|
||||
bun runx admin cai-load --file path/to/export.json --dry-run
|
||||
just cai-load --remote # the same, as a recipe
|
||||
```
|
||||
|
||||
Loads an export of `CustomAvatarItem` records (a JSON array, a `{ Results }` page, or one
|
||||
record) into the `custom_avatar_item` table, each stored as the row's JSON with its
|
||||
`CreatorAccountId` forced to 1, the Coach account, whatever the export said: that is what
|
||||
files it as stock content (the storefront tab searches by creator) and what a purchase
|
||||
pays. Each save's `ThumbnailFileName` is put under `avatar/`, where this server serves
|
||||
those images from (a name already prefixed is left alone). It MERGES: an id already in the table has its row replaced, and nothing is deleted, so the
|
||||
players' own shirts, which share the table, are untouched. Re-running is safe. The load is
|
||||
verified by counting rows and probing ids from across the export; it fails loudly rather
|
||||
than report a partial load. `--dry-run` reads and validates the export and prints the
|
||||
statement count without writing.
|
||||
|
||||
The saves in an export name their assetbundles by bare filename; the load stores the
|
||||
names only.
|
||||
|
||||
A record's `Price` is what a purchase charges, and the official export mostly says 0. The
|
||||
prices come from a storefront dump instead, `apps/econ/static/db/Watch_EnumValue_3.json`
|
||||
(a `{ StoreItems }` page with price and rarity, joined on
|
||||
`GiftDrop.AvatarItemInfo.DownloadableAvatarItemId` = `CustomAvatarItemId`). Before loading
|
||||
a fresh export, write those prices into it:
|
||||
|
||||
```sh
|
||||
bun apps/api/scripts/price-custom-avatar-items.ts \
|
||||
apps/econ/static/db/2025-1-cai.json apps/econ/static/db/Watch_EnumValue_3.json
|
||||
just cai-load --remote
|
||||
```
|
||||
|
||||
The script rewrites the export in place with the base token price (`CurrencyType` 2),
|
||||
ignoring any sale. It warns about every price it changes that wasn't 0 before. The
|
||||
storefront only prices items already in the export; nothing is added from it. An item the
|
||||
storefront doesn't list keeps its old price, unless that price is 0, which would make it
|
||||
free to buy. Those items (the Cryptid Creek event rewards and the Evergrown Hip Pack) get the
|
||||
catalog's rarity-50 price, `PRICE_BY_RARITY[50]` in `apps/econ/src/catalog-load.ts`. The
|
||||
script names every item the storefront doesn't list. The
|
||||
committed `2025-1-cai.json` is already priced. Rarity isn't carried over, since the record
|
||||
has no field for it.
|
||||
|
||||
### `lookup` — print an account
|
||||
|
||||
```sh
|
||||
@@ -135,7 +65,7 @@ the account has a password, the developer role, and the moderator role.
|
||||
|
||||
### Selecting an account
|
||||
|
||||
Every command except `reload-plus` and `cai-load` targets exactly one account, by **either**:
|
||||
Every command targets exactly one account, by **either**:
|
||||
|
||||
- `--account <id>` — numeric account id
|
||||
- `--username <name>` — username (case-insensitive)
|
||||
|
||||
+14
-58
@@ -10,7 +10,7 @@ These are the instructions for deploying the RecFlare infrastructure to Cloudfla
|
||||
the real thing — services scale,
|
||||
fail, and deploy independently — instead of collapsing everything into a single
|
||||
giant server.
|
||||
- **It's free/cheap to run a lot of services.**
|
||||
- **It's free/cheap to run a lot of service.**
|
||||
Cloudflare Workers' free tier is keyed
|
||||
to usage, not to the number of Workers — so whether you deploy 1 service or all
|
||||
36, the baseline cost is the same. You only start paying once usage crosses the
|
||||
@@ -43,16 +43,13 @@ services but would require small code changes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**You must have all these requirements or RecFlare will not work!**
|
||||
**You must have all these requirements or RecFlare deployment will fail!**
|
||||
|
||||
- node 24 (https://nodejs.org)
|
||||
- pnpm (install with `npm install -g pnpm`)
|
||||
- bun (https://bun.sh)
|
||||
- jq/awk/sed (on Windows try `winget jq` etc.)
|
||||
- A Cloudflare account with a zone (domain) you control, for deploying
|
||||
- **For 2023:** A Photon Cloud Realtime and Voice account OR a custom Photon server (Luxon, OpenPhoton)
|
||||
- **For 2025:** A Photon Cloud Realtime account AND a Tachyon server, which is a custom fork of Dissonance voice that Rec Room developed. See https://discord.com/channels/1524485206550118460/1544055485336915979
|
||||
If you don't provide a Tachyon server address during deployment, your server will still work but without voice.
|
||||
- A Cloudflare account with a zone (domain) you control, for deploying.
|
||||
|
||||
Cloudflare's free plan is good enough for testing (100k worker requests/day) but the
|
||||
Rec Room client is very chatty. Frequent testing may exhaust that quota. The $5/month
|
||||
@@ -62,8 +59,6 @@ See https://developers.cloudflare.com/workers/platform/pricing/#workers
|
||||
|
||||
## Getting Started
|
||||
|
||||
**WSL** is preferred for deploying if you are on Windows. Only Linux has been fully tested.
|
||||
|
||||
**Install dependencies:**
|
||||
|
||||
We use [Just](https://github.com/casey/just) for convenience. This will install all dependencies across the microservices.
|
||||
@@ -72,7 +67,7 @@ We use [Just](https://github.com/casey/just) for convenience. This will install
|
||||
just install
|
||||
```
|
||||
|
||||
You do not have to use `just` but then you will have to run things manually with `pnpm`/`bun`.
|
||||
You do not have to use `just` but you will have to run things manually with `pnpm`/`bun`.
|
||||
|
||||
**Configure your custom domain:**
|
||||
|
||||
@@ -84,8 +79,6 @@ cp .env.example .env
|
||||
|
||||
Edit `.env` and set `RECFLARE_DOMAIN` to your domain (or declare it with `export RECFLARE_DOMAIN=rec.example.com`)
|
||||
|
||||
Go through the rest of the file and set the variables as needed.
|
||||
|
||||
(Optional) - per-service subdomain overrides come from `RECFLARE_SUBDOMAINS`, a JSON
|
||||
object keyed by each service's default subdomain (see `SERVICES.md`), e.g.
|
||||
`'{"playersettings":"settings"}'`. A single entry both decides which host `just deploy`
|
||||
@@ -147,42 +140,6 @@ just migrate # migrate every worker that owns migrations
|
||||
just migrate -F rooms # or scope to one worker
|
||||
```
|
||||
|
||||
### Using Turso instead of D1
|
||||
|
||||
The workers can run on a [Turso](https://turso.tech) (libSQL) database instead of D1,
|
||||
with nothing else changing: same schema, same migrations, same workers. D1 stays the
|
||||
default and stays bound, so this is a switch, not a migration of the code. The
|
||||
switch is two lines in `.env`:
|
||||
|
||||
```bash
|
||||
turso db create recflare
|
||||
turso db show recflare --url # -> RECFLARE_LIBSQL_DB_URL=libsql://...
|
||||
turso db tokens create recflare # -> RECFLARE_LIBSQL_DB_AUTH_TOKEN=...
|
||||
```
|
||||
|
||||
With both set, `just migrate` applies the workers' migrations to that database (with
|
||||
the same per-worker history tables wrangler keeps on D1), `just deploy` hands every
|
||||
worker the URL as a var and the token as an encrypted worker secret, and the
|
||||
`--remote` operator commands (`just admin`, `just catalog`) act on it. Local dev and
|
||||
the tests keep using the local D1 regardless.
|
||||
|
||||
To carry an existing D1 database across, export it and load the dump before the
|
||||
first `just migrate` — the dump includes the migration history, so nothing is
|
||||
re-applied:
|
||||
|
||||
```bash
|
||||
wrangler d1 export recflare --remote --output recflare.sql # from any D1-backed app dir
|
||||
turso db shell recflare < recflare.sql
|
||||
```
|
||||
|
||||
To go back to D1, unset (or comment out) the two lines and run `just deploy`. The
|
||||
token secret is left on the workers and ignored without a URL; remove it with
|
||||
`wrangler secret delete LIBSQL_DB_AUTH_TOKEN` per worker if you want it gone.
|
||||
|
||||
> ⚠️ The URL is one switch for the whole deployment. Deploy every worker after
|
||||
> changing it: a worker left on the old database would be reading and writing
|
||||
> different data from the rest.
|
||||
|
||||
### R2 and Durable Objects
|
||||
|
||||
You only have to create the buckets:
|
||||
@@ -236,17 +193,16 @@ the same `.env` you already created. `.env.example` carries each one commented o
|
||||
its built-in default: copy the lines you want to change into your `.env`, uncomment them,
|
||||
edit the value, then re-deploy the worker that reads them.
|
||||
|
||||
| `.env` variable | Read by | Default | What it does |
|
||||
| --------------------------------------- | --------- | ---------- | -------------------------------------------------------------- |
|
||||
| `RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID` | `auth` | `3` | Accounts one Steam-verified identity may create. `0` disables. |
|
||||
| `RECFLARE_MAX_ACCOUNTS_PER_IP` | `auth` | `3` | Accounts one signup IP may create. `0` disables. |
|
||||
| `RECFLARE_STARTING_TOKENS` | `econ` | `10000` | RecCenterTokens a new player is granted. |
|
||||
| `RECFLARE_MAX_UPLOAD_BYTES` | `storage` | `67108864` | Maximum binary upload size; larger files receive HTTP 413. |
|
||||
| `RECFLARE_ROOM_REDIRECTS` | `match` | unset | Rooms to switch out on matchmake, e.g. `2=MyHub`. |
|
||||
| `RECFLARE_PHOTON_REALTIME_APP_ID` | `match` | empty | Your Photon Realtime app id. No app ships with recflare. |
|
||||
| `RECFLARE_PHOTON_VOICE_APP_ID` | `match` | empty | Your Photon Voice app id. |
|
||||
| `RECFLARE_PHOTON_CHAT_APP_ID` | `match` | empty | Your Photon Chat app id. |
|
||||
| `RECFLARE_PHOTON_REGION` | `match` | `us` | Photon region every session is pinned to. |
|
||||
| `.env` variable | Read by | Default | What it does |
|
||||
| --------------------------------------- | ------- | ------- | -------------------------------------------------------------- |
|
||||
| `RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID` | `auth` | `3` | Accounts one Steam-verified identity may create. `0` disables. |
|
||||
| `RECFLARE_MAX_ACCOUNTS_PER_IP` | `auth` | `3` | Accounts one signup IP may create. `0` disables. |
|
||||
| `RECFLARE_STARTING_TOKENS` | `econ` | `10000` | RecCenterTokens a new player is granted. |
|
||||
| `RECFLARE_ROOM_REDIRECTS` | `match` | unset | Rooms to switch out on matchmake, e.g. `2=MyHub`. |
|
||||
| `RECFLARE_PHOTON_REALTIME_APP_ID` | `match` | empty | Your Photon Realtime app id. No app ships with recflare. |
|
||||
| `RECFLARE_PHOTON_VOICE_APP_ID` | `match` | empty | Your Photon Voice app id. |
|
||||
| `RECFLARE_PHOTON_CHAT_APP_ID` | `match` | empty | Your Photon Chat app id. |
|
||||
| `RECFLARE_PHOTON_REGION` | `match` | `us` | Photon region every session is pinned to. |
|
||||
|
||||
Then deploy just the worker that reads it:
|
||||
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
|
||||
|
||||

|
||||

|
||||
|
||||
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
|
||||
Cloudflare Workers. It implements the network services the Rec Room client talks
|
||||
@@ -36,24 +36,15 @@ resulting in bugs getting fixed faster. I hope.
|
||||
|
||||
## Game client
|
||||
|
||||
See [RecFlare Client](https://github.com/djdevin/recflare-client) for the official RecFlare build.
|
||||
See [RecFlare Client](https://github.com/djdevin/recflare-client)
|
||||
|
||||
These game builds are supported:
|
||||
|
||||
| Build | Manifest | Support |
|
||||
| ------------- | --------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `20230414` | `7859140924515540835` | **Default**, official — the 2023 build the rest of the stack targets |
|
||||
| `20250718.01` | `1151455856673601091` | **Beta**, official — use the [patch-2025](https://github.com/recflare/patch-2025) patch |
|
||||
| `20250424.01` | | Alpha |
|
||||
| `20231207` | | Alpha |
|
||||
| `20230616` | | Alpha |
|
||||
|
||||
Alpha builds get past the version check and largely work, but nothing else in the
|
||||
stack targets them, so expect protocol differences. Other client or game versions
|
||||
may expect different endpoints and response shapes and are not supported.
|
||||
RecFlare is compatible with the
|
||||
[RecNet Plugin](https://github.com/djdevin/recnet-plugin) and the build of Rec
|
||||
Room with manifest `7859140924515540835` (around 2023). Other client or game versions may expect
|
||||
different endpoints and response shapes and are not supported.
|
||||
|
||||
Generally speaking any client that effectively rewrites the nameserver with the
|
||||
right mods (see [RecNet Plugin](https://github.com/djdevin/recnet-plugin), [2025 patch](https://github.com/recflare/patch-2025)) can be used with this server.
|
||||
right mods can be used with this server.
|
||||
|
||||
## Services
|
||||
|
||||
@@ -67,11 +58,9 @@ Want to run it yourself? See [DEPLOYING.md](DEPLOYING.md)
|
||||
|
||||
### What year is this for?
|
||||
|
||||
Most 2023 and 2025 clients. A few older builds work at
|
||||
alpha quality — see the table in the "Game client" section above. Other clients may not
|
||||
work.
|
||||
This works with 2023 clients. It has been tested with manifest `7859140924515540835`. Other clients may not work.
|
||||
|
||||
See the "Game client" section above for instructions on how to modify a client to connect to this server.
|
||||
See the "Client" section above for instructions on how to modify a client to connect to this server.
|
||||
|
||||
### Can I run this locally on my PC?
|
||||
|
||||
@@ -81,13 +70,11 @@ See "Run the development microservices" above. It may be possible later as Wrang
|
||||
|
||||
### Can I use this to make my own server?
|
||||
|
||||
Yes, that's the point. See [DEPLOYING.md](DEPLOYING.md)
|
||||
Yes, that's the point. Just set your custom domain and deploy it.
|
||||
|
||||
### Is there an admin panel?
|
||||
|
||||
Yes, the server comes bundled with a simple web panel with more functionality being added.
|
||||
|
||||
There are also [CLI tools](CLI.md) you can use for admin tasks like granting roles.
|
||||
Not yet. But there could be.
|
||||
|
||||
### Can I copy this project and modify it?
|
||||
|
||||
@@ -102,20 +89,15 @@ keeps those in sync: `pnpm` workspaces share dependencies, `@repo/` packages
|
||||
share code, Turborepo runs build/test/lint with a single cached task graph, and
|
||||
cross-service changes land in one atomic commit.
|
||||
|
||||
This makes it easier to deploy the whole stack at once or a smaller selection
|
||||
of microservices to avoid downtime events.
|
||||
|
||||
## Credits
|
||||
|
||||
I started this soon after the official servers shut down when I saw there were
|
||||
only monolithic servers usually running on one server. I could only see the
|
||||
I started this after the official servers shut down, so I could only see the
|
||||
request shapes coming from the game client. I used many different projects as
|
||||
resources to get response shapes, logic examples, enums, etc. They all had
|
||||
missing pieces. Again, another reason to come together on one project and
|
||||
stop gatekeeping.
|
||||
|
||||
- [CannedNet](https://github.com/CannedNet/CannedNet)
|
||||
- [DorkNet](https://github.com/DorkSquadRR/DorkNet)
|
||||
- jordanparki7's postman collection of RecNet APIs which is gone for some reason.
|
||||
- Leaked C# projects I won't list (for response shapes)
|
||||
- Claude and my [wire shapes skill](https://github.com/recflare/skills/blob/main/.claude/skills/wire-shapes/SKILL.md)
|
||||
Unfortunately, they were all leaked code except for
|
||||
[CannedNet](https://github.com/CannedNet/CannedNet), [DorkNet](https://github.com/DorkSquadRR/DorkNet), and jordanparki7's postman
|
||||
collection of RecNet APIs which is gone for some reason. So I will not list the
|
||||
leaks publicly.
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ apex/`ns` host and isn't listed within it. Each implemented worker has its own
|
||||
| GameLogs | `gamelogs` | — | Not yet implemented |
|
||||
| Geo | `geo` | — | Not yet implemented |
|
||||
| Images | `img` | `img` | Image storage & signed delivery (R2) |
|
||||
| Leaderboard | `leaderboard` | `leaderboard` | Per-room stat leaderboards, one board per stat channel (D1) |
|
||||
| Leaderboard | `leaderboard` | `leaderboard` | Stub — deploys and answers, no leaderboard endpoints yet |
|
||||
| Link | `link` | `link` | Stub — deploys and answers, no link endpoints yet |
|
||||
| Lists | `lists` | `lists` | Curated & algorithmic discovery lists (canned — nothing ranks yet) |
|
||||
| Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) |
|
||||
|
||||
@@ -15,13 +15,12 @@
|
||||
"test": "run-vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@2toad/profanity": "3.3.0",
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.13.5",
|
||||
"hono": "4.12.27",
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
@@ -33,6 +32,6 @@
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "26.0.1",
|
||||
"vitest": "4.1.9",
|
||||
"wrangler": "4.128.0"
|
||||
"wrangler": "4.105.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,12 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
createAccount,
|
||||
DEFAULT_USERNAME_CHANGES,
|
||||
defaultAccount,
|
||||
getAccount,
|
||||
getAccountByUsername,
|
||||
getAccountsByIds,
|
||||
searchAccounts,
|
||||
updateAccount,
|
||||
withDatabase,
|
||||
} from '@repo/domain'
|
||||
import {
|
||||
logger,
|
||||
@@ -30,12 +28,10 @@ import {
|
||||
BannerImageRequest,
|
||||
BioRequest,
|
||||
BioResponse,
|
||||
BulkIdsRequest,
|
||||
CreateAccountRequest,
|
||||
CreateAccountResult,
|
||||
DisplayNameRequest,
|
||||
EmailRequest,
|
||||
EmojiRequest,
|
||||
form,
|
||||
HealthResponse,
|
||||
IdentityFlagsRequest,
|
||||
@@ -49,14 +45,11 @@ import {
|
||||
SuccessResponse,
|
||||
UsernameRequest,
|
||||
UsernameResult,
|
||||
WhitelistedEmojis,
|
||||
} from './openapi'
|
||||
import { resolveWhitelistedEmoji, WHITELISTED_EMOJIS } from './whitelisted-emojis'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { DescribeRouteOptions } from 'hono-openapi'
|
||||
import type { Account } from '@repo/domain'
|
||||
import type { App, Env } from './context'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
* Account reads/writes are backed by the shared `accounts` table in D1 (schema
|
||||
@@ -82,6 +75,9 @@ function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
}
|
||||
|
||||
/** Username changes a fresh account starts with (until one has been consumed). */
|
||||
const DEFAULT_USERNAME_CHANGES = 1
|
||||
|
||||
/**
|
||||
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
|
||||
* On success `value` is the updated account; on error `error` carries the message
|
||||
@@ -103,69 +99,6 @@ async function formField(c: Context<App>, name: string): Promise<string> {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a REPEATED string field from a form-urlencoded / multipart body (`id=1&id=2`).
|
||||
* `parseBody` keeps only the last value for a duplicated key unless it's told to collect
|
||||
* them all, which is the whole point here.
|
||||
*/
|
||||
async function formFields(c: Context<App>, name: string): Promise<string[]> {
|
||||
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||
const value = body[name]
|
||||
const values = Array.isArray(value) ? value : [value]
|
||||
return values.filter((v): v is string => typeof v === 'string')
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAPI spec for the two `/account/bulk` registrations, which differ only in where the
|
||||
* ids ride.
|
||||
*/
|
||||
function bulkRouteSpec(source: 'query' | 'body'): DescribeRouteOptions {
|
||||
return {
|
||||
tags: ['Lookup'],
|
||||
summary: 'Look up many accounts by id',
|
||||
description: [
|
||||
'Accepts repeated `id` values (query params and/or a form-urlencoded body) and',
|
||||
'comma-separated lists. Every requested id appears in the response — ids with no',
|
||||
'stored row get a synthesized default.',
|
||||
].join(' '),
|
||||
...(source === 'query'
|
||||
? {
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Repeatable; each value may be a comma-separated list of ids',
|
||||
schema: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
],
|
||||
}
|
||||
: { requestBody: form(BulkIdsRequest, 'Repeated `id` fields') }),
|
||||
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up many accounts at once. Ids come from repeated `id` query params AND, on the
|
||||
* POST form, repeated `id` body fields; either may carry a comma-separated list. Deduped
|
||||
* (first occurrence wins) so a repeated id can't double a row or waste a bound parameter.
|
||||
*/
|
||||
async function bulkAccounts(c: Context<App>) {
|
||||
const raw = [...(c.req.queries('id') ?? []), ...(await formFields(c, 'id'))]
|
||||
const ids = [
|
||||
...new Set(
|
||||
raw
|
||||
.flatMap((v) => v.split(','))
|
||||
.map((v) => Number.parseInt(v.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
),
|
||||
]
|
||||
// Resolve stored accounts, synthesizing a default for any id not in the DB
|
||||
// so every requested id is present in the response.
|
||||
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
|
||||
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored account into the public account DTO — the client's camelCase
|
||||
* shape, excluding private fields like `email` (surfaced only by /account/me).
|
||||
@@ -176,8 +109,8 @@ function toAccountDto(account: Account) {
|
||||
username: account.username,
|
||||
displayName: account.displayName,
|
||||
profileImage: account.profileImage,
|
||||
// Rows stored before these fields existed have neither key — always emit them as
|
||||
// "" rather than letting them go missing.
|
||||
// Nothing writes these yet, and rows stored before they existed have neither
|
||||
// key — always emit them as "" rather than letting them go missing.
|
||||
bannerImage: account.bannerImage ?? '',
|
||||
displayEmoji: account.displayEmoji ?? '',
|
||||
isJunior: account.isJunior,
|
||||
@@ -277,24 +210,6 @@ const app = new Hono<App>()
|
||||
(c) => c.json({ service: 'accounts', status: 'ok' })
|
||||
)
|
||||
|
||||
// ---- Emoji config --------------------------------------------------------
|
||||
// The picker the client fills its displayEmoji grid from. A BARE array — no
|
||||
// `{ success, error, value }` envelope and no wrapper object; the client parses the
|
||||
// response body itself as the list.
|
||||
.get(
|
||||
'/emojiConfig/whitelistedEmojis',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Emoji a player may use as their displayEmoji',
|
||||
description: [
|
||||
'A bare JSON array of emoji, in the order the client draws them. Static — not',
|
||||
'auth-gated, and identical for every player.',
|
||||
].join(' '),
|
||||
responses: { 200: json(WhitelistedEmojis, 'The whitelisted emoji, in picker order') },
|
||||
}),
|
||||
(c) => c.json(WHITELISTED_EMOJIS)
|
||||
)
|
||||
|
||||
// ---- Self account --------------------------------------------------------
|
||||
.get(
|
||||
'/account/me',
|
||||
@@ -350,12 +265,40 @@ const app = new Hono<App>()
|
||||
|
||||
// ---- Bulk / single lookup ------------------------------------------------
|
||||
// Register the static `bulk` path before the `/account/:id` param route.
|
||||
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
|
||||
// The 2023 client asks for its friends list as a POST with the ids in a
|
||||
// form-urlencoded body — the same `id=1&id=2&…` it would put in a query string,
|
||||
// moved off the URL because a few hundred friends overflow it. GET is the same
|
||||
// lookup and stays for callers (and docs) that prefer it.
|
||||
.post('/account/bulk', describeRoute(bulkRouteSpec('body')), bulkAccounts)
|
||||
.get(
|
||||
'/account/bulk',
|
||||
describeRoute({
|
||||
tags: ['Lookup'],
|
||||
summary: 'Look up many accounts by id',
|
||||
description: [
|
||||
'Accepts repeated `id` query params and/or comma-separated lists. Every requested',
|
||||
'id appears in the response — ids with no stored row get a synthesized default.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Repeatable; each value may be a comma-separated list of ids',
|
||||
schema: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
|
||||
}),
|
||||
async (c) => {
|
||||
// Reads repeated `id` query params; also accept a comma-separated list.
|
||||
const ids =
|
||||
c.req
|
||||
.queries('id')
|
||||
?.flatMap((v) => v.split(','))
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n)) ?? []
|
||||
// Resolve stored accounts, synthesizing a default for any id not in the DB
|
||||
// so every requested id is present in the response.
|
||||
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
|
||||
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
|
||||
}
|
||||
)
|
||||
|
||||
.get(
|
||||
'/account/:id/bio',
|
||||
@@ -460,7 +403,7 @@ const app = new Hono<App>()
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({ accountId: id, disallowInAppPurchases: true })
|
||||
return c.json({ accountId: id, disallowInAppPurchases: false })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -505,9 +448,7 @@ const app = new Hono<App>()
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: {
|
||||
description: 'Empty, over 15 characters, non-alphanumeric, or profane (empty body)',
|
||||
},
|
||||
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -534,8 +475,7 @@ const app = new Hono<App>()
|
||||
tags: ['Profile'],
|
||||
summary: 'Change username',
|
||||
description: [
|
||||
'Letters and digits only, at most 50 characters, and free of profanity (the same',
|
||||
'word list as `api`’s `POST /api/sanitize/v1/isPure`). Rejects a name taken by another',
|
||||
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
|
||||
'account and requires a remaining change; on success the name is persisted and',
|
||||
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
|
||||
'(see the UsernameResult envelope).',
|
||||
@@ -722,46 +662,6 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// The emoji shown beside the player's display name. The body is a single field —
|
||||
// `displayEmoji=%F0%9F%A4%AA` — and the value is checked against the same list
|
||||
// `GET /emojiConfig/whitelistedEmojis` serves, then stored in that list's CANONICAL
|
||||
// form: `displayEmoji` is compared as a plain string, and the client highlights the
|
||||
// current pick by matching it against the picker list it fetched, so a stored value
|
||||
// that differs only by a variation selector highlights nothing.
|
||||
//
|
||||
// Broadcast like every other public-DTO mutation here — the emoji rides along in the
|
||||
// AccountUpdate payload, so it redraws beside the name without a refetch.
|
||||
.put(
|
||||
'/account/me/emoji',
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Set display emoji',
|
||||
description: [
|
||||
'Persists the emoji shown beside the display name and broadcasts it in the',
|
||||
'AccountUpdate payload. The value must be one the whitelist serves; an empty value',
|
||||
'clears the pick.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(EmojiRequest, 'A whitelisted emoji, or "" to clear'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Not a whitelisted emoji (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const submitted = (await formField(c, 'displayEmoji')).trim()
|
||||
// An empty value clears the pick; anything else has to be on the list.
|
||||
const displayEmoji = submitted === '' ? '' : resolveWhitelistedEmoji(submitted)
|
||||
if (displayEmoji === null) return c.body(null, 400)
|
||||
const account = await updateAccount(c.env.DB, id, { displayEmoji })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// The profile banner — the wide image behind the header on a player's profile. Same
|
||||
// shape as the avatar below: the body names an image the player has already uploaded
|
||||
// (the client posts one of their own photos, `sharecamera/<date>/<uuid>.jpg`), so this
|
||||
@@ -857,4 +757,4 @@ app.get(
|
||||
)
|
||||
)
|
||||
|
||||
export default withDatabase<Env>(app)
|
||||
export default app
|
||||
|
||||
@@ -9,11 +9,6 @@ import {
|
||||
nameRejection,
|
||||
} from '@repo/domain'
|
||||
|
||||
// The profanity filter behind `api`'s `POST /api/sanitize/v1/isPure`, imported rather
|
||||
// than copied so a name is held to the very same word list every other player-typed
|
||||
// string is.
|
||||
import { nameContainsSwears } from '../../api/src/sanitize'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
@@ -75,7 +70,7 @@ export const AccountDto = z.object({
|
||||
bannerImage: z.string().describe('Profile banner key — always "" (nothing sets it yet)'),
|
||||
displayEmoji: z
|
||||
.string()
|
||||
.describe('Emoji beside the display name, set by PUT /account/me/emoji; "" when unset'),
|
||||
.describe('Emoji beside the display name — always "" (nothing sets it yet)'),
|
||||
isJunior: z.boolean(),
|
||||
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
|
||||
personalPronouns: z.int().describe('Pronoun flags bitmask'),
|
||||
@@ -139,15 +134,6 @@ export const ParentalControl = z.object({ accountId: z.int(), disallowInAppPurch
|
||||
*/
|
||||
export const PrivacySettings = z.object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() })
|
||||
|
||||
/**
|
||||
* `GET /emojiConfig/whitelistedEmojis` response — a BARE array of emoji, no envelope
|
||||
* and no object around it (see `WHITELISTED_EMOJIS`).
|
||||
*/
|
||||
export const WhitelistedEmojis = z
|
||||
.string()
|
||||
.array()
|
||||
.describe('The emoji a player may set as their displayEmoji, in picker order')
|
||||
|
||||
/** Root health check. */
|
||||
export const HealthResponse = z.object({ service: z.literal('accounts'), status: z.literal('ok') })
|
||||
|
||||
@@ -172,54 +158,26 @@ export const CreateAccountRequest = z.object({
|
||||
* writes the player-facing sentence and there's no reason to write it twice.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Zod check that defers to the shared name rule, message and all, and then refuses a name
|
||||
* with a swear in it — the same filter, and the same word list, as `api`'s
|
||||
* `POST /api/sanitize/v1/isPure`.
|
||||
*
|
||||
* Shape first, profanity second: a name that already broke the charset rule gets the one
|
||||
* sentence that explains it rather than two, and the swear check never sees the
|
||||
* punctuation the charset rule has already refused.
|
||||
*/
|
||||
/** Zod check that defers to the shared name rule, message and all. */
|
||||
const nameCheck = (label: string, max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.superRefine((value, ctx) => {
|
||||
const rejection = nameRejection(value, label, max)
|
||||
if (rejection !== null) {
|
||||
ctx.addIssue({ code: 'custom', message: rejection })
|
||||
} else if (nameContainsSwears(value)) {
|
||||
// Deliberately vague about WHICH word: naming it back to the player prints the
|
||||
// swear in the UI, and the player knows what they typed.
|
||||
ctx.addIssue({ code: 'custom', message: `Your ${label} can't contain that word.` })
|
||||
}
|
||||
if (rejection !== null) ctx.addIssue({ code: 'custom', message: rejection })
|
||||
})
|
||||
|
||||
export const DisplayNameRequest = z.object({
|
||||
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
|
||||
.min(1)
|
||||
.describe(
|
||||
'Trimmed; letters and digits only, max 15, no profanity. Empty or invalid is rejected (400)'
|
||||
),
|
||||
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'),
|
||||
})
|
||||
|
||||
export const UsernameRequest = z.object({
|
||||
username: nameCheck('username', MAX_USERNAME_LENGTH)
|
||||
.min(1, 'You must enter a username.')
|
||||
.describe(
|
||||
'Trimmed; letters and digits only, max 50, no profanity. Must be unique and changes must remain'
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /account/bulk` form body. `id` repeats once per requested account (the client
|
||||
* sends its whole friends list this way); each value may also be a comma-separated list.
|
||||
*/
|
||||
export const BulkIdsRequest = z.object({
|
||||
id: z
|
||||
.union([z.string(), z.string().array()])
|
||||
.describe('Repeatable; each value may be a comma-separated list of ids'),
|
||||
.describe('Trimmed; letters and digits only, max 50. Must be unique and changes must remain'),
|
||||
})
|
||||
|
||||
export const EmailRequest = z.object({
|
||||
@@ -249,16 +207,6 @@ export const BioRequest = z.object({
|
||||
bio: z.string().refine(isValidBio).describe('Free text, max 255; empty is allowed'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /account/me/emoji` form body. The value must be one of the emoji served by
|
||||
* `GET /emojiConfig/whitelistedEmojis`; an empty value clears the current pick. Checked
|
||||
* in the handler rather than here, because the check also CANONICALIZES the value
|
||||
* (see `resolveWhitelistedEmoji`) and a schema can only accept or reject it.
|
||||
*/
|
||||
export const EmojiRequest = z.object({
|
||||
displayEmoji: z.string().describe('A whitelisted emoji, or "" to clear'),
|
||||
})
|
||||
|
||||
export const ProfileImageRequest = z.object({
|
||||
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
|
||||
})
|
||||
|
||||
@@ -6,8 +6,6 @@ import '../../accounts.app'
|
||||
|
||||
import { SCHEMA_DDL } from '@repo/domain'
|
||||
|
||||
import { WHITELISTED_EMOJIS } from '../../whitelisted-emojis'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
@@ -97,48 +95,6 @@ describe('public endpoints', () => {
|
||||
expect(accounts[1].username).toBe('Player2')
|
||||
})
|
||||
|
||||
test('POST /account/bulk reads the ids from a form body', async () => {
|
||||
// The 2023 client asks for its friends list as a POST — the ids are in a
|
||||
// form-urlencoded body, not the query string. GET-only left it a 404.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: 'id=1&id=2&id=3',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
|
||||
expect(accounts.map((a) => a.accountId)).toEqual([1, 2, 3])
|
||||
expect(accounts[0].username).toBe('Coach')
|
||||
})
|
||||
|
||||
test('POST /account/bulk answers more ids than D1 will bind at once', async () => {
|
||||
// D1 caps a statement at 100 bound parameters; a real friends list runs past that,
|
||||
// and an unchunked `IN (…)` 500s. Requesting a stored id in the LAST chunk proves
|
||||
// the later statements ran, not just the first.
|
||||
const ids = [...Array.from({ length: 250 }, (_, i) => 1000 + i), 1]
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: ids.map((id) => `id=${id}`).join('&'),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
|
||||
expect(accounts.map((a) => a.accountId)).toEqual(ids)
|
||||
expect(accounts.at(-1)?.username).toBe('Coach')
|
||||
})
|
||||
|
||||
test('POST /account/bulk returns one entry per id, deduped', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
// Negative ids are real in the client's list and synthesize like any other.
|
||||
body: 'id=1&id=-1&id=1',
|
||||
})
|
||||
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
|
||||
expect(accounts.map((a) => a.accountId)).toEqual([1, -1])
|
||||
expect(accounts[1].username).toBe('Player-1')
|
||||
})
|
||||
|
||||
test('GET /account/search prefix-matches usernames, returns public DTOs', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/search?name=coa`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -193,15 +149,6 @@ describe('auth-gated endpoints', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /account/me rejects a signed token with a non-canonical account subject', async () => {
|
||||
for (const sub of ['42junk', '42.5', '042', '-42', '9007199254740992']) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me`, {
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
expect(res.status, sub).toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /account/me returns the self account with a valid token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer() })
|
||||
expect(res.status).toBe(200)
|
||||
@@ -213,11 +160,11 @@ describe('auth-gated endpoints', () => {
|
||||
username: 'Player42',
|
||||
personalPronouns: 0,
|
||||
identityFlags: 0,
|
||||
availableUsernameChanges: 3,
|
||||
availableUsernameChanges: 1,
|
||||
// An unset email is "", not null — the client reads it as a string, and the
|
||||
// hub frame this DTO also rides drops null values outright.
|
||||
email: '',
|
||||
// Unset on a fresh account, but the key has to be present — the client reads
|
||||
// Nothing sets these yet, but the key has to be present — the client reads
|
||||
// both off the account DTO.
|
||||
bannerImage: '',
|
||||
displayEmoji: '',
|
||||
@@ -234,7 +181,7 @@ describe('auth-gated endpoints', () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/parentalcontrol/me`, {
|
||||
headers: await bearer(),
|
||||
})
|
||||
expect(await res.json()).toEqual({ accountId: 42, disallowInAppPurchases: true })
|
||||
expect(await res.json()).toEqual({ accountId: 42, disallowInAppPurchases: false })
|
||||
})
|
||||
|
||||
test('GET /accountprivacysettings/:id echoes the id with the privacy flags', async () => {
|
||||
@@ -267,32 +214,6 @@ describe('auth-gated endpoints', () => {
|
||||
expect(((await me.json()) as { displayName: string }).displayName).toBe('laskdjfasdlfkj')
|
||||
})
|
||||
|
||||
test('PUT /account/me/displayname 400s on a name with a swear in it', async () => {
|
||||
const headers = {
|
||||
...(await bearer('895')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
// A name carries no spaces, so the filter has to find the swear at the seam the
|
||||
// player typed instead of one.
|
||||
for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
...form({ displayName }),
|
||||
headers,
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
}
|
||||
|
||||
// And the words that merely contain one still get through — refusing these is worse
|
||||
// than missing a swear, because the player can't see why.
|
||||
for (const displayName of ['Scunthorpe', 'ClassicCar', 'Cumberland']) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
...form({ displayName }),
|
||||
headers,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
test('PUT /account/me/username 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||
...form({ username: 'whoever' }),
|
||||
@@ -314,31 +235,7 @@ describe('auth-gated endpoints', () => {
|
||||
expect(body.value).toBe('')
|
||||
})
|
||||
|
||||
test('PUT /account/me/username refuses a swear without spending a change', async () => {
|
||||
const headers = {
|
||||
...(await bearer('894')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||
...form({ username: 'ShitLord' }),
|
||||
headers,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { success: boolean; error: string; value: string }
|
||||
expect(body.success).toBe(false)
|
||||
// Vague on purpose — the message must not print the swear back at the player.
|
||||
expect(body.error).toMatch(/can't contain that word/i)
|
||||
expect(body.value).toBe('')
|
||||
|
||||
// The schema runs before the handler, so a refused name costs none of the account's
|
||||
// rationed changes.
|
||||
const me = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('894') })
|
||||
).json()) as { username: string; availableUsernameChanges: number }
|
||||
expect(me.availableUsernameChanges).toBe(3)
|
||||
})
|
||||
|
||||
test('PUT /account/me/username allows three changes, decrements the counter, then blocks', async () => {
|
||||
test('PUT /account/me/username changes the name, decrements the counter, then blocks', async () => {
|
||||
const headers = {
|
||||
...(await bearer('892')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -363,27 +260,11 @@ describe('auth-gated endpoints', () => {
|
||||
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('892') })
|
||||
).json()) as { username: string; availableUsernameChanges: number }
|
||||
expect(me.username).toBe('coachx')
|
||||
expect(me.availableUsernameChanges).toBe(2)
|
||||
expect(me.availableUsernameChanges).toBe(0)
|
||||
|
||||
// The second and third changes consume the rest of the account's allowance.
|
||||
for (const username of ['coachy', 'coachz']) {
|
||||
const changed = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||
...form({ username }),
|
||||
headers,
|
||||
})
|
||||
expect(changed.status).toBe(200)
|
||||
expect(((await changed.json()) as { success: boolean }).success).toBe(true)
|
||||
}
|
||||
|
||||
const exhausted = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('892') })
|
||||
).json()) as { username: string; availableUsernameChanges: number }
|
||||
expect(exhausted.username).toBe('coachz')
|
||||
expect(exhausted.availableUsernameChanges).toBe(0)
|
||||
|
||||
// A fourth change is blocked — no changes remaining (still HTTP 200).
|
||||
// A second change is blocked — no changes remaining (still HTTP 200).
|
||||
const blocked = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||
...form({ username: 'coachq' }),
|
||||
...form({ username: 'coachy' }),
|
||||
headers,
|
||||
})
|
||||
expect(blocked.status).toBe(200)
|
||||
@@ -447,91 +328,6 @@ describe('auth-gated endpoints', () => {
|
||||
expect(sent.map((n) => (n.data as { bannerImage?: string }).bannerImage)).toContain(key)
|
||||
})
|
||||
|
||||
test('PUT /account/me/emoji persists the emoji and pushes the profile update', async () => {
|
||||
type Sent = { playerId: number; notificationType: string | number; data: unknown }
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
await hub().fetch('http://do/', { method: 'DELETE' })
|
||||
|
||||
// Exactly the body the client sends: one urlencoded field (`%F0%9F%A4%AA`).
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('779')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'displayEmoji=%F0%9F%A4%AA',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
// Served back by both the self and public reads — displayEmoji is in the public DTO.
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('779') })
|
||||
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('\u{1F92A}')
|
||||
const pub = await exports.default.fetch(`${ORIGIN}/account/779`)
|
||||
expect(((await pub.json()) as { displayEmoji: string }).displayEmoji).toBe('\u{1F92A}')
|
||||
|
||||
// And it rides the profile-update notification, so it redraws beside the name.
|
||||
const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[]
|
||||
expect(sent.length).toBeGreaterThan(0)
|
||||
expect(sent.map((n) => (n.data as { displayEmoji?: string }).displayEmoji)).toContain(
|
||||
'\u{1F92A}'
|
||||
)
|
||||
})
|
||||
|
||||
// The pick is stored in the whitelist's CANONICAL form. A client that posts the emoji
|
||||
// without its U+FE0F variation selector means the same pick, but storing what arrived
|
||||
// would leave a string the picker list no longer matches, so the current pick would
|
||||
// stop highlighting.
|
||||
test('PUT /account/me/emoji canonicalizes a pick sent without its variation selector', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
|
||||
...form({ displayEmoji: '\u{2764}' }),
|
||||
headers: { ...(await bearer('780')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('780') })
|
||||
const stored = ((await me.json()) as { displayEmoji: string }).displayEmoji
|
||||
expect(stored).toBe('\u{2764}\u{FE0F}')
|
||||
expect(WHITELISTED_EMOJIS).toContain(stored)
|
||||
})
|
||||
|
||||
// An empty value clears the pick rather than 400ing — that's how the picker's "none"
|
||||
// gets back to no emoji at all.
|
||||
test('PUT /account/me/emoji clears the pick on an empty value', async () => {
|
||||
const set = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
|
||||
...form({ displayEmoji: '\u{1F389}' }),
|
||||
headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(set.status).toBe(200)
|
||||
|
||||
const cleared = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
|
||||
...form({ displayEmoji: '' }),
|
||||
headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(cleared.status).toBe(200)
|
||||
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('781') })
|
||||
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('')
|
||||
})
|
||||
|
||||
// displayEmoji renders beside the display name, so an unchecked field would be a
|
||||
// free-text label on every profile. Off-list values are refused, not stored.
|
||||
test('PUT /account/me/emoji 401s without a token, 400s on an off-list value', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
|
||||
...form({ displayEmoji: '\u{1F92A}' }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
for (const displayEmoji of ['not an emoji', '\u{1F92A}\u{1F92A}', '\u{1F595}\u{1F3FB}']) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
|
||||
...form({ displayEmoji }),
|
||||
headers: { ...(await bearer('782')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
}
|
||||
|
||||
// Nothing was stored by the refusals.
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('782') })
|
||||
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('')
|
||||
})
|
||||
|
||||
test('PUT /account/me/bannerimage 401s without a token, 400s without an imageName', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/account/me/bannerimage`, {
|
||||
...form({ imageName: 'x.jpg' }),
|
||||
@@ -692,16 +488,13 @@ describe('auth-gated endpoints', () => {
|
||||
'GET /account/{id}',
|
||||
'GET /account/{id}/bio',
|
||||
'GET /accountprivacysettings/{id}',
|
||||
'GET /emojiConfig/whitelistedEmojis',
|
||||
'GET /parentalcontrol/me',
|
||||
'POST /account/bulk',
|
||||
'POST /account/create',
|
||||
'POST /account/me/email',
|
||||
'POST /account/me/phone',
|
||||
'PUT /account/me/bannerimage',
|
||||
'PUT /account/me/bio',
|
||||
'PUT /account/me/displayname',
|
||||
'PUT /account/me/emoji',
|
||||
'PUT /account/me/identityflags',
|
||||
'PUT /account/me/personalpronouns',
|
||||
'PUT /account/me/profileimage',
|
||||
@@ -772,12 +565,12 @@ describe('name, email and bio validation', () => {
|
||||
expect(body.value).toBe('')
|
||||
}
|
||||
|
||||
// A rationed change must NOT be spent by a refusal: an account starts with three,
|
||||
// The rationed change must NOT be spent by a refusal: an account starts with one,
|
||||
// and burning it on a typo would leave the player stuck with a name they never had.
|
||||
const me = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('8801') })
|
||||
).json()) as { availableUsernameChanges: number }
|
||||
expect(me.availableUsernameChanges).toBe(3)
|
||||
expect(me.availableUsernameChanges).toBe(1)
|
||||
|
||||
// 50 is the client's own cap, so a name that long has to be accepted.
|
||||
const ok = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||
@@ -890,26 +683,3 @@ test('POST /account/me/phone stores an E.164 number exactly as the client sends
|
||||
// Verbatim — no normalising, no stripping of the +.
|
||||
expect(row?.phone).toBe('+15552223333')
|
||||
})
|
||||
|
||||
// The emoji picker. Served as a BARE array — the client parses the response body itself
|
||||
// as the list, so wrapping it in `{ value: [...] }` or the success envelope every
|
||||
// mutation here uses would leave the picker empty.
|
||||
test('GET /emojiConfig/whitelistedEmojis serves the list as a bare array', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/emojiConfig/whitelistedEmojis`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as string[]
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
expect(body).toEqual(WHITELISTED_EMOJIS)
|
||||
// Order is the picker's order, and the first entry anchors it.
|
||||
expect(body[0]).toBe('😀')
|
||||
// Every entry is a non-empty string and appears once — a duplicate draws twice in
|
||||
// the grid, and the list is compared against `displayEmoji` as an exact string.
|
||||
expect(body.every((e) => typeof e === 'string' && e.length > 0)).toBe(true)
|
||||
expect(new Set(body).size).toBe(body.length)
|
||||
})
|
||||
|
||||
// Not auth-gated: the client asks for the picker before it has a token in hand.
|
||||
test('GET /emojiConfig/whitelistedEmojis needs no bearer token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/emojiConfig/whitelistedEmojis`)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
/**
|
||||
* The emoji a player may pick as their `displayEmoji` — served verbatim as a bare JSON
|
||||
* array by `GET /emojiConfig/whitelistedEmojis`, with no envelope around it.
|
||||
*
|
||||
* ORDER IS THE PICKER'S ORDER: the client renders the array as it arrives, so this is
|
||||
* grouped by theme (smileys, hearts, gestures, animals, food, weather, tech, music,
|
||||
* sport, objects, vehicles, symbols). Sorting it would reshuffle the grid; append a new
|
||||
* entry to the end of the group it belongs to instead.
|
||||
*
|
||||
* Entries are UNIQUE — a repeat draws the same emoji twice in the grid — and are stored
|
||||
* as the exact code-point sequence the client sends back, variation selectors (U+FE0F)
|
||||
* and zero-width joiners included: `displayEmoji` is compared as a plain string, so
|
||||
* `\u{2764}` and `\u{2764}\u{FE0F}` are two different values and only the form listed
|
||||
* here round-trips.
|
||||
*/
|
||||
export const WHITELISTED_EMOJIS: string[] = [
|
||||
'😀',
|
||||
'😃',
|
||||
'😄',
|
||||
'😁',
|
||||
'😆',
|
||||
'😅',
|
||||
'😂',
|
||||
'🤣',
|
||||
'😊',
|
||||
'😇',
|
||||
'🙂',
|
||||
'🙃',
|
||||
'😉',
|
||||
'😍',
|
||||
'🥰',
|
||||
'😘',
|
||||
'😗',
|
||||
'😚',
|
||||
'😋',
|
||||
'😛',
|
||||
'😜',
|
||||
'🤪',
|
||||
'😝',
|
||||
'🤑',
|
||||
'🤗',
|
||||
'🤭',
|
||||
'🤫',
|
||||
'🤔',
|
||||
'🤨',
|
||||
'😐',
|
||||
'😑',
|
||||
'😶',
|
||||
'🙄',
|
||||
'😏',
|
||||
'😣',
|
||||
'😥',
|
||||
'😮',
|
||||
'🤐',
|
||||
'😯',
|
||||
'😪',
|
||||
'😫',
|
||||
'🥱',
|
||||
'😴',
|
||||
'😌',
|
||||
'🤓',
|
||||
'😎',
|
||||
'🤩',
|
||||
'🥳',
|
||||
'😤',
|
||||
'😭',
|
||||
'😢',
|
||||
'🥺',
|
||||
'😡',
|
||||
'🤬',
|
||||
'😱',
|
||||
'😨',
|
||||
'😰',
|
||||
'😬',
|
||||
'🤯',
|
||||
'🥶',
|
||||
'🥵',
|
||||
'🤠',
|
||||
'🤖',
|
||||
'👽',
|
||||
'👻',
|
||||
'💀',
|
||||
'☠️',
|
||||
'👹',
|
||||
'👺',
|
||||
'👾',
|
||||
'❤️',
|
||||
'🧡',
|
||||
'💛',
|
||||
'💚',
|
||||
'💙',
|
||||
'💜',
|
||||
'🖤',
|
||||
'🤍',
|
||||
'🤎',
|
||||
'💖',
|
||||
'💗',
|
||||
'💓',
|
||||
'💕',
|
||||
'💞',
|
||||
'💘',
|
||||
'💝',
|
||||
'💟',
|
||||
'❣️',
|
||||
'💔',
|
||||
'👍',
|
||||
'👎',
|
||||
'👌',
|
||||
'✌️',
|
||||
'🤞',
|
||||
'🤟',
|
||||
'🤘',
|
||||
'🤙',
|
||||
'👏',
|
||||
'🙌',
|
||||
'👐',
|
||||
'🤲',
|
||||
'🙏',
|
||||
'👋',
|
||||
'✋',
|
||||
'🤚',
|
||||
'🫶',
|
||||
'💪',
|
||||
'🧠',
|
||||
'👀',
|
||||
'👁️',
|
||||
'👄',
|
||||
'🦾',
|
||||
'🦿',
|
||||
'🐶',
|
||||
'🐱',
|
||||
'🐭',
|
||||
'🐹',
|
||||
'🐰',
|
||||
'🦊',
|
||||
'🐻',
|
||||
'🐼',
|
||||
'🐨',
|
||||
'🐯',
|
||||
'🦁',
|
||||
'🐸',
|
||||
'🐵',
|
||||
'🐧',
|
||||
'🐦',
|
||||
'🦅',
|
||||
'🦆',
|
||||
'🦄',
|
||||
'🐴',
|
||||
'🐢',
|
||||
'🐙',
|
||||
'🦈',
|
||||
'🐬',
|
||||
'🐳',
|
||||
'🦋',
|
||||
'🐝',
|
||||
'🐞',
|
||||
'🦖',
|
||||
'🦕',
|
||||
'🐲',
|
||||
'🍎',
|
||||
'🍌',
|
||||
'🍇',
|
||||
'🍉',
|
||||
'🍓',
|
||||
'🍒',
|
||||
'🥝',
|
||||
'🍍',
|
||||
'🥑',
|
||||
'🌮',
|
||||
'🍕',
|
||||
'🍔',
|
||||
'🍟',
|
||||
'🌭',
|
||||
'🥪',
|
||||
'🍗',
|
||||
'🍿',
|
||||
'🍩',
|
||||
'🍪',
|
||||
'🎂',
|
||||
'🍫',
|
||||
'🍬',
|
||||
'🍭',
|
||||
'🧋',
|
||||
'☕',
|
||||
'🥤',
|
||||
'🍺',
|
||||
'🥛',
|
||||
'☀️',
|
||||
'🌤️',
|
||||
'⛅',
|
||||
'🌥️',
|
||||
'☁️',
|
||||
'🌧️',
|
||||
'⛈️',
|
||||
'❄️',
|
||||
'🌈',
|
||||
'⭐',
|
||||
'🌟',
|
||||
'✨',
|
||||
'⚡',
|
||||
'🔥',
|
||||
'💧',
|
||||
'🌊',
|
||||
'🌸',
|
||||
'🌹',
|
||||
'🍀',
|
||||
'🌲',
|
||||
'🎮',
|
||||
'🕹️',
|
||||
'💻',
|
||||
'⌨️',
|
||||
'🖥️',
|
||||
'📱',
|
||||
'🖱️',
|
||||
'🎧',
|
||||
'📷',
|
||||
'📹',
|
||||
'💿',
|
||||
'💾',
|
||||
'🔋',
|
||||
'🔌',
|
||||
'🛰️',
|
||||
'🚀',
|
||||
'🎵',
|
||||
'🎶',
|
||||
'🎼',
|
||||
'🎤',
|
||||
'🥁',
|
||||
'🎸',
|
||||
'🎹',
|
||||
'🎺',
|
||||
'🎷',
|
||||
'🎻',
|
||||
'⚽',
|
||||
'🏀',
|
||||
'🏈',
|
||||
'⚾',
|
||||
'🎾',
|
||||
'🏐',
|
||||
'🏓',
|
||||
'🥊',
|
||||
'🏆',
|
||||
'🥇',
|
||||
'🥈',
|
||||
'🥉',
|
||||
'💎',
|
||||
'💰',
|
||||
'💸',
|
||||
'🪙',
|
||||
'🎁',
|
||||
'📦',
|
||||
'🔑',
|
||||
'🗝️',
|
||||
'🛡️',
|
||||
'⚔️',
|
||||
'🧸',
|
||||
'🎈',
|
||||
'🎉',
|
||||
'🎊',
|
||||
'🕯️',
|
||||
'💡',
|
||||
'📚',
|
||||
'📖',
|
||||
'✏️',
|
||||
'🖊️',
|
||||
'🚗',
|
||||
'🚕',
|
||||
'🚌',
|
||||
'🚓',
|
||||
'🚑',
|
||||
'🚒',
|
||||
'🏎️',
|
||||
'🚲',
|
||||
'✈️',
|
||||
'🚁',
|
||||
'🚢',
|
||||
'✔️',
|
||||
'✅',
|
||||
'❌',
|
||||
'⭕',
|
||||
'❗',
|
||||
'❓',
|
||||
'💯',
|
||||
'♾️',
|
||||
'🔔',
|
||||
'🔕',
|
||||
'❤️🔥',
|
||||
]
|
||||
|
||||
/**
|
||||
* The whitelist keyed by its FE0F-stripped form, so a lookup tolerates the one way the
|
||||
* client legitimately disagrees with this list: U+FE0F is a PRESENTATION hint, and
|
||||
* `\u{2764}`/`\u{2764}\u{FE0F}` are the same picked emoji even though they are different
|
||||
* strings. Verified collision-free — stripping FE0F maps the 271 entries onto 271
|
||||
* distinct keys — so the fold can never make two picks ambiguous.
|
||||
*/
|
||||
const BY_STRIPPED = new Map(
|
||||
WHITELISTED_EMOJIS.map((emoji) => [emoji.replaceAll('\u{FE0F}', ''), emoji])
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve a client-submitted emoji to its CANONICAL whitelist entry, or null when it
|
||||
* isn't on the list. Storing the canonical form (rather than what arrived) is what keeps
|
||||
* a stored `displayEmoji` string-equal to the picker entry it came from — the client
|
||||
* highlights the current pick by comparing against the list it fetched.
|
||||
*
|
||||
* Whitelisting matters here beyond tidiness: `displayEmoji` renders beside the display
|
||||
* name, so an unchecked field is a free-text label on every player's profile.
|
||||
*/
|
||||
export function resolveWhitelistedEmoji(input: string): string | null {
|
||||
return BY_STRIPPED.get(input.replaceAll('\u{FE0F}', '')) ?? null
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.13.5",
|
||||
"hono": "4.12.27",
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
@@ -31,6 +31,6 @@
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "26.0.1",
|
||||
"vitest": "4.1.9",
|
||||
"wrangler": "4.128.0"
|
||||
"wrangler": "4.105.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +8,6 @@ API surface. Database-backed queries and on-disk JSON files are stubbed for now
|
||||
|
||||
- **Auth-gated routes** validate the Bearer JWT issued by the `auth` worker
|
||||
(same dev secret, see `src/jwt.ts`) and 401 when it's missing/invalid.
|
||||
- **API-owned uploads** enforce `RECFLARE_MAX_API_UPLOAD_BYTES` per file (64 MiB
|
||||
by default) before copying a parsed file into an `ArrayBuffer` or writing it to R2.
|
||||
- **Static data** is served verbatim:
|
||||
- `src/default-avatar-items.ts` → `GET /api/avatar/v4/items`
|
||||
- `src/default-settings.ts` → `GET /api/settings/v2`
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
-- normal relational table rather than a JSON blob. Owned by the `api` worker;
|
||||
-- generated from src/events-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go, 3 Pending (a bulk
|
||||
-- invite's row for a player who hasn't answered yet). Only Going counts toward the
|
||||
-- event's `AttendeeCount`, which is recomputed from this table on every response. A decline is recorded rather than deleted, so the client can show
|
||||
-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go. Only Going
|
||||
-- counts toward the event's `AttendeeCount`, which is recomputed from this table on
|
||||
-- every response. A decline is recorded rather than deleted, so the client can show
|
||||
-- a player their own answer and changing your mind is an UPDATE (the composite
|
||||
-- primary key is what makes the upsert a replace).
|
||||
--
|
||||
|
||||
@@ -15,10 +15,6 @@
|
||||
-- Partial index: event reports are a small minority of rows, so indexing only the ones
|
||||
-- that name an event keeps "reports against this event" off a full scan without paying
|
||||
-- for the NULLs.
|
||||
--
|
||||
-- SUPERSEDED by 0016_report_invention.sql, which DROPS that index: nothing ever queried
|
||||
-- `event_id`, so it only cost writes. Left here so an unmigrated database still applies
|
||||
-- the migrations in order and ends up in the same place.
|
||||
|
||||
ALTER TABLE report ADD COLUMN event_id INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS idx_report_event ON report (event_id) WHERE event_id IS NOT NULL;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
-- Player reputation (the cheer counters on a profile) and the daily cheer credit that
|
||||
-- pays for handing one out. Both owned by the `api` worker, which is the only reader and
|
||||
-- the only writer — `POST /api/PlayerCheer/v1/create` writes them and the
|
||||
-- `/api/playerReputation/…` reads serve them. Generated from src/reputation-db.ts
|
||||
-- (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- Two tables because they answer two different questions. `reputation` is what a player
|
||||
-- has RECEIVED: one counter per cheer category. `player_cheer` is what they have left to
|
||||
-- GIVE, refilling to 20 once the window in `created` is a day old — a lazy reset, so
|
||||
-- nothing has to run on a schedule.
|
||||
--
|
||||
-- Neither row is created until it is needed: a missing `reputation` row means nobody has
|
||||
-- cheered that player, which is the all-zero record the endpoints already served, and a
|
||||
-- missing `player_cheer` row means they have never spent a cheer, i.e. full credit. So
|
||||
-- reads fall back to the defaults instead of inserting on a GET.
|
||||
--
|
||||
-- `CheerCredit` on the DTO is NOT a column here even though the client's record carries it
|
||||
-- next to the counters: it is `player_cheer.cheers_left` with the rollover applied. Storing
|
||||
-- it in both places would let the number a player reads drift from the one the spend checks.
|
||||
--
|
||||
-- `noteriety` keeps the reference's spelling (the client's field is `Noteriety`). It and
|
||||
-- the subscriber counts are stored but nothing writes them yet — they are per-player
|
||||
-- numbers that will have a source one day, so the column is here and turning them on later
|
||||
-- is a write rather than a migration.
|
||||
--
|
||||
-- `IsCheerful` and `SelectedCheer` were left off here on the theory that nothing varied
|
||||
-- them per player; 0014 adds them — `SelectedCheer` is written by `SetSelectedCheer`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reputation (
|
||||
account_id INTEGER PRIMARY KEY,
|
||||
noteriety INTEGER NOT NULL DEFAULT 0,
|
||||
cheer_general INTEGER NOT NULL DEFAULT 0,
|
||||
cheer_helpful INTEGER NOT NULL DEFAULT 0,
|
||||
cheer_creative INTEGER NOT NULL DEFAULT 0,
|
||||
cheer_great_host INTEGER NOT NULL DEFAULT 0,
|
||||
cheer_sportsman INTEGER NOT NULL DEFAULT 0,
|
||||
subscriber_count INTEGER NOT NULL DEFAULT 0,
|
||||
subscribed_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- `created` is the START of the live credit window, not the row's creation time: spending
|
||||
-- a cheer inside a window leaves it alone, so a player refills 24h after their FIRST cheer
|
||||
-- rather than sliding the deadline forward with every one they hand out. Stored as an
|
||||
-- ISO-8601 UTC string, which is fixed-width and so orders correctly under SQLite's plain
|
||||
-- string comparison — the spend compares against a cutoff without any date functions.
|
||||
CREATE TABLE IF NOT EXISTS player_cheer (
|
||||
player_id INTEGER PRIMARY KEY,
|
||||
cheers_left INTEGER NOT NULL,
|
||||
created TEXT NOT NULL
|
||||
);
|
||||
@@ -1,12 +0,0 @@
|
||||
-- The two profile fields 0013 left off the `reputation` table on the theory that nothing
|
||||
-- varied them per player. Owned by the `api` worker; generated from src/reputation-db.ts
|
||||
-- (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `selected_cheer` is the cheer a player has PINNED to their profile, written by
|
||||
-- `POST /api/PlayerCheer/v1/SetSelectedCheer` (form `CheerCategory`), which every reference
|
||||
-- server stores per player — 0013's "no endpoint sets one" was wrong. `is_cheerful` is the
|
||||
-- profile flag the client's DTO and `ReputationUpdate` frame both carry, read straight off
|
||||
-- the record like every reference does; it is a column so it can vary one day without a
|
||||
-- migration, defaulted true because that is what every reference serves.
|
||||
ALTER TABLE reputation ADD COLUMN is_cheerful INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE reputation ADD COLUMN selected_cheer INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -1,33 +0,0 @@
|
||||
-- Custom avatar items (the player-designed shirts/hats built on a base catalog item).
|
||||
-- Owned by the `api` worker: `POST /api/customAvatarItems/v1` inserts a row. Generated
|
||||
-- from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- One column per field of the client's `CustomAvatarItem` DTO, so a row IS the response.
|
||||
-- The two uploads (the design and the thumbnail PNG) live in the `recflare-img` bucket
|
||||
-- under `avatar-item/<date>/<id>-thumb.png` / `<id>-design.png`; the filename columns
|
||||
-- hold those bucket keys.
|
||||
--
|
||||
-- `ranking_context` and `purchase_info` are served as null and `current_saves` as an
|
||||
-- empty list; none of them has a source yet, so they are not columns.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS custom_avatar_item (
|
||||
custom_avatar_item_id TEXT PRIMARY KEY,
|
||||
creator_account_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
price INTEGER NOT NULL DEFAULT 0,
|
||||
accessibility INTEGER NOT NULL DEFAULT 0,
|
||||
force_cannot_publish INTEGER NOT NULL DEFAULT 0,
|
||||
is_featured INTEGER NOT NULL DEFAULT 0,
|
||||
is_rec_room_approved INTEGER NOT NULL DEFAULT 0,
|
||||
base_avatar_item_id INTEGER NOT NULL,
|
||||
base_avatar_item_color TEXT NOT NULL,
|
||||
design_filename TEXT NOT NULL,
|
||||
thumbnail_image_filename TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
modified_at TEXT NOT NULL,
|
||||
preview_orientation INTEGER NOT NULL DEFAULT 0,
|
||||
outfit_type INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id);
|
||||
@@ -1,22 +0,0 @@
|
||||
-- Reporting an INVENTION (`POST /api/inventions/v1/report`) reuses the report table, as
|
||||
-- the event report next to it does: same fields, same moderation life — a moderator
|
||||
-- acting on one sets `banned` on the row exactly as they would for a player report.
|
||||
-- Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `invention_id` names the reported invention; NULL on every other kind of report. It
|
||||
-- sits beside `event_id` and the two are mutually exclusive: a row names an event, or an
|
||||
-- invention, or neither (an ordinary player report), which is what tells the kinds apart.
|
||||
--
|
||||
-- The row's `reported_player_id` is the invention's CREATOR, read from the invention
|
||||
-- rather than sent by the client — the column is NOT NULL, and "who is answerable for
|
||||
-- this invention" is the only honest answer. No `room_id`: an invention is not tied to
|
||||
-- one room the way an event is, so there is nothing to fill it in from.
|
||||
--
|
||||
-- Neither id column is INDEXED. Both are written on every report of their kind and read
|
||||
-- by nothing — no query in any worker filters on either, and the moderation reads that do
|
||||
-- exist go by player (`idx_report_reported`) or by the ban flag. So the partial index
|
||||
-- 0011 built over `event_id` is dropped here rather than being mirrored for
|
||||
-- `invention_id`: it only cost writes. Add one back with the query that needs it.
|
||||
|
||||
ALTER TABLE report ADD COLUMN invention_id INTEGER;
|
||||
DROP INDEX IF EXISTS idx_report_event;
|
||||
@@ -1,26 +0,0 @@
|
||||
-- Reporting a CUSTOM AVATAR ITEM (`POST /api/customAvatarItems/v1/{id}/report`) reuses the
|
||||
-- report table, exactly as the event and invention reports beside it do: same fields, same
|
||||
-- moderation life — a moderator acting on one sets `banned` on the row as they would for a
|
||||
-- player report. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `custom_avatar_item_id` names the reported item. TEXT, not INTEGER, because a custom
|
||||
-- avatar item is keyed by a GUID (`aeef6bfa-09b1-4859-a77c-47a6e7523543`) where an event and
|
||||
-- an invention are keyed by a number — the three columns are the same idea in three key
|
||||
-- types, which is why they are three columns rather than one polymorphic id.
|
||||
--
|
||||
-- NULL on every other kind of report. It joins `event_id` and `invention_id`, and all three
|
||||
-- are mutually exclusive: a row names an event, an invention, an item, or none of them (an
|
||||
-- ordinary player report), which is what tells the kinds apart.
|
||||
--
|
||||
-- The row's `reported_player_id` is the item's CREATOR, read from the item rather than sent
|
||||
-- by the client. The client sends `ReportedPlayerId: null` on this route — it does not know
|
||||
-- who made the item — and the column is NOT NULL, so deriving it is the only way to fill it,
|
||||
-- and "who is answerable for this item" is the only honest answer anyway.
|
||||
--
|
||||
-- No `room_id`: an item is not tied to one room the way an event is.
|
||||
--
|
||||
-- NOT indexed, for the same reason 0016 gave and then dropped 0011's index over `event_id`:
|
||||
-- it is written on every report of this kind and read by nothing. Every moderation read goes
|
||||
-- by player (`idx_report_reported`) or by the ban flag. Add an index with the query needing it.
|
||||
|
||||
ALTER TABLE report ADD COLUMN custom_avatar_item_id TEXT;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- A player's interaction with an invention. One row per (player, invention); `cheered`
|
||||
-- is toggled in place and the invention JSON's denormalized `CheerCount` is resynced
|
||||
-- after every write. Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invention_interaction (
|
||||
player_id INTEGER NOT NULL,
|
||||
invention_id INTEGER NOT NULL,
|
||||
cheered INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT,
|
||||
PRIMARY KEY (player_id, invention_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_invention_interaction_invention
|
||||
ON invention_interaction (invention_id);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -22,7 +22,7 @@
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.13.5",
|
||||
"hono": "4.12.27",
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
@@ -34,6 +34,6 @@
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "26.0.1",
|
||||
"vitest": "4.1.9",
|
||||
"wrangler": "4.128.0"
|
||||
"wrangler": "4.105.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Turns an export of first-party custom avatar items into a D1 migration that seeds the
|
||||
* `custom_avatar_item` table with them, the way `apps/rooms/migrations/0002_import_rooms.sql`
|
||||
* seeds the rooms.
|
||||
*
|
||||
* bun apps/api/scripts/import-custom-avatar-items.ts <export.json> <NNNN>
|
||||
*
|
||||
* `export.json` is a JSON array of `CustomAvatarItem` records as the official service serves
|
||||
* them (a `{ Results: [...] }` page or a single record are accepted too). `NNNN` is the
|
||||
* migration number; the file is written to `apps/api/migrations/NNNN_import_custom_avatar_items.sql`.
|
||||
*
|
||||
* For a load that needs no migration and no deploy — the usual case — use
|
||||
* `just admin cai-load` instead, which runs the same statements straight against a database.
|
||||
* The statements come from src/custom-avatar-items-load.ts either way: each record stored as
|
||||
* the row's JSON minus `PurchaseInfo`, with `CreatorAccountId` forced to the Coach account (1)
|
||||
* and each save's `ThumbnailFileName` put under `avatar/` and its assetbundle hashes blanked,
|
||||
* an existing id REPLACED, nothing deleted.
|
||||
*
|
||||
* The export's `Price` is mostly 0; run `price-custom-avatar-items.ts` against it first to fill
|
||||
* in prices from the storefront dump.
|
||||
*
|
||||
* The saves name assetbundles by bare filename; the migration does nothing about those files,
|
||||
* which have to be served from wherever the client fetches them.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
import {
|
||||
customAvatarItemUpsertStatements,
|
||||
readCustomAvatarItemExport,
|
||||
} from '../src/custom-avatar-items-load'
|
||||
|
||||
const [input, number] = process.argv.slice(2)
|
||||
if (!input || !number || !/^\d{4}$/.test(number)) {
|
||||
console.error('usage: bun apps/api/scripts/import-custom-avatar-items.ts <export.json> <NNNN>')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
// Tolerate the BOM a .NET export carries (`JSON.parse` rejects U+FEFF).
|
||||
const records = readCustomAvatarItemExport(
|
||||
JSON.parse(readFileSync(input, 'utf8').replace(/^/, ''))
|
||||
)
|
||||
const lines = [
|
||||
`-- Seed first-party custom avatar items (${records.length} records) from ${input.split('/').pop()}.`,
|
||||
'-- Generated by apps/api/scripts/import-custom-avatar-items.ts — do not edit by hand.',
|
||||
'-- Each record is the official `CustomAvatarItem`, filed under the Coach account (CreatorAccountId 1);',
|
||||
'-- an existing id is replaced.',
|
||||
'',
|
||||
...customAvatarItemUpsertStatements(records),
|
||||
]
|
||||
|
||||
const out = decodeURIComponent(
|
||||
new URL(`../migrations/${number}_import_custom_avatar_items.sql`, import.meta.url).pathname
|
||||
)
|
||||
writeFileSync(out, lines.join('\n') + '\n')
|
||||
console.log(`wrote ${records.length} records to ${out}`)
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Fills in `Price` on an export of first-party custom avatar items from a storefront dump,
|
||||
* rewriting the export in place so `import-custom-avatar-items.ts` / `just admin cai-load`
|
||||
* carry the price into the table.
|
||||
*
|
||||
* bun apps/api/scripts/price-custom-avatar-items.ts <export.json> <storefront.json>
|
||||
*
|
||||
* The export (e.g. `apps/econ/static/db/2025-1-cai.json`) mostly carries `Price: 0`. The
|
||||
* storefront (e.g. `Watch_EnumValue_3.json`, a `{ StoreItems: [...] }` page) prices the same
|
||||
* items, joined on `GiftDrop.AvatarItemInfo.DownloadableAvatarItemId` = `CustomAvatarItemId`.
|
||||
* The price taken is the base token price (`CurrencyType` 2), ignoring any sale. The storefront
|
||||
* only prices what the export already holds: nothing is added from it.
|
||||
*
|
||||
* An item the storefront doesn't list keeps the price it had, unless that price is 0, which
|
||||
* would make it free to buy. Those are the Cryptid Creek event items and the Evergrown Hip Pack,
|
||||
* all rarity-50 rewards that were never sold, so they get the catalog's rarity-50 price.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
import { priceForRarity } from '../../econ/src/catalog-load'
|
||||
|
||||
const TOKENS = 2
|
||||
|
||||
/** The rarity an item missing from the storefront is priced as, when it has no price. */
|
||||
const UNLISTED_RARITY = 50
|
||||
|
||||
interface StoreItem {
|
||||
GiftDrop?: { AvatarItemInfo?: { DownloadableAvatarItemId?: string | null } | null }
|
||||
Prices?: Array<{ CurrencyType: number; Price: number }>
|
||||
}
|
||||
|
||||
const [exportPath, storefrontPath] = process.argv.slice(2)
|
||||
if (!exportPath || !storefrontPath) {
|
||||
console.error(
|
||||
'usage: bun apps/api/scripts/price-custom-avatar-items.ts <export.json> <storefront.json>'
|
||||
)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
// Tolerate the BOM a .NET export carries (`JSON.parse` rejects U+FEFF).
|
||||
const readJson = (path: string) => JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''))
|
||||
|
||||
const items: Array<{ CustomAvatarItemId: string; Name: string; Price: number }> =
|
||||
readJson(exportPath)
|
||||
const storefront: { StoreItems: StoreItem[] } = readJson(storefrontPath)
|
||||
|
||||
const prices = new Map<string, number>()
|
||||
for (const storeItem of storefront.StoreItems) {
|
||||
const id = storeItem.GiftDrop?.AvatarItemInfo?.DownloadableAvatarItemId?.toLowerCase()
|
||||
const price = storeItem.Prices?.find((p) => p.CurrencyType === TOKENS)?.Price
|
||||
if (id && price !== undefined && !prices.has(id)) {
|
||||
prices.set(id, price)
|
||||
}
|
||||
}
|
||||
|
||||
let priced = 0
|
||||
let changed = 0
|
||||
const unmatched: string[] = []
|
||||
for (const item of items) {
|
||||
let price = prices.get(item.CustomAvatarItemId.toLowerCase())
|
||||
if (price === undefined) {
|
||||
unmatched.push(item.Name)
|
||||
if (item.Price) continue
|
||||
price = priceForRarity(UNLISTED_RARITY)
|
||||
}
|
||||
priced++
|
||||
if (item.Price !== price) {
|
||||
if (item.Price) {
|
||||
console.warn(`${item.Name}: ${item.Price} -> ${price}`)
|
||||
}
|
||||
item.Price = price
|
||||
changed++
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(exportPath, JSON.stringify(items, null, 2) + '\n')
|
||||
console.log(`priced ${priced}/${items.length} items (${changed} changed) in ${exportPath}`)
|
||||
if (unmatched.length) {
|
||||
console.log(
|
||||
`not in the storefront (kept their price, or rarity ${UNLISTED_RARITY} if free): ${unmatched.join(', ')}`
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withDatabase } from '@repo/domain'
|
||||
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import { accountRoutes } from './routes/account'
|
||||
@@ -17,7 +16,7 @@ import { progressionRoutes } from './routes/progression'
|
||||
import { roomRoutes } from './routes/rooms'
|
||||
import { socialRoutes } from './routes/social'
|
||||
|
||||
import type { App, Env } from './context'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
* The Game API surface. Endpoints that would be backed by a database or on-disk
|
||||
@@ -106,4 +105,4 @@ app.get(
|
||||
)
|
||||
)
|
||||
|
||||
export default withDatabase<Env>(app)
|
||||
export default app
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@ export type Env = SharedHonoEnv & {
|
||||
* for local dev and tests.
|
||||
*/
|
||||
DOMAIN: string
|
||||
/** Maximum accepted size of each API-owned image upload, in bytes. */
|
||||
RECFLARE_MAX_API_UPLOAD_BYTES?: string
|
||||
// Shared rooms database (schema/migrations owned by the `rooms` worker). Used
|
||||
// read-only here to resolve room roles for `/api/rooms/v1/verifyRole`.
|
||||
DB: D1Database
|
||||
|
||||
@@ -1,610 +0,0 @@
|
||||
/**
|
||||
* Custom avatar items on the shared `recflare` D1 database: the player-designed shirts built
|
||||
* on a base catalog item, and the first-party items imported from the official service, which
|
||||
* are the same record with a set of built Unity assetbundles (`CurrentSaves`) in place of a
|
||||
* base item and a design PNG.
|
||||
*
|
||||
* A row is the client's `CustomAvatarItem` record verbatim, as JSON in `data` — the layout the
|
||||
* `invention`, `event`, `room`, `account` and `club` tables use — with the fields the queries
|
||||
* filter and sort on exposed as generated (virtual) columns. So a row IS the response, an
|
||||
* import is the exported record as-is, and a field the source adds later is served without a
|
||||
* migration. The flat one-column-per-field table this replaced (0015) could not hold a
|
||||
* first-party item at all: its base-item and filename columns were NOT NULL, and it had no home
|
||||
* for the saves.
|
||||
*
|
||||
* `CurrentSaves` stays EMBEDDED rather than in a child table: the client reads a save off its
|
||||
* item and picks one by `BodyType`, and nothing on this server looks a save up by its bare id
|
||||
* (the legacy-item lookup keys them by `AvatarItemDesc`). Room saves got their own table
|
||||
* because the client points at one by bare id; that does not apply here.
|
||||
*
|
||||
* The two uploads that accompany a player's creation (the design blob and the thumbnail PNG)
|
||||
* live in the shared image bucket (`recflare-img`, the `IMAGES` binding) under
|
||||
* `avatar-item/<date>/<id>-thumb.png` and `<id>-design.png`; `ThumbnailImageFilename` and
|
||||
* `DesignFilename` hold those bucket keys, which the `img` worker serves back by key. A
|
||||
* first-party item has both null and is rendered from its saves' assetbundles instead.
|
||||
*
|
||||
* The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.sql, rebuilt
|
||||
* as JSON by 0022_custom_avatar_item_json.sql, applied under its own `migrations_table`).
|
||||
*/
|
||||
|
||||
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load'
|
||||
|
||||
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
|
||||
data TEXT NOT NULL,
|
||||
custom_avatar_item_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.CustomAvatarItemId')) VIRTUAL,
|
||||
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
|
||||
accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL,
|
||||
outfit_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.OutfitType')) VIRTUAL,
|
||||
price INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Price')) VIRTUAL,
|
||||
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
|
||||
created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.CreatedAt')) VIRTUAL,
|
||||
name_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Name'), ''))) VIRTUAL,
|
||||
description_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Description'), ''))) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* One built version of a first-party item, as the client's PascalCase `CustomAvatarItemSave`:
|
||||
* a Unity assetbundle (`UnityAsset`, with `UnityAsset2` a second variant) the client downloads
|
||||
* to render the item, plus the thumbnail it shows in the store. An item carries one per
|
||||
* `BodyType` it was built for; the client picks the save matching the wearer's body.
|
||||
* `AdditionalConfiguration` is the client's own JSON-in-a-string document and is served as
|
||||
* stored. Not the camelCase `CustomAvatarItemSave` the legacy-item lookup documents — same
|
||||
* save, different casing and field set; keep the two apart.
|
||||
*/
|
||||
export interface CustomAvatarItemSave {
|
||||
CustomAvatarItemSaveId: number
|
||||
CustomAvatarItemId: string
|
||||
UnityAssetId: string
|
||||
BodyType: number
|
||||
OutfitType: number
|
||||
QAState: number
|
||||
CreatedAt: string
|
||||
ModifiedAt: string
|
||||
Description: string | null
|
||||
ThumbnailFileName: string
|
||||
AdditionalConfiguration: string
|
||||
UnityAsset: string
|
||||
UnityAssetHash: string
|
||||
UnityAsset2: string | null
|
||||
UnityAsset2Hash: string | null
|
||||
}
|
||||
|
||||
/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */
|
||||
export interface CustomAvatarItemTag {
|
||||
TagType: number
|
||||
Value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's `CustomAvatarItem` record (PascalCase, as served). The four nullable fields are
|
||||
* null on a first-party item, which has no base item or design PNG: it is rendered from
|
||||
* `CurrentSaves` instead, which is empty on a player-made shirt.
|
||||
*/
|
||||
export interface CustomAvatarItem {
|
||||
CustomAvatarItemId: string
|
||||
CreatorAccountId: number
|
||||
Name: string
|
||||
Description: string
|
||||
Price: number
|
||||
Accessibility: number
|
||||
ForceCannotPublish: boolean
|
||||
IsFeatured: boolean
|
||||
IsRecRoomApproved: boolean
|
||||
BaseAvatarItemId: number | null
|
||||
BaseAvatarItemColor: string | null
|
||||
DesignFilename: string | null
|
||||
ThumbnailImageFilename: string | null
|
||||
CreatedAt: string
|
||||
ModifiedAt: string
|
||||
PreviewOrientation: number
|
||||
RankingContext: null
|
||||
OutfitType: number
|
||||
CurrentSaves: CustomAvatarItemSave[]
|
||||
Tags: CustomAvatarItemTag[]
|
||||
CustomBadgeMetadata: unknown
|
||||
RankedEntityId: string
|
||||
PurchaseInfo: null
|
||||
}
|
||||
|
||||
/**
|
||||
* `RecRoom.Avatars.OutfitType.CustomShirt` — the slot of a custom shirt, the one kind of custom
|
||||
* avatar item the client can make. The enum (unobfuscated in the client) is banded by body
|
||||
* region: head None -1, Hat 0, Hair 2, Ear 3, Eye 10, Beard 20; torso Shoulder 100, Shirt 101,
|
||||
* Waist 102, Neck 103, TeamJersey 104, CustomShirt 105; arms Wrist 200, TeamWrist 203; legs
|
||||
* Legs 300, Feet 301; Roomie_Hat 500, Roomie_Waist 501, Roomie_Eye 502. It reaches the search
|
||||
* as a bare `IEnumerable<int>` — nothing on the wire ties 105 to the name.
|
||||
*
|
||||
* This is what the store's user-generated-content tab searches for, ALONE
|
||||
* (`GET /api/customAvatarItems/v1/search?outfitTypes=105&includeCoachItems=False`), so it is
|
||||
* what every created item must be filed under: the table's default of 0 (Hat) matched nothing,
|
||||
* and the tab sat empty over a full catalog. The imported first-party items carry their own
|
||||
* slot (a wing is 100, Shoulder) and are found by the storefront tab's dozen-slot query.
|
||||
*
|
||||
* Not to be confused with the search's `itemTypes`, a different 3-member enum (All -1, None 0,
|
||||
* Shirt 1) whose Shirt is 1, not 101 or 105. The client sends `itemTypes=-1` (All) alongside
|
||||
* `outfitTypes=105`; the outfit type is the filter, and the two are never cross-mapped.
|
||||
*/
|
||||
export const OUTFIT_TYPE_CUSTOM_SHIRT = 105
|
||||
|
||||
/** What `POST /api/customAvatarItems/v1` needs to create an item. */
|
||||
export interface CreateCustomAvatarItemInput {
|
||||
/** The item's id. Chosen by the caller because the upload keys are derived from it. */
|
||||
customAvatarItemId: string
|
||||
creatorAccountId: number
|
||||
name: string
|
||||
description: string
|
||||
price: number
|
||||
baseAvatarItemId: number
|
||||
baseAvatarItemColor: string
|
||||
accessibility: number
|
||||
designFilename: string
|
||||
thumbnailImageFilename: string
|
||||
/** The slot the item is worn in; {@link OUTFIT_TYPE_CUSTOM_SHIRT} when left out. */
|
||||
outfitType?: number
|
||||
}
|
||||
|
||||
interface Row {
|
||||
data: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What a stored record may lack and the response must carry: an imported record predates
|
||||
* some of these, and `PurchaseInfo` is a store-side projection (`econ` prices items), never
|
||||
* stored. Everything the record does carry wins over these.
|
||||
*/
|
||||
const DTO_DEFAULTS = {
|
||||
CurrentSaves: [] as CustomAvatarItemSave[],
|
||||
Tags: [] as CustomAvatarItemTag[],
|
||||
CustomBadgeMetadata: null,
|
||||
RankingContext: null,
|
||||
PurchaseInfo: null,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* A stored row's JSON as the client's record. Exported for the `econ` worker's owned-items
|
||||
* read, which joins `inventory_custom` to this table and must serve the same shape.
|
||||
*/
|
||||
export function parseCustomAvatarItem(data: string): CustomAvatarItem {
|
||||
const stored = JSON.parse(data) as CustomAvatarItem
|
||||
return { ...DTO_DEFAULTS, ...stored, RankingContext: null, PurchaseInfo: null }
|
||||
}
|
||||
|
||||
function toDto(row: Row): CustomAvatarItem {
|
||||
return parseCustomAvatarItem(row.data)
|
||||
}
|
||||
|
||||
/** Inserts a new player-made custom avatar item and returns it as the client's DTO. */
|
||||
export async function createCustomAvatarItem(
|
||||
db: D1Database,
|
||||
input: CreateCustomAvatarItemInput,
|
||||
now: Date = new Date()
|
||||
): Promise<CustomAvatarItem> {
|
||||
const ts = now.toISOString()
|
||||
const record: Omit<CustomAvatarItem, 'PurchaseInfo'> = {
|
||||
CustomAvatarItemId: input.customAvatarItemId,
|
||||
CreatorAccountId: input.creatorAccountId,
|
||||
Name: input.name,
|
||||
Description: input.description,
|
||||
Price: input.price,
|
||||
Accessibility: input.accessibility,
|
||||
ForceCannotPublish: false,
|
||||
IsFeatured: false,
|
||||
IsRecRoomApproved: false,
|
||||
BaseAvatarItemId: input.baseAvatarItemId,
|
||||
BaseAvatarItemColor: input.baseAvatarItemColor,
|
||||
DesignFilename: input.designFilename,
|
||||
ThumbnailImageFilename: input.thumbnailImageFilename,
|
||||
CreatedAt: ts,
|
||||
ModifiedAt: ts,
|
||||
PreviewOrientation: 0,
|
||||
OutfitType: input.outfitType ?? OUTFIT_TYPE_CUSTOM_SHIRT,
|
||||
CurrentSaves: [],
|
||||
Tags: [],
|
||||
CustomBadgeMetadata: null,
|
||||
RankedEntityId: input.customAvatarItemId,
|
||||
RankingContext: null,
|
||||
}
|
||||
const row = await db
|
||||
.prepare('INSERT INTO custom_avatar_item (data) VALUES (?1) RETURNING data')
|
||||
.bind(JSON.stringify(record))
|
||||
.first<Row>()
|
||||
if (!row) throw new Error('custom_avatar_item insert returned no row')
|
||||
return toDto(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a record as exported from the official service (the first-party items), verbatim,
|
||||
* replacing any row with the same `CustomAvatarItemId` so a re-import with corrections lands.
|
||||
* This is what the generated import migration does in SQL; it is here for tests and tooling.
|
||||
*/
|
||||
export async function importCustomAvatarItem(
|
||||
db: D1Database,
|
||||
record: Omit<CustomAvatarItem, 'PurchaseInfo'> & { PurchaseInfo?: unknown }
|
||||
): Promise<CustomAvatarItem> {
|
||||
const { PurchaseInfo: _purchaseInfo, ...stored } = record
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO custom_avatar_item (data) VALUES (?1)
|
||||
ON CONFLICT(custom_avatar_item_id) DO UPDATE SET data = excluded.data
|
||||
RETURNING data`
|
||||
)
|
||||
.bind(JSON.stringify(stored))
|
||||
.first<Row>()
|
||||
if (!row) throw new Error('custom_avatar_item import returned no row')
|
||||
return toDto(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ItemType` that names a custom avatar item in a UGC-purchasable reference
|
||||
* (`POST /api/ugcPurchasables/v1/items/bulk`'s `Ids[].itemType`).
|
||||
*/
|
||||
export const UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM = 3
|
||||
|
||||
/** A custom avatar item as the client's `UgcPurchasableItem` (the store-facing view). */
|
||||
export interface UgcPurchasableItem {
|
||||
ItemType: number
|
||||
ItemId: string
|
||||
Name: string
|
||||
Description: string
|
||||
ImageName: string
|
||||
RoomId: number
|
||||
Price: number
|
||||
PurchaseCurrencyId: string | null
|
||||
CreatedAt: string
|
||||
ModifiedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The store-facing projection of a custom avatar item. `RoomId` is echoed from the
|
||||
* request — the item table has no room; what the client wants it for is still unknown.
|
||||
* `PurchaseCurrencyId` is null (the client's field is nullable) until a currency exists.
|
||||
* `ImageName` is the item's own thumbnail for a player-made shirt; a first-party item has
|
||||
* none at the item level, so the first save's thumbnail stands in.
|
||||
*/
|
||||
export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPurchasableItem {
|
||||
return {
|
||||
ItemType: UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
|
||||
ItemId: item.CustomAvatarItemId,
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
ImageName: item.ThumbnailImageFilename ?? item.CurrentSaves[0]?.ThumbnailFileName ?? '',
|
||||
RoomId: roomId,
|
||||
Price: item.Price,
|
||||
PurchaseCurrencyId: null,
|
||||
CreatedAt: item.CreatedAt,
|
||||
ModifiedAt: item.ModifiedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches the items with these ids, in the order asked; unknown ids are skipped. */
|
||||
export async function getCustomAvatarItems(
|
||||
db: D1Database,
|
||||
ids: string[]
|
||||
): Promise<CustomAvatarItem[]> {
|
||||
if (ids.length === 0) return []
|
||||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id IN (${placeholders})`)
|
||||
.bind(...ids)
|
||||
.all<Row>()
|
||||
const byId = new Map(results.map(toDto).map((item) => [item.CustomAvatarItemId, item]))
|
||||
return ids.flatMap((id) => byId.get(id) ?? [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the QUEST builds of the assetbundles live, relative to the PC ones. An assetbundle is
|
||||
* built per Unity target, and a save names its bundle by bare filename for the client to fetch
|
||||
* from the cdn's `/avatar/`; the Android build of the same bundle keeps the same filename one
|
||||
* folder down, so a caller asking for the Quest target is served `quest/<name>.assetbundle` and asks the cdn for
|
||||
* `/avatar/quest/<name>.assetbundle`.
|
||||
*/
|
||||
export const QUEST_ASSET_PREFIX = 'quest/'
|
||||
|
||||
/**
|
||||
* The `unityAssetTarget` a Quest asks with — the Unity build target the caller wants its
|
||||
* assetbundles built for, which the client names on every custom-avatar-item read. 0 is PC
|
||||
* (Windows), and is what the saves' bare names are; 2 has been SEEN from the Android/Oculus
|
||||
* client and is read as that. The rest of the enum has not been observed.
|
||||
*/
|
||||
export const UNITY_ASSET_TARGET_QUEST = 2
|
||||
|
||||
/**
|
||||
* Whether a request's `unityAssetTarget` asks for the Quest builds. Anything else — 0, a
|
||||
* target not seen yet, or none at all — is served the PC names, which is what was served
|
||||
* before targets were read.
|
||||
*/
|
||||
export function isQuestAssetTarget(target: string | null | undefined): boolean {
|
||||
return target?.trim() === String(UNITY_ASSET_TARGET_QUEST)
|
||||
}
|
||||
|
||||
/**
|
||||
* An assetbundle name under {@link QUEST_ASSET_PREFIX}. Only `.assetbundle` names are touched,
|
||||
* and one that already carries the prefix is left alone; null (an absent `UnityAsset2`) is
|
||||
* returned as it came.
|
||||
*/
|
||||
function questAsset<T extends string | null>(name: T): T | string {
|
||||
if (name === null || !name.endsWith('.assetbundle')) return name
|
||||
return name.startsWith(QUEST_ASSET_PREFIX) ? name : QUEST_ASSET_PREFIX + name
|
||||
}
|
||||
|
||||
/**
|
||||
* An item as a caller asking for the Quest target is served it: every save's `UnityAsset`/`UnityAsset2` pointed at
|
||||
* the Quest build (see {@link QUEST_ASSET_PREFIX}). Per-response, never stored — the row keeps
|
||||
* the bare names, which are what every other target is served. The hashes are left as
|
||||
* stored. A player-made shirt has no saves and comes back unchanged.
|
||||
*/
|
||||
export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarItem {
|
||||
return {
|
||||
...item,
|
||||
CurrentSaves: item.CurrentSaves.map((save) => ({
|
||||
...save,
|
||||
UnityAsset: questAsset(save.UnityAsset),
|
||||
UnityAsset2: questAsset(save.UnityAsset2),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The featured feed (`GET /api/customAvatarItems/v1/featured`): items flagged
|
||||
* `is_featured` that are also published — `Accessibility` 0 is the unpublished state, so
|
||||
* those are excluded even when flagged. Newest first. Nothing sets the flag yet, so the
|
||||
* feed is empty until an operator writes `is_featured = 1`.
|
||||
*/
|
||||
export async function listFeaturedCustomAvatarItems(
|
||||
db: D1Database,
|
||||
limit = 50
|
||||
): Promise<CustomAvatarItem[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0
|
||||
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
|
||||
)
|
||||
.bind(limit)
|
||||
.all<Row>()
|
||||
return results.map(toDto)
|
||||
}
|
||||
|
||||
/**
|
||||
* The "hot" (trending) feed (`GET /api/customAvatarItems/v1/hot`): every PUBLISHED item —
|
||||
* `Accessibility` 0 is the unpublished state and is the only thing held back. Newest
|
||||
* first, standing in for a trend ranking there is nothing to compute one from yet (no
|
||||
* purchase or wear counts are recorded).
|
||||
*/
|
||||
export async function listHotCustomAvatarItems(
|
||||
db: D1Database,
|
||||
limit = 50
|
||||
): Promise<CustomAvatarItem[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM custom_avatar_item WHERE accessibility != 0
|
||||
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
|
||||
)
|
||||
.bind(limit)
|
||||
.all<Row>()
|
||||
return results.map(toDto)
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Coach" system account — this server's stock content is authored by it, the same id the
|
||||
* `econ` worker attributes a self-buy or an anonymous gift to. Lives in the loader module so
|
||||
* the CLI can name it without Workers types; re-exported here for the worker-side callers.
|
||||
*/
|
||||
export { COACH_ACCOUNT_ID }
|
||||
|
||||
/** What `GET /api/customAvatarItems/v1/search` narrows the catalog by. */
|
||||
export interface CustomAvatarItemSearch {
|
||||
/** Free text, matched against an item's NAME or its DESCRIPTION. Blank means no filter. */
|
||||
searchQuery?: string
|
||||
/**
|
||||
* `OutfitType`s to include. EMPTY means no filter rather than no results: the client sends
|
||||
* the full set of types it can render, so an absent parameter is "everything", not "nothing".
|
||||
*/
|
||||
outfitTypes?: number[]
|
||||
/**
|
||||
* WHICH SIDE of the catalog to serve, not whether to add one to the other: `true` is the
|
||||
* Coach's stock content ONLY, `false` is player-made content ONLY, and undefined is both.
|
||||
* The client browses the store's two tabs with this — its storefront call says `True`
|
||||
* and its user-generated-content call says `False` — so "include" reads as a toggle
|
||||
* between them, never as a superset.
|
||||
*/
|
||||
includeCoachItems?: boolean
|
||||
/**
|
||||
* Only items this account authored — how the client lists what one player has made (a
|
||||
* profile's creations, `creatorAccountId=<id>&includeCoachItems=False&take=1000`). Combines
|
||||
* with the rest as one more AND; naming the Coach here with `includeCoachItems=false`
|
||||
* matches nothing, as the two conditions read.
|
||||
*/
|
||||
creatorAccountId?: number
|
||||
/** Lowest price to include, inclusive. */
|
||||
minPrice?: number
|
||||
/** Highest price to include, inclusive. */
|
||||
maxPrice?: number
|
||||
/** Rows to skip, for paging. */
|
||||
skip?: number
|
||||
/** Rows to return. Capped at {@link SEARCH_MAX_TAKE}. */
|
||||
take?: number
|
||||
}
|
||||
|
||||
/** The most rows one search returns, whatever `take` asks for. The client asks for 100. */
|
||||
export const SEARCH_MAX_TAKE = 200
|
||||
|
||||
/**
|
||||
* The store's item search (`GET /api/customAvatarItems/v1/search`), newest first.
|
||||
*
|
||||
* PUBLISHED items only — `Accessibility` 0 is the unpublished state, and this is the browse
|
||||
* surface everyone shares, so an unpublished item must not appear here even to its creator (who
|
||||
* has `fromCreator` for that).
|
||||
*
|
||||
* `searchQuery` matches an item's NAME or its DESCRIPTION, case-insensitively, as a substring,
|
||||
* or its `CustomAvatarItemId` whole. Both sides are lowered rather than relying on `LIKE`, which
|
||||
* folds case for ASCII only and would miss half of what players type. `%` and `_` in the needle
|
||||
* are escaped, so searching for a literal one finds it instead of matching everything.
|
||||
*
|
||||
* `outfitTypes` is a WHITELIST when non-empty and no filter when empty, which is the opposite of
|
||||
* how an empty IN () clause reads in SQL: the client sends every type it can render, so treating
|
||||
* an absent parameter as "match nothing" would empty the store.
|
||||
*
|
||||
* `includeCoachItems` SPLITS the catalog by author rather than widening it: `true` is the Coach's
|
||||
* items alone, `false` everyone else's alone, absent both. The client's storefront tab sends
|
||||
* `True` and its user-generated-content tab sends `False`, and the two must not overlap — read
|
||||
* as "stock plus players'", the storefront would show every player's item too.
|
||||
*
|
||||
* `creatorAccountId` narrows to one author. It was ignored until it was noticed that a player's
|
||||
* creations list (which sends it) was serving the whole player-made catalog under every name.
|
||||
*
|
||||
* Ordered by recency because there is nothing else to order by — no purchase counts, no wear
|
||||
* counts, no ratings are recorded — which is the same stand-in the `hot` feed makes. The
|
||||
* `custom_avatar_item_id` tiebreak is what makes paging stable: without it, two items sharing a
|
||||
* `created_at` can swap places between pages and one is served twice while the other is missed.
|
||||
*/
|
||||
export async function searchCustomAvatarItems(
|
||||
db: D1Database,
|
||||
search: CustomAvatarItemSearch = {}
|
||||
): Promise<CustomAvatarItem[]> {
|
||||
const take = Math.min(Math.max(search.take ?? 50, 0), SEARCH_MAX_TAKE)
|
||||
const skip = Math.max(search.skip ?? 0, 0)
|
||||
if (take === 0) return []
|
||||
|
||||
const where = ['accessibility != 0']
|
||||
const binds: Array<number | string> = []
|
||||
/** Bind a value and get its placeholder, so the numbering can't drift as clauses are added. */
|
||||
const bind = (value: number | string): string => `?${binds.push(value)}`
|
||||
|
||||
const needle = search.searchQuery?.trim() ?? ''
|
||||
if (needle !== '') {
|
||||
// Escaped so a needle of LIKE metacharacters matches them literally rather than everything.
|
||||
const escaped = needle.toLowerCase().replace(/[\\%_]/g, (ch) => `\\${ch}`)
|
||||
const pattern = bind(`%${escaped}%`)
|
||||
// `name_lower`/`description_lower` are generated columns, lowered once at the row rather
|
||||
// than per query. An id typed in whole finds its item too, since the client searches by
|
||||
// `CustomAvatarItemId` as well as by text.
|
||||
const exact = bind(needle.toLowerCase())
|
||||
where.push(
|
||||
`(name_lower LIKE ${pattern} ESCAPE '\\' OR description_lower LIKE ${pattern} ESCAPE '\\'` +
|
||||
` OR lower(custom_avatar_item_id) = ${exact})`
|
||||
)
|
||||
}
|
||||
|
||||
const outfitTypes = search.outfitTypes ?? []
|
||||
if (outfitTypes.length > 0) {
|
||||
where.push(`outfit_type IN (${outfitTypes.map((t) => bind(t)).join(', ')})`)
|
||||
}
|
||||
if (search.includeCoachItems === true) {
|
||||
where.push(`creator_account_id = ${bind(COACH_ACCOUNT_ID)}`)
|
||||
} else if (search.includeCoachItems === false) {
|
||||
where.push(`creator_account_id != ${bind(COACH_ACCOUNT_ID)}`)
|
||||
}
|
||||
if (search.creatorAccountId !== undefined) {
|
||||
where.push(`creator_account_id = ${bind(search.creatorAccountId)}`)
|
||||
}
|
||||
if (search.minPrice !== undefined) where.push(`price >= ${bind(search.minPrice)}`)
|
||||
if (search.maxPrice !== undefined) where.push(`price <= ${bind(search.maxPrice)}`)
|
||||
|
||||
const limit = bind(take)
|
||||
const offset = bind(skip)
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM custom_avatar_item WHERE ${where.join(' AND ')}
|
||||
ORDER BY created_at DESC, custom_avatar_item_id
|
||||
LIMIT ${limit} OFFSET ${offset}`
|
||||
)
|
||||
.bind(...binds)
|
||||
.all<Row>()
|
||||
return results.map(toDto)
|
||||
}
|
||||
|
||||
/**
|
||||
* What an account has authored (`GET /api/customAvatarItems/v2/fromCreator/:id`), newest
|
||||
* first, with the total for the client's paginated envelope. `includeUnpublished` is for
|
||||
* the creator looking at their own shelf: it adds the `Accessibility` 0 items everyone
|
||||
* else is not shown. Paging is not applied yet (the client sends none), so `TotalResults`
|
||||
* always equals the list length.
|
||||
*/
|
||||
export async function listCustomAvatarItemsByCreator(
|
||||
db: D1Database,
|
||||
creatorAccountId: number,
|
||||
includeUnpublished = false
|
||||
): Promise<{ Results: CustomAvatarItem[]; TotalResults: number }> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM custom_avatar_item
|
||||
WHERE creator_account_id = ?1 AND (accessibility != 0 OR ?2)
|
||||
ORDER BY created_at DESC, custom_avatar_item_id`
|
||||
)
|
||||
.bind(creatorAccountId, includeUnpublished ? 1 : 0)
|
||||
.all<Row>()
|
||||
const items = results.map(toDto)
|
||||
return { Results: items, TotalResults: items.length }
|
||||
}
|
||||
|
||||
/** The editable fields of `PUT /api/customAvatarItems/v1/:id`; null/undefined = leave alone. */
|
||||
export interface UpdateCustomAvatarItemInput {
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
price?: number | null
|
||||
accessibility?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a partial edit to one item in place with `json_set`, bumping `ModifiedAt`. Fields
|
||||
* the caller leaves null keep their value (the client sends every field, nulling the
|
||||
* untouched ones).
|
||||
* Returns the updated item, or null when no row has that id.
|
||||
*/
|
||||
export async function updateCustomAvatarItem(
|
||||
db: D1Database,
|
||||
id: string,
|
||||
patch: UpdateCustomAvatarItemInput,
|
||||
now: Date = new Date()
|
||||
): Promise<CustomAvatarItem | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`UPDATE custom_avatar_item SET data = json_set(data,
|
||||
'$.Name', coalesce(?2, json_extract(data, '$.Name')),
|
||||
'$.Description', coalesce(?3, json_extract(data, '$.Description')),
|
||||
'$.Price', coalesce(?4, json_extract(data, '$.Price')),
|
||||
'$.Accessibility', coalesce(?5, json_extract(data, '$.Accessibility')),
|
||||
'$.ModifiedAt', ?6)
|
||||
WHERE custom_avatar_item_id = ?1
|
||||
RETURNING data`
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
patch.name ?? null,
|
||||
patch.description ?? null,
|
||||
patch.price ?? null,
|
||||
patch.accessibility ?? null,
|
||||
now.toISOString()
|
||||
)
|
||||
.first<Row>()
|
||||
return row ? toDto(row) : null
|
||||
}
|
||||
|
||||
/** Deletes one item's row. Returns the deleted item, or null when no row had that id. */
|
||||
export async function deleteCustomAvatarItem(
|
||||
db: D1Database,
|
||||
id: string
|
||||
): Promise<CustomAvatarItem | null> {
|
||||
const row = await db
|
||||
.prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING data')
|
||||
.bind(id)
|
||||
.first<Row>()
|
||||
return row ? toDto(row) : null
|
||||
}
|
||||
|
||||
/** Fetches one item by id, or null. */
|
||||
export async function getCustomAvatarItem(
|
||||
db: D1Database,
|
||||
id: string
|
||||
): Promise<CustomAvatarItem | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
|
||||
.bind(id)
|
||||
.first<Row>()
|
||||
return row ? toDto(row) : null
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
/**
|
||||
* Turning an export of custom avatar items into `custom_avatar_item` rows — the loader half of
|
||||
* the table, used by `runx admin cai-load` (in @repo/tools) and by the migration generator
|
||||
* (scripts/import-custom-avatar-items.ts) rather than by the worker.
|
||||
*
|
||||
* Separate from `custom-avatar-items-db.ts` for the reason `catalog-load.ts` is separate from
|
||||
* `catalog-db.ts` in econ: that module types its queries with `D1Database`, a Workers type,
|
||||
* and the loader runs in a plain Node CLI that has no such types. Everything here is pure data
|
||||
* mapping with no imports, so both sides can use it.
|
||||
*
|
||||
* A row is the record as JSON (see 0022_custom_avatar_item_json.sql), so a load is the export
|
||||
* verbatim: no per-field mapping, and a field the source adds later rides along. Three things
|
||||
* change on the way in: `PurchaseInfo`, a store-side projection the worker fills at read time,
|
||||
* is stripped; `CreatorAccountId` is FORCED to the Coach account; and each save's
|
||||
* `ThumbnailFileName` is put under the `avatar/` prefix — see {@link customAvatarItemRowLiteral}.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The "Coach" system account — this server's stock content is authored by it, the same id the
|
||||
* `econ` worker attributes a self-buy or an anonymous gift to. Defined here rather than in
|
||||
* `custom-avatar-items-db.ts` (which re-exports it) so the loader can name it without pulling
|
||||
* in Workers types.
|
||||
*/
|
||||
export const COACH_ACCOUNT_ID = 1
|
||||
|
||||
/**
|
||||
* Where a save's thumbnail is served from. The export names it bare
|
||||
* (`f2y1ndzuvm5ke2hjmn4cwfwfl.png`); this server keeps those images under `avatar/` in the
|
||||
* image bucket, so the name is prefixed on the way in and the client asks for the right key.
|
||||
*/
|
||||
export const SAVE_THUMBNAIL_PREFIX = 'avatar/'
|
||||
|
||||
/** The least an export record must carry to be a row. Everything else is kept as-is. */
|
||||
export interface CustomAvatarItemExportRecord {
|
||||
CustomAvatarItemId: string
|
||||
PurchaseInfo?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The records in an export, whether it is a bare JSON array, a `{ Results: [...] }` page, or a
|
||||
* single record. Refuses a record without an id and a repeated id — a repeat in one upsert
|
||||
* statement is an error at the database, and a repeat across statements would silently keep
|
||||
* whichever came last, which is a defect in the export rather than a choice to make for it.
|
||||
*/
|
||||
export function readCustomAvatarItemExport(parsed: unknown): CustomAvatarItemExportRecord[] {
|
||||
const records: unknown[] = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
Array.isArray((parsed as { Results?: unknown }).Results)
|
||||
? (parsed as { Results: unknown[] }).Results
|
||||
: [parsed]
|
||||
const seen = new Set<string>()
|
||||
return records.map((record, i) => {
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
||||
throw new Error(`record ${i} is not an object`)
|
||||
}
|
||||
const id = (record as { CustomAvatarItemId?: unknown }).CustomAvatarItemId
|
||||
if (typeof id !== 'string' || id === '') {
|
||||
throw new Error(`record ${i} has no CustomAvatarItemId`)
|
||||
}
|
||||
if (seen.has(id)) throw new Error(`duplicate CustomAvatarItemId in export: ${id}`)
|
||||
seen.add(id)
|
||||
return record as CustomAvatarItemExportRecord
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A save's `ThumbnailFileName` under {@link SAVE_THUMBNAIL_PREFIX}. Idempotent — a name that
|
||||
* already carries the prefix is left alone — so loading a file that was itself dumped from
|
||||
* this table does not stack a second `avatar/`. Anything that is not a non-empty string
|
||||
* (null, absent) is returned as it came.
|
||||
*/
|
||||
export function prefixSaveThumbnail(name: unknown): unknown {
|
||||
if (typeof name !== 'string' || name === '') return name
|
||||
return name.startsWith(SAVE_THUMBNAIL_PREFIX) ? name : SAVE_THUMBNAIL_PREFIX + name
|
||||
}
|
||||
|
||||
/**
|
||||
* A save's assetbundle hash as it is stored: BLANK. The export's `UnityAssetHash` /
|
||||
* `UnityAsset2Hash` are the hashes of the PC builds, and one stored save is served to every
|
||||
* build target — a Quest asking with `unityAssetTarget=2` is pointed at the `quest/` build of
|
||||
* the same bundle, whose bytes (and so whose hash) differ. The client checks a download against
|
||||
* a hash it is given and refuses the mismatch, so the Quest rendered nothing until these were
|
||||
* emptied by hand; given `""` it skips the check. A hash that is already null — the
|
||||
* `UnityAsset2Hash` of a save with no second bundle — stays null, so the pair keeps agreeing.
|
||||
*/
|
||||
export function blankAssetHash(hash: unknown): unknown {
|
||||
return typeof hash === 'string' ? '' : hash
|
||||
}
|
||||
|
||||
/**
|
||||
* One record as the SQL string literal its row holds: the JSON, minus `PurchaseInfo`, with
|
||||
* three things rewritten.
|
||||
*
|
||||
* `CreatorAccountId` is overridden to {@link COACH_ACCOUNT_ID} whatever the export said. An
|
||||
* imported item is first-party content by definition, and the creator id is what makes it so
|
||||
* here: the storefront tab finds stock items by `creator_account_id = 1`
|
||||
* (`includeCoachItems=True`), and a sale pays the creator. An export from another server
|
||||
* carries THAT server's creator ids — an item filed under one of those would vanish from the
|
||||
* storefront into the user-generated tab, and its price would be paid to whichever local
|
||||
* account happened to share the number.
|
||||
*
|
||||
* Each save's `ThumbnailFileName` is prefixed with {@link SAVE_THUMBNAIL_PREFIX}, which is where
|
||||
* this server serves those images from, and its `UnityAssetHash`/`UnityAsset2Hash` are blanked
|
||||
* (see {@link blankAssetHash}). The rest of the save — the assetbundle names above all — is
|
||||
* untouched.
|
||||
*/
|
||||
export function customAvatarItemRowLiteral(record: CustomAvatarItemExportRecord): string {
|
||||
const { PurchaseInfo: _purchaseInfo, ...stored } = record
|
||||
const saves = Array.isArray(stored.CurrentSaves)
|
||||
? stored.CurrentSaves.map((save: unknown) => {
|
||||
if (!save || typeof save !== 'object' || Array.isArray(save)) return save
|
||||
const fields = save as Record<string, unknown>
|
||||
return {
|
||||
...fields,
|
||||
ThumbnailFileName: prefixSaveThumbnail(fields.ThumbnailFileName),
|
||||
// Only where the export carries the key, so a save is never GIVEN a field.
|
||||
...('UnityAssetHash' in fields && {
|
||||
UnityAssetHash: blankAssetHash(fields.UnityAssetHash),
|
||||
}),
|
||||
...('UnityAsset2Hash' in fields && {
|
||||
UnityAsset2Hash: blankAssetHash(fields.UnityAsset2Hash),
|
||||
}),
|
||||
}
|
||||
})
|
||||
: stored.CurrentSaves
|
||||
const row = { ...stored, CreatorAccountId: COACH_ACCOUNT_ID, CurrentSaves: saves }
|
||||
return `'${JSON.stringify(row).replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* The largest statement a load builds, in bytes of SQL. D1 caps a single statement at 100 KB,
|
||||
* and a record with three built saves runs to a few KB, so rows are grouped by size rather
|
||||
* than by count — a fixed count would fit the small records and overflow on the large ones.
|
||||
*/
|
||||
export const CUSTOM_AVATAR_ITEM_LOAD_STATEMENT_BYTES = 64 * 1024
|
||||
|
||||
/**
|
||||
* The statements that upsert `records` into `custom_avatar_item`, each a multi-row INSERT
|
||||
* within {@link CUSTOM_AVATAR_ITEM_LOAD_STATEMENT_BYTES}. An id already in the table has its
|
||||
* row REPLACED (`ON CONFLICT ... DO UPDATE`), so a re-run with a corrected export lands and a
|
||||
* re-run with the same one changes nothing. Nothing is deleted: rows the export does not
|
||||
* mention — the players' own shirts among them — are left alone.
|
||||
*/
|
||||
export function customAvatarItemUpsertStatements(
|
||||
records: CustomAvatarItemExportRecord[],
|
||||
maxBytes = CUSTOM_AVATAR_ITEM_LOAD_STATEMENT_BYTES
|
||||
): string[] {
|
||||
const head = 'INSERT INTO custom_avatar_item (data) VALUES\n'
|
||||
const tail = '\nON CONFLICT(custom_avatar_item_id) DO UPDATE SET data = excluded.data;'
|
||||
const statements: string[] = []
|
||||
let rows: string[] = []
|
||||
let bytes = 0
|
||||
const flush = (): void => {
|
||||
if (rows.length === 0) return
|
||||
statements.push(head + rows.map((r) => `\t(${r})`).join(',\n') + tail)
|
||||
rows = []
|
||||
bytes = 0
|
||||
}
|
||||
for (const record of records) {
|
||||
const literal = customAvatarItemRowLiteral(record)
|
||||
// A single oversized record still gets its own statement rather than being dropped.
|
||||
if (rows.length > 0 && bytes + literal.length > maxBytes) flush()
|
||||
rows.push(literal)
|
||||
bytes += literal.length + 4
|
||||
}
|
||||
flush()
|
||||
return statements
|
||||
}
|
||||
+49
-113
@@ -25,8 +25,8 @@ import {
|
||||
} from '@repo/domain'
|
||||
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql +
|
||||
* 0010_event_tag.sql + 0028_event_id_seq.sql, sans any seed rows).
|
||||
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any
|
||||
* seed rows).
|
||||
*/
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS event (
|
||||
@@ -58,12 +58,6 @@ export const SCHEMA_DDL: string[] = [
|
||||
PRIMARY KEY (event_id, tag)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_tag_tag ON event_tag (tag)`,
|
||||
// The id sequence: AUTOINCREMENT so an id is never handed out twice, whatever gets
|
||||
// deleted. See {@link nextEventId}. The migration also seeds it past the existing
|
||||
// events; on a fresh database there are none, so the seed is omitted here.
|
||||
`CREATE TABLE IF NOT EXISTS event_id_seq (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
)`,
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -73,29 +67,19 @@ export const SCHEMA_DDL: string[] = [
|
||||
* Only `going` counts toward an event's `AttendeeCount`: interested is a maybe, and
|
||||
* declining is recorded rather than deleted so the client can show the player their own
|
||||
* answer (and so changing your mind is an update, not an insert).
|
||||
*
|
||||
* `pending` is not an answer: it's the row a bulk invite writes for the invitee, before
|
||||
* they've said anything. It's what the client shows as an open invitation — an invite
|
||||
* that landed as Going looked like the player had already accepted. Only the server
|
||||
* writes it; `respond` won't take it as a `Type`.
|
||||
*/
|
||||
export const EVENT_RESPONSE = {
|
||||
going: 0,
|
||||
interested: 1,
|
||||
cantGo: 2,
|
||||
pending: 3,
|
||||
} as const
|
||||
|
||||
/** The answers a player may give — every type but `pending`, for validating an incoming `Type`. */
|
||||
const EVENT_ANSWER_VALUES: number[] = [
|
||||
EVENT_RESPONSE.going,
|
||||
EVENT_RESPONSE.interested,
|
||||
EVENT_RESPONSE.cantGo,
|
||||
]
|
||||
/** The response types, for validating an incoming `Type`. */
|
||||
const EVENT_RESPONSE_VALUES: number[] = Object.values(EVENT_RESPONSE)
|
||||
|
||||
/** Whether a number is one of the three answers a player can give (never `pending`). */
|
||||
/** Whether a number is one of the three response types. */
|
||||
export function isEventResponseType(value: number): boolean {
|
||||
return EVENT_ANSWER_VALUES.includes(value)
|
||||
return EVENT_RESPONSE_VALUES.includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,9 +174,9 @@ interface EventRow {
|
||||
}
|
||||
|
||||
/**
|
||||
* A tag as the `v2` envelope carries it to a build newer than 20230414: the PascalCase
|
||||
* form of the stored `{ tag, type }` pair. NOT the lowercase pair the v1 read serves —
|
||||
* three casings of one tag, and the client parses each in exactly one place.
|
||||
* A tag as the 2023 build's `v2` envelope carries it: the PascalCase form of the stored
|
||||
* `{ tag, type }` pair. NOT the lowercase pair the v1 read serves — three casings of one
|
||||
* tag, and the client parses each in exactly one place.
|
||||
*/
|
||||
export interface PlayerEventEnvelopeTag {
|
||||
Tag: string
|
||||
@@ -206,11 +190,9 @@ export interface PlayerEventEnvelopeTag {
|
||||
*
|
||||
* `Tags` is the one field whose shape depends on the caller's BUILD, because Rec Room
|
||||
* changed it under the same unversioned path rather than minting a `v3`: the 2023 build
|
||||
* (20230414 and older) parses `["celebration"]` and anything newer parses
|
||||
* `[{ Tag, Type }]`. Serving either one to the other build leaves the event's tag chips
|
||||
* empty — the decoder drops what it can't read rather than erroring.
|
||||
* {@link toEventResult} picks; nothing else should. `TagModifyResult.Tags` is NOT part of
|
||||
* this: it is a `List<string>` to every build.
|
||||
* parses `[{ Tag, Type }]` and the 2025 build parses `["celebration"]`. Serving either
|
||||
* one to the other build leaves the event's tag chips empty — the decoder drops what it
|
||||
* can't read rather than erroring. {@link toEventResult} picks; nothing else should.
|
||||
*/
|
||||
export interface PlayerEventEnvelope extends PlayerEventBase {
|
||||
Tags: string[] | PlayerEventEnvelopeTag[]
|
||||
@@ -236,18 +218,17 @@ export interface PlayerEventResult {
|
||||
* tags — pass what `getEventTags` returns, so the answer reflects what was actually
|
||||
* written rather than what was asked for.
|
||||
*
|
||||
* `pairedTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see
|
||||
* {@link PlayerEventEnvelope}): the `{ Tag, Type }` pairs of a build newer than 20230414
|
||||
* when set, the 2023 build's bare names when not. It changes nothing else —
|
||||
* `TagModifyResult.Tags` is a name list to both builds.
|
||||
* `legacyTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see
|
||||
* {@link PlayerEventEnvelope}): the 2023 pairs when set, the 2025 names when not. It
|
||||
* changes nothing else — `TagModifyResult.Tags` is a name list to both builds.
|
||||
*/
|
||||
export function toEventResult(
|
||||
event: PlayerEvent,
|
||||
tags: EventTag[] = [],
|
||||
pairedTags = false
|
||||
legacyTags = false
|
||||
): PlayerEventResult {
|
||||
const names = tags.map((t) => t.tag)
|
||||
const carried = pairedTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names
|
||||
const carried = legacyTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names
|
||||
return {
|
||||
PlayerEvent: { Tags: carried, ...toEventBase(event) },
|
||||
Result: 0,
|
||||
@@ -309,13 +290,9 @@ export interface PlayerEventNotification {
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's BASE event — the 17-key shape the browse feed (`GET /api/playerevents/v1`),
|
||||
* the room shelf (`.../room/{roomId}`) and the bulk read (`POST|GET .../bulk`) all serve,
|
||||
* and the same thing the v2 envelope carries once `Tags` is added. Those three are one
|
||||
* generic helper over one element type on the client side, so they are shape-identical by
|
||||
* construction there; `toEventBase` is what holds that here.
|
||||
*
|
||||
* PascalCase like the stored record, but not identical to it — don't unify them:
|
||||
* The client's BASE event — the 17-key shape the browse feed (`GET /api/playerevents/v1`)
|
||||
* serves, and the same thing the v2 envelope carries once `Tags` is added. PascalCase like
|
||||
* the stored record, but not identical to it — don't unify them:
|
||||
*
|
||||
* - it drops `State`, which neither the feed nor the envelope carries;
|
||||
* - it carries `BroadcastingRoomInstanceId`, which the record has no field for (nothing
|
||||
@@ -323,8 +300,8 @@ export interface PlayerEventNotification {
|
||||
* - its `ImageName` is a string: an event with no image reads `""`, where the record holds
|
||||
* null.
|
||||
*
|
||||
* The by-id, search, searchlive and club reads serve the stored RECORD verbatim instead,
|
||||
* `State` and nullable `ImageName` included. Two shapes; keep them apart.
|
||||
* The by-id / bulk / search reads serve the stored RECORD verbatim instead, `State` and
|
||||
* nullable `ImageName` included. Two shapes; keep them apart.
|
||||
*/
|
||||
export interface PlayerEventBase extends Omit<PlayerEvent, 'State' | 'ImageName'> {
|
||||
ImageName: string
|
||||
@@ -639,11 +616,14 @@ export async function createEvent(
|
||||
creatorPlayerId: number,
|
||||
input: EventInput
|
||||
): Promise<PlayerEvent> {
|
||||
const id = await nextEventId(db)
|
||||
// Sequential id: one past the current max (the table starts empty).
|
||||
const row = await db
|
||||
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM event')
|
||||
.first<{ next: number }>()
|
||||
const now = Date.now()
|
||||
const startTime = input.startTime ?? eventTime(now)
|
||||
const event: PlayerEvent = {
|
||||
PlayerEventId: id,
|
||||
PlayerEventId: row?.next ?? 1,
|
||||
CreatorPlayerId: creatorPlayerId,
|
||||
ImageName: input.imageName ?? null,
|
||||
RoomId: input.roomId ?? 0,
|
||||
@@ -681,51 +661,46 @@ export async function createEvent(
|
||||
* per player per event, so changing your mind is an update rather than a second RSVP.
|
||||
* The event's `AttendeeCount` is recomputed from the table afterwards.
|
||||
*
|
||||
* Returns the updated event and the player's response row as it now stands (the route
|
||||
* pushes that row on the `PlayerEventResponseChanged` frame), or null when there's no
|
||||
* such event. Anyone who can see an event may respond to it, the creator included
|
||||
* (they're already Going from create, and nothing stops them declining their own event).
|
||||
* Returns the updated event, or null when there's no such event. Anyone who can see an
|
||||
* event may respond to it, the creator included (they're already Going from create, and
|
||||
* nothing stops them declining their own event).
|
||||
*/
|
||||
export async function setEventResponse(
|
||||
db: D1Database,
|
||||
eventId: number,
|
||||
playerId: number,
|
||||
status: number
|
||||
): Promise<{ event: PlayerEvent; response: EventAttendeeRow } | null> {
|
||||
): Promise<PlayerEvent | null> {
|
||||
const event = await getEventById(db, eventId)
|
||||
if (event === null) return null
|
||||
|
||||
const response = await db
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4
|
||||
RETURNING rowid AS id, *`
|
||||
ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4`
|
||||
)
|
||||
.bind(eventId, playerId, status, eventTime(Date.now()))
|
||||
.first<EventAttendeeRow>()
|
||||
.run()
|
||||
|
||||
const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
|
||||
await writeEvent(db, updated)
|
||||
// An upsert with RETURNING always yields the row it inserted or updated.
|
||||
return { event: updated, response: response! }
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Add invited players to an event as Pending — the bulk invite. Returns the updated
|
||||
* Add invited players to an event as Going — the bulk invite. Returns the updated
|
||||
* event (with its recounted `AttendeeCount`) and the rows actually created, or null
|
||||
* when there's no such event.
|
||||
*
|
||||
* An invite only ever INSERTS: a player who already has a row keeps the answer they
|
||||
* gave, so being invited can't reset an answer to Pending, and re-inviting the same
|
||||
* player is a no-op. The rows land as Pending (3), not Going: the invitee hasn't
|
||||
* accepted anything yet, so they don't count toward `AttendeeCount` until they answer
|
||||
* through `respond` — see the route.
|
||||
* gave, so being invited can't flip a decline back to Going, and re-inviting the same
|
||||
* player is a no-op rather than a reset. Since the rows land as Going, the invited
|
||||
* count toward `AttendeeCount` from the moment they're invited — see the route.
|
||||
*
|
||||
* `added` is what `RETURNING` gave back, so it holds exactly the new rows: a conflict
|
||||
* inserts nothing and returns nothing. That's who the route sends the invitation Message
|
||||
* to — a player whose existing answer was left alone gets nothing, because nothing
|
||||
* changed for them.
|
||||
* inserts nothing and returns nothing. That's what the route notifies on — a player
|
||||
* whose existing answer was left alone gets no frame, because nothing changed for them.
|
||||
*
|
||||
* Ids are deduplicated by the composite primary key; an empty list is a no-op that
|
||||
* still returns the event.
|
||||
@@ -749,7 +724,7 @@ export async function inviteToEvent(
|
||||
ON CONFLICT (event_id, player_id) DO NOTHING
|
||||
RETURNING rowid AS id, *`
|
||||
)
|
||||
.bind(eventId, playerId, EVENT_RESPONSE.pending, at)
|
||||
.bind(eventId, playerId, EVENT_RESPONSE.going, at)
|
||||
)
|
||||
)
|
||||
const added = inserts.flatMap((r) => r.results)
|
||||
@@ -759,22 +734,6 @@ export async function inviteToEvent(
|
||||
return { event: updated, added }
|
||||
}
|
||||
|
||||
/**
|
||||
* The players who said they're GOING to an event — who the `PlayerEventUpdated` and
|
||||
* `PlayerEventDeleted` frames go to. Interested, declined and Pending rows are left out:
|
||||
* an invitee who hasn't accepted isn't attending, so nothing about the event has changed
|
||||
* for them. The creator is included through their own Going row from create.
|
||||
*/
|
||||
export async function getGoingPlayerIds(db: D1Database, eventId: number): Promise<number[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
'SELECT player_id FROM event_attendee WHERE event_id = ?1 AND status = ?2 ORDER BY player_id'
|
||||
)
|
||||
.bind(eventId, EVENT_RESPONSE.going)
|
||||
.all<{ player_id: number }>()
|
||||
return results.map((r) => r.player_id)
|
||||
}
|
||||
|
||||
/** How many players said they're Going — an event's `AttendeeCount`. */
|
||||
export async function countGoing(db: D1Database, eventId: number): Promise<number> {
|
||||
const row = await db
|
||||
@@ -866,31 +825,12 @@ export async function updateEvent(
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the next event id. Sequential and NEVER reused: the id comes from the
|
||||
* `event_id_seq` AUTOINCREMENT counter, which only ever goes up, rather than from
|
||||
* `MAX(id) + 1` over the events themselves — that handed the newest event's id straight
|
||||
* back out once it was deleted, so a replacement event wore the old one's id and
|
||||
* inherited whatever was still filed under it (an invitation message's `PlayerEventId`,
|
||||
* the client's cache). The drawn row is thrown away in the same batch; only the counter
|
||||
* matters, and SQLite keeps that in `sqlite_sequence` regardless of the rows.
|
||||
*/
|
||||
async function nextEventId(db: D1Database): Promise<number> {
|
||||
const [drawn] = await db.batch<{ id: number }>([
|
||||
db.prepare('INSERT INTO event_id_seq DEFAULT VALUES RETURNING id'),
|
||||
db.prepare('DELETE FROM event_id_seq'),
|
||||
])
|
||||
const id = drawn.results[0]?.id
|
||||
if (id === undefined) throw new Error('event_id_seq returned no id')
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event and everything hanging off it — its RSVPs (`event_attendee`) and its tags
|
||||
* (`event_tag`) — in one batch, so a cancelled event can't leave rows behind that the
|
||||
* attendee counts and the `#tag` search would still find. Event ids are drawn from a
|
||||
* sequence that never reuses one (see {@link nextEventId}), so a deleted event's id is
|
||||
* retired with it — but orphan rows would still be counted against whatever id they name.
|
||||
* attendee counts and the `#tag` search would still find. Event ids are assigned in
|
||||
* sequence and never reused, but orphan rows would still be counted against whatever id
|
||||
* they name.
|
||||
*
|
||||
* Answers the event as it was, so the caller can report what it deleted; `null` when there
|
||||
* was no such event.
|
||||
@@ -916,12 +856,9 @@ export async function getEventById(db: D1Database, eventId: number): Promise<Pla
|
||||
}
|
||||
|
||||
/**
|
||||
* Several events by id — the bulk fetch behind `POST /api/playerevents/v1/bulk` (the form
|
||||
* body the client sends) and the query-string GET on the same path. Answers in the order
|
||||
* the ids were asked for (the client renders them in the order it requested), skipping ids
|
||||
* with no row rather than leaving a hole. Duplicated ids resolve to the same event.
|
||||
*
|
||||
* Returns stored records; both routes project them with `toEventBase` before serving.
|
||||
* Several events by id — the bulk fetch. Answers in the order the ids were asked for
|
||||
* (the client renders them in the order it requested), skipping ids with no row rather
|
||||
* than leaving a hole. Duplicated ids resolve to the same event.
|
||||
*/
|
||||
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
|
||||
if (ids.length === 0) return []
|
||||
@@ -970,8 +907,7 @@ export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promi
|
||||
|
||||
/**
|
||||
* A room's events — what is happening in this room and what is coming up, soonest first.
|
||||
* Backs the room's event shelf (`GET /api/playerevents/v1/room/{roomId}`), which serves
|
||||
* them through `toEventBase` like the browse feed and the bulk read.
|
||||
* Backs the room's event shelf (`GET /api/playerevents/v1/room/{roomId}`).
|
||||
*
|
||||
* FINISHED events are left out, like the browse feed's: this answers "what can I still turn
|
||||
* up to in this room", and an event that ended last month is not that. Running events count
|
||||
|
||||
+6
-20
@@ -27,27 +27,13 @@ export function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the `Ids` form field of a bulk POST into a list of integer ids.
|
||||
*
|
||||
* BOTH spellings, because the client uses both: `Ids` REPEATED once per id
|
||||
* (`Ids=101&Ids=102&Ids=103`, what the player-events bulk sends) and a single
|
||||
* comma-separated `Ids=1,2,3`. `parseBody({ all: true })` is what keeps the repeated form
|
||||
* from collapsing to its last value — plain `parseBody()` would answer one id out of
|
||||
* three, which reads as a short result rather than as an error.
|
||||
*
|
||||
* `ids` is accepted alongside `Ids` so a hand-written request doesn't silently come back
|
||||
* empty. Values that aren't integers are dropped; duplicates and order are left alone,
|
||||
* since the caller renders them in request order.
|
||||
*/
|
||||
/** Reads the `Ids` form field into a list of integer ids. */
|
||||
export async function parseFormIds(c: Context<App>): Promise<number[]> {
|
||||
const body = await c.req
|
||||
.parseBody({ all: true })
|
||||
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
|
||||
const raw = [body.Ids, body.ids].flat()
|
||||
return raw
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.flatMap((v) => v.split(','))
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const ids = body.Ids
|
||||
if (typeof ids !== 'string') return []
|
||||
return ids
|
||||
.split(',')
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
}
|
||||
|
||||
+35
-563
@@ -23,8 +23,8 @@ import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domai
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql +
|
||||
* 0008_invention_visibility.sql, sans any seed rows). `is_featured` backs the featured
|
||||
* feed's query and `is_published`/`hide_from_player` most of the "may anyone see this"
|
||||
* filter every feed shares (see `VISIBLE_IN_FEEDS`, which also excludes unlisted ones); json_extract of a JSON `true` is 1, so those columns are 1/0 — and
|
||||
* feed's query and `is_published`/`hide_from_player` the "may anyone see this" filter
|
||||
* every feed shares; json_extract of a JSON `true` is 1, so those columns are 1/0 — and
|
||||
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
|
||||
*/
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
@@ -39,22 +39,6 @@ export const SCHEMA_DDL: string[] = [
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invention_featured ON invention (is_featured)`,
|
||||
// Backs `searchInventions`' newest-first paging (migrations/0027). Its WHERE must match
|
||||
// `VISIBLE_IN_FEEDS` term for term or SQLite won't use it.
|
||||
`CREATE INDEX IF NOT EXISTS idx_invention_feed_newest
|
||||
ON invention (json_extract(data, '$.CreatedAt') DESC, id DESC)
|
||||
WHERE is_published = 1
|
||||
AND hide_from_player = 0
|
||||
AND COALESCE(json_extract(data, '$.Accessibility'), 0) <> 2`,
|
||||
`CREATE TABLE IF NOT EXISTS invention_interaction (
|
||||
player_id INTEGER NOT NULL,
|
||||
invention_id INTEGER NOT NULL,
|
||||
cheered INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT,
|
||||
PRIMARY KEY (player_id, invention_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invention_interaction_invention
|
||||
ON invention_interaction (invention_id)`,
|
||||
]
|
||||
|
||||
/** A single saved version of an invention (Rec Room's `RRInventionVersion`). */
|
||||
@@ -69,16 +53,6 @@ export interface InventionVersion {
|
||||
ChipsCost: number
|
||||
CloudVariablesCost: number
|
||||
AICost: number
|
||||
/**
|
||||
* Whether the blob uses content still in beta, sent from `v9/save` on. It sits on the
|
||||
* VERSION, where the client's own `RRInventionVersion` carries it and for the same
|
||||
* reason the costs do: it describes the one revision saved, not the invention across
|
||||
* all of them. `UgcVersion`, which reads like its twin, is an INVENTION field — see
|
||||
* {@link SavedInvention}. Absent on a record saved through `v6/save`, which sends
|
||||
* neither; the client's decoder reads a missing member as its default, so an old
|
||||
* record is not retroactively wrong.
|
||||
*/
|
||||
HasBetaContent?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,32 +103,9 @@ export interface SavedInvention {
|
||||
HideFromPlayer: boolean
|
||||
ReferencedInventions: number[]
|
||||
/**
|
||||
* The rest of what `v9/save` sends, kept beside `ReferencedInventions` — the field
|
||||
* they most resemble, and the one this record has always carried on the invention.
|
||||
* Note the v9 client's own `RRInvention` has no `Referenced*` at all (its VERSION
|
||||
* carries them) and no `LongDescription`/`ConvertedFromInventionId` (it sends both and
|
||||
* never reads them back); they are stored anyway, because what the client sent is
|
||||
* worth keeping, and `toSaveResultV9` puts each where that client expects it.
|
||||
*
|
||||
* `UgcVersion` is the exception that has to be got right rather than tolerated: it is
|
||||
* an invention field there, not a version one, next to `CurrentVersionNumber`.
|
||||
*
|
||||
* `DisplayMetadataJson` is stored as the opaque string the client sent: it is the
|
||||
* client's own display state (`{"0":0,"99":0}`), and re-encoding it would be
|
||||
* inventing a schema for something only the client reads.
|
||||
*
|
||||
* Each is absent on a `v6/save` record, which sends none of them.
|
||||
*/
|
||||
ReferencedUnityAssetIds?: string[]
|
||||
UgcVersion?: number
|
||||
LongDescription?: string
|
||||
DisplayMetadataJson?: string
|
||||
ConvertedFromInventionId?: number
|
||||
/**
|
||||
* Tags served by `v1/details` and written by `v1/settags`. Optional: the real
|
||||
* `RRInvention` carries no Tags field, so an untagged invention's DTO stays
|
||||
* identical to the real one. `v6/save` never sets it (that client tags in a second
|
||||
* call); `v9/save` sets it when its `tagsRequest` names at least one tag.
|
||||
* Tags served by `v1/details` and written by `v1/settags`. Optional and unset on
|
||||
* save: the real `RRInvention` carries no Tags field and the client sends no tags
|
||||
* when saving, so an untagged invention's DTO stays identical to the real one.
|
||||
*/
|
||||
Tags?: InventionTag[]
|
||||
}
|
||||
@@ -179,224 +130,6 @@ export function toSaveResult(invention: SavedInvention): InventionSaveResult {
|
||||
return { Status: 0, Invention: invention, InventionVersion: invention.CurrentVersion }
|
||||
}
|
||||
|
||||
/**
|
||||
* `TagsResponse.Result` on a v9 save — the client's own tag-result enum, whose members
|
||||
* run Success 0 … ReservedWordViolation 13. Only Success is named: the members between
|
||||
* were not recovered from the client, and it never reads this field anyway, so a refused
|
||||
* tag needs only to be something other than Success.
|
||||
*/
|
||||
export const INVENTION_TAG_RESULT = {
|
||||
success: 0,
|
||||
rejected: 1,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The `Invention` inside a v9 save response — the client's 28-key `RRInvention`, which is
|
||||
* NOT the record this server stores (that one mirrors the older shape the read endpoints
|
||||
* still serve). The differences that matter: no nested `CurrentVersion` (the version rides
|
||||
* beside it), no `Referenced*` (they moved onto the version), no `IsPublished` (the client
|
||||
* infers it from `FirstPublishedAt`), and `UgcVersion`/`LatestVersionNumber`/
|
||||
* `ForceCannotPublish`/`IsRecRoomApproved` that the stored record has no equivalent for.
|
||||
*
|
||||
* The client reads exactly one of these keys — `InventionId` — and its decoder null-checks
|
||||
* every member and drops the ones it doesn't know, so this projection is about being right
|
||||
* rather than about being parseable.
|
||||
*/
|
||||
export interface InventionV9Dto {
|
||||
InventionId: number
|
||||
ReplicationId: string
|
||||
CreatorPlayerId: number
|
||||
Name: string
|
||||
Description: string
|
||||
ImageName: string
|
||||
UgcVersion: number
|
||||
CurrentVersionNumber: number
|
||||
LatestVersionNumber: number
|
||||
Accessibility: number
|
||||
ForceCannotPublish: boolean
|
||||
ModifiedAt: string
|
||||
CreatedAt: string
|
||||
FirstPublishedAt: string | null
|
||||
CreationRoomId: number | null
|
||||
NumPlayersHaveUsedInRoom: number
|
||||
NumDownloads: number
|
||||
CheerCount: number
|
||||
CreatorPermission: number
|
||||
GeneralPermission: number
|
||||
IsAGInvention: boolean
|
||||
IsCertifiedInvention: boolean
|
||||
IsRecRoomApproved: boolean
|
||||
AllowTrial: boolean
|
||||
Price: number | null
|
||||
HideFromPlayer: boolean
|
||||
DisplayMetadataJson: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The `InventionVersion` inside a v9 save response — the client's 13-key
|
||||
* `RRInventionVersion`. It carries `HasBetaContent`, a `CreatedAt` of its own and a
|
||||
* nullable `UgcAccessibility` the stored version has no field for, and notably NO
|
||||
* `AICost`, which the request body still sends and this server still stores.
|
||||
*
|
||||
* Both `Referenced*` lists are emitted here even though the client's DTO has room for one:
|
||||
* which of the two it is wasn't recovered, and an unknown member is dropped silently while
|
||||
* a missing one would be the list the client asked for going astray.
|
||||
*/
|
||||
export interface InventionVersionV9Dto {
|
||||
InventionId: number
|
||||
ReplicationId: string
|
||||
VersionNumber: number
|
||||
HasBetaContent: boolean
|
||||
InstantiationCost: number
|
||||
LightsCost: number
|
||||
ChipsCost: number
|
||||
CloudVariablesCost: number
|
||||
BlobName: string
|
||||
BlobHash: string | null
|
||||
CreatedAt: string
|
||||
UgcAccessibility: number | null
|
||||
ReferencedInventions: number[]
|
||||
ReferencedUnityAssetIds: string[]
|
||||
}
|
||||
|
||||
/** The tag half of a v9 save — `v1/settags`' answer, folded into the save response. */
|
||||
export interface InventionTagsV9Dto {
|
||||
Result: number
|
||||
Tags: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The four keys inside a v9 save envelope's `Value`. `Status` is 0 on success.
|
||||
*
|
||||
* `InventionVersion` and `TagsResponse` are nullable because the envelope is not the save
|
||||
* route's alone: econ's `POST /api/storefronts/v3/buyInvention` answers in it too, and a
|
||||
* BUY mints neither a version nor a tag result — it sends both as null, and the client
|
||||
* (which reads only `Success` and `Value.Invention`) never looks. The keys stay present.
|
||||
*/
|
||||
export interface InventionSaveV9Value {
|
||||
Status: number
|
||||
Invention: InventionV9Dto
|
||||
InventionVersion: InventionVersionV9Dto | null
|
||||
TagsResponse: InventionTagsV9Dto | null
|
||||
}
|
||||
|
||||
/**
|
||||
* What `v9/save` answers, and the whole reason it isn't just v6 with a bigger body: the
|
||||
* result is ENVELOPED, where v6 serves the bare `{ Status, Invention, InventionVersion }`.
|
||||
*
|
||||
* The client's contract is two fields deep. It checks `Success`, then reads
|
||||
* `Value.Invention.InventionId` and tags the invention with it; `Error` is the only text
|
||||
* that ever reaches a human (it is logged as "Invention datablob upload failed"). `Status`
|
||||
* is deserialized and never read on this route — the failure channel is the envelope, not
|
||||
* the 55-member status enum — and so are `InventionVersion` and `TagsResponse`.
|
||||
*
|
||||
* The one shape that CRASHES the client is `Success: true` with `Value` null or absent: it
|
||||
* dereferences `Value.Invention` unguarded. `Success: false` with a null `Value` is safe —
|
||||
* that branch reads only `Error` — which is why every refusal goes through
|
||||
* {@link inventionSaveV9Failure} rather than answering a bare `{ error }` like v6 does. A
|
||||
* body that doesn't deserialize into this envelope at all is the same crash, so even the
|
||||
* 401 answers it.
|
||||
*/
|
||||
export interface InventionSaveV9Result {
|
||||
Value: InventionSaveV9Value | null
|
||||
Success: boolean
|
||||
Error: string | null
|
||||
error_id: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored invention into the client's 28-key `RRInvention`. Shared by the v9 save
|
||||
* envelope below and by econ's `v3/buyInvention`, which answers in that same envelope — so
|
||||
* the two can never drift into serving one build two different inventions.
|
||||
*
|
||||
* Fields the stored record has no equivalent for are served as what they are here rather
|
||||
* than guessed: nothing forces an invention not to publish, and nothing in this server
|
||||
* approves one.
|
||||
*/
|
||||
export function toInventionV9(invention: SavedInvention): InventionV9Dto {
|
||||
return {
|
||||
InventionId: invention.InventionId,
|
||||
ReplicationId: invention.ReplicationId,
|
||||
CreatorPlayerId: invention.CreatorPlayerId,
|
||||
Name: invention.Name,
|
||||
Description: invention.Description,
|
||||
ImageName: invention.ImageName,
|
||||
UgcVersion: invention.UgcVersion ?? 0,
|
||||
CurrentVersionNumber: invention.CurrentVersionNumber,
|
||||
// One save, one version: the newest is the current one.
|
||||
LatestVersionNumber: invention.CurrentVersionNumber,
|
||||
Accessibility: invention.Accessibility,
|
||||
ForceCannotPublish: false,
|
||||
ModifiedAt: invention.ModifiedAt,
|
||||
CreatedAt: invention.CreatedAt,
|
||||
FirstPublishedAt: invention.FirstPublishedAt,
|
||||
CreationRoomId: invention.CreationRoomId,
|
||||
NumPlayersHaveUsedInRoom: invention.NumPlayersHaveUsedInRoom,
|
||||
NumDownloads: invention.NumDownloads,
|
||||
CheerCount: invention.CheerCount,
|
||||
CreatorPermission: invention.CreatorPermission,
|
||||
GeneralPermission: invention.GeneralPermission,
|
||||
IsAGInvention: invention.IsAGInvention,
|
||||
IsCertifiedInvention: invention.IsCertifiedInvention,
|
||||
IsRecRoomApproved: false,
|
||||
AllowTrial: invention.AllowTrial,
|
||||
Price: invention.Price,
|
||||
HideFromPlayer: invention.HideFromPlayer,
|
||||
DisplayMetadataJson: invention.DisplayMetadataJson ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored invention into the v9 save envelope. `tags` are the ones stored with
|
||||
* it, answered as the bare names `v1/settags` answers with; `tagResult` says whether they
|
||||
* were taken (see {@link INVENTION_TAG_RESULT}) — a tag the rules refuse costs the tags,
|
||||
* never the save, because the save is the thing the player would have to redo.
|
||||
*/
|
||||
export function toSaveResultV9(
|
||||
invention: SavedInvention,
|
||||
tags: InventionTag[],
|
||||
tagResult: number = INVENTION_TAG_RESULT.success
|
||||
): InventionSaveV9Result {
|
||||
const version = invention.CurrentVersion
|
||||
return {
|
||||
Value: {
|
||||
Status: 0,
|
||||
Invention: toInventionV9(invention),
|
||||
InventionVersion: {
|
||||
InventionId: version.InventionId,
|
||||
ReplicationId: version.ReplicationId,
|
||||
VersionNumber: version.VersionNumber,
|
||||
HasBetaContent: version.HasBetaContent ?? false,
|
||||
InstantiationCost: version.InstantiationCost,
|
||||
LightsCost: version.LightsCost,
|
||||
ChipsCost: version.ChipsCost,
|
||||
CloudVariablesCost: version.CloudVariablesCost,
|
||||
BlobName: version.BlobName,
|
||||
BlobHash: version.BlobHash,
|
||||
// The version is minted with the invention, so they share a timestamp.
|
||||
CreatedAt: invention.CreatedAt,
|
||||
UgcAccessibility: null,
|
||||
ReferencedInventions: invention.ReferencedInventions,
|
||||
ReferencedUnityAssetIds: invention.ReferencedUnityAssetIds ?? [],
|
||||
},
|
||||
TagsResponse: { Result: tagResult, Tags: tags.map((t) => t.Tag) },
|
||||
},
|
||||
Success: true,
|
||||
Error: null,
|
||||
error_id: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused v9 save. `Value` is null, which is safe precisely because `Success` is false:
|
||||
* the client reads `Error` on that branch and nothing else. See
|
||||
* {@link InventionSaveV9Result} for why the alternative — a bare `{ error }` body — would
|
||||
* take the client down instead.
|
||||
*/
|
||||
export function inventionSaveV9Failure(message: string): InventionSaveV9Result {
|
||||
return { Value: null, Success: false, Error: message, error_id: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Invention data blobs are named `<name>.inv`, and the client expects the extension
|
||||
* on the `BlobName` it reads back. Uploads through the `storage` worker already land
|
||||
@@ -456,19 +189,6 @@ export interface NewInvention {
|
||||
aiCost?: number
|
||||
creationRoomId?: number | null
|
||||
referencedInventions?: number[]
|
||||
/**
|
||||
* The rest of what `v9/save` sends. Every one is optional and is written onto the
|
||||
* record only when the caller actually supplied it, so a `v6/save` — which sends
|
||||
* none of them — stores and answers exactly the record it always did.
|
||||
*/
|
||||
ugcVersion?: number
|
||||
hasBetaContent?: boolean
|
||||
referencedUnityAssetIds?: string[]
|
||||
longDescription?: string | null
|
||||
displayMetadataJson?: string | null
|
||||
convertedFromInventionId?: number | null
|
||||
/** Tags to store with the record, already normalized by {@link normalizeInventionTags}. */
|
||||
tags?: InventionTag[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -481,11 +201,6 @@ export interface NewInvention {
|
||||
* what narrows `GeneralPermission` down (to UseOnly by default). Trials are allowed.
|
||||
* The client's `creatorAccountRole` is ignored: it's the player's role in the room
|
||||
* they built it in, not a permission over the invention.
|
||||
*
|
||||
* The fields `v9/save` added over `v6/save` are written only when the caller supplies
|
||||
* them, so the record a v6 client stores is byte-for-byte the one it always stored —
|
||||
* the new keys appear on new records rather than being back-filled with defaults onto
|
||||
* every old one.
|
||||
*/
|
||||
export async function createInvention(
|
||||
db: D1Database,
|
||||
@@ -518,7 +233,6 @@ export async function createInvention(
|
||||
ChipsCost: input.chipsCost ?? 0,
|
||||
CloudVariablesCost: input.cloudVariablesCost ?? 0,
|
||||
AICost: input.aiCost ?? 0,
|
||||
...(input.hasBetaContent === undefined ? {} : { HasBetaContent: input.hasBetaContent }),
|
||||
},
|
||||
Accessibility: 0,
|
||||
IsPublished: false,
|
||||
@@ -538,16 +252,6 @@ export async function createInvention(
|
||||
AllowTrial: true,
|
||||
HideFromPlayer: false,
|
||||
ReferencedInventions: input.referencedInventions ?? [],
|
||||
...(input.referencedUnityAssetIds === undefined
|
||||
? {}
|
||||
: { ReferencedUnityAssetIds: input.referencedUnityAssetIds }),
|
||||
...(input.ugcVersion === undefined ? {} : { UgcVersion: input.ugcVersion }),
|
||||
...(input.longDescription ? { LongDescription: input.longDescription } : {}),
|
||||
...(input.displayMetadataJson ? { DisplayMetadataJson: input.displayMetadataJson } : {}),
|
||||
...(typeof input.convertedFromInventionId === 'number'
|
||||
? { ConvertedFromInventionId: input.convertedFromInventionId }
|
||||
: {}),
|
||||
...(input.tags?.length ? { Tags: input.tags } : {}),
|
||||
}
|
||||
await db.prepare('INSERT INTO invention (data) VALUES (?1)').bind(JSON.stringify(invention)).run()
|
||||
return invention
|
||||
@@ -640,30 +344,12 @@ export async function ownsAllInventions(
|
||||
}
|
||||
|
||||
/**
|
||||
* Invention search — the browse/search list the client shows when picking an invention to
|
||||
* spawn. Only published, non-hidden inventions are visible here (a player's own unpublished
|
||||
* ones come from `getInventionsByCreator`). Newest first, paginated via skip/take. Returns a
|
||||
* bare array — the shape the client expects from v2/search.
|
||||
*
|
||||
* `value` is split into terms on whitespace and `+`, and EVERY term must match (AND, not OR),
|
||||
* which is what makes typing more words narrow the list. Each is matched case-insensitively
|
||||
* against the NAME and the DESCRIPTION.
|
||||
*
|
||||
* Filtered, ordered and paged entirely IN SQL. It used to read every published invention into
|
||||
* memory, filter there and slice — which meant the cost of a search grew with the whole
|
||||
* catalogue no matter how narrow the query or how small the page, and a browse screen asking
|
||||
* for 100 rows paid for all of them. `Name`, `Description` and `CreatedAt` live inside the JSON
|
||||
* blob, so they are reached with `json_extract`; `is_published`/`hide_from_player` are already
|
||||
* generated columns.
|
||||
*
|
||||
* Both sides of the comparison are lowered rather than leaning on `LIKE`, which folds case for
|
||||
* ASCII only — and invention names are full of things it would not fold. `%` and `_` in a term
|
||||
* are escaped so a player searching for one finds it instead of matching everything.
|
||||
*
|
||||
* A term starting with `#` is NOT special here: the browse screen's filter chips send `#small`,
|
||||
* and a tag appears in no name or description, so those searches find nothing. Matching tags
|
||||
* needs them out of the JSON blob and into something indexable first; until then this stays a
|
||||
* text search rather than one that scans every row to look at its tags.
|
||||
* Invention search — the browse/search list the client shows when picking an
|
||||
* invention to spawn. Only published, non-hidden inventions are visible here (a
|
||||
* player's own unpublished ones come from `getInventionsByCreator`). `value` is
|
||||
* matched case-insensitively against the name and description, term by term; an
|
||||
* empty `value` browses everything published. Paginated via skip/take, newest
|
||||
* first. Returns a bare array — the shape the client expects from v2/search.
|
||||
*/
|
||||
export async function searchInventions(
|
||||
db: D1Database,
|
||||
@@ -671,39 +357,22 @@ export async function searchInventions(
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<SavedInvention[]> {
|
||||
const limit = Math.max(take, 0)
|
||||
const offset = Math.max(skip, 0)
|
||||
if (limit === 0) return []
|
||||
let inventions = await publicInventions(db)
|
||||
|
||||
const where = [...VISIBLE_IN_FEEDS]
|
||||
const binds: Array<string | number> = []
|
||||
/** Bind a value and get its placeholder, so the numbering can't drift as terms are added. */
|
||||
const bind = (v: string | number): string => `?${binds.push(v)}`
|
||||
|
||||
for (const term of value
|
||||
const terms = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/[\s+]+/)
|
||||
.filter(Boolean)) {
|
||||
const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`)
|
||||
const pattern = bind(`%${escaped}%`)
|
||||
where.push(
|
||||
`(lower(json_extract(data, '$.Name')) LIKE ${pattern} ESCAPE '\\'` +
|
||||
` OR lower(json_extract(data, '$.Description')) LIKE ${pattern} ESCAPE '\\')`
|
||||
.filter(Boolean)
|
||||
for (const term of terms) {
|
||||
inventions = inventions.filter(
|
||||
(i) => i.Name.toLowerCase().includes(term) || i.Description.toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
|
||||
const limitAt = bind(limit)
|
||||
const offsetAt = bind(offset)
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM invention WHERE ${where.join(' AND ')}
|
||||
ORDER BY json_extract(data, '$.CreatedAt') DESC, id DESC
|
||||
LIMIT ${limitAt} OFFSET ${offsetAt}`
|
||||
)
|
||||
.bind(...binds)
|
||||
.all<InventionRow>()
|
||||
return results.map((r) => JSON.parse(r.data) as SavedInvention)
|
||||
return inventions
|
||||
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
||||
.slice(skip, skip + take)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -717,7 +386,8 @@ async function publicInventions(db: D1Database, featuredOnly = false): Promise<S
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM invention
|
||||
WHERE ${VISIBLE_IN_FEEDS.join(' AND ')}
|
||||
WHERE is_published = 1
|
||||
AND hide_from_player = 0
|
||||
${featuredOnly ? 'AND is_featured = 1' : ''}`
|
||||
)
|
||||
.all<InventionRow>()
|
||||
@@ -794,9 +464,10 @@ export async function getFeaturedInventions(
|
||||
/**
|
||||
* Replace an invention's tags (the `v1/settags` write). Auto tags are the ones the
|
||||
* client derives from the invention itself (Type 2); custom tags are the creator's
|
||||
* own (Type 0). Both lists are replaced wholesale, normalized as
|
||||
* {@link normalizeInventionTags} describes. Returns the stored tag list, or null when
|
||||
* there's no such invention.
|
||||
* own (Type 0). Both lists are replaced wholesale — auto first, then custom, the
|
||||
* order the tags come back in — and are lowercased/trimmed and de-duplicated so
|
||||
* `details` doesn't echo back near-duplicates. Returns the stored tag list, or null
|
||||
* when there's no such invention.
|
||||
*/
|
||||
export async function setInventionTags(
|
||||
db: D1Database,
|
||||
@@ -807,21 +478,6 @@ export async function setInventionTags(
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
|
||||
const tags = normalizeInventionTags(autoTags, customTags)
|
||||
await writeInvention(db, { ...invention, Tags: tags })
|
||||
return tags
|
||||
}
|
||||
|
||||
/**
|
||||
* The two tag lists as they are stored: auto first (Type 2), then custom (Type 0) —
|
||||
* the order they come back in — each trimmed, lowercased and de-duplicated across both
|
||||
* lists so `details` doesn't echo back near-duplicates. Blanks are dropped: the client
|
||||
* pads its lists with empties.
|
||||
*
|
||||
* Shared by `v1/settags` and by `v9/save`, which carries the same two lists in its
|
||||
* `tagsRequest` — a tag has to mean the same thing however it arrived.
|
||||
*/
|
||||
export function normalizeInventionTags(autoTags: string[], customTags: string[]): InventionTag[] {
|
||||
const tags: InventionTag[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const [list, type] of [
|
||||
@@ -835,6 +491,8 @@ export function normalizeInventionTags(autoTags: string[], customTags: string[])
|
||||
tags.push({ Tag: tag, Type: type })
|
||||
}
|
||||
}
|
||||
|
||||
await writeInvention(db, { ...invention, Tags: tags })
|
||||
return tags
|
||||
}
|
||||
|
||||
@@ -847,9 +505,6 @@ export function normalizeInventionTags(autoTags: string[], customTags: string[])
|
||||
export const INVENTION_PERMISSION = {
|
||||
unassigned: 0,
|
||||
limitedoneuseonly: 10,
|
||||
// Recovered from the client's own ladder; nothing here sends it, and no name for it
|
||||
// appears in `v1/update`'s picker.
|
||||
disallowkeylock: 15,
|
||||
useonly: 20,
|
||||
editandsave: 40,
|
||||
publish: 60,
|
||||
@@ -857,39 +512,6 @@ export const INVENTION_PERMISSION = {
|
||||
unlimited: 100,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Where a published invention may be FOUND, which `v4/publish` sets and nothing before it
|
||||
* did — every record written before that endpoint carries 0, the value a save mints.
|
||||
*
|
||||
* Only `unlisted` is recovered from the client for certain; the other two mirror the room
|
||||
* accessibility enum, which they match member-for-member, and the publish sheet sends 1 for
|
||||
* an ordinary publish.
|
||||
*
|
||||
* Note what that leaves ambiguous: a stored 0 is either "private" or "written before this
|
||||
* enum meant anything", and the two are indistinguishable without a backfill. So the browse
|
||||
* filter excludes `unlisted` by name rather than requiring `public` — the latter reads
|
||||
* every invention published through `v3/publish` as private and empties the feeds.
|
||||
*/
|
||||
export const INVENTION_ACCESSIBILITY = {
|
||||
private: 0,
|
||||
public: 1,
|
||||
unlisted: 2,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The "anyone may come across this" test the browse feeds and search share: published, not
|
||||
* hidden, and not unlisted. An unlisted invention is still reachable BY ID — that is what
|
||||
* unlisted means — so the by-id reads deliberately don't apply it.
|
||||
*
|
||||
* `idx_invention_feed_newest` (SCHEMA_DDL, migrations/0027) is a partial index over exactly
|
||||
* these terms — edit one here and edit it there, or the feed goes back to a full scan.
|
||||
*/
|
||||
const VISIBLE_IN_FEEDS = [
|
||||
'is_published = 1',
|
||||
'hide_from_player = 0',
|
||||
`COALESCE(json_extract(data, '$.Accessibility'), 0) <> ${INVENTION_ACCESSIBILITY.unlisted}`,
|
||||
]
|
||||
|
||||
/**
|
||||
* Parse a permission level the way the client sends it: a name (`useonly`,
|
||||
* `edit_and_save`) or the raw number. Undefined when it's neither.
|
||||
@@ -910,13 +532,6 @@ export interface InventionPatch {
|
||||
imageName?: string
|
||||
allowTrial?: boolean
|
||||
generalPermission?: number
|
||||
/**
|
||||
* The rest of what `v2/metadata` can edit. Undefined leaves the stored value alone,
|
||||
* which is how both editors say "not this field" — `v1/update` by omitting the query
|
||||
* param, `v2/metadata` by sending the key as null.
|
||||
*/
|
||||
longDescription?: string
|
||||
tags?: InventionTag[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -941,27 +556,11 @@ export async function updateInvention(
|
||||
ImageName: patch.imageName ?? invention.ImageName,
|
||||
AllowTrial: patch.allowTrial ?? invention.AllowTrial,
|
||||
GeneralPermission: patch.generalPermission ?? invention.GeneralPermission,
|
||||
// Both of these are optional ON the record, so an untouched one resolves to
|
||||
// undefined and JSON.stringify drops the key — an invention that never had a long
|
||||
// description doesn't acquire an empty one by being edited.
|
||||
LongDescription: patch.longDescription ?? invention.LongDescription,
|
||||
Tags: patch.tags ?? invention.Tags,
|
||||
}
|
||||
await writeInvention(db, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* What a publish decides. Each is optional and an omitted one keeps what the invention
|
||||
* has — except the permission, which falls back to UseOnly, the level the older
|
||||
* `v3/publish` has always defaulted to when its query string named none.
|
||||
*/
|
||||
export interface InventionPublish {
|
||||
permissionLevel?: number
|
||||
accessibility?: number
|
||||
price?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an invention (`v3/publish`) — what puts it into search and the feeds.
|
||||
* Publishing sets the permission other players get (UseOnly unless the creator asks
|
||||
@@ -971,7 +570,8 @@ export interface InventionPublish {
|
||||
export async function publishInvention(
|
||||
db: D1Database,
|
||||
inventionId: number,
|
||||
publish: InventionPublish = {}
|
||||
permissionLevel: number | undefined,
|
||||
price: number | undefined
|
||||
): Promise<SavedInvention | null> {
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
@@ -979,13 +579,8 @@ export async function publishInvention(
|
||||
const updated: SavedInvention = {
|
||||
...invention,
|
||||
IsPublished: true,
|
||||
GeneralPermission: publish.permissionLevel ?? INVENTION_PERMISSION.useonly,
|
||||
Accessibility: publish.accessibility ?? invention.Accessibility,
|
||||
// An unmentioned price is the price it already has, not zero: a republish that says
|
||||
// nothing about money must not quietly give away something that was for sale. A
|
||||
// first publish is unaffected — a fresh invention's price is 0 either way.
|
||||
Price: publish.price ?? invention.Price,
|
||||
// The FIRST publish is the one that gets dated; re-publishing doesn't reset it.
|
||||
GeneralPermission: permissionLevel ?? INVENTION_PERMISSION.useonly,
|
||||
Price: price ?? 0,
|
||||
FirstPublishedAt: invention.FirstPublishedAt ?? new Date().toISOString(),
|
||||
}
|
||||
await writeInvention(db, updated)
|
||||
@@ -1008,110 +603,6 @@ export async function setInventionPrice(
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an invention (`v2/delete`), returning the record that was removed, or null
|
||||
* when there's no such row. The whole invention lives in the one JSON blob, so its
|
||||
* versions, tags and referenced-invention lists go with it in a single DELETE.
|
||||
*
|
||||
* Two things are deliberately LEFT behind.
|
||||
*
|
||||
* The data blob in R2 stays: it is named by the file the creator uploaded through the
|
||||
* `storage` worker, and nothing here knows whether another record still points at that
|
||||
* name (a converted invention carries the same lineage, and a save that reuses a
|
||||
* filename reuses the object). An orphan blob costs storage; a missing one breaks
|
||||
* whatever still references it.
|
||||
*
|
||||
* The `inventory_invention` rows stay too — deleting a creator's invention must not
|
||||
* rewrite what other players bought. They already fall out of every list on their own:
|
||||
* `getMyInventions` resolves owned ids against this table and an id with no row left
|
||||
* simply drops out, and `ownsAllInventions` reads a missing row as not-owned. Purging
|
||||
* them would also erase the acquisition history that ranks the "top today" feed.
|
||||
*/
|
||||
export async function deleteInvention(
|
||||
db: D1Database,
|
||||
inventionId: number
|
||||
): Promise<SavedInvention | null> {
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
await db.batch([
|
||||
db.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId),
|
||||
db.prepare('DELETE FROM invention_interaction WHERE invention_id = ?1').bind(inventionId),
|
||||
])
|
||||
return invention
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear one player's cheer on an invention and resync the invention's denormalized
|
||||
* `CheerCount`. Repeating either state is idempotent because the interaction row is keyed by
|
||||
* `(player_id, invention_id)` and the public count is always derived from those rows.
|
||||
*/
|
||||
export async function setInventionCheer(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
inventionId: number,
|
||||
cheer: boolean
|
||||
): Promise<number> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO invention_interaction (player_id, invention_id, cheered, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(player_id, invention_id) DO UPDATE SET cheered = ?3`
|
||||
)
|
||||
.bind(playerId, inventionId, cheer ? 1 : 0, new Date().toISOString())
|
||||
.run()
|
||||
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS n FROM invention_interaction WHERE invention_id = ?1 AND cheered = 1'
|
||||
)
|
||||
.bind(inventionId)
|
||||
.first<{ n: number }>()
|
||||
const count = row?.n ?? 0
|
||||
await db
|
||||
.prepare(
|
||||
"UPDATE invention SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1"
|
||||
)
|
||||
.bind(inventionId, count)
|
||||
.run()
|
||||
return count
|
||||
}
|
||||
|
||||
/** Whether one player currently cheers an invention. */
|
||||
export async function isInventionCheered(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
inventionId: number
|
||||
): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT 1 AS found FROM invention_interaction
|
||||
WHERE player_id = ?1 AND invention_id = ?2 AND cheered = 1`
|
||||
)
|
||||
.bind(playerId, inventionId)
|
||||
.first<{ found: number }>()
|
||||
return row !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* What `v2/delete` answers: the same `{ Value, Success, Error, error_id }` envelope the
|
||||
* other newer-client invention routes use, with `Value` always NULL — the invention is
|
||||
* gone, so there is nothing for the client to redraw from and it reads only `Success`
|
||||
* (and `Error`, the one string that reaches a human). This is why the delete does not
|
||||
* borrow {@link InventionSaveV9Result}: that envelope's `Value` carries an invention the
|
||||
* client dereferences, and a delete has none to give.
|
||||
*/
|
||||
export interface InventionDeleteResult {
|
||||
Value: null
|
||||
Success: boolean
|
||||
Error: string | null
|
||||
error_id: string | null
|
||||
}
|
||||
|
||||
/** The delete envelope: a refusal when given a message, success when given null. */
|
||||
export function inventionDeleteResult(error: string | null = null): InventionDeleteResult {
|
||||
return { Value: null, Success: error === null, Error: error, error_id: null }
|
||||
}
|
||||
|
||||
/** The tag filter chips the client offers when browsing inventions. */
|
||||
export interface InventionTagFilters {
|
||||
PinnedFilters: string[]
|
||||
@@ -1188,7 +679,8 @@ export async function getInventionsByRoom(
|
||||
.prepare(
|
||||
`SELECT data FROM invention
|
||||
WHERE json_extract(data, '$.CreationRoomId') = ?1
|
||||
AND ${VISIBLE_IN_FEEDS.join(' AND ')}`
|
||||
AND is_published = 1
|
||||
AND hide_from_player = 0`
|
||||
)
|
||||
.bind(roomId)
|
||||
.all<InventionRow>()
|
||||
@@ -1198,14 +690,6 @@ export async function getInventionsByRoom(
|
||||
.slice(skip, skip + take)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `version` that means "whichever is current" rather than a version number to match.
|
||||
*
|
||||
* Zero is not a version any invention has — a fresh save is version 1 — so a caller sending
|
||||
* it does not know which version it wants, and reading it literally finds nothing.
|
||||
*/
|
||||
const CURRENT_INVENTION_VERSION = 0
|
||||
|
||||
/**
|
||||
* A single version of an invention (`v1/version?inventionId=…&version=…`), which
|
||||
* is how the client resolves the blob to download for a given version number.
|
||||
@@ -1214,13 +698,6 @@ const CURRENT_INVENTION_VERSION = 0
|
||||
* (there's no `v4/addversion` yet), and a fresh save is always version 1. So this
|
||||
* answers for the current version number and reports null for any other, rather
|
||||
* than inventing a version whose blob doesn't exist.
|
||||
*
|
||||
* VERSION 0 is the exception: it means "whichever version is current" rather than a
|
||||
* version number to match, and gets {@link CURRENT_INVENTION_VERSION}. The client asks
|
||||
* for 0 when it has an invention id but no version to go with it — a discovery row or a
|
||||
* spawn that carries the id alone — and there is no version 0 to find, so matching it
|
||||
* literally 404s and the invention silently fails to load. Answering with the current
|
||||
* version is what it would have asked for had it known the number.
|
||||
*/
|
||||
export async function getInventionVersion(
|
||||
db: D1Database,
|
||||
@@ -1230,12 +707,7 @@ export async function getInventionVersion(
|
||||
): Promise<InventionVersion | null> {
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
if (
|
||||
versionNumber !== CURRENT_INVENTION_VERSION &&
|
||||
invention.CurrentVersionNumber !== versionNumber
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (invention.CurrentVersionNumber !== versionNumber) return null
|
||||
|
||||
// A version saved before its blob finished uploading (or before we hashed on
|
||||
// save at all) carries no hash. Hash it now and keep the result, so the other
|
||||
|
||||
+42
-624
@@ -50,13 +50,6 @@ export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer t
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
export const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
/**
|
||||
* A bearer token is honoured but not required: anonymous is a valid alternative. For routes
|
||||
* that serve public data but show more to a known caller (a creator's own unpublished
|
||||
* custom avatar items) instead of 401ing.
|
||||
*/
|
||||
export const OPTIONAL_AUTHED: OpenAPIV3_1.SecurityRequirementObject[] = [{}, { bearerAuth: [] }]
|
||||
|
||||
/** An integer path parameter (ids are constrained to `[0-9]+` by the route pattern). */
|
||||
export function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'path', required: true, description, schema: { type: 'integer' } }
|
||||
@@ -98,9 +91,6 @@ export const JsonArray = z.array(z.unknown())
|
||||
/** A bare JSON boolean — several routes answer `true`/`false` with no envelope. */
|
||||
export const BareBoolean = z.boolean()
|
||||
|
||||
/** A bare JSON integer (e.g. `/api/customAvatarItems/v1/minPriceForPublicItem`). */
|
||||
export const BareInteger = z.number().int()
|
||||
|
||||
/** A bare JSON string (`POST /api/sanitize/v1` echoes one back). */
|
||||
export const BareString = z.string()
|
||||
|
||||
@@ -119,14 +109,12 @@ export const SuccessErrorEnvelope = z.object({
|
||||
|
||||
// ---- Config ----------------------------------------------------------------
|
||||
|
||||
/** `GET /api/config/v1/amplitude` — the client's analytics keys (blank; RudderStack and StatSig off). */
|
||||
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
|
||||
export const AmplitudeConfig = z.object({
|
||||
AmplitudeKey: z.string(),
|
||||
UseRudderStack: z.boolean(),
|
||||
RudderStackKey: z.string(),
|
||||
UseStatSig: z.boolean(),
|
||||
StatSigKey: z.string(),
|
||||
StatSigEnvironment: z.number().int(),
|
||||
RudderStackKey: z.string(),
|
||||
UseRudderStack: z.boolean(),
|
||||
})
|
||||
|
||||
/** `GET /api/config/v1/azurespeech` — speech-to-text config; `Enabled` is false here. */
|
||||
@@ -234,23 +222,6 @@ export const DeleteMessagesRequest = z.object({
|
||||
MessageIds: z.array(z.int()).describe('Ids of the messages to delete'),
|
||||
})
|
||||
|
||||
/**
|
||||
* One stored message, as `GET /api/messages/v2/get` serves it — the client's Message model,
|
||||
* which is also what the `MessageReceived` frame carries (see the `notify` worker's
|
||||
* `MessageReceivedPayload`). Same shape in the inbox and on the wire, deliberately: they are
|
||||
* one message, and `Id` is the same id in both so the client can match them up.
|
||||
*/
|
||||
export const MessageDto = z.object({
|
||||
Id: z.int().describe('The message’s id — also the `Id` on the frame that delivered it'),
|
||||
FromPlayerId: z.int(),
|
||||
ToPlayerId: z.int(),
|
||||
SentTime: z.string().describe('ISO-8601 UTC'),
|
||||
Type: z.int().describe('The Message-model type (a different enum from `NotificationType`)'),
|
||||
Data: z.string().nullable().describe('Null on the types carrying no payload of their own'),
|
||||
RoomId: z.int().nullable(),
|
||||
PlayerEventId: z.int().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/messages/v1/friendOnlineStatus` — how many of the caller's friends are
|
||||
* online, wrapped in the client's `{ success, value }` envelope.
|
||||
@@ -279,20 +250,16 @@ export const MutualFriendDto = z.object({
|
||||
// ---- Progression -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A player's reputation (cheer counters), read from the `reputation` table. A player
|
||||
* nobody has cheered yet has no row and reads back all-zero with full cheer credit.
|
||||
* `SelectedCheer` is an int (0 = none), not null, and `IsCheerful` is a bool the client
|
||||
* reads to decide whether the player may hand out cheers at all.
|
||||
*
|
||||
* `CheerCredit` is the odd one out: it is what the player has left to GIVE (out of 20 per
|
||||
* day), not something they have received, and it comes from `player_cheer` rather than
|
||||
* from the reputation row.
|
||||
* A player's reputation (cheer counters). Nobody has earned cheers yet, so every
|
||||
* counter is 0 and everyone has their full credit. `SelectedCheer` is an int (0 = none),
|
||||
* not null, and `IsCheerful` is true — the client reads it to decide whether the player
|
||||
* may hand out cheers at all.
|
||||
*/
|
||||
export const ReputationDto = z.object({
|
||||
AccountId: z.int(),
|
||||
IsCheerful: z.boolean(),
|
||||
Noteriety: z.int(),
|
||||
SelectedCheer: z.int().describe('The cheer pinned to the profile; 0 = none selected'),
|
||||
SelectedCheer: z.int().describe('0 = none selected'),
|
||||
CheerCredit: z.int(),
|
||||
CheerGeneral: z.int(),
|
||||
CheerHelpful: z.int(),
|
||||
@@ -310,154 +277,9 @@ export const ProgressionDto = z.object({
|
||||
XP: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The form body of `POST /api/PlayerCheer/v1/create`. Nothing here is stored beyond the
|
||||
* counter the cheer increments: `Anonymous` is spent on the notification it triggers and
|
||||
* `RoomId` is dropped outright — see the route.
|
||||
*/
|
||||
export const CheerPlayerRequest = z.object({
|
||||
PlayerIdTo: z.string().describe('The account being cheered'),
|
||||
CheerCategory: z
|
||||
.string()
|
||||
.describe('0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative'),
|
||||
RoomId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The room it happened in. Accepted but NOT used — the audience for the cheer’s ' +
|
||||
'effect comes from the caller’s live presence, so a client cannot aim it at a room ' +
|
||||
'it is not in'
|
||||
),
|
||||
Anonymous: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'`True`/`False` (default `False`). Not stored — it picks the `PlayerCheerAnonymous` ' +
|
||||
'message type (sender 0) over `PlayerCheer` for the frame that plays the cheer'
|
||||
),
|
||||
})
|
||||
|
||||
/** The form body of `POST /api/PlayerCheer/v1/SetSelectedCheer`. */
|
||||
export const SetSelectedCheerRequest = z.object({
|
||||
CheerCategory: z
|
||||
.string()
|
||||
.describe(
|
||||
'The category to pin: 0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative; -1 unpins'
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* What a cheer answers — the reference's PascalCase `{ Success, Message }`, NOT the
|
||||
* lowercase `{ success, error }` envelope the reports use, and `Message` is NULL on success
|
||||
* where that one sends an empty string. On a refusal it names the reason (out of credit,
|
||||
* bad category, cheering yourself), which the client shows the player.
|
||||
*/
|
||||
export const CheerPlayerResponse = z.object({
|
||||
Success: z.boolean(),
|
||||
Message: z.string().nullable().describe('Null when the cheer landed'),
|
||||
})
|
||||
|
||||
/** The `metadata` JSON field of a custom-avatar-item creation. */
|
||||
export const CreateCustomAvatarItemMetadata = z.object({
|
||||
Name: z.string(),
|
||||
Description: z.string().optional(),
|
||||
Price: z.number().int().optional(),
|
||||
BaseAvatarItemId: z.number().int(),
|
||||
BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'),
|
||||
Accessibility: z.number().int().optional(),
|
||||
})
|
||||
|
||||
/** The multipart body `POST /api/customAvatarItems/v1` takes. */
|
||||
export const CreateCustomAvatarItemRequest = z.object({
|
||||
metadata: z.string().describe('JSON `CreateCustomAvatarItemMetadata`, posted as a text field'),
|
||||
thumbnailImage: z.string().describe('The thumbnail PNG (binary file part)'),
|
||||
design: z.string().describe('The design blob (binary file part)'),
|
||||
})
|
||||
|
||||
/**
|
||||
* One built version of a first-party item, PascalCase as it rides inside `CurrentSaves`: the
|
||||
* Unity assetbundle the client downloads to render the item on one `BodyType`, its hash, and
|
||||
* the thumbnail the store shows. `AdditionalConfiguration` is the client's own JSON-in-a-string.
|
||||
* Not the camelCase `CustomAvatarItemSave` of the legacy-item lookup below — same save,
|
||||
* different casing and field set.
|
||||
*/
|
||||
export const CustomAvatarItemCurrentSave = z.object({
|
||||
CustomAvatarItemSaveId: z.int(),
|
||||
CustomAvatarItemId: z.string(),
|
||||
UnityAssetId: z.string(),
|
||||
BodyType: z.int(),
|
||||
OutfitType: z.int(),
|
||||
QAState: z.int(),
|
||||
CreatedAt: z.string(),
|
||||
ModifiedAt: z.string(),
|
||||
Description: z.string().nullable(),
|
||||
ThumbnailFileName: z.string(),
|
||||
AdditionalConfiguration: z.string().describe('JSON-in-a-string'),
|
||||
UnityAsset: z.string(),
|
||||
UnityAssetHash: z.string(),
|
||||
UnityAsset2: z.string().nullable(),
|
||||
UnityAsset2Hash: z.string().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The client's `CustomAvatarItem` record, served straight out of the row's JSON. A player-made
|
||||
* shirt has a base item and a design PNG and no saves; a first-party item has the four
|
||||
* base/filename fields null and is rendered from `CurrentSaves` instead.
|
||||
*/
|
||||
export const CustomAvatarItemDto = z.object({
|
||||
CustomAvatarItemId: z.string(),
|
||||
CreatorAccountId: z.number().int(),
|
||||
Name: z.string(),
|
||||
Description: z.string(),
|
||||
Price: z.number().int(),
|
||||
Accessibility: z.number().int(),
|
||||
ForceCannotPublish: z.boolean(),
|
||||
IsFeatured: z.boolean(),
|
||||
IsRecRoomApproved: z.boolean(),
|
||||
BaseAvatarItemId: z.number().int().nullable(),
|
||||
BaseAvatarItemColor: z.string().nullable(),
|
||||
DesignFilename: z.string().nullable(),
|
||||
ThumbnailImageFilename: z.string().nullable(),
|
||||
CreatedAt: z.string(),
|
||||
ModifiedAt: z.string(),
|
||||
PreviewOrientation: z.number().int(),
|
||||
RankingContext: z.null(),
|
||||
OutfitType: z.number().int(),
|
||||
CurrentSaves: z.array(CustomAvatarItemCurrentSave),
|
||||
Tags: z.array(z.object({ TagType: z.int(), Value: z.string() })),
|
||||
CustomBadgeMetadata: z.unknown().nullable(),
|
||||
RankedEntityId: z.string(),
|
||||
PurchaseInfo: z.null(),
|
||||
})
|
||||
|
||||
/** The JSON body `PUT /api/customAvatarItems/v1/:id` takes; null leaves a field unchanged. */
|
||||
export const UpdateCustomAvatarItemRequest = z.object({
|
||||
Name: z.string().nullable().optional(),
|
||||
Description: z.string().nullable().optional(),
|
||||
Price: z.number().int().nullable().optional(),
|
||||
Accessibility: z.number().int().nullable().optional(),
|
||||
})
|
||||
|
||||
/** A bare list of custom avatar items (the featured feed). */
|
||||
export const CustomAvatarItemList = z.array(CustomAvatarItemDto)
|
||||
|
||||
/** The PascalCase `{ Value, Success, Error, error_id }` envelope custom-avatar-item routes answer with. */
|
||||
export const CustomAvatarItemResponse = z.object({
|
||||
Value: CustomAvatarItemDto.nullable(),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable(),
|
||||
error_id: z.string().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The `Ids` form body the bulk POST endpoints take, in either of the two spellings the
|
||||
* client sends: `Ids` REPEATED once per id (`Ids=101&Ids=102&Ids=103`) or a single
|
||||
* comma-separated `Ids=1,2,3`. Both are read by `parseFormIds`.
|
||||
*/
|
||||
/** The `Ids` form body the bulk POST endpoints take. */
|
||||
export const BulkIdsRequest = z.object({
|
||||
Ids: z
|
||||
.union([z.string(), z.array(z.string())])
|
||||
.describe('Repeated (`Ids=101&Ids=102`) or comma-separated (`Ids=1,2,3`)'),
|
||||
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
|
||||
})
|
||||
|
||||
// ---- Inventions ------------------------------------------------------------
|
||||
@@ -477,10 +299,6 @@ export const InventionVersionDto = z.object({
|
||||
ChipsCost: z.int(),
|
||||
CloudVariablesCost: z.int(),
|
||||
AICost: z.int(),
|
||||
HasBetaContent: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Set from `v9/save` on — absent on a version saved through `v6/save`'),
|
||||
})
|
||||
|
||||
/** A tag on an invention. `Type` 0 = custom (creator-submitted), 2 = auto-derived. */
|
||||
@@ -517,30 +335,10 @@ export const InventionDto = z.object({
|
||||
AllowTrial: z.boolean(),
|
||||
HideFromPlayer: z.boolean(),
|
||||
ReferencedInventions: z.array(z.int()),
|
||||
ReferencedUnityAssetIds: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Set from `v9/save` on — absent on an invention saved through `v6/save`'),
|
||||
UgcVersion: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe('An invention field, not a version one — set from `v9/save` on'),
|
||||
LongDescription: z.string().optional().describe('Set from `v9/save` on, when non-empty'),
|
||||
DisplayMetadataJson: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The client’s own display state, stored as the opaque string it sent'),
|
||||
ConvertedFromInventionId: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe('The invention this one was converted from, when `v9/save` named one'),
|
||||
Tags: z
|
||||
.array(InventionTagDto)
|
||||
.optional()
|
||||
.describe(
|
||||
'The real RRInvention carries no Tags field. Unset by `v6/save`; set by `v9/save` ' +
|
||||
'when its `tagsRequest` names at least one tag'
|
||||
),
|
||||
.describe('Unset on save — the real RRInvention carries no Tags field'),
|
||||
})
|
||||
|
||||
/** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */
|
||||
@@ -550,88 +348,6 @@ export const InventionSaveResult = z.object({
|
||||
InventionVersion: InventionVersionDto,
|
||||
})
|
||||
|
||||
/**
|
||||
* The `Invention` a v9 save answers with — the newer client's own `RRInvention`, which is
|
||||
* not the record this server stores or the read endpoints serve: no nested
|
||||
* `CurrentVersion` (the version rides beside it), no `Referenced*` (those moved onto the
|
||||
* version), no `IsPublished`.
|
||||
*/
|
||||
export const InventionV9Dto = z.object({
|
||||
InventionId: z.int(),
|
||||
ReplicationId: z.string(),
|
||||
CreatorPlayerId: z.int(),
|
||||
Name: z.string(),
|
||||
Description: z.string(),
|
||||
ImageName: z.string(),
|
||||
UgcVersion: z.int().describe('The UGC format the blob was written in; 0 when unsent'),
|
||||
CurrentVersionNumber: z.int(),
|
||||
LatestVersionNumber: z.int().describe('The same as CurrentVersionNumber on a fresh save'),
|
||||
Accessibility: z.int(),
|
||||
ForceCannotPublish: z.boolean().describe('Always false — nothing here forbids publishing'),
|
||||
ModifiedAt: z.string(),
|
||||
CreatedAt: z.string(),
|
||||
FirstPublishedAt: z.string().nullable(),
|
||||
CreationRoomId: z.int().nullable(),
|
||||
NumPlayersHaveUsedInRoom: z.int(),
|
||||
NumDownloads: z.int(),
|
||||
CheerCount: z.int(),
|
||||
CreatorPermission: z.int(),
|
||||
GeneralPermission: z.int(),
|
||||
IsAGInvention: z.boolean(),
|
||||
IsCertifiedInvention: z.boolean(),
|
||||
IsRecRoomApproved: z.boolean().describe('Always false — nothing here approves an invention'),
|
||||
AllowTrial: z.boolean(),
|
||||
Price: z.int().nullable(),
|
||||
HideFromPlayer: z.boolean(),
|
||||
DisplayMetadataJson: z.string().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The `InventionVersion` a v9 save answers with. It carries `HasBetaContent`, a `CreatedAt`
|
||||
* of its own and a nullable `UgcAccessibility`, and notably no `AICost` — which the request
|
||||
* still sends and this server still stores.
|
||||
*/
|
||||
export const InventionVersionV9Dto = z.object({
|
||||
InventionId: z.int(),
|
||||
ReplicationId: z.string(),
|
||||
VersionNumber: z.int(),
|
||||
HasBetaContent: z.boolean(),
|
||||
InstantiationCost: z.int(),
|
||||
LightsCost: z.int(),
|
||||
ChipsCost: z.int(),
|
||||
CloudVariablesCost: z.int(),
|
||||
BlobName: z.string(),
|
||||
BlobHash: z.string().nullable(),
|
||||
CreatedAt: z.string(),
|
||||
UgcAccessibility: z.int().nullable().describe('Always null — versions carry no accessibility'),
|
||||
ReferencedInventions: z.array(z.int()),
|
||||
ReferencedUnityAssetIds: z.array(z.string()),
|
||||
})
|
||||
|
||||
/**
|
||||
* What `v9/save` answers — the enveloped result. The client checks `Success` and then reads
|
||||
* `Value.Invention.InventionId`; `Error` is the only text it shows a human, and `Status`,
|
||||
* `InventionVersion` and `TagsResponse` are deserialized and never read. `Success: true`
|
||||
* with a null `Value` crashes it, so a refusal is `Success: false` with `Value: null`.
|
||||
*/
|
||||
export const InventionSaveV9Result = z.object({
|
||||
Value: z
|
||||
.object({
|
||||
Status: z.int().describe('0 = success; the client never reads it on this route'),
|
||||
Invention: InventionV9Dto,
|
||||
InventionVersion: InventionVersionV9Dto,
|
||||
TagsResponse: z.object({
|
||||
Result: z.int().describe('0 = success; non-zero when a tag broke the tag rule'),
|
||||
Tags: z.array(z.string()).describe('The stored tag NAMES, auto first, then custom'),
|
||||
}),
|
||||
})
|
||||
.nullable()
|
||||
.describe('Null when Success is false — and only then'),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable().describe('The refusal message; the only text the client shows'),
|
||||
error_id: z.string().nullable().describe('Always null'),
|
||||
})
|
||||
|
||||
/** The tag filter chips on a browse screen, derived from the tags actually in use. */
|
||||
export const TagFilters = z.object({
|
||||
PinnedFilters: z.array(z.string()),
|
||||
@@ -647,13 +363,7 @@ export const InventionDetails = z.object({ Tags: z.array(InventionTagDto) })
|
||||
|
||||
/** `GET /api/inventions/v1/personaldetails/:id` — the caller's own relation to it. */
|
||||
export const InventionPersonalDetails = z.object({
|
||||
IsCheering: z.boolean().describe('Whether the caller currently cheers this invention'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v1/cheer` JSON body. */
|
||||
export const InventionCheerRequest = z.object({
|
||||
InventionId: z.int().describe('The invention whose cheer state is changing'),
|
||||
Cheer: z.boolean().describe('True to cheer; false to remove the cheer'),
|
||||
IsCheering: z.boolean().describe('Always false — nothing can cheer an invention yet'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
|
||||
@@ -675,113 +385,6 @@ export const SetTagsResponse = z.object({
|
||||
Tags: z.array(z.string()).describe('Auto tags first, then custom'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /api/inventions/v2/metadata` JSON body — PascalCase, and every field but the id is
|
||||
* NULLABLE: the newer client sends the whole shape on every edit and marks the fields it
|
||||
* isn't touching as null. An empty string is not a null — it clears the field.
|
||||
*/
|
||||
export const UpdateInventionMetadataRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
Name: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('3–24 chars, letters/digits/spaces/dashes/colons; null leaves it alone'),
|
||||
Description: z.string().nullable().optional().describe('Max 512 chars; empty clears it'),
|
||||
LongDescription: z.string().nullable().optional().describe('Empty clears it'),
|
||||
ImageName: z.string().nullable().optional().describe('New thumbnail; empty clears it'),
|
||||
TagsRequest: z
|
||||
.object({
|
||||
AutoTags: z.array(z.string()).nullable().optional(),
|
||||
CustomTags: z.array(z.string()).nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Replaces both lists wholesale, as `v1/settags` does; null leaves them alone'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/inventions/v2/update` JSON body — the newer client's counterpart to
|
||||
* `v1/update`'s query string. PascalCase, and nullable the way `v2/metadata`'s is: a null
|
||||
* field keeps what the invention already has, so this is a patch rather than a replace.
|
||||
*
|
||||
* Only `InventionId` and `Permission` are confirmed from a live capture (the permission
|
||||
* picker sends exactly those two). The rest are accepted defensively — they are the fields
|
||||
* `v1/update` took that `v2/metadata` does not — so that a build sending one gets it
|
||||
* applied instead of silently dropped.
|
||||
*/
|
||||
export const UpdateInventionRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
Permission: z
|
||||
.int()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'The `GeneralPermission` other players get, as a raw ladder number: Unassigned 0, ' +
|
||||
'LimitedOneUseOnly 10, DisallowKeyLock 15, UseOnly 20, EditAndSave 40, Publish 60, ' +
|
||||
'Charge 80, Unlimited 100. Unlike `v4/publish`, null LEAVES IT ALONE rather than ' +
|
||||
'falling back to UseOnly — an edit that names no permission is not a demotion'
|
||||
),
|
||||
AllowTrial: z
|
||||
.boolean()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Whether other players may trial it; null leaves it alone'),
|
||||
Name: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('3–24 chars, letters/digits/spaces/dashes/colons; null leaves it alone'),
|
||||
Description: z.string().nullable().optional().describe('Max 512 chars; empty clears it'),
|
||||
LongDescription: z.string().nullable().optional().describe('Empty clears it'),
|
||||
ImageName: z.string().nullable().optional().describe('New thumbnail; empty clears it'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/inventions/v4/publish` JSON body — PascalCase, and nullable the way
|
||||
* `v2/metadata`'s is: a null field keeps what the invention already has.
|
||||
*/
|
||||
export const PublishInventionRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
Permission: z
|
||||
.int()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'The `GeneralPermission` other players get, as a raw ladder number: Unassigned 0, ' +
|
||||
'LimitedOneUseOnly 10, DisallowKeyLock 15, UseOnly 20, EditAndSave 40, Publish 60, ' +
|
||||
'Charge 80, Unlimited 100. Null publishes as UseOnly'
|
||||
),
|
||||
Accessibility: z
|
||||
.int()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Private 0, Public 1, Unlisted 2. Unlisted stays out of browse and search'),
|
||||
Price: z
|
||||
.int()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Price in tokens; null leaves it as it is, and a negative one is ignored'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v2/delete` JSON body — the id and nothing else. */
|
||||
export const DeleteInventionRequest = z.object({
|
||||
InventionId: z.int().describe('The invention to delete; the caller must have created it'),
|
||||
})
|
||||
|
||||
/**
|
||||
* What `v2/delete` answers — the same `{ Value, Success, Error, error_id }` envelope the
|
||||
* other newer-client invention routes use, with `Value` always null. The invention is
|
||||
* gone, so there is nothing for the client to redraw from: it reads `Success`, and
|
||||
* `Error` when that is false.
|
||||
*/
|
||||
export const InventionDeleteResult = z.object({
|
||||
Value: z.null().describe('Always null — the invention no longer exists'),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable().describe('The refusal message; null on success'),
|
||||
error_id: z.string().nullable().describe('Always null'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v1/updateprice` JSON body. */
|
||||
export const UpdatePriceRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
@@ -809,34 +412,6 @@ export const SaveInventionRequest = z.object({
|
||||
aiCost: z.int().optional(),
|
||||
creationRoomId: z.int().optional(),
|
||||
referencedInventions: z.array(z.int()).optional(),
|
||||
creatorAccountRole: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe('Accepted and ignored — a room role, not a permission over the invention'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/inventions/v9/save` JSON body — `v6`’s fields plus what the invention
|
||||
* points at, what it says about itself, and the tags that used to need a second
|
||||
* `v1/settags` call.
|
||||
*/
|
||||
export const SaveInventionV9Request = SaveInventionRequest.extend({
|
||||
ugcVersion: z.int().optional().describe('The UGC format the blob was written in'),
|
||||
hasBetaContent: z.boolean().optional(),
|
||||
referencedUnityAssetIds: z.array(z.string()).optional(),
|
||||
longDescription: z.string().optional().describe('Stored when non-empty'),
|
||||
displayMetadataJson: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Opaque client display state, e.g. `{"0":0,"99":0}`; stored verbatim'),
|
||||
convertedFromInventionId: z.int().nullable().optional(),
|
||||
tagsRequest: z
|
||||
.object({
|
||||
AutoTags: z.array(z.string()).nullable().optional(),
|
||||
CustomTags: z.array(z.string()).nullable().optional(),
|
||||
})
|
||||
.optional()
|
||||
.describe('The same two lists `v1/settags` takes, folded into the save'),
|
||||
})
|
||||
|
||||
// ---- Avatar / custom avatar items ------------------------------------------
|
||||
@@ -848,7 +423,7 @@ export const GeneratedGift = z.object({
|
||||
ConsumableItemDesc: z.string(),
|
||||
AvatarItemDesc: z.string(),
|
||||
FriendlyName: z.string(),
|
||||
AvatarItemType: z.int().nullable().describe('Null — a token box holds no avatar item'),
|
||||
AvatarItemType: z.int(),
|
||||
EquipmentPrefabName: z.string(),
|
||||
EquipmentModificationGuid: z.string(),
|
||||
CurrencyType: z.int(),
|
||||
@@ -879,18 +454,11 @@ export const BulkCustomAvatarItemsRequest = z.object({
|
||||
customAvatarItemIds: z
|
||||
.array(z.string())
|
||||
.describe('The ids to resolve; repeat the field once per id'),
|
||||
unityAssetTarget: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe(
|
||||
'The Unity build target the assetbundles are wanted for: 0 PC (Windows), 2 ' +
|
||||
'Android/Oculus — which is served the `quest/` builds. Read off the query string too'
|
||||
),
|
||||
})
|
||||
|
||||
/** A paginated custom-avatar-item page, out of the `custom_avatar_item` table. */
|
||||
/** A paginated custom-avatar-item page (no storage yet, so always empty). */
|
||||
export const CustomAvatarItemsPage = z.object({
|
||||
Results: CustomAvatarItemList,
|
||||
Results: JsonArray,
|
||||
TotalResults: z.int(),
|
||||
})
|
||||
|
||||
@@ -1118,23 +686,13 @@ export const PlayerEventDetailsDto = PlayerEventDto.extend({
|
||||
})
|
||||
|
||||
/**
|
||||
* The client's BASE event, 17 keys — the stored record minus `State`, with `ImageName` as a
|
||||
* string (`""`, not null) and a `BroadcastingRoomInstanceId` (always null — nothing
|
||||
* broadcasts an event yet). It is also what the v2 envelope carries once `Tags` is added.
|
||||
* The client's BASE event, 17 keys — what `GET /api/playerevents/v1` serves, and what the
|
||||
* v2 envelope carries once `Tags` is added. The stored record minus `State`, with
|
||||
* `ImageName` as a string (`""`, not null) and a `BroadcastingRoomInstanceId` (always null —
|
||||
* nothing broadcasts an event yet).
|
||||
*
|
||||
* THREE reads serve exactly this, through one generic helper on the client and one element
|
||||
* type: the browse feed (`GET /api/playerevents/v1`), the room shelf
|
||||
* (`GET /api/playerevents/v1/room/{roomId}`) and the bulk read
|
||||
* (`POST|GET /api/playerevents/v1/bulk`). They are shape-identical by construction on the
|
||||
* client side; keep them that way here.
|
||||
*
|
||||
* The remaining reads — by id, search, searchlive and the club feeds — serve the stored
|
||||
* RECORD verbatim, `State` and nullable `ImageName` included. Two projections; don't unify
|
||||
* them.
|
||||
*
|
||||
* `DefaultBroadcastPermissions` and `CanRequestBroadcastPermissions` are the client's
|
||||
* broadcast-permission enum, whose members are NOT 0/1/2: None 0, RoomOwners 256, All
|
||||
* 2147483647. Reading them as an ordinal is the classic way to break broadcast.
|
||||
* The by-id, bulk and search reads serve the stored RECORD verbatim instead, so don't unify
|
||||
* the two.
|
||||
*/
|
||||
export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName: true }).extend({
|
||||
ImageName: z.string().describe('Empty string when the event has no image, never null'),
|
||||
@@ -1150,16 +708,15 @@ export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName:
|
||||
* image.
|
||||
*
|
||||
* `Tags` has two shapes, picked from the caller's build: Rec Room reshaped it without
|
||||
* minting a new path, so a build newer than `20230414` gets the `{ Tag, Type }` pairs and
|
||||
* that build, every older one, and any caller whose token names no build get the tag
|
||||
* NAMES. Neither is the lowercase `{ tag, type }` the v1 read serves. `TagModifyResult.Tags`
|
||||
* is a name list to every build.
|
||||
* minting a new path, so a build newer than `20230414` gets the tag NAMES and every older
|
||||
* one (and any caller whose token names no build) gets the `{ Tag, Type }` pairs. Neither
|
||||
* is the lowercase `{ tag, type }` the v1 read serves.
|
||||
*/
|
||||
export const PlayerEventEnvelopeDto = PlayerEventBaseDto.extend({
|
||||
Tags: z
|
||||
.union([z.array(z.string()), z.array(z.object({ Tag: z.string(), Type: z.int() }))])
|
||||
.describe(
|
||||
'The event’s tags: `{ Tag, Type }` pairs for a build newer than 20230414, names for ' +
|
||||
'The event’s tags: names for a build newer than 20230414, `{ Tag, Type }` pairs for ' +
|
||||
'that build and older'
|
||||
),
|
||||
})
|
||||
@@ -1177,7 +734,7 @@ export const PlayerEventResultDto = z.object({
|
||||
Result: z.int().describe('0 = success'),
|
||||
TagModifyResult: z.object({
|
||||
Result: z.int().describe('0 = success'),
|
||||
Tags: z.array(z.string()).describe('The tags the event now carries — names to every build'),
|
||||
Tags: z.array(z.string()).describe('The tags the event now carries'),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1265,9 +822,7 @@ export const PlayerEventResponseDto = z.object({
|
||||
'When the answer that stands was given — a changed answer updates the row, so this ' +
|
||||
'moves with it rather than recording the player’s first response'
|
||||
),
|
||||
Type: z
|
||||
.int()
|
||||
.describe('0 Going, 1 Interested, 2 Can’t go, 3 Pending (invited, not yet answered)'),
|
||||
Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'),
|
||||
})
|
||||
|
||||
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
|
||||
@@ -1290,39 +845,6 @@ export const PlayerEventReportRequest = z.object({
|
||||
Details: z.string().optional().describe('The free-text description the reporter typed'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/inventions/v1/report` JSON body — a report against an invention. JSON, like
|
||||
* the event report and unlike the form-encoded player report. The reporter is NOT in the
|
||||
* body: it's the bearer token's player, and neither is the invention's creator, who is
|
||||
* read from the invention.
|
||||
*/
|
||||
/**
|
||||
* `POST /api/customAvatarItems/v1/{id}/report` JSON body. The item is named by the PATH, not
|
||||
* the body, and `ReportedPlayerId` arrives NULL — the client does not know who made the item,
|
||||
* so the creator is read off the item instead.
|
||||
*/
|
||||
export const CustomAvatarItemReportRequest = z.object({
|
||||
ReportCategory: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe('The reason picked in the report UI. Stored verbatim; unmapped'),
|
||||
Details: z.string().optional().describe('The free-text description the reporter typed'),
|
||||
ReportedPlayerId: z
|
||||
.int()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Sent as null and IGNORED — the reported player is the item’s creator'),
|
||||
})
|
||||
|
||||
export const InventionReportRequest = z.object({
|
||||
InventionId: z.int().describe('The invention being reported'),
|
||||
ReportCategory: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe('The reason picked in the report UI. Stored verbatim; unmapped'),
|
||||
Details: z.string().optional().describe('The free-text description the reporter typed'),
|
||||
})
|
||||
|
||||
/** `POST /api/playerevents/v1/bulkInvite` JSON body — who to invite to which event. */
|
||||
export const PlayerEventBulkInviteRequest = z.object({
|
||||
PlayerEventId: z.int(),
|
||||
@@ -1360,62 +882,24 @@ export const VoteToKickReason = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — the caller's block. With an
|
||||
* account-wide ban in force (a `report` row with `banned` set) it describes that ban:
|
||||
* `IsBan` true, the report's `ReportCategory`, a fixed `Message` of "Rule violation", and
|
||||
* its span as `TimeoutStartedAt` (the report's `created_at`) plus `Duration` (seconds to
|
||||
* `ban_expires`; int32 max for a permanent ban). Otherwise it is the "not blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`:
|
||||
* `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and
|
||||
* `Message` is null — the client distinguishes "no message" from a blank one, so we send
|
||||
* null where the reference sends an empty string. `IsVoiceModAutoban`/`TimeoutStartedAt`
|
||||
* are on the DTO but unset by that stub, so they carry their C# defaults (false / null).
|
||||
*
|
||||
* Sixteen keys on the wire — every one the 2025 client's `ModerationBlockDetail` formatter
|
||||
* reads. The seven past the stub's nine (`IsDeviceBan` … `BottomMessageOverride`) are
|
||||
* block kinds and screen dressings this server never hands out, so they always carry their
|
||||
* "none" value; they are sent so a decoder that wants the key present finds it.
|
||||
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
|
||||
* answer (no ban storage yet), mirroring the reference server's stub
|
||||
* `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1) rather than 0,
|
||||
* which is a real category, and `Message` is null — the client distinguishes "no
|
||||
* message" from a blank one, so we send null where the reference sends an empty string.
|
||||
* `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but unset by that stub, so
|
||||
* they carry their C# defaults (false / null).
|
||||
*/
|
||||
export const ModerationBlockDetails = z.object({
|
||||
ReportCategory: z
|
||||
.int()
|
||||
.describe(
|
||||
'The category the ban’s report was filed under; -1 = ReportCategory.Unknown when not blocked (0 is a real category)'
|
||||
),
|
||||
Duration: z
|
||||
.int()
|
||||
.describe(
|
||||
'Length of the block in seconds from `TimeoutStartedAt`; 2147483647 (int32 max) for a permanent ban; 0 when not blocked'
|
||||
),
|
||||
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
|
||||
Duration: z.int(),
|
||||
GameSessionId: z.int(),
|
||||
IsHostKick: z.boolean().describe('Always false — no host kick is ever recorded here'),
|
||||
Message: z.string().nullable().describe('“Rule violation” on a ban; null when not blocked'),
|
||||
PlayerIdReporter: z
|
||||
.int()
|
||||
.nullable()
|
||||
.describe('Always null — the reporter is not shown to the reported'),
|
||||
IsBan: z.boolean().describe('True when an account-wide ban is in force'),
|
||||
IsVoiceModAutoban: z.boolean().describe('Always false'),
|
||||
IsDeviceBan: z.boolean().describe('Always false — bans here are account-wide, not per device'),
|
||||
IsWarning: z
|
||||
.boolean()
|
||||
.describe('Always false — warnings are delivered as notifications, not here'),
|
||||
VoteKickReason: z.string().nullable().describe('Always null — no vote-kick is recorded here'),
|
||||
TimeoutStartedAt: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
'When the block began — the ban’s report `created_at` (ISO-8601 UTC); `Duration` runs from it. Null when not blocked'
|
||||
),
|
||||
AssociatedAccountUsername: z.string().nullable().describe('Always null'),
|
||||
ShowCreatorCodeOfConduct: z.boolean().describe('Always false'),
|
||||
TopMessageOverride: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Always null — the client’s default block-screen text stands'),
|
||||
BottomMessageOverride: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Always null — the client’s default block-screen text stands'),
|
||||
IsBan: z.boolean(),
|
||||
IsHostKick: z.boolean(),
|
||||
IsVoiceModAutoban: z.boolean(),
|
||||
Message: z.string().nullable(),
|
||||
PlayerIdReporter: z.int().nullable(),
|
||||
TimeoutStartedAt: z.string().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1442,27 +926,6 @@ export const CreateReportRequest = z.object({
|
||||
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/chatreport/createChatReport` form body — a report against one chat message.
|
||||
* Form-encoded, so everything is a string; only `ChatMessageId` is required. Neither the
|
||||
* reporter (the bearer token) nor the reported player (the message's sender) is in the body.
|
||||
*/
|
||||
export const CreateChatReportRequest = z.object({
|
||||
ChatMessageId: z.string().describe('Id of the message being reported, e.g. `72`'),
|
||||
ChatThreadId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The thread the message is in. Accepted and unused — the message names its own'),
|
||||
ReportCategory: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The reason picked in the report UI, sent as a NAME (`Discriminatory`) where the ' +
|
||||
'player report sends a number. Mapped onto the numeric category the table stores'
|
||||
),
|
||||
ReportDescription: z.string().optional().describe('The free-text description the reporter typed'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything
|
||||
* is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
|
||||
@@ -1481,51 +944,6 @@ export const CreateWarningRequest = z.object({
|
||||
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/PlayerReporting/v3/voteToKick` form body — a player calling a vote on
|
||||
* another. Everything is a string on the wire (it's form-encoded). `Reason` is one of the
|
||||
* labels `GET /api/PlayerReporting/v1/voteToKickReasons` serves; the voter is NOT in the
|
||||
* body — it's the bearer token's subject.
|
||||
*/
|
||||
export const VoteToKickRequest = z.object({
|
||||
PlayerId: z
|
||||
.string()
|
||||
.describe(
|
||||
'Account id of the player being voted on — relayed as the frame’s `FromPlayerId`, which is what the client’s prompt names'
|
||||
),
|
||||
Response: z.string().describe('The caller’s own vote, e.g. `True`. Not relayed'),
|
||||
Reason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'A `voteToKickReasons` label, e.g. `Inactive in games (AFK)` — relayed verbatim as the frame’s `Data`, which is the text the prompt shows. Empty when absent'
|
||||
),
|
||||
GameSessionId: z.string().describe('The room instance both players are standing in'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/PlayerReporting/v1/instantKick` JSON body — the players a room's staff are
|
||||
* ejecting from one live instance. JSON, not a form, unlike its neighbours in this
|
||||
* controller. `GameSessionId` is the room INSTANCE id (`roomInstanceId`); the kick is
|
||||
* scoped to it, so a player named here who is standing somewhere else is left alone.
|
||||
*/
|
||||
export const InstantKickRequest = z.object({
|
||||
GameSessionId: z.int().describe('The room instance (game session) to eject them from'),
|
||||
PlayerIds: z.array(z.int()).describe('Account ids to kick out of that instance'),
|
||||
})
|
||||
|
||||
/** `POST /api/PlayerReporting/v1/roomModKick` form body — one player out of one instance. */
|
||||
export const RoomModKickRequest = z.object({
|
||||
PlayerId: z.string().describe('Account id of the player to kick'),
|
||||
GameSessionId: z
|
||||
.string()
|
||||
.describe('The room instance (game session) to eject them from — they must be standing in it'),
|
||||
Reason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Free text, shown to the kicked player in the kick message'),
|
||||
})
|
||||
|
||||
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
|
||||
export const DeviceIdRequest = z.object({
|
||||
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
|
||||
|
||||
+27
-412
@@ -7,48 +7,28 @@
|
||||
* what a player submitted: the table is a log of exactly what was reported.
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
|
||||
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql,
|
||||
* 0017_report_custom_avatar_item.sql, 0020_report_ban_audit.sql and
|
||||
* 0025_report_chat_message.sql, applied under its own `migrations_table` so it doesn't clash
|
||||
* with the other workers' migrations that share the database).
|
||||
* 0009_report_ban.sql and 0011_report_event.sql, applied under its own
|
||||
* `migrations_table` so it doesn't clash with the other workers' migrations that share
|
||||
* the database).
|
||||
*
|
||||
* The moderation-side READS here — `searchReports`, `getTopReported`, `getBansInForce` —
|
||||
* are not called by any endpoint on this worker. They back the staff panel served by
|
||||
* `www` (`/api/staff/*`), which imports them: the endpoints are a recflare addition with
|
||||
* no counterpart in the real service, and those live on `www` rather than on the workers
|
||||
* that reimplement the game's own API. The SQL stays here, with the table that owns it.
|
||||
*
|
||||
* A reported player EVENT, INVENTION or CUSTOM AVATAR ITEM lands here too, rather than in a
|
||||
* table of its own: same fields, same moderation life. Such a row carries `event_id`,
|
||||
* `invention_id` or `custom_avatar_item_id`, and its `reported_player_id` is that thing's
|
||||
* CREATOR — see `POST /api/playerevents/v1/report`, `POST /api/inventions/v1/report` and
|
||||
* `POST /api/customAvatarItems/v1/{id}/report`. A reported CHAT MESSAGE is the fourth kind:
|
||||
* `chat_message_id`, with the message's SENDER as the reported player — see
|
||||
* `POST /api/chatreport/createChatReport`. The four id columns are mutually exclusive; a row
|
||||
* with none of them is an ordinary player report. They are separate columns rather than one
|
||||
* polymorphic id because the keys differ in TYPE (numbers and a guid) and in what they key.
|
||||
* A reported player EVENT lands here too, rather than in a table of its own: same
|
||||
* fields, same moderation life. Such a row carries `event_id`, and its
|
||||
* `reported_player_id` is the event's creator — see `POST /api/playerevents/v1/report`.
|
||||
*
|
||||
* A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned`
|
||||
* on that same row (see `banFromReport`), so the ban carries the evidence for it. It is
|
||||
* ENFORCED by `match`, which refuses every matchmake for a banned player, and DESCRIBED
|
||||
* by `/api/PlayerReporting/v1/moderationBlockDetails`, which tells the banned player why
|
||||
* (via `getActiveBan`). `auth` still issues a banned account a token — that is what lets
|
||||
* the client reach the block screen — and reads this table only for ban EVASION (an
|
||||
* account sharing a device or network with a banned one; see bans-db). This is distinct
|
||||
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player out
|
||||
* of ONE room, this one out of the game.
|
||||
* on that same row (see `banFromReport`), so the ban carries the evidence for it. Two
|
||||
* workers read it — `match` refuses every matchmake for a banned player, and `auth`
|
||||
* refuses to issue them a token at all — both via `isPlayerBanned`. This is distinct
|
||||
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player
|
||||
* out of ONE room, this one out of the game.
|
||||
*
|
||||
* `/api/PlayerReporting/v1/moderationBlockDetails` is NOT wired to it yet and still
|
||||
* answers "not blocked" unconditionally.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql +
|
||||
* 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql +
|
||||
* 0020_report_ban_audit.sql + 0025_report_chat_message.sql + 0029_report_ban_lifted.sql).
|
||||
*
|
||||
* None of `event_id`, `invention_id`, `custom_avatar_item_id` or `chat_message_id` is
|
||||
* indexed: each is written on every report of its kind and read by nothing — no query here filters on any of them,
|
||||
* and the reads that do exist go by player or by the ban flag. 0011's partial index over
|
||||
* `event_id` was dropped in 0016 rather than mirrored. Add one back alongside the query that
|
||||
* needs it.
|
||||
* 0011_report_event.sql).
|
||||
*/
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS report (
|
||||
@@ -64,18 +44,12 @@ export const SCHEMA_DDL: string[] = [
|
||||
created_at TEXT NOT NULL,
|
||||
banned INTEGER NOT NULL DEFAULT 0,
|
||||
ban_expires TEXT,
|
||||
event_id INTEGER,
|
||||
invention_id INTEGER,
|
||||
custom_avatar_item_id TEXT,
|
||||
banned_by_player_id INTEGER,
|
||||
banned_at TEXT,
|
||||
chat_message_id INTEGER,
|
||||
unbanned_by_player_id INTEGER,
|
||||
unbanned_at TEXT
|
||||
event_id INTEGER
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_report_banned ON report (reported_player_id) WHERE banned = 1`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_report_event ON report (event_id) WHERE event_id IS NOT NULL`,
|
||||
]
|
||||
|
||||
/** A stored report row (snake_case columns, one row per submission). */
|
||||
@@ -102,53 +76,6 @@ export interface ReportRow {
|
||||
* `reported_player_id` and `room_id` are filled in from the event itself.
|
||||
*/
|
||||
event_id: number | null
|
||||
/**
|
||||
* The invention this report is against, or NULL for any other kind — mutually exclusive
|
||||
* with `event_id`. See `POST /api/inventions/v1/report`: `reported_player_id` is the
|
||||
* invention's creator, read from the invention itself. No `room_id` comes with it; an
|
||||
* invention isn't tied to one room the way an event is.
|
||||
*/
|
||||
invention_id: number | null
|
||||
/**
|
||||
* The custom avatar item this report is against, or NULL for any other kind — mutually
|
||||
* exclusive with the two above. TEXT because such an item is keyed by a GUID where an
|
||||
* event and an invention are keyed by numbers. See
|
||||
* `POST /api/customAvatarItems/v1/{id}/report`: `reported_player_id` is the item's
|
||||
* creator, read from the item, because the client sends `ReportedPlayerId: null` here —
|
||||
* it does not know who made it.
|
||||
*/
|
||||
custom_avatar_item_id: string | null
|
||||
/**
|
||||
* The moderator who set `banned` on this row, or NULL when nobody has (and cleared
|
||||
* again when a ban is lifted). Read per-row by the staff panel; nothing filters on it.
|
||||
*/
|
||||
banned_by_player_id: number | null
|
||||
/**
|
||||
* ISO-8601 UTC instant the ban was HANDED DOWN — distinct from `created_at`, which is
|
||||
* when the report was filed, and the two can be months apart. This is what the client's
|
||||
* block screen counts the ban from (see `banBlockDetails`); rows written before
|
||||
* 0020_report_ban_audit.sql carry NULL and fall back to `created_at`.
|
||||
*/
|
||||
banned_at: string | null
|
||||
/**
|
||||
* The chat message this report is against, or NULL for any other kind — mutually
|
||||
* exclusive with `event_id`, `invention_id` and `custom_avatar_item_id`. See
|
||||
* `POST /api/chatreport/createChatReport`: `reported_player_id` is the message's SENDER,
|
||||
* read from the `message` table, because the body names no player. The thread the client
|
||||
* also posts is not kept — a message id is unique across threads, so it is the whole
|
||||
* reference.
|
||||
*/
|
||||
chat_message_id: number | null
|
||||
/**
|
||||
* The moderator who LIFTED the ban that once stood on this row, or NULL when none has
|
||||
* been lifted — including while one stands: a re-ban clears it, with `unbanned_at`. A
|
||||
* lift wipes the four ban columns above, so without this pair a lifted ban is
|
||||
* indistinguishable from a report nobody acted on, and the staff panel offered to ban
|
||||
* again. Read per row by the panel; nothing filters on it.
|
||||
*/
|
||||
unbanned_by_player_id: number | null
|
||||
/** ISO-8601 UTC instant the ban was lifted; NULL with `unbanned_by_player_id`. */
|
||||
unbanned_at: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,12 +95,6 @@ export interface NewReport {
|
||||
roomInstanceType?: string | null
|
||||
/** Set only when reporting a player EVENT; absent on an ordinary player report. */
|
||||
eventId?: number | null
|
||||
/** Set only when reporting an INVENTION; never set alongside `eventId`. */
|
||||
inventionId?: number | null
|
||||
/** Set only when reporting a CUSTOM AVATAR ITEM; never set alongside the two above. */
|
||||
customAvatarItemId?: string | null
|
||||
/** Set only when reporting a CHAT MESSAGE; never set alongside the three above. */
|
||||
chatMessageId?: number | null
|
||||
}
|
||||
|
||||
/** Record a submitted report, returning the stored row (with its assigned id). */
|
||||
@@ -183,8 +104,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
|
||||
`INSERT INTO report (
|
||||
reporter_player_id, reported_player_id, report_category, details,
|
||||
height_reporter, height_reported, room_id, room_instance_type, created_at,
|
||||
event_id, invention_id, custom_avatar_item_id, chat_message_id
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
|
||||
event_id
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
@@ -197,10 +118,7 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
|
||||
input.roomId ?? null,
|
||||
input.roomInstanceType ?? null,
|
||||
new Date().toISOString(),
|
||||
input.eventId ?? null,
|
||||
input.inventionId ?? null,
|
||||
input.customAvatarItemId ?? null,
|
||||
input.chatMessageId ?? null
|
||||
input.eventId ?? null
|
||||
)
|
||||
.first<ReportRow>()
|
||||
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
||||
@@ -208,11 +126,7 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
|
||||
return row!
|
||||
}
|
||||
|
||||
/**
|
||||
* Every report filed against a player, newest first — unpaged, because it is the whole
|
||||
* history a moderator reads on one account (`www`: `GET /api/staff/players/:id`), not a
|
||||
* feed. `searchReports` is the paged, filtered form for looking across accounts.
|
||||
*/
|
||||
/** Every report filed against a player, newest first. Backs a future moderation view. */
|
||||
export async function getReportsAgainst(db: D1Database, playerId: number): Promise<ReportRow[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT * FROM report WHERE reported_player_id = ?1 ORDER BY id DESC')
|
||||
@@ -252,9 +166,9 @@ export async function getActiveBan(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a player is banned right now. The hot-path form of `getActiveBan`, for a caller
|
||||
* that has nothing to say about WHICH report did it. `moderationBlockDetails` is the
|
||||
* caller that does, and reads `getActiveBan` itself.
|
||||
* Whether a player is banned right now. The hot-path form of `getActiveBan` — `match`
|
||||
* calls it on every matchmake and `auth` on every token grant, and neither has anything
|
||||
* to say about WHICH report did it.
|
||||
*/
|
||||
export async function isPlayerBanned(
|
||||
db: D1Database,
|
||||
@@ -270,17 +184,6 @@ export async function isPlayerBanned(
|
||||
* permanent ban. Passing `banned: false` lifts the ban and clears the expiry, leaving the
|
||||
* report itself intact.
|
||||
*
|
||||
* `bannedBy` is the acting moderator, and is recorded alongside `banned_at` — the instant
|
||||
* the ban was handed down, which is NOT the report's `created_at` and is what the client's
|
||||
* block screen counts from. A lift clears all four columns together: an unbanned row must
|
||||
* not keep an audit trail saying a ban runs from somewhere, and `banned = 0` with a
|
||||
* `banned_at` still set would read as a ban to anything checking the timestamp.
|
||||
*
|
||||
* A lift instead SIGNS `unbanned_by_player_id`/`unbanned_at` with the same `bannedBy` and
|
||||
* the same instant, and a re-ban clears those two — a standing ban has not been lifted.
|
||||
* That is what lets the staff panel tell "a colleague let this player back in" from
|
||||
* "nobody has acted on this report", so a second moderator doesn't undo the first.
|
||||
*
|
||||
* Returns the updated row, or null when there is no report with that id — so the caller
|
||||
* can tell "banned" from "banned nobody" (wrangler's `d1 execute --json` reports no
|
||||
* changes count, hence RETURNING).
|
||||
@@ -288,299 +191,11 @@ export async function isPlayerBanned(
|
||||
export async function banFromReport(
|
||||
db: D1Database,
|
||||
reportId: number,
|
||||
options: { banned?: boolean; banExpires?: string | null; bannedBy?: number | null } = {}
|
||||
options: { banned?: boolean; banExpires?: string | null } = {}
|
||||
): Promise<ReportRow | null> {
|
||||
const banned = options.banned ?? true
|
||||
const now = new Date().toISOString()
|
||||
return db
|
||||
.prepare(
|
||||
`UPDATE report
|
||||
SET banned = ?2, ban_expires = ?3, banned_by_player_id = ?4, banned_at = ?5,
|
||||
unbanned_by_player_id = ?6, unbanned_at = ?7
|
||||
WHERE id = ?1
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
reportId,
|
||||
banned ? 1 : 0,
|
||||
banned ? (options.banExpires ?? null) : null,
|
||||
banned ? (options.bannedBy ?? null) : null,
|
||||
banned ? now : null,
|
||||
banned ? null : (options.bannedBy ?? null),
|
||||
banned ? null : now
|
||||
)
|
||||
.prepare('UPDATE report SET banned = ?2, ban_expires = ?3 WHERE id = ?1 RETURNING *')
|
||||
.bind(reportId, banned ? 1 : 0, banned ? (options.banExpires ?? null) : null)
|
||||
.first<ReportRow>()
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of reports matching a moderator's filters — the staff panel's search
|
||||
* (`www`: `GET /api/staff/reports`).
|
||||
*
|
||||
* Every filter is optional and ANDed; an empty filter set is the whole table, newest
|
||||
* first. `ORDER BY id DESC` rather than by `created_at`: the id is AUTOINCREMENT, so it is
|
||||
* already chronological, and it is the primary key — ordering by the timestamp column,
|
||||
* which is unindexed, would sort the whole result set. The date window is applied to
|
||||
* `created_at` (an ISO-8601 string, so a lexical comparison IS a chronological one) and
|
||||
* `to` is EXCLUSIVE, which is what makes a day-boundary window composable.
|
||||
*
|
||||
* `banned` filters on the FLAG, not on whether a ban is in force: a moderator reviewing
|
||||
* what has been actioned wants the expired ones too (`getBansInForce` is the other
|
||||
* question). The count is a second statement rather than a window function, so the caller
|
||||
* can page without re-deriving the total — it counts the same predicate, unpaged.
|
||||
*/
|
||||
export interface ReportSearch {
|
||||
reportedPlayerId?: number | null
|
||||
reporterPlayerId?: number | null
|
||||
reportCategory?: number | null
|
||||
/** true = banned rows only, false = unbanned only, null/undefined = both. */
|
||||
banned?: boolean | null
|
||||
/** ISO-8601; inclusive lower bound on `created_at`. */
|
||||
from?: string | null
|
||||
/** ISO-8601; EXCLUSIVE upper bound on `created_at`. */
|
||||
to?: string | null
|
||||
}
|
||||
|
||||
/** A page of search results, plus how many rows the filters match in total. */
|
||||
export interface ReportPage {
|
||||
reports: ReportRow[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export async function searchReports(
|
||||
db: D1Database,
|
||||
filters: ReportSearch = {},
|
||||
page: { skip?: number; take?: number } = {}
|
||||
): Promise<ReportPage> {
|
||||
// Built as a parallel list of clauses and binds so a filter is added in one place and
|
||||
// can't drift between the page query and the count query, which share both.
|
||||
const clauses: string[] = []
|
||||
const binds: Array<number | string> = []
|
||||
const where = (sql: string, value: number | string) => {
|
||||
binds.push(value)
|
||||
clauses.push(sql.replace('?', `?${binds.length}`))
|
||||
}
|
||||
|
||||
if (filters.reportedPlayerId != null) where('reported_player_id = ?', filters.reportedPlayerId)
|
||||
if (filters.reporterPlayerId != null) where('reporter_player_id = ?', filters.reporterPlayerId)
|
||||
if (filters.reportCategory != null) where('report_category = ?', filters.reportCategory)
|
||||
if (filters.banned != null) where('banned = ?', filters.banned ? 1 : 0)
|
||||
if (filters.from) where('created_at >= ?', filters.from)
|
||||
if (filters.to) where('created_at < ?', filters.to)
|
||||
|
||||
const predicate = clauses.length === 0 ? '' : ` WHERE ${clauses.join(' AND ')}`
|
||||
// Clamped rather than trusted: `take` reaches this from a query string, and an
|
||||
// unbounded one would hand a moderator's browser the entire table.
|
||||
const take = Math.min(Math.max(page.take ?? 50, 1), 200)
|
||||
const skip = Math.max(page.skip ?? 0, 0)
|
||||
|
||||
const [rows, count] = await db.batch<ReportRow | { total: number }>([
|
||||
db
|
||||
.prepare(
|
||||
`SELECT * FROM report${predicate} ORDER BY id DESC LIMIT ?${binds.length + 1} OFFSET ?${binds.length + 2}`
|
||||
)
|
||||
.bind(...binds, take, skip),
|
||||
db.prepare(`SELECT COUNT(*) AS total FROM report${predicate}`).bind(...binds),
|
||||
])
|
||||
|
||||
return {
|
||||
reports: rows.results as ReportRow[],
|
||||
total: (count.results as Array<{ total: number }>)[0]?.total ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** One report by id, or null. The detail behind a search row. */
|
||||
export async function getReportById(db: D1Database, reportId: number): Promise<ReportRow | null> {
|
||||
return db.prepare('SELECT * FROM report WHERE id = ?1').bind(reportId).first<ReportRow>()
|
||||
}
|
||||
|
||||
/** A player who has collected reports, as the staff panel's triage list shows them. */
|
||||
export interface ReportedPlayerTally {
|
||||
playerId: number
|
||||
/** Reports filed against them inside the window. */
|
||||
reports: number
|
||||
/**
|
||||
* How many DIFFERENT accounts filed those reports. The number that actually means
|
||||
* something: one player filing forty reports against someone they are feuding with
|
||||
* should not outrank four unconnected players filing one each.
|
||||
*/
|
||||
distinctReporters: number
|
||||
/** `created_at` of the most recent report in the window. */
|
||||
lastReportAt: string
|
||||
/** Whether a ban is in force against them right now — so the list can skip the done ones. */
|
||||
bannedNow: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The players with the most reports against them — the staff panel's triage list
|
||||
* (`www`: `GET /api/staff/reports/top-reported`).
|
||||
*
|
||||
* WINDOWED by default (30 days), because the unwindowed version ossifies: an account that
|
||||
* collected reports a year ago and stopped would sit at the top forever, above whoever is
|
||||
* a problem this week. Pass `sinceDays: null` for all time.
|
||||
*
|
||||
* Ranked by distinct reporters FIRST and raw count second, for the reason
|
||||
* `distinctReporters` exists at all. `minReports` keeps the long tail of single reports
|
||||
* out — almost every report is a one-off, and a list of them is not a triage list.
|
||||
*
|
||||
* `GROUP BY reported_player_id` rides `idx_report_reported`. The in-force ban is decided
|
||||
* in the same statement (a correlated EXISTS over the partial `idx_report_banned`) rather
|
||||
* than with a round trip per player.
|
||||
*/
|
||||
export async function getTopReported(
|
||||
db: D1Database,
|
||||
options: { sinceDays?: number | null; minReports?: number; take?: number; now?: Date } = {}
|
||||
): Promise<ReportedPlayerTally[]> {
|
||||
const now = options.now ?? new Date()
|
||||
const sinceDays = options.sinceDays === undefined ? 30 : options.sinceDays
|
||||
// An all-time window is expressed as a floor no timestamp can precede, so the SQL keeps
|
||||
// one shape rather than growing a second predicate.
|
||||
const since =
|
||||
sinceDays === null
|
||||
? '0000-01-01T00:00:00.000Z'
|
||||
: new Date(now.getTime() - sinceDays * 86_400_000).toISOString()
|
||||
const minReports = Math.max(options.minReports ?? 3, 1)
|
||||
const take = Math.min(Math.max(options.take ?? 50, 1), 200)
|
||||
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT
|
||||
r.reported_player_id AS playerId,
|
||||
COUNT(*) AS reports,
|
||||
COUNT(DISTINCT r.reporter_player_id) AS distinctReporters,
|
||||
MAX(r.created_at) AS lastReportAt,
|
||||
EXISTS (
|
||||
SELECT 1 FROM report b
|
||||
WHERE b.reported_player_id = r.reported_player_id AND b.banned = 1
|
||||
AND (b.ban_expires IS NULL OR b.ban_expires > ?2)
|
||||
) AS bannedNow
|
||||
FROM report r
|
||||
WHERE r.created_at >= ?1
|
||||
GROUP BY r.reported_player_id
|
||||
HAVING COUNT(*) >= ?3
|
||||
ORDER BY distinctReporters DESC, reports DESC, lastReportAt DESC
|
||||
LIMIT ?4`
|
||||
)
|
||||
.bind(since, now.toISOString(), minReports, take)
|
||||
.all<Omit<ReportedPlayerTally, 'bannedNow'> & { bannedNow: number }>()
|
||||
|
||||
return results.map((row) => ({ ...row, bannedNow: row.bannedNow === 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every ban in force right now, MOST RECENTLY HANDED DOWN FIRST — the staff panel's
|
||||
* standing-bans list (`www`: `GET /api/staff/bans`).
|
||||
*
|
||||
* "In force" is `getActiveBan`'s test applied to the whole table rather than to one
|
||||
* player: a row whose `ban_expires` has passed has served its time and is not listed,
|
||||
* though it stays as the record that it happened. A player with several in force appears
|
||||
* once per ban — each is a separate report, and which report justified which ban is the
|
||||
* point of the list.
|
||||
*
|
||||
* Ordered by WHEN THE BAN LANDED because the list's job is catching mistakes: the ban most
|
||||
* likely to be wrong is the one just handed down, and a moderator who has slipped goes
|
||||
* looking for it at the top. Ordering by severity instead (longest-lasting first, as this
|
||||
* did) buries a fresh one-day ban under every permanent ban ever issued — exactly the
|
||||
* wrong way round for the one read this list exists to serve.
|
||||
*
|
||||
* `COALESCE(banned_at, created_at)`: a ban set before 0020_report_ban_audit.sql added
|
||||
* `banned_at` has only its report's date to sort by, which is the same fallback the block
|
||||
* screen uses. Without the coalesce those rows would sort as NULL — all together at one
|
||||
* end, in no order at all.
|
||||
*/
|
||||
export async function getBansInForce(
|
||||
db: D1Database,
|
||||
now: Date = new Date(),
|
||||
take = 200
|
||||
): Promise<ReportRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM report
|
||||
WHERE banned = 1 AND (ban_expires IS NULL OR ban_expires > ?1)
|
||||
ORDER BY COALESCE(banned_at, created_at) DESC, id DESC
|
||||
LIMIT ?2`
|
||||
)
|
||||
.bind(now.toISOString(), Math.min(Math.max(take, 1), 500))
|
||||
.all<ReportRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
// ---- Block details ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `Duration` on a permanent ban. The client's field is a 32-bit int of seconds that PAIRS
|
||||
* with `TimeoutStartedAt` — start + duration is the end of the block — so a ban with no
|
||||
* end gets the largest value the field holds, 68 years past its start.
|
||||
*/
|
||||
export const PERMANENT_BAN_DURATION = 2_147_483_647
|
||||
|
||||
/**
|
||||
* The "not blocked" answer — the reference server's stub `ReturnModerationBlockDetails()`,
|
||||
* widened to every key the client's `ModerationBlockDetail` decoder names (16 on the wire;
|
||||
* the 2025 build's formatter reads them all). The ones past the stub's nine are the block
|
||||
* kinds and screen dressings this server never uses — a device ban, a warning, the
|
||||
* vote-kick reason, an associated account, the creator code of conduct, the top/bottom
|
||||
* message overrides — so they carry their "none" values on every answer.
|
||||
*/
|
||||
export const NOT_BLOCKED = {
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsHostKick: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
IsBan: false,
|
||||
IsVoiceModAutoban: false,
|
||||
IsDeviceBan: false,
|
||||
IsWarning: false,
|
||||
VoteKickReason: null,
|
||||
TimeoutStartedAt: null,
|
||||
AssociatedAccountUsername: null,
|
||||
ShowCreatorCodeOfConduct: false,
|
||||
TopMessageOverride: null,
|
||||
BottomMessageOverride: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* The block details for a ban in force — the `report` row a moderator set `banned` on.
|
||||
*
|
||||
* Shared by the two places a player meets an account ban: `api`'s `moderationBlockDetails`
|
||||
* (the screen at sign-in) and `www`'s live `ModerationKick` frame (the screen when the ban is
|
||||
* handed down mid-session). Built once here so both screens describe the same ban.
|
||||
*
|
||||
* `Duration` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
|
||||
* start for the duration. The start is `banned_at`, the instant the ban was handed down
|
||||
* (see 0020_report_ban_audit.sql), and the duration is the seconds from there to
|
||||
* `ban_expires`, so the two sum to the expiry; or `PERMANENT_BAN_DURATION` when there is
|
||||
* none.
|
||||
*
|
||||
* It falls back to the report's `created_at` for a row banned before that column existed.
|
||||
* The two can be months apart, and using `created_at` as the start — which is what this
|
||||
* did before there was anything else to use — misreports both halves of the pair: a 7-day
|
||||
* ban applied to a 30-day-old report told the player their block began a month ago and
|
||||
* ended three weeks ago. Every pre-migration row still reads exactly as it used to, which
|
||||
* is the point of the fallback rather than a coalesce to now.
|
||||
*
|
||||
* The category is the one the report was
|
||||
* filed under, so the client's ban screen names the reason. `Message` is a fixed "Rule
|
||||
* violation" rather than the report's `details` — those are the REPORTER's words, and the
|
||||
* banned player isn't shown them, for the same reason `PlayerIdReporter` stays null: the
|
||||
* reporter is not a host who kicked them, and naming them would tell the banned player who
|
||||
* reported them. Everything else keeps its `NOT_BLOCKED` value: the other block kinds and
|
||||
* screen dressings, none of which this server hands out.
|
||||
*/
|
||||
export function banBlockDetails(ban: ReportRow) {
|
||||
const startedAtIso = ban.banned_at ?? ban.created_at
|
||||
const startedAt = Date.parse(startedAtIso)
|
||||
const duration =
|
||||
ban.ban_expires === null
|
||||
? PERMANENT_BAN_DURATION
|
||||
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - startedAt) / 1000))
|
||||
return {
|
||||
...NOT_BLOCKED,
|
||||
ReportCategory: ban.report_category,
|
||||
Duration: duration,
|
||||
IsBan: true,
|
||||
Message: 'Rule violation',
|
||||
TimeoutStartedAt: startedAtIso,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
/**
|
||||
* Player reputation — the cheer counters on a profile — and the daily cheer credit that
|
||||
* pays for handing one out, on the shared `recflare` D1 database.
|
||||
*
|
||||
* Two tables, because they answer two different questions:
|
||||
*
|
||||
* - `reputation` is what a player has RECEIVED: one counter per cheer category, plus the
|
||||
* assorted profile numbers the DTO carries. One row per account, created the first time
|
||||
* somebody cheers them — a missing row means "nobody has cheered them yet", which is
|
||||
* exactly the all-zero default the reputation endpoints already served, so reads fall
|
||||
* back to it rather than inserting on a GET.
|
||||
* - `player_cheer` is what a player has left to GIVE: a credit that refills to
|
||||
* {@link DAILY_CHEER_CREDIT} once the window in `created` is a day old. One row per
|
||||
* account, created the first time they spend one.
|
||||
*
|
||||
* One of the client's fields is deliberately NOT a column. `CheerCredit` sits alongside
|
||||
* the counters in the client's record but is `player_cheer.cheers_left` with the rollover
|
||||
* applied — storing it twice would let the number a player reads drift from the one the
|
||||
* spend checks. `SelectedCheer` (the cheer pinned to the profile, set by
|
||||
* `POST /api/PlayerCheer/v1/SetSelectedCheer`) and `IsCheerful` (a profile flag every
|
||||
* reference serves as true) ARE columns, added in 0014 — the `ReputationUpdate` frame is
|
||||
* the record trimmed, nothing more, so both come off the row.
|
||||
*
|
||||
* The `api` worker owns the schema/migrations (migrations/0013_reputation.sql and
|
||||
* 0014_reputation_selected_cheer.sql, applied under its own `migrations_table` so they
|
||||
* don't clash with the other workers' migrations that share the database).
|
||||
*/
|
||||
|
||||
import { bindPlaceholders, chunkForBinds } from '@repo/domain'
|
||||
|
||||
/** Schema DDL (mirror of migrations/0013_reputation.sql + 0014, folded into one CREATE). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS reputation (
|
||||
account_id INTEGER PRIMARY KEY,
|
||||
noteriety INTEGER NOT NULL DEFAULT 0,
|
||||
cheer_general INTEGER NOT NULL DEFAULT 0,
|
||||
cheer_helpful INTEGER NOT NULL DEFAULT 0,
|
||||
cheer_creative INTEGER NOT NULL DEFAULT 0,
|
||||
cheer_great_host INTEGER NOT NULL DEFAULT 0,
|
||||
cheer_sportsman INTEGER NOT NULL DEFAULT 0,
|
||||
subscriber_count INTEGER NOT NULL DEFAULT 0,
|
||||
subscribed_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_cheerful INTEGER NOT NULL DEFAULT 1,
|
||||
selected_cheer INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS player_cheer (
|
||||
player_id INTEGER PRIMARY KEY,
|
||||
cheers_left INTEGER NOT NULL,
|
||||
created TEXT NOT NULL
|
||||
)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* The cheer categories the client posts as `CheerCategory`. The gaps are the client's —
|
||||
* the enum steps by ten, so it can grow without renumbering.
|
||||
*/
|
||||
export enum CheerCategory {
|
||||
None = -1,
|
||||
General = 0,
|
||||
Helpful = 10,
|
||||
Sportmanship = 20,
|
||||
GreatHost = 30,
|
||||
Creative = 40,
|
||||
}
|
||||
|
||||
/**
|
||||
* The `reputation` column each category counts into. Doubles as the whitelist the spend
|
||||
* interpolates into its SQL: a category that isn't a key here never reaches the query.
|
||||
* `None` is absent deliberately — it is the client's "no category", not a counter.
|
||||
*/
|
||||
const CHEER_COLUMN: Partial<Record<CheerCategory, string>> = {
|
||||
[CheerCategory.General]: 'cheer_general',
|
||||
[CheerCategory.Helpful]: 'cheer_helpful',
|
||||
[CheerCategory.Sportmanship]: 'cheer_sportsman',
|
||||
[CheerCategory.GreatHost]: 'cheer_great_host',
|
||||
[CheerCategory.Creative]: 'cheer_creative',
|
||||
}
|
||||
|
||||
/** Whether `value` names a category that counts — i.e. anything but `None`. */
|
||||
export function isCheerCategory(value: number): value is CheerCategory {
|
||||
return value in CHEER_COLUMN
|
||||
}
|
||||
|
||||
/** How many cheers a player may hand out per window. */
|
||||
export const DAILY_CHEER_CREDIT = 20
|
||||
|
||||
/** How long a credit window lasts before it refills. */
|
||||
export const CHEER_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/** A stored reputation row (snake_case columns, one row per account). */
|
||||
interface ReputationRow {
|
||||
account_id: number
|
||||
noteriety: number
|
||||
cheer_general: number
|
||||
cheer_helpful: number
|
||||
cheer_creative: number
|
||||
cheer_great_host: number
|
||||
cheer_sportsman: number
|
||||
subscriber_count: number
|
||||
subscribed_count: number
|
||||
/** SQLite boolean: 0 / 1. */
|
||||
is_cheerful: number
|
||||
selected_cheer: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A player's reputation as the client's DTO renders it — and, trimmed, as the
|
||||
* `ReputationUpdate` frame carries it.
|
||||
*
|
||||
* `Noteriety` (the reference's spelling), `SubscriberCount` and `SubscribedCount` are
|
||||
* columns nothing writes yet. `IsCheerful` is a column nothing writes either, defaulted
|
||||
* true like every reference serves it. `SelectedCheer` is the pinned cheer, 0 = none.
|
||||
*/
|
||||
export interface Reputation {
|
||||
AccountId: number
|
||||
IsCheerful: boolean
|
||||
Noteriety: number
|
||||
SelectedCheer: number
|
||||
CheerCredit: number
|
||||
CheerGeneral: number
|
||||
CheerHelpful: number
|
||||
CheerCreative: number
|
||||
CheerGreatHost: number
|
||||
CheerSportsman: number
|
||||
SubscriberCount: number
|
||||
SubscribedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* What a player with no row has: nobody has cheered them, and they hold their full credit.
|
||||
* `credit` is passed in rather than defaulted because a player can have spent cheers
|
||||
* without having received any — the two tables are independent.
|
||||
*/
|
||||
export function defaultReputation(accountId: number, credit = DAILY_CHEER_CREDIT): Reputation {
|
||||
return {
|
||||
AccountId: accountId,
|
||||
IsCheerful: true,
|
||||
Noteriety: 0,
|
||||
SelectedCheer: 0,
|
||||
CheerCredit: credit,
|
||||
CheerGeneral: 0,
|
||||
CheerHelpful: 0,
|
||||
CheerCreative: 0,
|
||||
CheerGreatHost: 0,
|
||||
CheerSportsman: 0,
|
||||
SubscriberCount: 0,
|
||||
SubscribedCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Project a stored row onto the DTO, with the credit read from `player_cheer`. */
|
||||
function toReputation(row: ReputationRow, credit: number): Reputation {
|
||||
return {
|
||||
AccountId: row.account_id,
|
||||
IsCheerful: row.is_cheerful !== 0,
|
||||
Noteriety: row.noteriety,
|
||||
SelectedCheer: row.selected_cheer,
|
||||
CheerCredit: credit,
|
||||
CheerGeneral: row.cheer_general,
|
||||
CheerHelpful: row.cheer_helpful,
|
||||
CheerCreative: row.cheer_creative,
|
||||
CheerGreatHost: row.cheer_great_host,
|
||||
CheerSportsman: row.cheer_sportsman,
|
||||
SubscriberCount: row.subscriber_count,
|
||||
SubscribedCount: row.subscribed_count,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant a credit window has to have started AFTER for the stored `cheers_left` to
|
||||
* still apply. Anything at or before it has rolled over. ISO-8601 UTC is fixed-width, so
|
||||
* SQLite's string comparison orders these correctly — no date functions needed.
|
||||
*/
|
||||
function windowCutoff(now: Date): string {
|
||||
return new Date(now.getTime() - CHEER_WINDOW_MS).toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* How many cheers each of `playerIds` has left to give, in the order asked — a read, so
|
||||
* a window that has rolled over reads as a full credit WITHOUT writing the reset back.
|
||||
* The reset is the spend's job; doing it here would refill a player's credit every time
|
||||
* somebody looked at their profile.
|
||||
*/
|
||||
export async function getCheerCredits(
|
||||
db: D1Database,
|
||||
playerIds: number[],
|
||||
now: Date = new Date()
|
||||
): Promise<Map<number, number>> {
|
||||
const credits = new Map<number, number>()
|
||||
if (playerIds.length === 0) return credits
|
||||
// The window cutoff is `?1`, so the ids start at `?2` and each chunk is one short of
|
||||
// D1's bind cap — a bulk read over a friends list runs well past it.
|
||||
const pages = await Promise.all(
|
||||
chunkForBinds(playerIds, 1).map((chunk) =>
|
||||
db
|
||||
.prepare(
|
||||
`SELECT player_id, cheers_left FROM player_cheer
|
||||
WHERE created > ?1 AND player_id IN (${bindPlaceholders(chunk, 1)})`
|
||||
)
|
||||
.bind(windowCutoff(now), ...chunk)
|
||||
.all<{ player_id: number; cheers_left: number }>()
|
||||
)
|
||||
)
|
||||
for (const row of pages.flatMap((page) => page.results)) {
|
||||
credits.set(row.player_id, row.cheers_left)
|
||||
}
|
||||
return credits
|
||||
}
|
||||
|
||||
/** One player's remaining cheer credit (see {@link getCheerCredits} — also a pure read). */
|
||||
export async function getCheerCredit(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
now: Date = new Date()
|
||||
): Promise<number> {
|
||||
const credits = await getCheerCredits(db, [playerId], now)
|
||||
return credits.get(playerId) ?? DAILY_CHEER_CREDIT
|
||||
}
|
||||
|
||||
/**
|
||||
* Reputations for a list of ids, in the order asked and one per id — the bulk lookups
|
||||
* render a profile card per entry, so an id with no row still gets its default rather
|
||||
* than being dropped from the list.
|
||||
*/
|
||||
export async function getReputations(
|
||||
db: D1Database,
|
||||
accountIds: number[],
|
||||
now: Date = new Date()
|
||||
): Promise<Reputation[]> {
|
||||
if (accountIds.length === 0) return []
|
||||
const [pages, credits] = await Promise.all([
|
||||
// Chunked: the friends list behind `/api/playerReputation/v2/bulk` runs past D1's
|
||||
// bind cap, and an unchunked statement fails the whole lookup rather than part of it.
|
||||
Promise.all(
|
||||
chunkForBinds(accountIds).map((chunk) =>
|
||||
db
|
||||
.prepare(`SELECT * FROM reputation WHERE account_id IN (${bindPlaceholders(chunk)})`)
|
||||
.bind(...chunk)
|
||||
.all<ReputationRow>()
|
||||
)
|
||||
),
|
||||
getCheerCredits(db, accountIds, now),
|
||||
])
|
||||
const stored = new Map(pages.flatMap((page) => page.results).map((r) => [r.account_id, r]))
|
||||
return accountIds.map((id) => {
|
||||
const credit = credits.get(id) ?? DAILY_CHEER_CREDIT
|
||||
const row = stored.get(id)
|
||||
return row === undefined ? defaultReputation(id, credit) : toReputation(row, credit)
|
||||
})
|
||||
}
|
||||
|
||||
/** One player's reputation, defaulted when nobody has cheered them yet. */
|
||||
export async function getReputation(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
now: Date = new Date()
|
||||
): Promise<Reputation> {
|
||||
const [reputation] = await getReputations(db, [accountId], now)
|
||||
return reputation!
|
||||
}
|
||||
|
||||
/**
|
||||
* Take one cheer out of a player's daily credit, resolving the credit they have left, or
|
||||
* null when they had none to spend.
|
||||
*
|
||||
* One statement, so two cheers fired off together can't both read the same stale credit
|
||||
* and write it back — the client lets a player cheer several people in a row. The three
|
||||
* cases fold into the upsert:
|
||||
*
|
||||
* - no row: insert one at `DAILY_CHEER_CREDIT - 1`, window starting now;
|
||||
* - the window rolled over (`created` at or before the cutoff): reset to
|
||||
* `DAILY_CHEER_CREDIT - 1` and start a fresh window, which is what makes the credit
|
||||
* refill lazily rather than needing a cron;
|
||||
* - the window is live: decrement, keeping the window's original start so a player who
|
||||
* spends all day still refills 24h after their FIRST cheer, not their last.
|
||||
*
|
||||
* The `WHERE` on the update is the refusal: a live window with nothing left updates no
|
||||
* row, so `RETURNING` yields nothing and the caller answers "out of cheers".
|
||||
*/
|
||||
export async function spendCheerCredit(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
now: Date = new Date()
|
||||
): Promise<number | null> {
|
||||
const cutoff = windowCutoff(now)
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO player_cheer (player_id, cheers_left, created) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT (player_id) DO UPDATE SET
|
||||
cheers_left = CASE WHEN player_cheer.created <= ?4
|
||||
THEN ?2 ELSE player_cheer.cheers_left - 1 END,
|
||||
created = CASE WHEN player_cheer.created <= ?4
|
||||
THEN ?3 ELSE player_cheer.created END
|
||||
WHERE player_cheer.created <= ?4 OR player_cheer.cheers_left > 0
|
||||
RETURNING cheers_left`
|
||||
)
|
||||
.bind(playerId, DAILY_CHEER_CREDIT - 1, now.toISOString(), cutoff)
|
||||
.first<{ cheers_left: number }>()
|
||||
return row === null ? null : row.cheers_left
|
||||
}
|
||||
|
||||
/**
|
||||
* Count a received cheer against `accountId`'s category counter, returning the reputation
|
||||
* they now hold. Creates the row on the first cheer they ever receive.
|
||||
*
|
||||
* The column is looked up in {@link CHEER_COLUMN} rather than built from the category, so
|
||||
* only the five known names can reach the SQL; an unknown category is rejected by the
|
||||
* route before it gets here.
|
||||
*/
|
||||
export async function addCheer(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
category: CheerCategory,
|
||||
now: Date = new Date()
|
||||
): Promise<Reputation> {
|
||||
const column = CHEER_COLUMN[category]
|
||||
if (column === undefined) throw new Error(`unknown cheer category ${category}`)
|
||||
const [row, credit] = await Promise.all([
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO reputation (account_id, ${column}) VALUES (?1, 1)
|
||||
ON CONFLICT (account_id) DO UPDATE SET ${column} = reputation.${column} + 1
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(accountId)
|
||||
.first<ReputationRow>(),
|
||||
getCheerCredit(db, accountId, now),
|
||||
])
|
||||
// RETURNING always yields the upserted row; the non-null assert keeps the caller from
|
||||
// having to handle an impossible null.
|
||||
return toReputation(row!, credit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin `category` as `accountId`'s selected cheer — the badge the client shows on their
|
||||
* profile — returning the reputation they now hold. `None` (-1) unpins it, stored as 0 the
|
||||
* way the DTO reads "nothing selected". Creates the row if nobody has cheered them yet:
|
||||
* a player can pin a badge before ever receiving a cheer.
|
||||
*/
|
||||
export async function setSelectedCheer(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
category: CheerCategory,
|
||||
now: Date = new Date()
|
||||
): Promise<Reputation> {
|
||||
const selected = category === CheerCategory.None ? 0 : category
|
||||
const [row, credit] = await Promise.all([
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO reputation (account_id, selected_cheer) VALUES (?1, ?2)
|
||||
ON CONFLICT (account_id) DO UPDATE SET selected_cheer = ?2
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(accountId, selected)
|
||||
.first<ReputationRow>(),
|
||||
getCheerCredit(db, accountId, now),
|
||||
])
|
||||
return toReputation(row!, credit)
|
||||
}
|
||||
+103
-1319
@@ -4,33 +4,17 @@ import { describeRoute } from 'hono-openapi'
|
||||
import {
|
||||
CURRENT_OUTFIT_SLOT,
|
||||
getOutfit,
|
||||
getOutfits,
|
||||
getOutfitsByAccounts,
|
||||
inventionDescriptionRejection,
|
||||
inventionLongDescriptionRejection,
|
||||
inventionNameRejection,
|
||||
inventionTagRejection,
|
||||
MAX_BULK_OUTFIT_ACCOUNTS,
|
||||
setOutfit,
|
||||
} from '@repo/domain'
|
||||
|
||||
import {
|
||||
createCustomAvatarItem,
|
||||
deleteCustomAvatarItem,
|
||||
getCustomAvatarItem,
|
||||
getCustomAvatarItems,
|
||||
isQuestAssetTarget,
|
||||
listCustomAvatarItemsByCreator,
|
||||
listFeaturedCustomAvatarItems,
|
||||
listHotCustomAvatarItems,
|
||||
searchCustomAvatarItems,
|
||||
toQuestCustomAvatarItem,
|
||||
updateCustomAvatarItem,
|
||||
} from '../custom-avatar-items-db'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
createInvention,
|
||||
deleteInvention,
|
||||
getFeaturedInventions,
|
||||
getInventionById,
|
||||
getInventionsByIds,
|
||||
@@ -40,280 +24,75 @@ import {
|
||||
getInventionVersion,
|
||||
getMyInventions,
|
||||
getTopInventions,
|
||||
INVENTION_TAG_RESULT,
|
||||
inventionDeleteResult,
|
||||
inventionSaveV9Failure,
|
||||
isInventionCheered,
|
||||
normalizeInventionTags,
|
||||
ownsAllInventions,
|
||||
parsePermissionLevel,
|
||||
publishInvention,
|
||||
searchInventions,
|
||||
setInventionCheer,
|
||||
setInventionPrice,
|
||||
setInventionTags,
|
||||
toSaveResult,
|
||||
toSaveResultV9,
|
||||
updateInvention,
|
||||
} from '../inventions-db'
|
||||
import {
|
||||
AUTHED,
|
||||
BareBoolean,
|
||||
BareInteger,
|
||||
BulkCustomAvatarItemsRequest,
|
||||
CreateCustomAvatarItemRequest,
|
||||
CustomAvatarItemList,
|
||||
CustomAvatarItemReportRequest,
|
||||
CustomAvatarItemResponse,
|
||||
CustomAvatarItemsPage,
|
||||
DeleteInventionRequest,
|
||||
ErrorResponse,
|
||||
form,
|
||||
GeneratedGift,
|
||||
GenerateGiftRequest,
|
||||
idParam,
|
||||
intQuery,
|
||||
InventionCheerRequest,
|
||||
InventionDeleteResult,
|
||||
InventionDetails,
|
||||
InventionDto,
|
||||
InventionPersonalDetails,
|
||||
InventionReportRequest,
|
||||
InventionSaveResult,
|
||||
InventionSaveV9Result,
|
||||
InventionVersionDto,
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
LegacyAvatarItemSaves,
|
||||
OPTIONAL_AUTHED,
|
||||
OutfitSaveResponse,
|
||||
OutfitsBulkRequest,
|
||||
OutfitsBulkResponse,
|
||||
OutfitsMeRequest,
|
||||
OutfitsMeResponse,
|
||||
pageParams,
|
||||
PublishInventionRequest,
|
||||
SaveInventionRequest,
|
||||
SaveInventionV9Request,
|
||||
SetTagsRequest,
|
||||
SetTagsResponse,
|
||||
stringParam,
|
||||
stringQuery,
|
||||
SuccessErrorEnvelope,
|
||||
SuccessValueEnvelope,
|
||||
TagFilters,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UpdateCustomAvatarItemRequest,
|
||||
UpdateInventionMetadataRequest,
|
||||
UpdateInventionRequest,
|
||||
UpdatePriceRequest,
|
||||
} from '../openapi'
|
||||
import { createReport } from '../reports-db'
|
||||
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
import type { InventionTag, SavedInvention } from '../inventions-db'
|
||||
|
||||
/**
|
||||
* The most ids `POST /api/customAvatarItems/v1/bulk` will resolve. A batch over this answers
|
||||
* EMPTY rather than being truncated.
|
||||
*
|
||||
* Empty rather than the first 100, because a truncated answer is indistinguishable from the
|
||||
* items simply not existing — the client reads the items it got back, not the ids it asked
|
||||
* about, so it cannot tell a cut-off batch from a batch of misses and would cache the
|
||||
* difference. Nothing renders this many custom items at once, so a batch this size is the
|
||||
* client doing something other than filling a screen.
|
||||
*/
|
||||
const BULK_CUSTOM_AVATAR_ITEM_CAP = 100
|
||||
|
||||
/**
|
||||
* The ids `POST /api/customAvatarItems/v1/bulk` was asked to resolve. They ride as repeated
|
||||
* `customAvatarItemIds` form fields, and the same spelling is read off the query string
|
||||
* too — the client's exact encoding here has not been pinned down, so both are accepted
|
||||
* rather than guessing one and answering nothing when it's the other.
|
||||
*
|
||||
* Each value may itself be a comma-separated list, and blanks are dropped rather than
|
||||
* failing the request: a stray id must not cost the caller the rest of the batch. The order
|
||||
* asked for is preserved, since `getCustomAvatarItems` answers in it.
|
||||
*/
|
||||
async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
|
||||
const raw = [...(c.req.queries('customAvatarItemIds') ?? [])]
|
||||
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'customavataritemids')
|
||||
const posted = key === undefined ? [] : body[key]
|
||||
for (const value of Array.isArray(posted) ? posted : [posted]) {
|
||||
if (typeof value === 'string') raw.push(value)
|
||||
}
|
||||
return raw
|
||||
.flatMap((value) => value.split(','))
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v !== '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The `unityAssetTarget` `POST /api/customAvatarItems/v1/bulk` was asked for, read the way
|
||||
* {@link bulkCustomAvatarItemIds} reads the ids and for the same reason: off the query string
|
||||
* (where the GET reads carry it) or out of the form, whatever the casing. `parseBody` caches,
|
||||
* so reading the form a second time costs nothing.
|
||||
*/
|
||||
async function bulkUnityAssetTarget(c: Context<App>): Promise<string | undefined> {
|
||||
const queried = c.req.query('unityAssetTarget')
|
||||
if (queried !== undefined) return queried
|
||||
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'unityassettarget')
|
||||
const posted = key === undefined ? undefined : body[key]
|
||||
const value = Array.isArray(posted) ? posted[0] : posted
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
import type { SavedInvention } from '../inventions-db'
|
||||
|
||||
/**
|
||||
* The gate every invention write runs through: the caller must be signed in, the
|
||||
* invention must exist, and it must be theirs. Yields the loaded invention, or why not —
|
||||
* as a reason and the status it maps to, so that a caller answering an envelope can put
|
||||
* the reason where its client will read it instead of in a body that client can't parse.
|
||||
* {@link creatorsInvention} is the rendering the older routes want.
|
||||
*/
|
||||
async function creatorsInventionResult(
|
||||
c: Context<App>,
|
||||
inventionId: number
|
||||
): Promise<{ invention: SavedInvention } | { rejection: string; status: 400 | 401 | 403 | 404 }> {
|
||||
const playerId = await authedId(c)
|
||||
if (playerId === null) return { rejection: 'Unauthorized', status: 401 }
|
||||
if (Number.isNaN(inventionId)) return { rejection: 'inventionId is required', status: 400 }
|
||||
|
||||
const invention = await getInventionById(c.env.DB, inventionId)
|
||||
if (invention === null) return { rejection: 'No such invention', status: 404 }
|
||||
if (invention.CreatorPlayerId !== playerId) {
|
||||
return { rejection: 'Not your invention', status: 403 }
|
||||
}
|
||||
return { invention }
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link creatorsInventionResult} as the older invention writes answer it: the loaded
|
||||
* invention, or the response to return as-is (400 / 401 / 403 / 404).
|
||||
* invention must exist, and it must be theirs. Yields the loaded invention, or the
|
||||
* error response to return as-is (401 / 404 / 403).
|
||||
*/
|
||||
async function creatorsInvention(
|
||||
c: Context<App>,
|
||||
inventionId: number
|
||||
): Promise<{ invention: SavedInvention } | { response: Response | Promise<Response> }> {
|
||||
const gate = await creatorsInventionResult(c, inventionId)
|
||||
if ('invention' in gate) return gate
|
||||
if (gate.status === 401) return { response: unauthorized(c) }
|
||||
if (gate.status === 404) return { response: c.notFound() }
|
||||
return { response: c.json({ error: gate.rejection }, gate.status) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags a `{ AutoTags, CustomTags }` request asks for, and whether they were taken —
|
||||
* the block the v9 save sends as `tagsRequest` and `v2/metadata` sends as `TagsRequest`.
|
||||
* Null when the client named no block at all, which each caller reads its own way: a save
|
||||
* stores no tags, an edit leaves the stored ones alone.
|
||||
*
|
||||
* Tags are held to the same rule `v1/settags` applies, but a tag that breaks it costs the
|
||||
* TAGS and not the write: both replies carry a tag result of their own precisely because
|
||||
* the two outcomes are separate, and refusing a save would make the player redo a build
|
||||
* over a hyphen. All the tags go rather than the offending one alone, so nothing is
|
||||
* silently half-applied — the creator re-submits the list and sees what took. Blanks are
|
||||
* skipped rather than counted against it; the client pads its lists with empties.
|
||||
*/
|
||||
function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: number } | null {
|
||||
if (typeof request !== 'object' || request === null) return null
|
||||
|
||||
const lists = request as Record<string, unknown>
|
||||
const strings = (v: unknown): string[] =>
|
||||
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
|
||||
const autoTags = strings(lists.AutoTags)
|
||||
const customTags = strings(lists.CustomTags)
|
||||
|
||||
const rejected = [...autoTags, ...customTags].some((raw) => {
|
||||
const tag = raw.trim().toLowerCase()
|
||||
return tag !== '' && inventionTagRejection(tag) !== null
|
||||
})
|
||||
return rejected
|
||||
? { tags: [], tagResult: INVENTION_TAG_RESULT.rejected }
|
||||
: {
|
||||
tags: normalizeInventionTags(autoTags, customTags),
|
||||
tagResult: INVENTION_TAG_RESULT.success,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What an invention save produced: the stored record and how its tags fared, or the one
|
||||
* message that refuses it. Both save routes go through {@link createInventionFromBody} to
|
||||
* get one of these and then render it their own way — v6 bare, v9 enveloped — because the
|
||||
* two versions disagree about the shape of a reply, not about what a save is.
|
||||
*/
|
||||
type InventionSaveOutcome =
|
||||
{ rejection: string } | { invention: SavedInvention; tags: InventionTag[]; tagResult: number }
|
||||
|
||||
/**
|
||||
* The invention save both `v6/save` and `v9/save` run through. v9 sends everything v6 does
|
||||
* plus what the invention points at (`referencedUnityAssetIds`), what it says about itself
|
||||
* (`longDescription`, `displayMetadataJson`, `convertedFromInventionId`), `ugcVersion` and
|
||||
* `hasBetaContent`, and the tags that until now needed a second `v1/settags` call. One
|
||||
* reader takes them all: a v6 client sends none of them, and each is optional, so parsing
|
||||
* them here changes nothing about the record a v6 save stores.
|
||||
*/
|
||||
async function createInventionFromBody(
|
||||
c: Context<App>,
|
||||
creatorPlayerId: number,
|
||||
body: Record<string, unknown>
|
||||
): Promise<InventionSaveOutcome> {
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||
const bool = (v: unknown): boolean | undefined => (typeof v === 'boolean' ? v : undefined)
|
||||
const list = <T>(v: unknown, is: (x: unknown) => x is T): T[] | undefined =>
|
||||
Array.isArray(v) ? v.filter(is) : undefined
|
||||
const isString = (v: unknown): v is string => typeof v === 'string'
|
||||
const isNumber = (v: unknown): v is number => typeof v === 'number'
|
||||
|
||||
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
|
||||
if (!inventionDataFilename) return { rejection: 'inventionDataFilename is required' }
|
||||
|
||||
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
|
||||
// "No description yet"), so only a supplied one is held to the rules — otherwise
|
||||
// saving an unnamed invention would fail the 3-character minimum on a name the
|
||||
// player never typed.
|
||||
const name = str(body.name)?.trim()
|
||||
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
|
||||
if (nameRejection !== null) return { rejection: nameRejection }
|
||||
|
||||
const description = str(body.description)
|
||||
const descriptionRejection =
|
||||
description === undefined ? null : inventionDescriptionRejection(description)
|
||||
if (descriptionRejection !== null) return { rejection: descriptionRejection }
|
||||
|
||||
// v9 folds `v1/settags` into the save; a client that names no tags gets none.
|
||||
const requested = requestedTags(body.tagsRequest) ?? {
|
||||
tags: [],
|
||||
tagResult: INVENTION_TAG_RESULT.success,
|
||||
const playerId = await authedId(c)
|
||||
if (playerId === null) return { response: unauthorized(c) }
|
||||
if (Number.isNaN(inventionId)) {
|
||||
return { response: c.json({ error: 'inventionId is required' }, 400) }
|
||||
}
|
||||
|
||||
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
|
||||
creatorPlayerId,
|
||||
inventionDataFilename,
|
||||
name,
|
||||
description,
|
||||
imageName: str(body.imageName),
|
||||
instantiationCost: num(body.instantiationCost),
|
||||
lightsCost: num(body.lightsCost),
|
||||
chipsCost: num(body.chipsCost),
|
||||
cloudVariablesCost: num(body.cloudVariablesCost),
|
||||
aiCost: num(body.aiCost),
|
||||
creationRoomId: num(body.creationRoomId),
|
||||
referencedInventions: list(body.referencedInventions, isNumber),
|
||||
ugcVersion: num(body.ugcVersion),
|
||||
hasBetaContent: bool(body.hasBetaContent),
|
||||
referencedUnityAssetIds: list(body.referencedUnityAssetIds, isString),
|
||||
longDescription: str(body.longDescription),
|
||||
displayMetadataJson: str(body.displayMetadataJson),
|
||||
convertedFromInventionId: num(body.convertedFromInventionId),
|
||||
tags: requested.tags,
|
||||
})
|
||||
return { invention, ...requested }
|
||||
const invention = await getInventionById(c.env.DB, inventionId)
|
||||
if (invention === null) return { response: c.notFound() }
|
||||
if (invention.CreatorPlayerId !== playerId) {
|
||||
return { response: c.json({ error: 'Not your invention' }, 403) }
|
||||
}
|
||||
return { invention }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -372,8 +151,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: '',
|
||||
FriendlyName: '',
|
||||
// A token box holds no avatar item: null, or the client goes looking for type 0.
|
||||
AvatarItemType: null,
|
||||
AvatarItemType: 0,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
CurrencyType: 2,
|
||||
@@ -449,428 +227,38 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
(c) => c.json(true)
|
||||
)
|
||||
.get(
|
||||
'/api/customAvatarItems/v1/minPriceForPublicItem',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Minimum token price for a public custom item',
|
||||
description:
|
||||
'The floor the creation UI enforces when listing a custom item publicly. A fixed `100`.',
|
||||
responses: { 200: json(BareInteger, 'A bare `100`') },
|
||||
}),
|
||||
(c) => c.json(100)
|
||||
)
|
||||
.post(
|
||||
'/api/customAvatarItems/v1',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Create a custom avatar item',
|
||||
description:
|
||||
'Multipart: a `metadata` JSON text field plus two file parts, `thumbnailImage` ' +
|
||||
'(PNG) and `design` (the design blob). Inserts a `custom_avatar_item` row owned ' +
|
||||
'by the caller and answers with it in the PascalCase `{ Value, Success, Error, ' +
|
||||
'error_id }` envelope. The item is filed under `OutfitType` 105, the custom shirt, ' +
|
||||
'which is what the store’s user-generated-content tab searches for.\n\n' +
|
||||
'The two files go to the shared image bucket (`recflare-img`) under ' +
|
||||
'`avatar-item/<date>/<id>-thumb.png` and `avatar-item/<date>/<id>-design.png`; those ' +
|
||||
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateCustomAvatarItemRequest, 'The metadata and the two files'),
|
||||
responses: {
|
||||
200: json(CustomAvatarItemResponse, 'The created item'),
|
||||
400: json(CustomAvatarItemResponse, 'Missing or malformed metadata / files'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const fail = (message: string) =>
|
||||
c.json({ Value: null, Success: false, Error: message, error_id: null }, 400)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
if (typeof body.metadata !== 'string') return fail('metadata is required')
|
||||
let meta: Record<string, unknown>
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body.metadata)
|
||||
if (!parsed || typeof parsed !== 'object') return fail('metadata must be a JSON object')
|
||||
meta = parsed as Record<string, unknown>
|
||||
} catch {
|
||||
return fail('metadata is not valid JSON')
|
||||
}
|
||||
if (typeof meta.Name !== 'string' || meta.Name.trim() === '') return fail('Name is required')
|
||||
if (typeof meta.BaseAvatarItemId !== 'number') return fail('BaseAvatarItemId is required')
|
||||
if (typeof meta.BaseAvatarItemColor !== 'string')
|
||||
return fail('BaseAvatarItemColor is required')
|
||||
if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required')
|
||||
if (!(body.design instanceof File)) return fail('design is required')
|
||||
const limit = maxApiUploadBytes(c.env)
|
||||
// Each file gets the full per-file ceiling. Check both before either is copied into
|
||||
// an ArrayBuffer or written, so a rejected request never leaves half an item in R2.
|
||||
if (exceedsApiUploadLimit(body.thumbnailImage, limit)) {
|
||||
return c.json(
|
||||
{
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: `thumbnailImage exceeds the ${limit}-byte upload limit`,
|
||||
error_id: null,
|
||||
},
|
||||
413
|
||||
)
|
||||
}
|
||||
if (exceedsApiUploadLimit(body.design, limit)) {
|
||||
return c.json(
|
||||
{
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: `design exceeds the ${limit}-byte upload limit`,
|
||||
error_id: null,
|
||||
},
|
||||
413
|
||||
)
|
||||
}
|
||||
|
||||
// Both files go to the shared image bucket, foldered by upload date and keyed by
|
||||
// the item's id (chosen here so the keys can carry it). The `img` worker serves
|
||||
// them back by key.
|
||||
const customAvatarItemId = crypto.randomUUID()
|
||||
const prefix = `avatar-item/${new Date().toISOString().slice(0, 10)}/${customAvatarItemId}`
|
||||
const thumbnailImageFilename = `${prefix}-thumb.png`
|
||||
const designFilename = `${prefix}-design.png`
|
||||
await Promise.all([
|
||||
c.env.IMAGES.put(thumbnailImageFilename, await body.thumbnailImage.arrayBuffer(), {
|
||||
httpMetadata: { contentType: body.thumbnailImage.type || 'image/png' },
|
||||
}),
|
||||
c.env.IMAGES.put(designFilename, await body.design.arrayBuffer(), {
|
||||
httpMetadata: { contentType: body.design.type || 'image/png' },
|
||||
}),
|
||||
])
|
||||
|
||||
const item = await createCustomAvatarItem(c.env.DB, {
|
||||
customAvatarItemId,
|
||||
creatorAccountId: id,
|
||||
name: meta.Name,
|
||||
description: typeof meta.Description === 'string' ? meta.Description : '',
|
||||
price: typeof meta.Price === 'number' ? meta.Price : 0,
|
||||
baseAvatarItemId: meta.BaseAvatarItemId,
|
||||
baseAvatarItemColor: meta.BaseAvatarItemColor,
|
||||
accessibility: typeof meta.Accessibility === 'number' ? meta.Accessibility : 0,
|
||||
designFilename,
|
||||
thumbnailImageFilename,
|
||||
})
|
||||
return c.json({ Value: item, Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Edit a custom avatar item',
|
||||
description:
|
||||
'A partial edit of `Name`, `Description`, `Price` and `Accessibility` — the client ' +
|
||||
'sends every field and nulls the ones it is not changing, so null means "leave ' +
|
||||
'alone". Only the creator may edit. `ModifiedAt` is bumped. Answers the updated ' +
|
||||
'item in the same `{ Value, Success, Error, error_id }` envelope as the create.',
|
||||
security: AUTHED,
|
||||
parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
|
||||
requestBody: jsonBody(UpdateCustomAvatarItemRequest, 'The fields to change'),
|
||||
responses: {
|
||||
200: json(CustomAvatarItemResponse, 'The updated item'),
|
||||
400: json(CustomAvatarItemResponse, 'Malformed body'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(CustomAvatarItemResponse, 'Not the creator'),
|
||||
404: json(CustomAvatarItemResponse, 'No such item'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const fail = (status: 400 | 403 | 404, message: string) =>
|
||||
c.json({ Value: null, Success: false, Error: message, error_id: null }, status)
|
||||
|
||||
const itemId = c.req.param('id')
|
||||
const existing = await getCustomAvatarItem(c.env.DB, itemId)
|
||||
if (!existing) return fail(404, 'No such item')
|
||||
if (existing.CreatorAccountId !== id) return fail(403, 'Not your item')
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (!body) return fail(400, 'A JSON body is required')
|
||||
const str = (v: unknown, field: string): string | null | undefined => {
|
||||
if (v === null || v === undefined) return null
|
||||
if (typeof v !== 'string') throw new TypeError(`${field} must be a string`)
|
||||
return v
|
||||
}
|
||||
const int = (v: unknown, field: string): number | null => {
|
||||
if (v === null || v === undefined) return null
|
||||
if (typeof v !== 'number' || !Number.isInteger(v))
|
||||
throw new TypeError(`${field} must be an integer`)
|
||||
return v
|
||||
}
|
||||
let patch
|
||||
try {
|
||||
patch = {
|
||||
name: str(body.Name, 'Name'),
|
||||
description: str(body.Description, 'Description'),
|
||||
price: int(body.Price, 'Price'),
|
||||
accessibility: int(body.Accessibility, 'Accessibility'),
|
||||
}
|
||||
} catch (e) {
|
||||
return fail(400, (e as Error).message)
|
||||
}
|
||||
if (patch.name !== null && patch.name?.trim() === '')
|
||||
return fail(400, 'Name must not be blank')
|
||||
|
||||
const item = await updateCustomAvatarItem(c.env.DB, itemId, patch)
|
||||
if (!item) return fail(404, 'No such item')
|
||||
return c.json({ Value: item, Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
.delete(
|
||||
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Delete a custom avatar item',
|
||||
description:
|
||||
'Removes the item and its two bucket objects (thumbnail and design). Only the ' +
|
||||
'creator may delete. Answers the deleted item in the `{ Value, Success, Error, ' +
|
||||
'error_id }` envelope.',
|
||||
security: AUTHED,
|
||||
parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
|
||||
responses: {
|
||||
200: json(CustomAvatarItemResponse, 'The deleted item'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(CustomAvatarItemResponse, 'Not the creator'),
|
||||
404: json(CustomAvatarItemResponse, 'No such item'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const fail = (status: 403 | 404, message: string) =>
|
||||
c.json({ Value: null, Success: false, Error: message, error_id: null }, status)
|
||||
|
||||
const itemId = c.req.param('id')
|
||||
const existing = await getCustomAvatarItem(c.env.DB, itemId)
|
||||
if (!existing) return fail(404, 'No such item')
|
||||
if (existing.CreatorAccountId !== id) return fail(403, 'Not your item')
|
||||
|
||||
const item = await deleteCustomAvatarItem(c.env.DB, itemId)
|
||||
if (!item) return fail(404, 'No such item')
|
||||
// The row is gone; the objects follow. A missing key is a no-op for R2. A first-party
|
||||
// item has neither (it is rendered from its saves' assetbundles), so there is nothing
|
||||
// to delete for one.
|
||||
const keys = [item.ThumbnailImageFilename, item.DesignFilename].filter(
|
||||
(k): k is string => k !== null
|
||||
)
|
||||
if (keys.length > 0) await c.env.IMAGES.delete(keys)
|
||||
return c.json({ Value: item, Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
|
||||
// The featured custom-avatar-item feed: flagged (`is_featured`) AND published
|
||||
// (`Accessibility` != 0) items from the `custom_avatar_item` table.
|
||||
// The featured custom-avatar-item feed. No curated items yet → an empty list.
|
||||
.get(
|
||||
'/api/customAvatarItems/v1/featured',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Featured custom avatar items',
|
||||
description:
|
||||
'The curated feed: items with `IsFeatured` set that are also published ' +
|
||||
'(`Accessibility` 0 is unpublished and is excluded even when flagged), newest first, ' +
|
||||
'up to 50. Nothing sets the flag yet, so it stays empty until an operator does.',
|
||||
responses: { 200: json(CustomAvatarItemList, 'The items, newest first') },
|
||||
description: 'The curated feed. Nothing is curated yet, so it is empty.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
async (c) => c.json(await listFeaturedCustomAvatarItems(c.env.DB))
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// The store's item search. The client sends the full set of `outfitTypes` it can render
|
||||
// plus paging, and expects a BARE ARRAY of items back — not the `{ Results, TotalResults }`
|
||||
// envelope `fromCreator` uses.
|
||||
//
|
||||
// Several parameters are accepted and not yet acted on; they are listed in the description
|
||||
// rather than dropped silently, because a caller cannot tell the difference between a filter
|
||||
// that was applied and one that was ignored by looking at the results.
|
||||
.get(
|
||||
'/api/customAvatarItems/v1/search',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Search custom avatar items',
|
||||
description: [
|
||||
'The store’s item search: published items (`Accessibility` 0 is unpublished and is',
|
||||
'left out, from its creator too — `fromCreator` is where they see their own),',
|
||||
'newest first, as a BARE ARRAY.',
|
||||
'`searchQuery` matches an item’s NAME or its DESCRIPTION, case-insensitively, as a',
|
||||
'substring; `%` and `_` in it are literal.',
|
||||
'`outfitTypes` may repeat and acts as a whitelist; sending none means no filter',
|
||||
'rather than no results, since the client sends every type it can render.',
|
||||
'`minPrice`/`maxPrice` bound the price, inclusive.',
|
||||
'`skip`/`take` page the results, `take` capped at 200.',
|
||||
'`includeCoachItems` picks a SIDE of the catalog: `true` serves only the Coach’s',
|
||||
'stock content (account 1), `false` only player-made items, and leaving it out serves',
|
||||
'both. The client’s storefront tab sends `True` and its user-generated-content tab',
|
||||
'`False`, so the two never overlap.',
|
||||
'`creatorAccountId` narrows to one author’s items — what a player’s creations list',
|
||||
'sends (`creatorAccountId=<id>&includeCoachItems=False`); still published items only.',
|
||||
'`outfitTypes` values are `RecRoom.Avatars.OutfitType` ordinals; the user-generated-',
|
||||
'content tab sends 105 (`CustomShirt`) alone, the storefront tab a dozen slots.',
|
||||
'`itemTypes` (a separate enum: All -1, None 0, Shirt 1 — not the outfit type),',
|
||||
'`ordering` (0 is `SearchScoreDescending`) and `unityAssetVersion` are accepted and NOT',
|
||||
'yet acted on — nothing records purchase or wear counts to rank by, no per-version',
|
||||
'asset variants are stored, and custom shirts are the only item type there is.',
|
||||
'`unityAssetTarget` IS acted on: 2 (Android/Oculus) serves each save’s',
|
||||
'`UnityAsset`/`UnityAsset2` under `quest/`, the Android build of the same assetbundle;',
|
||||
'0 (PC), any other value, and none at all get the bare filenames as stored.',
|
||||
'`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,',
|
||||
'whatever it says.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'searchQuery',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Free text matched against the item’s name or description',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'outfitTypes',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'OutfitType to include; repeat for several. None means all.',
|
||||
schema: { type: 'array', items: { type: 'integer' } },
|
||||
},
|
||||
{
|
||||
name: 'skip',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Rows to skip (default 0)',
|
||||
schema: { type: 'integer', minimum: 0 },
|
||||
},
|
||||
{
|
||||
name: 'take',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Rows to return (default 50, capped at 200)',
|
||||
schema: { type: 'integer', minimum: 0 },
|
||||
},
|
||||
{
|
||||
name: 'minPrice',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Lowest price to include, inclusive',
|
||||
schema: { type: 'integer', minimum: 0 },
|
||||
},
|
||||
{
|
||||
name: 'maxPrice',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Highest price to include, inclusive',
|
||||
schema: { type: 'integer', minimum: 0 },
|
||||
},
|
||||
{
|
||||
name: 'includeCoachItems',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description:
|
||||
'true: only the Coach’s stock items; false: only player-made items; absent: both',
|
||||
schema: { type: 'boolean' },
|
||||
},
|
||||
{
|
||||
name: 'creatorAccountId',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Only items this account made',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
{
|
||||
name: 'unityAssetTarget',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description:
|
||||
'The Unity build target the assetbundles are wanted for: 0 PC (Windows), 2 ' +
|
||||
'Android/Oculus — which is served the `quest/` builds',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(CustomAvatarItemList, 'The matching items, newest first') },
|
||||
}),
|
||||
async (c) => {
|
||||
// `?outfitTypes=0&outfitTypes=2&…` — repeated, so read every value. A non-numeric one is
|
||||
// dropped rather than turned into NaN, which would match nothing and quietly empty a
|
||||
// filter the caller believes they set.
|
||||
const outfitTypes = c.req
|
||||
.queries('outfitTypes')
|
||||
?.map((v) => Number.parseInt(v, 10))
|
||||
.filter((n) => Number.isInteger(n))
|
||||
|
||||
// Three-way: `True` is the Coach's side of the store, `False` the players', absent both.
|
||||
// The client capitalises its booleans (`includeCoachItems=True`), so this is folded
|
||||
// before comparing; anything that isn't recognisably true or false is treated as absent.
|
||||
const coach = c.req.query('includeCoachItems')?.toLowerCase()
|
||||
const includeCoachItems = coach === 'true' ? true : coach === 'false' ? false : undefined
|
||||
|
||||
const int = (name: string): number | undefined => {
|
||||
const raw = c.req.query(name)
|
||||
if (raw === undefined) return undefined
|
||||
const n = Number.parseInt(raw, 10)
|
||||
return Number.isInteger(n) ? n : undefined
|
||||
}
|
||||
|
||||
const items = await searchCustomAvatarItems(c.env.DB, {
|
||||
searchQuery: c.req.query('searchQuery'),
|
||||
outfitTypes,
|
||||
includeCoachItems,
|
||||
creatorAccountId: int('creatorAccountId'),
|
||||
minPrice: int('minPrice'),
|
||||
maxPrice: int('maxPrice'),
|
||||
skip: int('skip'),
|
||||
take: int('take'),
|
||||
})
|
||||
// The same switch the bulk lookup makes: a caller asking for the Quest target is
|
||||
// pointed at the `quest/` builds of the assetbundles the saves name.
|
||||
return c.json(
|
||||
isQuestAssetTarget(c.req.query('unityAssetTarget'))
|
||||
? items.map(toQuestCustomAvatarItem)
|
||||
: items
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// The "hot" (trending) custom-avatar-item feed: every published (`Accessibility` != 0)
|
||||
// item from the `custom_avatar_item` table. There is nothing to rank a trend from yet,
|
||||
// so it is the accessible items, newest first.
|
||||
// The "hot" (trending) custom-avatar-item feed. No items yet → an empty list.
|
||||
.get(
|
||||
'/api/customAvatarItems/v1/hot',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Trending custom avatar items',
|
||||
description:
|
||||
'The “hot” feed: the published items (`Accessibility` 0 is unpublished and is left ' +
|
||||
'out), newest first, up to 50. No purchase or wear counts are recorded, so there is ' +
|
||||
'no trend to rank by and recency stands in for one.',
|
||||
responses: { 200: json(CustomAvatarItemList, 'The items, newest first') },
|
||||
description: 'The “hot” feed. No custom items exist yet, so it is empty.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
async (c) => c.json(await listHotCustomAvatarItems(c.env.DB))
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// A batch lookup of custom avatar items by id, out of the `custom_avatar_item` table.
|
||||
// The reference filters its catalog down to the posted ids and returns the MATCHES AS A
|
||||
// BARE ARRAY — not the `{ Results, TotalResults }` page that catalog is written in, and
|
||||
// not a 404 for ids it doesn't hold.
|
||||
//
|
||||
// This is how a `1.<guid>` entity in a GENERIC discovery row (`lists`
|
||||
// `/algorithmiclists/:list?type=5`) gets resolved, so a row naming a custom item renders
|
||||
// nothing at all when this doesn't answer. It stubbed out `[]` while nothing stored custom
|
||||
// items; the table has existed since migration 0015 and the stub outlived it.
|
||||
// A batch lookup of custom avatar items by id. The reference filters a static catalog
|
||||
// down to the posted ids and returns the MATCHES AS A BARE ARRAY — not the
|
||||
// `{ Results, TotalResults }` page its catalog file is written in, and not a 404 for
|
||||
// ids it doesn't hold. Nothing stores custom items here (the reference's own catalog
|
||||
// ships empty too), so every id misses and the array is empty.
|
||||
//
|
||||
// Auth-gated, and the token is checked before anything else, as the reference does.
|
||||
//
|
||||
// A batch over {@link BULK_CUSTOM_AVATAR_ITEM_CAP} ids answers EMPTY. The client has been
|
||||
// seen posting far more ids than a screen could draw, and serving those is both a large
|
||||
// query and a large response for a request that is already not what it looks like. Empty is
|
||||
// the safe answer because a miss here is not an error: unknown ids are simply absent, so the
|
||||
// client already handles getting back fewer items than it asked about.
|
||||
.post(
|
||||
'/api/customAvatarItems/v1/bulk',
|
||||
describeRoute({
|
||||
@@ -878,83 +266,42 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
summary: 'Custom avatar items in bulk',
|
||||
description:
|
||||
'Resolves a batch of custom-avatar-item ids to their items: the posted ' +
|
||||
'`customAvatarItemIds` filtered against the `custom_avatar_item` table, returned ' +
|
||||
'as a BARE ARRAY of the ones that matched, in the order they were asked for. Not ' +
|
||||
'the `{ Results, TotalResults }` page the sibling custom-item reads serve — the ' +
|
||||
'reference keeps its catalog in that shape but answers this route with the ' +
|
||||
'filtered array alone.\n\n' +
|
||||
'`customAvatarItemIds` filtered against the catalog, returned as a BARE ARRAY of ' +
|
||||
'the ones that matched. Not the `{ Results, TotalResults }` page the sibling ' +
|
||||
'custom-item reads serve — the reference keeps its catalog in that shape but ' +
|
||||
'answers this route with the filtered array alone.\n\n' +
|
||||
'A miss is not an error: unknown ids are simply absent from the response, and the ' +
|
||||
'client reads the items it got back rather than the ids it asked for. Unpublished ' +
|
||||
'items (`Accessibility` 0) miss for everyone but their creator, the same rule the ' +
|
||||
'feeds and the creator shelf apply.\n\n' +
|
||||
'Ids ride as repeated `customAvatarItemIds` form fields; a comma-separated value ' +
|
||||
'and the same spelling on the query string are both accepted, since the client’s ' +
|
||||
'exact encoding here has not been pinned down.\n\n' +
|
||||
'A caller asking for the Quest build — `unityAssetTarget` 2 (Android/Oculus), on ' +
|
||||
'the query string or in the form — is served each save’s `UnityAsset`/`UnityAsset2` ' +
|
||||
'under `quest/`, the Android build of the same assetbundle. Target 0 (PC), any ' +
|
||||
'other value, and no target at all get the bare filenames as stored.\n\n' +
|
||||
'A batch of more than 100 ids answers an EMPTY array without reading the table: the ' +
|
||||
'client has been seen posting more than a screen could draw, and a miss is already ' +
|
||||
'not an error here.',
|
||||
'client reads the items it got back rather than the ids it asked for. Nothing ' +
|
||||
'stores custom items here, so every id misses and this is always `[]` — which is ' +
|
||||
'why the posted ids are not parsed.',
|
||||
security: AUTHED,
|
||||
requestBody: form(BulkCustomAvatarItemsRequest, 'The custom-avatar-item ids to resolve'),
|
||||
responses: {
|
||||
200: json(CustomAvatarItemList, 'The items that matched, in request order'),
|
||||
200: json(JsonArray, 'The matching items — always empty here'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const ids = await bulkCustomAvatarItemIds(c)
|
||||
|
||||
// Over the cap: empty, and the table is not touched. Answering the batch would be a
|
||||
// large query and a large response for a request that is already not what it looks
|
||||
// like — a screen does not draw this many items.
|
||||
if (ids.length > BULK_CUSTOM_AVATAR_ITEM_CAP) return c.json([])
|
||||
|
||||
const items = await getCustomAvatarItems(c.env.DB, ids)
|
||||
// Unpublished items are held back from everyone but their creator — the same rule
|
||||
// the featured/hot feeds and the creator shelf apply, so an item can't be surfaced
|
||||
// through this route that the feeds hide.
|
||||
const visible = items.filter(
|
||||
(item) => item.Accessibility !== 0 || item.CreatorAccountId === id
|
||||
)
|
||||
// A Quest can't load the PC assetbundles the saves name; its builds sit beside them
|
||||
// under `quest/`, so the names are pointed there for a caller asking for that target.
|
||||
return c.json(
|
||||
isQuestAssetTarget(await bulkUnityAssetTarget(c))
|
||||
? visible.map(toQuestCustomAvatarItem)
|
||||
: visible
|
||||
)
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
// Custom avatar items created by a given account, from the `custom_avatar_item` table,
|
||||
// in the paginated shape (matches the econ `customAvatarItems/v1/owned` shape). Auth is
|
||||
// optional: the creator themselves also sees their unpublished (`Accessibility` 0) items.
|
||||
// Custom avatar items created by a given account. No storage yet → an empty
|
||||
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
|
||||
.get(
|
||||
'/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'A creator’s custom avatar items',
|
||||
description:
|
||||
'The items an account has authored, newest first, in the same page shape as the ' +
|
||||
'`econ` worker’s `customAvatarItems/v1/owned`. Published items only — unless the ' +
|
||||
'bearer token is the creator’s, in which case their unpublished (`Accessibility` 0) ' +
|
||||
'items are included too. Paging is not applied (the client sends none), so ' +
|
||||
'`TotalResults` is the length of `Results`.',
|
||||
security: OPTIONAL_AUTHED,
|
||||
'The items an account has authored. Nothing stores custom items yet, so this is an ' +
|
||||
'empty page — in the same shape as the `econ` worker’s `customAvatarItems/v1/owned`.',
|
||||
parameters: [idParam('accountId', 'Creator account id')],
|
||||
responses: { 200: json(CustomAvatarItemsPage, 'The creator’s items') },
|
||||
responses: { 200: json(CustomAvatarItemsPage, 'An empty page') },
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = Number.parseInt(c.req.param('accountId'), 10)
|
||||
const viewer = await authedId(c)
|
||||
return c.json(await listCustomAvatarItemsByCreator(c.env.DB, accountId, viewer === accountId))
|
||||
}
|
||||
(c) => c.json({ Results: [], TotalResults: 0 })
|
||||
)
|
||||
|
||||
// The client asks which legacy avatar items have been rebuilt as custom items, so it
|
||||
@@ -1125,46 +472,29 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The caller's outfit wardrobe — every slot they have saved, ordered by slot. The same
|
||||
// read as `econ`'s `GET /api/avatar/v3/saved`, on the bare path the newer client uses:
|
||||
// both worker's write paths land in the shared `outfit` table, so both list endpoints
|
||||
// serve the same rows.
|
||||
//
|
||||
// Slot 0 is INCLUDED. It is the outfit being worn (what `/outfits/me` reads), but it is
|
||||
// also a saved slot: the newer client picks the slot it saves into (`/api/avatar/v4/saved/set`
|
||||
// 400s without one), so filtering slot 0 out would hide a real saved outfit whenever a
|
||||
// wardrobe entry lands there. Showing the worn outfit as a wardrobe entry is the cheaper
|
||||
// mistake of the two.
|
||||
//
|
||||
// Rows are served exactly as they were stored, unprojected — see the note atop
|
||||
// `outfits-db.ts`: econ's saved slots hold the old flat PascalCase outfit while
|
||||
// `/outfits/me` holds the newer envelope, and neither is converted into the other.
|
||||
// The caller's outfit wardrobe. An empty list for now — the outfits saved through
|
||||
// `PUT /outfits/me` are in the shared `outfit` table already, but which of them
|
||||
// belong in this list (and in what shape) has not been pinned down, so it answers []
|
||||
// rather than guessing.
|
||||
.get(
|
||||
'/outfits/me/saved',
|
||||
describeRoute({
|
||||
tags: ['Avatar', '2025'],
|
||||
summary: 'The caller’s saved outfits',
|
||||
description:
|
||||
'The wardrobe behind the newer outfit screen: every slot the caller has saved, ' +
|
||||
'ordered by slot, and `[]` when they have saved none. The same rows `econ`’s ' +
|
||||
'`GET /api/avatar/v3/saved` serves — both write paths land in the shared `outfit` ' +
|
||||
'table.\n\n' +
|
||||
'Slot 0 is included. It is the outfit being worn (what `GET /outfits/me` reads) but ' +
|
||||
'it is a saved slot too, and the client chooses the slot it saves into, so omitting ' +
|
||||
'it would hide a real outfit whenever a wardrobe entry lands there.\n\n' +
|
||||
'Each outfit is served exactly as it was stored, unprojected: slots written through ' +
|
||||
'`PUT /outfits/me` hold the newer envelope while `econ`’s saved-set slots hold the ' +
|
||||
'old flat shape, and neither is converted into the other.',
|
||||
'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' +
|
||||
'through `PUT /outfits/me` are in the shared `outfit` table, but which of them this ' +
|
||||
'list should carry, and in what shape, is not pinned down yet.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(JsonArray, 'The saved outfits, ordered by slot (empty when none)'),
|
||||
200: json(JsonArray, 'An empty list'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getOutfits(c.env.DB, id))
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1295,36 +625,30 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The signed-in player's own relationship to an invention (`/personaldetails/2`) —
|
||||
// just whether they're cheering it. Signed-out callers read false: there is no player
|
||||
// whose interaction could be looked up, and the client still needs a flag to render.
|
||||
// The signed-in player's own relationship to an invention (`/personaldetails/2`)
|
||||
// — just whether they're cheering it. We store no cheers (nothing can cheer an
|
||||
// invention yet), so this is always false; it stays a 200 for signed-out callers
|
||||
// too, since the client only reads the flag.
|
||||
.get(
|
||||
'/api/inventions/v1/personaldetails/:inventionId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The caller’s own relation to an invention',
|
||||
description:
|
||||
'Whether the caller is cheering this invention. Signed-out callers receive false, ' +
|
||||
'since there is no player interaction to look up.',
|
||||
'Just whether the caller is cheering it. We store no cheers, so it is always false ' +
|
||||
'— and this stays a 200 for signed-out callers too, since the client only reads the ' +
|
||||
'flag.',
|
||||
parameters: [idParam('inventionId', 'Invention id')],
|
||||
responses: { 200: json(InventionPersonalDetails, 'The caller’s cheer state') },
|
||||
responses: { 200: json(InventionPersonalDetails, 'Always not cheering') },
|
||||
}),
|
||||
async (c) => {
|
||||
const playerId = await authedId(c)
|
||||
if (playerId === null) return c.json({ IsCheering: false })
|
||||
const inventionId = Number.parseInt(c.req.param('inventionId'), 10)
|
||||
return c.json({ IsCheering: await isInventionCheered(c.env.DB, playerId, inventionId) })
|
||||
}
|
||||
(c) => c.json({ IsCheering: false })
|
||||
)
|
||||
|
||||
// A single version of an invention (`?inventionId=…&version=…`) — the bare
|
||||
// RRInventionVersion, which carries the blob name the client downloads and the
|
||||
// SHA-256 of that blob. Public. Only the current version exists (nothing writes
|
||||
// version history yet), so any other version number 404s rather than naming a
|
||||
// blob that isn't there — except `version=0`, which means "whichever is current"
|
||||
// rather than a number to match. Nothing has a version 0, so a caller sending it
|
||||
// doesn't know which version it wants, and matching it literally 404s an invention
|
||||
// that exists.
|
||||
// blob that isn't there.
|
||||
.get(
|
||||
'/api/inventions/v1/version',
|
||||
describeRoute({
|
||||
@@ -1335,19 +659,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
|
||||
'never uploaded). Only the current version exists — nothing writes version ' +
|
||||
'history yet — so any other version number 404s rather than naming a blob that ' +
|
||||
'is not there.\n\n' +
|
||||
'`version=0` is the exception: it means “whichever is current” rather than a ' +
|
||||
'number to match, and gets the current version. No invention has a version 0 — a ' +
|
||||
'fresh save is version 1 — so a caller sending it does not know which version it ' +
|
||||
'wants, and matching it literally 404s an invention that exists.',
|
||||
'is not there.',
|
||||
parameters: [
|
||||
intQuery('inventionId', 'Invention id; required'),
|
||||
intQuery('version', 'Version number; required. `0` means the current version'),
|
||||
intQuery('version', 'Version number; required'),
|
||||
],
|
||||
responses: {
|
||||
200: json(InventionVersionDto, 'The version'),
|
||||
400: json(ErrorResponse, 'Missing inventionId or version'),
|
||||
404: { description: 'No such invention, or a version number that is not the current one' },
|
||||
404: { description: 'No such invention, or not the current version' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
@@ -1479,11 +799,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
const permissionLevel = c.req.query('permissionLevel')
|
||||
const price = Number.parseInt(c.req.query('price') ?? '', 10)
|
||||
|
||||
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
|
||||
permissionLevel:
|
||||
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
|
||||
price: Number.isNaN(price) || price < 0 ? undefined : price,
|
||||
})
|
||||
const published = await publishInvention(
|
||||
c.env.DB,
|
||||
gate.invention.InventionId,
|
||||
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
|
||||
Number.isNaN(price) || price < 0 ? undefined : price
|
||||
)
|
||||
return published === null ? c.notFound() : c.json(toSaveResult(published))
|
||||
}
|
||||
)
|
||||
@@ -1663,19 +984,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The featured dorm-skin feed (inventions that reskin the dorm). Nothing curates these
|
||||
// yet → an empty list, so the client's shelf renders empty rather than 404ing.
|
||||
.get(
|
||||
'/api/inventions/v1/featureddormskins',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The featured dorm-skin feed',
|
||||
description: 'Curated dorm-skin inventions. Nothing is curated yet, so it is empty.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Inventions by particular creators (`?id=207&id=…`) — what the client fills a creator's
|
||||
// shelf, and the "from creators you follow" row, from.
|
||||
//
|
||||
@@ -1706,20 +1014,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
// Invention search/browse: published inventions matching `value` (matched against
|
||||
// name + description; absent → browse everything published), newest first.
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
//
|
||||
// Filtered, ordered and paged in SQL — it must not read the catalogue into memory to
|
||||
// answer one page.
|
||||
.get(
|
||||
'/api/inventions/v2/search',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Search / browse inventions',
|
||||
description:
|
||||
'Published inventions matching `value`, newest first. `value` is split into terms ' +
|
||||
'and every term must match, each against the name and the description. An absent ' +
|
||||
'`value` browses everything published — that is the browse screen’s initial ' +
|
||||
'request. Tags are NOT searched: a `#tag` term from the browse screen’s filter ' +
|
||||
'chips is treated as text and matches nothing.',
|
||||
'Published inventions matching `value` (matched against name and description), ' +
|
||||
'newest first. An absent `value` browses everything published — that is the ' +
|
||||
'browse screen’s initial request.',
|
||||
parameters: [
|
||||
stringQuery('value', 'Search text; absent browses everything'),
|
||||
...pageParams(100),
|
||||
@@ -1761,175 +1064,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Report a custom avatar item. Stored in the `report` table the player, event and invention
|
||||
// reports use — same fields, same moderation life — with `custom_avatar_item_id` set. See
|
||||
// migrations/0017_report_custom_avatar_item.sql.
|
||||
//
|
||||
// The item is named by the PATH, not the body, which is what distinguishes this from its
|
||||
// siblings; the body's `ReportedPlayerId` arrives NULL and is ignored, since the client does
|
||||
// not know who made the item.
|
||||
.post(
|
||||
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}/report',
|
||||
describeRoute({
|
||||
tags: ['Avatar', 'Moderation'],
|
||||
summary: 'Report a custom avatar item',
|
||||
description:
|
||||
'Files a report against a custom avatar item, named by the PATH. Stored as a row in ' +
|
||||
'the same `report` table a player report goes to (`POST /api/PlayerReporting/v3/create`), ' +
|
||||
'an event report and an invention report — the same submission with the same ' +
|
||||
'moderation life, which a moderator converts into a ban the same way. What marks it ' +
|
||||
'as an item report is `custom_avatar_item_id`; the row’s `reported_player_id` is the ' +
|
||||
'item’s CREATOR, read from the item. The body’s `ReportedPlayerId` is sent as null ' +
|
||||
'and IGNORED even when set — the client does not know who made the item, and letting ' +
|
||||
'a client name who a report is against would let it point one at anybody. Nothing ' +
|
||||
'fills `room_id`: an item isn’t tied to one room the way an event is.\n\n' +
|
||||
'The reporter is the caller (from the bearer token), never a body field. ' +
|
||||
'`ReportCategory` is stored verbatim — the enum is not mapped here. Nothing dedupes ' +
|
||||
'the rows: reporting the same item twice files two reports, and reporting your own ' +
|
||||
'is allowed rather than being a special case.\n\n' +
|
||||
'Answers the `{ success, error }` envelope the event and invention reports use, ' +
|
||||
'`error` being an empty string rather than null, on the rejected branches too so ' +
|
||||
'there is only one shape to parse.',
|
||||
security: AUTHED,
|
||||
parameters: [idParam('id', 'The custom avatar item’s guid')],
|
||||
requestBody: jsonBody(CustomAvatarItemReportRequest, 'The report'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: json(SuccessErrorEnvelope, 'No such custom avatar item'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const reporterId = await authedId(c)
|
||||
if (reporterId === null) return unauthorized(c)
|
||||
|
||||
const customAvatarItemId = c.req.param('id')
|
||||
|
||||
// The item supplies the reported player. An unknown item is refused rather than filed
|
||||
// against nobody: the row's reported player has to be someone, and a report naming an
|
||||
// item that never existed isn't actionable.
|
||||
const item = await getCustomAvatarItem(c.env.DB, customAvatarItemId)
|
||||
if (item === null) return c.json({ success: false, error: 'No such item' }, 404)
|
||||
|
||||
// A body that won't parse is not a reason to lose the report: the path already names
|
||||
// what is being reported and the token names who reported it, so an unreadable body
|
||||
// costs the category and the description, not the row.
|
||||
const body = await c.req
|
||||
.json<{ ReportCategory?: unknown; Details?: unknown }>()
|
||||
.catch(() => ({}) as Record<string, unknown>)
|
||||
const category = Number(body.ReportCategory)
|
||||
await createReport(c.env.DB, {
|
||||
reporterPlayerId: reporterId,
|
||||
reportedPlayerId: item.CreatorAccountId,
|
||||
reportCategory: Number.isInteger(category) ? category : 0,
|
||||
details: typeof body.Details === 'string' ? body.Details : null,
|
||||
customAvatarItemId,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// Cheer or un-cheer an invention. The interaction row is per player and the stored
|
||||
// invention's public CheerCount is derived from all active cheers.
|
||||
.post(
|
||||
'/api/inventions/v1/cheer',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Cheer or un-cheer an invention',
|
||||
description:
|
||||
'Persists the caller’s cheer state and resyncs the invention’s `CheerCount`. ' +
|
||||
'Repeating the same state is idempotent.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(InventionCheerRequest, 'The invention and new cheer state'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'Invalid body'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: json(SuccessErrorEnvelope, 'No such invention'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const playerId = await authedId(c)
|
||||
if (playerId === null) return unauthorized(c)
|
||||
const body = await c.req
|
||||
.json<{ InventionId?: unknown; Cheer?: unknown }>()
|
||||
.catch(() => ({}) as Record<string, unknown>)
|
||||
const inventionId = Number(body.InventionId)
|
||||
if (!Number.isInteger(inventionId) || typeof body.Cheer !== 'boolean') {
|
||||
return c.json({ success: false, error: 'InventionId and Cheer are required' }, 400)
|
||||
}
|
||||
if ((await getInventionById(c.env.DB, inventionId)) === null) {
|
||||
return c.json({ success: false, error: 'No such invention' }, 404)
|
||||
}
|
||||
await setInventionCheer(c.env.DB, playerId, inventionId, body.Cheer)
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
// Report an invention. Stored in the `report` table the player and event reports use —
|
||||
// same fields, same moderation life — with `invention_id` set. See
|
||||
// migrations/0016_report_invention.sql.
|
||||
.post(
|
||||
'/api/inventions/v1/report',
|
||||
describeRoute({
|
||||
tags: ['Inventions', 'Moderation'],
|
||||
summary: 'Report an invention',
|
||||
description:
|
||||
'Files a report against an invention. Stored as a row in the same `report` table a ' +
|
||||
'player report goes to (`POST /api/PlayerReporting/v3/create`) and an event report ' +
|
||||
'(`POST /api/playerevents/v1/report`) — it is the same submission with the same ' +
|
||||
'moderation life, and a moderator converts any of them into a ban the same way. ' +
|
||||
'What marks it as an invention report is `invention_id`; the row’s ' +
|
||||
'`reported_player_id` is the invention’s CREATOR — who a moderator would act ' +
|
||||
'against — read from the invention rather than sent by the client. Nothing fills ' +
|
||||
'`room_id`: an invention isn’t tied to one room the way an event is.\n\n' +
|
||||
'The reporter is the caller (from the bearer token), never a body field. ' +
|
||||
'`ReportCategory` is stored verbatim — the enum is not mapped here. Nothing ' +
|
||||
'dedupes the rows: reporting the same invention twice files two reports, and ' +
|
||||
'reporting your own is allowed rather than being a special case.\n\n' +
|
||||
'Answers the same `{ success, error }` envelope as the event report, `error` being ' +
|
||||
'an empty string rather than null, on the rejected branches too so there is only ' +
|
||||
'one shape to parse.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(InventionReportRequest, 'The report'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No usable `InventionId` in the body'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: json(SuccessErrorEnvelope, 'No such invention'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const reporterId = await authedId(c)
|
||||
if (reporterId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req
|
||||
.json<{ InventionId?: unknown; ReportCategory?: unknown; Details?: unknown }>()
|
||||
.catch(() => ({}) as Record<string, unknown>)
|
||||
const inventionId = Number(body.InventionId)
|
||||
if (!Number.isInteger(inventionId)) {
|
||||
return c.json({ success: false, error: 'InventionId is required' }, 400)
|
||||
}
|
||||
|
||||
// The invention supplies the reported player. An unknown invention is refused rather
|
||||
// than filed against nobody: the row's reported player has to be someone, and a
|
||||
// report naming an invention that never existed isn't actionable.
|
||||
const invention = await getInventionById(c.env.DB, inventionId)
|
||||
if (invention === null) return c.json({ success: false, error: 'No such invention' }, 404)
|
||||
|
||||
const category = Number(body.ReportCategory)
|
||||
await createReport(c.env.DB, {
|
||||
reporterPlayerId: reporterId,
|
||||
reportedPlayerId: invention.CreatorPlayerId,
|
||||
reportCategory: Number.isInteger(category) ? category : 0,
|
||||
details: typeof body.Details === 'string' ? body.Details : null,
|
||||
inventionId,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// Save an invention's metadata. The data file itself is uploaded separately
|
||||
// through the `storage` worker and referenced here by `inventionDataFilename` —
|
||||
// the one required field, since an invention with no data blob is unusable. An
|
||||
@@ -1968,393 +1102,43 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||||
|
||||
const outcome = await createInventionFromBody(c, id, body)
|
||||
if ('rejection' in outcome) return c.json({ error: outcome.rejection }, 400)
|
||||
return c.json(toSaveResult(outcome.invention))
|
||||
}
|
||||
)
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||
|
||||
// The same save as the newer client sends it: v6's body plus the invention's
|
||||
// references, its long description and display metadata, what the saved blob is, and
|
||||
// the tags — which v6 clients set afterwards through `v1/settags`. It stores the same
|
||||
// record; what differs is the REPLY, which is enveloped. See `InventionSaveV9Result`:
|
||||
// the client reads `Success` and then `Value.Invention.InventionId`, and a body that
|
||||
// isn't this envelope — a bare `{ error }`, or the empty 401 the other routes answer —
|
||||
// takes it down rather than failing it, which is why every branch below answers one.
|
||||
.post(
|
||||
'/api/inventions/v9/save',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Save a new invention (v9)',
|
||||
description:
|
||||
'`v6/save` plus the fields the newer client sends: `referencedUnityAssetIds`, ' +
|
||||
'`longDescription`, `displayMetadataJson`, `convertedFromInventionId`, ' +
|
||||
'`ugcVersion`, `hasBetaContent`, and a `tagsRequest` carrying the same ' +
|
||||
'`AutoTags`/`CustomTags` lists `v1/settags` takes. Every one is optional and is ' +
|
||||
'stored only when sent, so a body v6 would accept produces the same record here.' +
|
||||
'\n\n' +
|
||||
'The reply is where the two versions part: v9 is ENVELOPED as ' +
|
||||
'`{ Value, Success, Error, error_id }`, with v6’s ' +
|
||||
'`{ Status, Invention, InventionVersion }` inside `Value` alongside a ' +
|
||||
'`TagsResponse`. The client reads `Success` and then ' +
|
||||
'`Value.Invention.InventionId`; `Error` is the only text it ever shows a human.' +
|
||||
'\n\n' +
|
||||
'So a refusal is *also* a 200 carrying `{ Success: false, Error, Value: null }` — ' +
|
||||
'the client dereferences `Value` unguarded when `Success` is true, and treats ' +
|
||||
'anything that isn’t this envelope as a null one. Tags are held to the ' +
|
||||
'`v1/settags` rule (at most 15 letters each), but one that breaks it costs the ' +
|
||||
'tags and not the save: `TagsResponse.Result` comes back non-zero and the creator ' +
|
||||
're-submits them through `v1/settags`.\n\n' +
|
||||
'A freshly saved invention is private: it shows up only in the creator’s own list ' +
|
||||
'until they call `v3/publish`.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SaveInventionV9Request, 'The invention metadata (camelCase)'),
|
||||
responses: {
|
||||
200: json(
|
||||
InventionSaveV9Result,
|
||||
'The envelope — the stored invention under `Value`, or `Success: false` with ' +
|
||||
'`Error` when the save was refused'
|
||||
),
|
||||
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.json(inventionSaveV9Failure('Unauthorized'), 401)
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
|
||||
|
||||
const outcome = await createInventionFromBody(c, id, body)
|
||||
return c.json(
|
||||
'rejection' in outcome
|
||||
? inventionSaveV9Failure(outcome.rejection)
|
||||
: toSaveResultV9(outcome.invention, outcome.tags, outcome.tagResult)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Edit an invention's metadata, as the newer client sends it: one PUT with a PascalCase
|
||||
// body where every field but the id is nullable, and NULL means "leave this alone" —
|
||||
// the client sends the whole shape every time and marks the fields it isn't touching.
|
||||
// The tags ride along the way they do on `v9/save`, and the reply is that same
|
||||
// envelope: `v1/update` is the older client's version of this endpoint, query params
|
||||
// and a bare body and all.
|
||||
.put(
|
||||
'/api/inventions/v2/metadata',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Edit an invention’s metadata (v2)',
|
||||
description:
|
||||
'Creator only. Every field but `InventionId` is nullable and a null one is left ' +
|
||||
'as it is — the client sends the whole shape on every edit — so this is a patch, ' +
|
||||
'not a replace. An empty string is not a null: it is how a creator CLEARS a ' +
|
||||
'description, long description or image. `Name` is the exception, since a nameless ' +
|
||||
'invention isn’t a thing the client can draw: it is held to the same 3–24 ' +
|
||||
'character rule `v6/save` enforces, which an empty name fails.\n\n' +
|
||||
'`TagsRequest` replaces both tag lists wholesale, exactly as `v1/settags` does; a ' +
|
||||
'null one leaves the stored tags alone. A tag that breaks the tag rule costs the ' +
|
||||
'tags and not the edit — `TagsResponse.Result` comes back non-zero.\n\n' +
|
||||
'Answers the enveloped result `v9/save` answers, carrying the UPDATED invention: ' +
|
||||
'the client re-renders the detail page from `Value.Invention`. Refusals — an ' +
|
||||
'unknown invention and someone else’s alike — are `Success: false` with a null ' +
|
||||
'`Value` rather than a bare error body, which that client cannot parse.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(UpdateInventionMetadataRequest, 'The fields to change'),
|
||||
responses: {
|
||||
200: json(
|
||||
InventionSaveV9Result,
|
||||
'The envelope — the updated invention under `Value`, or `Success: false` with ' +
|
||||
'`Error` when the edit was refused'
|
||||
),
|
||||
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
|
||||
|
||||
// The id rides in the body here, not the query string.
|
||||
const gate = await creatorsInventionResult(
|
||||
c,
|
||||
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
)
|
||||
// Only a missing token is answered as a transport failure. An unknown invention
|
||||
// or someone else's is a domain answer the client is meant to read — its own
|
||||
// status enum has DoesNotExist and NotCreator members — so it goes in the
|
||||
// envelope, where the message reaches a human.
|
||||
if ('rejection' in gate) {
|
||||
return gate.status === 401
|
||||
? c.json(inventionSaveV9Failure(gate.rejection), 401)
|
||||
: c.json(inventionSaveV9Failure(gate.rejection))
|
||||
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
|
||||
if (!inventionDataFilename) {
|
||||
return c.json({ error: 'inventionDataFilename is required' }, 400)
|
||||
}
|
||||
|
||||
// Null is "leave it"; a string, empty or not, is an edit.
|
||||
const edited = (key: string): string | undefined =>
|
||||
typeof body[key] === 'string' ? body[key] : undefined
|
||||
const name = edited('Name')?.trim()
|
||||
const description = edited('Description')
|
||||
const longDescription = edited('LongDescription')
|
||||
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
|
||||
// "No description yet"), so only a supplied one is held to the rules — otherwise
|
||||
// saving an unnamed invention would fail the 3-character minimum on a name the
|
||||
// player never typed.
|
||||
const name = str(body.name)?.trim()
|
||||
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
|
||||
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
|
||||
|
||||
for (const rejection of [
|
||||
name === undefined ? null : inventionNameRejection(name),
|
||||
description === undefined ? null : inventionDescriptionRejection(description),
|
||||
longDescription === undefined ? null : inventionLongDescriptionRejection(longDescription),
|
||||
]) {
|
||||
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
|
||||
}
|
||||
const description = str(body.description)
|
||||
const descriptionRejection =
|
||||
description === undefined ? null : inventionDescriptionRejection(description)
|
||||
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
|
||||
|
||||
// A null TagsRequest leaves the stored tags alone, and the reply still reports
|
||||
// them: the client reads the list back as the tags the invention now has, not as
|
||||
// the ones this call changed.
|
||||
const requested = requestedTags(body.TagsRequest)
|
||||
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||||
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
|
||||
creatorPlayerId: id,
|
||||
inventionDataFilename,
|
||||
name,
|
||||
description,
|
||||
longDescription,
|
||||
imageName: edited('ImageName'),
|
||||
tags: requested?.tags,
|
||||
imageName: str(body.imageName),
|
||||
instantiationCost: num(body.instantiationCost),
|
||||
lightsCost: num(body.lightsCost),
|
||||
chipsCost: num(body.chipsCost),
|
||||
cloudVariablesCost: num(body.cloudVariablesCost),
|
||||
aiCost: num(body.aiCost),
|
||||
creationRoomId: num(body.creationRoomId),
|
||||
referencedInventions: Array.isArray(body.referencedInventions)
|
||||
? body.referencedInventions.filter((v): v is number => typeof v === 'number')
|
||||
: undefined,
|
||||
})
|
||||
if (updated === null) return c.json(inventionSaveV9Failure('No such invention'))
|
||||
return c.json(
|
||||
toSaveResultV9(
|
||||
updated,
|
||||
updated.Tags ?? [],
|
||||
requested?.tagResult ?? INVENTION_TAG_RESULT.success
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Edit an invention, as the newer client sends it: the `v1/update` write with a
|
||||
// PascalCase body instead of a query string. What the permission picker posts —
|
||||
// `{ InventionId, Permission }` — and so the endpoint that changes what other players
|
||||
// may DO with an invention, where `v2/metadata` beside it changes what it SAYS.
|
||||
//
|
||||
// Creator only, which is the whole point of the gate: `GeneralPermission` is what lets
|
||||
// another player edit, re-publish or charge for someone's build, so a write that took
|
||||
// the caller's word for the id would let anyone hand themselves rights over any
|
||||
// invention in the game.
|
||||
.post(
|
||||
'/api/inventions/v2/update',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Edit an invention (v2)',
|
||||
description:
|
||||
'The newer client’s form of `v1/update`: a PascalCase JSON body in place of the ' +
|
||||
'query string. A patch, not a replace — every field but `InventionId` is nullable ' +
|
||||
'and a null one is left as it is, since the client sends the whole shape on every ' +
|
||||
'edit.\n\n' +
|
||||
'**Creator only.** `Permission` decides what other players may do with the ' +
|
||||
'invention — up to editing, re-publishing and charging for it — so only the ' +
|
||||
'account that created it may set it.\n\n' +
|
||||
'`Permission` is the raw `GeneralPermission` ladder number (UseOnly 20, ' +
|
||||
'EditAndSave 40, Publish 60 …), stored verbatim and unmapped, exactly as ' +
|
||||
'`v1/update` stores the value its picker names. A null one LEAVES IT ALONE — ' +
|
||||
'unlike `v4/publish`, where null means UseOnly: publishing something with no ' +
|
||||
'permission named is a decision, but editing it without naming one is not.\n\n' +
|
||||
'Publishing, pricing and tags are elsewhere (`v4/publish`, `v1/updateprice`, ' +
|
||||
'`v2/metadata`), as they are for `v1/update`.\n\n' +
|
||||
'Answers the enveloped result `v9/save` answers, carrying the UPDATED invention: ' +
|
||||
'the client re-renders the detail sheet from `Value.Invention`, so a refusal — an ' +
|
||||
'unknown invention and someone else’s alike — is `Success: false` with a null ' +
|
||||
'`Value` rather than a bare error body, which that client cannot parse.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(UpdateInventionRequest, 'The fields to change'),
|
||||
responses: {
|
||||
200: json(
|
||||
InventionSaveV9Result,
|
||||
'The envelope — the updated invention under `Value`, or `Success: false` with ' +
|
||||
'`Error` when the edit was refused'
|
||||
),
|
||||
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
|
||||
|
||||
// The id rides in the body here, not the query string. The gate is what makes this
|
||||
// creator-only: it loads the invention and refuses anyone but the account that
|
||||
// created it.
|
||||
const gate = await creatorsInventionResult(
|
||||
c,
|
||||
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
)
|
||||
// Only a missing token is answered as a transport failure. An unknown invention or
|
||||
// someone else's is a domain answer this client is meant to read — its own status
|
||||
// enum has DoesNotExist and NotCreator members — so it goes in the envelope, where
|
||||
// the message reaches a human. Same split `v2/metadata` makes.
|
||||
if ('rejection' in gate) {
|
||||
return gate.status === 401
|
||||
? c.json(inventionSaveV9Failure(gate.rejection), 401)
|
||||
: c.json(inventionSaveV9Failure(gate.rejection))
|
||||
}
|
||||
|
||||
// A number is the picker's own form; a string is accepted through the same parser
|
||||
// `v1/update`'s query param goes through, so a build sending `"EditAndSave"` or
|
||||
// `"40"` lands on the same level. Anything else (null included) leaves it alone.
|
||||
const permission =
|
||||
typeof body.Permission === 'number'
|
||||
? body.Permission
|
||||
: typeof body.Permission === 'string'
|
||||
? parsePermissionLevel(body.Permission)
|
||||
: undefined
|
||||
|
||||
// Null is "leave it"; a string, empty or not, is an edit — `v2/metadata`'s rule,
|
||||
// since these are the same client's bodies.
|
||||
const edited = (key: string): string | undefined =>
|
||||
typeof body[key] === 'string' ? body[key] : undefined
|
||||
const name = edited('Name')?.trim()
|
||||
const description = edited('Description')
|
||||
const longDescription = edited('LongDescription')
|
||||
|
||||
for (const rejection of [
|
||||
name === undefined ? null : inventionNameRejection(name),
|
||||
description === undefined ? null : inventionDescriptionRejection(description),
|
||||
longDescription === undefined ? null : inventionLongDescriptionRejection(longDescription),
|
||||
]) {
|
||||
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
|
||||
}
|
||||
|
||||
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||||
generalPermission: permission,
|
||||
allowTrial: typeof body.AllowTrial === 'boolean' ? body.AllowTrial : undefined,
|
||||
name,
|
||||
description,
|
||||
longDescription,
|
||||
imageName: edited('ImageName'),
|
||||
})
|
||||
if (updated === null) return c.json(inventionSaveV9Failure('No such invention'))
|
||||
// The tags are reported as the ones the invention HAS, not as ones this call
|
||||
// changed — it changes none. Same reading `v2/metadata` gives a null TagsRequest.
|
||||
return c.json(toSaveResultV9(updated, updated.Tags ?? [], INVENTION_TAG_RESULT.success))
|
||||
}
|
||||
)
|
||||
|
||||
// Publish an invention, as the newer client sends it: a PascalCase body instead of a
|
||||
// query string, and an Accessibility of its own — where `v3/publish` only ever flipped
|
||||
// the published flag, this decides whether the result can be FOUND. Same enveloped
|
||||
// reply as `v9/save`, carrying the published invention.
|
||||
.post(
|
||||
'/api/inventions/v4/publish',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Publish an invention (v4)',
|
||||
description:
|
||||
'What puts an invention into search and the feeds. Creator only.\n\n' +
|
||||
'`Permission` is the `GeneralPermission` other players get, as a raw ladder ' +
|
||||
'number (the publish sheet sends 20, UseOnly). `Accessibility` says where it can ' +
|
||||
'be found — 1 (Public) lists it, 2 (Unlisted) publishes it reachable by id but ' +
|
||||
'keeps it out of browse and search. A null `Price` leaves the price alone rather ' +
|
||||
'than zeroing it, so re-publishing something that was for sale doesn’t give it ' +
|
||||
'away; every field but `InventionId` is nullable and an omitted one keeps what ' +
|
||||
'the invention has.\n\n' +
|
||||
'Publishing is not undone here, and re-publishing doesn’t re-date the first ' +
|
||||
'publish. Refusals answer `Success: false` with a null `Value`, the way ' +
|
||||
'`v9/save` does.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PublishInventionRequest, 'What the publish decides'),
|
||||
responses: {
|
||||
200: json(
|
||||
InventionSaveV9Result,
|
||||
'The envelope — the published invention under `Value`, or `Success: false` ' +
|
||||
'with `Error` when the publish was refused'
|
||||
),
|
||||
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
|
||||
|
||||
const gate = await creatorsInventionResult(
|
||||
c,
|
||||
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
)
|
||||
// As on `v2/metadata`: only a missing token is a transport failure. The rest are
|
||||
// answers the client is meant to read out of the envelope.
|
||||
if ('rejection' in gate) {
|
||||
return gate.status === 401
|
||||
? c.json(inventionSaveV9Failure(gate.rejection), 401)
|
||||
: c.json(inventionSaveV9Failure(gate.rejection))
|
||||
}
|
||||
|
||||
// Null is "leave it". The permission and accessibility are taken as sent rather
|
||||
// than checked against the ladder, the way `parsePermissionLevel` already accepts
|
||||
// a raw number: the ladders are the client's, and a level this server hasn't heard
|
||||
// of is better stored than swapped for one the creator didn't pick.
|
||||
const int = (key: string): number | undefined =>
|
||||
typeof body[key] === 'number' && Number.isInteger(body[key]) ? body[key] : undefined
|
||||
const price = int('Price')
|
||||
|
||||
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
|
||||
permissionLevel: int('Permission'),
|
||||
accessibility: int('Accessibility'),
|
||||
// A negative price is dropped rather than stored, as it is on `v3/publish`.
|
||||
price: price !== undefined && price < 0 ? undefined : price,
|
||||
})
|
||||
if (published === null) return c.json(inventionSaveV9Failure('No such invention'))
|
||||
return c.json(toSaveResultV9(published, published.Tags ?? []))
|
||||
}
|
||||
)
|
||||
|
||||
// Delete an invention. The newer client's shape: a POST with a PascalCase body
|
||||
// carrying nothing but the id. Auth-gated, creator only — the only thing that may
|
||||
// remove an invention is the account that made it, not a co-owner and not a buyer.
|
||||
//
|
||||
// The record and everything inside it (versions, tags, referenced-invention lists)
|
||||
// go in one DELETE; the data blob in R2 and the `inventory_invention` rows of
|
||||
// players who bought it are left alone. See `deleteInvention` for why.
|
||||
.post(
|
||||
'/api/inventions/v2/delete',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Delete an invention',
|
||||
description:
|
||||
'Creator only — a buyer or a co-owner cannot delete someone else’s invention. ' +
|
||||
'The record goes entirely: its versions, tags and referenced-invention lists live ' +
|
||||
'in the same row.\n\n' +
|
||||
'What survives is deliberate. The data blob stays in storage, because nothing ' +
|
||||
'here knows whether another record still points at that filename. The ownership ' +
|
||||
'rows of players who bought it stay too — a delete must not rewrite what someone ' +
|
||||
'else paid for — and they fall out of every list on their own, since an owned id ' +
|
||||
'with no invention row behind it is skipped.\n\n' +
|
||||
'Answers the `{ Value, Success, Error, error_id }` envelope the other v2+ ' +
|
||||
'invention routes use, with `Value` NULL: the invention is gone, so there is ' +
|
||||
'nothing to redraw from and the client reads only `Success`. Refusals — an ' +
|
||||
'unknown invention and someone else’s alike — are `Success: false` with a ' +
|
||||
'message, not a bare error body that client cannot parse.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(DeleteInventionRequest, 'The invention to delete'),
|
||||
responses: {
|
||||
200: json(InventionDeleteResult, 'The delete envelope, `Value` null either way'),
|
||||
401: json(InventionDeleteResult, 'The same envelope, refused — not an empty body'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json(inventionDeleteResult('Invalid request body'))
|
||||
|
||||
// The id rides in the body, as it does on `v2/metadata` and `v4/publish`.
|
||||
const gate = await creatorsInventionResult(
|
||||
c,
|
||||
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
)
|
||||
// As on those two: only a missing token is a transport failure. An unknown
|
||||
// invention or someone else's is a domain answer the client reads out of the
|
||||
// envelope, where the message reaches a human.
|
||||
if ('rejection' in gate) {
|
||||
return gate.status === 401
|
||||
? c.json(inventionDeleteResult(gate.rejection), 401)
|
||||
: c.json(inventionDeleteResult(gate.rejection))
|
||||
}
|
||||
|
||||
// The gate already loaded the row, so a null here is a race — someone deleted it
|
||||
// between the two reads — and lands where the client would put it anyway: gone.
|
||||
const deleted = await deleteInvention(c.env.DB, gate.invention.InventionId)
|
||||
return c.json(
|
||||
deleted === null ? inventionDeleteResult('No such invention') : inventionDeleteResult()
|
||||
)
|
||||
return c.json(toSaveResult(invention))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -29,19 +29,17 @@ export const configRoutes = new Hono<App>({ strict: false })
|
||||
tags: ['Config'],
|
||||
summary: 'Analytics keys',
|
||||
description:
|
||||
'The Amplitude / RudderStack / StatSig keys the client initialises its analytics ' +
|
||||
'with. This server collects nothing, so the keys are blank and RudderStack and ' +
|
||||
'StatSig are off — but the client needs the object to finish loading.',
|
||||
'The Amplitude / StatSig / RudderStack keys the client initialises its analytics ' +
|
||||
'with. This server collects nothing, so the keys are placeholders and RudderStack ' +
|
||||
'is off — but the client needs the object to finish loading.',
|
||||
responses: { 200: json(AmplitudeConfig, 'Placeholder analytics keys') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
AmplitudeKey: '',
|
||||
AmplitudeKey: 'a',
|
||||
StatSigKey: 'a',
|
||||
RudderStackKey: 'a',
|
||||
UseRudderStack: false,
|
||||
RudderStackKey: '',
|
||||
UseStatSig: false,
|
||||
StatSigKey: '',
|
||||
StatSigEnvironment: 0,
|
||||
})
|
||||
)
|
||||
.get(
|
||||
|
||||
+60
-290
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { Accessibility, createNotification, GAME_VERSION, MessageType } from '@repo/domain'
|
||||
import { Accessibility, GAME_VERSION } from '@repo/domain'
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
import { validateAndGetVersion } from '@repo/jwt'
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
getEventsByIds,
|
||||
getEventsByRoom,
|
||||
getEventTags,
|
||||
getGoingPlayerIds,
|
||||
getLiveEvents,
|
||||
inviteToEvent,
|
||||
isEventResponseType,
|
||||
@@ -36,10 +35,9 @@ import {
|
||||
toEventResult,
|
||||
updateEvent,
|
||||
} from '../events-db'
|
||||
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
|
||||
import { authedId, queryIds, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BulkIdsRequest,
|
||||
form,
|
||||
idParam,
|
||||
intQuery,
|
||||
@@ -71,11 +69,7 @@ import {
|
||||
import { createReport } from '../reports-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { StoredNotification } from '@repo/domain'
|
||||
import type {
|
||||
PlayerEventIdPayload,
|
||||
PlayerEventResponsePayload,
|
||||
} from '../../../notify/src/notification-payloads'
|
||||
import type { PlayerEventResponsePayload } from '../../../notify/src/notification-payloads'
|
||||
import type { App } from '../context'
|
||||
import type { EventAttendeeRow, EventInput, EventTag, PlayerEvent } from '../events-db'
|
||||
|
||||
@@ -110,84 +104,12 @@ async function notifyEventCreated(
|
||||
}
|
||||
|
||||
/**
|
||||
* Push one frame to every player who is GOING to an event — the audience for a change
|
||||
* to the event itself. Only Going rows (see {@link getGoingPlayerIds}): a player who is
|
||||
* merely invited, interested or declined has nothing on screen to update. The creator
|
||||
* is among them through their own Going row, so their own edit or delete reaches their
|
||||
* client too, the way `PlayerEventCreated` does.
|
||||
* Push a `PlayerEventResponseChanged` (83) to each player a bulk invite just added —
|
||||
* what puts the event on their screen without a refetch, since an invite writes their
|
||||
* response row for them.
|
||||
*
|
||||
* `playerIds` is passed in rather than read here because a delete has to read them
|
||||
* BEFORE the rows go. Hub failures are logged and swallowed, and one player's failure
|
||||
* doesn't stop the rest: the change is already stored by the time this runs.
|
||||
*/
|
||||
async function notifyGoing(
|
||||
c: Context<App>,
|
||||
playerIds: number[],
|
||||
type: NotificationType,
|
||||
payload: Record<string, unknown>,
|
||||
playerEventId: number
|
||||
): Promise<void> {
|
||||
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
|
||||
for (const playerId of playerIds) {
|
||||
try {
|
||||
await hub.notifyPlayer(playerId, type, payload)
|
||||
} catch (err) {
|
||||
logger.error('failed to push player event notification', {
|
||||
notificationType: type,
|
||||
playerEventId,
|
||||
playerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a `PlayerEventUpdated` (81) to everyone Going to an event that was just edited —
|
||||
* the whole-event update and every single-field edit alike — carrying the event as it
|
||||
* now stands, in the same camelCase {@link toEventNotification} projection the
|
||||
* `PlayerEventCreated` frame uses: the two share a payload shape on the client
|
||||
* (`PlayerEventPayload` in notification-payloads.ts documents its fields; the decoder
|
||||
* accepts either casing).
|
||||
*/
|
||||
async function notifyEventUpdated(
|
||||
c: Context<App>,
|
||||
event: PlayerEvent,
|
||||
tags: EventTag[]
|
||||
): Promise<void> {
|
||||
const payload: Record<string, unknown> = { ...toEventNotification(event, tags) }
|
||||
await notifyGoing(
|
||||
c,
|
||||
await getGoingPlayerIds(c.env.DB, event.PlayerEventId),
|
||||
NotificationType.PlayerEventUpdated,
|
||||
payload,
|
||||
event.PlayerEventId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a `PlayerEventDeleted` (82) to everyone who was Going to an event that was just
|
||||
* deleted, so it leaves their screen. The frame is just the id — there is no event left
|
||||
* to carry. `playerIds` must have been read before the delete took the RSVP rows with it.
|
||||
*/
|
||||
async function notifyEventDeleted(
|
||||
c: Context<App>,
|
||||
playerIds: number[],
|
||||
playerEventId: number
|
||||
): Promise<void> {
|
||||
const payload = { PlayerEventId: playerEventId } satisfies PlayerEventIdPayload
|
||||
await notifyGoing(c, playerIds, NotificationType.PlayerEventDeleted, payload, playerEventId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a `PlayerEventResponseChanged` (83) to a player who just ANSWERED an event
|
||||
* (`POST /api/playerevents/v1/respond`) — what puts the event on their screen without a
|
||||
* refetch. This is the responder's own frame, carrying their own row.
|
||||
*
|
||||
* An INVITE does not send this. What an invitee gets is the invitation as a Message
|
||||
* (see {@link pushEventInvitation}); their Pending row is not a response, and the
|
||||
* client populates the event from this frame only once they have actually responded.
|
||||
* Sending it on invite made the event appear as if the player had already answered.
|
||||
* Only the players who actually gained a row are notified: an invite that hit an
|
||||
* existing answer changed nothing, so there is nothing to tell them about.
|
||||
*
|
||||
* The frame carries BOTH nested objects the client's decoder expects. That is not
|
||||
* optional — several of its handlers dereference one level down with no null guard, so
|
||||
@@ -197,100 +119,30 @@ async function notifyEventDeleted(
|
||||
* response in the PascalCase {@link toEventResponse} one the RSVP list serves; the
|
||||
* decoder accepts either casing, so the two need not agree.
|
||||
*
|
||||
* Hub failures are logged and swallowed: the answer is already stored by the time this
|
||||
* runs.
|
||||
*/
|
||||
async function notifyResponseChanged(
|
||||
c: Context<App>,
|
||||
event: PlayerEvent,
|
||||
response: EventAttendeeRow
|
||||
): Promise<void> {
|
||||
const payload = {
|
||||
PlayerEvent: { ...toEventNotification(event) },
|
||||
PlayerEventResponse: { ...toEventResponse(response) },
|
||||
} satisfies PlayerEventResponsePayload
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
response.player_id,
|
||||
NotificationType.PlayerEventResponseChanged,
|
||||
payload
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push PlayerEventResponseChanged notification', {
|
||||
playerEventId: event.PlayerEventId,
|
||||
playerId: response.player_id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell each player a bulk invite just added about it: the invitation as a Message of
|
||||
* type 81 `PlayerEventInvitation`, stored and pushed as `MessageReceived` — see
|
||||
* {@link pushEventInvitation}. That Message is the whole of what an invitee is sent; the
|
||||
* `PlayerEventResponseChanged` frame is theirs only once they answer.
|
||||
*
|
||||
* Only the players who actually gained a row are notified: an invite that hit an
|
||||
* existing answer changed nothing, so there is nothing to tell them about. One player's
|
||||
* failure doesn't stop the rest: the invites are already stored by the time this runs.
|
||||
* Hub failures are logged and swallowed, and one player's failure doesn't stop the
|
||||
* rest: the invites are already stored by the time this runs.
|
||||
*/
|
||||
async function notifyInvited(
|
||||
c: Context<App>,
|
||||
inviterId: number,
|
||||
event: PlayerEvent,
|
||||
added: EventAttendeeRow[]
|
||||
): Promise<void> {
|
||||
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
|
||||
const PlayerEvent = { ...toEventNotification(event) }
|
||||
for (const row of added) {
|
||||
await pushEventInvitation(c, inviterId, row.player_id, event.PlayerEventId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store and push the invitation Message a bulk invite shows the invitee: a Message of
|
||||
* type 81 `PlayerEventInvitation` from the inviter, delivered as `MessageReceived` and
|
||||
* kept in the inbox (`GET /api/messages/v2/get`) so a player who was offline still finds
|
||||
* it. The event is named twice — on the row's own `PlayerEventId` column, and as `Data`,
|
||||
* the event id as a STRING (a Message's `Data` is always a string on the wire).
|
||||
*
|
||||
* The row is committed before the push; a hub failure is logged and swallowed, since the
|
||||
* invite itself is already stored.
|
||||
*/
|
||||
async function pushEventInvitation(
|
||||
c: Context<App>,
|
||||
fromPlayerId: number,
|
||||
toPlayerId: number,
|
||||
playerEventId: number
|
||||
): Promise<void> {
|
||||
let message: StoredNotification
|
||||
try {
|
||||
message = await createNotification(c.env.DB, {
|
||||
FromPlayerId: fromPlayerId,
|
||||
ToPlayerId: toPlayerId,
|
||||
Type: MessageType.PlayerEventInvitation,
|
||||
Data: String(playerEventId),
|
||||
PlayerEventId: playerEventId,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('failed to store PlayerEventInvitation message', {
|
||||
playerEventId,
|
||||
toPlayerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
toPlayerId,
|
||||
NotificationType.MessageReceived,
|
||||
{ ...message }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push PlayerEventInvitation MessageReceived notification', {
|
||||
notificationId: message.Id,
|
||||
playerEventId,
|
||||
toPlayerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
const payload = {
|
||||
PlayerEvent,
|
||||
PlayerEventResponse: { ...toEventResponse(row) },
|
||||
} satisfies PlayerEventResponsePayload
|
||||
try {
|
||||
await hub.notifyPlayer(row.player_id, NotificationType.PlayerEventResponseChanged, payload)
|
||||
} catch (err) {
|
||||
logger.error('failed to push PlayerEventResponseChanged notification', {
|
||||
playerEventId: event.PlayerEventId,
|
||||
playerId: row.player_id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,13 +150,12 @@ async function pushEventInvitation(
|
||||
* Wrap an event in the v2 envelope for THIS caller's build.
|
||||
*
|
||||
* Rec Room reshaped `PlayerEvent.Tags` without minting a new path, so the same endpoint
|
||||
* owes the 2023 build `["celebration"]` and anything newer `[{ Tag, Type }]`. The build
|
||||
* owes the 2023 build `[{ Tag, Type }]` and the 2025 build `["celebration"]`. The build
|
||||
* comes off the token's `rn.ver` claim — the request carries no version of its own — and
|
||||
* the split is the one `/api/gameconfigs/v1/all` already makes: anything NEWER than
|
||||
* `GAME_VERSION` (20230414) gets the pairs; that build, anything older, and a request
|
||||
* with no readable token version all get the 2023 names. Builds are date-stamped, so they
|
||||
* order as strings. `TagModifyResult.Tags` is a name list to every build — only the
|
||||
* inline field moves.
|
||||
* `GAME_VERSION` (20230414) is the 2025 client; that build, anything older, and a request
|
||||
* with no readable token version all get the 2023 shape. Builds are date-stamped, so they
|
||||
* order as strings.
|
||||
*
|
||||
* Like the other version gates here the claim is unverified — a client that lies about its
|
||||
* build only empties its own tag chips.
|
||||
@@ -312,7 +163,7 @@ async function pushEventInvitation(
|
||||
async function eventResult(c: Context<App>, event: PlayerEvent, tags: EventTag[]) {
|
||||
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const isModernBuild = version !== null && version > GAME_VERSION
|
||||
return toEventResult(event, tags, isModernBuild)
|
||||
return toEventResult(event, tags, !isModernBuild)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,7 +179,6 @@ async function eventResult(c: Context<App>, event: PlayerEvent, tags: EventTag[]
|
||||
*
|
||||
* These edits are creator-only like the whole-event update, and they go through the same
|
||||
* {@link updateEvent}, so a patch touching one field leaves the rest of the event alone.
|
||||
* Like it, each pushes a `PlayerEventUpdated` to everyone Going.
|
||||
*/
|
||||
function editEventField(
|
||||
parse: (c: Context<App>, event: PlayerEvent) => Promise<EventInput | null>
|
||||
@@ -348,9 +198,7 @@ function editEventField(
|
||||
if (input === null) return c.body(null, 400)
|
||||
const updated = await updateEvent(c.env.DB, eventId, input)
|
||||
// updateEvent only returns null when the row vanished, which the read above rules out.
|
||||
const tags = await getEventTags(c.env.DB, eventId)
|
||||
await notifyEventUpdated(c, updated!, tags)
|
||||
return c.json(await eventResult(c, updated!, tags))
|
||||
return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,12 +383,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
)
|
||||
|
||||
// A room's event shelf (`/room/12`) — what is on in this room, current and upcoming.
|
||||
// A bare array, no envelope: the single-club form's `{ ContinuationToken, Events }` is
|
||||
// the odd one out, and a room's shelf is small enough that there is nothing to page.
|
||||
//
|
||||
// The BASE event, not the stored record. The client reads this through the same generic
|
||||
// helper and the same element type as the browse feed and the bulk read, so those three
|
||||
// are shape-identical on its side — `toEventBase` is what keeps them identical here.
|
||||
// A bare array of the stored record, like the multi-club shelf and `/searchlive`: the
|
||||
// single-club form's `{ ContinuationToken, Events }` envelope is the odd one out, and a
|
||||
// room's shelf is small enough that there is nothing to page.
|
||||
.get(
|
||||
'/api/playerevents/v1/room/:roomId{[0-9]+}',
|
||||
describeRoute({
|
||||
@@ -548,25 +393,17 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
summary: 'Player events in one room',
|
||||
description:
|
||||
'The events scheduled in a room — the shelf on the room’s page — soonest first. A ' +
|
||||
'bare array of the client’s BASE event (17 keys — no `State`, `ImageName` as `""` ' +
|
||||
'rather than null, plus `BroadcastingRoomInstanceId`), the same projection the ' +
|
||||
'browse feed and the bulk read serve: the client decodes all three through one ' +
|
||||
'generic helper and one element type. `/searchlive` and the club shelves serve the ' +
|
||||
'stored record instead.\n\n' +
|
||||
'bare array of the stored record, the same projection `/searchlive` and the ' +
|
||||
'multi-club shelf serve.\n\n' +
|
||||
'CURRENT and UPCOMING only: the filter is on the END time, so a running event stays ' +
|
||||
'listed until it is over rather than vanishing the moment it starts, and an event ' +
|
||||
'that has finished is dropped — this answers what someone can still turn up to. A ' +
|
||||
'room with nothing scheduled, and a room id that does not exist, both answer an ' +
|
||||
'empty array; the shelf is about events, not about whether the room is real.',
|
||||
parameters: [idParam('roomId', 'Room id')],
|
||||
responses: {
|
||||
200: json(PlayerEventBaseDto.array(), 'The room’s current and upcoming events'),
|
||||
},
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The room’s current and upcoming events') },
|
||||
}),
|
||||
async (c) => {
|
||||
const events = await getEventsByRoom(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
|
||||
return c.json(events.map(toEventBase))
|
||||
}
|
||||
async (c) => c.json(await getEventsByRoom(c.env.DB, Number.parseInt(c.req.param('roomId'), 10)))
|
||||
)
|
||||
|
||||
// Live player-event search (the "happening now" browse query) — events that have
|
||||
@@ -620,62 +457,22 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Bulk fetch — the events behind a list of ids the client already holds. What the
|
||||
// client actually calls is the POST, with the ids in a form body
|
||||
// (`Ids=101&Ids=102&Ids=103`, or `Ids=13` for one); the GET below is the same read with
|
||||
// the ids in the query, kept for hand-written calls.
|
||||
//
|
||||
// The BASE event, like the browse feed and the room shelf: one generic helper and one
|
||||
// element type decode all three on the client, so this is "the feed, filtered to these
|
||||
// ids" and must not drift into the stored-record shape the by-id read serves.
|
||||
//
|
||||
// Answers in the order asked for — the client renders them in request order — and skips
|
||||
// ids with no event rather than leaving a hole, so the result may be shorter than the
|
||||
// request. A bare array either way: no envelope, no `{ ContinuationToken, Events }`.
|
||||
.post(
|
||||
// Bulk fetch (`?id=1&id=2`) — the events behind a list of ids the client already
|
||||
// holds. Answers in the order asked for; ids with no event are skipped.
|
||||
.get(
|
||||
'/api/playerevents/v1/bulk',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Several player events by id',
|
||||
description:
|
||||
'The events behind a list of ids the client already holds, as a form body: `Ids` ' +
|
||||
'repeated once per id (`Ids=101&Ids=102&Ids=103`), or one comma-separated `Ids=1,2,3`. ' +
|
||||
'A bare array of the client’s BASE event — the same projection the browse feed and ' +
|
||||
'the room shelf serve, this one filtered to the requested ids.\n\n' +
|
||||
'Answers in the order the ids were asked for and skips ids with no event rather ' +
|
||||
'than leaving a hole, so the result may be shorter than the request. No ids at all ' +
|
||||
'is an empty array, not a 400.',
|
||||
requestBody: form(BulkIdsRequest, 'The event ids to look up'),
|
||||
responses: {
|
||||
200: json(PlayerEventBaseDto.array(), 'The events that exist, in request order'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const events = await getEventsByIds(c.env.DB, await parseFormIds(c))
|
||||
return c.json(events.map(toEventBase))
|
||||
}
|
||||
)
|
||||
|
||||
// The same read with the ids in the query (`?id=1&id=2`) — not what the client sends,
|
||||
// but the shape stays identical to the POST's so the path can't answer two things.
|
||||
.get(
|
||||
'/api/playerevents/v1/bulk',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Several player events by id (query form)',
|
||||
description:
|
||||
'The same read as the POST on this path, with the ids in the query (`?id=1&id=2`) ' +
|
||||
'rather than a form body — the client sends the POST. Identical response: a bare ' +
|
||||
'array of the BASE event, in request order, skipping ids with no event.',
|
||||
'The events behind a list of ids the client already holds (`?id=1&id=2`). Answers ' +
|
||||
'in the order the ids were asked for — the client renders them in request order — ' +
|
||||
'and skips ids with no event rather than leaving a hole, so the result may be ' +
|
||||
'shorter than the request. A bare array.',
|
||||
parameters: [intQuery('id', 'Repeatable event id')],
|
||||
responses: {
|
||||
200: json(PlayerEventBaseDto.array(), 'The events that exist, in request order'),
|
||||
},
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The events that exist, in request order') },
|
||||
}),
|
||||
async (c) => {
|
||||
const events = await getEventsByIds(c.env.DB, queryIds(c))
|
||||
return c.json(events.map(toEventBase))
|
||||
}
|
||||
async (c) => c.json(await getEventsByIds(c.env.DB, queryIds(c)))
|
||||
)
|
||||
|
||||
// RSVP. One row per player per event, so responding again replaces the previous
|
||||
@@ -697,13 +494,8 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
'event. Answers the same `{ Result, TagModifyResult, PlayerEvent }` envelope the ' +
|
||||
'v2 writes do, carrying the event with its updated count, so the client can ' +
|
||||
're-render from the response.\n\n' +
|
||||
'The caller is also pushed a `PlayerEventResponseChanged` frame carrying the event ' +
|
||||
'and their new response row — this frame, not the invite, is what populates the ' +
|
||||
'event on the client. An invitee answering Pending → Going gets it here like ' +
|
||||
'anyone else.\n\n' +
|
||||
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400 — 3 ' +
|
||||
'Pending is the server’s own mark for an unanswered invite, not an answer a ' +
|
||||
'player can give; an unknown event is a 404.',
|
||||
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400; an ' +
|
||||
'unknown event is a 404.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'),
|
||||
responses: {
|
||||
@@ -726,10 +518,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
// Going would silently inflate the count.
|
||||
if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400)
|
||||
|
||||
const result = await setEventResponse(c.env.DB, eventId, id, type)
|
||||
if (result === null) return c.body(null, 404)
|
||||
await notifyResponseChanged(c, result.event, result.response)
|
||||
return c.json(await eventResult(c, result.event, await getEventTags(c.env.DB, eventId)))
|
||||
const updated = await setEventResponse(c.env.DB, eventId, id, type)
|
||||
if (updated === null) return c.body(null, 404)
|
||||
return c.json(await eventResult(c, updated, await getEventTags(c.env.DB, eventId)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -797,30 +588,20 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
)
|
||||
|
||||
// Bulk invite — the "invite friends" button on an event. Adds the invited players to
|
||||
// the same `event_attendee` table an RSVP writes to, as Pending (3) — an open
|
||||
// invitation, not an acceptance.
|
||||
// the same `event_attendee` table an RSVP writes to, as Going.
|
||||
.post(
|
||||
'/api/playerevents/v1/bulkInvite',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Invite players to an event',
|
||||
description:
|
||||
'Adds the invited players to the event as Pending (`Type` 3) — the same ' +
|
||||
'`event_attendee` rows an RSVP writes, so an invited player shows up in ' +
|
||||
'`…/responses` with an open invitation. Pending is not Going: the invitee does not ' +
|
||||
'count toward `AttendeeCount` until they answer through `…/respond`. (Inviting as ' +
|
||||
'Going made the invitation look already accepted on the client.)\n\n' +
|
||||
'Adds the invited players to the event as Going — the same `event_attendee` rows ' +
|
||||
'an RSVP writes, so an invited player shows up in `…/responses` and counts toward ' +
|
||||
'`AttendeeCount` immediately, without having answered.\n\n' +
|
||||
'An invite never overwrites an answer: a player who already responded keeps what ' +
|
||||
'they said, so inviting someone who declined does not reset them to Pending, and ' +
|
||||
'they said, so inviting someone who declined does not flip them back to Going, and ' +
|
||||
're-inviting is a no-op. The caller is skipped (they are already on the list), as ' +
|
||||
'are duplicate ids.\n\n' +
|
||||
'Each newly invited player is sent the invitation as a Message of type 81 ' +
|
||||
'`PlayerEventInvitation` from the caller — stored in their inbox ' +
|
||||
'(`GET /api/messages/v2/get`) and pushed as `MessageReceived`, with the event id ' +
|
||||
'on `PlayerEventId` and, as a string, in `Data`. That is all an invitee gets: no ' +
|
||||
'`PlayerEventResponseChanged` is pushed on invite — that frame is what `…/respond` ' +
|
||||
'sends once they answer, and is what populates the event on their client. ' +
|
||||
'Players whose existing answer was left alone get nothing.\n\n' +
|
||||
'The caller must be on the event themselves — its creator, or a player with a ' +
|
||||
'response row of any kind. Anyone else gets 403: an invite adds attendees, so it ' +
|
||||
'is not something a passer-by can do. Answers the same ' +
|
||||
@@ -829,7 +610,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PlayerEventBulkInviteRequest, 'The event and who to invite'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The event (its attendee count is unchanged by an invite)'),
|
||||
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'),
|
||||
400: { description: 'Missing `PlayerEventId` or `InvitedPlayerIds` (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'The caller is not on the event (empty body)' },
|
||||
@@ -869,7 +650,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
]
|
||||
const result = await inviteToEvent(c.env.DB, eventId, invited)
|
||||
// inviteToEvent only returns null when the row vanished, which the read above rules out.
|
||||
await notifyInvited(c, id, result!.event, result!.added)
|
||||
await notifyInvited(c, result!.event, result!.added)
|
||||
return c.json(await eventResult(c, result!.event, await getEventTags(c.env.DB, eventId)))
|
||||
}
|
||||
)
|
||||
@@ -948,9 +729,6 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
'Deletes an event the caller created, along with its RSVPs and its tags — an event ' +
|
||||
'whose attendee rows outlived it would still be counted, and its tags would still ' +
|
||||
'answer `#tag` searches.\n\n' +
|
||||
'Everyone who was GOING to the event (the creator included) is pushed a ' +
|
||||
'`PlayerEventDeleted` frame naming the event id, so it leaves their screen; ' +
|
||||
'interested, declined and still-Pending invitees are not.\n\n' +
|
||||
'Creator only: anyone else gets 403, and an unknown event 404. Answers the v2 ' +
|
||||
'envelope with `PlayerEvent` and `TagModifyResult` both null — the event is gone, ' +
|
||||
'so there is nothing for the client to redraw from, and it reads only `Result`. ' +
|
||||
@@ -974,10 +752,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
if (existing === null) return c.body(null, 404)
|
||||
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
|
||||
|
||||
// Who to tell has to be read BEFORE the delete: it takes the RSVP rows with it.
|
||||
const going = await getGoingPlayerIds(c.env.DB, eventId)
|
||||
await deleteEvent(c.env.DB, eventId)
|
||||
await notifyEventDeleted(c, going, eventId)
|
||||
// Both payload fields are null here — the delete envelope is not the one the other
|
||||
// v2 routes answer with. Nothing is left to redraw, and the client reads `Result`.
|
||||
return c.json(EVENT_DELETED_RESULT)
|
||||
@@ -1030,9 +805,6 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
'The id, the creator and the attendee count are not editable: ownership doesn’t ' +
|
||||
'transfer and RSVPs aren’t set by hand. Creator only — anyone else gets 403, and ' +
|
||||
'an unknown event is 404. Answers the same envelope as create.\n\n' +
|
||||
'Everyone who is GOING to the event (the creator included) is pushed a ' +
|
||||
'`PlayerEventUpdated` frame carrying the edited event; interested, declined and ' +
|
||||
'still-Pending invitees are not. The single-field edits push the same frame.\n\n' +
|
||||
'The 24-hour window cap applies to what the post RESOLVES to, not to what it ' +
|
||||
'carries: moving the start alone still has to leave a window that ends after it ' +
|
||||
'and runs no longer than a day against the STORED end.',
|
||||
@@ -1067,9 +839,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
if (eventInputRejection(input, existing) !== null) return c.body(null, 400)
|
||||
const updated = await updateEvent(c.env.DB, eventId, input)
|
||||
// updateEvent only returns null when the row vanished, which the read above rules out.
|
||||
const tags = await getEventTags(c.env.DB, eventId)
|
||||
await notifyEventUpdated(c, updated!, tags)
|
||||
return c.json(await eventResult(c, updated!, tags))
|
||||
return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId)))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -227,32 +227,6 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
(c) => c.json(communityBoard)
|
||||
)
|
||||
// Circuit chip lists — the palettes the Maker Pen's circuit board groups its chips into
|
||||
// (`/api/CircuitChipLists/Favorites`, `/api/CircuitChipLists/Recent`, and so on). The path
|
||||
// segment names the list; nothing here records which chips a player has used or favourited,
|
||||
// so every one of them is empty.
|
||||
//
|
||||
// EMPTY rather than 404 for a name this server doesn't know: the client asks for whichever
|
||||
// palettes its build has, and an unknown one is a palette this server has no opinion about
|
||||
// rather than an error — a 404 shows as a palette that failed to load, where an empty list
|
||||
// shows as one with nothing in it, which is the truth for all of them.
|
||||
.get(
|
||||
'/api/CircuitChipLists/:list',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'One circuit chip list',
|
||||
description:
|
||||
'A palette on the Maker Pen’s circuit board, named by the path (`Favorites`, ' +
|
||||
'`Recent`, …). Always empty: nothing records which chips a player has used or ' +
|
||||
'favourited yet. An unknown name is empty too rather than a 404 — the client asks ' +
|
||||
'for whichever palettes its build has, and a 404 renders as a palette that failed ' +
|
||||
'to load rather than one with nothing in it.',
|
||||
parameters: [stringParam('list', 'The palette name, e.g. `Favorites`')],
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Player events live in their own controller (routes/events.ts) — they're D1-backed
|
||||
// now, unlike the stubs around them here.
|
||||
.get(
|
||||
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
UploadImageRequest,
|
||||
UploadImageResponse,
|
||||
} from '../openapi'
|
||||
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
@@ -73,17 +72,6 @@ const typeFolder: Record<number, string> = {
|
||||
*/
|
||||
const PHOTO_TAGGING_KEY = 'playerPhotoTaggingSetting'
|
||||
|
||||
/**
|
||||
* The owner's-view clause the player photo lists share in their docs. The lists are
|
||||
* public, but they read a Bearer token if one is sent, and the one player it can unlock
|
||||
* anything for is the player whose list it is.
|
||||
*/
|
||||
const OWN_VIEW_NOTE =
|
||||
'Public, but reads a Bearer token if one is sent: a player asking for THEIR OWN list ' +
|
||||
'gets their private photos in it too (`Accessibility` 0 — that is where the photos they ' +
|
||||
'have not shared yet live). Any other player’s token, no token, or an invalid one serves ' +
|
||||
'the public list; none of those is a 401.'
|
||||
|
||||
/**
|
||||
* The preference a player has before they have ever set one. The value is an opaque enum
|
||||
* ordinal to this server (see `PhotoTaggingSettingRequest`), and 0 is what an unset .NET
|
||||
@@ -222,7 +210,6 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
200: json(UploadImageResponse, 'The stored bucket key'),
|
||||
400: json(ErrorResponse, 'No file in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
413: json(ErrorResponse, 'The image exceeds the configured per-file limit'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
@@ -234,12 +221,6 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
const candidate = body.image ?? body.file
|
||||
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
|
||||
const file = candidate
|
||||
const limit = maxApiUploadBytes(c.env)
|
||||
// parseBody has already materialized the multipart part. Reject it before arrayBuffer()
|
||||
// creates another full-size allocation and before the object can consume R2 storage.
|
||||
if (exceedsApiUploadLimit(file, limit)) {
|
||||
return c.json({ error: `image exceeds the ${limit}-byte upload limit` }, 413)
|
||||
}
|
||||
|
||||
// `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`),
|
||||
// posted as a multipart field. It carries the metadata we record on the image
|
||||
@@ -384,22 +365,15 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
// A player's photos — the public images that player has taken, newest first.
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array of the
|
||||
// client's ImagesPlayer projection (SavedImageId/SavedImageType, not Id/Type).
|
||||
//
|
||||
// Public, but a token is READ if one is sent: a player asking for their own list gets
|
||||
// their private photos in it too (the profile they open on themselves is where those
|
||||
// live), while anyone else's token — or none — serves the public list. A bad token is
|
||||
// not a 401 here; it just reads as nobody.
|
||||
.get(
|
||||
'/api/images/v4/player/:playerId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'A player’s photos',
|
||||
description:
|
||||
'The public camera photos (`SavedImageType` 1) that player has taken, newest first — ' +
|
||||
'not the outfit, room, invention or profile thumbnails filed under them. Serves the client’s ' +
|
||||
'The public images that player has taken, newest first. Serves the client’s ' +
|
||||
'`ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) ' +
|
||||
'— the raw `SavedImage` renders blank thumbnails here.\n\n' +
|
||||
OWN_VIEW_NOTE,
|
||||
'— the raw `SavedImage` renders blank thumbnails here.',
|
||||
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
|
||||
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s photos') },
|
||||
}),
|
||||
@@ -407,8 +381,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const viewer = await authedId(c)
|
||||
const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take, viewer)
|
||||
const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
}
|
||||
)
|
||||
@@ -420,9 +393,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'A player’s photos, sortable',
|
||||
description:
|
||||
'v4 plus a `sort` option. Same `ImagesPlayer` projection, and the same owner’s view — ' +
|
||||
'see the notes on v4.',
|
||||
description: 'v4 plus a `sort` option. Same `ImagesPlayer` projection — see the note on v4.',
|
||||
parameters: [
|
||||
idParam('playerId', 'Account id'),
|
||||
intQuery('sort', '1 = most cheered; anything else = newest first'),
|
||||
@@ -435,8 +406,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const viewer = await authedId(c)
|
||||
const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take, viewer)
|
||||
const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
}
|
||||
)
|
||||
@@ -450,11 +420,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
tags: ['Images'],
|
||||
summary: 'A player’s photo feed',
|
||||
description:
|
||||
'The public camera photos they took PLUS the ones they are tagged in, newest first — the ' +
|
||||
'photo tab on a profile. Same `ImagesPlayer` projection as the player photo lists.\n\n' +
|
||||
OWN_VIEW_NOTE +
|
||||
' A photo someone else took and tagged them in stays public-only either way: its ' +
|
||||
'privacy is the taker’s.',
|
||||
'The public images they took PLUS the ones they are tagged in, newest first — the ' +
|
||||
'photo tab on a profile. Same `ImagesPlayer` projection as the player photo lists.',
|
||||
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
|
||||
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s feed') },
|
||||
}),
|
||||
@@ -462,8 +429,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const viewer = await authedId(c)
|
||||
const images = await getPlayerFeed(c.env.DB, playerId, skip, take, viewer)
|
||||
const images = await getPlayerFeed(c.env.DB, playerId, skip, take)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,60 +1,25 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import {
|
||||
canManageRoom,
|
||||
canModerateRoom,
|
||||
countPlayersInInstance,
|
||||
getPlayerIdsInInstance,
|
||||
getPresence,
|
||||
getPresences,
|
||||
getRoomById,
|
||||
getStoredRoomInstance,
|
||||
MessageType,
|
||||
movePlayerToDorm,
|
||||
} from '@repo/domain'
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
|
||||
// The message being reported, and whether the reporter can see it — `chat` owns both tables
|
||||
// on the shared database, so its reads are used rather than restated here.
|
||||
import { getMessage } from '../../../chat/src/message-db'
|
||||
import { isThreadMember } from '../../../chat/src/thread-db'
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
|
||||
// value — the enum has no runtime dependencies.
|
||||
import { KickReportCategory } from '../../../notify/src/notification-payloads'
|
||||
import { NotificationType } from '../../../notify/src/notification-types'
|
||||
import { authedId, authedRoles, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BareBoolean,
|
||||
CreateChatReportRequest,
|
||||
CreateReportRequest,
|
||||
CreateWarningRequest,
|
||||
DeviceIdRequest,
|
||||
form,
|
||||
InstantKickRequest,
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
ModerationBlockDetails,
|
||||
RoomModKickRequest,
|
||||
SuccessErrorEnvelope,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
VoteToKickReason,
|
||||
VoteToKickRequest,
|
||||
} from '../openapi'
|
||||
import { banBlockDetails, createReport, getActiveBan, NOT_BLOCKED } from '../reports-db'
|
||||
import {
|
||||
countKickVotes,
|
||||
getOpenVote,
|
||||
isKickMajority,
|
||||
isOnVoteCooldown,
|
||||
recordRoomVote,
|
||||
} from '../votes-db'
|
||||
import { createReport } from '../reports-db'
|
||||
import { createWarning } from '../warnings-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { ModerationKickPayload } from '../../../notify/src/notification-payloads'
|
||||
import type { App } from '../context'
|
||||
|
||||
/**
|
||||
@@ -79,15 +44,6 @@ function formField(
|
||||
return c.req.query(name) || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a posted `Response` is a YES. The client sends `True`/`False`; `1`/`0` are accepted
|
||||
* beside them because the same field is an int elsewhere in this controller. Anything else —
|
||||
* absent included — is a no, which is the safe way round: an unreadable answer must not count
|
||||
* toward removing somebody.
|
||||
*/
|
||||
const isYesVote = (value: string | undefined): boolean =>
|
||||
value !== undefined && ['true', '1', 'yes'].includes(value.trim().toLowerCase())
|
||||
|
||||
/** Parse a field as an integer, or null when absent / not a number. */
|
||||
const asInt = (v: string | undefined): number | null => {
|
||||
if (v === undefined) return null
|
||||
@@ -102,39 +58,6 @@ const asFloat = (v: string | undefined): number | null => {
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat-report category names that are not a `KickReportCategory` member's own name. Only
|
||||
* `Discriminatory` has been seen on the wire; add the others as the client is seen to send
|
||||
* them, rather than guessing at an enum nobody here has a copy of.
|
||||
*/
|
||||
const CHAT_REPORT_CATEGORY_ALIASES: Record<string, KickReportCategory> = {
|
||||
discriminatory: KickReportCategory.CoCDiscrimination,
|
||||
}
|
||||
|
||||
/**
|
||||
* The numeric category a chat report is filed under. The chat report sends its category as
|
||||
* an enum NAME (`ReportCategory=Discriminatory`) where every other report sends a number,
|
||||
* and `report.report_category` is numeric — it is what the staff panel labels rows from and
|
||||
* what a ban's block screen names as the reason — so the name is mapped onto the same
|
||||
* `KickReportCategory` codes the other reports carry. A number is taken as it comes.
|
||||
*
|
||||
* Null for a name that maps to nothing, which the caller files as `Unknown` with the name
|
||||
* kept in the details: losing which box the reporter ticked would be worse than an odd-looking
|
||||
* description.
|
||||
*/
|
||||
function chatReportCategory(raw: string | undefined): number | null {
|
||||
if (raw === undefined) return KickReportCategory.Unknown
|
||||
const name = raw.trim()
|
||||
if (/^-?\d+$/.test(name)) return Number.parseInt(name, 10)
|
||||
const key = name.toLowerCase()
|
||||
const alias = CHAT_REPORT_CATEGORY_ALIASES[key]
|
||||
if (alias !== undefined) return alias
|
||||
for (const [member, value] of Object.entries(KickReportCategory)) {
|
||||
if (typeof value === 'number' && member.toLowerCase() === key) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The vote-to-kick reasons, in the order the client renders them. `ReportCategory` is the
|
||||
* category the report a carried vote files: 102 hate, 101 sexual content, 103 griefing,
|
||||
@@ -159,201 +82,16 @@ const VOTE_TO_KICK_REASONS = [
|
||||
{ Reason: 'Not following game rules', ReportCategory: 6 },
|
||||
] as const
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/**
|
||||
* Eject players from the instance they're standing in — the `ModerationKick` frame (id 22)
|
||||
* the client acts on to leave a room, the same one a room ban sends (`rooms`:
|
||||
* `pushRoomBan`). This one only kicks: `IsBan` is false, so nothing keeps them from walking
|
||||
* straight back in, and no ban row exists to lift.
|
||||
*
|
||||
* Sent EPHEMERALLY, unlike the ban's frame, and to the whole batch in one round-trip. A
|
||||
* kick is only true of the moment it happened: queued and delivered on the player's next
|
||||
* connect it would throw them out of some unrelated session hours later. A recipient who
|
||||
* has already gone offline needs no kick anyway.
|
||||
*
|
||||
* `GameSessionId` is the instance they're being removed from — every recipient is in it,
|
||||
* which is what the caller checked before this runs. `IsHostKick` is always true: this
|
||||
* endpoint is the room's own staff acting, never the room majority vote-kicking (that path
|
||||
* would carry `VoteKick` and false). Built against the client's recovered payload interface
|
||||
* so a renamed key fails the build rather than vanishing on the wire.
|
||||
*
|
||||
* Best-effort — presence is already deleted by the time this runs, so a hub hiccup must
|
||||
* not fail the request.
|
||||
*/
|
||||
async function pushInstantKick(
|
||||
c: Context<App>,
|
||||
playerIds: number[],
|
||||
gameSessionId: number,
|
||||
roomName: string,
|
||||
moderatorId: number,
|
||||
{ reason = '', isHostKick = true }: { reason?: string; isHostKick?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const frame: ModerationKickPayload = {
|
||||
ReportCategory: KickReportCategory.Moderator,
|
||||
Duration: 0,
|
||||
GameSessionId: gameSessionId,
|
||||
IsHostKick: isHostKick,
|
||||
Message: reason
|
||||
? `You have been kicked from ${roomName}. Reason: ${reason}`
|
||||
: `You have been kicked from ${roomName}.`,
|
||||
PlayerIdReporter: moderatorId,
|
||||
IsBan: false,
|
||||
IsVoiceModAutoban: false,
|
||||
IsWarning: false,
|
||||
VoteKickReason: '',
|
||||
TimeoutStartedAt: null,
|
||||
}
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
|
||||
playerIds,
|
||||
NotificationType.ModerationKick,
|
||||
{ ...frame }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push ModerationKick notification', {
|
||||
playerIds,
|
||||
gameSessionId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a vote-to-kick Message carries, and it is not what this once sent.
|
||||
*
|
||||
* `FromPlayerId` is the player being VOTED ON, not the one who called the vote. The client
|
||||
* raises its prompt about whoever that names, so sending the caller put the voter's own name
|
||||
* on the prompt — "kick 153?" when 153 was the one asking to kick 187. Nothing on the frame
|
||||
* identifies the caller, and nothing needs to: the prompt is about the accused.
|
||||
*
|
||||
* `Data` is the REASON, as plain text (`Inactive in games (AFK)`) — the label the client
|
||||
* offered in `voteToKickReasons` and posted back. It used to be an escaped JSON object of
|
||||
* `{ PlayerId, Response, GameSessionId }`, which reached players as that JSON printed on the
|
||||
* prompt where the reason belongs.
|
||||
*
|
||||
* It is still a STRING on the wire, which is the part that has not changed: a Message's `Data`
|
||||
* is a string like every other Message's, and an object there fails the client's decoder
|
||||
* outright (`expected:'String Begin Token', actual:'{'`), aborting the whole notification
|
||||
* rather than dropping the field. A reason is text, so it needs no escaping to satisfy that.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Message a vote-to-kick frame carries — the same four fields as every other Message
|
||||
* this server sends (see the `social` routes' `Message`), `Data` string included. A type
|
||||
* alias rather than an interface: the hub's send takes an index-signature record, which
|
||||
* only aliases satisfy implicitly.
|
||||
*/
|
||||
type VoteToKickMessage = {
|
||||
FromPlayerId: number
|
||||
ToPlayerId: number
|
||||
Type: number
|
||||
Data: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a vote-to-kick to one player — a `MessageReceived` frame carrying a Message of type
|
||||
* 5 (`VoteToKick`), the frame their client raises the vote prompt from. Resolves false when
|
||||
* the hub could not be reached, which the caller reports honestly: nothing stores a vote,
|
||||
* so the notification is the whole delivery.
|
||||
*
|
||||
* EPHEMERAL, unlike the messages the social routes send. A vote belongs to the moment it
|
||||
* was called: queued for an offline player, it would raise a prompt on their next connect
|
||||
* about a session that ended hours ago, and there would be nothing left to vote on.
|
||||
*/
|
||||
async function pushVoteToKick(c: Context<App>, message: VoteToKickMessage): Promise<boolean> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayerEphemeral(
|
||||
message.ToPlayerId,
|
||||
NotificationType.MessageReceived,
|
||||
message
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
logger.error('failed to push VoteToKick MessageReceived notification', {
|
||||
toPlayerId: message.ToPlayerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry out a vote-to-kick that has passed: the player is moved into their own dorm and gets a
|
||||
* `ModerationKick` frame (id 22), exactly as the staff kicks in this controller do.
|
||||
*
|
||||
* It differs from those in who it says did it. `ReportCategory` is `VoteKick` (10) and
|
||||
* `IsHostKick` is false — the ROOM removed them, not its host — and `PlayerIdReporter` is the
|
||||
* player whose vote carried it rather than a moderator. `IsBan` is false: a vote-kick ends the
|
||||
* session they are in and nothing more, so they may walk straight back in, and a room that
|
||||
* wants them gone for good bans them.
|
||||
*
|
||||
* `VoteKickReason` carries the reason the vote was called on, which is the field the client
|
||||
* has for it — unlike the staff kicks, which leave it empty and put their text in `Message`.
|
||||
*
|
||||
* Best-effort, and deliberately after the ballot is recorded: the vote happened and was
|
||||
* counted whatever the hub does next.
|
||||
*/
|
||||
async function applyVoteKick(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
gameSessionId: number,
|
||||
reason: string,
|
||||
carriedByAccountId: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Into their own dorm, not out of existence — see the note in `instantKick`.
|
||||
await movePlayerToDorm(c.env.DB, playerId)
|
||||
|
||||
const instance = await getStoredRoomInstance(c.env.DB, gameSessionId)
|
||||
const room = instance && (await getRoomById(c.env.DB, instance.roomId))
|
||||
const roomName = typeof room?.Name === 'string' ? room.Name : 'this room'
|
||||
|
||||
const frame: ModerationKickPayload = {
|
||||
ReportCategory: KickReportCategory.VoteKick,
|
||||
Duration: 0,
|
||||
GameSessionId: gameSessionId,
|
||||
IsHostKick: false,
|
||||
Message: `You have been kicked from ${roomName}.`,
|
||||
PlayerIdReporter: carriedByAccountId,
|
||||
IsBan: false,
|
||||
IsVoiceModAutoban: false,
|
||||
IsWarning: false,
|
||||
VoteKickReason: reason,
|
||||
TimeoutStartedAt: null,
|
||||
}
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
|
||||
[playerId],
|
||||
NotificationType.ModerationKick,
|
||||
{ ...frame }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to carry out a vote kick', {
|
||||
playerId,
|
||||
gameSessionId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Player reporting ------------------------------------------------------
|
||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). The one
|
||||
// kind of block this server has is the account-wide ban — a `report` row with `banned`
|
||||
// set (see `getActiveBan`), the same row matchmake refuses on — so a caller with one in
|
||||
// force gets it described here, and everyone else gets the "not blocked" answer of the
|
||||
// reference server's stub `ReturnModerationBlockDetails()`. This is the screen a banned
|
||||
// player is shown, which is why `auth` still issues them a token: without one the client
|
||||
// never gets here, and the ban reads as a failed sign-in.
|
||||
// Only the caller's OWN account is consulted, not the evasion arms `resolveBan` adds
|
||||
// at matchmake and login: this screen explains a ban handed to this account, and a
|
||||
// player blocked for sharing a network with a banned one has no report row to show.
|
||||
// In the "not blocked" answer `ReportCategory` is `Unknown` (-1) rather than 0, which
|
||||
// is a real category, and `Message` is null, not the empty string that stub sends —
|
||||
// the client distinguishes "no message" from a blank one. `IsVoiceModAutoban` /
|
||||
// `TimeoutStartedAt` are on the DTO but unset there, so they go out with their C#
|
||||
// defaults.
|
||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
|
||||
// are stored (a report row with `banned` set) and enforced at matchmake and at login,
|
||||
// but this endpoint is not wired to them, so it's always the "not blocked" answer —
|
||||
// the reference server's stub `ReturnModerationBlockDetails()`.
|
||||
// `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category;
|
||||
// `Message` is null, not the empty string that stub sends — the client distinguishes
|
||||
// "no message" from a blank one. `IsVoiceModAutoban`/`TimeoutStartedAt` are on the
|
||||
// DTO but left unset there, so they go out with their C# defaults.
|
||||
// The newer client POSTs this with no body despite it being a pure read; it answers
|
||||
// GET too, so the path is reachable from either build.
|
||||
.on(
|
||||
@@ -363,42 +101,29 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
tags: ['Moderation'],
|
||||
summary: 'Whether the caller is blocked',
|
||||
description:
|
||||
'Ban / timeout / host-kick state for the caller. The one block this server hands ' +
|
||||
'out is the account-wide ban — a `report` row with `banned` set, the same row ' +
|
||||
'matchmake refuses on (login still issues a token, so the client can reach this ' +
|
||||
'screen) — so a caller with one in force gets ' +
|
||||
'`IsBan: true`, the `ReportCategory` the report was filed under, the fixed ' +
|
||||
'`Message` “Rule violation”, and the block’s span as the pair the client reads ' +
|
||||
'them as: `TimeoutStartedAt` is when the ban was handed down (`banned_at`, ' +
|
||||
'falling back to the report’s `created_at` for a ban set before that column ' +
|
||||
'existed) and `Duration` the ' +
|
||||
'seconds from there to `ban_expires` (2147483647, the int32 max, for a permanent ' +
|
||||
'ban). ' +
|
||||
'`PlayerIdReporter` stays null: it names a kicking host, and the reporter is not ' +
|
||||
'shown to the player they reported. Only the caller’s own account is consulted, ' +
|
||||
'not the ban-evasion arms.\n\n' +
|
||||
'Everyone else gets the reference server’s stub “not blocked” answer: ' +
|
||||
'`ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and ' +
|
||||
'`Message` is null rather than the empty string that stub sends — the client ' +
|
||||
'distinguishes “no message” from a blank one. `IsVoiceModAutoban` and ' +
|
||||
'`TimeoutStartedAt` are on the DTO but unset by that stub, so they carry their ' +
|
||||
'defaults, as do the seven keys past the stub’s nine that the 2025 client’s decoder ' +
|
||||
'names (`IsDeviceBan`, `IsWarning`, `VoteKickReason`, `AssociatedAccountUsername`, ' +
|
||||
'`ShowCreatorCodeOfConduct`, `TopMessageOverride`, `BottomMessageOverride`) — ' +
|
||||
'block kinds and screen dressings this server never uses. Answers GET or POST: ' +
|
||||
'the newer client POSTs it with no body.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(ModerationBlockDetails, 'The caller’s block, or “not blocked”'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' +
|
||||
'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' +
|
||||
'not wired to them, so it is always the “not blocked” answer, following the ' +
|
||||
'reference server’s stub: `ReportCategory` is `Unknown` (-1) rather than 0, which ' +
|
||||
'is a real category, and `Message` is null rather than the empty string that stub ' +
|
||||
'sends — the client distinguishes “no message” from a blank one. ' +
|
||||
'`IsVoiceModAutoban` and `TimeoutStartedAt` are on the DTO but unset by that ' +
|
||||
'stub, so they carry their defaults. Answers GET or POST: the newer client POSTs ' +
|
||||
'it with no body.',
|
||||
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const ban = await getActiveBan(c.env.DB, id)
|
||||
return c.json(ban ? banBlockDetails(ban) : NOT_BLOCKED)
|
||||
}
|
||||
(c) =>
|
||||
c.json({
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
)
|
||||
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
|
||||
// the client renders them in the order they arrive — and the list is grouped by the
|
||||
@@ -484,10 +209,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
tags: ['Moderation'],
|
||||
summary: 'Submit a player report',
|
||||
description:
|
||||
'Records a player report in the `report` table; nothing dedupes the rows. A report ' +
|
||||
'Records a player report in the `report` table; nothing dedupes the rows, and ' +
|
||||
'`moderationBlockDetails` still answers “not blocked” unconditionally. A report ' +
|
||||
'is filed unbanned — a moderator converts one into an account-wide ban by setting ' +
|
||||
'`banned` on the row, which is what matchmaking refuses on and what ' +
|
||||
'`moderationBlockDetails` describes to the banned player.\n\n' +
|
||||
'`banned` on the row, which is what matchmaking and `/connect/token` refuse on.\n\n' +
|
||||
'The reporter is the caller (from the bearer token), NOT a body field. Only ' +
|
||||
'`PlayerIdReported` is required; the client omits whatever it has no value for ' +
|
||||
'(a report raised outside a room carries no `RoomId`), and those are stored as ' +
|
||||
@@ -532,475 +257,6 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Report one chat message. Stored in the `report` table every other report uses — same
|
||||
// fields, same moderation life — with `chat_message_id` set. See
|
||||
// migrations/0025_report_chat_message.sql.
|
||||
.post(
|
||||
'/api/chatreport/createChatReport',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Report a chat message',
|
||||
description:
|
||||
'Files a report against one chat message. Stored as a row in the same `report` table ' +
|
||||
'a player report goes to (`POST /api/PlayerReporting/v3/create`), beside the event, ' +
|
||||
'invention and custom-avatar-item reports — the same submission with the same ' +
|
||||
'moderation life, and a moderator converts any of them into a ban the same way. What ' +
|
||||
'marks it as a chat report is `chat_message_id`; the row’s `reported_player_id` is ' +
|
||||
'the message’s SENDER, read from the `message` table, since the body names no ' +
|
||||
'player. `ChatThreadId` is accepted and unused: a message id is unique across ' +
|
||||
'threads, so the message names its own thread and nothing stores a second copy.\n\n' +
|
||||
'The reporter is the caller (from the bearer token) and must be a MEMBER of the ' +
|
||||
'thread the message is in — the same gate reading the message sits behind. A ' +
|
||||
'message the caller cannot see answers exactly as one that does not exist, so the ' +
|
||||
'route cannot be used to probe message ids or to file reports citing conversations ' +
|
||||
'the reporter was never in. A system notice (a join/leave line) has no sender to ' +
|
||||
'report and is refused.\n\n' +
|
||||
'`ReportCategory` arrives as an enum NAME (`Discriminatory`), unlike the numeric ' +
|
||||
'one a player report sends, and is mapped onto the numeric category the table ' +
|
||||
'stores (`Discriminatory` → 102). A number is stored as sent; a name that maps to ' +
|
||||
'nothing is filed as 0 with the name kept at the front of the details. ' +
|
||||
'`ReportDescription` is the details. Nothing dedupes the rows.\n\n' +
|
||||
'Answers the `{ success, error }` envelope the other reports use, `error` an empty ' +
|
||||
'string rather than null, on the rejected branches too.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateChatReportRequest, 'The report'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No usable `ChatMessageId`, or a system message'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: json(SuccessErrorEnvelope, 'No such message, or not one the caller can see'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const reporterId = await authedId(c)
|
||||
if (reporterId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const chatMessageId = asInt(formField(body, c, 'ChatMessageId'))
|
||||
if (chatMessageId === null) {
|
||||
return c.json({ success: false, error: 'ChatMessageId is required' }, 400)
|
||||
}
|
||||
|
||||
// The message supplies the reported player, and its OWN thread is the one the
|
||||
// membership check runs against — not the body's `ChatThreadId`, which would let a
|
||||
// caller vouch for a message by naming a thread they happen to be in.
|
||||
const message = await getMessage(c.env.DB, chatMessageId)
|
||||
if (message === null || !(await isThreadMember(c.env.DB, message.chatThreadId, reporterId))) {
|
||||
return c.json({ success: false, error: 'No such message' }, 404)
|
||||
}
|
||||
// System notices are sent as a pseudo-player with a negative id (see `chat`'s
|
||||
// SYSTEM_SENDER_ID); a report against one would be a report against nobody.
|
||||
if (message.senderPlayerId <= 0) {
|
||||
return c.json({ success: false, error: 'That message has no sender to report' }, 400)
|
||||
}
|
||||
|
||||
const rawCategory = formField(body, c, 'ReportCategory')
|
||||
const category = chatReportCategory(rawCategory)
|
||||
const description = formField(body, c, 'ReportDescription') ?? null
|
||||
await createReport(c.env.DB, {
|
||||
reporterPlayerId: reporterId,
|
||||
reportedPlayerId: message.senderPlayerId,
|
||||
reportCategory: category ?? KickReportCategory.Unknown,
|
||||
details:
|
||||
category === null
|
||||
? [`[${rawCategory?.trim()}]`, description].filter(Boolean).join(' ')
|
||||
: description,
|
||||
chatMessageId,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// A player calling a vote to kick another. Ungated by role — anyone may start one —
|
||||
// but both players have to be standing in the session the vote is called in, which is
|
||||
// what stops a client putting a vote to a room it isn't in, about someone who isn't
|
||||
// there. The call and every answer arrive here alike; the first ballot while no vote on
|
||||
// that player is open CALLS one (`init`), and only a call is put to the room.
|
||||
.post(
|
||||
'/api/PlayerReporting/v3/voteToKick',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Call a vote to kick a player',
|
||||
description:
|
||||
'Puts a vote-to-kick to the room instance. Open to any player — no role is ' +
|
||||
'required — but BOTH the caller and `PlayerId` must have a live `presence` row in ' +
|
||||
'the instance `GameSessionId` names, or the call is refused with a 403. That is ' +
|
||||
'the whole gate: without it a client could raise a vote in a session it is not ' +
|
||||
'in, or against a player who is not there.\n\n' +
|
||||
'Everyone else in that instance — the player being voted on included, since a ' +
|
||||
'vote is called in front of them — gets a `MessageReceived` frame carrying a ' +
|
||||
'Message of type 5 (`VoteToKick`). The caller is left out: they have voted ' +
|
||||
'already, and their own `Response` is what they posted.\n\n' +
|
||||
'`FromPlayerId` on that Message is the player being VOTED ON, not the caller — the ' +
|
||||
'client raises its prompt about whoever it names, so the caller there asks the room ' +
|
||||
'to kick the wrong player. `Data` is the `Reason` as PLAIN TEXT (`Inactive in games ' +
|
||||
'(AFK)`), which is what the prompt shows; it is still a string on the wire, because ' +
|
||||
"an object there fails the client’s decoder (`expected:'String Begin Token', " +
|
||||
"actual:'{'`) and aborts the whole notification.\n\n" +
|
||||
'The frames are EPHEMERAL: a vote belongs to the moment it was called, so an ' +
|
||||
'offline player gets nothing rather than a prompt about a dead session on their ' +
|
||||
'next connect.\n\n' +
|
||||
'Every call RECORDS a ballot in `room_vote` — the session, the player voted on, the ' +
|
||||
'voter and their answer — and then tallies. A vote CARRIES when the yes votes are ' +
|
||||
'strictly more than half the players standing in the session right now (a tie is not ' +
|
||||
'a majority, and the count is of the room as it is, since players leave mid-vote). ' +
|
||||
'The tally counts DISTINCT voters and reads only each one’s latest ballot, so ' +
|
||||
're-posting a vote cannot carry one and a voter may change their mind.\n\n' +
|
||||
'When it carries, the player is kicked exactly as a staff kick does it — moved into ' +
|
||||
'their own dorm, the instance they left freed, an ephemeral `ModerationKick` (id 22) — ' +
|
||||
'but with `ReportCategory` `VoteKick` (10), `IsHostKick` false (the ROOM removed ' +
|
||||
'them), `PlayerIdReporter` the voter whose ballot carried it, and the reason in ' +
|
||||
'`VoteKickReason`. `IsBan` is false: nothing stops them rejoining. No prompt goes ' +
|
||||
'out in that case — the room is not asked a question that is already answered.\n\n' +
|
||||
'The call and the answers arrive on this same endpoint with the same body. A ballot ' +
|
||||
'posted while no vote on `PlayerId` is open in that session CALLS one: it is stored ' +
|
||||
'with `init` 1 and is the only kind that puts the prompt to the room. A vote stays ' +
|
||||
'open for 60 seconds; every ballot inside that window answers it — recorded and ' +
|
||||
'tallied, but relayed to nobody — and only ballots from its call on are counted, so ' +
|
||||
'an expired vote’s yes ballots cannot carry the next.\n\n' +
|
||||
'Calling is THROTTLED: a player who called a vote in the last 5 minutes, in any ' +
|
||||
'session, is refused with a 429. Answering is never throttled. A `No` cannot call a ' +
|
||||
'vote — with none open it is refused with a 409.\n\n' +
|
||||
'`Response` is the caller’s own vote, recorded but never relayed. Answers the same lowercase `{ success, error }` envelope as the ' +
|
||||
'write; a hub failure for any recipient is reported honestly as a 500, since ' +
|
||||
'with nothing behind it the frame is the whole delivery.',
|
||||
security: AUTHED,
|
||||
requestBody: form(VoteToKickRequest, 'The vote'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No `PlayerId` or no `GameSessionId`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(SuccessErrorEnvelope, 'Either player is not in that game session'),
|
||||
409: json(SuccessErrorEnvelope, 'A `No` with no vote open to answer'),
|
||||
429: json(SuccessErrorEnvelope, 'The caller called a vote too recently'),
|
||||
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const voterId = await authedId(c)
|
||||
if (voterId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const playerIdField = formField(body, c, 'PlayerId')
|
||||
const playerId = asInt(playerIdField)
|
||||
if (playerIdField === undefined || playerId === null) {
|
||||
return c.json({ success: false, error: 'PlayerId is required' }, 400)
|
||||
}
|
||||
const gameSessionId = asInt(formField(body, c, 'GameSessionId'))
|
||||
if (gameSessionId === null) {
|
||||
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
|
||||
}
|
||||
|
||||
// The prompt's own text. Absent is an empty string rather than a refusal: a vote with
|
||||
// no reason given is still a vote, and the client picks from a fixed list anyway.
|
||||
const reason = formField(body, c, 'Reason') ?? ''
|
||||
|
||||
// One read for both players. A vote may only be called by someone standing in the
|
||||
// session, about someone standing in the same one — the session is read from live
|
||||
// presence, never from the body, so neither side can be asserted by the client.
|
||||
const presences = await getPresences<{ roomInstanceId?: number }>(c.env.DB, [
|
||||
voterId,
|
||||
playerId,
|
||||
])
|
||||
const isHere = (id: number) =>
|
||||
presences.get(id)?.roomInstance?.roomInstanceId === gameSessionId
|
||||
if (!isHere(voterId)) {
|
||||
return c.json({ success: false, error: 'You are not in that game session!' }, 403)
|
||||
}
|
||||
if (!isHere(playerId)) {
|
||||
return c.json({ success: false, error: 'That player is not in that game session!' }, 403)
|
||||
}
|
||||
|
||||
// Call or answer? Nothing in the body says, so it is whether a vote on this player is
|
||||
// already open here: with none, this ballot calls one.
|
||||
const response = isYesVote(formField(body, c, 'Response'))
|
||||
const openVote = await getOpenVote(c.env.DB, gameSessionId, playerId)
|
||||
const init = openVote === null
|
||||
if (init) {
|
||||
// A late "no" to a vote that has expired would otherwise call a new one.
|
||||
if (!response) {
|
||||
return c.json({ success: false, error: 'There is no vote in progress!' }, 409)
|
||||
}
|
||||
// Only calling is throttled — this is what stops one player putting vote after
|
||||
// vote to the room. Answering someone else's vote never is.
|
||||
if (await isOnVoteCooldown(c.env.DB, voterId)) {
|
||||
return c.json({ success: false, error: 'You are calling votes too quickly!' }, 429)
|
||||
}
|
||||
}
|
||||
|
||||
// The caller's own ballot, recorded before the room is asked: they have voted, and a
|
||||
// vote in a session holding only them and the player they called it on should carry on
|
||||
// its own rather than waiting for an answer that can never come.
|
||||
const ballot = await recordRoomVote(c.env.DB, {
|
||||
gameSessionId,
|
||||
playerId,
|
||||
voterId,
|
||||
response,
|
||||
init,
|
||||
})
|
||||
|
||||
// Counted against who is standing there RIGHT NOW: a majority is of the room as it is,
|
||||
// not of the room as it was when the vote was called, and players leave mid-vote. Only
|
||||
// THIS vote's ballots count — from its call on.
|
||||
const yesVotes = await countKickVotes(
|
||||
c.env.DB,
|
||||
gameSessionId,
|
||||
playerId,
|
||||
openVote?.id ?? ballot.id
|
||||
)
|
||||
const playerCount = await countPlayersInInstance(c.env.DB, gameSessionId)
|
||||
if (isKickMajority(yesVotes, playerCount)) {
|
||||
logger.info('vote to kick carried', {
|
||||
playerId,
|
||||
gameSessionId,
|
||||
yesVotes,
|
||||
playerCount,
|
||||
carriedBy: voterId,
|
||||
})
|
||||
await applyVoteKick(c, playerId, gameSessionId, reason, voterId)
|
||||
// The room is not asked a question that has already been answered.
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
|
||||
// An answer is recorded and no more: the room was asked when the vote was called, and
|
||||
// re-sending the prompt on every answer would raise it again for everyone.
|
||||
if (!init) return c.json({ success: true, error: '' })
|
||||
|
||||
// The room votes, so the audience is everyone standing there — the player being
|
||||
// voted on included; a vote is called in front of them. The caller is dropped:
|
||||
// their vote is the one they just posted.
|
||||
const audience = (await getPlayerIdsInInstance(c.env.DB, gameSessionId)).filter(
|
||||
(id) => id !== voterId
|
||||
)
|
||||
|
||||
// Every recipient is attempted even if an earlier one fails, so the reachable
|
||||
// players still get the vote.
|
||||
const results = await Promise.all(
|
||||
audience.map((toPlayerId) =>
|
||||
pushVoteToKick(c, {
|
||||
// The player being VOTED ON — the client raises its prompt about this id, so
|
||||
// the caller's own id here asks the room to kick the wrong player.
|
||||
FromPlayerId: playerId,
|
||||
ToPlayerId: toPlayerId,
|
||||
Type: MessageType.VoteToKick,
|
||||
// The reason as plain text; see the note above the Message type.
|
||||
Data: reason,
|
||||
})
|
||||
)
|
||||
)
|
||||
if (results.includes(false)) {
|
||||
return c.json({ success: false, error: 'Failed to deliver vote' }, 500)
|
||||
}
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// The kick a room's own staff hand out from the moderation menu: eject named players
|
||||
// from ONE live instance. Two gates, and both matter — the caller must be able to
|
||||
// moderate the room the instance belongs to, and each named player must actually be
|
||||
// standing in that instance. Without the second, a creator could name any account id
|
||||
// and kick a stranger out of somebody else's room.
|
||||
.post(
|
||||
'/api/PlayerReporting/v1/instantKick',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Kick players out of a room instance',
|
||||
description:
|
||||
'Ejects the named players from one live room instance. `GameSessionId` is that ' +
|
||||
'instance (`roomInstanceId`); the body is JSON, unlike the form posts elsewhere in ' +
|
||||
'this controller.\n\n' +
|
||||
'Gated to the instance’s room: the caller must be its creator or hold a role of ' +
|
||||
'Moderator (20) or above on it — anyone else with a valid token gets a 403. ' +
|
||||
'Nobody who can moderate the room can be kicked out of it, and a caller cannot ' +
|
||||
'kick themselves.\n\n' +
|
||||
'A player is only kicked if their live `presence` row puts them in **that** ' +
|
||||
'instance. Anyone else named — offline, or standing in another room — is skipped ' +
|
||||
'in silence, so naming an account id cannot reach into a session the caller has ' +
|
||||
'no authority over.\n\n' +
|
||||
'Each kicked player is moved into their own DORM — presence rewritten, not deleted, ' +
|
||||
'so their client lands where it already is instead of loading the dorm a second ' +
|
||||
'time — and the instance they left frees a slot. They also get a `ModerationKick` ' +
|
||||
'frame (id 22) — the frame the ' +
|
||||
'client acts on to leave. It is the same frame a room ban sends, but `IsBan` is ' +
|
||||
'false: this only removes them from the session they are in, and nothing stops ' +
|
||||
'them rejoining. The frame is EPHEMERAL — a kick is true of the moment it ' +
|
||||
'happened, and queueing one would eject the player from an unrelated session on ' +
|
||||
'their next connect.\n\n' +
|
||||
'Answers the same lowercase `{ success, error }` envelope the report write uses, ' +
|
||||
'and says nothing about who was actually kicked — the response shape is ' +
|
||||
'unverified against the real service.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(InstantKickRequest, 'The instance and the players to eject'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'Unparseable body, no `GameSessionId` or no `PlayerIds`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(SuccessErrorEnvelope, 'The caller cannot moderate the instance’s room'),
|
||||
404: json(SuccessErrorEnvelope, 'No such game session'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const moderatorId = await authedId(c)
|
||||
if (moderatorId === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as {
|
||||
GameSessionId?: unknown
|
||||
PlayerIds?: unknown
|
||||
} | null
|
||||
if (body === null) return c.json({ success: false, error: 'Invalid request body' }, 400)
|
||||
|
||||
const gameSessionId = typeof body.GameSessionId === 'number' ? body.GameSessionId : Number.NaN
|
||||
if (!Number.isInteger(gameSessionId)) {
|
||||
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
|
||||
}
|
||||
const playerIds = Array.isArray(body.PlayerIds)
|
||||
? body.PlayerIds.filter((id): id is number => Number.isInteger(id))
|
||||
: []
|
||||
if (playerIds.length === 0) {
|
||||
return c.json({ success: false, error: 'PlayerIds is required' }, 400)
|
||||
}
|
||||
|
||||
// The instance names the room, and the room carries the roles this is gated on —
|
||||
// a game session with no room behind it can't authorise anything.
|
||||
const instance = await getStoredRoomInstance(c.env.DB, gameSessionId)
|
||||
const room = instance && (await getRoomById(c.env.DB, instance.roomId))
|
||||
if (!room) return c.json({ success: false, error: 'This game session does not exist!' }, 404)
|
||||
if (!canModerateRoom(room, moderatorId)) {
|
||||
return c.json({ success: false, error: 'Forbidden' }, 403)
|
||||
}
|
||||
|
||||
// One read for the batch. A player is kicked only when their LIVE presence puts
|
||||
// them in this very instance: offline, expired or standing elsewhere are all the
|
||||
// same "not here", and are skipped rather than refused — the client sends a list
|
||||
// and one stale id in it must not sink the rest.
|
||||
const presences = await getPresences<{ roomInstanceId?: number }>(c.env.DB, playerIds)
|
||||
const kicked: number[] = []
|
||||
for (const playerId of playerIds) {
|
||||
// The room's own staff are not kickable out of their room — otherwise a
|
||||
// moderator could throw the creator out of it. Nor is the caller themselves.
|
||||
if (playerId === moderatorId || canModerateRoom(room, playerId)) continue
|
||||
if (presences.get(playerId)?.roomInstance?.roomInstanceId !== gameSessionId) continue
|
||||
// Moved to their own dorm rather than having their presence deleted: the client
|
||||
// goes to the dorm either way, and with no presence to read it arrives not knowing
|
||||
// where it is and loads the dorm a second time. `movePlayerToDorm` recomputes the
|
||||
// fullness of the instance they left, so this one no longer has to.
|
||||
await movePlayerToDorm(c.env.DB, playerId)
|
||||
kicked.push(playerId)
|
||||
}
|
||||
|
||||
if (kicked.length > 0) {
|
||||
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
|
||||
await pushInstantKick(c, kicked, gameSessionId, roomName, moderatorId)
|
||||
}
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// ONE player kicked out of ONE live instance, from the in-room moderation menu, with a
|
||||
// reason. The single-player sibling of `instantKick`, but form-encoded and gated more
|
||||
// narrowly: the room's owner or a co-owner, or an account holding the staff role — not
|
||||
// the room's Moderator (20) tier, which `instantKick` lets through.
|
||||
//
|
||||
// The cross-room guard is the same and it is the point: the instance names the room, the
|
||||
// room is what the caller's authority is checked against, and the target's LIVE presence
|
||||
// must put them in that very instance. Naming a player standing in some other room — even
|
||||
// another instance of this one — kicks nobody, so authority over one room can never reach
|
||||
// a session in another.
|
||||
.post(
|
||||
'/api/PlayerReporting/v1/roomModKick',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Kick one player out of a room instance, with a reason',
|
||||
description:
|
||||
'Ejects `PlayerId` from the live room instance `GameSessionId` (a `roomInstanceId`). ' +
|
||||
'Form-encoded, unlike `instantKick`’s JSON; `Reason` is optional free text shown to ' +
|
||||
'the kicked player in the kick message.\n\n' +
|
||||
'Gated to the instance’s room: its creator or a co-owner, or any account whose token ' +
|
||||
'carries the `moderator` / `developer` role. A room Moderator (20) or Host is NOT ' +
|
||||
'enough here. Anyone else with a valid token gets a 403.\n\n' +
|
||||
'The player must be standing in **that** instance — their live `presence` row must ' +
|
||||
'name it. Otherwise nothing happens and the call answers `success: false`, so an ' +
|
||||
'account id cannot reach into a session in another room, or another instance of this ' +
|
||||
'one. The caller need not be in the instance themselves. The caller cannot kick ' +
|
||||
'themselves, and nobody can kick the room’s owner or a co-owner.\n\n' +
|
||||
'A kick moves the player into their own DORM — presence rewritten rather than deleted, ' +
|
||||
'so their client does not load the dorm twice — frees the slot they held, and sends ' +
|
||||
'them an EPHEMERAL `ModerationKick` ' +
|
||||
'frame (id 22) with `IsBan: false` — nothing stops them rejoining. `IsHostKick` is ' +
|
||||
'true when the caller runs the room, false for a staff account acting in a room it ' +
|
||||
'does not.\n\n' +
|
||||
'Answers the lowercase `{ success, error }` envelope; the shape is unverified against ' +
|
||||
'the real service.',
|
||||
security: AUTHED,
|
||||
requestBody: form(RoomModKickRequest, 'The session, the player and a reason'),
|
||||
responses: {
|
||||
200: json(
|
||||
SuccessErrorEnvelope,
|
||||
'`{ success: true, error: "" }`, or `success: false` when the player cannot be kicked'
|
||||
),
|
||||
400: json(SuccessErrorEnvelope, 'No `GameSessionId` or `PlayerId`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(SuccessErrorEnvelope, 'The caller neither runs the room nor holds a staff role'),
|
||||
404: json(SuccessErrorEnvelope, 'No such game session'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const moderatorId = await authedId(c)
|
||||
if (moderatorId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const gameSessionId = asInt(formField(body, c, 'GameSessionId'))
|
||||
if (gameSessionId === null) {
|
||||
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
|
||||
}
|
||||
const playerId = asInt(formField(body, c, 'PlayerId'))
|
||||
if (playerId === null) return c.json({ success: false, error: 'PlayerId is required' }, 400)
|
||||
const reason = formField(body, c, 'Reason')?.trim() ?? ''
|
||||
|
||||
// The instance names the room, and the room carries the roles this is gated on.
|
||||
const instance = await getStoredRoomInstance(c.env.DB, gameSessionId)
|
||||
const room = instance && (await getRoomById(c.env.DB, instance.roomId))
|
||||
if (!room) return c.json({ success: false, error: 'This game session does not exist!' }, 404)
|
||||
|
||||
// The room's own owners first; the token's roles are only read when that fails.
|
||||
const runsRoom = canManageRoom(room, moderatorId)
|
||||
if (!runsRoom) {
|
||||
const roles = await authedRoles(c)
|
||||
if (!roles?.some((role) => MODERATOR_ROLES.has(role))) {
|
||||
return c.json({ success: false, error: 'Forbidden' }, 403)
|
||||
}
|
||||
}
|
||||
|
||||
if (playerId === moderatorId) {
|
||||
return c.json({ success: false, error: 'You cannot kick yourself!' })
|
||||
}
|
||||
// Otherwise a co-owner, or a staffer, could throw the room's owner out of it.
|
||||
if (canManageRoom(room, playerId)) {
|
||||
return c.json({ success: false, error: 'You cannot kick an owner of this room!' })
|
||||
}
|
||||
|
||||
// The cross-room guard: their LIVE presence must name this exact instance. Offline,
|
||||
// expired, in another room, or in another instance of this room are all "not here".
|
||||
const presence = await getPresence<{ roomInstanceId?: number }>(c.env.DB, playerId)
|
||||
if (presence?.roomInstance?.roomInstanceId !== gameSessionId) {
|
||||
return c.json({ success: false, error: 'That player is not in this game session!' })
|
||||
}
|
||||
|
||||
// Into their own dorm, not out of existence — see the note in `instantKick`.
|
||||
await movePlayerToDorm(c.env.DB, playerId)
|
||||
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
|
||||
await pushInstantKick(c, [playerId], gameSessionId, roomName, moderatorId, {
|
||||
reason,
|
||||
isHostKick: runsRoom,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// A warning handed down by a moderator — the staff-side counterpart to a report.
|
||||
// Gated on the `moderator` role in the token, not just a valid one.
|
||||
.post(
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import {
|
||||
getPlayerIdsInInstance,
|
||||
getPresence,
|
||||
getProgression,
|
||||
getProgressions,
|
||||
MessageType,
|
||||
} from '@repo/domain'
|
||||
import { getProgression, getProgressions } from '@repo/domain'
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
|
||||
// value — the enum has no runtime dependencies.
|
||||
import { NotificationType } from '../../../notify/src/notification-types'
|
||||
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
|
||||
import { parseFormIds, queryIds } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BareInteger,
|
||||
BulkIdsRequest,
|
||||
CheerPlayerRequest,
|
||||
CheerPlayerResponse,
|
||||
SetSelectedCheerRequest,
|
||||
form,
|
||||
idParam,
|
||||
intQuery,
|
||||
@@ -28,24 +17,11 @@ import {
|
||||
JsonArray,
|
||||
ProgressionDto,
|
||||
ReputationDto,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
import {
|
||||
addCheer,
|
||||
CheerCategory,
|
||||
DAILY_CHEER_CREDIT,
|
||||
getReputation,
|
||||
getReputations,
|
||||
isCheerCategory,
|
||||
setSelectedCheer,
|
||||
spendCheerCredit,
|
||||
} from '../reputation-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { Progression } from '@repo/domain'
|
||||
import type { ReputationPayload } from '../../../notify/src/notification-payloads'
|
||||
import type { App } from '../context'
|
||||
import type { Reputation } from '../reputation-db'
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
@@ -73,160 +49,28 @@ async function pushProgression(c: Context<App>, progression: Progression): Promi
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the `MessageReceived` frame that actually plays a cheer on the cheered player's
|
||||
* client — a Message of type `PlayerCheer` (or its anonymous twin). That message, NOT
|
||||
* `ReputationUpdate`, is what the client renders the cheer from; every reference server
|
||||
* (meownet-api, DorkNet, the E12354 C# server) sends it, and a cheer that only pushes
|
||||
* `ReputationUpdate` moves the counters and plays nothing.
|
||||
*
|
||||
* `Data` is the category given, as a string (a Message's `Data` is always a string). An
|
||||
* anonymous cheer uses the anonymous type and names sender 0, so the recipient's client
|
||||
* neither shows nor can look up who gave it — `Anonymous` decides nothing else.
|
||||
*
|
||||
* Durable, like the rest of the target's frames: the cheer is theirs whether or not they are
|
||||
* connected right now. Best-effort — the cheer is already counted.
|
||||
* Default reputation for an account — the fallback used with no DB. Nobody has
|
||||
* earned cheers yet, so every counter is 0 and everyone has their full cheer credit.
|
||||
* `SelectedCheer` is an int (0 = none selected), not null, and `IsCheerful` is true:
|
||||
* the client reads it to decide whether the player may hand out cheers at all.
|
||||
*/
|
||||
async function pushCheerMessage(
|
||||
c: Context<App>,
|
||||
fromId: number,
|
||||
toId: number,
|
||||
category: number,
|
||||
anonymous: boolean
|
||||
): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
toId,
|
||||
NotificationType.MessageReceived,
|
||||
{
|
||||
FromPlayerId: anonymous ? 0 : fromId,
|
||||
ToPlayerId: toId,
|
||||
Type: anonymous ? MessageType.PlayerCheerAnonymous : MessageType.PlayerCheer,
|
||||
Data: String(category),
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push PlayerCheer MessageReceived notification', {
|
||||
fromId,
|
||||
toId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
function defaultReputation(id: number) {
|
||||
return {
|
||||
AccountId: id,
|
||||
IsCheerful: true,
|
||||
Noteriety: 0,
|
||||
SelectedCheer: 0,
|
||||
CheerCredit: 20,
|
||||
CheerGeneral: 0,
|
||||
CheerHelpful: 0,
|
||||
CheerCreative: 0,
|
||||
CheerGreatHost: 0,
|
||||
CheerSportsman: 0,
|
||||
SubscriberCount: 0,
|
||||
SubscribedCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim a stored reputation to the fields a `ReputationUpdate` frame carries — the client's
|
||||
* decoder has no `Noteriety` or subscriber counts on this payload. Nothing else changes:
|
||||
* `IsCheerful` and `SelectedCheer` are the record's, exactly as the profile DTO serves them.
|
||||
* (This once overrode both per send to "play" the cheer — no reference does that, and the
|
||||
* client plays a cheer off the `PlayerCheer` MESSAGE, not this frame.) Built against the
|
||||
* recovered interface so a renamed key fails the build rather than vanishing on the wire.
|
||||
*
|
||||
* `AccountId` is who the frame is ABOUT, which is not who it is sent to: a cheer's frame
|
||||
* names the player being cheered and goes to everyone watching.
|
||||
*/
|
||||
function reputationFrame(reputation: Reputation): ReputationPayload {
|
||||
const { Noteriety: _n, SubscriberCount: _sr, SubscribedCount: _sd, ...payload } = reputation
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a `ReputationUpdate` frame to one player, durably — it survives them being offline
|
||||
* and lands on their next connect. For the frames that report a real change to the player
|
||||
* they name: their counters moved, or their credit did.
|
||||
*
|
||||
* Best-effort: the cheer is already stored by the time this runs, so a hub hiccup must not
|
||||
* fail the request — the numbers are right on the next read either way.
|
||||
*/
|
||||
async function pushReputation(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
frame: ReputationPayload
|
||||
): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
playerId,
|
||||
NotificationType.ReputationUpdate,
|
||||
{ ...frame }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push ReputationUpdate notification', {
|
||||
playerId,
|
||||
accountId: frame.AccountId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the same frame to a roomful of players, EPHEMERALLY — delivered to whoever is
|
||||
* connected and dropped for anyone who isn't.
|
||||
*
|
||||
* That is the right send for an audience frame. A cheer's effect belongs to the moment it
|
||||
* happened; queueing it would play someone else's cheer at a bystander when they next log
|
||||
* in, hours later and somewhere else. The people the cheer actually changed something for
|
||||
* get their own durable frame instead.
|
||||
*/
|
||||
async function pushReputationToRoom(
|
||||
c: Context<App>,
|
||||
playerIds: number[],
|
||||
frame: ReputationPayload
|
||||
): Promise<void> {
|
||||
if (playerIds.length === 0) return
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
|
||||
playerIds,
|
||||
NotificationType.ReputationUpdate,
|
||||
{ ...frame }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push ReputationUpdate notification to room', {
|
||||
playerIds,
|
||||
accountId: frame.AccountId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one field of the cheer form. The client posts it form-encoded, but the same names
|
||||
* also arrive as a query string on some builds, so both are accepted (same helper the
|
||||
* moderation routes use on their forms).
|
||||
*/
|
||||
function formField(
|
||||
body: Record<string, unknown>,
|
||||
c: Context<App>,
|
||||
name: string
|
||||
): string | undefined {
|
||||
const raw = body[name]
|
||||
if (typeof raw === 'string' && raw !== '') return raw
|
||||
return c.req.query(name) || undefined
|
||||
}
|
||||
|
||||
/** Parse a form field as an integer, or null when absent / not a number. */
|
||||
function asInt(value: string | undefined): number | null {
|
||||
if (value === undefined) return null
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a form field as a bool. The client sends .NET's `True`/`False`, so the match is
|
||||
* case-insensitive; anything else — an absent field included — reads as false, which is the
|
||||
* safe default for `Anonymous` (a cheer nobody asked to hide is a signed one).
|
||||
*/
|
||||
function asBool(value: string | undefined): boolean {
|
||||
return value !== undefined && /^(true|1)$/i.test(value.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* The `{ Success, Message }` body a cheer answers with — PascalCase, as the reference, and
|
||||
* `Message` is NULL on success rather than an empty string. That is not the same envelope
|
||||
* as the lowercase `{ success, error: "" }` the reports and warnings use; don't unify them.
|
||||
*/
|
||||
function cheerResult(c: Context<App>, message: string | null = null) {
|
||||
return c.json({ Success: message === null, Message: message })
|
||||
}
|
||||
|
||||
/**
|
||||
* The repeated `id` query param the 2023 client uses on the bulk GET forms — each value
|
||||
* may itself be a comma-separated list, so `?id=1,2&id=3` is three ids.
|
||||
@@ -246,15 +90,12 @@ export const progressionRoutes = new Hono<App>({ strict: false })
|
||||
tags: ['Progression'],
|
||||
summary: 'A player’s reputation',
|
||||
description:
|
||||
'The cheer counters shown on a player’s profile, from the `reputation` table. A ' +
|
||||
'player nobody has cheered has no row and reads back all-zero. `CheerCredit` is ' +
|
||||
'the odd one out — what they have left to GIVE today, out of ' +
|
||||
`${DAILY_CHEER_CREDIT}; it refills lazily, so a stale window reads as full ` +
|
||||
'without being reset here.',
|
||||
'The cheer counters shown on a player’s profile. No cheers are stored yet, so ' +
|
||||
'every player gets the same all-zero record with full cheer credit.',
|
||||
parameters: [idParam('id', 'Account id')],
|
||||
responses: { 200: json(ReputationDto, 'The player’s reputation') },
|
||||
}),
|
||||
async (c) => c.json(await getReputation(c.env.DB, Number.parseInt(c.req.param('id'), 10)))
|
||||
(c) => c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
|
||||
)
|
||||
.get(
|
||||
'/api/players/v1/progression/:id',
|
||||
@@ -290,19 +131,23 @@ export const progressionRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
// Synthesize a default reputation per requested id (the intended behavior;
|
||||
// the DB-less fallback reads a static JSON file instead).
|
||||
.post(
|
||||
'/api/playerReputation/v2/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Reputations in bulk',
|
||||
description:
|
||||
'One reputation per requested id, in request order. Ids that name no account — or ' +
|
||||
'that nobody has cheered — still get an all-zero record rather than being dropped: ' +
|
||||
'the client renders a profile card from each entry.',
|
||||
'One default reputation per requested id, in request order. Ids that name no ' +
|
||||
'account still get a record — the client renders a profile card from it.',
|
||||
requestBody: BULK_ID_BODY,
|
||||
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
|
||||
}),
|
||||
async (c) => c.json(await getReputations(c.env.DB, await parseFormIds(c)))
|
||||
async (c) => {
|
||||
const ids = await parseFormIds(c)
|
||||
return c.json(ids.map(defaultReputation))
|
||||
}
|
||||
)
|
||||
// The 2023 client calls this as a GET with repeated `id` query params.
|
||||
.get(
|
||||
@@ -316,148 +161,7 @@ export const progressionRoutes = new Hono<App>({ strict: false })
|
||||
parameters: BULK_ID_QUERY,
|
||||
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
|
||||
}),
|
||||
async (c) => c.json(await getReputations(c.env.DB, queryIds(c)))
|
||||
)
|
||||
// Cheering another player: spend one of the caller's daily credits, count it against the
|
||||
// target's category counter, and play it in front of the room.
|
||||
//
|
||||
// Nothing stores an individual cheer — this keeps a per-player counter, not a log of who
|
||||
// cheered whom. So neither `RoomId` nor `Anonymous` reaches storage: both are spent
|
||||
// immediately on the notification, one deciding who sees it and the other whether it is
|
||||
// seen at all.
|
||||
.post(
|
||||
'/api/PlayerCheer/v1/create',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Cheer another player',
|
||||
description:
|
||||
'Hands one cheer to `PlayerIdTo` in the category `CheerCategory` names (0 General, ' +
|
||||
'10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative), counting it on their ' +
|
||||
'`reputation` row.\n\n' +
|
||||
`A player may give ${DAILY_CHEER_CREDIT} cheers per day. The credit refills lazily: ` +
|
||||
'the first cheer opens a 24-hour window, and the first cheer after that window has ' +
|
||||
'passed starts a fresh one at full credit — so a player who spends all day refills ' +
|
||||
'24h after their FIRST cheer, not their last.\n\n' +
|
||||
'The cheered player gets a durable `MessageReceived` frame carrying a Message of ' +
|
||||
'type 50 (`PlayerCheer`) — 51 (`PlayerCheerAnonymous`, sender 0) when `Anonymous` — ' +
|
||||
'with `Data` = the category. That message is what plays the cheer on their ' +
|
||||
'client; the `ReputationUpdate` frames below only refresh the numbers.\n\n' +
|
||||
'A cheer is played in front of people, so the `ReputationUpdate` frame naming the ' +
|
||||
'cheered player goes to EVERYONE in the room instance the caller is standing in, ' +
|
||||
'not just the two of them. The cheered player gets it durably (their counters ' +
|
||||
'really moved); the rest of the room gets it only if they are connected, since ' +
|
||||
'the effect belongs to the moment. The caller gets a second frame of their own ' +
|
||||
'because their `CheerCredit` moved and the response body does not carry it.\n\n' +
|
||||
'`Anonymous` swaps the message for its anonymous twin (type 51, sender 0) and ' +
|
||||
'nothing else — the counters move the same either way.\n\n' +
|
||||
'The audience comes from the caller’s live presence, not from `RoomId`, which is ' +
|
||||
'accepted and unused: a client cannot aim its effect at a room it is not in. ' +
|
||||
'Neither field is stored — this keeps counters, not a log of individual cheers.\n\n' +
|
||||
'Refusals (no credit left, an unknown category, cheering yourself) answer 200 with ' +
|
||||
'`{ Success: false, Message }` rather than an error status — the client shows the ' +
|
||||
'message.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CheerPlayerRequest, 'The cheer'),
|
||||
responses: {
|
||||
200: json(
|
||||
CheerPlayerResponse,
|
||||
'`{ Success: true, Message: null }` — see above for refusals'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const fromId = await authedId(c)
|
||||
if (fromId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const toId = asInt(formField(body, c, 'PlayerIdTo'))
|
||||
if (toId === null) return cheerResult(c, 'PlayerIdTo is required')
|
||||
if (toId === fromId) return cheerResult(c, 'You cannot cheer yourself')
|
||||
|
||||
// Validated BEFORE the credit is spent: a category we can't count would otherwise
|
||||
// take a cheer off the caller and give nothing to anyone.
|
||||
const category = asInt(formField(body, c, 'CheerCategory'))
|
||||
if (category === null || !isCheerCategory(category)) {
|
||||
return cheerResult(c, 'CheerCategory is not a cheer category')
|
||||
}
|
||||
|
||||
const remaining = await spendCheerCredit(c.env.DB, fromId)
|
||||
if (remaining === null) return cheerResult(c, 'You are out of cheers for today')
|
||||
|
||||
const cheered = await addCheer(c.env.DB, toId, category)
|
||||
|
||||
// The frame that PLAYS the cheer on the cheered player's client — a Message of
|
||||
// type PlayerCheer (or its anonymous twin). `ReputationUpdate` alone moves the
|
||||
// numbers and shows nothing.
|
||||
await pushCheerMessage(c, fromId, toId, category, asBool(formField(body, c, 'Anonymous')))
|
||||
|
||||
// The frame the room sees: the cheered player's record, so everyone's copy of
|
||||
// their counters moves. It is ABOUT them — `AccountId` is theirs — but goes to
|
||||
// everyone standing there.
|
||||
const frame = reputationFrame(cheered)
|
||||
|
||||
// The audience is read from the GIVER's live presence, not from the body's
|
||||
// `RoomId` — a client that lied about the room would otherwise play its effect in
|
||||
// someone else's. A cheer given outside a room instance (from a profile screen)
|
||||
// simply has no audience.
|
||||
const presence = await getPresence<{ roomInstanceId?: number }>(c.env.DB, fromId)
|
||||
const instanceId = presence?.roomInstance?.roomInstanceId
|
||||
const audience =
|
||||
instanceId === undefined
|
||||
? []
|
||||
: (await getPlayerIdsInInstance(c.env.DB, instanceId)).filter((id) => id !== toId)
|
||||
|
||||
// The cheered player is deliberately not in that list: for them this is a real
|
||||
// change to their own record, so they get it durably and get it whether or not
|
||||
// they were in the room — the room gets a copy that expires with the moment.
|
||||
await pushReputation(c, toId, frame)
|
||||
await pushReputationToRoom(c, audience, frame)
|
||||
|
||||
// The caller's own record, with the credit the spend just resolved rather than a
|
||||
// re-read — a cheer they fired off in parallel must not make this frame report a
|
||||
// credit they no longer have.
|
||||
await pushReputation(
|
||||
c,
|
||||
fromId,
|
||||
reputationFrame({ ...(await getReputation(c.env.DB, fromId)), CheerCredit: remaining })
|
||||
)
|
||||
|
||||
return cheerResult(c)
|
||||
}
|
||||
)
|
||||
// Pinning a cheer to the caller's own profile: the badge the client shows next to
|
||||
// their name, read back as `SelectedCheer` on the reputation DTO.
|
||||
.post(
|
||||
'/api/PlayerCheer/v1/SetSelectedCheer',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Pin a cheer to your profile',
|
||||
description:
|
||||
'Stores `CheerCategory` as the caller’s `SelectedCheer` (-1 `None` unpins, read ' +
|
||||
'back as 0) and pushes them a `ReputationUpdate` so a second device catches up. ' +
|
||||
'Same `{ Success, Message }` reply as the cheer.',
|
||||
security: AUTHED,
|
||||
requestBody: form(SetSelectedCheerRequest, 'The category to pin'),
|
||||
responses: {
|
||||
200: json(CheerPlayerResponse, '`{ Success: true, Message: null }`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const category = asInt(formField(body, c, 'CheerCategory'))
|
||||
if (category === null || !(category === CheerCategory.None || isCheerCategory(category))) {
|
||||
return cheerResult(c, 'CheerCategory is not a cheer category')
|
||||
}
|
||||
|
||||
const reputation = await setSelectedCheer(c.env.DB, id, category)
|
||||
await pushReputation(c, id, reputationFrame(reputation))
|
||||
return cheerResult(c)
|
||||
}
|
||||
(c) => c.json(queryIds(c).map(defaultReputation))
|
||||
)
|
||||
.post(
|
||||
'/api/players/v1/progression/bulk',
|
||||
@@ -521,26 +225,23 @@ export const progressionRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The progression event running right now — the limited-time XP event the client shows
|
||||
// The progression events running right now — the limited-time XP events the client shows
|
||||
// a banner and a progress track for.
|
||||
//
|
||||
// STUB: a bare `-1`, the reference server's "no active event" value (the id of the
|
||||
// active event, or -1 when there is none). The client reads it as "no event on" and
|
||||
// skips the event UI entirely. That is the honest answer (nothing here runs events) and
|
||||
// the safe one: a fabricated event would draw a track that never fills. No auth —
|
||||
// whether an event is running is the same fact for everybody, and the client asks while
|
||||
// loading.
|
||||
// STUB: an empty list, which the client reads as "no event on" and skips the event UI
|
||||
// entirely. That is the honest answer (nothing here runs events) and the safe one: a
|
||||
// fabricated event would draw a track that never fills. No auth — whether an event is
|
||||
// running is the same fact for everybody, and the client asks while loading.
|
||||
.get(
|
||||
'/api/progressionEvents/active',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Progression event currently running (stub)',
|
||||
summary: 'Progression events currently running (stub)',
|
||||
description:
|
||||
'The limited-time XP event in progress, as a bare event id. Always `-1` — the ' +
|
||||
'“no active event” value; nothing on this server runs one — which the client reads ' +
|
||||
'as “no event” and skips the event UI, where a 404 would stall the load. No auth: ' +
|
||||
'it is the same answer for every player.',
|
||||
responses: { 200: json(BareInteger, 'A bare `-1` — no event is running') },
|
||||
'The limited-time XP events in progress. Always an empty list — nothing on this ' +
|
||||
'server runs one — which the client reads as “no event” and skips the event UI, ' +
|
||||
'where a 404 would stall the load. No auth: it is the same answer for every player.',
|
||||
responses: { 200: json(JsonArray, 'Empty — no event is running') },
|
||||
}),
|
||||
(c) => c.json(-1)
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
+59
-163
@@ -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
|
||||
|
||||
@@ -60,40 +60,6 @@ export function containsSwears(value: string): boolean {
|
||||
return value !== '' && filter.exists(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a space would be in a name if the charset allowed one: a lowercase-to-uppercase
|
||||
* hop, the last capital of a run before a capitalised word, and either side of a run of
|
||||
* digits. Applied in that order, so `ShitLord`, `XXFuckYou` and `Fucker123` each come
|
||||
* apart at the seam a player wrote them with.
|
||||
*/
|
||||
const NAME_WORD_BOUNDARIES: Array<[RegExp, string]> = [
|
||||
[/([a-z0-9])([A-Z])/g, '$1 $2'],
|
||||
[/([A-Z]+)([A-Z][a-z])/g, '$1 $2'],
|
||||
[/([A-Za-z])([0-9])/g, '$1 $2'],
|
||||
]
|
||||
|
||||
/**
|
||||
* Whether `value`, read as a NAME, contains a swear.
|
||||
*
|
||||
* A username or display name is letters and digits only (`nameRejection`), so it carries
|
||||
* no spaces — and the filter matches whole words. Handing one to {@link containsSwears}
|
||||
* as-is therefore only refuses a name that IS a swear and nothing else: `Fucker123` and
|
||||
* `ShitLord` sail through. So the name is split at the boundaries a player types instead
|
||||
* of a space, and the pieces are checked as words.
|
||||
*
|
||||
* That keeps the library's trade-off rather than reaching for substring matching, which
|
||||
* is the tempting fix and the wrong one: `Scunthorpe`, `assassin`, `Classic`,
|
||||
* `Cumberland` and `Shiitake` all contain a swear as a substring, and refusing someone's
|
||||
* name without being able to say why is worse than missing `Bitchy`.
|
||||
*/
|
||||
export function nameContainsSwears(value: string): boolean {
|
||||
const spaced = NAME_WORD_BOUNDARIES.reduce(
|
||||
(text, [pattern, replacement]) => text.replace(pattern, replacement),
|
||||
value
|
||||
)
|
||||
return containsSwears(spaced)
|
||||
}
|
||||
|
||||
/** The mask `POST /api/sanitize/v1` uses when the request names no `ReplacementChar`. */
|
||||
export const DEFAULT_REPLACEMENT_CHAR = '*'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { adminSecretsStore, env } from 'cloudflare:test'
|
||||
import { exports } from 'cloudflare:workers'
|
||||
import { beforeAll, beforeEach, describe, expect, test } from 'vitest'
|
||||
import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
addXp,
|
||||
@@ -14,14 +14,11 @@ import {
|
||||
LEVEL_REQUIRED_XP,
|
||||
LEVEL_REWARDS,
|
||||
MAX_LEVEL,
|
||||
MessageType,
|
||||
NOTIFICATION_SCHEMA_DDL,
|
||||
OUTFIT_SCHEMA_DDL,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
PRESENCE_TTL_SECONDS,
|
||||
PROGRESSION_SCHEMA_DDL,
|
||||
RELATIONSHIP_SCHEMA_DDL,
|
||||
ROOM_INSTANCE_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
@@ -31,20 +28,7 @@ import {
|
||||
import '../../api.app'
|
||||
|
||||
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
|
||||
import { SCHEMA_DDL as MESSAGE_SCHEMA_DDL } from '../../../../chat/src/message-db'
|
||||
import {
|
||||
createThread,
|
||||
postMessage,
|
||||
SYSTEM_SENDER_ID,
|
||||
THREAD_SCHEMA_DDL,
|
||||
} from '../../../../chat/src/thread-db'
|
||||
import { banEvasionMatch, resolveBan } from '../../bans-db'
|
||||
import {
|
||||
createCustomAvatarItem,
|
||||
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
||||
importCustomAvatarItem,
|
||||
} from '../../custom-avatar-items-db'
|
||||
import { customAvatarItemRowLiteral } from '../../custom-avatar-items-load'
|
||||
import {
|
||||
countGoing,
|
||||
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
|
||||
@@ -56,34 +40,17 @@ import {
|
||||
banFromReport,
|
||||
createReport,
|
||||
getActiveBan,
|
||||
getBansInForce,
|
||||
getReportById,
|
||||
getReportsAgainst,
|
||||
getTopReported,
|
||||
isPlayerBanned,
|
||||
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||
searchReports,
|
||||
} from '../../reports-db'
|
||||
import {
|
||||
CheerCategory,
|
||||
DAILY_CHEER_CREDIT,
|
||||
getCheerCredit,
|
||||
getReputation,
|
||||
SCHEMA_DDL as REPUTATION_SCHEMA_DDL,
|
||||
spendCheerCredit,
|
||||
} from '../../reputation-db'
|
||||
import { charadesWordsFor } from '../../routes/gameplay'
|
||||
import { SCHEMA_DDL as VOTES_SCHEMA_DDL } from '../../votes-db'
|
||||
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
|
||||
|
||||
import type { SavedImage } from '@repo/domain'
|
||||
import type { Env } from '../../context'
|
||||
import type { EventTag, PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db'
|
||||
import type {
|
||||
InventionSaveResult,
|
||||
InventionSaveV9Result,
|
||||
SavedInvention,
|
||||
} from '../../inventions-db'
|
||||
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
@@ -110,21 +77,6 @@ const TEST_ROOMS = [
|
||||
SubRooms: [{ SubRoomId: 3 }],
|
||||
Roles: [{ AccountId: 42, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
|
||||
},
|
||||
{
|
||||
// The instant kick's room. Owned by account 42 (the default test token); 43 holds
|
||||
// Moderator (20) and 44 only Host (10) — the tier just below that gate. 45 is a
|
||||
// CoOwner (30), for `roomModKick`, which is gated on owners rather than moderators.
|
||||
RoomId: 4,
|
||||
Name: 'KickRoom',
|
||||
IsDorm: false,
|
||||
CreatorAccountId: 42,
|
||||
SubRooms: [{ SubRoomId: 4 }],
|
||||
Roles: [
|
||||
{ AccountId: 43, Role: 20, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
{ AccountId: 44, Role: 10, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
{ AccountId: 45, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -163,10 +115,6 @@ beforeAll(async () => {
|
||||
// Presence (owned by the rooms worker) — the online-friend count joins onto it.
|
||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Room instances (owned by the rooms worker) — the instant kick resolves the game
|
||||
// session it is given to the room whose staff may kick from it.
|
||||
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Outfit table (owned by the econ worker) — /outfits/me reads and writes slot 0.
|
||||
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
@@ -179,8 +127,6 @@ beforeAll(async () => {
|
||||
|
||||
// Reports table (owned by the api worker) — player reports are recorded here.
|
||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Vote-to-kick ballots (owned by the api worker) — one row per vote cast.
|
||||
for (const stmt of VOTES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Platform identity links (owned by the auth worker) — the sharp arm of the
|
||||
// ban-evasion resolution matches on them.
|
||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
@@ -190,21 +136,6 @@ beforeAll(async () => {
|
||||
|
||||
// Player events table (owned by the api worker) — scheduled events live here.
|
||||
for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Reputation + cheer credit (owned by the api worker) — cheering writes both.
|
||||
for (const stmt of REPUTATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Custom avatar items (owned by the api worker).
|
||||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// The message store (owned by the api worker) — the inbox reads it and every send
|
||||
// writes to it.
|
||||
for (const stmt of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Chat messages and thread membership (owned by the chat worker) — a chat report reads
|
||||
// the reported player off the message, and gates on the reporter being in its thread.
|
||||
for (const stmt of MESSAGE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of THREAD_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
|
||||
@@ -259,12 +190,10 @@ describe('public endpoints', () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
AmplitudeKey: '',
|
||||
AmplitudeKey: 'a',
|
||||
StatSigKey: 'a',
|
||||
RudderStackKey: 'a',
|
||||
UseRudderStack: false,
|
||||
RudderStackKey: '',
|
||||
UseStatSig: false,
|
||||
StatSigKey: '',
|
||||
StatSigEnvironment: 0,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -390,360 +319,6 @@ describe('public endpoints', () => {
|
||||
expect(reps.map((r) => r.AccountId)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
// Cheering: `POST /api/PlayerCheer/v1/create`. The giver comes from the token, so these
|
||||
// use ids of their own (71xx) rather than the shared 42 — a spent credit is durable
|
||||
// state, and the reputation reads above assert all-zero records.
|
||||
const cheer = async (fields: Record<string, string>, sub = '7100') =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
|
||||
const reputationOf = async (id: number) =>
|
||||
(await (await exports.default.fetch(`${ORIGIN}/api/playerReputation/v1/${id}`)).json()) as {
|
||||
CheerCredit: number
|
||||
CheerGeneral: number
|
||||
CheerHelpful: number
|
||||
}
|
||||
|
||||
test('a cheer counts on the target and spends the giver’s credit', async () => {
|
||||
// The body the client posts, verbatim from the live request.
|
||||
const res = await cheer({
|
||||
PlayerIdTo: '7101',
|
||||
CheerCategory: '0',
|
||||
RoomId: '112',
|
||||
Anonymous: 'False',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ Success: true, Message: null })
|
||||
|
||||
// The target's counter moved and nothing else did — in particular their OWN credit is
|
||||
// untouched, since receiving a cheer doesn't pay for giving one.
|
||||
expect(await reputationOf(7101)).toMatchObject({
|
||||
CheerGeneral: 1,
|
||||
CheerHelpful: 0,
|
||||
CheerCredit: DAILY_CHEER_CREDIT,
|
||||
})
|
||||
// The giver paid, and has no counters of their own.
|
||||
expect(await reputationOf(7100)).toMatchObject({
|
||||
CheerGeneral: 0,
|
||||
CheerCredit: DAILY_CHEER_CREDIT - 1,
|
||||
})
|
||||
})
|
||||
|
||||
test('each category counts into its own column', async () => {
|
||||
for (const category of [
|
||||
CheerCategory.General,
|
||||
CheerCategory.Helpful,
|
||||
CheerCategory.Sportmanship,
|
||||
CheerCategory.GreatHost,
|
||||
CheerCategory.Creative,
|
||||
]) {
|
||||
expect(
|
||||
(await cheer({ PlayerIdTo: '7102', CheerCategory: String(category) }, '7103')).status
|
||||
).toBe(200)
|
||||
}
|
||||
expect(await getReputation(env.DB, 7102)).toEqual({
|
||||
AccountId: 7102,
|
||||
IsCheerful: true,
|
||||
Noteriety: 0,
|
||||
SelectedCheer: 0,
|
||||
CheerCredit: DAILY_CHEER_CREDIT,
|
||||
CheerGeneral: 1,
|
||||
CheerHelpful: 1,
|
||||
CheerCreative: 1,
|
||||
CheerGreatHost: 1,
|
||||
CheerSportsman: 1,
|
||||
SubscriberCount: 0,
|
||||
SubscribedCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
test('a cheer the server can’t count is refused before it costs anything', async () => {
|
||||
// Each refusal answers 200 with the reason — the client shows `Message` — and none of
|
||||
// them may take a credit off the caller, which is what the closing assertion checks.
|
||||
for (const [fields, Message] of [
|
||||
[{ PlayerIdTo: '7105' }, 'CheerCategory is not a cheer category'],
|
||||
// -1 is the enum's `None`: a real member, but not a counter.
|
||||
[{ PlayerIdTo: '7105', CheerCategory: '-1' }, 'CheerCategory is not a cheer category'],
|
||||
[{ PlayerIdTo: '7105', CheerCategory: '5' }, 'CheerCategory is not a cheer category'],
|
||||
[{ CheerCategory: '0' }, 'PlayerIdTo is required'],
|
||||
[{ PlayerIdTo: '7104', CheerCategory: '0' }, 'You cannot cheer yourself'],
|
||||
] as Array<[Record<string, string>, string]>) {
|
||||
const res = await cheer(fields, '7104')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ Success: false, Message })
|
||||
}
|
||||
expect(await getCheerCredit(env.DB, 7104)).toBe(DAILY_CHEER_CREDIT)
|
||||
expect(await reputationOf(7105)).toMatchObject({ CheerGeneral: 0 })
|
||||
})
|
||||
|
||||
test('the cheer frame plays in front of the whole room instance', async () => {
|
||||
// Presence is written by the `match` worker; seeded straight into the table here.
|
||||
// 7108 (the giver), 7109 (the target) and 7120 (a bystander) share instance 8800;
|
||||
// 7121 stands in a different instance and must hear nothing.
|
||||
const standIn = async (accountId: number, roomInstanceId: number | null) =>
|
||||
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId,
|
||||
roomInstance: roomInstanceId === null ? null : { roomInstanceId, roomId: 112 },
|
||||
statusVisibility: 0,
|
||||
deviceClass: 0,
|
||||
vrMovementMode: 0,
|
||||
platform: 0,
|
||||
appVersion: GAME_VERSION,
|
||||
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
// The notify DO is stubbed to record every notifyPlayer / notifyPlayersEphemeral call
|
||||
// (see vitest.config).
|
||||
const cheerHub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
const framesFor = async (anonymous: string) => {
|
||||
await cheerHub().fetch('http://do/all', { method: 'DELETE' })
|
||||
expect(
|
||||
(
|
||||
await cheer(
|
||||
{ PlayerIdTo: '7109', CheerCategory: '10', RoomId: '112', Anonymous: anonymous },
|
||||
'7108'
|
||||
)
|
||||
).status
|
||||
).toBe(200)
|
||||
return (await (await cheerHub().fetch('http://do/all')).json()) as Array<{
|
||||
playerId?: number
|
||||
playerIds?: number[]
|
||||
ephemeral?: boolean
|
||||
notificationType: string
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
|
||||
for (const id of [7108, 7109, 7120]) await standIn(id, 8800)
|
||||
await standIn(7121, 8801)
|
||||
|
||||
// A signed cheer. Four sends: the PlayerCheer message that plays the cheer on the
|
||||
// target's client, then the ReputationUpdate for the target durably, the rest of their
|
||||
// instance ephemerally, and the giver's own credit refresh.
|
||||
const all = await framesFor('False')
|
||||
expect(all).toHaveLength(4)
|
||||
expect(all[0]).toMatchObject({
|
||||
playerId: 7109,
|
||||
notificationType: 2, // NotificationType.MessageReceived
|
||||
data: { FromPlayerId: 7108, ToPlayerId: 7109, Type: MessageType.PlayerCheer, Data: '10' },
|
||||
})
|
||||
expect(all[0]!.ephemeral).toBeFalsy()
|
||||
const signed = all.slice(1)
|
||||
expect(signed.every((f) => f.notificationType === 'ReputationUpdate')).toBe(true)
|
||||
|
||||
// `AccountId` is who the frame is ABOUT, not who it goes to — the room hears about
|
||||
// 7109. The frame is 7109's RECORD: `SelectedCheer` is their pinned cheer (none), not
|
||||
// the category just given, and `IsCheerful` is the profile flag — the message above
|
||||
// is what plays the cheer.
|
||||
const played = {
|
||||
AccountId: 7109,
|
||||
IsCheerful: true,
|
||||
SelectedCheer: 0,
|
||||
CheerHelpful: 1,
|
||||
}
|
||||
expect(signed[0]).toMatchObject({ playerId: 7109, data: played })
|
||||
// The bystander and the giver see it; the target is not in the room list (they got
|
||||
// the durable copy), and 7121 is in another instance entirely.
|
||||
expect(signed[1]).toMatchObject({ playerIds: [7108, 7120], ephemeral: true, data: played })
|
||||
|
||||
// The giver's second frame is about THEM: their record with the spent credit.
|
||||
expect(signed[2]).toMatchObject({
|
||||
playerId: 7108,
|
||||
data: {
|
||||
AccountId: 7108,
|
||||
IsCheerful: true,
|
||||
SelectedCheer: 0,
|
||||
CheerCredit: DAILY_CHEER_CREDIT - 1,
|
||||
},
|
||||
})
|
||||
|
||||
// An anonymous cheer reaches exactly the same people and moves the same counter —
|
||||
// it just doesn't announce who gave it: the message is the anonymous type from
|
||||
// sender 0. The reputation frames are the same records as before.
|
||||
const allAnonymous = await framesFor('True')
|
||||
expect(allAnonymous).toHaveLength(4)
|
||||
expect(allAnonymous[0]).toMatchObject({
|
||||
playerId: 7109,
|
||||
notificationType: 2, // NotificationType.MessageReceived
|
||||
data: {
|
||||
FromPlayerId: 0,
|
||||
ToPlayerId: 7109,
|
||||
Type: MessageType.PlayerCheerAnonymous,
|
||||
Data: '10',
|
||||
},
|
||||
})
|
||||
const anonymous = allAnonymous.slice(1)
|
||||
expect(anonymous[0]).toMatchObject({
|
||||
playerId: 7109,
|
||||
data: { AccountId: 7109, IsCheerful: true, SelectedCheer: 0, CheerHelpful: 2 },
|
||||
})
|
||||
expect(anonymous[1]).toMatchObject({ playerIds: [7108, 7120], data: { IsCheerful: true } })
|
||||
|
||||
// The frame carries only the fields the client's decoder has — no Noteriety or
|
||||
// subscriber counts, which live on the profile DTO alone.
|
||||
expect(Object.keys(anonymous[0]!.data).sort()).toEqual([
|
||||
'AccountId',
|
||||
'CheerCreative',
|
||||
'CheerCredit',
|
||||
'CheerGeneral',
|
||||
'CheerGreatHost',
|
||||
'CheerHelpful',
|
||||
'CheerSportsman',
|
||||
'IsCheerful',
|
||||
'SelectedCheer',
|
||||
])
|
||||
})
|
||||
|
||||
test('a cheer with no room instance still reaches the player cheered', async () => {
|
||||
// Cheering from a profile screen: the giver has lobby presence (roomInstance null),
|
||||
// so there is no audience — but the target's own frame is not the room's to lose.
|
||||
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 7130,
|
||||
roomInstance: null,
|
||||
statusVisibility: 0,
|
||||
deviceClass: 0,
|
||||
vrMovementMode: 0,
|
||||
platform: 0,
|
||||
appVersion: GAME_VERSION,
|
||||
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
const cheerHub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
await cheerHub().fetch('http://do/all', { method: 'DELETE' })
|
||||
|
||||
expect((await cheer({ PlayerIdTo: '7131', CheerCategory: '40' }, '7130')).status).toBe(200)
|
||||
|
||||
const frames = (await (await cheerHub().fetch('http://do/all')).json()) as Array<{
|
||||
playerId?: number
|
||||
ephemeral?: boolean
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
// Three sends — the target's cheer message and reputation, then the giver's
|
||||
// reputation — all durable and all addressed: nothing was broadcast.
|
||||
expect(frames.map((f) => f.playerId)).toEqual([7131, 7131, 7130])
|
||||
expect(frames.some((f) => f.ephemeral)).toBe(false)
|
||||
expect(frames[0]!.data).toMatchObject({
|
||||
ToPlayerId: 7131,
|
||||
Type: MessageType.PlayerCheer,
|
||||
Data: '40',
|
||||
})
|
||||
expect(frames[1]!.data).toMatchObject({ AccountId: 7131, CheerCreative: 1 })
|
||||
})
|
||||
|
||||
test('SetSelectedCheer pins a cheer to the profile and pushes the record', async () => {
|
||||
const pin = async (CheerCategory: string, sub: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/SetSelectedCheer`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ CheerCategory }),
|
||||
})
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
|
||||
// 7140 has never been cheered — pinning still works, creating their row.
|
||||
const res = await pin(String(CheerCategory.GreatHost), '7140')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ Success: true, Message: null })
|
||||
expect(await getReputation(env.DB, 7140)).toMatchObject({
|
||||
SelectedCheer: CheerCategory.GreatHost,
|
||||
IsCheerful: true,
|
||||
CheerGreatHost: 0,
|
||||
})
|
||||
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
|
||||
playerId?: number
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
expect(frames).toHaveLength(1)
|
||||
expect(frames[0]).toMatchObject({
|
||||
playerId: 7140,
|
||||
data: { AccountId: 7140, SelectedCheer: CheerCategory.GreatHost },
|
||||
})
|
||||
|
||||
// The pin survives a cheer landing on the row, and a cheer's frame carries it.
|
||||
expect((await cheer({ PlayerIdTo: '7140', CheerCategory: '0' }, '7141')).status).toBe(200)
|
||||
expect(await reputationOf(7140)).toMatchObject({ CheerGeneral: 1 })
|
||||
expect(await getReputation(env.DB, 7140)).toMatchObject({
|
||||
SelectedCheer: CheerCategory.GreatHost,
|
||||
})
|
||||
|
||||
// -1 (`None`) unpins, read back as 0; a made-up category is refused.
|
||||
expect(await (await pin('-1', '7140')).json()).toEqual({ Success: true, Message: null })
|
||||
expect(await getReputation(env.DB, 7140)).toMatchObject({ SelectedCheer: 0 })
|
||||
expect(await (await pin('7', '7140')).json()).toEqual({
|
||||
Success: false,
|
||||
Message: 'CheerCategory is not a cheer category',
|
||||
})
|
||||
expect(await pin('0', '7140').then((r) => r.status)).toBe(200)
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/SetSelectedCheer`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ CheerCategory: '0' }),
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
})
|
||||
|
||||
test('cheering needs a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ PlayerIdTo: '7101', CheerCategory: '0' }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('the daily credit runs out and refills a day after the FIRST cheer', async () => {
|
||||
// Driven through spendCheerCredit with an injected clock: burning 20 cheers over HTTP
|
||||
// says nothing more than this does, and the rollover can't be tested any other way.
|
||||
const start = new Date('2026-08-25T09:00:00.000Z')
|
||||
const at = (hours: number) => new Date(start.getTime() + hours * 60 * 60 * 1000)
|
||||
|
||||
// The first spend opens the window; the credit counts down to nothing.
|
||||
for (let spent = 1; spent <= DAILY_CHEER_CREDIT; spent++) {
|
||||
// Spread across the window — spending inside it must not slide the deadline.
|
||||
expect(await spendCheerCredit(env.DB, 7110, at(spent === 1 ? 0 : 12))).toBe(
|
||||
DAILY_CHEER_CREDIT - spent
|
||||
)
|
||||
}
|
||||
expect(await spendCheerCredit(env.DB, 7110, at(12))).toBeNull()
|
||||
expect(await getCheerCredit(env.DB, 7110, at(12))).toBe(0)
|
||||
|
||||
// 23 hours in, still empty: the window is measured from the first cheer, not the last.
|
||||
expect(await spendCheerCredit(env.DB, 7110, at(23))).toBeNull()
|
||||
|
||||
// A day after that first cheer it refills — lazily, on the spend itself, so nothing
|
||||
// has to run on a schedule.
|
||||
expect(await getCheerCredit(env.DB, 7110, at(24.5))).toBe(DAILY_CHEER_CREDIT)
|
||||
expect(await spendCheerCredit(env.DB, 7110, at(24.5))).toBe(DAILY_CHEER_CREDIT - 1)
|
||||
expect(await getCheerCredit(env.DB, 7110, at(25))).toBe(DAILY_CHEER_CREDIT - 1)
|
||||
})
|
||||
|
||||
test('a player out of credit is refused, and the target keeps their counters', async () => {
|
||||
// Empty 7106's credit directly, then try to cheer over HTTP.
|
||||
for (let i = 0; i < DAILY_CHEER_CREDIT; i++) await spendCheerCredit(env.DB, 7106)
|
||||
const res = await cheer({ PlayerIdTo: '7107', CheerCategory: '10' }, '7106')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
Success: false,
|
||||
Message: 'You are out of cheers for today',
|
||||
})
|
||||
expect(await reputationOf(7107)).toMatchObject({ CheerHelpful: 0 })
|
||||
})
|
||||
|
||||
test('GET /api/activities/charades/v1/words/Charades returns the word bank', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/activities/charades/v1/words/Charades`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -778,6 +353,32 @@ describe('public endpoints', () => {
|
||||
expect(charadesWordsFor(new Date('2026-04-01T23:59:59Z'))).toBe(april)
|
||||
})
|
||||
|
||||
// The client POSTs this with no body, despite it being a pure read; the route answers
|
||||
// GET as well, and both methods serve the same body.
|
||||
test.each(['GET', 'POST'])(
|
||||
'%s /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"',
|
||||
async (method) => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
|
||||
{ method }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
|
||||
// reference stub's empty string — the client tells "no message" from a blank one.
|
||||
expect(await res.json()).toEqual({
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// A fixed list, in render order — the client shows the buttons in the order they
|
||||
// arrive, so the order is part of the contract, not just the contents.
|
||||
test('GET /api/PlayerReporting/v1/voteToKickReasons serves the reasons in order', async () => {
|
||||
@@ -867,28 +468,6 @@ describe('public endpoints', () => {
|
||||
expect(body[0]).toMatchObject({ Level: 1, XP: 0 })
|
||||
})
|
||||
|
||||
test('the bulk profile reads answer more ids than D1 will bind at once', async () => {
|
||||
// Both of these are asked about a whole friends list at once, which runs past D1's
|
||||
// 100-bound-parameter cap; unchunked, the statement fails and the endpoint 500s
|
||||
// rather than returning a short list.
|
||||
const ids = Array.from({ length: 250 }, (_, i) => 6000 + i)
|
||||
const query = ids.map((id) => `id=${id}`).join('&')
|
||||
|
||||
const progression = await exports.default.fetch(
|
||||
`${ORIGIN}/api/players/v2/progression/bulk?${query}`
|
||||
)
|
||||
expect(progression.status).toBe(200)
|
||||
const levels = (await progression.json()) as Array<{ PlayerId: number }>
|
||||
expect(levels.map((p) => p.PlayerId)).toEqual(ids)
|
||||
|
||||
const reputation = await exports.default.fetch(
|
||||
`${ORIGIN}/api/playerReputation/v2/bulk?${query}`
|
||||
)
|
||||
expect(reputation.status).toBe(200)
|
||||
const reps = (await reputation.json()) as Array<{ AccountId: number }>
|
||||
expect(reps.map((r) => r.AccountId)).toEqual(ids)
|
||||
})
|
||||
|
||||
test('progression reads back the XP game rewards banked, levelled up', async () => {
|
||||
// The two workers share this table; `econ` writes it when a game reward is claimed (5 XP
|
||||
// at a time). Granted in one lump here to exercise a multi-level climb: 25 XP from level
|
||||
@@ -935,13 +514,6 @@ describe('public endpoints', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('the config carries the room key limit', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/config/v2`)
|
||||
expect(res.status).toBe(200)
|
||||
const config = (await res.json()) as { RoomKeyConfig: { MaxKeysPerRoom: number } }
|
||||
expect(config.RoomKeyConfig).toEqual({ MaxKeysPerRoom: 10 })
|
||||
})
|
||||
|
||||
test('the level rewards match the published reward table', async () => {
|
||||
// Rec Room's published level-reward table, spot-checked at the points where it turns:
|
||||
// consumables early, then clothing at a rising star rating (2★ = 10, 3★ = 20, 4★ = 30,
|
||||
@@ -1009,683 +581,22 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toBe(true)
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v1/featured lists flagged, published items, newest first', async () => {
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
const older = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
item('Older', 1),
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
)
|
||||
const newer = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
item('Newer', 1),
|
||||
new Date('2026-08-02T00:00:00Z')
|
||||
)
|
||||
const unpublished = await createCustomAvatarItem(env.DB, item('Unpublished', 0))
|
||||
const unflagged = await createCustomAvatarItem(env.DB, item('Unflagged', 1))
|
||||
// Nothing flags items yet, so flag straight in the table — the unflagged one stays.
|
||||
await env.DB.prepare(
|
||||
`UPDATE custom_avatar_item SET data = json_set(data, '$.IsFeatured', json('true'))
|
||||
WHERE custom_avatar_item_id != ?1`
|
||||
)
|
||||
.bind(unflagged.CustomAvatarItemId)
|
||||
.run()
|
||||
|
||||
test('GET /api/customAvatarItems/v1/featured returns []', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/featured`)
|
||||
expect(res.status).toBe(200)
|
||||
const got = (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }>
|
||||
expect(got.map((i) => i.CustomAvatarItemId)).toEqual([
|
||||
newer.CustomAvatarItemId,
|
||||
older.CustomAvatarItemId,
|
||||
])
|
||||
expect(got.map((i) => i.CustomAvatarItemId)).not.toContain(unpublished.CustomAvatarItemId)
|
||||
expect(got[0]).toMatchObject({ Name: 'Newer', IsFeatured: true, CurrentSaves: [] })
|
||||
|
||||
function item(name: string, accessibility: number) {
|
||||
return {
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name,
|
||||
description: '',
|
||||
price: 0,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
accessibility,
|
||||
designFilename: 'design_x.bin',
|
||||
thumbnailImageFilename: 'thumb_x.png',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /api/CircuitChipLists/:list is empty for any name', async () => {
|
||||
// A palette on the Maker Pen's circuit board, named by the path. Nothing records which
|
||||
// chips a player has used or favourited, so every one of them is empty — including names
|
||||
// this server has never heard of, which the client will ask for as its build changes.
|
||||
// An unknown name being a 404 would render as a palette that FAILED to load rather than
|
||||
// one with nothing in it.
|
||||
for (const list of [
|
||||
'Favorites',
|
||||
'Recent',
|
||||
'All',
|
||||
'SomePaletteThisServerHasNeverHeardOf',
|
||||
// Path-segment oddities: a name that needs escaping, and a numeric one.
|
||||
encodeURIComponent('Weird Name/With Slash'),
|
||||
'42',
|
||||
]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/CircuitChipLists/${list}`)
|
||||
expect(res.status, list).toBe(200)
|
||||
expect(await res.json(), list).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/featureddormskins returns []', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featureddormskins`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v1/hot lists the published items, newest first', async () => {
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
const empty = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/hot`)
|
||||
expect(empty.status).toBe(200)
|
||||
expect(await empty.json()).toEqual([])
|
||||
|
||||
const older = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
item('Older', 1),
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
)
|
||||
const newer = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
item('Newer', 1),
|
||||
new Date('2026-08-02T00:00:00Z')
|
||||
)
|
||||
const unpublished = await createCustomAvatarItem(env.DB, item('Unpublished', 0))
|
||||
|
||||
test('GET /api/customAvatarItems/v1/hot returns []', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/hot`)
|
||||
expect(res.status).toBe(200)
|
||||
const got = (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }>
|
||||
// Unfeatured but published: the hot feed does not care about the featured flag.
|
||||
expect(got.map((i) => i.CustomAvatarItemId)).toEqual([
|
||||
newer.CustomAvatarItemId,
|
||||
older.CustomAvatarItemId,
|
||||
])
|
||||
expect(got.map((i) => i.CustomAvatarItemId)).not.toContain(unpublished.CustomAvatarItemId)
|
||||
expect(got[0]).toMatchObject({ Name: 'Newer', IsFeatured: false, CurrentSaves: [] })
|
||||
|
||||
function item(name: string, accessibility: number) {
|
||||
return {
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name,
|
||||
description: '',
|
||||
price: 0,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
accessibility,
|
||||
designFilename: 'design_x.bin',
|
||||
thumbnailImageFilename: 'thumb_x.png',
|
||||
}
|
||||
}
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v1/search filters, pages and excludes unpublished', async () => {
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
|
||||
const search = async (query: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`)
|
||||
expect(res.status, query).toBe(200)
|
||||
return (await res.json()) as Array<{
|
||||
CustomAvatarItemId: string
|
||||
Name: string
|
||||
OutfitType: number
|
||||
PurchaseInfo: null
|
||||
}>
|
||||
}
|
||||
|
||||
// A BARE ARRAY, not the `{ Results, TotalResults }` envelope `fromCreator` uses.
|
||||
expect(await search('')).toEqual([])
|
||||
|
||||
const made: Record<string, string> = {}
|
||||
// Six published items across three outfit types, plus one unpublished and one Coach's.
|
||||
// Creation times ascend with the index so "newest first" is unambiguous.
|
||||
const spec: Array<[name: string, outfitType: number, accessibility: number, creator: number]> =
|
||||
[
|
||||
['Hat A', 0, 1, 205],
|
||||
['Shirt A', 2, 1, 205],
|
||||
['Shirt B', 2, 1, 205],
|
||||
['Trousers A', 3, 1, 205],
|
||||
['Coach Hat', 0, 1, 1],
|
||||
['Hidden', 0, 0, 205],
|
||||
]
|
||||
for (const [i, [name, outfitType, accessibility, creator]] of spec.entries()) {
|
||||
const created = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: creator,
|
||||
name,
|
||||
description: '',
|
||||
price: 0,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
accessibility,
|
||||
designFilename: 'design_x.bin',
|
||||
thumbnailImageFilename: 'thumb_x.png',
|
||||
},
|
||||
new Date(Date.UTC(2026, 7, 1 + i))
|
||||
)
|
||||
made[name] = created.CustomAvatarItemId
|
||||
// Every created item is a custom shirt (OutfitType 105); this test wants a spread of
|
||||
// slots to filter across, so set them straight in the table.
|
||||
expect(created.OutfitType).toBe(105)
|
||||
await env.DB.prepare(
|
||||
`UPDATE custom_avatar_item SET data = json_set(data, '$.OutfitType', ?2)
|
||||
WHERE custom_avatar_item_id = ?1`
|
||||
)
|
||||
.bind(created.CustomAvatarItemId, outfitType)
|
||||
.run()
|
||||
}
|
||||
|
||||
// Newest first, and `Hidden` never appears: Accessibility 0 is unpublished, and this is
|
||||
// the shared browse surface — its creator sees it through `fromCreator`, not here.
|
||||
const all = await search('')
|
||||
expect(all.map((i) => i.Name)).toEqual([
|
||||
'Coach Hat',
|
||||
'Trousers A',
|
||||
'Shirt B',
|
||||
'Shirt A',
|
||||
'Hat A',
|
||||
])
|
||||
|
||||
// `outfitTypes` repeats and acts as a whitelist — the real client sends a dozen of them.
|
||||
expect((await search('?outfitTypes=2')).map((i) => i.Name)).toEqual(['Shirt B', 'Shirt A'])
|
||||
expect((await search('?outfitTypes=0&outfitTypes=3')).map((i) => i.Name)).toEqual([
|
||||
'Coach Hat',
|
||||
'Trousers A',
|
||||
'Hat A',
|
||||
])
|
||||
|
||||
// Sending NONE means no filter, not no results: the client sends every type it can render,
|
||||
// so reading an absent parameter as an empty `IN ()` would empty the store.
|
||||
expect((await search('?skip=0&take=100')).map((i) => i.Name)).toEqual(all.map((i) => i.Name))
|
||||
|
||||
// A non-numeric value is dropped rather than becoming NaN, which would match nothing and
|
||||
// quietly empty a filter the caller believes they set.
|
||||
expect((await search('?outfitTypes=2&outfitTypes=nonsense')).map((i) => i.Name)).toEqual([
|
||||
'Shirt B',
|
||||
'Shirt A',
|
||||
])
|
||||
|
||||
// Paging, and it is STABLE: consecutive pages must not repeat or skip a row, which the
|
||||
// id tiebreak in the ordering is what guarantees when timestamps collide.
|
||||
const page1 = await search('?skip=0&take=2')
|
||||
const page2 = await search('?skip=2&take=2')
|
||||
expect(page1.map((i) => i.Name)).toEqual(['Coach Hat', 'Trousers A'])
|
||||
expect(page2.map((i) => i.Name)).toEqual(['Shirt B', 'Shirt A'])
|
||||
expect(
|
||||
page1.some((i) => page2.some((j) => j.CustomAvatarItemId === i.CustomAvatarItemId))
|
||||
).toBe(false)
|
||||
expect(await search('?skip=99&take=10')).toEqual([])
|
||||
expect(await search('?take=0')).toEqual([])
|
||||
|
||||
// `includeCoachItems` picks a SIDE of the catalog rather than widening it: `True` is the
|
||||
// Coach's stock content alone (the storefront tab), `False` the players' alone (the
|
||||
// user-generated-content tab), and the two never overlap. Absent is both. The client
|
||||
// capitalises its booleans, so the comparison folds case.
|
||||
expect((await search('?includeCoachItems=True')).map((i) => i.Name)).toEqual(['Coach Hat'])
|
||||
expect((await search('?includeCoachItems=false')).map((i) => i.Name)).toEqual([
|
||||
'Trousers A',
|
||||
'Shirt B',
|
||||
'Shirt A',
|
||||
'Hat A',
|
||||
])
|
||||
expect((await search('?includeCoachItems=False')).map((i) => i.Name)).not.toContain('Coach Hat')
|
||||
expect((await search('?includeCoachItems=maybe')).map((i) => i.Name)).toEqual(
|
||||
all.map((i) => i.Name)
|
||||
)
|
||||
|
||||
// `creatorAccountId` is one author's published items — a player's creations list. It
|
||||
// was once ignored, and every player's shelf showed the whole player-made catalog.
|
||||
const spec2: Array<[name: string, creator: number]> = [['Hat C', 206]]
|
||||
for (const [name, creator] of spec2) {
|
||||
await createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: creator,
|
||||
name,
|
||||
description: '',
|
||||
price: 0,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
accessibility: 1,
|
||||
designFilename: 'design_x.bin',
|
||||
thumbnailImageFilename: 'thumb_x.png',
|
||||
},
|
||||
new Date(Date.UTC(2026, 7, 20))
|
||||
)
|
||||
}
|
||||
expect((await search('?creatorAccountId=206')).map((i) => i.Name)).toEqual(['Hat C'])
|
||||
// The query the client sends for a player's creations: theirs alone, `Hidden` still out.
|
||||
expect(
|
||||
(
|
||||
await search(
|
||||
'?creatorAccountId=205&includePurchaseInfos=True&includeCoachItems=False&skip=0' +
|
||||
'&take=1000&unityAssetTarget=0&unityAssetVersion=3'
|
||||
)
|
||||
).map((i) => i.Name)
|
||||
).toEqual(['Trousers A', 'Shirt B', 'Shirt A', 'Hat A'])
|
||||
expect(await search('?creatorAccountId=1&includeCoachItems=False')).toEqual([])
|
||||
expect(await search('?creatorAccountId=424242')).toEqual([])
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item WHERE creator_account_id = 206').run()
|
||||
|
||||
// The whole storefront query the client actually sends, unchanged — the parameters that
|
||||
// aren't acted on yet must be accepted rather than 400 or throw, and `includeCoachItems=True`
|
||||
// narrows it to the stock content.
|
||||
const real = await search(
|
||||
'?outfitTypes=0&outfitTypes=2&outfitTypes=3&outfitTypes=10&outfitTypes=20&outfitTypes=100' +
|
||||
'&outfitTypes=101&outfitTypes=102&outfitTypes=103&outfitTypes=200&outfitTypes=300' +
|
||||
'&outfitTypes=301&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
|
||||
'&take=100&unityAssetTarget=0&unityAssetVersion=3'
|
||||
)
|
||||
expect(real.map((i) => i.Name)).toEqual(['Coach Hat'])
|
||||
// `includePurchaseInfos=True` notwithstanding: nothing prices a custom item here yet, so
|
||||
// the field is null on every item and the parameter changes nothing.
|
||||
expect(real.every((i) => i.PurchaseInfo === null)).toBe(true)
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v1/search matches name or description, and bounds price', async () => {
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
|
||||
const search = async (query: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`)
|
||||
expect(res.status, query).toBe(200)
|
||||
return ((await res.json()) as Array<{ Name: string }>).map((i) => i.Name)
|
||||
}
|
||||
|
||||
const make = async (name: string, description: string, price: number, i: number) =>
|
||||
createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
accessibility: 1,
|
||||
designFilename: 'design_x.bin',
|
||||
thumbnailImageFilename: 'thumb_x.png',
|
||||
},
|
||||
new Date(Date.UTC(2026, 7, 1 + i))
|
||||
)
|
||||
|
||||
await make('Room Hat', '', 100, 0)
|
||||
// Matched on DESCRIPTION, not name — the two are searched together.
|
||||
await make('Cosy Beanie', 'Warm in any ROOM', 500, 1)
|
||||
// Matched case-insensitively and as a SUBSTRING, mid-word.
|
||||
await make('Ballroom Shoes', '', 9000, 2)
|
||||
await make('Unrelated Cap', 'nothing to do with it', 250, 3)
|
||||
// A name holding LIKE metacharacters, for the escaping below.
|
||||
await make('100% Wool', 'a_b', 50, 4)
|
||||
|
||||
// Newest first throughout, so the order also proves the query didn't disturb the ordering.
|
||||
expect(await search('?searchQuery=room')).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
||||
// Case folds both ways: SQLite's own LIKE only folds ASCII, so both sides are lowered.
|
||||
expect(await search('?searchQuery=ROOM')).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
||||
expect(await search('?searchQuery=beanie')).toEqual(['Cosy Beanie'])
|
||||
expect(await search('?searchQuery=nothing%20to%20do')).toEqual(['Unrelated Cap'])
|
||||
expect(await search('?searchQuery=zzzz')).toEqual([])
|
||||
|
||||
// Blank or absent is NO filter, not an empty result — a cleared search box must show the
|
||||
// store rather than nothing.
|
||||
expect(await search('?searchQuery=')).toHaveLength(5)
|
||||
expect(await search('?searchQuery=%20%20')).toHaveLength(5)
|
||||
|
||||
// A needle of LIKE metacharacters matches them LITERALLY. Unescaped, `%` would match every
|
||||
// item and `_` any single character, so a player searching for "100%" would get the lot.
|
||||
expect(await search('?searchQuery=%25')).toEqual(['100% Wool'])
|
||||
expect(await search('?searchQuery=a_b')).toEqual(['100% Wool'])
|
||||
|
||||
// Price bounds, inclusive at both ends.
|
||||
expect(await search('?minPrice=250&maxPrice=9000')).toEqual([
|
||||
'Unrelated Cap',
|
||||
'Ballroom Shoes',
|
||||
'Cosy Beanie',
|
||||
])
|
||||
expect(await search('?maxPrice=100')).toEqual(['100% Wool', 'Room Hat'])
|
||||
expect(await search('?minPrice=9000')).toEqual(['Ballroom Shoes'])
|
||||
expect(await search('?minPrice=100000')).toEqual([])
|
||||
|
||||
// Combined with the text search, since the client sends both together.
|
||||
expect(await search('?searchQuery=room&maxPrice=500')).toEqual(['Cosy Beanie', 'Room Hat'])
|
||||
|
||||
// The whole query the client's user-generated-content tab actually sends. `itemTypes=-1`
|
||||
// ("every item type") and the unity asset parameters are accepted and not acted on;
|
||||
// `outfitTypes=105` is the custom shirt, which is what every created item is filed under,
|
||||
// so the tab finds them. (It once found nothing: creation left `outfit_type` at 0.)
|
||||
expect(
|
||||
await search(
|
||||
'?searchQuery=room&itemTypes=-1&outfitTypes=105&minPrice=0&maxPrice=10000' +
|
||||
'&includePurchaseInfos=True&includeCoachItems=False&ordering=0&skip=0&take=1000' +
|
||||
'&unityAssetTarget=0&unityAssetVersion=3'
|
||||
)
|
||||
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
||||
// Same query with the outfit-type filter dropped matches the same, as it must.
|
||||
expect(
|
||||
await search(
|
||||
'?searchQuery=room&itemTypes=-1&minPrice=0&maxPrice=10000&includePurchaseInfos=True' +
|
||||
'&includeCoachItems=False&ordering=0&skip=0&take=1000&unityAssetTarget=0' +
|
||||
'&unityAssetVersion=3'
|
||||
)
|
||||
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
||||
})
|
||||
|
||||
test('the first-party load blanks a save’s assetbundle hashes and leaves the names alone', () => {
|
||||
// The export's hashes are the PC builds'. The same stored save is served to a Quest
|
||||
// pointed at the `quest/` build, which fails the client's check against a PC hash — so
|
||||
// the load stores them blank. A null hash (no second bundle) stays null.
|
||||
const literal = customAvatarItemRowLiteral({
|
||||
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||
CreatorAccountId: 9001,
|
||||
CurrentSaves: [
|
||||
{
|
||||
BodyType: 1,
|
||||
ThumbnailFileName: 'f2y1ndzuvm5ke2hjmn4cwfwfl.png',
|
||||
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
|
||||
UnityAssetHash: 'b1946ac92492d2347c6235b4d2611184',
|
||||
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
|
||||
UnityAsset2Hash: '591785b794601e212b260e25925636fd',
|
||||
},
|
||||
{
|
||||
BodyType: 2,
|
||||
ThumbnailFileName: 'div4cdxc5b7ameui0d5h8ickl.png',
|
||||
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
|
||||
UnityAssetHash: '',
|
||||
UnityAsset2: null,
|
||||
UnityAsset2Hash: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(JSON.parse(literal.slice(1, -1))).toEqual({
|
||||
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||
CreatorAccountId: 1,
|
||||
CurrentSaves: [
|
||||
{
|
||||
BodyType: 1,
|
||||
ThumbnailFileName: 'avatar/f2y1ndzuvm5ke2hjmn4cwfwfl.png',
|
||||
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
|
||||
UnityAssetHash: '',
|
||||
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
|
||||
UnityAsset2Hash: '',
|
||||
},
|
||||
{
|
||||
BodyType: 2,
|
||||
ThumbnailFileName: 'avatar/div4cdxc5b7ameui0d5h8ickl.png',
|
||||
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
|
||||
UnityAssetHash: '',
|
||||
UnityAsset2: null,
|
||||
UnityAsset2Hash: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v1/search serves an imported first-party item as exported', async () => {
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
|
||||
// A first-party item, as the official service exports it: the same record as a player's
|
||||
// shirt with the base-item and filename fields NULL and a built Unity assetbundle per
|
||||
// body type in `CurrentSaves`. The flat table could not hold this at all; the JSON row
|
||||
// stores it verbatim and the search serves it back as-is, saves included.
|
||||
const wings = {
|
||||
Accessibility: 1,
|
||||
BaseAvatarItemColor: null,
|
||||
BaseAvatarItemId: null,
|
||||
CreatedAt: '2024-09-26T21:32:48.523Z',
|
||||
CreatorAccountId: 1,
|
||||
CurrentSaves: [
|
||||
{
|
||||
AdditionalConfiguration:
|
||||
'{"v":1,"d":{"n":"(BB_SkellyWings_Shoulder)","o":100,"b":0,"ir":[6,184,0],"p":"3a74ca85-fa71-44c7-9c28-aabd605908db","a":"09999705-15d4-4a84-9c0a-65cfe5bde8d7"},"ls":1}',
|
||||
BodyType: 0,
|
||||
CreatedAt: '2024-10-02T22:50:38.809Z',
|
||||
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||
CustomAvatarItemSaveId: 1083,
|
||||
Description: null,
|
||||
ModifiedAt: '2024-11-27T02:34:07.523Z',
|
||||
OutfitType: 100,
|
||||
QAState: 2,
|
||||
ThumbnailFileName: 'f2y1ndzuvm5ke2hjmn4cwfwfl.png',
|
||||
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
|
||||
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
|
||||
UnityAsset2Hash: '/QYNs1vk1E+GF6qhVf3eR4YUCb0z4vFVkzPIA6mqjqY=',
|
||||
UnityAssetHash: 'hQjd30phdbC0U7lBMOX/YtpwxEjPwVvVJaQ/4UzKA0Y=',
|
||||
UnityAssetId: '7ba365a7-cf85-4029-80a3-7d0a08ab6ce6',
|
||||
},
|
||||
{
|
||||
AdditionalConfiguration:
|
||||
'{"v":1,"d":{"n":"(FB_SkellyWings_Shoulder)","o":100,"b":1,"ir":[6,186,0],"p":"09999705-15d4-4a84-9c0a-65cfe5bde8d7","a":"3a74ca85-fa71-44c7-9c28-aabd605908db"},"ls":1}',
|
||||
BodyType: 1,
|
||||
CreatedAt: '2024-10-02T22:52:04.891Z',
|
||||
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||
CustomAvatarItemSaveId: 1085,
|
||||
Description: null,
|
||||
ModifiedAt: '2024-11-27T02:34:07.523Z',
|
||||
OutfitType: 100,
|
||||
QAState: 2,
|
||||
ThumbnailFileName: 'div4cdxc5b7ameui0d5h8ickl.png',
|
||||
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
|
||||
UnityAsset2: '5ppm948m3znl2chvbvy88j092.assetbundle',
|
||||
UnityAsset2Hash: 'ZtPbhPSKXkBD9bsmajTmO7A99Ew8tCQBYjE1THE+VUk=',
|
||||
UnityAssetHash: 'TCPaQ1+T74pPBWiVChCYDyC/MNHjHbWEYk7leIqVTOU=',
|
||||
UnityAssetId: '03413da3-e63c-4971-8ca9-c726bc90c0e2',
|
||||
},
|
||||
],
|
||||
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||
CustomBadgeMetadata: null,
|
||||
Description:
|
||||
'This item will have a fix on its connection point to the back on some body types in a future patch.',
|
||||
DesignFilename: null,
|
||||
ForceCannotPublish: false,
|
||||
IsFeatured: false,
|
||||
IsRecRoomApproved: true,
|
||||
ModifiedAt: '2025-02-19T05:19:28.328Z',
|
||||
Name: 'Skeletal Wings',
|
||||
OutfitType: 100,
|
||||
PreviewOrientation: 0,
|
||||
Price: 6000,
|
||||
RankedEntityId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||
RankingContext: null,
|
||||
Tags: [{ TagType: 0, Value: 'export' }],
|
||||
ThumbnailImageFilename: null,
|
||||
}
|
||||
await importCustomAvatarItem(env.DB, wings)
|
||||
// A player-made shirt beside it, to prove the two kinds share one table and one shape.
|
||||
const shirt = await createCustomAvatarItem(env.DB, {
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name: 'Skeleton Tee',
|
||||
description: '',
|
||||
price: 0,
|
||||
baseAvatarItemId: 2184,
|
||||
baseAvatarItemColor: '#F55C1A',
|
||||
accessibility: 1,
|
||||
designFilename: 'design_x.bin',
|
||||
thumbnailImageFilename: 'thumb_x.png',
|
||||
})
|
||||
|
||||
const search = async (query: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`)
|
||||
expect(res.status, query).toBe(200)
|
||||
return (await res.json()) as Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
// The storefront tab's real query finds it (Coach-authored, OutfitType 100 among the
|
||||
// dozen slots asked for) and serves the record exactly as exported, plus the store-side
|
||||
// `PurchaseInfo` the client asked for with `includePurchaseInfos` (null until priced).
|
||||
const store = await search(
|
||||
'?outfitTypes=0&outfitTypes=2&outfitTypes=3&outfitTypes=10&outfitTypes=20&outfitTypes=100' +
|
||||
'&outfitTypes=101&outfitTypes=102&outfitTypes=103&outfitTypes=200&outfitTypes=300' +
|
||||
'&outfitTypes=301&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
|
||||
'&take=100&unityAssetTarget=0&unityAssetVersion=3'
|
||||
)
|
||||
expect(store).toEqual([{ ...wings, PurchaseInfo: null }])
|
||||
|
||||
// The same query from a Quest (`unityAssetTarget=2`, Android/Oculus) serves the same
|
||||
// record with its assetbundles pointed at the `quest/` builds, and nothing else moved.
|
||||
const questStore = await search(
|
||||
'?outfitTypes=100&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
|
||||
'&take=100&unityAssetTarget=2&unityAssetVersion=3'
|
||||
)
|
||||
expect(questStore).toEqual([
|
||||
{
|
||||
...wings,
|
||||
PurchaseInfo: null,
|
||||
CurrentSaves: wings.CurrentSaves.map((save) => ({
|
||||
...save,
|
||||
UnityAsset: `quest/${save.UnityAsset}`,
|
||||
UnityAsset2: `quest/${save.UnityAsset2}`,
|
||||
})),
|
||||
},
|
||||
])
|
||||
|
||||
// Text search matches it by name and by description, and by its id typed whole.
|
||||
expect((await search('?searchQuery=skeletal')).map((i) => i.Name)).toEqual(['Skeletal Wings'])
|
||||
expect((await search('?searchQuery=connection%20point')).map((i) => i.Name)).toEqual([
|
||||
'Skeletal Wings',
|
||||
])
|
||||
expect(
|
||||
(await search('?searchQuery=83FE651F-15B3-46A7-8AFC-ADEC32C35568')).map((i) => i.Name)
|
||||
).toEqual(['Skeletal Wings'])
|
||||
// "skeleton" is not a substring of "skeletal": only the shirt.
|
||||
expect((await search('?searchQuery=skeleton')).map((i) => i.Name)).toEqual(['Skeleton Tee'])
|
||||
|
||||
// The user-generated-content tab's query does not see it: wrong side of the catalog and
|
||||
// wrong slot both.
|
||||
expect(
|
||||
(await search('?outfitTypes=105&includeCoachItems=False')).map((i) => i.CustomAvatarItemId)
|
||||
).toEqual([shirt.CustomAvatarItemId])
|
||||
|
||||
// A player-made shirt still carries the full shape — empty saves, empty tags — so the
|
||||
// client decodes both kinds through one parser.
|
||||
const [tee] = await search('?searchQuery=skeleton')
|
||||
expect(tee).toMatchObject({
|
||||
BaseAvatarItemId: 2184,
|
||||
CurrentSaves: [],
|
||||
Tags: [],
|
||||
CustomBadgeMetadata: null,
|
||||
RankedEntityId: shirt.CustomAvatarItemId,
|
||||
})
|
||||
|
||||
// Re-importing the same id REPLACES the record (a corrected export lands) rather than
|
||||
// duplicating or being ignored.
|
||||
await importCustomAvatarItem(env.DB, { ...wings, Price: 5000 })
|
||||
expect((await search('?searchQuery=skeletal')).map((i) => i.Price)).toEqual([5000])
|
||||
|
||||
// The bulk lookup resolves it too, saves and all.
|
||||
const bulk = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(await bearer('42')) },
|
||||
body: new URLSearchParams([['customAvatarItemIds', wings.CustomAvatarItemId]]),
|
||||
})
|
||||
expect(bulk.status).toBe(200)
|
||||
const [got] = (await bulk.json()) as Array<{ CurrentSaves: unknown[]; Price: number }>
|
||||
expect(got.CurrentSaves).toHaveLength(2)
|
||||
expect(got.Price).toBe(5000)
|
||||
|
||||
// A caller asking for the Quest build (`unityAssetTarget` 2, Android/Oculus) is pointed at
|
||||
// the Quest builds of the same assetbundles, under `quest/` — whether the target rides on
|
||||
// the query string or in the form. Target 0 (PC) and no target get the names as stored.
|
||||
const bulkAssets = async (query: string, form: string[][] = []) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk${query}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
...(await bearer('42')),
|
||||
},
|
||||
body: new URLSearchParams([['customAvatarItemIds', wings.CustomAvatarItemId], ...form]),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const [item] = (await res.json()) as Array<{
|
||||
CurrentSaves: Array<{ UnityAsset: string; UnityAsset2: string | null }>
|
||||
}>
|
||||
return item.CurrentSaves.map((save) => [save.UnityAsset, save.UnityAsset2])
|
||||
}
|
||||
const stored = wings.CurrentSaves.map((save) => [save.UnityAsset, save.UnityAsset2])
|
||||
const quest = stored.map((names) => names.map((name) => `quest/${name}`))
|
||||
expect(await bulkAssets('')).toEqual(stored)
|
||||
expect(await bulkAssets('?unityAssetTarget=0')).toEqual(stored)
|
||||
expect(await bulkAssets('?unityAssetTarget=2')).toEqual(quest)
|
||||
expect(await bulkAssets('', [['unityAssetTarget', '2']])).toEqual(quest)
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v2/fromCreator/:id shows unpublished items only to the creator', async () => {
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
const base = {
|
||||
description: '',
|
||||
price: 0,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
designFilename: 'design_x.bin',
|
||||
thumbnailImageFilename: 'thumb_x.png',
|
||||
}
|
||||
const pub = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
...base,
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name: 'Published',
|
||||
accessibility: 1,
|
||||
},
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
)
|
||||
const draft = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
...base,
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name: 'Draft',
|
||||
accessibility: 0,
|
||||
},
|
||||
new Date('2026-08-02T00:00:00Z')
|
||||
)
|
||||
await createCustomAvatarItem(env.DB, {
|
||||
...base,
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 9,
|
||||
name: 'Other',
|
||||
accessibility: 0,
|
||||
})
|
||||
|
||||
type Page = { Results: Array<{ CustomAvatarItemId: string }>; TotalResults: number }
|
||||
const url = `${ORIGIN}/api/customAvatarItems/v2/fromCreator/205`
|
||||
|
||||
// Anonymous, or someone else: only the published (Accessibility != 0) item.
|
||||
for (const headers of [{}, await bearer('9')]) {
|
||||
const res = await exports.default.fetch(url, { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const page = (await res.json()) as Page
|
||||
expect(page.TotalResults).toBe(1)
|
||||
expect(page.Results.map((i) => i.CustomAvatarItemId)).toEqual([pub.CustomAvatarItemId])
|
||||
}
|
||||
|
||||
// The creator: their unpublished item too, newest first.
|
||||
const own = (await (
|
||||
await exports.default.fetch(url, { headers: await bearer('205') })
|
||||
).json()) as Page
|
||||
expect(own.TotalResults).toBe(2)
|
||||
expect(own.Results.map((i) => i.CustomAvatarItemId)).toEqual([
|
||||
draft.CustomAvatarItemId,
|
||||
pub.CustomAvatarItemId,
|
||||
])
|
||||
|
||||
const none = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
|
||||
expect(await none.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||
test('GET /api/customAvatarItems/v2/fromCreator/:id returns an empty paginated result', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||
})
|
||||
|
||||
// Nothing locks avatar items here, so the array is empty and the posted ids are never
|
||||
@@ -1701,110 +612,29 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
/** Create a custom avatar item and return it, so a bulk lookup has something to find. */
|
||||
async function createCustomItem(
|
||||
creator: string,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<{ CustomAvatarItemId: string; Name: string }> {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
Name: 'bulk item',
|
||||
Description: '',
|
||||
Price: 0,
|
||||
BaseAvatarItemId: 2184,
|
||||
BaseAvatarItemColor: '#F55C1A',
|
||||
Accessibility: 1,
|
||||
...metadata,
|
||||
})
|
||||
)
|
||||
form.set('thumbnailImage', new File([new Uint8Array([1])], 'f.bin', { type: 'image/png' }))
|
||||
form.set('design', new File([new Uint8Array([2])], 'f.bin', { type: 'image/png' }))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(creator),
|
||||
body: form,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as { Value: { CustomAvatarItemId: string; Name: string } }).Value
|
||||
}
|
||||
|
||||
/** POST the bulk lookup with `ids` as repeated form fields, as the client binds them. */
|
||||
async function bulkLookup(
|
||||
ids: string[],
|
||||
as = '42'
|
||||
): Promise<Array<{ CustomAvatarItemId: string; Name: string }>> {
|
||||
// A BARE ARRAY of the items that matched — not the `{ Results, TotalResults }` page
|
||||
// the sibling custom-item reads serve. Nothing stores custom items, so every id
|
||||
// misses, and a miss is an absent entry rather than an error.
|
||||
test('POST /api/customAvatarItems/v1/bulk returns the matching items as an array', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(await bearer(as)) },
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
...(await bearer()),
|
||||
},
|
||||
// Repeated form field, as `[FromForm] List<string>` binds it.
|
||||
body: new URLSearchParams(ids.map((id) => ['customAvatarItemIds', id])),
|
||||
body: new URLSearchParams([
|
||||
['customAvatarItemIds', 'a'],
|
||||
['customAvatarItemIds', 'b'],
|
||||
]),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }>
|
||||
}
|
||||
|
||||
// A BARE ARRAY of the items that matched — not the `{ Results, TotalResults }` page
|
||||
// the sibling custom-item reads serve — resolved out of the `custom_avatar_item` table.
|
||||
// This is what a `1.<guid>` entity in a Generic discovery row resolves through, so it
|
||||
// answering `[]` (as it did while it was a stub) renders that row's items as nothing.
|
||||
test('POST /api/customAvatarItems/v1/bulk resolves the posted ids against the table', async () => {
|
||||
const first = await createCustomItem('205', { Name: 'bulk one' })
|
||||
const second = await createCustomItem('205', { Name: 'bulk two' })
|
||||
|
||||
// In REQUEST order, not creation order — the client reads the array positionally.
|
||||
const items = await bulkLookup([second.CustomAvatarItemId, first.CustomAvatarItemId])
|
||||
expect(items.map((i) => i.CustomAvatarItemId)).toEqual([
|
||||
second.CustomAvatarItemId,
|
||||
first.CustomAvatarItemId,
|
||||
])
|
||||
expect(items[0]).toMatchObject({ Name: 'bulk two', CreatorAccountId: 205, Accessibility: 1 })
|
||||
|
||||
// A miss is an absent entry, not an error: the client reads the items it got back
|
||||
// rather than the ids it asked for, so an unknown id must not cost it the rest.
|
||||
const mixed = await bulkLookup([
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
first.CustomAvatarItemId,
|
||||
])
|
||||
expect(mixed.map((i) => i.CustomAvatarItemId)).toEqual([first.CustomAvatarItemId])
|
||||
|
||||
// Ids also ride comma-separated inside one field, and on the query string — the
|
||||
// client's exact encoding here isn't pinned down, so all three spellings are read.
|
||||
const commas = await bulkLookup([`${first.CustomAvatarItemId},${second.CustomAvatarItemId}`])
|
||||
expect(commas).toHaveLength(2)
|
||||
const queried = await exports.default.fetch(
|
||||
`${ORIGIN}/api/customAvatarItems/v1/bulk?customAvatarItemIds=${first.CustomAvatarItemId}`,
|
||||
{ method: 'POST', headers: await bearer() }
|
||||
)
|
||||
expect(((await queried.json()) as unknown[]).length).toBe(1)
|
||||
|
||||
// Over 100 ids answers EMPTY without touching the table. The client has been seen posting
|
||||
// far more than a screen could draw, and empty is safe precisely because a miss here is
|
||||
// already not an error. Empty rather than the first 100: the client reads the items it got
|
||||
// back, not the ids it asked about, so it cannot tell a truncated batch from a batch of
|
||||
// misses and would cache the difference.
|
||||
const padding = Array.from({ length: 99 }, () => '00000000-0000-0000-0000-000000000000')
|
||||
expect(await bulkLookup([first.CustomAvatarItemId, ...padding])).toHaveLength(1)
|
||||
expect(
|
||||
await bulkLookup([first.CustomAvatarItemId, second.CustomAvatarItemId, ...padding])
|
||||
).toEqual([])
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
// Unpublished items are held back from everyone but their creator — the same rule the
|
||||
// featured/hot feeds and the creator shelf apply, so this route can't surface an item
|
||||
// the feeds hide.
|
||||
test('POST /api/customAvatarItems/v1/bulk hides unpublished items from everyone but the creator', async () => {
|
||||
const hidden = await createCustomItem('206', { Name: 'unpublished', Accessibility: 0 })
|
||||
|
||||
expect(await bulkLookup([hidden.CustomAvatarItemId], '42')).toEqual([])
|
||||
const own = await bulkLookup([hidden.CustomAvatarItemId], '206')
|
||||
expect(own.map((i) => i.Name)).toEqual(['unpublished'])
|
||||
})
|
||||
|
||||
// A missing body is a 200 with an empty array rather than a 400: nothing was asked for,
|
||||
// so nothing matched — the same shape as asking for ids that all miss.
|
||||
test('POST /api/customAvatarItems/v1/bulk answers an empty array for an empty body', async () => {
|
||||
// The ids are never parsed (nothing could match), so a missing body is still a 200
|
||||
// rather than the 400 a body-reading handler would produce.
|
||||
test('POST /api/customAvatarItems/v1/bulk ignores the body', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(),
|
||||
@@ -1914,58 +744,15 @@ describe('public endpoints', () => {
|
||||
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null)
|
||||
})
|
||||
|
||||
test('GET /outfits/me/saved lists every saved slot, ordered by slot', async () => {
|
||||
test('GET /outfits/me/saved 401s without a token, returns [] with one', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`)
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
// A distinct account, so this doesn't depend on what the tests above saved for 42.
|
||||
const saved = async (sub: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
// A player who has never saved gets [], not the empty-outfit envelope `/outfits/me`
|
||||
// serves — an empty wardrobe is an empty list.
|
||||
expect(await saved('4242')).toEqual([])
|
||||
|
||||
const outfit = (slot: number, name: string | null) => ({
|
||||
DataVersion: 2,
|
||||
LegacyData: {
|
||||
SelectionsV1: '193a3bf9-abc0-4d78-8d63-92046908b1c5,,0',
|
||||
SelectionsV2: '{"selections":[]}',
|
||||
FaceFeatures: '{"ver":7}',
|
||||
SkinColor: 'Dc6StLFk60u5iUTrb3_C3w',
|
||||
HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg',
|
||||
},
|
||||
CustomizationSettings: '{"AvatarVersion":2,"AvatarBodyType":0}',
|
||||
Selections: [],
|
||||
Slot: slot,
|
||||
Name: name,
|
||||
Accessibility: 1,
|
||||
ThumbnailFileName: null,
|
||||
// Empty even for account 42, which saved an outfit through PUT /outfits/me above.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
|
||||
headers: await bearer(),
|
||||
})
|
||||
|
||||
// Saved out of order, to prove the list is ordered by slot rather than by write time.
|
||||
for (const [slot, name] of [
|
||||
[2, 'two'],
|
||||
[0, null],
|
||||
] as Array<[number, string | null]>) {
|
||||
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('4242')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(outfit(slot, name)),
|
||||
})
|
||||
}
|
||||
|
||||
// Slot 0 is in the list: it is the outfit being worn, but it is a saved slot too, and
|
||||
// the client picks the slot it writes. Each row comes back verbatim.
|
||||
expect(await saved('4242')).toEqual([outfit(0, null), outfit(2, 'two')])
|
||||
|
||||
// Another account's wardrobe is its own.
|
||||
expect(await saved('4343')).toEqual([])
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /outfits/bulk serves each account’s worn outfit, keyed by id', async () => {
|
||||
@@ -2062,12 +849,12 @@ describe('public endpoints', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('GET /api/progressionEvents/active is a bare -1 (no auth)', async () => {
|
||||
// -1 is the reference server's "no active event" value; the client reads it as "no event
|
||||
// running" and skips the event UI. A 404 would stall its load instead.
|
||||
test('GET /api/progressionEvents/active is an empty list (no auth)', async () => {
|
||||
// The client reads an empty list as "no event running" and skips the event UI; a 404
|
||||
// would stall its load instead.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/active`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(-1)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => {
|
||||
@@ -2197,853 +984,6 @@ describe('public endpoints', () => {
|
||||
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v9/save answers the enveloped result the client reads', async () => {
|
||||
const body = {
|
||||
name: '082926 13:42:46',
|
||||
description: 'No description yet',
|
||||
imageName: 'invention/2026-08-29/52c1e282-76f5-4974-975f-d85060884085.jpg',
|
||||
hasBetaContent: false,
|
||||
instantiationCost: 101,
|
||||
lightsCost: 0,
|
||||
chipsCost: 0,
|
||||
cloudVariablesCost: 0,
|
||||
aiCost: 0,
|
||||
ugcVersion: 1,
|
||||
creationRoomId: 398,
|
||||
inventionDataFilename: '2026-08-29/cb608051-f38b-4ef2-aa8a-a26eb0195b2b.inv',
|
||||
referencedInventions: [],
|
||||
referencedUnityAssetIds: [],
|
||||
creatorAccountRole: 255,
|
||||
convertedFromInventionId: null,
|
||||
displayMetadataJson: '{"0":0,"99":0}',
|
||||
longDescription: '',
|
||||
tagsRequest: { AutoTags: ['small'], CustomTags: null },
|
||||
}
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5151')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// v9 is ENVELOPED where v6 is bare. The client checks Success and then reads
|
||||
// Value.Invention.InventionId — unguarded, so a true Success with a null Value is
|
||||
// the one shape that takes it down.
|
||||
const result = (await res.json()) as InventionSaveV9Result
|
||||
expect(result.Success).toBe(true)
|
||||
expect(result.Error).toBeNull()
|
||||
expect(result.error_id).toBeNull()
|
||||
const value = result.Value
|
||||
if (value === null) throw new Error('Value must not be null on a successful save')
|
||||
expect(value.Status).toBe(0)
|
||||
expect(Object.keys(value).sort()).toEqual([
|
||||
'Invention',
|
||||
'InventionVersion',
|
||||
'Status',
|
||||
'TagsResponse',
|
||||
])
|
||||
|
||||
const saved = value.Invention
|
||||
expect(saved.InventionId).toBeGreaterThan(0)
|
||||
expect(saved.CreatorPlayerId).toBe(5151)
|
||||
expect(saved.Name).toBe(body.name)
|
||||
expect(saved.CreationRoomId).toBe(398)
|
||||
expect(saved.DisplayMetadataJson).toBe('{"0":0,"99":0}')
|
||||
// UgcVersion is an INVENTION field here, next to the version numbers — not a
|
||||
// version one, where its twin HasBetaContent lives.
|
||||
expect(saved.UgcVersion).toBe(1)
|
||||
expect(saved.CurrentVersionNumber).toBe(1)
|
||||
expect(saved.LatestVersionNumber).toBe(1)
|
||||
// The v9 RRInvention has no nested version, no Referenced* and no IsPublished —
|
||||
// the client reads publication from FirstPublishedAt.
|
||||
expect(saved).not.toHaveProperty('CurrentVersion')
|
||||
expect(saved).not.toHaveProperty('ReferencedInventions')
|
||||
expect(saved).not.toHaveProperty('IsPublished')
|
||||
expect(saved.FirstPublishedAt).toBeNull()
|
||||
|
||||
// Costs, the blob and the beta flag ride on the version beside it. No AICost: the
|
||||
// request sends one and this DTO has nowhere to put it.
|
||||
expect(value.InventionVersion).toMatchObject({
|
||||
InventionId: saved.InventionId,
|
||||
VersionNumber: 1,
|
||||
InstantiationCost: 101,
|
||||
HasBetaContent: false,
|
||||
BlobName: body.inventionDataFilename,
|
||||
UgcAccessibility: null,
|
||||
ReferencedInventions: [],
|
||||
ReferencedUnityAssetIds: [],
|
||||
})
|
||||
expect(value.InventionVersion).not.toHaveProperty('AICost')
|
||||
|
||||
// The tagsRequest is applied as `v1/settags` would have applied it, and answered
|
||||
// the way settags answers: a result code and the bare tag NAMES.
|
||||
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['small'] })
|
||||
const details = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/details?inventionId=${saved.InventionId}`
|
||||
)
|
||||
expect(await details.json()).toEqual({ Tags: [{ Tag: 'small', Type: 2 }] })
|
||||
|
||||
// Stored once, read by every version: the older lookup still serves the record it
|
||||
// always did, nested CurrentVersion and all.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${saved.InventionId}`
|
||||
)
|
||||
expect((await one.json()) as SavedInvention).toMatchObject({
|
||||
InventionId: saved.InventionId,
|
||||
IsPublished: false,
|
||||
CurrentVersion: { BlobName: body.inventionDataFilename },
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v9/save leaves the v9-only keys off the stored record', async () => {
|
||||
// The same body a v6 client sends, posted at v9: nothing is back-filled, so the
|
||||
// record is the one v6 has always stored. The response still carries the full v9
|
||||
// projection — those fields have defaults there, not absences.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5152')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Bare Save', inventionDataFilename: 'bare.inv' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const value = ((await res.json()) as InventionSaveV9Result).Value
|
||||
if (value === null) throw new Error('Value must not be null on a successful save')
|
||||
expect(value.Invention.UgcVersion).toBe(0)
|
||||
expect(value.Invention.DisplayMetadataJson).toBeNull()
|
||||
// A save always mints a version; the key is nullable only because econ's
|
||||
// `v3/buyInvention` answers in this same envelope and a buy mints none.
|
||||
expect(value.InventionVersion).not.toBeNull()
|
||||
expect(value.InventionVersion?.HasBetaContent).toBe(false)
|
||||
expect(value.TagsResponse).toEqual({ Result: 0, Tags: [] })
|
||||
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${value.Invention.InventionId}`
|
||||
)
|
||||
const stored = (await one.json()) as SavedInvention
|
||||
expect(stored).not.toHaveProperty('Tags')
|
||||
expect(stored).not.toHaveProperty('UgcVersion')
|
||||
expect(stored).not.toHaveProperty('ReferencedUnityAssetIds')
|
||||
expect(stored.CurrentVersion).not.toHaveProperty('HasBetaContent')
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v9/save refuses through the envelope, never a bare error', async () => {
|
||||
// A refusal the client can show is Success:false with a null Value — the branch
|
||||
// that reads Error and nothing else. A bare `{ error }` body would deserialize to
|
||||
// a null envelope and take the client down instead of failing the save.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5153')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'No Blob' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'inventionDataFilename is required',
|
||||
error_id: null,
|
||||
})
|
||||
|
||||
// Even the 401 answers the envelope: an empty body is a null envelope to the
|
||||
// client, which is the crash, not a refusal.
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Anon', inventionDataFilename: 'anon.inv' }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({
|
||||
Value: null,
|
||||
Success: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v9/save keeps the save when a tag breaks the tag rule', async () => {
|
||||
// The reply carries a tag result of its own, so the two outcomes are separate: a
|
||||
// hyphen in a tag must not cost the player the build they just saved.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5154')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Tagged Badly',
|
||||
inventionDataFilename: 'tagged-badly.inv',
|
||||
tagsRequest: { AutoTags: ['small'], CustomTags: ['bad-tag'] },
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const result = (await res.json()) as InventionSaveV9Result
|
||||
expect(result.Success).toBe(true)
|
||||
const value = result.Value
|
||||
if (value === null) throw new Error('a refused tag must not refuse the save')
|
||||
expect(value.Invention.InventionId).toBeGreaterThan(0)
|
||||
|
||||
// Non-zero result, and the whole list dropped rather than the offending tag alone —
|
||||
// the creator re-submits it through `v1/settags` and sees what took.
|
||||
expect(value.TagsResponse).toEqual({ Result: 1, Tags: [] })
|
||||
const details = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/details?inventionId=${value.Invention.InventionId}`
|
||||
)
|
||||
expect(await details.json()).toEqual({ Tags: [] })
|
||||
|
||||
// The invention is on the creator's shelf regardless.
|
||||
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
|
||||
headers: await bearer('5154'),
|
||||
})
|
||||
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([
|
||||
value.Invention.InventionId,
|
||||
])
|
||||
})
|
||||
|
||||
test('PUT /api/inventions/v2/metadata edits only the fields that aren’t null', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5160')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Before Edit',
|
||||
description: 'the original description',
|
||||
imageName: 'invention/before.jpg',
|
||||
inventionDataFilename: 'before-edit.inv',
|
||||
longDescription: 'the original blurb',
|
||||
tagsRequest: { AutoTags: ['small'], CustomTags: null },
|
||||
}),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
expect(inventionId).toBeGreaterThan(0)
|
||||
|
||||
// The client sends the whole shape every time and marks what it isn't touching as
|
||||
// null — so a null Name must not blank the name.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('5160')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
InventionId: inventionId,
|
||||
Name: null,
|
||||
Description: 'devin test No description yet',
|
||||
LongDescription: null,
|
||||
ImageName: null,
|
||||
TagsRequest: null,
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const result = (await res.json()) as InventionSaveV9Result
|
||||
expect(result.Success).toBe(true)
|
||||
const value = result.Value
|
||||
if (value === null) throw new Error('Value must not be null on a successful edit')
|
||||
|
||||
// The edit answers the UPDATED invention — the client re-renders the detail page
|
||||
// from it — in the same envelope the save answers.
|
||||
expect(value.Invention.Description).toBe('devin test No description yet')
|
||||
expect(value.Invention.Name).toBe('Before Edit')
|
||||
expect(value.Invention.ImageName).toBe('invention/before.jpg')
|
||||
expect(value.Invention.InventionId).toBe(inventionId)
|
||||
// A null TagsRequest leaves the stored tags alone, and they are still reported: the
|
||||
// list is what the invention HAS, not what this call changed.
|
||||
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['small'] })
|
||||
|
||||
// And it stuck — including the long description, which the v9 Invention DTO has no
|
||||
// key for but the record keeps.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect((await one.json()) as SavedInvention).toMatchObject({
|
||||
Name: 'Before Edit',
|
||||
Description: 'devin test No description yet',
|
||||
LongDescription: 'the original blurb',
|
||||
Tags: [{ Tag: 'small', Type: 2 }],
|
||||
})
|
||||
})
|
||||
|
||||
test('PUT /api/inventions/v2/metadata treats an empty string as a clear, not a null', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Clear Me',
|
||||
description: 'to be cleared',
|
||||
imageName: 'invention/clear-me.jpg',
|
||||
inventionDataFilename: 'clear-me.inv',
|
||||
longDescription: 'blurb to be cleared',
|
||||
}),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
InventionId: inventionId,
|
||||
Name: null,
|
||||
Description: '',
|
||||
LongDescription: '',
|
||||
ImageName: '',
|
||||
TagsRequest: { AutoTags: ['large'], CustomTags: ['puzzle'] },
|
||||
}),
|
||||
})
|
||||
const value = ((await res.json()) as InventionSaveV9Result).Value
|
||||
if (value === null) throw new Error('Value must not be null on a successful edit')
|
||||
expect(value.Invention.Description).toBe('')
|
||||
expect(value.Invention.ImageName).toBe('')
|
||||
// TagsRequest replaces both lists wholesale, auto first, then custom.
|
||||
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['large', 'puzzle'] })
|
||||
|
||||
// An empty name is not how a name is cleared — nothing can draw a nameless
|
||||
// invention, so it fails the same rule a save holds it to and nothing is written.
|
||||
const named = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Name: '' }),
|
||||
})
|
||||
expect(named.status).toBe(200)
|
||||
expect((await named.json()) as InventionSaveV9Result).toMatchObject({
|
||||
Value: null,
|
||||
Success: false,
|
||||
})
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect(((await one.json()) as SavedInvention).Name).toBe('Clear Me')
|
||||
})
|
||||
|
||||
// The permission picker's own call. `Permission` is the `GeneralPermission` ladder
|
||||
// number — 40 is EditAndSave — and it decides what OTHER players may do with the
|
||||
// invention, up to editing, re-publishing and charging for it.
|
||||
test('POST /api/inventions/v2/update sets the permission from the body', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Permission Me', inventionDataFilename: 'perm.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
expect(inventionId).toBeGreaterThan(0)
|
||||
|
||||
// The body verbatim as the client posts it.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: 40 }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const result = (await res.json()) as InventionSaveV9Result
|
||||
expect(result.Success).toBe(true)
|
||||
// The UPDATED invention comes back in the envelope: the client re-renders the detail
|
||||
// sheet from `Value.Invention`, so a reply that omitted it would leave the old
|
||||
// permission on screen.
|
||||
expect(result.Value?.Invention.GeneralPermission).toBe(40)
|
||||
|
||||
// And it stuck.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect(((await one.json()) as SavedInvention).GeneralPermission).toBe(40)
|
||||
})
|
||||
|
||||
// THE gate. `GeneralPermission` is what lets another player edit, re-publish or charge
|
||||
// for someone's build, so a write that took the caller's word for the id would let
|
||||
// anyone hand themselves rights over any invention in the game. Refused in-band, the
|
||||
// way `v2/metadata` refuses — this client cannot parse a bare error body — and, the
|
||||
// part that matters, the stored permission must not move.
|
||||
test('POST /api/inventions/v2/update is gated to the invention’s creator', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Not Yours Either', inventionDataFilename: 'nye.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
const permissionNow = async () => {
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
return ((await one.json()) as SavedInvention).GeneralPermission
|
||||
}
|
||||
const before = await permissionNow()
|
||||
|
||||
// Another player, granting themselves Unlimited (100) over someone else's build.
|
||||
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: 100 }),
|
||||
})
|
||||
expect(theirs.status).toBe(200)
|
||||
expect(await theirs.json()).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'Not your invention',
|
||||
error_id: null,
|
||||
})
|
||||
expect(await permissionNow()).toBe(before)
|
||||
|
||||
// An unknown invention is the same kind of answer.
|
||||
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: 987655, Permission: 40 }),
|
||||
})
|
||||
expect(missing.status).toBe(200)
|
||||
expect((await missing.json()) as InventionSaveV9Result).toMatchObject({
|
||||
Value: null,
|
||||
Error: 'No such invention',
|
||||
})
|
||||
|
||||
// A missing token is the one refusal that stays a transport failure — but it still
|
||||
// answers the envelope, because an unparseable body crashes the client.
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: 100 }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({
|
||||
Value: null,
|
||||
Success: false,
|
||||
})
|
||||
expect(await permissionNow()).toBe(before)
|
||||
})
|
||||
|
||||
// A patch, not a replace — and specifically NOT `v4/publish`'s reading of a null
|
||||
// permission, which is UseOnly. Publishing something without naming a permission is a
|
||||
// decision; editing it without naming one is not, and defaulting here would silently
|
||||
// demote an invention every time the creator renamed it.
|
||||
test('POST /api/inventions/v2/update leaves a null permission alone', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Keep My Rights', inventionDataFilename: 'keep.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
// Set it to Publish (60) first, so a fallback to UseOnly (20) would be visible.
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: 60 }),
|
||||
})
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: null, Name: 'Renamed' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const value = ((await res.json()) as InventionSaveV9Result).Value
|
||||
expect(value?.Invention.GeneralPermission).toBe(60)
|
||||
expect(value?.Invention.Name).toBe('Renamed')
|
||||
})
|
||||
|
||||
// The same parser `v1/update`'s query param goes through, so a build that sends the
|
||||
// ladder by NAME lands on the same level as one sending the number.
|
||||
test('POST /api/inventions/v2/update accepts a permission named as a string', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Named Ladder', inventionDataFilename: 'named.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
for (const [sent, expected] of [
|
||||
['EditAndSave', 40],
|
||||
['edit_and_save', 40],
|
||||
['60', 60],
|
||||
] as const) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: sent }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as InventionSaveV9Result).Value?.Invention.GeneralPermission).toBe(
|
||||
expected
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('PUT /api/inventions/v2/metadata refuses another creator’s invention in-band', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5162')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Not Yours', inventionDataFilename: 'not-yours.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
// Someone else's invention and an unknown one are domain answers, not transport
|
||||
// ones — the client's own status enum has NotCreator and DoesNotExist members — so
|
||||
// they come back 200 in the envelope, where the message reaches a human.
|
||||
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('5163')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Description: 'hijacked' }),
|
||||
})
|
||||
expect(theirs.status).toBe(200)
|
||||
expect(await theirs.json()).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'Not your invention',
|
||||
error_id: null,
|
||||
})
|
||||
|
||||
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('5162')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: 987654, Description: 'nobody' }),
|
||||
})
|
||||
expect(missing.status).toBe(200)
|
||||
expect((await missing.json()) as InventionSaveV9Result).toMatchObject({
|
||||
Value: null,
|
||||
Error: 'No such invention',
|
||||
})
|
||||
|
||||
// A missing token is the one refusal that stays a transport failure — but it still
|
||||
// answers the envelope, because an unparseable body crashes the client.
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Description: 'anon' }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({
|
||||
Value: null,
|
||||
Success: false,
|
||||
})
|
||||
|
||||
// Untouched throughout.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect(((await one.json()) as SavedInvention).Description).toBe('No description yet')
|
||||
})
|
||||
|
||||
test('PUT /api/inventions/v2/metadata keeps the edit when a tag breaks the tag rule', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5164')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Tag Trouble',
|
||||
inventionDataFilename: 'tag-trouble.inv',
|
||||
tagsRequest: { AutoTags: ['small'], CustomTags: null },
|
||||
}),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('5164')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
InventionId: inventionId,
|
||||
Description: 'edited anyway',
|
||||
TagsRequest: { AutoTags: ['small'], CustomTags: ['bad-tag'] },
|
||||
}),
|
||||
})
|
||||
const value = ((await res.json()) as InventionSaveV9Result).Value
|
||||
if (value === null) throw new Error('a refused tag must not refuse the edit')
|
||||
// The metadata edit lands; the tags are what didn't.
|
||||
expect(value.Invention.Description).toBe('edited anyway')
|
||||
expect(value.TagsResponse).toEqual({ Result: 1, Tags: [] })
|
||||
const details = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/details?inventionId=${inventionId}`
|
||||
)
|
||||
expect(await details.json()).toEqual({ Tags: [] })
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v4/publish publishes with the permission and accessibility sent', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Publish Me', inventionDataFilename: 'publish-me.inv' }),
|
||||
})
|
||||
const saved = ((await save.json()) as InventionSaveV9Result).Value?.Invention
|
||||
expect(saved?.FirstPublishedAt).toBeNull()
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
InventionId: saved?.InventionId,
|
||||
Permission: 20,
|
||||
Accessibility: 1,
|
||||
Price: null,
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const result = (await res.json()) as InventionSaveV9Result
|
||||
expect(result.Success).toBe(true)
|
||||
const value = result.Value
|
||||
if (value === null) throw new Error('Value must not be null on a successful publish')
|
||||
|
||||
// Publishing narrows what everyone else gets down to what the sheet sent, and dates
|
||||
// the invention — the client reads publication from FirstPublishedAt, not a flag.
|
||||
expect(value.Invention.GeneralPermission).toBe(20)
|
||||
expect(value.Invention.Accessibility).toBe(1)
|
||||
expect(typeof value.Invention.FirstPublishedAt).toBe('string')
|
||||
expect(value.Invention.Price).toBe(0)
|
||||
|
||||
// And it's findable now: the record the older reads serve says published, and it
|
||||
// turns up in search.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${value.Invention.InventionId}`
|
||||
)
|
||||
expect((await one.json()) as SavedInvention).toMatchObject({ IsPublished: true })
|
||||
const found = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?value=Publish Me`)
|
||||
expect(((await found.json()) as SavedInvention[]).map((i) => i.InventionId)).toContain(
|
||||
value.Invention.InventionId
|
||||
)
|
||||
|
||||
// Taken back out: the browse tests below assert the exact published catalogue, and
|
||||
// a test that publishes something publicly is a test that changes it.
|
||||
await env.DB.prepare('DELETE FROM invention WHERE id = ?1')
|
||||
.bind(value.Invention.InventionId)
|
||||
.run()
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v4/publish keeps an unlisted invention out of the feeds', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Quietly Published',
|
||||
inventionDataFilename: 'quietly-published.inv',
|
||||
creationRoomId: 4171,
|
||||
}),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 2 }),
|
||||
})
|
||||
const value = ((await res.json()) as InventionSaveV9Result).Value
|
||||
expect(value?.Invention.Accessibility).toBe(2)
|
||||
|
||||
// Unlisted is published — it is reachable by id, which is the whole point of it —
|
||||
// but it is not something anyone comes across.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect((await one.json()) as SavedInvention).toMatchObject({
|
||||
InventionId: inventionId,
|
||||
IsPublished: true,
|
||||
})
|
||||
const found = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v2/search?value=Quietly Published`
|
||||
)
|
||||
expect(await found.json()).toEqual([])
|
||||
const room = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=4171`)
|
||||
expect(await room.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v4/publish leaves a price and a first-publish date alone', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'For Sale', inventionDataFilename: 'for-sale.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
const publish = async (body: Record<string, unknown>) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, ...body }),
|
||||
})
|
||||
const value = ((await res.json()) as InventionSaveV9Result).Value
|
||||
if (value === null) throw new Error('Value must not be null on a successful publish')
|
||||
return value.Invention
|
||||
}
|
||||
|
||||
const first = await publish({ Permission: 80, Accessibility: 1, Price: 250 })
|
||||
expect(first.Price).toBe(250)
|
||||
|
||||
// A republish that says nothing about money must not give away something that was
|
||||
// for sale, and must not re-date the first publish.
|
||||
const again = await publish({ Permission: 20, Accessibility: 1, Price: null })
|
||||
expect(again.Price).toBe(250)
|
||||
expect(again.GeneralPermission).toBe(20)
|
||||
expect(again.FirstPublishedAt).toBe(first.FirstPublishedAt)
|
||||
|
||||
// A negative price is dropped rather than stored.
|
||||
expect((await publish({ Price: -5 })).Price).toBe(250)
|
||||
|
||||
// Out of the published catalogue again — see the note in the publish test above.
|
||||
await env.DB.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run()
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v4/publish refuses another creator’s invention in-band', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Theirs Alone', inventionDataFilename: 'theirs-alone.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 1 }),
|
||||
})
|
||||
expect(theirs.status).toBe(200)
|
||||
expect(await theirs.json()).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'Not your invention',
|
||||
error_id: null,
|
||||
})
|
||||
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 1 }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({ Value: null })
|
||||
|
||||
// Still unpublished throughout.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect((await one.json()) as SavedInvention).toMatchObject({
|
||||
IsPublished: false,
|
||||
FirstPublishedAt: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v2/delete removes the creator’s invention', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Delete Me',
|
||||
inventionDataFilename: 'delete-me.inv',
|
||||
tagsRequest: { AutoTags: ['small'], CustomTags: null },
|
||||
}),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
expect(inventionId).toBeGreaterThan(0)
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
// `Value` is null even on success — there is no invention left to redraw from.
|
||||
expect(await res.json()).toEqual({ Value: null, Success: true, Error: null, error_id: null })
|
||||
|
||||
// Gone from the read and from the creator's shelf.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect(one.status).toBe(404)
|
||||
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
|
||||
headers: await bearer('5180'),
|
||||
})
|
||||
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).not.toContain(
|
||||
inventionId
|
||||
)
|
||||
|
||||
// And the row itself, tags and all, rather than a hidden record still taking the id.
|
||||
const row = await env.DB.prepare('SELECT COUNT(*) AS n FROM invention WHERE id = ?1')
|
||||
.bind(inventionId)
|
||||
.first<{ n: number }>()
|
||||
expect(row?.n).toBe(0)
|
||||
|
||||
// Deleting it twice is a refusal, not a second success: the id resolves to nothing.
|
||||
const again = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId }),
|
||||
})
|
||||
expect(await again.json()).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'No such invention',
|
||||
error_id: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v2/delete refuses anyone but the creator, in-band', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5181')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Not Yours To Bin', inventionDataFilename: 'not-yours.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
// 5182 BOUGHT it — owning a copy is still not the right to delete it.
|
||||
await grantInvention(env.DB, 5182, inventionId as number)
|
||||
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5182')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId }),
|
||||
})
|
||||
expect(theirs.status).toBe(200)
|
||||
expect(await theirs.json()).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'Not your invention',
|
||||
error_id: null,
|
||||
})
|
||||
|
||||
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5181')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: 987_655 }),
|
||||
})
|
||||
expect(missing.status).toBe(200)
|
||||
expect(await missing.json()).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'No such invention',
|
||||
error_id: null,
|
||||
})
|
||||
|
||||
// A missing token is the one refusal that stays a transport failure — and it still
|
||||
// answers the envelope rather than a bare error body.
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
expect(await anon.json()).toMatchObject({ Value: null, Success: false })
|
||||
|
||||
// Still there throughout.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect(one.status).toBe(200)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v2/delete leaves a buyer’s ownership row behind', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5183')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Sold Then Binned', inventionDataFilename: 'sold.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention
|
||||
.InventionId as number
|
||||
await grantInvention(env.DB, 5184, inventionId)
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5183')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId }),
|
||||
})
|
||||
expect((await res.json()) as { Success: boolean }).toMatchObject({ Success: true })
|
||||
|
||||
// The purchase record is not rewritten by someone else's delete...
|
||||
const owned = await env.DB.prepare(
|
||||
'SELECT COUNT(*) AS n FROM inventory_invention WHERE invention_id = ?1'
|
||||
)
|
||||
.bind(inventionId)
|
||||
.first<{ n: number }>()
|
||||
expect(owned?.n).toBe(1)
|
||||
|
||||
// ...but with no invention row behind it, it drops out of the buyer's shelf anyway.
|
||||
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
|
||||
headers: await bearer('5184'),
|
||||
})
|
||||
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).not.toContain(
|
||||
inventionId
|
||||
)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v2/mine lists bought inventions alongside the caller’s own', async () => {
|
||||
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
|
||||
// exactly this row) and also creates one of their own.
|
||||
@@ -3087,245 +1027,6 @@ describe('public endpoints', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('POST /api/customAvatarItems/v1/:id/report files a report against the item’s creator', async () => {
|
||||
// 205 makes an item; 42 reports it. The creator is derived FROM the item — the client
|
||||
// sends `ReportedPlayerId: null` because it does not know who made it.
|
||||
const item = await createCustomItem('205', { Name: 'Reportable Hat' })
|
||||
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ReportCategory: 2,
|
||||
Details: 'tesfsfsdf',
|
||||
ReportedPlayerId: null,
|
||||
}),
|
||||
}
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
// One row in the shared report table, marked as an item report by `custom_avatar_item_id`.
|
||||
// `room_id` stays null — an item isn't tied to one room the way an event is — and the
|
||||
// other two id columns stay null, which is what tells the kinds apart.
|
||||
const row = await env.DB.prepare('SELECT * FROM report WHERE custom_avatar_item_id = ?1')
|
||||
.bind(item.CustomAvatarItemId)
|
||||
.first<Record<string, unknown>>()
|
||||
expect(row).toMatchObject({
|
||||
reporter_player_id: 42,
|
||||
reported_player_id: 205, // the item's creator
|
||||
report_category: 2,
|
||||
details: 'tesfsfsdf',
|
||||
custom_avatar_item_id: item.CustomAvatarItemId,
|
||||
invention_id: null,
|
||||
event_id: null,
|
||||
room_id: null,
|
||||
banned: 0, // filed unbanned, like any report
|
||||
})
|
||||
|
||||
// A body naming SOMEONE ELSE is ignored: the reported player is read off the item either
|
||||
// way. Letting a client name who a report is against would let it point one at anybody.
|
||||
await exports.default.fetch(
|
||||
`${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ReportCategory: 1, ReportedPlayerId: 999 }),
|
||||
}
|
||||
)
|
||||
const reported = await env.DB.prepare(
|
||||
'SELECT reported_player_id FROM report WHERE custom_avatar_item_id = ?1'
|
||||
)
|
||||
.bind(item.CustomAvatarItemId)
|
||||
.all<{ reported_player_id: number }>()
|
||||
// Nothing dedupes: two reports of the same item are two rows, both against the creator.
|
||||
expect(reported.results.map((r) => r.reported_player_id)).toEqual([205, 205])
|
||||
|
||||
// An item that does not exist is refused rather than filed against nobody — the row's
|
||||
// reported player has to be someone.
|
||||
const unknown = await exports.default.fetch(
|
||||
`${ORIGIN}/api/customAvatarItems/v1/00000000-0000-0000-0000-000000000000/report`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ReportCategory: 0 }),
|
||||
}
|
||||
)
|
||||
expect(unknown.status).toBe(404)
|
||||
expect(await unknown.json()).toEqual({ success: false, error: 'No such item' })
|
||||
|
||||
// Auth-gated: the reporter comes from the token, so there is no filing one signed out.
|
||||
const anon = await exports.default.fetch(
|
||||
`${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ReportCategory: 0 }),
|
||||
}
|
||||
)
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v1/report files a report row against the invention', async () => {
|
||||
// 5150 saves an invention; 42 reports it. The creator is derived from the invention,
|
||||
// so the reporter never gets to name who the report is against.
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Reportable', inventionDataFilename: 'blob' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveResult).Invention.InventionId
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Details: 'test', ReportCategory: 0 }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
// One row in the shared report table, marked as an invention report by `invention_id`,
|
||||
// with the reported player filled in FROM the invention. `room_id` stays null: an
|
||||
// invention isn't tied to one room the way an event is, so there is nothing to read.
|
||||
const row = await env.DB.prepare('SELECT * FROM report WHERE invention_id = ?1')
|
||||
.bind(inventionId)
|
||||
.first<Record<string, unknown>>()
|
||||
expect(row).toMatchObject({
|
||||
reporter_player_id: 42,
|
||||
reported_player_id: 5150, // the invention's creator
|
||||
report_category: 0,
|
||||
details: 'test',
|
||||
invention_id: inventionId,
|
||||
event_id: null, // the two id columns are mutually exclusive
|
||||
room_id: null,
|
||||
banned: 0, // filed unbanned, like any report
|
||||
})
|
||||
|
||||
// A body with no usable invention id, and one naming an invention that doesn't exist —
|
||||
// both answer the same envelope shape as the success branch.
|
||||
const noId = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ Details: 'x' }),
|
||||
})
|
||||
expect(noId.status).toBe(400)
|
||||
expect(await noId.json()).toEqual({ success: false, error: 'InventionId is required' })
|
||||
|
||||
const unknown = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: 999999 }),
|
||||
})
|
||||
expect(unknown.status).toBe(404)
|
||||
expect(await unknown.json()).toEqual({ success: false, error: 'No such invention' })
|
||||
|
||||
// Auth-gated: the reporter comes from the token, so there's no filing one signed out.
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ InventionId: inventionId }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/chatreport/createChatReport files a report row against the message sender', async () => {
|
||||
const url = `${ORIGIN}/api/chatreport/createChatReport`
|
||||
const post = async (sub: string, fields: Record<string, string>) =>
|
||||
exports.default.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: await bearer(sub),
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
|
||||
// 5150 says something in a DM with 42; 42 reports it. The body names no player — the
|
||||
// reported one is the message's sender.
|
||||
const threadId = await createThread(env.DB, [42, 5150])
|
||||
const message = await postMessage(env.DB, {
|
||||
chatThreadId: threadId,
|
||||
senderPlayerId: 5150,
|
||||
contents: '{"Type":0,"Version":1,"Data":"something awful"}',
|
||||
})
|
||||
const id = String(message.chatMessageId)
|
||||
|
||||
// The body exactly as the client posts it: the category is a NAME, not a number.
|
||||
const res = await post('42', {
|
||||
ChatThreadId: String(threadId),
|
||||
ChatMessageId: id,
|
||||
ReportCategory: 'Discriminatory',
|
||||
ReportDescription: 'details',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
const row = await env.DB.prepare('SELECT * FROM report WHERE chat_message_id = ?1')
|
||||
.bind(message.chatMessageId)
|
||||
.first<Record<string, unknown>>()
|
||||
expect(row).toMatchObject({
|
||||
reporter_player_id: 42,
|
||||
reported_player_id: 5150, // the message's sender
|
||||
report_category: 102, // `Discriminatory` → CoCDiscrimination
|
||||
details: 'details',
|
||||
chat_message_id: message.chatMessageId,
|
||||
event_id: null, // the id columns are mutually exclusive
|
||||
invention_id: null,
|
||||
custom_avatar_item_id: null,
|
||||
room_id: null,
|
||||
banned: 0, // filed unbanned, like any report
|
||||
})
|
||||
|
||||
// The thread in the body is not what vouches for the message: a wrong one changes
|
||||
// nothing, and a category the server can't place is filed as 0 with its name kept.
|
||||
const odd = await post('42', {
|
||||
ChatThreadId: '999999',
|
||||
ChatMessageId: id,
|
||||
ReportCategory: 'SomethingNew',
|
||||
ReportDescription: 'why',
|
||||
})
|
||||
expect(odd.status).toBe(200)
|
||||
const oddRow = await env.DB.prepare(
|
||||
'SELECT report_category, details FROM report WHERE chat_message_id = ?1 ORDER BY id DESC'
|
||||
)
|
||||
.bind(message.chatMessageId)
|
||||
.first()
|
||||
expect(oddRow).toEqual({ report_category: 0, details: '[SomethingNew] why' })
|
||||
|
||||
// Someone outside the thread can't report a message they can't read — and is told
|
||||
// exactly what they'd be told about a message that doesn't exist.
|
||||
const outsider = await post('777', { ChatMessageId: id, ReportCategory: 'Discriminatory' })
|
||||
expect(outsider.status).toBe(404)
|
||||
expect(await outsider.json()).toEqual({ success: false, error: 'No such message' })
|
||||
const unknown = await post('42', { ChatMessageId: '999999' })
|
||||
expect(unknown.status).toBe(404)
|
||||
expect(await unknown.json()).toEqual({ success: false, error: 'No such message' })
|
||||
|
||||
// A system notice has no sender to file a report against.
|
||||
const notice = await postMessage(env.DB, {
|
||||
chatThreadId: threadId,
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
contents: '{"Type":1,"Version":1,"Data":"5150"}',
|
||||
})
|
||||
const system = await post('42', { ChatMessageId: String(notice.chatMessageId) })
|
||||
expect(system.status).toBe(400)
|
||||
|
||||
const noId = await post('42', { ReportDescription: 'x' })
|
||||
expect(noId.status).toBe(400)
|
||||
expect(await noId.json()).toEqual({ success: false, error: 'ChatMessageId is required' })
|
||||
|
||||
// Auth-gated: the reporter comes from the token, so there's no filing one signed out.
|
||||
const anon = await exports.default.fetch(url, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ ChatMessageId: id }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
// Only the two accepted reports were filed.
|
||||
const count = await env.DB.prepare(
|
||||
'SELECT COUNT(*) AS n FROM report WHERE chat_message_id IS NOT NULL'
|
||||
).first<{ n: number }>()
|
||||
expect(count?.n).toBe(2)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
@@ -3578,82 +1279,6 @@ describe('public endpoints', () => {
|
||||
expect(await miss.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v2/search filters and pages in SQL, and does not search tags', async () => {
|
||||
const published = (id: number, name: string, description: string, tags: string[]) =>
|
||||
({
|
||||
InventionId: id,
|
||||
ReplicationId: crypto.randomUUID(),
|
||||
CreatorPlayerId: 8081,
|
||||
Name: name,
|
||||
Description: description,
|
||||
ImageName: '',
|
||||
CurrentVersionNumber: 1,
|
||||
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
|
||||
IsPublished: true,
|
||||
HideFromPlayer: false,
|
||||
CreatedAt: `2026-09-0${id - 200}T00:00:00Z`,
|
||||
Tags: tags.map((Tag) => ({ Tag, Type: 2 })),
|
||||
}) as unknown as SavedInvention
|
||||
|
||||
for (const inv of [
|
||||
published(201, 'Devin Cube', 'i dont even know lol', ['small']),
|
||||
published(202, 'Devin Cube 2', 'No description yet', ['small', 'dormanchor']),
|
||||
published(203, 'Recflarian Flag', 'idk..... lol', ['medium']),
|
||||
published(204, 'Smallest Table', 'a small table', ['medium']),
|
||||
// Name holds LIKE metacharacters, for the escaping below.
|
||||
published(205, '100% Cube_Thing', '', []),
|
||||
]) {
|
||||
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
|
||||
.bind(JSON.stringify(inv))
|
||||
.run()
|
||||
}
|
||||
|
||||
const ids = async (query: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?${query}`)
|
||||
expect(res.status, query).toBe(200)
|
||||
return ((await res.json()) as SavedInvention[])
|
||||
.map((i) => i.InventionId)
|
||||
.filter((id) => id >= 201)
|
||||
}
|
||||
|
||||
// Newest first, matched against name OR description, case-insensitively.
|
||||
expect(await ids('value=cube&skip=0&take=100')).toEqual([205, 202, 201])
|
||||
expect(await ids('value=CUBE&skip=0&take=100')).toEqual([205, 202, 201])
|
||||
expect(await ids('value=small&skip=0&take=100')).toEqual([204])
|
||||
expect(await ids('value=lol&skip=0&take=100')).toEqual([203, 201])
|
||||
|
||||
// Terms are ANDed, so more words narrow rather than widen.
|
||||
expect(await ids(`value=${encodeURIComponent('devin cube')}&skip=0&take=100`)).toEqual([
|
||||
202, 201,
|
||||
])
|
||||
expect(await ids(`value=${encodeURIComponent('devin flag')}&skip=0&take=100`)).toEqual([])
|
||||
|
||||
// LIKE metacharacters are escaped: unescaped, `%` would match everything and `_` any
|
||||
// single character, so searching for "100%" would return the whole catalogue.
|
||||
expect(await ids('value=%25&skip=0&take=100')).toEqual([205])
|
||||
expect(await ids('value=cube_thing&skip=0&take=100')).toEqual([205])
|
||||
|
||||
// TAGS ARE NOT SEARCHED. The browse screen's chips send `#small`, and no name or
|
||||
// description contains it, so the term matches nothing — the tag is on 201 and 202, and a
|
||||
// tag search would have returned them. Deliberate for now: matching tags needs them out of
|
||||
// the JSON blob and into something indexable, and doing it in memory would mean reading
|
||||
// every row to answer one page.
|
||||
expect(await ids(`value=${encodeURIComponent('#small')}&skip=0&take=100`)).toEqual([])
|
||||
|
||||
// Paged in SQL: consecutive pages neither repeat nor skip a row. The `id` tiebreak in the
|
||||
// ordering is what guarantees that when two inventions share a `CreatedAt`.
|
||||
const page1 = await ids('value=cube&skip=0&take=2')
|
||||
const page2 = await ids('value=cube&skip=2&take=2')
|
||||
expect(page1).toEqual([205, 202])
|
||||
expect(page2).toEqual([201])
|
||||
expect(page1.some((id) => page2.includes(id))).toBe(false)
|
||||
expect(await ids('value=cube&skip=99&take=10')).toEqual([])
|
||||
|
||||
// Cleaned up: the feeds and the tag-filter chips are derived from EVERY published
|
||||
// invention, so rows left behind here would change what those tests see.
|
||||
await env.DB.prepare('DELETE FROM invention WHERE id >= 201 AND id <= 205').run()
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/tagfilters ranks the tags in use', async () => {
|
||||
// Two published inventions tagged `furniture`, one `bed` — plus a tagged draft,
|
||||
// whose tags must not leak into the public filter chips.
|
||||
@@ -3821,71 +1446,17 @@ describe('public endpoints', () => {
|
||||
expect(noId.status).toBe(400)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v1/cheer persists and personaldetails reflects it', async () => {
|
||||
const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('8200')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Cheerable Lamp', inventionDataFilename: 'cheerable.inv' }),
|
||||
test('GET /api/inventions/v1/personaldetails/:id reports the cheer flag', async () => {
|
||||
// No cheer storage yet, so nobody is ever cheering — signed in or not.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`, {
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
const invention = ((await saved.json()) as InventionSaveResult).Invention
|
||||
const path = `${ORIGIN}/api/inventions/v1/cheer`
|
||||
const cheer = async (value: boolean, sub = '42') =>
|
||||
exports.default.fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: invention.InventionId, Cheer: value }),
|
||||
})
|
||||
const personal = async (sub?: string) =>
|
||||
exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/personaldetails/${invention.InventionId}`,
|
||||
sub ? { headers: await bearer(sub) } : undefined
|
||||
)
|
||||
const storedCount = async (): Promise<number> => {
|
||||
const row = await env.DB.prepare('SELECT data FROM invention WHERE id = ?1')
|
||||
.bind(invention.InventionId)
|
||||
.first<{ data: string }>()
|
||||
return (JSON.parse(row!.data) as SavedInvention).CheerCount
|
||||
}
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ IsCheering: false })
|
||||
|
||||
// The write requires a player; the read remains useful to signed-out callers.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: invention.InventionId, Cheer: true }),
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
expect(await (await personal()).json()).toEqual({ IsCheering: false })
|
||||
|
||||
expect((await cheer(true)).status).toBe(200)
|
||||
expect(await (await personal('42')).json()).toEqual({ IsCheering: true })
|
||||
expect(await storedCount()).toBe(1)
|
||||
|
||||
// Repeating a state is idempotent, and a second player counts separately.
|
||||
await cheer(true)
|
||||
expect(await storedCount()).toBe(1)
|
||||
await cheer(true, '43')
|
||||
expect(await storedCount()).toBe(2)
|
||||
|
||||
await cheer(false)
|
||||
expect(await (await personal('42')).json()).toEqual({ IsCheering: false })
|
||||
expect(await (await personal('43')).json()).toEqual({ IsCheering: true })
|
||||
expect(await storedCount()).toBe(1)
|
||||
|
||||
const unknown = await exports.default.fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: 999999, Cheer: true }),
|
||||
})
|
||||
expect(unknown.status).toBe(404)
|
||||
const malformed = await exports.default.fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: invention.InventionId, Cheer: 'yes' }),
|
||||
})
|
||||
expect(malformed.status).toBe(400)
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`)
|
||||
expect(anon.status).toBe(200)
|
||||
expect(await anon.json()).toEqual({ IsCheering: false })
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
|
||||
@@ -3920,27 +1491,7 @@ describe('public endpoints', () => {
|
||||
InstantiationCost: 42,
|
||||
})
|
||||
|
||||
// `version=0` means "whichever is current" rather than a number to match, and gets the
|
||||
// same version 1 back. Nothing has a version 0 — a fresh save is version 1 — so a caller
|
||||
// sending it does not know which version it wants, and matching it literally would 404 an
|
||||
// invention that exists.
|
||||
const v0 = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=0`
|
||||
)
|
||||
expect(v0.status).toBe(200)
|
||||
expect(await v0.json()).toMatchObject({
|
||||
InventionId: Invention.InventionId,
|
||||
VersionNumber: 1,
|
||||
BlobName: '2026-07-12/lamp.inv',
|
||||
})
|
||||
|
||||
// The 0 shortcut does NOT make up an invention: an unknown id still 404s at 0.
|
||||
const zeroUnknown = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/version?inventionId=999999&version=0`
|
||||
)
|
||||
expect(zeroUnknown.status).toBe(404)
|
||||
|
||||
// Only the current version exists; any other NUMBER still 404s, as does an unknown id.
|
||||
// Only the current version exists; anything else 404s, as does an unknown id.
|
||||
const v2 = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=2`
|
||||
)
|
||||
@@ -4448,1134 +1999,6 @@ describe('auth-gated endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom avatar items', () => {
|
||||
test('minPriceForPublicItem is a bare 100', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/customAvatarItems/v1/minPriceForPublicItem`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(100)
|
||||
})
|
||||
|
||||
test('POST creates an item from the multipart form and returns it', async () => {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
Name: 'custom shirt 1',
|
||||
Description: 'custom shirt 2',
|
||||
Price: 0,
|
||||
BaseAvatarItemId: 2184,
|
||||
BaseAvatarItemColor: '#F55C1A',
|
||||
Accessibility: 0,
|
||||
})
|
||||
)
|
||||
form.set(
|
||||
'thumbnailImage',
|
||||
new File([new Uint8Array([1, 2, 3])], 'file.bin', { type: 'image/png' })
|
||||
)
|
||||
form.set('design', new File([new Uint8Array([4, 5, 6])], 'file.bin', { type: 'image/png' }))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('205'),
|
||||
body: form,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Value: Record<string, unknown>
|
||||
Success: boolean
|
||||
Error: null
|
||||
error_id: null
|
||||
}
|
||||
expect(body.Success).toBe(true)
|
||||
expect(body.Error).toBeNull()
|
||||
expect(body.error_id).toBeNull()
|
||||
expect(body.Value).toMatchObject({
|
||||
CreatorAccountId: 205,
|
||||
Name: 'custom shirt 1',
|
||||
Description: 'custom shirt 2',
|
||||
Price: 0,
|
||||
Accessibility: 0,
|
||||
ForceCannotPublish: false,
|
||||
IsFeatured: false,
|
||||
IsRecRoomApproved: false,
|
||||
BaseAvatarItemId: 2184,
|
||||
BaseAvatarItemColor: '#F55C1A',
|
||||
PreviewOrientation: 0,
|
||||
RankingContext: null,
|
||||
// Filed as a custom shirt, the slot the store's user-generated-content tab searches.
|
||||
OutfitType: 105,
|
||||
// The full shape a first-party item carries, empty: no built saves, no tags.
|
||||
CurrentSaves: [],
|
||||
Tags: [],
|
||||
CustomBadgeMetadata: null,
|
||||
PurchaseInfo: null,
|
||||
})
|
||||
const itemId = body.Value.CustomAvatarItemId as string
|
||||
expect(body.Value.RankedEntityId).toBe(itemId)
|
||||
expect(itemId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
const date = (body.Value.CreatedAt as string).slice(0, 10)
|
||||
expect(body.Value.ThumbnailImageFilename).toBe(`avatar-item/${date}/${itemId}-thumb.png`)
|
||||
expect(body.Value.DesignFilename).toBe(`avatar-item/${date}/${itemId}-design.png`)
|
||||
expect(body.Value.CreatedAt).toBe(body.Value.ModifiedAt)
|
||||
|
||||
// Both uploads landed in the image bucket under those keys.
|
||||
const thumb = await env.IMAGES.get(body.Value.ThumbnailImageFilename as string)
|
||||
expect(new Uint8Array((await thumb!.arrayBuffer()) as ArrayBuffer)).toEqual(
|
||||
new Uint8Array([1, 2, 3])
|
||||
)
|
||||
expect(thumb!.httpMetadata?.contentType).toBe('image/png')
|
||||
const design = await env.IMAGES.get(body.Value.DesignFilename as string)
|
||||
expect(new Uint8Array((await design!.arrayBuffer()) as ArrayBuffer)).toEqual(
|
||||
new Uint8Array([4, 5, 6])
|
||||
)
|
||||
|
||||
// The row is the record as JSON, with the queried fields generated off it.
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT json_extract(data, '$.Name') AS name, creator_account_id
|
||||
FROM custom_avatar_item WHERE custom_avatar_item_id = ?1`
|
||||
)
|
||||
.bind(body.Value.CustomAvatarItemId)
|
||||
.first()
|
||||
expect(row).toEqual({ name: 'custom shirt 1', creator_account_id: 205 })
|
||||
})
|
||||
|
||||
test('PUT edits the creator’s item, leaving nulled fields alone', async () => {
|
||||
const item = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name: 'Visor',
|
||||
description: 'shiny',
|
||||
price: 0,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
accessibility: 0,
|
||||
designFilename: 'd',
|
||||
thumbnailImageFilename: 't',
|
||||
},
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
)
|
||||
const url = `${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}`
|
||||
const res = await exports.default.fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Name: null, Description: null, Price: 100, Accessibility: 1 }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Value: Record<string, unknown>
|
||||
Success: boolean
|
||||
Error: null
|
||||
}
|
||||
expect(body.Success).toBe(true)
|
||||
expect(body.Value).toMatchObject({
|
||||
CustomAvatarItemId: item.CustomAvatarItemId,
|
||||
Name: 'Visor',
|
||||
Description: 'shiny',
|
||||
Price: 100,
|
||||
Accessibility: 1,
|
||||
CreatedAt: '2026-08-01T00:00:00.000Z',
|
||||
})
|
||||
expect(body.Value.ModifiedAt).not.toBe(item.ModifiedAt)
|
||||
|
||||
// Someone else can't edit it; an unknown id 404s; a bad type 400s.
|
||||
const other = await exports.default.fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('9')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Price: 5 }),
|
||||
})
|
||||
expect(other.status).toBe(403)
|
||||
const missing = await exports.default.fetch(
|
||||
`${ORIGIN}/api/customAvatarItems/v1/${crypto.randomUUID()}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Price: 5 }),
|
||||
}
|
||||
)
|
||||
expect(missing.status).toBe(404)
|
||||
const bad = await exports.default.fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Price: 'lots' }),
|
||||
})
|
||||
expect(bad.status).toBe(400)
|
||||
expect(await bad.json()).toMatchObject({ Success: false, Value: null })
|
||||
expect(await (await exports.default.fetch(url, { method: 'PUT' })).status).toBe(401)
|
||||
})
|
||||
|
||||
test('DELETE removes the creator’s item and its bucket objects', async () => {
|
||||
// Create through the endpoint so the objects really exist in the bucket.
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'Gone', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
|
||||
)
|
||||
form.set('thumbnailImage', new File([new Uint8Array([1])], 'file.bin', { type: 'image/png' }))
|
||||
form.set('design', new File([new Uint8Array([2])], 'file.bin', { type: 'image/png' }))
|
||||
const created = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('205'),
|
||||
body: form,
|
||||
})
|
||||
).json()) as {
|
||||
Value: { CustomAvatarItemId: string; ThumbnailImageFilename: string; DesignFilename: string }
|
||||
}
|
||||
const { CustomAvatarItemId, ThumbnailImageFilename, DesignFilename } = created.Value
|
||||
expect(await env.IMAGES.get(ThumbnailImageFilename)).not.toBeNull()
|
||||
const url = `${ORIGIN}/api/customAvatarItems/v1/${CustomAvatarItemId}`
|
||||
|
||||
// Not the creator → 403 and nothing changes.
|
||||
const other = await exports.default.fetch(url, { method: 'DELETE', headers: await bearer('9') })
|
||||
expect(other.status).toBe(403)
|
||||
expect(await env.IMAGES.get(ThumbnailImageFilename)).not.toBeNull()
|
||||
|
||||
const res = await exports.default.fetch(url, { method: 'DELETE', headers: await bearer('205') })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({
|
||||
Success: true,
|
||||
Error: null,
|
||||
Value: { CustomAvatarItemId, Name: 'Gone' },
|
||||
})
|
||||
expect(await env.IMAGES.get(ThumbnailImageFilename)).toBeNull()
|
||||
expect(await env.IMAGES.get(DesignFilename)).toBeNull()
|
||||
expect(
|
||||
await env.DB.prepare('SELECT 1 FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
|
||||
.bind(CustomAvatarItemId)
|
||||
.first()
|
||||
).toBeNull()
|
||||
|
||||
// Gone now → 404; no token → 401.
|
||||
const again = await exports.default.fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: await bearer('205'),
|
||||
})
|
||||
expect(again.status).toBe(404)
|
||||
expect((await exports.default.fetch(url, { method: 'DELETE' })).status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST 400s without the files', async () => {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'x', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
|
||||
)
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(),
|
||||
body: form,
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toMatchObject({ Success: false, Value: null })
|
||||
})
|
||||
|
||||
test('POST rejects either oversized file before writing anything to R2', async () => {
|
||||
const previous = env.RECFLARE_MAX_API_UPLOAD_BYTES
|
||||
env.RECFLARE_MAX_API_UPLOAD_BYTES = '3'
|
||||
try {
|
||||
const objectsBefore = (await env.IMAGES.list({ prefix: 'avatar-item/' })).objects.length
|
||||
const upload = async (thumbnail: Uint8Array, design: Uint8Array) => {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'bounded', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
|
||||
)
|
||||
form.set('thumbnailImage', new File([thumbnail], 'thumb.png', { type: 'image/png' }))
|
||||
form.set('design', new File([design], 'design.png', { type: 'image/png' }))
|
||||
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('205'),
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
|
||||
const oversizedThumbnail = await upload(new Uint8Array(4), new Uint8Array(3))
|
||||
expect(oversizedThumbnail.status).toBe(413)
|
||||
expect(await oversizedThumbnail.json()).toMatchObject({
|
||||
Success: false,
|
||||
Error: 'thumbnailImage exceeds the 3-byte upload limit',
|
||||
})
|
||||
|
||||
const oversizedDesign = await upload(new Uint8Array(3), new Uint8Array(4))
|
||||
expect(oversizedDesign.status).toBe(413)
|
||||
expect(await oversizedDesign.json()).toMatchObject({
|
||||
Success: false,
|
||||
Error: 'design exceeds the 3-byte upload limit',
|
||||
})
|
||||
|
||||
// Neither rejected request may create metadata or leave one of its two objects behind.
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE json_extract(data, '$.Name') = 'bounded'"
|
||||
).first<{ n: number }>()
|
||||
expect(row?.n).toBe(0)
|
||||
expect((await env.IMAGES.list({ prefix: 'avatar-item/' })).objects).toHaveLength(
|
||||
objectsBefore
|
||||
)
|
||||
} finally {
|
||||
env.RECFLARE_MAX_API_UPLOAD_BYTES = previous
|
||||
}
|
||||
})
|
||||
|
||||
test('POST 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('instant kick', () => {
|
||||
// The game session the kick names, and one belonging to the same room that must be
|
||||
// left out of it.
|
||||
const SESSION = 1013781
|
||||
const OTHER_SESSION = 1013782
|
||||
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
|
||||
// Room instances are written by the `match` worker; seeded straight into the table
|
||||
// here, the way the presence rows below are.
|
||||
const seedInstance = async (roomInstanceId: number, maxCapacity = 0, isFull = false) =>
|
||||
env.DB.prepare('INSERT OR REPLACE INTO room_instance (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
roomInstanceId,
|
||||
ownerAccountId: 42,
|
||||
roomId: 4,
|
||||
subRoomId: 4,
|
||||
location: '',
|
||||
dataBlob: '',
|
||||
eventId: 0,
|
||||
photonRegionId: 'us',
|
||||
photonRoomId: `photon-${roomInstanceId}`,
|
||||
name: '',
|
||||
maxCapacity,
|
||||
isFull,
|
||||
isPrivate: false,
|
||||
isInProgress: false,
|
||||
roomCode: '',
|
||||
roomInstanceType: 0,
|
||||
clubId: 0,
|
||||
EncryptVoiceChat: false,
|
||||
matchmakingPolicy: 0,
|
||||
allowNewUsers: true,
|
||||
joinDisabled: false,
|
||||
gameVersion: GAME_VERSION,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
const standIn = async (accountId: number, roomInstanceId: number) =>
|
||||
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId,
|
||||
roomInstance: { roomInstanceId, roomId: 4 },
|
||||
statusVisibility: 0,
|
||||
deviceClass: 0,
|
||||
vrMovementMode: 0,
|
||||
platform: 0,
|
||||
appVersion: GAME_VERSION,
|
||||
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
const isPresent = async (accountId: number) =>
|
||||
(await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
// A kick no longer deletes presence — it moves the player into their own dorm — so
|
||||
// "kicked" is "no longer standing in that session", not "gone".
|
||||
const inSession = async (accountId: number, roomInstanceId: number) =>
|
||||
(await env.DB.prepare(
|
||||
`SELECT COUNT(*) AS n FROM presence
|
||||
WHERE account_id = ?1 AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
|
||||
)
|
||||
.bind(accountId, roomInstanceId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
/** The instance a player's presence says they are standing in. */
|
||||
const presenceInstance = async (accountId: number) =>
|
||||
JSON.parse(
|
||||
(await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first<{ data: string }>())!.data
|
||||
).roomInstance as { roomInstanceId: number; roomInstanceType: number; isPrivate: boolean }
|
||||
|
||||
const isFull = async (roomInstanceId: number) =>
|
||||
(await env.DB.prepare('SELECT is_full AS full FROM room_instance WHERE id = ?1')
|
||||
.bind(roomInstanceId)
|
||||
.first<{ full: number }>())!.full === 1
|
||||
|
||||
const kick = async (body: unknown, sub = '42') =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const frames = async () =>
|
||||
(await (await hub().fetch('http://do/all')).json()) as Array<{
|
||||
playerIds?: number[]
|
||||
ephemeral?: boolean
|
||||
notificationType: number
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
|
||||
test('the room’s creator kicks a player out of the session they name', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
// A full two-player instance: 205 is kicked, 206 stays.
|
||||
await seedInstance(SESSION, 2, true)
|
||||
await standIn(205, SESSION)
|
||||
await standIn(206, SESSION)
|
||||
|
||||
const res = await kick({ GameSessionId: SESSION, PlayerIds: [205] })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
// Out of the session — and into their own DORM, rather than having their presence
|
||||
// deleted: with nothing to read the client lands not knowing where it is and loads the
|
||||
// dorm a second time. 206 is untouched.
|
||||
expect(await inSession(205, SESSION)).toBe(false)
|
||||
expect(await isPresent(205)).toBe(true)
|
||||
const dorm = await presenceInstance(205)
|
||||
// The OFFLINE dorm: the sentinel instance id, not a `room_instance` row. A kicked player
|
||||
// is not joining a session, and minting one per kick would put live dorm sessions on the
|
||||
// books that nothing ever joins.
|
||||
expect(dorm.roomInstanceId).toBe(-2)
|
||||
expect(dorm.roomInstanceType).toBe(2) // RoomInstanceType.Dormroom
|
||||
expect(dorm.isPrivate).toBe(true)
|
||||
expect(await inSession(206, SESSION)).toBe(true)
|
||||
// The instance lost a player, so it is no longer full.
|
||||
expect(await isFull(SESSION)).toBe(false)
|
||||
|
||||
// One EPHEMERAL ModerationKick, addressed to the kicked player only. `IsBan` is
|
||||
// false — this ejects them from the session and nothing more.
|
||||
expect(await frames()).toEqual([
|
||||
{
|
||||
playerIds: [205],
|
||||
ephemeral: true,
|
||||
notificationType: 22, // NotificationType.ModerationKick
|
||||
data: {
|
||||
ReportCategory: -1, // KickReportCategory.Moderator
|
||||
Duration: 0,
|
||||
GameSessionId: SESSION,
|
||||
IsHostKick: true,
|
||||
Message: 'You have been kicked from KickRoom.',
|
||||
PlayerIdReporter: 42,
|
||||
IsBan: false,
|
||||
IsVoiceModAutoban: false,
|
||||
IsWarning: false,
|
||||
VoteKickReason: '',
|
||||
TimeoutStartedAt: null,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
// The gate that stops a creator kicking a stranger out of somebody else's session by
|
||||
// naming their account id.
|
||||
test('a player who is not in that session is skipped in silence', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await seedInstance(SESSION)
|
||||
await seedInstance(OTHER_SESSION)
|
||||
await standIn(207, OTHER_SESSION)
|
||||
|
||||
// 207 stands in another instance; 208 is offline entirely.
|
||||
const res = await kick({ GameSessionId: SESSION, PlayerIds: [207, 208] })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
expect(await isPresent(207)).toBe(true)
|
||||
expect(await frames()).toEqual([])
|
||||
})
|
||||
|
||||
test('a room moderator may kick; a host, a stranger and no token may not', async () => {
|
||||
await seedInstance(SESSION)
|
||||
await standIn(209, SESSION)
|
||||
|
||||
// 43 holds Moderator (20) on the room.
|
||||
expect((await kick({ GameSessionId: SESSION, PlayerIds: [209] }, '43')).status).toBe(200)
|
||||
expect(await inSession(209, SESSION)).toBe(false)
|
||||
|
||||
await standIn(209, SESSION)
|
||||
// 44 is only a Host (10), and 99 holds nothing at all.
|
||||
for (const sub of ['44', '99']) {
|
||||
const res = await kick({ GameSessionId: SESSION, PlayerIds: [209] }, sub)
|
||||
expect(res.status, sub).toBe(403)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
expect(await inSession(209, SESSION)).toBe(true)
|
||||
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ GameSessionId: SESSION, PlayerIds: [209] }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
|
||||
// Otherwise a moderator could throw the room's own creator out of it.
|
||||
test('the room’s staff — and the caller — cannot be kicked', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await seedInstance(SESSION)
|
||||
await standIn(42, SESSION)
|
||||
await standIn(43, SESSION)
|
||||
|
||||
// 43 (a moderator) names the creator, a fellow moderator and themselves.
|
||||
const res = await kick({ GameSessionId: SESSION, PlayerIds: [42, 43] }, '43')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await inSession(42, SESSION)).toBe(true)
|
||||
expect(await inSession(43, SESSION)).toBe(true)
|
||||
expect(await frames()).toEqual([])
|
||||
})
|
||||
|
||||
test('an unknown session 404s, and the body must name a session and players', async () => {
|
||||
await seedInstance(SESSION)
|
||||
const unknown = await kick({ GameSessionId: 999999, PlayerIds: [205] })
|
||||
expect(unknown.status).toBe(404)
|
||||
expect(await unknown.json()).toEqual({
|
||||
success: false,
|
||||
error: 'This game session does not exist!',
|
||||
})
|
||||
|
||||
for (const [body, error] of [
|
||||
[{ PlayerIds: [205] }, 'GameSessionId is required'],
|
||||
[{ GameSessionId: 'nope', PlayerIds: [205] }, 'GameSessionId is required'],
|
||||
[{ GameSessionId: SESSION }, 'PlayerIds is required'],
|
||||
[{ GameSessionId: SESSION, PlayerIds: [] }, 'PlayerIds is required'],
|
||||
[{ GameSessionId: SESSION, PlayerIds: ['205'] }, 'PlayerIds is required'],
|
||||
] as Array<[unknown, string]>) {
|
||||
const res = await kick(body)
|
||||
expect(res.status, error).toBe(400)
|
||||
expect(await res.json()).toEqual({ success: false, error })
|
||||
}
|
||||
|
||||
// A body that isn't JSON at all is the same shape, not a crash.
|
||||
const broken = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
|
||||
body: 'not json',
|
||||
})
|
||||
expect(broken.status).toBe(400)
|
||||
expect(await broken.json()).toEqual({ success: false, error: 'Invalid request body' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('room mod kick', () => {
|
||||
// A session of KickRoom (room 4, owned by 42, co-owned by 45), a second session of the
|
||||
// same room, and a session of RecCenter (room 2, owned by 1) — somebody else's room.
|
||||
const SESSION = 1074859
|
||||
const SAME_ROOM_SESSION = 1074860
|
||||
const OTHER_ROOM_SESSION = 1074861
|
||||
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
|
||||
const seedInstance = async (
|
||||
roomInstanceId: number,
|
||||
roomId: number,
|
||||
maxCapacity = 0,
|
||||
isFull = false
|
||||
) =>
|
||||
env.DB.prepare('INSERT OR REPLACE INTO room_instance (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
roomInstanceId,
|
||||
ownerAccountId: 42,
|
||||
roomId,
|
||||
subRoomId: roomId,
|
||||
maxCapacity,
|
||||
isFull,
|
||||
gameVersion: GAME_VERSION,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
const standIn = async (accountId: number, roomInstanceId: number, roomId = 4) =>
|
||||
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId,
|
||||
roomInstance: { roomInstanceId, roomId },
|
||||
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
// A kick no longer deletes presence — it moves the player into their own dorm — so
|
||||
// "kicked" is "no longer standing in that session", not "gone".
|
||||
const inSession = async (accountId: number, roomInstanceId: number) =>
|
||||
(await env.DB.prepare(
|
||||
`SELECT COUNT(*) AS n FROM presence
|
||||
WHERE account_id = ?1 AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
|
||||
)
|
||||
.bind(accountId, roomInstanceId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
/** The instance a player's presence says they are standing in. */
|
||||
const presenceInstance = async (accountId: number) =>
|
||||
JSON.parse(
|
||||
(await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first<{ data: string }>())!.data
|
||||
).roomInstance as { roomInstanceId: number; roomInstanceType: number; isPrivate: boolean }
|
||||
|
||||
// The client's form body.
|
||||
const modKick = async (fields: Record<string, string>, sub = '42', roles?: string[]) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub, roles)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
|
||||
const frames = async () =>
|
||||
(await (await hub().fetch('http://do/all')).json()) as Array<{
|
||||
playerIds?: number[]
|
||||
ephemeral?: boolean
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
|
||||
test('the room’s owner kicks a player out of the session they are in, with a reason', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
// A full one-seat instance that 205 is filling.
|
||||
await seedInstance(SESSION, 4, 1, true)
|
||||
await standIn(205, SESSION)
|
||||
|
||||
// The client's body, verbatim.
|
||||
const res = await modKick({ PlayerId: '205', GameSessionId: String(SESSION), Reason: 'test' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
// Out of the session and into their own dorm — not deleted.
|
||||
expect(await inSession(205, SESSION)).toBe(false)
|
||||
const kickedTo = await presenceInstance(205)
|
||||
expect(kickedTo.roomInstanceId).toBe(-2) // the offline dorm sentinel
|
||||
expect(kickedTo.roomInstanceType).toBe(2) // Dormroom
|
||||
const full = await env.DB.prepare('SELECT is_full AS f FROM room_instance WHERE id = ?1')
|
||||
.bind(SESSION)
|
||||
.first<{ f: number }>()
|
||||
expect(full!.f).toBe(0)
|
||||
|
||||
expect(await frames()).toEqual([
|
||||
expect.objectContaining({
|
||||
playerIds: [205],
|
||||
ephemeral: true,
|
||||
data: expect.objectContaining({
|
||||
GameSessionId: SESSION,
|
||||
IsHostKick: true,
|
||||
IsBan: false,
|
||||
PlayerIdReporter: 42,
|
||||
Message: 'You have been kicked from KickRoom. Reason: test',
|
||||
}),
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
// The whole reason for the presence check: authority over one room must never reach a
|
||||
// session somewhere else.
|
||||
test('a player who is not standing in that exact session is not kicked', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await seedInstance(SESSION, 4)
|
||||
await seedInstance(SAME_ROOM_SESSION, 4)
|
||||
await seedInstance(OTHER_ROOM_SESSION, 2)
|
||||
|
||||
// In another instance of the SAME room, in a different room entirely, and offline.
|
||||
await standIn(206, SAME_ROOM_SESSION)
|
||||
await standIn(207, OTHER_ROOM_SESSION, 2)
|
||||
for (const playerId of ['206', '207', '208']) {
|
||||
const res = await modKick({ PlayerId: playerId, GameSessionId: String(SESSION) }, '42')
|
||||
expect(res.status, playerId).toBe(200)
|
||||
expect(await res.json(), playerId).toEqual({
|
||||
success: false,
|
||||
error: 'That player is not in this game session!',
|
||||
})
|
||||
}
|
||||
expect(await inSession(206, SAME_ROOM_SESSION)).toBe(true)
|
||||
expect(await inSession(207, OTHER_ROOM_SESSION)).toBe(true)
|
||||
|
||||
// Naming the OTHER room's session instead doesn't help: 42 has no authority there.
|
||||
const cross = await modKick(
|
||||
{ PlayerId: '207', GameSessionId: String(OTHER_ROOM_SESSION) },
|
||||
'42'
|
||||
)
|
||||
expect(cross.status).toBe(403)
|
||||
expect(await inSession(207, OTHER_ROOM_SESSION)).toBe(true)
|
||||
expect(await frames()).toEqual([])
|
||||
})
|
||||
|
||||
test('a co-owner or a staff role may kick; a room moderator, a host and a stranger may not', async () => {
|
||||
await seedInstance(SESSION, 4)
|
||||
|
||||
// 45 co-owns the room.
|
||||
await standIn(209, SESSION)
|
||||
expect(
|
||||
await (await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, '45')).json()
|
||||
).toEqual({ success: true, error: '' })
|
||||
expect(await inSession(209, SESSION)).toBe(false)
|
||||
|
||||
// 999 has no role on the room but carries the staff `moderator` role — and is not the
|
||||
// host, so the frame says so.
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await standIn(209, SESSION)
|
||||
const staff = await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, '999', [
|
||||
'gameClient',
|
||||
'moderator',
|
||||
])
|
||||
expect(await staff.json()).toEqual({ success: true, error: '' })
|
||||
expect(await inSession(209, SESSION)).toBe(false)
|
||||
expect((await frames())[0].data).toMatchObject({ IsHostKick: false, PlayerIdReporter: 999 })
|
||||
|
||||
// 43 is the room's Moderator (20) — enough for instantKick, not for this. 44 is a Host,
|
||||
// 99 holds nothing.
|
||||
await standIn(209, SESSION)
|
||||
for (const sub of ['43', '44', '99']) {
|
||||
const res = await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, sub)
|
||||
expect(res.status, sub).toBe(403)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
expect(await inSession(209, SESSION)).toBe(true)
|
||||
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `PlayerId=209&GameSessionId=${SESSION}`,
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
|
||||
test('nobody kicks themselves or an owner of the room', async () => {
|
||||
await seedInstance(SESSION, 4)
|
||||
await standIn(42, SESSION)
|
||||
await standIn(45, SESSION)
|
||||
|
||||
expect(
|
||||
await (await modKick({ PlayerId: '42', GameSessionId: String(SESSION) }, '42')).json()
|
||||
).toEqual({ success: false, error: 'You cannot kick yourself!' })
|
||||
// A co-owner cannot throw out the owner, and staff cannot throw out a co-owner.
|
||||
expect(
|
||||
await (await modKick({ PlayerId: '42', GameSessionId: String(SESSION) }, '45')).json()
|
||||
).toEqual({ success: false, error: 'You cannot kick an owner of this room!' })
|
||||
expect(
|
||||
await (
|
||||
await modKick({ PlayerId: '45', GameSessionId: String(SESSION) }, '999', ['moderator'])
|
||||
).json()
|
||||
).toEqual({ success: false, error: 'You cannot kick an owner of this room!' })
|
||||
expect(await inSession(42, SESSION)).toBe(true)
|
||||
expect(await inSession(45, SESSION)).toBe(true)
|
||||
})
|
||||
|
||||
test('an unknown session 404s, and the body must name a session and a player', async () => {
|
||||
const unknown = await modKick({ PlayerId: '205', GameSessionId: '999999' })
|
||||
expect(unknown.status).toBe(404)
|
||||
expect(await unknown.json()).toEqual({
|
||||
success: false,
|
||||
error: 'This game session does not exist!',
|
||||
})
|
||||
|
||||
for (const [fields, error] of [
|
||||
[{ PlayerId: '205' }, 'GameSessionId is required'],
|
||||
[{ PlayerId: '205', GameSessionId: 'nope' }, 'GameSessionId is required'],
|
||||
[{ GameSessionId: String(SESSION) }, 'PlayerId is required'],
|
||||
] as Array<[Record<string, string>, string]>) {
|
||||
const res = await modKick(fields)
|
||||
expect(res.status, error).toBe(400)
|
||||
expect(await res.json()).toEqual({ success: false, error })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('vote to kick', () => {
|
||||
// Two live sessions, so a vote called in one can be checked against a player in the
|
||||
// other. The gate is presence alone; `room_instance` is read only to name the room in a
|
||||
// carried vote's kick frame.
|
||||
const SESSION = 1014079
|
||||
const OTHER_SESSION = 1014080
|
||||
// The tally tests get sessions of their own: ballots are appended and never pruned, so
|
||||
// votes cast by the tests above would otherwise be counted into theirs.
|
||||
const TALLY_SESSION = 1014081
|
||||
const REVOTE_SESSION = 1014082
|
||||
const TIE_SESSION = 1014083
|
||||
|
||||
// Every test calls votes as 42, and calling is throttled per caller — with the ballots of
|
||||
// the test before still there, each call after the first would be refused with a 429, or
|
||||
// land as an answer to a vote still open from earlier.
|
||||
beforeEach(async () => {
|
||||
await env.DB.prepare('DELETE FROM room_vote').run()
|
||||
})
|
||||
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
|
||||
const standIn = async (accountId: number, roomInstanceId: number) =>
|
||||
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId,
|
||||
roomInstance: { roomInstanceId, roomId: 4 },
|
||||
statusVisibility: 0,
|
||||
deviceClass: 0,
|
||||
vrMovementMode: 0,
|
||||
platform: 0,
|
||||
appVersion: GAME_VERSION,
|
||||
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
const isPresent = async (accountId: number) =>
|
||||
(await env.DB.prepare(
|
||||
"SELECT COUNT(*) AS n FROM presence WHERE json_extract(data, '$.accountId') = ?1"
|
||||
)
|
||||
.bind(accountId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
// A carried vote moves the player into their own dorm rather than deleting their presence,
|
||||
// so "kicked" is "no longer standing in that session".
|
||||
const inSession = async (accountId: number, roomInstanceId: number) =>
|
||||
(await env.DB.prepare(
|
||||
`SELECT COUNT(*) AS n FROM presence
|
||||
WHERE json_extract(data, '$.accountId') = ?1
|
||||
AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
|
||||
)
|
||||
.bind(accountId, roomInstanceId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
/** Every ballot cast against one player in one session, oldest first. */
|
||||
const ballots = async (gameSessionId: number, playerId: number) =>
|
||||
(
|
||||
await env.DB.prepare(
|
||||
'SELECT * FROM room_vote WHERE game_session_id = ?1 AND player_id = ?2 ORDER BY id'
|
||||
)
|
||||
.bind(gameSessionId, playerId)
|
||||
.all<{
|
||||
voter_id: number
|
||||
response: number
|
||||
player_id: number
|
||||
voted_at: string
|
||||
init: number
|
||||
}>()
|
||||
).results
|
||||
|
||||
// The body the client posts: `PlayerId=205&Response=True&Reason=…&GameSessionId=…`.
|
||||
const vote = async (fields: Record<string, string>, sub = '42') =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
|
||||
const frames = async () =>
|
||||
(await (await hub().fetch('http://do/all')).json()) as Array<{
|
||||
playerId?: number
|
||||
ephemeral?: boolean
|
||||
notificationType: number
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
|
||||
const FIELDS = {
|
||||
PlayerId: '205',
|
||||
Response: 'True',
|
||||
Reason: 'Inactive in games (AFK)',
|
||||
GameSessionId: String(SESSION),
|
||||
}
|
||||
|
||||
test('the vote goes to everyone in the session except the caller', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
// 42 calls the vote, 205 is voted on, 206 is a bystander; 207 stands elsewhere.
|
||||
await standIn(42, SESSION)
|
||||
await standIn(205, SESSION)
|
||||
await standIn(206, SESSION)
|
||||
await standIn(207, OTHER_SESSION)
|
||||
|
||||
const res = await vote(FIELDS)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
// One frame each for 205 and 206 — the player voted on gets it too (the vote is
|
||||
// called in front of them), the caller does not, and 207 is in another session.
|
||||
//
|
||||
// `FromPlayerId` is the player being VOTED ON (205), NOT the caller (42): the client
|
||||
// raises its prompt about whoever that names, so the caller there asks the room to kick
|
||||
// the wrong player. `Data` is the REASON as plain text — it is what the prompt shows,
|
||||
// and it used to be a JSON blob of the ids, which players saw printed on the prompt. It
|
||||
// is still a string on the wire: an object fails the client's decoder
|
||||
// (`expected:'String Begin Token', actual:'{'`) and takes the whole notification with it.
|
||||
const message = {
|
||||
ephemeral: true,
|
||||
notificationType: 2, // NotificationType.MessageReceived
|
||||
data: {
|
||||
FromPlayerId: 205,
|
||||
Type: MessageType.VoteToKick,
|
||||
Data: 'Inactive in games (AFK)',
|
||||
},
|
||||
}
|
||||
const sent = await frames()
|
||||
expect(sent).toHaveLength(2)
|
||||
expect(sent).toContainEqual({
|
||||
...message,
|
||||
playerId: 205,
|
||||
data: { ...message.data, ToPlayerId: 205 },
|
||||
})
|
||||
expect(sent).toContainEqual({
|
||||
...message,
|
||||
playerId: 206,
|
||||
data: { ...message.data, ToPlayerId: 206 },
|
||||
})
|
||||
})
|
||||
|
||||
test('a vote with no reason still goes out, with an empty Data', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await standIn(42, SESSION)
|
||||
await standIn(205, SESSION)
|
||||
await standIn(206, SESSION)
|
||||
|
||||
const { Reason: _reason, ...noReason } = FIELDS
|
||||
expect((await vote(noReason)).status).toBe(200)
|
||||
const sent = await frames()
|
||||
expect(sent).toHaveLength(2)
|
||||
expect(sent.every((f) => f.data.Data === '')).toBe(true)
|
||||
// Still about the player voted on, reason or no reason.
|
||||
expect(sent.every((f) => f.data.FromPlayerId === 205)).toBe(true)
|
||||
})
|
||||
|
||||
// A vote is recorded whether or not it carries — the row is the record of who asked.
|
||||
test('the caller’s ballot is recorded', async () => {
|
||||
await standIn(42, SESSION)
|
||||
await standIn(205, SESSION)
|
||||
await standIn(206, SESSION)
|
||||
const before = await ballots(SESSION, 205)
|
||||
|
||||
expect((await vote(FIELDS)).status).toBe(200)
|
||||
const after = await ballots(SESSION, 205)
|
||||
expect(after).toHaveLength(before.length + 1)
|
||||
expect(after.at(-1)).toMatchObject({ voter_id: 42, response: 1, player_id: 205 })
|
||||
expect(typeof after.at(-1)!.voted_at).toBe('string')
|
||||
|
||||
// `Response=False` is recorded as a no rather than dropped.
|
||||
expect((await vote({ ...FIELDS, Response: 'False' }, '206')).status).toBe(200)
|
||||
expect((await ballots(SESSION, 205)).at(-1)).toMatchObject({ voter_id: 206, response: 0 })
|
||||
})
|
||||
|
||||
// The point of the tally: enough of the room says yes and the player goes, without any
|
||||
// moderator being involved.
|
||||
test('a majority of the room carries the vote and kicks the player', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
// Three in the session: 42 calls it on 210, 211 is the other voter.
|
||||
for (const id of [42, 210, 211]) await standIn(id, TALLY_SESSION)
|
||||
const fields = { ...FIELDS, PlayerId: '210', GameSessionId: String(TALLY_SESSION) }
|
||||
|
||||
// One yes of three is not a majority — the room is asked instead.
|
||||
expect((await vote(fields)).status).toBe(200)
|
||||
expect(await inSession(210, TALLY_SESSION)).toBe(true)
|
||||
expect((await frames()).every((f) => f.notificationType === 2)).toBe(true)
|
||||
|
||||
// The second yes carries it: 2 of 3 is strictly more than half.
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
expect((await vote(fields, '211')).status).toBe(200)
|
||||
|
||||
// Kicked exactly as a staff kick does it — out of the session and into their own dorm,
|
||||
// their presence rewritten rather than deleted...
|
||||
expect(await inSession(210, TALLY_SESSION)).toBe(false)
|
||||
expect(await isPresent(210)).toBe(true)
|
||||
expect(await inSession(210, -2)).toBe(true) // the offline dorm sentinel
|
||||
// ...and ONE ephemeral ModerationKick, no vote prompt: the question is answered.
|
||||
const sent = await frames()
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]).toMatchObject({
|
||||
playerIds: [210],
|
||||
ephemeral: true,
|
||||
notificationType: 22, // NotificationType.ModerationKick
|
||||
data: {
|
||||
ReportCategory: 10, // KickReportCategory.VoteKick — the ROOM removed them
|
||||
IsHostKick: false,
|
||||
IsBan: false, // nothing stops them coming back
|
||||
Duration: 0,
|
||||
GameSessionId: TALLY_SESSION,
|
||||
PlayerIdReporter: 211, // whose ballot carried it
|
||||
VoteKickReason: 'Inactive in games (AFK)',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Otherwise one player could carry a vote alone by posting it repeatedly.
|
||||
test('re-posting a vote cannot carry it, and a voter may change their mind', async () => {
|
||||
for (const id of [42, 212, 213]) await standIn(id, REVOTE_SESSION)
|
||||
const fields = { ...FIELDS, PlayerId: '212', GameSessionId: String(REVOTE_SESSION) }
|
||||
|
||||
// Three yes votes from one voter is still one voter.
|
||||
for (let i = 0; i < 3; i++) expect((await vote(fields)).status).toBe(200)
|
||||
expect(await ballots(REVOTE_SESSION, 212)).toHaveLength(3)
|
||||
expect(await inSession(212, REVOTE_SESSION)).toBe(true)
|
||||
|
||||
// 213 says yes — 2 of 3 — and it carries.
|
||||
expect((await vote(fields, '213')).status).toBe(200)
|
||||
expect(await inSession(212, REVOTE_SESSION)).toBe(false)
|
||||
})
|
||||
|
||||
test('a tie is not a majority', async () => {
|
||||
// Four in the session, so two yes votes are exactly half.
|
||||
for (const id of [42, 214, 215, 216]) await standIn(id, TIE_SESSION)
|
||||
const fields = { ...FIELDS, PlayerId: '214', GameSessionId: String(TIE_SESSION) }
|
||||
|
||||
expect((await vote(fields)).status).toBe(200)
|
||||
expect((await vote(fields, '215')).status).toBe(200)
|
||||
expect(await inSession(214, TIE_SESSION)).toBe(true)
|
||||
|
||||
// The third yes is more than half, and carries.
|
||||
expect((await vote(fields, '216')).status).toBe(200)
|
||||
expect(await inSession(214, TIE_SESSION)).toBe(false)
|
||||
})
|
||||
|
||||
test('both players have to be standing in the session', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await standIn(42, OTHER_SESSION)
|
||||
await standIn(205, SESSION)
|
||||
|
||||
// The caller is somewhere else — a vote can't be called into a session you're not in.
|
||||
const away = await vote(FIELDS)
|
||||
expect(away.status).toBe(403)
|
||||
expect(await away.json()).toEqual({
|
||||
success: false,
|
||||
error: 'You are not in that game session!',
|
||||
})
|
||||
|
||||
// And with the caller present, the player voted on has to be there too — offline,
|
||||
// or standing elsewhere, both refuse.
|
||||
await standIn(42, SESSION)
|
||||
await standIn(205, OTHER_SESSION)
|
||||
const elsewhere = await vote(FIELDS)
|
||||
expect(elsewhere.status).toBe(403)
|
||||
expect(await elsewhere.json()).toEqual({
|
||||
success: false,
|
||||
error: 'That player is not in that game session!',
|
||||
})
|
||||
expect((await vote({ ...FIELDS, PlayerId: '208' })).status).toBe(403)
|
||||
|
||||
// Nothing was put to the room on any of those.
|
||||
expect(await frames()).toEqual([])
|
||||
})
|
||||
|
||||
test('the body must name a player and a session, and the call needs a token', async () => {
|
||||
await standIn(42, SESSION)
|
||||
await standIn(205, SESSION)
|
||||
|
||||
for (const [fields, error] of [
|
||||
[{ Response: 'True', GameSessionId: String(SESSION) }, 'PlayerId is required'],
|
||||
[{ PlayerId: 'nope', GameSessionId: String(SESSION) }, 'PlayerId is required'],
|
||||
[{ PlayerId: '205', Response: 'True' }, 'GameSessionId is required'],
|
||||
[{ PlayerId: '205', GameSessionId: 'nope' }, 'GameSessionId is required'],
|
||||
] as Array<[Record<string, string>, string]>) {
|
||||
const res = await vote(fields)
|
||||
expect(res.status, error).toBe(400)
|
||||
expect(await res.json()).toEqual({ success: false, error })
|
||||
}
|
||||
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(FIELDS),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
|
||||
// A vote called with nobody else there is a no-op rather than an error: the caller and
|
||||
// the player voted on are both here, so the gate passes, and there is simply no room
|
||||
// to put it to.
|
||||
test('a session holding only the two of them sends nothing', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await standIn(42, SESSION)
|
||||
await standIn(205, SESSION)
|
||||
await env.DB.prepare('DELETE FROM presence WHERE account_id NOT IN (42, 205)').run()
|
||||
|
||||
const res = await vote(FIELDS)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
// 205 is still in the session, so they still hear it — only the caller is dropped.
|
||||
expect((await frames()).map((f) => f.playerId)).toEqual([205])
|
||||
})
|
||||
|
||||
// The client posts the call and every answer alike; only the call raises the prompt.
|
||||
test('the first ballot calls the vote, and answers to it put nothing to the room', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
for (const id of [42, 205, 206, 207, 208]) await standIn(id, SESSION)
|
||||
|
||||
expect((await vote(FIELDS)).status).toBe(200)
|
||||
expect(await frames()).toHaveLength(4)
|
||||
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
expect((await vote({ ...FIELDS, Response: 'False' }, '206')).status).toBe(200)
|
||||
expect((await vote(FIELDS, '207')).status).toBe(200)
|
||||
expect(await frames()).toEqual([])
|
||||
|
||||
expect((await ballots(SESSION, 205)).map((b) => [b.voter_id, b.init])).toEqual([
|
||||
[42, 1],
|
||||
[206, 0],
|
||||
[207, 0],
|
||||
])
|
||||
})
|
||||
|
||||
test('a player may not call vote after vote', async () => {
|
||||
for (const id of [42, 205, 206, 207, 208]) await standIn(id, SESSION)
|
||||
|
||||
expect((await vote(FIELDS)).status).toBe(200)
|
||||
// Another vote, on another player — still a CALL, so still refused.
|
||||
const again = await vote({ ...FIELDS, PlayerId: '206' })
|
||||
expect(again.status).toBe(429)
|
||||
expect(await again.json()).toEqual({
|
||||
success: false,
|
||||
error: 'You are calling votes too quickly!',
|
||||
})
|
||||
// Answering someone else's vote is never throttled...
|
||||
expect((await vote({ ...FIELDS, PlayerId: '206' }, '207')).status).toBe(200)
|
||||
expect((await vote({ ...FIELDS, PlayerId: '206' })).status).toBe(200)
|
||||
// ...and neither is re-voting in their own.
|
||||
expect((await vote(FIELDS)).status).toBe(200)
|
||||
|
||||
// Once the cooldown has passed, they may call again.
|
||||
await env.DB.prepare(
|
||||
"UPDATE room_vote SET voted_at = '2000-01-01T00:00:00.000Z' WHERE voter_id = 42 AND init = 1"
|
||||
).run()
|
||||
expect((await vote({ ...FIELDS, PlayerId: '208' })).status).toBe(200)
|
||||
})
|
||||
|
||||
test('a "no" cannot call a vote', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await standIn(42, SESSION)
|
||||
await standIn(205, SESSION)
|
||||
await standIn(206, SESSION)
|
||||
|
||||
const res = await vote({ ...FIELDS, Response: 'False' })
|
||||
expect(res.status).toBe(409)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'There is no vote in progress!' })
|
||||
expect(await ballots(SESSION, 205)).toEqual([])
|
||||
expect(await frames()).toEqual([])
|
||||
})
|
||||
|
||||
// Otherwise yes ballots from a vote that failed would carry the next one on the same player.
|
||||
test('an expired vote’s ballots do not count toward the next', async () => {
|
||||
for (const id of [42, 217, 218, 219]) await standIn(id, TALLY_SESSION)
|
||||
const fields = { ...FIELDS, PlayerId: '217', GameSessionId: String(TALLY_SESSION) }
|
||||
|
||||
// 42 calls it: one yes of four. Then the vote expires.
|
||||
expect((await vote(fields)).status).toBe(200)
|
||||
await env.DB.prepare("UPDATE room_vote SET voted_at = '2000-01-01T00:00:00.000Z'").run()
|
||||
|
||||
// 218 calls a fresh one — counting 42's old yes, that would be two of four... and 219's
|
||||
// yes would make three and carry it. Without it, 219 makes two of four: a tie.
|
||||
expect((await vote(fields, '218')).status).toBe(200)
|
||||
expect((await vote(fields, '219')).status).toBe(200)
|
||||
expect(await inSession(217, TALLY_SESSION)).toBe(true)
|
||||
expect((await ballots(TALLY_SESSION, 217)).map((b) => b.init)).toEqual([1, 1, 0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('player reports', () => {
|
||||
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
|
||||
@@ -5734,398 +2157,6 @@ describe('player reports', () => {
|
||||
test('banFromReport returns null for an unknown report', async () => {
|
||||
expect(await banFromReport(env.DB, 999_999)).toBeNull()
|
||||
})
|
||||
|
||||
// The audit trail 0020 added: who handed the ban down, and when. `banned_at` is not
|
||||
// bookkeeping — it is what the client's block screen counts the ban from (see
|
||||
// `banBlockDetails`), which is why a lift has to clear it along with the flag.
|
||||
test('banFromReport records the acting moderator and the instant it landed', async () => {
|
||||
await submit({ PlayerIdReported: '216' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 216)
|
||||
// An unactioned report carries neither.
|
||||
expect(row).toMatchObject({ banned_by_player_id: null, banned_at: null })
|
||||
|
||||
const before = Date.now()
|
||||
const banned = await banFromReport(env.DB, row!.id, { bannedBy: 9001 })
|
||||
expect(banned!.banned_by_player_id).toBe(9001)
|
||||
expect(Date.parse(banned!.banned_at!)).toBeGreaterThanOrEqual(before)
|
||||
// Stamped by the write, so it is the ban's own time rather than the report's.
|
||||
expect(banned!.banned_at).not.toBe(row!.created_at)
|
||||
|
||||
// A lift clears all four together. Leaving `banned_at` behind on an unbanned row
|
||||
// would read as a ban to anything checking the timestamp.
|
||||
const lifted = await banFromReport(env.DB, row!.id, { banned: false })
|
||||
expect(lifted).toMatchObject({
|
||||
banned: 0,
|
||||
ban_expires: null,
|
||||
banned_by_player_id: null,
|
||||
banned_at: null,
|
||||
})
|
||||
})
|
||||
|
||||
// The moderation READS — searchReports / getTopReported / getBansInForce. No endpoint
|
||||
// on this worker calls them: they back the staff panel `www` serves (`/api/staff/*`),
|
||||
// which imports them, because the panel is a recflare addition with no counterpart in
|
||||
// the real service while the SQL belongs with the table. Tested here, where the table
|
||||
// and its migrations live.
|
||||
describe('moderation reads', () => {
|
||||
test('searchReports filters, orders newest-first and counts the full match', async () => {
|
||||
const target = 3300
|
||||
await createReport(env.DB, {
|
||||
reporterPlayerId: 3301,
|
||||
reportedPlayerId: target,
|
||||
reportCategory: 101,
|
||||
details: 'one',
|
||||
})
|
||||
await createReport(env.DB, {
|
||||
reporterPlayerId: 3302,
|
||||
reportedPlayerId: target,
|
||||
reportCategory: 102,
|
||||
details: 'two',
|
||||
})
|
||||
await createReport(env.DB, { reporterPlayerId: 3301, reportedPlayerId: 3399 })
|
||||
|
||||
const byPlayer = await searchReports(env.DB, { reportedPlayerId: target })
|
||||
expect(byPlayer.total).toBe(2)
|
||||
// `ORDER BY id DESC` — the id is AUTOINCREMENT, so it is already chronological
|
||||
// and is the primary key, where `created_at` is unindexed.
|
||||
expect(byPlayer.reports.map((r) => r.details)).toEqual(['two', 'one'])
|
||||
|
||||
expect((await searchReports(env.DB, { reporterPlayerId: 3302 })).total).toBe(1)
|
||||
expect(
|
||||
(await searchReports(env.DB, { reportedPlayerId: target, reportCategory: 101 })).total
|
||||
).toBe(1)
|
||||
|
||||
// `take` pages the ROWS; `total` stays the unpaged count of the same predicate, so
|
||||
// a pager can say "3–4 of 9" without asking twice.
|
||||
const page = await searchReports(env.DB, { reportedPlayerId: target }, { take: 1 })
|
||||
expect(page.reports).toHaveLength(1)
|
||||
expect(page.total).toBe(2)
|
||||
})
|
||||
|
||||
test('searchReports filters on the ban FLAG, expired bans included', async () => {
|
||||
const report = await createReport(env.DB, {
|
||||
reporterPlayerId: 3311,
|
||||
reportedPlayerId: 3310,
|
||||
})
|
||||
// Banned, and the ban has since run out — so it is not in force, but it IS a
|
||||
// report that was actioned, which is what a moderator reviewing their own work
|
||||
// is looking for.
|
||||
await banFromReport(env.DB, report.id, { banExpires: '2020-01-01T00:00:00.000Z' })
|
||||
|
||||
expect((await searchReports(env.DB, { reportedPlayerId: 3310, banned: true })).total).toBe(1)
|
||||
expect((await searchReports(env.DB, { reportedPlayerId: 3310, banned: false })).total).toBe(0)
|
||||
// An omitted filter is BOTH, not false.
|
||||
expect((await searchReports(env.DB, { reportedPlayerId: 3310 })).total).toBe(1)
|
||||
})
|
||||
|
||||
test('searchReports windows on created_at, with an exclusive upper bound', async () => {
|
||||
const report = await createReport(env.DB, {
|
||||
reporterPlayerId: 3321,
|
||||
reportedPlayerId: 3320,
|
||||
})
|
||||
const filed = '2024-06-15T12:00:00.000Z'
|
||||
await env.DB.prepare('UPDATE report SET created_at = ?2 WHERE id = ?1')
|
||||
.bind(report.id, filed)
|
||||
.run()
|
||||
|
||||
const inWindow = await searchReports(env.DB, {
|
||||
reportedPlayerId: 3320,
|
||||
from: '2024-06-01T00:00:00.000Z',
|
||||
to: '2024-07-01T00:00:00.000Z',
|
||||
})
|
||||
expect(inWindow.total).toBe(1)
|
||||
|
||||
// `from` is inclusive, `to` exclusive — which is what makes day-boundary windows
|
||||
// composable rather than double-counting the row on the seam.
|
||||
expect((await searchReports(env.DB, { reportedPlayerId: 3320, from: filed })).total).toBe(1)
|
||||
expect((await searchReports(env.DB, { reportedPlayerId: 3320, to: filed })).total).toBe(0)
|
||||
})
|
||||
|
||||
test('getTopReported ranks distinct reporters ahead of raw count', async () => {
|
||||
// Four reports from one person, against one player…
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await createReport(env.DB, { reporterPlayerId: 3331, reportedPlayerId: 3330 })
|
||||
}
|
||||
// …and three reports from three different people, against another.
|
||||
for (const reporter of [3341, 3342, 3343]) {
|
||||
await createReport(env.DB, { reporterPlayerId: reporter, reportedPlayerId: 3340 })
|
||||
}
|
||||
|
||||
const rows = await getTopReported(env.DB, { minReports: 3 })
|
||||
const feud = rows.find((r) => r.playerId === 3330)
|
||||
const real = rows.find((r) => r.playerId === 3340)
|
||||
expect(feud).toMatchObject({ reports: 4, distinctReporters: 1 })
|
||||
expect(real).toMatchObject({ reports: 3, distinctReporters: 3 })
|
||||
// Fewer reports, but from more people — so ahead. One player filing twenty
|
||||
// reports about someone they are feuding with must not top the queue.
|
||||
expect(rows.indexOf(real!)).toBeLessThan(rows.indexOf(feud!))
|
||||
})
|
||||
|
||||
test('getTopReported windows, thresholds, and flags a ban in force', async () => {
|
||||
for (const reporter of [3351, 3352, 3353]) {
|
||||
await createReport(env.DB, { reporterPlayerId: reporter, reportedPlayerId: 3350 })
|
||||
}
|
||||
// Backdate them all a year, so only an all-time read sees them. The default
|
||||
// window is 30 days precisely so the queue is who is a problem NOW — an account
|
||||
// that collected reports last year and stopped would otherwise sit at the top
|
||||
// forever.
|
||||
await env.DB.prepare('UPDATE report SET created_at = ?1 WHERE reported_player_id = 3350')
|
||||
.bind(new Date(Date.now() - 365 * 86_400_000).toISOString())
|
||||
.run()
|
||||
|
||||
expect(
|
||||
(await getTopReported(env.DB, { minReports: 1 })).map((r) => r.playerId)
|
||||
).not.toContain(3350)
|
||||
const allTime = await getTopReported(env.DB, { sinceDays: null, minReports: 1 })
|
||||
expect(allTime.map((r) => r.playerId)).toContain(3350)
|
||||
expect(allTime.find((r) => r.playerId === 3350)?.bannedNow).toBe(false)
|
||||
|
||||
// A ban in force is decided in the same statement, so the queue can skip the
|
||||
// ones already dealt with.
|
||||
const [row] = await getReportsAgainst(env.DB, 3350)
|
||||
await banFromReport(env.DB, row!.id)
|
||||
const banned = await getTopReported(env.DB, { sinceDays: null, minReports: 1 })
|
||||
expect(banned.find((r) => r.playerId === 3350)?.bannedNow).toBe(true)
|
||||
})
|
||||
|
||||
// The standing-bans list exists to catch MISTAKES, so it is ordered by when the ban
|
||||
// landed: the one most likely to be wrong is the one just handed down. Ordering by
|
||||
// severity instead would bury a fresh one-day ban under every permanent ban ever
|
||||
// issued.
|
||||
test('getBansInForce puts the most recently handed-down ban first', async () => {
|
||||
const first = await createReport(env.DB, { reporterPlayerId: 3371, reportedPlayerId: 3370 })
|
||||
const second = await createReport(env.DB, { reporterPlayerId: 3373, reportedPlayerId: 3372 })
|
||||
const third = await createReport(env.DB, { reporterPlayerId: 3375, reportedPlayerId: 3374 })
|
||||
|
||||
// Handed down oldest-first, and deliberately mixed in severity: the permanent one
|
||||
// is banned FIRST, so an order by "longest lasting" would float it to the top.
|
||||
await banFromReport(env.DB, first.id)
|
||||
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
|
||||
.bind(first.id, '2024-01-01T00:00:00.000Z')
|
||||
.run()
|
||||
await banFromReport(env.DB, second.id, { banExpires: '2999-01-01T00:00:00.000Z' })
|
||||
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
|
||||
.bind(second.id, '2024-06-01T00:00:00.000Z')
|
||||
.run()
|
||||
await banFromReport(env.DB, third.id, { banExpires: '2999-01-01T00:00:00.000Z' })
|
||||
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
|
||||
.bind(third.id, '2025-01-01T00:00:00.000Z')
|
||||
.run()
|
||||
|
||||
const ordered = (await getBansInForce(env.DB))
|
||||
.map((b) => b.id)
|
||||
.filter((id) => [first.id, second.id, third.id].includes(id))
|
||||
expect(ordered).toEqual([third.id, second.id, first.id])
|
||||
})
|
||||
|
||||
// A ban set before 0020 added `banned_at` has only its report's date to sort by —
|
||||
// the same fallback the block screen uses. Without the coalesce every such row would
|
||||
// sort as NULL: all at one end, in no order at all.
|
||||
test('getBansInForce falls back to created_at for a ban with no banned_at', async () => {
|
||||
const old = await createReport(env.DB, { reporterPlayerId: 3381, reportedPlayerId: 3380 })
|
||||
const recent = await createReport(env.DB, { reporterPlayerId: 3383, reportedPlayerId: 3382 })
|
||||
// The pre-migration shape: banned, with no record of when.
|
||||
await env.DB.prepare(
|
||||
'UPDATE report SET banned = 1, banned_at = NULL, created_at = ?2 WHERE id = ?1'
|
||||
)
|
||||
.bind(old.id, '2023-01-01T00:00:00.000Z')
|
||||
.run()
|
||||
await banFromReport(env.DB, recent.id)
|
||||
|
||||
const ordered = (await getBansInForce(env.DB))
|
||||
.map((b) => b.id)
|
||||
.filter((id) => [old.id, recent.id].includes(id))
|
||||
// The undated one sorts by its report's date, so it lands behind today's ban
|
||||
// rather than at an arbitrary end of the list.
|
||||
expect(ordered).toEqual([recent.id, old.id])
|
||||
})
|
||||
|
||||
test('getBansInForce lists in-force bans only, and getReportById reads one row', async () => {
|
||||
const permanent = await createReport(env.DB, {
|
||||
reporterPlayerId: 3361,
|
||||
reportedPlayerId: 3360,
|
||||
})
|
||||
const expired = await createReport(env.DB, {
|
||||
reporterPlayerId: 3363,
|
||||
reportedPlayerId: 3362,
|
||||
})
|
||||
await banFromReport(env.DB, permanent.id, { bannedBy: 9002 })
|
||||
await banFromReport(env.DB, expired.id, { banExpires: '2020-01-01T00:00:00.000Z' })
|
||||
|
||||
const ids = (await getBansInForce(env.DB)).map((b) => b.id)
|
||||
expect(ids).toContain(permanent.id)
|
||||
// A ban that has served its time is not in force, though the row stays as the
|
||||
// record that it happened — the same rule `getActiveBan` applies.
|
||||
expect(ids).not.toContain(expired.id)
|
||||
|
||||
const one = await getReportById(env.DB, permanent.id)
|
||||
expect(one).toMatchObject({ id: permanent.id, banned: 1, banned_by_player_id: 9002 })
|
||||
expect(await getReportById(env.DB, 999_999)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// What the banned player is TOLD. The block screen reads this; it's the same row
|
||||
// matchmake and login refuse on, described rather than merely enforced.
|
||||
describe('moderationBlockDetails', () => {
|
||||
// All sixteen keys the 2025 client's decoder names, every one at its "none" value.
|
||||
const NOT_BLOCKED = {
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsHostKick: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
IsBan: false,
|
||||
IsVoiceModAutoban: false,
|
||||
IsDeviceBan: false,
|
||||
IsWarning: false,
|
||||
VoteKickReason: null,
|
||||
TimeoutStartedAt: null,
|
||||
AssociatedAccountUsername: null,
|
||||
ShowCreatorCodeOfConduct: false,
|
||||
TopMessageOverride: null,
|
||||
BottomMessageOverride: null,
|
||||
}
|
||||
const details = async (method: string, sub: string) => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
|
||||
{ method, headers: await bearer(sub) }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// The client POSTs this with no body, despite it being a pure read; the route
|
||||
// answers GET as well, and both methods serve the same body.
|
||||
test.each(['GET', 'POST'])(
|
||||
'%s reports "not blocked" for an unbanned player',
|
||||
async (method) => {
|
||||
// A report against them that nobody acted on is not a block.
|
||||
await submit({ PlayerIdReported: '220' }, await bearer())
|
||||
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
|
||||
// reference stub's empty string — the client tells "no message" from a blank one.
|
||||
expect(await details(method, '220')).toEqual(NOT_BLOCKED)
|
||||
}
|
||||
)
|
||||
|
||||
test('401s without a bearer token', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
// Duration and TimeoutStartedAt are a pair in the client — the block runs from the
|
||||
// start for the duration. The start is `banned_at`, the instant the ban was handed
|
||||
// down (see 0020_report_ban_audit.sql), NOT the report's created_at: the two can be
|
||||
// months apart. A permanent ban runs for the largest span the int holds.
|
||||
test('describes a permanent ban', async () => {
|
||||
await submit(
|
||||
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
|
||||
await bearer()
|
||||
)
|
||||
const [row] = await getReportsAgainst(env.DB, 221)
|
||||
const banned = await banFromReport(env.DB, row!.id)
|
||||
|
||||
expect(await details('POST', '221')).toEqual({
|
||||
...NOT_BLOCKED,
|
||||
ReportCategory: 102,
|
||||
Duration: 2_147_483_647,
|
||||
IsBan: true,
|
||||
// A fixed message — the report's `details` are the reporter's words, and
|
||||
// the reporter is not shown to the player they reported (PlayerIdReporter
|
||||
// stays null).
|
||||
Message: 'Rule violation',
|
||||
TimeoutStartedAt: banned!.banned_at,
|
||||
})
|
||||
})
|
||||
|
||||
// A timed ban's Duration is the seconds from the start to the expiry, so the pair
|
||||
// sums to `ban_expires` — not the seconds left as of the request.
|
||||
test('describes a timed ban as its banned_at plus the span to expiry', async () => {
|
||||
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 222)
|
||||
// The expiry is set relative to NOW, which is what a moderator handing down a
|
||||
// one-hour ban means — and now is `banned_at`, not the report's created_at.
|
||||
const banExpires = new Date(Date.now() + 3600 * 1000)
|
||||
const banned = await banFromReport(env.DB, row!.id, {
|
||||
banExpires: banExpires.toISOString(),
|
||||
})
|
||||
|
||||
// `Duration` is asserted as a range below, so it is left out of the object match
|
||||
// — NOT_BLOCKED carries a 0 for it, which would win over the real value.
|
||||
const { Duration: _duration, ...unblocked } = NOT_BLOCKED
|
||||
const body = (await details('GET', '222')) as Record<string, unknown>
|
||||
expect(body).toMatchObject({
|
||||
...unblocked,
|
||||
ReportCategory: 103,
|
||||
IsBan: true,
|
||||
Message: 'Rule violation',
|
||||
TimeoutStartedAt: banned!.banned_at,
|
||||
})
|
||||
// Within a second of the hour: `banned_at` is stamped by the write, so the span
|
||||
// is the hour minus however long the write took.
|
||||
expect(body.Duration).toBeGreaterThan(3595)
|
||||
expect(body.Duration).toBeLessThanOrEqual(3600)
|
||||
})
|
||||
|
||||
// The whole point of `banned_at`: a ban applied to an OLD report used to be
|
||||
// described as having started when the report was filed, so a 7-day ban on a
|
||||
// month-old report told the player their block began a month ago — and the duration,
|
||||
// measured from there, came out as already served. Both halves of the pair now
|
||||
// start from the ban.
|
||||
test('counts a ban from when it was handed down, not from when the report was filed', async () => {
|
||||
await submit({ PlayerIdReported: '224', ReportCategory: '102' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 224)
|
||||
// Backdate the report a month, as if it had sat in the queue.
|
||||
const filed = new Date(Date.now() - 30 * 86_400_000).toISOString()
|
||||
await env.DB.prepare('UPDATE report SET created_at = ?2 WHERE id = ?1')
|
||||
.bind(row!.id, filed)
|
||||
.run()
|
||||
|
||||
const banned = await banFromReport(env.DB, row!.id, {
|
||||
banExpires: new Date(Date.now() + 7 * 86_400_000).toISOString(),
|
||||
})
|
||||
const body = (await details('GET', '224')) as Record<string, unknown>
|
||||
expect(body.TimeoutStartedAt).toBe(banned!.banned_at)
|
||||
expect(body.TimeoutStartedAt).not.toBe(filed)
|
||||
// Seven days, not the negative-then-clamped span the report's date would give.
|
||||
expect(body.Duration).toBeGreaterThan(7 * 86_400 - 5)
|
||||
expect(body.Duration).toBeLessThanOrEqual(7 * 86_400)
|
||||
})
|
||||
|
||||
// A row banned BEFORE 0020 added the column carries no `banned_at`, and must keep
|
||||
// reading exactly as it used to rather than falling back to "now" — which would
|
||||
// silently restart every standing ban the moment this shipped.
|
||||
test('falls back to created_at for a ban with no recorded banned_at', async () => {
|
||||
await submit({ PlayerIdReported: '225', ReportCategory: '101' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 225)
|
||||
await env.DB.prepare(
|
||||
`UPDATE report SET banned = 1, ban_expires = ?2, banned_at = NULL WHERE id = ?1`
|
||||
)
|
||||
.bind(row!.id, new Date(Date.parse(row!.created_at) + 3600 * 1000).toISOString())
|
||||
.run()
|
||||
|
||||
expect(await details('GET', '225')).toEqual({
|
||||
...NOT_BLOCKED,
|
||||
ReportCategory: 101,
|
||||
Duration: 3600,
|
||||
IsBan: true,
|
||||
Message: 'Rule violation',
|
||||
TimeoutStartedAt: row!.created_at,
|
||||
})
|
||||
})
|
||||
|
||||
// A ban that has served its time is not a block, even though the row still says
|
||||
// `banned = 1` — the same rule `getActiveBan` applies for matchmake and login.
|
||||
test('reports "not blocked" once a ban has expired', async () => {
|
||||
await submit({ PlayerIdReported: '223' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 223)
|
||||
await banFromReport(env.DB, row!.id, { banExpires: '2020-01-01T00:00:00.000Z' })
|
||||
|
||||
expect(await details('GET', '223')).toEqual(NOT_BLOCKED)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('player warnings', () => {
|
||||
@@ -6776,26 +2807,6 @@ describe('images', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('POST /api/images/v4/uploadsaved rejects an oversized image before storing it', async () => {
|
||||
const previous = env.RECFLARE_MAX_API_UPLOAD_BYTES
|
||||
env.RECFLARE_MAX_API_UPLOAD_BYTES = '3'
|
||||
try {
|
||||
const objectsBefore = (await env.IMAGES.list()).objects.length
|
||||
const fd = new FormData()
|
||||
fd.append('image', new File([new Uint8Array(4)], 'large.png', { type: 'image/png' }))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('42'),
|
||||
body: fd,
|
||||
})
|
||||
expect(res.status).toBe(413)
|
||||
expect(await res.json()).toEqual({ error: 'image exceeds the 3-byte upload limit' })
|
||||
expect((await env.IMAGES.list()).objects).toHaveLength(objectsBefore)
|
||||
} finally {
|
||||
env.RECFLARE_MAX_API_UPLOAD_BYTES = previous
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /api/images/v4/room/:id returns a public room feed, filtered/sorted/paginated', async () => {
|
||||
// Seed images in room 54: two public (one with more cheers, of different
|
||||
// types), one private (hidden), and one in another room (excluded).
|
||||
@@ -6887,10 +2898,6 @@ describe('images', () => {
|
||||
}),
|
||||
// Unrelated to 700 → in neither.
|
||||
seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }),
|
||||
// 700's outfit thumbnail: an image under their id, but not a photo → in neither,
|
||||
// not even in their own view (207 is private, as the client stores such things).
|
||||
seed({ Id: 200, PlayerId: 700, Type: 2, CreatedAt: '2026-06-01T00:00:00.000Z' }),
|
||||
seed({ Id: 207, PlayerId: 700, Type: 3, Accessibility: 0 }),
|
||||
])
|
||||
|
||||
// The lists serve the client's ImagesPlayer projection: the id and type are
|
||||
@@ -6942,44 +2949,6 @@ describe('images', () => {
|
||||
expect(
|
||||
await (await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/424242`)).json()
|
||||
).toEqual([])
|
||||
|
||||
// The owner's view: 700 asking for their own list gets the private 203 too, on v4,
|
||||
// v5 and the feed alike. 203 has the seed's default CreatedAt (January), so it sorts
|
||||
// last — the private photo takes its place in the order, it isn't pinned anywhere.
|
||||
const own = await bearer('700')
|
||||
for (const path of ['v4/player/700', 'v5/player/700?sort=0']) {
|
||||
const list = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/${path}`, { headers: own })
|
||||
).json()) as Array<ImagesPlayer & { Accessibility: number }>
|
||||
expect(list.map((i) => i.SavedImageId)).toEqual([202, 201, 203])
|
||||
expect(list[2]?.Accessibility).toBe(0)
|
||||
}
|
||||
const ownFeed = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700`, { headers: own })
|
||||
).json()) as ImagesPlayer[]
|
||||
expect(ownFeed.map((i) => i.SavedImageId)).toEqual([204, 202, 201, 203])
|
||||
|
||||
// Being tagged in someone else's private photo does NOT surface it in the feed —
|
||||
// the taker decides its privacy, not the tag.
|
||||
await seed({ Id: 206, PlayerId: 999, TaggedPlayerIds: [700], Accessibility: 0 }).run()
|
||||
const ownFeedAgain = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700`, { headers: own })
|
||||
).json()) as ImagesPlayer[]
|
||||
expect(ownFeedAgain.map((i) => i.SavedImageId)).not.toContain(206)
|
||||
|
||||
// Anyone else's token — a friend, staff — is the public list, same as no token.
|
||||
const other = await bearer('999', ['developer', 'moderator'])
|
||||
const asOther = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`, { headers: other })
|
||||
).json()) as ImagesPlayer[]
|
||||
expect(asOther.map((i) => i.SavedImageId)).toEqual([202, 201])
|
||||
|
||||
// A garbage token isn't a 401 on a public list; it reads as nobody.
|
||||
const junk = await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`, {
|
||||
headers: { Authorization: 'Bearer not.a.jwt' },
|
||||
})
|
||||
expect(junk.status).toBe(200)
|
||||
expect(((await junk.json()) as ImagesPlayer[]).map((i) => i.SavedImageId)).toEqual([202, 201])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7243,11 +3212,8 @@ describe('relationships', () => {
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '770', 771)
|
||||
|
||||
// Both sides hear about it, each seeing the other player and their own side's
|
||||
// type: the sender Sent (1), the recipient Received (2). The target ALSO gets the
|
||||
// request itself as a FriendInvite (4) message — that is what the client shows.
|
||||
const sent = await sentNotifications()
|
||||
expect(sent).toHaveLength(3)
|
||||
expect(sent.slice(0, 2)).toEqual([
|
||||
// type: the sender Sent (1), the recipient Received (2).
|
||||
expect(await sentNotifications()).toEqual([
|
||||
{
|
||||
playerId: 770,
|
||||
notificationType: 1,
|
||||
@@ -7259,34 +3225,6 @@ describe('relationships', () => {
|
||||
data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
},
|
||||
])
|
||||
expect(sent[2]?.playerId).toBe(771)
|
||||
expect(sent[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
|
||||
expect(sent[2]?.data).toMatchObject({ FromPlayerId: 770, ToPlayerId: 771, Type: 4, Data: null })
|
||||
|
||||
// The message is stored under the same id, so an offline target finds it on login.
|
||||
const frameId = (sent[2]?.data as unknown as { Id: number }).Id
|
||||
expect(frameId).toBeGreaterThan(0)
|
||||
const inbox = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, { headers: await bearer('771') })
|
||||
).json()) as Array<{ Id: number; FromPlayerId: number; Type: number }>
|
||||
expect(inbox.find((m) => m.Id === frameId)).toMatchObject({ FromPlayerId: 770, Type: 4 })
|
||||
})
|
||||
|
||||
test('a re-sent request says nothing; a crossing request tells the requester it was accepted', async () => {
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '772', 773)
|
||||
await resetNotifications()
|
||||
// Re-sending an outstanding request is a no-op: nothing is pushed at all.
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '772', 773)
|
||||
expect(await sentNotifications()).toEqual([])
|
||||
// The crossing request auto-accepts: both hear Friend, and the ORIGINAL requester
|
||||
// (772) is told their request was accepted rather than asked anything.
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '773', 772)
|
||||
const sent = await sentNotifications()
|
||||
expect(sent).toHaveLength(3)
|
||||
expect(sent.slice(0, 2).every((n) => n.notificationType === 1)).toBe(true)
|
||||
expect(sent[2]?.playerId).toBe(772)
|
||||
expect(sent[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
|
||||
expect(sent[2]?.data).toMatchObject({ FromPlayerId: 773, ToPlayerId: 772, Type: 40 })
|
||||
})
|
||||
|
||||
test('accepting notifies both players as Friend', async () => {
|
||||
@@ -7294,13 +3232,8 @@ describe('relationships', () => {
|
||||
await resetNotifications()
|
||||
await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780)
|
||||
|
||||
const all = await sentNotifications()
|
||||
expect(all).toHaveLength(3)
|
||||
// The requester also gets the acceptance as a FriendRequestAccepted (40) message.
|
||||
expect(all[2]?.playerId).toBe(780)
|
||||
expect(all[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
|
||||
expect(all[2]?.data).toMatchObject({ FromPlayerId: 781, ToPlayerId: 780, Type: 40, Data: null })
|
||||
const sent = all.slice(0, 2)
|
||||
const sent = await sentNotifications()
|
||||
expect(sent).toHaveLength(2)
|
||||
// Friend (3) is symmetric, so both sides see the same type, each pointing at the other.
|
||||
expect(sent).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -7347,10 +3280,8 @@ describe('relationships', () => {
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '821', 820)
|
||||
|
||||
const sent = await sentNotifications()
|
||||
expect(sent).toHaveLength(3)
|
||||
for (const n of sent.slice(0, 2)) expect(n.data.RelationshipType).toBe(3)
|
||||
// ...and 820, whose request this crossed, is told it was accepted.
|
||||
expect(sent[2]).toMatchObject({ playerId: 820, notificationType: 2, data: { Type: 40 } })
|
||||
expect(sent).toHaveLength(2)
|
||||
for (const n of sent) expect(n.data.RelationshipType).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7448,17 +3379,7 @@ describe('messages', () => {
|
||||
type Sent = {
|
||||
playerId: number
|
||||
notificationType: number
|
||||
// The frame carries the STORED message, so `Id` and `SentTime` come off the row.
|
||||
data: {
|
||||
Id: number
|
||||
FromPlayerId: number
|
||||
ToPlayerId: number
|
||||
SentTime: string
|
||||
Type: number
|
||||
Data: string | null
|
||||
RoomId: number | null
|
||||
PlayerEventId: number | null
|
||||
}
|
||||
data: { FromPlayerId: number; ToPlayerId: number; Type: number; Data: string }
|
||||
}
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
const pushed = async (): Promise<Sent[]> =>
|
||||
@@ -7476,60 +3397,26 @@ describe('messages', () => {
|
||||
// NotificationType.MessageReceived — the same frame the Coach broadcast uses.
|
||||
const MESSAGE_RECEIVED = 2
|
||||
|
||||
/** One player's inbox, as the client reads it on login. */
|
||||
const inboxOf = async (playerId: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, {
|
||||
headers: await bearer(playerId),
|
||||
})
|
||||
).json()) as Array<{
|
||||
Id: number
|
||||
FromPlayerId: number
|
||||
ToPlayerId: number
|
||||
SentTime: string
|
||||
Type: number
|
||||
Data: string | null
|
||||
}>
|
||||
|
||||
const del = async (body: unknown, headers?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/messages/v3/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
test('POST /api/messages/v2/send pushes MessageReceived to the recipient', async () => {
|
||||
const res = await send({ ToPlayerId: '2', Type: '10', Data: '' }, await bearer('42'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
const sent = await pushed()
|
||||
expect(sent).toHaveLength(1)
|
||||
// Delivered to the recipient, not the sender.
|
||||
expect(sent[0]?.playerId).toBe(2)
|
||||
expect(sent[0]?.notificationType).toBe(MESSAGE_RECEIVED)
|
||||
// FromPlayerId is the token's subject, not a body field. The frame carries the
|
||||
// STORED message, so it has the row's Id and SentTime on it too.
|
||||
expect(sent[0]?.data).toMatchObject({
|
||||
FromPlayerId: 42,
|
||||
ToPlayerId: 2,
|
||||
Type: 10,
|
||||
Data: '',
|
||||
RoomId: null,
|
||||
PlayerEventId: null,
|
||||
})
|
||||
const frameId = sent[0]!.data.Id
|
||||
expect(frameId).toBeGreaterThan(0)
|
||||
|
||||
// ...and the recipient reads the same message, under the same id, from their inbox.
|
||||
const inbox = await inboxOf('2')
|
||||
expect(inbox[0]).toMatchObject({ Id: frameId, FromPlayerId: 42, Type: 10, Data: '' })
|
||||
expect(await pushed()).toEqual([
|
||||
{
|
||||
// Delivered to the recipient, not the sender.
|
||||
playerId: 2,
|
||||
notificationType: MESSAGE_RECEIVED,
|
||||
// FromPlayerId is the token's subject, not a body field.
|
||||
data: { FromPlayerId: 42, ToPlayerId: 2, Type: 10, Data: '' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('POST /api/messages/v2/send defaults Type and Data when omitted', async () => {
|
||||
const res = await send({ ToPlayerId: '2' }, await bearer('42'))
|
||||
expect(res.status).toBe(200)
|
||||
expect((await pushed())[0]?.data).toMatchObject({
|
||||
expect((await pushed())[0]?.data).toEqual({
|
||||
FromPlayerId: 42,
|
||||
ToPlayerId: 2,
|
||||
Type: 0,
|
||||
@@ -7569,17 +3456,18 @@ describe('messages', () => {
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
// Each frame is addressed to its own recipient; the sender is the token's subject.
|
||||
const sent = await pushed()
|
||||
expect(sent.map((n) => n.playerId)).toEqual([205, 206])
|
||||
expect(sent.map((n) => n.notificationType)).toEqual([MESSAGE_RECEIVED, MESSAGE_RECEIVED])
|
||||
expect(sent[0]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 205, Type: 20, Data: 'hi' })
|
||||
expect(sent[1]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 206, Type: 20, Data: 'hi' })
|
||||
|
||||
// One STORED message each, with ids of their own — not one message shared.
|
||||
const ids = sent.map((n) => n.data.Id)
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
expect((await inboxOf('205'))[0]).toMatchObject({ Id: ids[0], Data: 'hi' })
|
||||
expect((await inboxOf('206'))[0]).toMatchObject({ Id: ids[1], Data: 'hi' })
|
||||
expect(await pushed()).toEqual([
|
||||
{
|
||||
playerId: 205,
|
||||
notificationType: MESSAGE_RECEIVED,
|
||||
data: { FromPlayerId: 42, ToPlayerId: 205, Type: 20, Data: 'hi' },
|
||||
},
|
||||
{
|
||||
playerId: 206,
|
||||
notificationType: MESSAGE_RECEIVED,
|
||||
data: { FromPlayerId: 42, ToPlayerId: 206, Type: 20, Data: 'hi' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('POST /api/messages/v1/sendMultiple defaults Type and Data, and de-duplicates ids', async () => {
|
||||
@@ -7588,7 +3476,7 @@ describe('messages', () => {
|
||||
|
||||
const sent = await pushed()
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' })
|
||||
expect(sent[0]?.data).toEqual({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' })
|
||||
})
|
||||
|
||||
test('POST /api/messages/v1/sendMultiple 400s with no usable recipient, pushing nothing', async () => {
|
||||
@@ -7606,66 +3494,19 @@ describe('messages', () => {
|
||||
expect(await pushed()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/messages/v2/get serves the caller’s inbox, newest first', async () => {
|
||||
// Two messages to 9600, one to somebody else — an inbox is the caller's own.
|
||||
await send({ ToPlayerId: '9600', Data: 'first' }, await bearer('42'))
|
||||
await send({ ToPlayerId: '9600', Data: 'second' }, await bearer('43'))
|
||||
await send({ ToPlayerId: '9601', Data: 'not yours' }, await bearer('42'))
|
||||
|
||||
const inbox = await inboxOf('9600')
|
||||
expect(inbox.map((m) => m.Data)).toEqual(['second', 'first'])
|
||||
expect(inbox[0]).toMatchObject({ FromPlayerId: 43, ToPlayerId: 9600, Type: 0 })
|
||||
// Every message carries the full Message model, ids and all.
|
||||
expect(Date.parse(inbox[0].SentTime)).not.toBeNaN()
|
||||
expect(inbox.every((m) => m.Id > 0)).toBe(true)
|
||||
// The other player's message is not in it.
|
||||
expect(inbox.some((m) => m.Data === 'not yours')).toBe(false)
|
||||
})
|
||||
|
||||
test('GET /api/messages/v2/get is auth-gated', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/messages/v3/delete removes only the caller’s messages', async () => {
|
||||
await send({ ToPlayerId: '9610', Data: 'keep' }, await bearer('42'))
|
||||
await send({ ToPlayerId: '9610', Data: 'drop' }, await bearer('42'))
|
||||
await send({ ToPlayerId: '9611', Data: 'someone else' }, await bearer('42'))
|
||||
|
||||
const before = await inboxOf('9610')
|
||||
const drop = before.find((m) => m.Data === 'drop')!
|
||||
const theirs = (await inboxOf('9611'))[0]
|
||||
|
||||
// The caller deletes their own message, and takes a swing at another player's id
|
||||
// in the same call — an id names a message globally, so this must not reach it.
|
||||
const res = await del({ MessageIds: [drop.Id, theirs.Id] }, await bearer('9610'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('')
|
||||
|
||||
expect((await inboxOf('9610')).map((m) => m.Data)).toEqual(['keep'])
|
||||
expect((await inboxOf('9611')).map((m) => m.Data)).toEqual(['someone else'])
|
||||
})
|
||||
|
||||
test('POST /api/messages/v3/delete takes a bare array, and shrugs off the rest', async () => {
|
||||
await send({ ToPlayerId: '9620', Data: 'bare' }, await bearer('42'))
|
||||
const message = (await inboxOf('9620'))[0]
|
||||
|
||||
// The client posts a bare array; the documented `{ MessageIds }` object works too.
|
||||
expect((await del([message.Id], await bearer('9620'))).status).toBe(200)
|
||||
expect(await inboxOf('9620')).toEqual([])
|
||||
|
||||
// An unknown id, an empty list and a missing body are all an empty 200 — the client
|
||||
// removes its rows locally and re-reads the list either way.
|
||||
for (const body of [{ MessageIds: [1787377235629] }, { MessageIds: [] }, {}, []]) {
|
||||
const res = await del(body, await bearer('9620'))
|
||||
test('POST /api/messages/v3/delete accepts anything with an empty 200', async () => {
|
||||
// No message store, so no id can be real and nothing is gated — an unknown id, an
|
||||
// empty list and a missing body all land the same way.
|
||||
for (const body of [{ MessageIds: [1787377235629] }, { MessageIds: [] }, {}]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/messages/v3/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
test('POST /api/messages/v3/delete is auth-gated', async () => {
|
||||
expect((await del({ MessageIds: [1] })).status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutual friends', () => {
|
||||
@@ -7806,16 +3647,6 @@ describe('player events', () => {
|
||||
return { ...rest, ImageName: imageName, State: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's BASE event behind an envelope's event — what the browse feed, the room
|
||||
* shelf and the bulk read all serve. The envelope minus `Tags`, plus a null
|
||||
* `BroadcastingRoomInstanceId`; `ImageName` is already `""` on the envelope.
|
||||
*/
|
||||
const asBase = (event: PlayerEventEnvelope): Record<string, unknown> => {
|
||||
const { Tags: _tags, ...rest } = event
|
||||
return { ...rest, BroadcastingRoomInstanceId: null }
|
||||
}
|
||||
|
||||
// The fixture set every test below reads. Times are relative to the run so the
|
||||
// upcoming/live/finished distinction the browse queries make is real.
|
||||
let upcoming: PlayerEventEnvelope
|
||||
@@ -8041,10 +3872,10 @@ describe('player events', () => {
|
||||
expect(body.Result).toBe(0)
|
||||
expect(body.PlayerEvent.Name).toBe('Enveloped')
|
||||
// The tags ride inline on the event AND in TagModifyResult. Inline they take the
|
||||
// caller's build shape — this token names no build, so the 2023 bare names (a newer
|
||||
// build gets the PascalCase `{ Tag, Type }` pairs). TagModifyResult is names to every
|
||||
// build.
|
||||
expect(body.PlayerEvent.Tags).toEqual(['music'])
|
||||
// caller's build shape — this token names no build, so the 2023 `{ Tag, Type }` pairs
|
||||
// (PascalCase: not the lowercase pairs the v1 read's `tags` serves). TagModifyResult
|
||||
// is names to every build.
|
||||
expect(body.PlayerEvent.Tags).toEqual([{ Tag: 'music', Type: 0 }])
|
||||
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['music'] })
|
||||
// No `State`, and the broadcast instance is present and null.
|
||||
expect(body.PlayerEvent).not.toHaveProperty('State')
|
||||
@@ -8053,7 +3884,7 @@ describe('player events', () => {
|
||||
|
||||
test('the v2 envelope shapes PlayerEvent.Tags per the caller’s build', async () => {
|
||||
// Rec Room reshaped this field without minting a new path, so one endpoint owes two
|
||||
// shapes: the 2023 build parses bare names, anything newer `{ Tag, Type }` pairs.
|
||||
// shapes: the 2023 build parses `{ Tag, Type }` pairs, the 2025 build bare names.
|
||||
// Serving either to the wrong build empties the event's chips instead of erroring.
|
||||
// A tag of this test's own: the `#tag` search tests assert exact result sets, and the
|
||||
// four events below would join any set they share a tag with.
|
||||
@@ -8073,18 +3904,16 @@ describe('player events', () => {
|
||||
return (await res.json()) as PlayerEventResult
|
||||
}
|
||||
|
||||
// Newer than 20230414 gets the pairs; that build, an older one, and a token naming
|
||||
// no build at all are all the 2023 client and get names. Builds are date-stamped, so
|
||||
// they compare as strings.
|
||||
expect((await created('20250718.01')).PlayerEvent.Tags).toEqual(PAIRS)
|
||||
expect((await created('20230414')).PlayerEvent.Tags).toEqual(NAMES)
|
||||
expect((await created('20220101')).PlayerEvent.Tags).toEqual(NAMES)
|
||||
// Newer than 20230414 is the 2025 client; that build, an older one, and a token naming
|
||||
// no build at all are all the 2023 client. Builds are date-stamped, so they compare as
|
||||
// strings.
|
||||
expect((await created('20250718.01')).PlayerEvent.Tags).toEqual(NAMES)
|
||||
expect((await created('20230414')).PlayerEvent.Tags).toEqual(PAIRS)
|
||||
expect((await created('20220101')).PlayerEvent.Tags).toEqual(PAIRS)
|
||||
const legacy = await created()
|
||||
expect(legacy.PlayerEvent.Tags).toEqual(NAMES)
|
||||
// Only the inline field moves: TagModifyResult carries names to both builds — it
|
||||
// really is a List<string> on the client, so the pairs must never land there.
|
||||
expect(legacy.PlayerEvent.Tags).toEqual(PAIRS)
|
||||
// Only the inline field moves: TagModifyResult carries names to both builds.
|
||||
expect(legacy.TagModifyResult).toEqual({ Result: 0, Tags: NAMES })
|
||||
expect((await created('20250718.01')).TagModifyResult).toEqual({ Result: 0, Tags: NAMES })
|
||||
|
||||
// The gate is on the ENVELOPE, not on the create: the read and the field edits answer
|
||||
// the same shape, so a client that made an event and one opening it cold agree.
|
||||
@@ -8097,8 +3926,8 @@ describe('player events', () => {
|
||||
})
|
||||
).json()) as PlayerEventResult
|
||||
).PlayerEvent.Tags
|
||||
expect(await read('20250718.01')).toEqual(PAIRS)
|
||||
expect(await read()).toEqual(NAMES)
|
||||
expect(await read('20250718.01')).toEqual(NAMES)
|
||||
expect(await read()).toEqual(PAIRS)
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v2/:eventId serves the same envelope as the write', async () => {
|
||||
@@ -8218,61 +4047,7 @@ describe('player events', () => {
|
||||
expect(await (await get(path)).json()).toEqual(asRecord(upcoming))
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/bulk answers the requested ids as base events', async () => {
|
||||
// What the client sends: `Ids` repeated once per id, form-urlencoded.
|
||||
const body = new URLSearchParams()
|
||||
for (const id of [clubEvent.PlayerEventId, 999999, upcoming.PlayerEventId]) {
|
||||
body.append('Ids', String(id))
|
||||
}
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const events = (await res.json()) as PlayerEvent[]
|
||||
|
||||
// Request order, not id order — and the missing id leaves no hole.
|
||||
expect(events.map((e) => e.PlayerEventId)).toEqual([
|
||||
clubEvent.PlayerEventId,
|
||||
upcoming.PlayerEventId,
|
||||
])
|
||||
|
||||
// A bare array — no envelope — of the BASE event, the same projection the browse feed
|
||||
// and the room shelf serve. Not the stored record: no `State`.
|
||||
const entry = events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)!
|
||||
expect(entry).toEqual(asBase(upcoming))
|
||||
expect(Object.keys(entry)).toHaveLength(17)
|
||||
expect(Object.hasOwn(entry, 'State')).toBe(false)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/bulk reads a single id and the comma-separated form', async () => {
|
||||
const bulk = async (raw: string): Promise<PlayerEvent[]> => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: raw,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as PlayerEvent[]
|
||||
}
|
||||
|
||||
// The raw one-id body the client sends for a single event.
|
||||
expect((await bulk(`Ids=${upcoming.PlayerEventId}`)).map((e) => e.PlayerEventId)).toEqual([
|
||||
upcoming.PlayerEventId,
|
||||
])
|
||||
// …and the comma-separated spelling the other bulk POSTs take.
|
||||
expect(
|
||||
(await bulk(`Ids=${clubEvent.PlayerEventId},${upcoming.PlayerEventId}`)).map(
|
||||
(e) => e.PlayerEventId
|
||||
)
|
||||
).toEqual([clubEvent.PlayerEventId, upcoming.PlayerEventId])
|
||||
// Nothing to look up is an empty array, not every event and not a 400.
|
||||
expect(await bulk('')).toEqual([])
|
||||
expect(await bulk('Ids=')).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/bulk answers the same shape as the POST', async () => {
|
||||
test('GET /api/playerevents/v1/bulk answers in request order, skipping unknown ids', async () => {
|
||||
const res = await get(
|
||||
`/api/playerevents/v1/bulk?id=${clubEvent.PlayerEventId}&id=999999&id=${upcoming.PlayerEventId}`
|
||||
)
|
||||
@@ -8283,8 +4058,6 @@ describe('player events', () => {
|
||||
clubEvent.PlayerEventId,
|
||||
upcoming.PlayerEventId,
|
||||
])
|
||||
// The same base projection the POST serves: one path, one shape.
|
||||
expect(events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)).toEqual(asBase(upcoming))
|
||||
|
||||
// No ids is an empty list, not every event.
|
||||
expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([])
|
||||
@@ -8476,15 +4249,9 @@ describe('player events', () => {
|
||||
expect(events.map((e) => e.PlayerEventId)).not.toContain(finished.PlayerEventId)
|
||||
expect(events.map((e) => e.PlayerEventId)).not.toContain(elsewhere.PlayerEventId)
|
||||
|
||||
// The BASE event, 17 keys — the same projection the browse feed and the bulk read
|
||||
// serve, since the client decodes all three through one helper and one element type.
|
||||
// Not the stored record (`/searchlive` and the club shelves keep that), and not the
|
||||
// single-club envelope.
|
||||
expect(events[0]).toEqual(asBase(running))
|
||||
expect(Object.keys(events[0]!)).toHaveLength(17)
|
||||
expect(Object.hasOwn(events[0]!, 'State')).toBe(false)
|
||||
// An event created with no banner reads `""` here, never the record's null.
|
||||
expect(events[0]!.ImageName).toBe('')
|
||||
// A bare array of the STORED record, like `/searchlive` and the multi-club shelf —
|
||||
// not the base projection the browse feed serves, and not the single-club envelope.
|
||||
expect(events[0]).toEqual(asRecord(running, null))
|
||||
|
||||
// A room with nothing scheduled, and a room id nothing knows about, are both empty.
|
||||
expect(await (await get('/api/playerevents/v1/room/999999')).json()).toEqual([])
|
||||
@@ -8691,7 +4458,7 @@ describe('player events', () => {
|
||||
).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/bulkInvite adds invitees as Pending without overwriting answers', async () => {
|
||||
test('POST /api/playerevents/v1/bulkInvite adds invitees as Going without overwriting answers', async () => {
|
||||
const event = await create({ RoomId: 3, Name: 'Invite Test', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
|
||||
@@ -8707,9 +4474,8 @@ describe('player events', () => {
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as PlayerEventResult
|
||||
expect(body.Result).toBe(0)
|
||||
// Only the creator: an invite is Pending, not Going, so the two newly invited don't
|
||||
// count until they answer — and 43 keeps their decline.
|
||||
expect(body.PlayerEvent.AttendeeCount).toBe(1)
|
||||
// The creator plus the two newly invited — 43 keeps their decline, so isn't counted.
|
||||
expect(body.PlayerEvent.AttendeeCount).toBe(3)
|
||||
|
||||
const responses = (await (await get(`/api/playerevents/v1/${id}/responses`)).json()) as Array<{
|
||||
PlayerId: number
|
||||
@@ -8718,10 +4484,10 @@ describe('player events', () => {
|
||||
expect(
|
||||
responses.sort((a, b) => a.PlayerId - b.PlayerId).map((r) => [r.PlayerId, r.Type])
|
||||
).toEqual([
|
||||
[2, 3],
|
||||
[2, 0],
|
||||
[42, 0],
|
||||
[43, 2],
|
||||
[187, 3],
|
||||
[187, 0],
|
||||
])
|
||||
|
||||
// Re-inviting is a no-op, not a reset: 43 still declines and the count holds.
|
||||
@@ -8730,23 +4496,14 @@ describe('player events', () => {
|
||||
{ PlayerEventId: id, InvitedPlayerIds: [187, 43] },
|
||||
'42'
|
||||
)
|
||||
expect(((await again.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
|
||||
expect(((await again.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(3)
|
||||
|
||||
// An empty list is a no-op that still answers the event.
|
||||
const none = await post('/api/playerevents/v1/bulkInvite', {
|
||||
PlayerEventId: id,
|
||||
InvitedPlayerIds: [],
|
||||
})
|
||||
expect(((await none.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
|
||||
|
||||
// Accepting the invite is an ordinary respond: Pending becomes Going and counts.
|
||||
const accepted = await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
|
||||
expect(((await accepted.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(2)
|
||||
|
||||
// Pending is the server's mark, not an answer a player can give.
|
||||
expect(
|
||||
(await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 3 }, '187')).status
|
||||
).toBe(400)
|
||||
expect(((await none.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(3)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/bulkInvite notifies only the players it actually added', async () => {
|
||||
@@ -8764,57 +4521,17 @@ describe('player events', () => {
|
||||
data: Record<string, Record<string, unknown>>
|
||||
}>
|
||||
|
||||
// ONE frame, to the one player who gained a row: the invitation Message. No
|
||||
// PlayerEventResponseChanged — a Pending row is not a response; that frame is the
|
||||
// responder's, sent by `respond` once they answer. 43 kept their answer, so nothing
|
||||
// One frame, to the one player who gained a row. 43 kept their answer, so nothing
|
||||
// changed for them and nothing is pushed.
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]!.playerId).toBe(2)
|
||||
expect(sent[0]!.notificationType).toBe(2) // MessageReceived
|
||||
|
||||
// A PlayerEventInvitation (81) Message from the inviter, naming the event on
|
||||
// PlayerEventId and as a string in Data.
|
||||
expect(sent[0]!.data).toMatchObject({
|
||||
FromPlayerId: 42,
|
||||
ToPlayerId: 2,
|
||||
Type: 81,
|
||||
Data: String(id),
|
||||
PlayerEventId: id,
|
||||
})
|
||||
// Stored under the same id, so an offline invitee finds it on login.
|
||||
const frameId = (sent[0]!.data as unknown as { Id: number }).Id
|
||||
expect(frameId).toBeGreaterThan(0)
|
||||
const inbox = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, { headers: await bearer('2') })
|
||||
).json()) as Array<{ Id: number; Type: number; PlayerEventId: number | null }>
|
||||
expect(inbox.find((m) => m.Id === frameId)).toMatchObject({ Type: 81, PlayerEventId: id })
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/respond pushes PlayerEventResponseChanged to the responder', async () => {
|
||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
const event = await create({ RoomId: 3, Name: 'RSVP Frames', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [2] })
|
||||
|
||||
// The invitee accepts: THIS is where their response frame comes from — the frame
|
||||
// that populates the event on their client — carrying their row as it now stands.
|
||||
await hub.fetch('http://do/all', { method: 'DELETE' })
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
|
||||
const sent = (await (await hub.fetch('http://do/all')).json()) as Array<{
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: Record<string, Record<string, unknown>>
|
||||
}>
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]!.playerId).toBe(2)
|
||||
expect(sent[0]!.notificationType).toBe(83) // PlayerEventResponseChanged
|
||||
|
||||
// BOTH nested objects are present — the client dereferences them without a null
|
||||
// guard, so a missing one is a NullReferenceException rather than a blank field.
|
||||
// The event carries the count as recomputed AFTER the answer.
|
||||
expect(sent[0]!.data.PlayerEvent).toMatchObject({
|
||||
playerEventId: id,
|
||||
name: 'RSVP Frames',
|
||||
name: 'Invite Frames',
|
||||
attendeeCount: 2,
|
||||
})
|
||||
expect(sent[0]!.data.PlayerEventResponse).toEqual({
|
||||
@@ -8824,106 +4541,6 @@ describe('player events', () => {
|
||||
CreatedAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/),
|
||||
Type: 0,
|
||||
})
|
||||
|
||||
// Changing the answer pushes again, with the same row id and the new Type — the
|
||||
// upsert updates in place, so the frame's id matches what the RSVP list serves.
|
||||
await hub.fetch('http://do/all', { method: 'DELETE' })
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '2')
|
||||
const again = (await (await hub.fetch('http://do/all')).json()) as typeof sent
|
||||
expect(again).toHaveLength(1)
|
||||
expect(again[0]!.data.PlayerEventResponse).toMatchObject({
|
||||
PlayerEventResponseId: sent[0]!.data.PlayerEventResponse!.PlayerEventResponseId,
|
||||
Type: 2,
|
||||
})
|
||||
expect(again[0]!.data.PlayerEvent).toMatchObject({ attendeeCount: 1 })
|
||||
})
|
||||
|
||||
test('a deleted event’s id is never reused by a later create', async () => {
|
||||
// Ids used to be MAX(id) + 1 over the table, so deleting the newest event handed its
|
||||
// id to the next one created — which then inherited everything still filed under
|
||||
// that id outside the event's own rows (an invitation message, the client's cache).
|
||||
const first = await create({ RoomId: 3, Name: 'First', StartTime: at(HOUR) })
|
||||
expect(
|
||||
(await post(`/api/playerevents/v2/delete/${first.PlayerEventId}`, undefined)).status
|
||||
).toBe(200)
|
||||
const second = await create({ RoomId: 3, Name: 'Second', StartTime: at(HOUR) })
|
||||
expect(second.PlayerEventId).toBeGreaterThan(first.PlayerEventId)
|
||||
// And the replacement starts with its own guest list: just its creator.
|
||||
const responses = (await (
|
||||
await get(`/api/playerevents/v1/${second.PlayerEventId}/responses`)
|
||||
).json()) as Array<{ PlayerId: number }>
|
||||
expect(responses.map((r) => r.PlayerId)).toEqual([42])
|
||||
})
|
||||
|
||||
test('editing an event pushes PlayerEventUpdated to everyone Going, and only them', async () => {
|
||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
const event = await create({ RoomId: 3, Name: 'Before', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
// 2 is Going, 43 is Interested, 44 declined, 45 is invited and still Pending.
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '44')
|
||||
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [45] })
|
||||
|
||||
const frames = async () =>
|
||||
(await (await hub.fetch('http://do/all')).json()) as Array<{
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
|
||||
// The whole-event update: the creator (Going from create) and 2 hear about it, with
|
||||
// the event as it now stands in the camelCase projection PlayerEventCreated uses.
|
||||
await hub.fetch('http://do/all', { method: 'DELETE' })
|
||||
const res = await post(`/api/playerevents/v2/${id}`, { Name: 'After' })
|
||||
expect(res.status).toBe(200)
|
||||
const sent = await frames()
|
||||
expect(sent.map((s) => [s.playerId, s.notificationType])).toEqual([
|
||||
[2, 81], // PlayerEventUpdated
|
||||
[42, 81],
|
||||
])
|
||||
for (const frame of sent) {
|
||||
expect(frame.data).toMatchObject({ playerEventId: id, name: 'After', attendeeCount: 2 })
|
||||
}
|
||||
|
||||
// A single-field edit pushes the same frame to the same audience.
|
||||
await hub.fetch('http://do/all', { method: 'DELETE' })
|
||||
const renamed = await exports.default.fetch(`${ORIGIN}/api/playerevents/v2/${id}/name`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('42')), 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ name: 'Renamed' }),
|
||||
})
|
||||
expect(renamed.status).toBe(200)
|
||||
const again = await frames()
|
||||
expect(again.map((s) => [s.playerId, s.notificationType])).toEqual([
|
||||
[2, 81],
|
||||
[42, 81],
|
||||
])
|
||||
expect(again[0]!.data).toMatchObject({ name: 'Renamed' })
|
||||
})
|
||||
|
||||
test('deleting an event pushes PlayerEventDeleted to everyone who was Going, and only them', async () => {
|
||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
const event = await create({ RoomId: 3, Name: 'Doomed', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
|
||||
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [45] })
|
||||
|
||||
await hub.fetch('http://do/all', { method: 'DELETE' })
|
||||
const res = await post(`/api/playerevents/v2/delete/${id}`, undefined)
|
||||
expect(res.status).toBe(200)
|
||||
const sent = (await (await hub.fetch('http://do/all')).json()) as Array<{
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
// The audience is read before the RSVP rows are deleted with the event; the frame
|
||||
// is just the id, there being no event left to carry.
|
||||
expect(sent).toEqual([
|
||||
{ playerId: 2, notificationType: 82, data: { PlayerEventId: id } }, // PlayerEventDeleted
|
||||
{ playerId: 42, notificationType: 82, data: { PlayerEventId: id } },
|
||||
])
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/bulkInvite is gated on the caller being on the event', async () => {
|
||||
@@ -9190,9 +4807,11 @@ describe('player events', () => {
|
||||
const path = `/api/playerevents/v2/${event.PlayerEventId}/tags`
|
||||
|
||||
// A bare JSON array, not an object — and a replace, not a merge, so `meetup` goes.
|
||||
// Names, since this token names no build (a newer build would get the pairs).
|
||||
const tagged = await edited(await putJson(path, ['tag1', '#Class']))
|
||||
expect(tagged.Tags).toEqual(['class', 'tag1'])
|
||||
expect(tagged.Tags).toEqual([
|
||||
{ Tag: 'class', Type: 0 },
|
||||
{ Tag: 'tag1', Type: 0 },
|
||||
])
|
||||
// The envelope's TagModifyResult reports the same set the client redraws chips from.
|
||||
const body = (await (await putJson(path, ['workshops'])).json()) as PlayerEventResult
|
||||
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['workshops'] })
|
||||
@@ -9295,10 +4914,8 @@ describe('openapi', () => {
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'DELETE /api/customAvatarItems/v1/{id}',
|
||||
'DELETE /api/images/v1/deletesaved',
|
||||
'DELETE /api/playerevents/v2/delete/{eventId}',
|
||||
'GET /api/CircuitChipLists/{list}',
|
||||
'GET /api/PlayerReporting/v1/moderationBlockDetails',
|
||||
'GET /api/PlayerReporting/v1/voteToKickReasons',
|
||||
'GET /api/activities/charades/v1/words/{activity}',
|
||||
@@ -9314,8 +4931,6 @@ describe('openapi', () => {
|
||||
'GET /api/customAvatarItems/v1/isCreationAllowedForAccount',
|
||||
'GET /api/customAvatarItems/v1/isCreationEnabled',
|
||||
'GET /api/customAvatarItems/v1/isRenderingEnabled',
|
||||
'GET /api/customAvatarItems/v1/minPriceForPublicItem',
|
||||
'GET /api/customAvatarItems/v1/search',
|
||||
'GET /api/customAvatarItems/v2/fromCreator/{accountId}',
|
||||
'GET /api/equipment/v2/getUnlocked',
|
||||
'GET /api/gameconfigs/v1/all',
|
||||
@@ -9331,7 +4946,6 @@ describe('openapi', () => {
|
||||
'GET /api/inventions/v1',
|
||||
'GET /api/inventions/v1/details',
|
||||
'GET /api/inventions/v1/featured',
|
||||
'GET /api/inventions/v1/featureddormskins',
|
||||
'GET /api/inventions/v1/fromcreators',
|
||||
'GET /api/inventions/v1/fulllineageowner',
|
||||
'GET /api/inventions/v1/personaldetails/{inventionId}',
|
||||
@@ -9390,44 +5004,29 @@ describe('openapi', () => {
|
||||
'GET /outfits/me',
|
||||
'GET /outfits/me/saved',
|
||||
'GET /voice/config',
|
||||
'POST /api/PlayerCheer/v1/SetSelectedCheer',
|
||||
'POST /api/PlayerCheer/v1/create',
|
||||
'POST /api/PlayerReporting/v1/deviceId',
|
||||
'POST /api/PlayerReporting/v1/hile',
|
||||
'POST /api/PlayerReporting/v1/instantKick',
|
||||
'POST /api/PlayerReporting/v1/moderationBlockDetails',
|
||||
'POST /api/PlayerReporting/v1/referee',
|
||||
'POST /api/PlayerReporting/v1/roomModKick',
|
||||
'POST /api/PlayerReporting/v3/create',
|
||||
'POST /api/PlayerReporting/v3/voteToKick',
|
||||
'POST /api/avatar/v1/lockeditems/bulk',
|
||||
'POST /api/avatar/v2/gifts/generate',
|
||||
'POST /api/chatreport/createChatReport',
|
||||
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||
'POST /api/customAvatarItems/v1',
|
||||
'POST /api/customAvatarItems/v1/bulk',
|
||||
'POST /api/customAvatarItems/v1/{id}/report',
|
||||
'POST /api/gamesight/event',
|
||||
'POST /api/images/v1/cheer',
|
||||
'POST /api/images/v4/uploadsaved',
|
||||
'POST /api/images/v5/cheered/bulk',
|
||||
'POST /api/inventions/v1/cheer',
|
||||
'POST /api/inventions/v1/report',
|
||||
'POST /api/inventions/v1/settags',
|
||||
'POST /api/inventions/v1/update',
|
||||
'POST /api/inventions/v1/updateprice',
|
||||
'POST /api/inventions/v2/delete',
|
||||
'POST /api/inventions/v2/update',
|
||||
'POST /api/inventions/v4/publish',
|
||||
'POST /api/inventions/v6/save',
|
||||
'POST /api/inventions/v9/save',
|
||||
'POST /api/messages/v1/friendOnlineStatus',
|
||||
'POST /api/messages/v1/sendMultiple',
|
||||
'POST /api/messages/v2/send',
|
||||
'POST /api/messages/v3/delete',
|
||||
'POST /api/playerReputation/v1/bulk',
|
||||
'POST /api/playerReputation/v2/bulk',
|
||||
'POST /api/playerevents/v1/bulk',
|
||||
'POST /api/playerevents/v1/bulkInvite',
|
||||
'POST /api/playerevents/v1/report',
|
||||
'POST /api/playerevents/v1/respond',
|
||||
@@ -9453,8 +5052,6 @@ describe('openapi', () => {
|
||||
'POST /api/v1/progression/bulk',
|
||||
'POST /outfits/bulk',
|
||||
'POST /statsigUserProperties',
|
||||
'PUT /api/customAvatarItems/v1/{id}',
|
||||
'PUT /api/inventions/v2/metadata',
|
||||
'PUT /api/playerevents/v2/{eventId}/accessibility',
|
||||
'PUT /api/playerevents/v2/{eventId}/description',
|
||||
'PUT /api/playerevents/v2/{eventId}/name',
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { intVar } from '@repo/hono-helpers'
|
||||
|
||||
import type { App } from './context'
|
||||
|
||||
/** Safe fallback when the deployment does not configure an API upload ceiling. */
|
||||
export const DEFAULT_MAX_API_UPLOAD_BYTES = 64 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Resolve the per-file ceiling shared by API-owned image uploads. A non-positive
|
||||
* setting does not disable the protection: public upload routes must always remain
|
||||
* bounded, so invalid values fall back to the safe default.
|
||||
*/
|
||||
export function maxApiUploadBytes(env: App['Bindings']): number {
|
||||
const configured = intVar(env.RECFLARE_MAX_API_UPLOAD_BYTES, DEFAULT_MAX_API_UPLOAD_BYTES)
|
||||
return configured > 0 ? configured : DEFAULT_MAX_API_UPLOAD_BYTES
|
||||
}
|
||||
|
||||
/** Whether a parsed multipart file is safe to copy into memory and persist to R2. */
|
||||
export function exceedsApiUploadLimit(file: File, limit: number): boolean {
|
||||
return file.size > limit
|
||||
}
|
||||
@@ -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
|
||||
@@ -382,8 +382,5 @@
|
||||
"CloudRegion": "us",
|
||||
"CrcCheckEnabled": false,
|
||||
"EnableServerTracingAfterDisconnect": false
|
||||
},
|
||||
"RoomKeyConfig": {
|
||||
"MaxKeysPerRoom": 10
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,7 +925,7 @@
|
||||
},
|
||||
{
|
||||
"Key": "Statsig.Enabled",
|
||||
"Value": "false",
|
||||
"Value": "true",
|
||||
"ActiveExperiments": null,
|
||||
"StartTime": null,
|
||||
"EndTime": null
|
||||
@@ -3992,7 +3992,7 @@
|
||||
{
|
||||
"Key": "Econ.Store.Categories",
|
||||
"Value": "{\"Categories\":[{\"Name\":\"Featured\",\"Depth\":0,\"Icon\":1,\"DiscoveryPageSource\":\"StoreFeatured\",\"ClickAction\":1},{\"Name\":\"Appearance\",\"Depth\":0,\"Icon\":2,\"DiscoveryPageSource\":\"StoreClothing\",\"ClickAction\":1},{\"Name\":\"Clothing\",\"Depth\":1,\"DiscoveryPageSource\":\"StoreAppearance\",\"ClickAction\":1},{\"Name\":\"Tops\",\"Depth\":2,\"ClickAction\":3,\"Filters\":512,\"StoreCarouselType\":8,\"SortCriteria\":1},{\"Name\":\"Bottoms\",\"Depth\":2,\"ClickAction\":3,\"Filters\":8388608,\"StoreCarouselType\":17,\"SortCriteria\":1},{\"Name\":\"Footwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":16777216,\"StoreCarouselType\":18,\"SortCriteria\":1},{\"Name\":\"Accessories\",\"Depth\":1,\"DiscoveryPageSource\":\"StoreAccessories\",\"ClickAction\":1},{\"Name\":\"Headwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":64,\"StoreCarouselType\":7,\"SortCriteria\":1},{\"Name\":\"Eyewear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":256,\"StoreCarouselType\":19,\"SortCriteria\":1},{\"Name\":\"Earwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":33554432,\"StoreCarouselType\":20,\"SortCriteria\":1},{\"Name\":\"Neckwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":67108864,\"StoreCarouselType\":21,\"SortCriteria\":1},{\"Name\":\"Shoulders & Back\",\"Depth\":2,\"ClickAction\":3,\"Filters\":134217728,\"StoreCarouselType\":22,\"SortCriteria\":1},{\"Name\":\"Waist\",\"Depth\":2,\"ClickAction\":3,\"Filters\":268435456,\"StoreCarouselType\":23,\"SortCriteria\":1},{\"Name\":\"Hands\",\"Depth\":2,\"ClickAction\":3,\"Filters\":1024,\"StoreCarouselType\":10,\"SortCriteria\":1},{\"Name\":\"Styling\",\"Depth\":1,\"DiscoveryPageSource\":\"StoreStyling\",\"ClickAction\":1},{\"Name\":\"Hairstyles\",\"Depth\":2,\"ClickAction\":3,\"Filters\":128,\"StoreCarouselType\":24,\"SortCriteria\":1},{\"Name\":\"Facial Hair\",\"Depth\":2,\"ClickAction\":3,\"Filters\":536870912,\"StoreCarouselType\":25,\"SortCriteria\":1},{\"Name\":\"Hair Dye\",\"Depth\":2,\"ClickAction\":3,\"Filters\":131072,\"StoreCarouselType\":6,\"SortCriteria\":1},{\"Name\":\"Equipment Skins\",\"Depth\":2,\"ClickAction\":3,\"Filters\":262144,\"StoreCarouselType\":5,\"SortCriteria\":1},{\"Name\":\"Inventions\",\"Depth\":0,\"Icon\":3,\"DiscoveryPageSource\":\"StoreInventions\",\"ClickAction\":1,\"Filters\":0,\"StoreCarouselType\":0},{\"Name\":\"Consumables\",\"Depth\":0,\"Icon\":4,\"DiscoveryPageSource\":\"StoreConsumables\",\"ClickAction\":1,\"Filters\":0,\"StoreCarouselType\":0}]}",
|
||||
"ActiveExperiments": "null",
|
||||
"ActiveExperiments": "revenue_2025q1_appearance_improve_conversion\r\nrevenue_2025q3_trending_by_payersegment",
|
||||
"StartTime": null,
|
||||
"EndTime": null
|
||||
},
|
||||
@@ -4614,7 +4614,7 @@
|
||||
},
|
||||
{
|
||||
"Key": "RoomieAI.System.Enabled",
|
||||
"Value": "False",
|
||||
"Value": "True",
|
||||
"ActiveExperiments": null,
|
||||
"StartTime": null,
|
||||
"EndTime": null
|
||||
@@ -6060,19 +6060,5 @@
|
||||
"ActiveExperiments": null,
|
||||
"StartTime": null,
|
||||
"EndTime": null
|
||||
},
|
||||
{
|
||||
"Key": "DataCollection.Enabled",
|
||||
"Value": "false",
|
||||
"ActiveExperiments": null,
|
||||
"StartTime": null,
|
||||
"EndTime": null
|
||||
},
|
||||
{
|
||||
"Key": "Backtrace.SampleRate",
|
||||
"Value": "0",
|
||||
"ActiveExperiments": null,
|
||||
"StartTime": null,
|
||||
"EndTime": null
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2716,19 +2716,5 @@
|
||||
"Value": "false",
|
||||
"StartTime": null,
|
||||
"EndTime": null
|
||||
},
|
||||
{
|
||||
"Key": "DataCollection.Enabled",
|
||||
"Value": "false",
|
||||
"ActiveExperiments": null,
|
||||
"StartTime": null,
|
||||
"EndTime": null
|
||||
},
|
||||
{
|
||||
"Key": "Backtrace.SampleRate",
|
||||
"Value": "0",
|
||||
"ActiveExperiments": null,
|
||||
"StartTime": null,
|
||||
"EndTime": null
|
||||
}
|
||||
]
|
||||
|
||||
@@ -25,13 +25,6 @@ export default defineConfig({
|
||||
// worker pushed (type + payload). GET the DO for the most recent one,
|
||||
// GET /all for the whole list (friend-graph changes notify both players),
|
||||
// DELETE to reset it between assertions.
|
||||
//
|
||||
// The ephemeral sends land in the same list, tagged `ephemeral`:
|
||||
// notifyPlayerEphemeral carries `playerId` like the durable send,
|
||||
// notifyPlayersEphemeral carries `playerIds` for the whole batch. Durable
|
||||
// and ephemeral differ in whether an offline recipient gets the frame
|
||||
// later, which is a thing worth asserting (a cheer's effect is broadcast to
|
||||
// a room this way, a vote-to-kick is put to each player in it).
|
||||
script: `
|
||||
import { DurableObject } from 'cloudflare:workers'
|
||||
export class NotificationsHub extends DurableObject {
|
||||
@@ -40,14 +33,6 @@ export default defineConfig({
|
||||
this.sent.push({ playerId, notificationType, data })
|
||||
return { delivered: 0, queued: true }
|
||||
}
|
||||
async notifyPlayerEphemeral(playerId, notificationType, data) {
|
||||
this.sent.push({ playerId, ephemeral: true, notificationType, data })
|
||||
return { delivered: 0 }
|
||||
}
|
||||
async notifyPlayersEphemeral(playerIds, notificationType, data) {
|
||||
this.sent.push({ playerIds, ephemeral: true, notificationType, data })
|
||||
return { delivered: 0 }
|
||||
}
|
||||
async broadcast() { return { delivered: 0 } }
|
||||
async fetch(request) {
|
||||
if (request.method === 'DELETE') {
|
||||
|
||||
@@ -66,15 +66,14 @@
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"enabled": false
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
"SENTRY_RELEASE": "unknown", // overridden during deployment
|
||||
"DOMAIN": "rec.example.com", // base domain; overridden during deployment
|
||||
// Per-file ceiling for saved images and custom-avatar-item files. Multipart parsing
|
||||
// happens first, but this prevents oversized files from being copied into another
|
||||
// ArrayBuffer and persisted to R2. Invalid/non-positive values fall back to 64 MiB.
|
||||
"RECFLARE_MAX_API_UPLOAD_BYTES": "67108864"
|
||||
"DOMAIN": "rec.example.com" // base domain; overridden during deployment
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
-- Expose the account's Rec Room Plus flag (`hasPlus` in the JSON blob) as an indexed
|
||||
-- generated column, so "every Plus member" is an index walk rather than a scan of every
|
||||
-- account's JSON. The reader is the operator's periodic token reload
|
||||
-- (`just reload-plus`, packages/tools/src/cmd/admin.cmd.ts), which credits the
|
||||
-- `balance` row of each Plus member in one statement.
|
||||
--
|
||||
-- The index is PARTIAL: it holds only the rows where the flag is set, so it is as small
|
||||
-- as the subscriber list and never grows with the account table. A query must say
|
||||
-- exactly `has_plus = 1` to use it. `json_extract` yields 1 for JSON true, 0 for false
|
||||
-- and NULL when the key is absent, so neither a revoked nor a never-granted account is
|
||||
-- in it. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts (and its mirror
|
||||
-- in apps/econ/src/avatar-db.ts).
|
||||
|
||||
ALTER TABLE account ADD COLUMN has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL;
|
||||
CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1;
|
||||
@@ -1,37 +0,0 @@
|
||||
-- Record the roles a linked identity held in its platform's community, for the one
|
||||
-- platform that has any: `PlatformType.Discord` (101), written by the website's benefits
|
||||
-- claim from the member record Discord itself served
|
||||
-- (`GET /users/@me/guilds/{guild}/member`). Steam and Meta links leave it NULL — there is
|
||||
-- no such thing there — so the column is nullable and has no default.
|
||||
--
|
||||
-- Until now the claim asked Discord for the member's roles, tested them against the
|
||||
-- configured qualifying list, and threw the answer away; all that survived was `hasPlus`,
|
||||
-- which records "held A qualifying role once" and not WHICH one. That is the question an
|
||||
-- operator has to answer by hand in Discord for every player who writes in — and the only
|
||||
-- way to tell a supporter from a booster from staff after the fact, or to see that a tier
|
||||
-- was granted under a role that has since been retired.
|
||||
--
|
||||
-- A JSON ARRAY of role id snowflakes (`["1077…001","1077…002"]`), not one id: a member
|
||||
-- holds as many roles as they hold, and the claim qualifies on ANY of them, so storing a
|
||||
-- single one would have to pick arbitrarily. Snowflakes stay STRINGS — one exceeds 2^53,
|
||||
-- so a JSON number would round and stop matching the real role. The list is the whole
|
||||
-- member record's roles, not just the qualifying ones: it is a snapshot of who they were
|
||||
-- in the guild, and filtering it to the config of the day would make it unreadable the
|
||||
-- moment the config changed.
|
||||
--
|
||||
-- It is a SNAPSHOT, taken at claim time and refreshed only when the same player claims
|
||||
-- again. Nothing sweeps it: reading a member's roles needs that member's own OAuth token
|
||||
-- (this design deliberately holds no bot token — see apps/www/src/discord.ts), so the
|
||||
-- server cannot re-read them unprompted. Read it as "the roles they held when they
|
||||
-- claimed", the same tense `hasPlus` carries, and never as "the roles they hold".
|
||||
--
|
||||
-- No index. Nothing queries BY role yet; when something does, it will want
|
||||
-- `json_each(role)` and an expression index built for that query, not a plain one over
|
||||
-- the array text, which could only serve an exact-whole-array match. Kept in sync with
|
||||
-- PLATFORM_SCHEMA_DDL in src/platform-db.ts.
|
||||
--
|
||||
-- Safe to run before or after the deploy that ships it: an added nullable column is
|
||||
-- invisible to the currently-deployed code, which names its columns explicitly and never
|
||||
-- SELECTs *.
|
||||
|
||||
ALTER TABLE platform_account ADD COLUMN role TEXT;
|
||||
@@ -21,7 +21,7 @@
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.13.5",
|
||||
"hono": "4.12.27",
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
@@ -33,6 +33,6 @@
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "26.0.1",
|
||||
"vitest": "4.1.9",
|
||||
"wrangler": "4.128.0"
|
||||
"wrangler": "4.105.0"
|
||||
}
|
||||
}
|
||||
|
||||
+71
-187
@@ -21,8 +21,6 @@ import {
|
||||
subRoomDataBlob,
|
||||
updateAccount,
|
||||
verifyPassword,
|
||||
withDatabase,
|
||||
writeAuditLog,
|
||||
} from '@repo/domain'
|
||||
import {
|
||||
intVar,
|
||||
@@ -49,7 +47,6 @@ import {
|
||||
PlatformIdsRequest,
|
||||
PlatformType,
|
||||
RestrictionDto,
|
||||
roleFilter,
|
||||
roleLookup,
|
||||
TokenRequest,
|
||||
TokenResponse,
|
||||
@@ -66,7 +63,7 @@ import { verifySteamTicket } from './steam-ticket'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { Account } from '@repo/domain'
|
||||
import type { App, Env } from './context'
|
||||
import type { App } from './context'
|
||||
import type { PlatformLink } from './platform-db'
|
||||
|
||||
/** OAuth scopes granted by `/connect/token`. */
|
||||
@@ -74,18 +71,19 @@ const TOKEN_SCOPE =
|
||||
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
|
||||
|
||||
/**
|
||||
* The `error_description` a SIGNUP is refused with when it shares an identity with a banned
|
||||
* account (see bans-db's linked arms). A fixed
|
||||
* sentence, because `www`'s shared auth-messages table keys on this exact string to put a
|
||||
* real sentence in front of a player — anything varying would fall through to the generic
|
||||
* "you could not be signed in". Keep the two in sync. Deliberately vague: the account
|
||||
* being refused may be an innocent housemate of a banned player, so telling them "this
|
||||
* account is banned" would be a lie, and naming the account we matched them to would hand
|
||||
* out somebody else's moderation record.
|
||||
*
|
||||
* Only `create_account` is refused this way. SIGNING IN is never refused for a ban, its own
|
||||
* or a linked one — see the token grant: the client needs a token to reach the block screen,
|
||||
* and `match` is what holds a blocked player to their dorm.
|
||||
* The `error_description` a banned account's grant is refused with. A fixed sentence,
|
||||
* never interpolated with the expiry, because `www`'s shared auth-messages table keys on
|
||||
* this exact string to put a real sentence in front of a player — anything varying would
|
||||
* fall through to the generic "you could not be signed in". Keep the two in sync.
|
||||
*/
|
||||
const BANNED_DESCRIPTION = 'this account is banned'
|
||||
|
||||
/**
|
||||
* The refusal when it is not THIS account that is banned but one it shares an identity
|
||||
* with (see bans-db's linked arms). Deliberately a different, vaguer sentence: the
|
||||
* account being refused may be an innocent housemate of a banned player, so telling them
|
||||
* "this account is banned" would be a lie, and naming the account we matched them to
|
||||
* would hand out somebody else's moderation record.
|
||||
*/
|
||||
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
|
||||
|
||||
@@ -205,51 +203,6 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/** Whether one account row holds a role. Absent/false both mean no role. */
|
||||
function holdsRole(account: Account, role: 'developer' | 'moderator'): boolean {
|
||||
return (role === 'developer' ? account.isDeveloper : account.isModerator) === true
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE-player lookup (`/role/{role}/{id}`): a BARE JSON boolean — the client reads the body
|
||||
* as a bool, not as an object — or an empty 404 for a player this server has no account for,
|
||||
* mirroring the reference API. An id that is not a number is answered as unknown (404) rather
|
||||
* than as a 400: `/role/developer/nonsense` is just an id that matches nothing.
|
||||
*/
|
||||
async function roleAnswer(c: Context<App>, role: 'developer' | 'moderator', id: string) {
|
||||
logger.info(`${role} role lookup`, { id })
|
||||
const accountId = Number.parseInt(id, 10)
|
||||
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
|
||||
if (!account) return c.body(null, 404)
|
||||
return c.json(holdsRole(account, role))
|
||||
}
|
||||
|
||||
/**
|
||||
* The BULK form (`/role/{role}?id=1&id=2`): WHICH of the players asked about hold the role, as
|
||||
* an array of ints. A different question from the single lookup and so a different shape —
|
||||
* never that route's boolean.
|
||||
*
|
||||
* A FILTER, so nothing here is an error: an id that names no account, or names one without the
|
||||
* role, is left out, and a query naming no ids answers `[]`. A 404 would be wrong — the
|
||||
* question "which of these are developers" has the answer "none of them", not "no such thing".
|
||||
*
|
||||
* Answered in the order the query asked, de-duplicated, so a caller can read the result
|
||||
* against its own list. One round trip however many ids are named: `getAccountsByIds` splits
|
||||
* the read across statements when it exceeds D1's bound-parameter limit.
|
||||
*/
|
||||
async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator') {
|
||||
// `queries` and not `query`: the ids arrive as a REPEATED parameter, and `query` would
|
||||
// collapse them to the first and silently answer about one player out of the set.
|
||||
const asked = c.req.queries('id') ?? []
|
||||
const ids = [...new Set(asked.map((v) => Number.parseInt(v, 10)).filter(Number.isInteger))]
|
||||
logger.info(`${role} role filter`, { asked: asked.length, ids: ids.length })
|
||||
if (ids.length === 0) return c.json([])
|
||||
|
||||
const accounts = await getAccountsByIds(c.env.DB, ids)
|
||||
const holders = new Set(accounts.filter((a) => holdsRole(a, role)).map((a) => a.accountId))
|
||||
return c.json(ids.filter((id) => holders.has(id)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The role names beyond `gameClient` for an account's token `role` claim. Base roles
|
||||
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
|
||||
@@ -655,25 +608,19 @@ const app = new Hono<App>()
|
||||
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
|
||||
'client reads it as a claim beside `role`, and it is absent for everyone else.',
|
||||
'',
|
||||
'**Bans.** A BLOCKED account still gets a token — every grant, including a refresh,',
|
||||
'and whether the ban is its own or reaches it through the evasion arms.',
|
||||
'A ban is a `report` row with `banned` set (the `api` worker owns that table); it',
|
||||
'lifts on its own when `ban_expires` passes, and never if that is null. The token',
|
||||
'is what lets the client reach `api`’s `/api/PlayerReporting/v1/moderationBlockDetails`',
|
||||
'and show the player the block screen that explains the ban; the ban itself is',
|
||||
'enforced by `match`, which refuses every matchmake but the caller’s own dorm, so a',
|
||||
'token gets them as far as that screen and no further.',
|
||||
'**Bans.** Once the grant has resolved an account, a BANNED account is refused a',
|
||||
'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a',
|
||||
'`report` row with `banned` set (the `api` worker owns that table); it lifts on its',
|
||||
'own when `ban_expires` passes, and never if that is null.',
|
||||
'',
|
||||
'Ban EVASION — an account sharing a PROVEN platform identity (a `platform_account`',
|
||||
'link) or an IP (`signupIp`/`lastLoginIp`, or the address this request came from)',
|
||||
'with a banned one — signs in the same way, and is stopped at matchmaking like any',
|
||||
'other blocked account. Those two arms are the operator’s `BAN_EVASION_MATCH` knob',
|
||||
'(`ip`, `platform`, or `off`).',
|
||||
'',
|
||||
'What IS refused here (`invalid_grant`) is a `create_account` carrying either link,',
|
||||
'refused BEFORE it mints anything: making a NEW account to evade with is a different',
|
||||
'act from signing into one. The description is deliberately vague — the signup',
|
||||
'refused may belong to a housemate of the banned player rather than to them.',
|
||||
'The refusal follows the player, not just the account: it also catches an account',
|
||||
'that shares a PROVEN platform identity (a `platform_account` link) or an IP',
|
||||
'(`signupIp`/`lastLoginIp`, or the address this request came from) with a banned',
|
||||
'one, and a `create_account` carrying either is refused BEFORE it mints anything.',
|
||||
'Those two arms are the operator’s `BAN_EVASION_MATCH` knob (`ip`, `platform`, or',
|
||||
'`off`); the ban on the account itself is always enforced. A linked match answers a',
|
||||
'deliberately vaguer description than a direct one — the account refused may belong',
|
||||
'to a housemate of the banned player rather than to them.',
|
||||
].join('\n'),
|
||||
requestBody: form(
|
||||
TokenRequest,
|
||||
@@ -686,7 +633,7 @@ const app = new Hono<App>()
|
||||
[
|
||||
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
|
||||
'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
|
||||
'or an account sharing a banned one’s device or network',
|
||||
'or a banned account',
|
||||
].join(' ')
|
||||
),
|
||||
500: json(
|
||||
@@ -842,9 +789,9 @@ const app = new Hono<App>()
|
||||
// via create_account or /account/me/changepassword.
|
||||
let accountId: string
|
||||
if (grantType === 'create_account') {
|
||||
// A banned player's next move is a new account, so the evasion arms are checked
|
||||
// BEFORE one is minted — against the only identity a signup has, the IP it came
|
||||
// from and the platform identity it just proved. Refusing after the fact (as the
|
||||
// A banned player's next move is a new account, so the ban is checked BEFORE
|
||||
// one is minted — against the only identity a signup has, the IP it came from
|
||||
// and the platform identity it just proved. Refusing after the fact (as the
|
||||
// shared check below would) still refuses the token, but leaves the account
|
||||
// row behind and burns a slot off both signup caps, so the evader gets to keep
|
||||
// making them.
|
||||
@@ -1040,36 +987,28 @@ const app = new Hono<App>()
|
||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||
}
|
||||
|
||||
// EVERY account gets its token, banned or not — a ban of its own, or a link to
|
||||
// somebody else's through the evasion arms. The client needs one to reach `api`'s
|
||||
// moderationBlockDetails, which is where a banned player is TOLD they are banned
|
||||
// (category, time left, "Rule violation"); refused here they would only ever see a
|
||||
// sign-in that fails, with nothing saying why. The ban is enforced by `match`, which
|
||||
// refuses every matchmake but the caller's own dorm, so a token gets them as far as
|
||||
// the block screen and no further.
|
||||
// A banned player gets no token — and with no token every other worker is shut to
|
||||
// them, so this is the outer wall of a ban; matchmaking's refusal is the inner
|
||||
// one, which still has to exist because a token issued before the ban stays valid
|
||||
// until it expires.
|
||||
//
|
||||
// That includes ban EVASION, which this used to refuse. An evader signing in lands
|
||||
// in their dorm and can enter no room — the same place the ban leaves them — and
|
||||
// refusing the token instead only ever produced a client stuck on a failed login.
|
||||
// `create_account` is still refused before it mints anything (above): that is making
|
||||
// a NEW account to evade with, which is a different act from signing into one.
|
||||
//
|
||||
// Logged either way, and the arm is logged with it: "banned" and "shares a network
|
||||
// with somebody banned" are very different things to be reading in a log.
|
||||
//
|
||||
// Deliberately AFTER the credential checks — a wrong password is still "invalid
|
||||
// account_id or password", so this can't be used to probe whether an account exists
|
||||
// or is banned without knowing it.
|
||||
// Checked once here, after the grant has resolved an account, so it covers every
|
||||
// grant: password, cached_login and a refresh_token redeemed by a client that has
|
||||
// been running since before the ban. Deliberately AFTER the credential checks —
|
||||
// a wrong password is still "invalid account_id or password", so this can't be
|
||||
// used to probe whether an account exists or is banned without knowing it.
|
||||
//
|
||||
// The request's own IP and proven identity are passed alongside the account, so a
|
||||
// ban also reaches an old, clean account logged into from the banned player's device
|
||||
// or network — the stored ips alone would only catch that on the SECOND login.
|
||||
// ban also reaches an old, clean account logged into from the banned player's
|
||||
// device or network — the stored ips alone would only catch that on the SECOND
|
||||
// login. create_account was already refused before it minted anything (above);
|
||||
// this still runs for it, so a signup that raced one is refused too.
|
||||
const ban = await resolveBan(c.env.DB, Number(accountId), {
|
||||
identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
|
||||
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
||||
})
|
||||
if (ban) {
|
||||
logger.info('token issued to a blocked account', {
|
||||
logger.info('token refused: player banned', {
|
||||
accountId,
|
||||
grantType,
|
||||
via: ban.via,
|
||||
@@ -1077,6 +1016,13 @@ const app = new Hono<App>()
|
||||
reportId: ban.ban.id,
|
||||
banExpires: ban.ban.ban_expires,
|
||||
})
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: ban.via === 'account' ? BANNED_DESCRIPTION : BLOCKED_DESCRIPTION,
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
|
||||
@@ -1094,9 +1040,8 @@ const app = new Hono<App>()
|
||||
|
||||
// Stamp the account's elevated roles into the token's `role` claim so the client
|
||||
// authorizes developer/moderator powers from the token itself (not just the
|
||||
// /role/* lookups), and its Plus flag into `rn.plus`. One read of the just-resolved
|
||||
// account serves both; they thus refresh on every login and every refresh_token
|
||||
// grant.
|
||||
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
|
||||
// every login and every refresh_token grant.
|
||||
const roleAccount = await getAccount(c.env.DB, Number(accountId))
|
||||
// A refresh grant posts no platform of its own, so the identity comes off the
|
||||
// account — the same read, and the only place the bound identity is authoritative.
|
||||
@@ -1111,70 +1056,12 @@ const app = new Hono<App>()
|
||||
jwtSecret,
|
||||
accountRoles(roleAccount),
|
||||
accountPrivileges(roleAccount),
|
||||
version,
|
||||
// Rec Room Plus, off the same account read as the roles above — `econ` decides the
|
||||
// CampusCard and the subscriber discount from this claim alone, so it never has to
|
||||
// load the account. It therefore refreshes on every login and every refresh_token
|
||||
// grant, and only then: a player who claims Plus on the website keeps a token that
|
||||
// says otherwise until they sign in again.
|
||||
roleAccount?.hasPlus === true
|
||||
version
|
||||
)
|
||||
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
||||
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
||||
const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId))
|
||||
|
||||
// A STAFF sign-in is an audit event in its own right. What these accounts then do is
|
||||
// recorded against them on the `audit_log` table (`notify` files every `/internal/*`
|
||||
// call there); this is the row saying the session those actions came from began — and
|
||||
// from what device, build and IP, which is the first thing anyone asks when an action
|
||||
// further down the log looks wrong. The flags themselves are the trigger, so an account
|
||||
// granted the role between logins starts being recorded the next time it signs in.
|
||||
//
|
||||
// A real sign-in only: `refresh_token` renews the SAME session every TOKEN_TTL_SECONDS,
|
||||
// and recording those would bury the logins under hourly repeats that say nothing new
|
||||
// about who came in or from where.
|
||||
//
|
||||
// Written after the token is minted, so the row means a login that actually happened,
|
||||
// and caught rather than awaited into the response: a failed audit insert must not turn
|
||||
// a staffer's successful sign-in into a 500. It leaves a loud line in the worker log
|
||||
// instead — the one thing a gap in an audit trail should never do quietly.
|
||||
const staffRoles =
|
||||
roleAccount && grantType !== 'refresh_token'
|
||||
? (['developer', 'moderator'] as const).filter((role) => holdsRole(roleAccount, role))
|
||||
: []
|
||||
if (staffRoles.length > 0) {
|
||||
try {
|
||||
await writeAuditLog(c.env.DB, {
|
||||
playerId: Number(accountId),
|
||||
action: 'staff_login',
|
||||
// The IP leads: it is the first thing anyone asks of a staff sign-in, and the one
|
||||
// field here that says WHERE the session came from rather than what it could do.
|
||||
// Empty outside the Cloudflare edge — recorded as the empty string it is rather
|
||||
// than omitted, so a row can't be read as "we didn't record one".
|
||||
//
|
||||
// Then: which powers the session carries, HOW they signed in, and the same
|
||||
// unverified-but-recorded device/build context the account row keeps — kept here
|
||||
// too so the row stands on its own months later, after the account's
|
||||
// `lastLoginIp` and device have been overwritten by every login since.
|
||||
data: {
|
||||
ip: clientIp,
|
||||
roles: staffRoles,
|
||||
grantType,
|
||||
username: roleAccount?.username ?? null,
|
||||
deviceId,
|
||||
deviceClass,
|
||||
version: version ?? null,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('could not write a staff_login audit log row', {
|
||||
accountId,
|
||||
grantType,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
access_token: accessToken,
|
||||
expires_in: TOKEN_TTL_SECONDS,
|
||||
@@ -1271,29 +1158,26 @@ const app = new Hono<App>()
|
||||
// is off by default and only an operator grants it (via `runx admin grant-developer`,
|
||||
// which sets the account's isDeveloper flag); it also rides in the token's `role`
|
||||
// claim (see accountRoles).
|
||||
.get('/role/developer/:id', describeRoute(roleLookup('developer')), (c) =>
|
||||
roleAnswer(c, 'developer', c.req.param('id'))
|
||||
)
|
||||
|
||||
// The BULK form — `/role/developer?id=2&id=3` answers WHICH of those players are developers,
|
||||
// as an array of ints. A route of its own rather than an optional `:id?` segment, and a
|
||||
// different answer shape from the single lookup above: both are kept because the client asks
|
||||
// both ways (the path form is the 2023 one).
|
||||
.get('/role/developer', describeRoute(roleFilter('developer')), (c) =>
|
||||
roleFilterAnswer(c, 'developer')
|
||||
)
|
||||
.get('/role/developer/:id', describeRoute(roleLookup('developer')), async (c) => {
|
||||
const { id } = c.req.param()
|
||||
logger.info('developer role lookup', { id })
|
||||
const accountId = Number.parseInt(id, 10)
|
||||
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
|
||||
if (!account) return c.body(null, 404)
|
||||
return c.json(account.isDeveloper === true)
|
||||
})
|
||||
|
||||
// Moderator role lookup, mirroring developer (bare boolean, 404 for unknown player).
|
||||
// Operator-granted only (via `runx admin grant-moderator`); the flag also rides in
|
||||
// the token's `role` claim.
|
||||
.get('/role/moderator/:id', describeRoute(roleLookup('moderator')), (c) =>
|
||||
roleAnswer(c, 'moderator', c.req.param('id'))
|
||||
)
|
||||
|
||||
// Moderator's bulk form, kept in step with developer's above.
|
||||
.get('/role/moderator', describeRoute(roleFilter('moderator')), (c) =>
|
||||
roleFilterAnswer(c, 'moderator')
|
||||
)
|
||||
.get('/role/moderator/:id', describeRoute(roleLookup('moderator')), async (c) => {
|
||||
const { id } = c.req.param()
|
||||
logger.info('moderator role lookup', { id })
|
||||
const accountId = Number.parseInt(id, 10)
|
||||
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
|
||||
if (!account) return c.body(null, 404)
|
||||
return c.json(account.isModerator === true)
|
||||
})
|
||||
|
||||
// @guess Oculus nonce. The client asks for this before a Meta login; the exact shape
|
||||
// it expects hasn't been observed, so this mints a fresh 64-char hex nonce (the length
|
||||
@@ -1352,4 +1236,4 @@ app.get(
|
||||
)
|
||||
)
|
||||
|
||||
export default withDatabase<Env>(app)
|
||||
export default app
|
||||
|
||||
+18
-50
@@ -1,8 +1,6 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { PlatformType } from '@repo/domain/src/enums'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
@@ -51,13 +49,24 @@ export function form(schema: z.ZodType, description: string): OpenAPIV3_1.Reques
|
||||
}
|
||||
|
||||
/**
|
||||
* PlatformType, the client's platform enum — the single source for the schema and
|
||||
* description below. It lives in `@repo/domain` rather than here because the link table
|
||||
* (`platform-db`) and the website's benefits claim both need the values, and neither has
|
||||
* any business importing this module's zod/hono-openapi dependencies. Re-exported so
|
||||
* `import { PlatformType } from './openapi'` keeps working alongside the schemas.
|
||||
* PlatformType, the client's platform enum. Declaration order is wire order, and is
|
||||
* the single source for the schema and description below. The `platform` form field
|
||||
* is posted as the integer; the token's `platform` claim carries the name.
|
||||
*/
|
||||
export { PlatformType } from '@repo/domain/src/enums'
|
||||
export const PlatformType = {
|
||||
All: -1,
|
||||
Steam: 0,
|
||||
Oculus: 1,
|
||||
PlayStation: 2,
|
||||
Xbox: 3,
|
||||
RecNet: 4,
|
||||
IOS: 5,
|
||||
GooglePlay: 6,
|
||||
Standalone: 7,
|
||||
Pico: 8,
|
||||
} as const
|
||||
|
||||
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
|
||||
|
||||
/**
|
||||
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified —
|
||||
@@ -239,9 +248,7 @@ export function roleLookup(role: 'developer' | 'moderator') {
|
||||
description:
|
||||
`Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
|
||||
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
|
||||
`in the access token's \`role\` claim, so the client rarely needs this route.\n\n` +
|
||||
`This is the ONE-player form. \`/role/${role}?id=\` is a different question with a ` +
|
||||
`different answer shape — which of several players hold the role, as an array.`,
|
||||
`in the access token's \`role\` claim, so the client rarely needs this route.`,
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
@@ -258,45 +265,6 @@ export function roleLookup(role: 'developer' | 'moderator') {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The BULK form: `/role/{role}?id=1&id=2` answers which of those players hold the role, as an
|
||||
* array of ints. Not the single lookup's boolean — a different question, so a different shape;
|
||||
* the two live side by side because the client asks both ways.
|
||||
*/
|
||||
export function roleFilter(role: 'developer' | 'moderator') {
|
||||
return {
|
||||
tags: ['Roles'],
|
||||
summary: `Which of these players have the ${role} role`,
|
||||
description:
|
||||
`The subset of the accounts named by \`?id=\` that hold the ${role} role, as an ARRAY ` +
|
||||
`OF INTS — not a boolean, and not an object. Ids are repeated (\`?id=1&id=2\`), not ` +
|
||||
`comma-joined.\n\n` +
|
||||
`It is a FILTER, so a player who does not hold the role is simply absent from the ` +
|
||||
`answer, as is an id matching no account — neither is an error, and a query naming no ` +
|
||||
`ids answers \`[]\`. Results keep the order the query asked in, and a repeated id ` +
|
||||
`yields at most one entry.\n\n` +
|
||||
`The role is off by default and granted only by an operator via ` +
|
||||
`\`runx admin grant-${role}\`.`,
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'query' as const,
|
||||
required: false,
|
||||
description:
|
||||
'An account id to test, repeated once per player. Ids that are non-numeric, unknown ' +
|
||||
'or lack the role are left out of the answer rather than erroring.',
|
||||
style: 'form' as const,
|
||||
explode: true,
|
||||
schema: { type: 'array' as const, items: { type: 'string' as const } },
|
||||
example: ['2', '42'],
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(z.int().array(), `The ids, of those asked for, that hold the ${role} role`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Bulk cached-login lookup form body: repeated `id=` fields. */
|
||||
export const PlatformIdsRequest = z.object({
|
||||
id: z.union([z.string(), z.array(z.string())]).describe('Repeated `id=` form fields'),
|
||||
|
||||
+11
-180
@@ -19,36 +19,15 @@
|
||||
* A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a
|
||||
* Meta-validated nonce). It is what turns "this platform user" into "may enter this
|
||||
* account with no password", so an unproven `platform_id` must never reach it.
|
||||
*
|
||||
* Not every link is a LOGIN, though. The table is the account's set of external
|
||||
* identities, and some are stored for what they entitle the player to rather than for
|
||||
* entry: `PlatformType.Discord` (101) is written by the website's benefits claim, from an
|
||||
* OAuth2 code exchange Discord itself vouched for. Nobody signs in with it —
|
||||
* `verifyPlatformProof` answers `unsupported` for anything but Steam and Meta, so a
|
||||
* `cached_login` naming platform 101 is refused — and the picker must not offer it
|
||||
* either. See {@link CACHED_LOGIN_PLATFORMS}, which is what keeps those two in step.
|
||||
*
|
||||
* That entitlement is also the one thing a link carries beyond the identity itself: the
|
||||
* `role` column (migration 0010) records the Discord roles the claim read, so what a
|
||||
* player was granted Plus FOR survives the claim instead of collapsing into a bare
|
||||
* `hasPlus`. The `www` worker's daily cron re-reads them through a bot token when one is
|
||||
* configured (apps/www/src/discord-roles.ts). See {@link PlatformLink.roles}.
|
||||
*/
|
||||
|
||||
import { PlatformType } from '@repo/domain/src/enums'
|
||||
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill, plus
|
||||
* 0010's `role` column — which is an ALTER on prod and a plain column here, since this
|
||||
* builds the table from nothing).
|
||||
*/
|
||||
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
|
||||
export const PLATFORM_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS platform_account (
|
||||
account_id INTEGER NOT NULL,
|
||||
platform INTEGER NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
linked_at TEXT NOT NULL,
|
||||
role TEXT,
|
||||
PRIMARY KEY (platform, platform_id, account_id)
|
||||
)`,
|
||||
// The picker's lookup: "which accounts does this identity open?". Covered by the
|
||||
@@ -85,38 +64,6 @@ export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (ac
|
||||
WHERE json_extract(data, '$.platformId') IS NOT NULL
|
||||
AND json_extract(data, '$.platformId') <> ''`
|
||||
|
||||
/**
|
||||
* The platforms a cached login can actually be redeemed for — the ones
|
||||
* `verifyPlatformProof` can prove, which is Steam (a Steam-signed ticket) and Meta/Oculus
|
||||
* (a Meta-validated nonce).
|
||||
*
|
||||
* This is the picker's filter, and it exists to keep a promise the picker's own API
|
||||
* documentation makes: "an entry here is always redeemable by a `cached_login` grant
|
||||
* (both read the same table)". Once the table began holding identities that are NOT
|
||||
* credentials — Discord, from the website's benefits claim — listing every row would have
|
||||
* broken that promise in two ways at once. The client would be offered an account it can
|
||||
* never log into (the grant refuses platform 101 outright), and, worse, the picker is
|
||||
* PUBLIC and unauthenticated: `GET /cachedlogin/forplatformid/101/<snowflake>` would have
|
||||
* told anyone which RecFlare account a given Discord user owns, and the bulk route would
|
||||
* have done it for a list of them at once. A Discord id is trivially readable by anyone in
|
||||
* a shared server, so that is a deanonymisation of every player who claimed benefits.
|
||||
*
|
||||
* Adding a platform here means asserting `verifyPlatformProof` can prove it. Filtering
|
||||
* happens in the two picker reads only — {@link isPlatformIdentityLinked},
|
||||
* {@link countAccountsForPlatformIdentity} and {@link getLinksForAccount} deliberately see
|
||||
* every link, because they answer "is this identity taken / whose is it", which is exactly
|
||||
* the question the benefits claim's once-only guard asks about a Discord id.
|
||||
*/
|
||||
export const CACHED_LOGIN_PLATFORMS: readonly number[] = [PlatformType.Steam, PlatformType.Oculus]
|
||||
|
||||
/**
|
||||
* `IN (…)` fragment for the allowlist, so the filter is applied by the query rather than
|
||||
* in JS. Placeholders are numbered from ?2 because the one caller binds the platform id
|
||||
* as ?1 — explicit indices rather than bare `?`, which SQLite would number by position
|
||||
* and quietly renumber the moment another parameter is added ahead of it.
|
||||
*/
|
||||
const CACHED_LOGIN_FILTER = `platform IN (${CACHED_LOGIN_PLATFORMS.map((_, i) => `?${i + 2}`).join(', ')})`
|
||||
|
||||
/** One account ↔ platform identity link. */
|
||||
export interface PlatformLink {
|
||||
accountId: number
|
||||
@@ -124,19 +71,6 @@ export interface PlatformLink {
|
||||
platformId: string
|
||||
/** ISO-8601 time the link was made. */
|
||||
linkedAt: string
|
||||
/**
|
||||
* The roles this identity held in its platform's community when the link was last
|
||||
* written — Discord role id snowflakes, kept as strings (one exceeds 2^53). Empty for
|
||||
* every platform that has no such thing, which is all of them but Discord.
|
||||
*
|
||||
* A SNAPSHOT, not a live read. The benefits claim writes it from the member record the
|
||||
* player's own OAuth token served, and the `www` worker's daily cron re-reads every
|
||||
* Discord link through a bot token when the operator has configured one
|
||||
* (apps/www/src/discord-roles.ts) — so with a bot it trails the guild by at most a day,
|
||||
* and without one it says what they held when they claimed. Stored as a JSON array in
|
||||
* the `role` column (migration 0010).
|
||||
*/
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
interface LinkRow {
|
||||
@@ -144,39 +78,10 @@ interface LinkRow {
|
||||
platform: number
|
||||
platformId: string
|
||||
linkedAt: string
|
||||
/** The `role` column: a JSON array of role ids, or NULL for a link that has none. */
|
||||
role: string | null
|
||||
}
|
||||
|
||||
const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS platformId,
|
||||
linked_at AS linkedAt, role FROM platform_account`
|
||||
|
||||
/**
|
||||
* A stored `role` array back into role ids. Tolerant by design — every failure mode is
|
||||
* "this link has no roles recorded", which is the truth for every Steam and Meta link and
|
||||
* the honest answer for a row written before 0010. A NULL, a value that isn't JSON, or a
|
||||
* JSON value that isn't an array of strings all land there rather than throwing: the roles
|
||||
* are a record, and no caller's correctness depends on them, so a bad one must not take
|
||||
* down the login picker that reads the same rows.
|
||||
*/
|
||||
const parseRoles = (raw: string | null): string[] => {
|
||||
if (raw === null || raw === '') return []
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.filter((r): r is string => typeof r === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** One row as the rest of the codebase sees it. */
|
||||
const toLink = (row: LinkRow): PlatformLink => ({
|
||||
accountId: row.accountId,
|
||||
platform: row.platform,
|
||||
platformId: row.platformId,
|
||||
linkedAt: row.linkedAt,
|
||||
roles: parseRoles(row.role),
|
||||
})
|
||||
linked_at AS linkedAt FROM platform_account`
|
||||
|
||||
/**
|
||||
* Link a verified platform identity to an account. Idempotent — re-logging in on the
|
||||
@@ -185,89 +90,24 @@ const toLink = (row: LinkRow): PlatformLink => ({
|
||||
*
|
||||
* Callers must pass an identity the platform itself proved. Nothing in here can tell
|
||||
* a verified id from a spoofed one.
|
||||
*
|
||||
* `roles` records what that identity held in its platform's community — Discord role ids,
|
||||
* from the benefits claim's member read. Omitting it (every login path: Steam and Meta
|
||||
* have no such thing) leaves the column NULL rather than writing an empty array, so
|
||||
* "never had roles" stays distinguishable in the table from "claimed holding none". An
|
||||
* EMPTY array is a real value and is stored as one.
|
||||
*
|
||||
* Unlike the rest of the row, roles are REFRESHED on a repeat link: they're a snapshot of
|
||||
* a membership that moves, and the later claim is the fresher reading. That is also why
|
||||
* the update is separate from the insert rather than an upsert — the insert's
|
||||
* `meta.changes` is what tells a new link from an existing one, and an
|
||||
* `ON CONFLICT DO UPDATE` reports a change either way, which would make every re-login
|
||||
* look like a first link to {@link linkLoginIdentity}'s primary-identity write. The second
|
||||
* statement only runs on the Discord path, and only when the row was already there.
|
||||
*/
|
||||
export async function linkPlatformIdentity(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string,
|
||||
roles?: readonly string[]
|
||||
): Promise<boolean> {
|
||||
if (platformId === '') return false
|
||||
const role = roles === undefined ? null : JSON.stringify([...roles])
|
||||
const res = await db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at, role)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)`
|
||||
)
|
||||
.bind(accountId, platform, platformId, new Date().toISOString(), role)
|
||||
.run()
|
||||
if (res.meta.changes > 0) return true
|
||||
|
||||
if (roles !== undefined) {
|
||||
await setPlatformLinkRoles(db, accountId, platform, platformId, roles)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite the roles recorded on an EXISTING link. The daily sweep's write
|
||||
* (apps/www/src/discord-roles.ts), and the second half of {@link linkPlatformIdentity}'s
|
||||
* re-claim. Returns whether a link matched: the sweep reads links and writes them back, so
|
||||
* a false here means the row went away in between, which is worth a log line and nothing
|
||||
* more.
|
||||
*/
|
||||
export async function setPlatformLinkRoles(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string,
|
||||
roles: readonly string[]
|
||||
platformId: string
|
||||
): Promise<boolean> {
|
||||
if (platformId === '') return false
|
||||
const res = await db
|
||||
.prepare(
|
||||
`UPDATE platform_account SET role = ?4
|
||||
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
|
||||
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
)
|
||||
.bind(accountId, platform, platformId, JSON.stringify([...roles]))
|
||||
.bind(accountId, platform, platformId, new Date().toISOString())
|
||||
.run()
|
||||
return res.meta.changes > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Every link on one platform, oldest first — the sweep's read, which walks all of them.
|
||||
*
|
||||
* Unfiltered by {@link CACHED_LOGIN_PLATFORMS} on purpose — its one caller asks about
|
||||
* Discord, the platform that filter exists to exclude. It is not a lookup by identity, so
|
||||
* the privacy argument for the filter (see {@link getLinksForPlatformId}) doesn't apply:
|
||||
* nothing here is reachable from a request.
|
||||
*/
|
||||
export async function getLinksForPlatform(
|
||||
db: D1Database,
|
||||
platform: number
|
||||
): Promise<PlatformLink[]> {
|
||||
const { results } = await db
|
||||
.prepare(`${SELECT_LINK} WHERE platform = ?1 ORDER BY linked_at, account_id`)
|
||||
.bind(platform)
|
||||
.all<LinkRow>()
|
||||
return results.map(toLink)
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts a platform identity opens — what the login-screen picker lists.
|
||||
* Ordered oldest link first so the list is stable between launches (D1 row order
|
||||
@@ -279,16 +119,13 @@ export async function getLinksForPlatformIdentity(
|
||||
platformId: string
|
||||
): Promise<PlatformLink[]> {
|
||||
if (platformId === '') return []
|
||||
// Asking about a platform nobody can log in from yields nothing at all, rather than a
|
||||
// list the grant would refuse — see CACHED_LOGIN_PLATFORMS.
|
||||
if (!CACHED_LOGIN_PLATFORMS.includes(platform)) return []
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
|
||||
)
|
||||
.bind(platform, platformId)
|
||||
.all<LinkRow>()
|
||||
return results.map(toLink)
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -301,17 +138,11 @@ export async function getLinksForPlatformId(
|
||||
platformId: string
|
||||
): Promise<PlatformLink[]> {
|
||||
if (platformId === '') return []
|
||||
// Matches on any platform a cached login can be redeemed for — but only those. This is
|
||||
// the route a bare id takes, so without the filter a Discord snowflake posted here
|
||||
// would resolve its account even though naming platform 101 explicitly would not.
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`${SELECT_LINK} WHERE platform_id = ?1 AND ${CACHED_LOGIN_FILTER}
|
||||
ORDER BY linked_at, account_id`
|
||||
)
|
||||
.bind(platformId, ...CACHED_LOGIN_PLATFORMS)
|
||||
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
|
||||
.bind(platformId)
|
||||
.all<LinkRow>()
|
||||
return results.map(toLink)
|
||||
return results
|
||||
}
|
||||
|
||||
/** Every platform identity linked to an account (a player's PC and headset, say). */
|
||||
@@ -323,7 +154,7 @@ export async function getLinksForAccount(
|
||||
.prepare(`${SELECT_LINK} WHERE account_id = ?1 ORDER BY linked_at, platform`)
|
||||
.bind(accountId)
|
||||
.all<LinkRow>()
|
||||
return results.map(toLink)
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,6 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
import '../../auth.app'
|
||||
|
||||
import {
|
||||
AUDIT_LOG_SCHEMA_DDL,
|
||||
GAME_VERSION,
|
||||
getAccountsByDeviceId,
|
||||
hashPassword,
|
||||
@@ -22,9 +21,7 @@ import {
|
||||
createReport,
|
||||
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||
} from '../../../../api/src/reports-db'
|
||||
import { PlatformType } from '../../openapi'
|
||||
import {
|
||||
countAccountsForPlatformIdentity,
|
||||
getLinksForAccount,
|
||||
linkPlatformIdentity,
|
||||
PLATFORM_BACKFILL_SQL,
|
||||
@@ -71,9 +68,6 @@ beforeAll(async () => {
|
||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
|
||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// The audit trail a staff sign-in is recorded on. Owned by the `api` worker, whose
|
||||
// migrations don't run here, so build it from the mirrored DDL.
|
||||
for (const stmt of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Seed the accounts the credential-login tests use, each with LOGIN_PASSWORD set.
|
||||
const hash = await hashPassword(LOGIN_PASSWORD)
|
||||
@@ -93,8 +87,8 @@ beforeAll(async () => {
|
||||
IsDorm: false,
|
||||
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
|
||||
})
|
||||
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
|
||||
// the token grant reads it for the evasion arms.
|
||||
// Report table (owned by the api worker) — a banned account is refused a token, and
|
||||
// a ban is a report row with `banned` set.
|
||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
@@ -427,106 +421,6 @@ describe('auth worker routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// A Discord link is an external identity, not a credential — `www`'s benefits claim
|
||||
// writes one so a claimed Discord user can't claim again on a second account. It must
|
||||
// stay invisible to BOTH picker routes, for two independent reasons:
|
||||
//
|
||||
// - Every entry the picker lists is promised to be redeemable by a `cached_login`
|
||||
// grant, and that grant refuses platform 101 outright (verifyPlatformProof answers
|
||||
// `unsupported`). Listing one offers the client an account it can never log into.
|
||||
// - These routes are PUBLIC and unauthenticated, and a Discord snowflake is readable by
|
||||
// anyone sharing a server with its owner. Answering here would turn the login picker
|
||||
// into a lookup from "Discord user" to "their RecFlare account", for every player who
|
||||
// ever claimed benefits.
|
||||
//
|
||||
// The bare-id route is checked too, and it is the easier one to miss: it matches on ANY
|
||||
// platform, so it would resolve the snowflake even though naming 101 explicitly did not.
|
||||
test('never lists a Discord link in either cached-login picker', async () => {
|
||||
const discordId = '308994132968210433'
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 31399, username: 'DiscordClaimer', hasPlus: true }))
|
||||
.run()
|
||||
await linkPlatformIdentity(env.DB, 31399, PlatformType.Discord, discordId)
|
||||
|
||||
// Named explicitly…
|
||||
const named = await exports.default.fetch(
|
||||
`${ORIGIN}/cachedlogin/forplatformid/${PlatformType.Discord}/${discordId}`
|
||||
)
|
||||
expect(named.status).toBe(200)
|
||||
expect(await named.json()).toEqual([])
|
||||
|
||||
// …and via the bare id, which matches across platforms.
|
||||
const bare = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/any/${discordId}`)
|
||||
expect(await bare.json()).toEqual([])
|
||||
|
||||
// …and through the bulk friends-resolution route, which takes bare ids only.
|
||||
const bulk = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformids`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ id: discordId }).toString(),
|
||||
})
|
||||
expect(await bulk.json()).toEqual([])
|
||||
|
||||
// The link is still really there — this is a filtered READ, not a failed write.
|
||||
await expect(
|
||||
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, discordId)
|
||||
).resolves.toBe(1)
|
||||
})
|
||||
|
||||
// The `role` column (migration 0010): what the benefits claim read about a member, kept
|
||||
// so "why does this account have Plus" is answerable later. Exercised through the helper
|
||||
// rather than the claim route, which runs through discord.com.
|
||||
test('records the roles a claimed Discord identity held, and refreshes them on a re-claim', async () => {
|
||||
const discordId = '308994132968210434'
|
||||
const supporter = '1077000000000000001'
|
||||
const booster = '1077000000000000002'
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 31400, username: 'RoleClaimer', hasPlus: true }))
|
||||
.run()
|
||||
|
||||
await linkPlatformIdentity(env.DB, 31400, PlatformType.Discord, discordId, [supporter])
|
||||
const first = await getLinksForAccount(env.DB, 31400)
|
||||
// Snowflakes come back as STRINGS — parsed as numbers they'd round past 2^53 and stop
|
||||
// matching the real role.
|
||||
expect(first.map((l) => l.roles)).toEqual([[supporter]])
|
||||
|
||||
// A re-claim is the same row (INSERT OR IGNORE, so `linkedAt` is the first claim's)
|
||||
// with a FRESH role list: a membership moves, and this reading is the later one.
|
||||
await linkPlatformIdentity(env.DB, 31400, PlatformType.Discord, discordId, [supporter, booster])
|
||||
const second = await getLinksForAccount(env.DB, 31400)
|
||||
expect(second).toHaveLength(1)
|
||||
expect(second[0]?.roles).toEqual([supporter, booster])
|
||||
expect(second[0]?.linkedAt).toBe(first[0]?.linkedAt)
|
||||
|
||||
// Losing every role still stores the reading. Empty is a fact about the claim, not an
|
||||
// absence of one — and it is what distinguishes this from a Steam link, below.
|
||||
await linkPlatformIdentity(env.DB, 31400, PlatformType.Discord, discordId, [])
|
||||
expect((await getLinksForAccount(env.DB, 31400))[0]?.roles).toEqual([])
|
||||
const stored = await env.DB.prepare(
|
||||
'SELECT role FROM platform_account WHERE account_id = 31400'
|
||||
).first<{ role: string | null }>()
|
||||
expect(stored?.role).toBe('[]')
|
||||
})
|
||||
|
||||
// Every login path omits the argument, because Steam and Meta have no such thing. The
|
||||
// column stays NULL there rather than becoming `[]`, so the table can still tell "never
|
||||
// had roles" from "claimed holding none".
|
||||
test('leaves the role column null for a link with no roles to record', async () => {
|
||||
const steamId = '76561197962463299'
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 31401, username: 'NoRoles' }))
|
||||
.run()
|
||||
await linkPlatformIdentity(env.DB, 31401, PlatformType.Steam, steamId)
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
'SELECT role FROM platform_account WHERE account_id = 31401'
|
||||
).first<{ role: string | null }>()
|
||||
expect(row?.role).toBeNull()
|
||||
// A NULL reads back as no roles rather than throwing — which is also how a row written
|
||||
// before 0010 reads.
|
||||
expect((await getLinksForAccount(env.DB, 31401))[0]?.roles).toEqual([])
|
||||
})
|
||||
|
||||
// The 20250424.01 build POSTs the picker lookup with a platform-attestation form body
|
||||
// instead of GETting it. Nothing reads that body yet, so both methods must answer the
|
||||
// same list — otherwise the newer client's login screen comes up empty.
|
||||
@@ -683,9 +577,6 @@ describe('auth worker routes', () => {
|
||||
expect(payload.role).not.toContain('junior')
|
||||
// No privileges to carry, so the claim is absent rather than an empty array.
|
||||
expect(payload['rn.privilege']).toBeUndefined()
|
||||
// Same for Plus: omitted rather than `false`, so a non-subscriber's token is
|
||||
// byte-for-byte what it was before `rn.plus` existed.
|
||||
expect(payload['rn.plus']).toBeUndefined()
|
||||
expect(payload.scope).toContain('rn.api')
|
||||
})
|
||||
|
||||
@@ -724,64 +615,6 @@ describe('auth worker routes', () => {
|
||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
|
||||
})
|
||||
|
||||
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
|
||||
// the website's Discord benefits claim sets. `econ` decides the CampusCard and the
|
||||
// subscriber discount from this claim ALONE and never reads the account, so if this
|
||||
// stops being stamped, Plus silently stops existing for everyone.
|
||||
//
|
||||
// It is a CLAIM, not a scope: `scope` is a fixed list the client parses, and this is
|
||||
// ours. And it is not a role — the `developer` role does not confer Plus.
|
||||
test('POST /connect/token stamps rn.plus for a hasPlus account', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 93,
|
||||
username: 'PlusPlayer',
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
hasPlus: true,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
const payload = await tokenFor(`account_id=93&password=${LOGIN_PASSWORD}`)
|
||||
expect(payload['rn.plus']).toBe(true)
|
||||
expect(payload.scope).not.toContain('rn.plus')
|
||||
// Plus is not an elevated role, and does not come with one.
|
||||
expect(payload.role).not.toContain('developer')
|
||||
})
|
||||
|
||||
// The flag is read at LOGIN, so signing in again is what activates it — the website's
|
||||
// claim page and `runx admin grant-plus` both say so, and this is the mechanism behind it.
|
||||
//
|
||||
// This also pins that `hasPlus` stands ALONE: the flag is set here by raw SQL, exactly as
|
||||
// `runx admin grant-plus` sets it, with no Discord app configured, no OAuth exchange and
|
||||
// no `platform_account` link anywhere. An operator must be able to grant Plus outright.
|
||||
test('rn.plus refreshes on the next login after hasPlus is set, with no Discord link', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 94,
|
||||
username: 'LateClaimer',
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
})
|
||||
)
|
||||
.run()
|
||||
// Before claiming: no Plus.
|
||||
expect((await tokenFor(`account_id=94&password=${LOGIN_PASSWORD}`))['rn.plus']).toBeUndefined()
|
||||
|
||||
// The website's claim writes the flag…
|
||||
await env.DB.prepare(
|
||||
"UPDATE account SET data = json_set(data, '$.hasPlus', json('true')) WHERE account_id = 94"
|
||||
).run()
|
||||
|
||||
// …and the NEXT token carries it. The one already in the player's hands does not,
|
||||
// which is exactly why they have to sign in again.
|
||||
expect((await tokenFor(`account_id=94&password=${LOGIN_PASSWORD}`))['rn.plus']).toBe(true)
|
||||
|
||||
// Nothing linked a Discord identity to this account, and Plus does not care.
|
||||
const links = await getLinksForAccount(env.DB, 94)
|
||||
expect(links.filter((l) => l.platform === PlatformType.Discord)).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /connect/token stamps the junior role for an isJunior account', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
@@ -922,74 +755,6 @@ describe('auth worker routes', () => {
|
||||
expect(row!.data).not.toContain('2.0')
|
||||
})
|
||||
|
||||
// The grant the live client actually logs in with, and the one path whose device
|
||||
// recording had no test — `create_account` and the password grant were both pinned,
|
||||
// cached_login only happened to work. The body is the shape the 2025 client posts (see
|
||||
// the capture the device id below comes from): device_id alongside the platform
|
||||
// attestation, on every sign-in.
|
||||
//
|
||||
// Nothing verifies a device id — the client picks it — so it is recorded and never
|
||||
// authorized on. It is stored for ban EVASION to read later: an account logging in from
|
||||
// the same install as a banned one. See `getAccountsByDeviceId`.
|
||||
test('POST /connect/token cached_login stores the login device on the account', async () => {
|
||||
const metaId = '27061366730201234'
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 5151,
|
||||
username: 'CachedDevicePlayer',
|
||||
platform: 1,
|
||||
platformId: metaId,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await linkPlatformIdentity(env.DB, 5151, 1, metaId)
|
||||
|
||||
const deviceId = '69640e6ae1b54ae5b0ca8eeb4a8872ec6cf8fd88'
|
||||
const res = await metaLogin(
|
||||
`grant_type=cached_login&account_id=5151&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}` +
|
||||
`&device_id=${deviceId}&device_class=2&isInitialLogin=true&locale=en`,
|
||||
true
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
|
||||
.bind(5151)
|
||||
.first<{ data: string }>()
|
||||
const account = JSON.parse(row!.data) as { deviceId: string; deviceClass: number }
|
||||
expect(account.deviceId).toBe(deviceId)
|
||||
expect(account.deviceClass).toBe(2)
|
||||
// And findable by it, which is the whole reason it is kept.
|
||||
expect((await getAccountsByDeviceId(env.DB, deviceId)).map((a) => a.accountId)).toContain(5151)
|
||||
})
|
||||
|
||||
// A refresh is NOT a login: the client redeems a stored token and re-attests nothing,
|
||||
// so there is no device to believe and the grant records none. Pinned because the
|
||||
// alternative is worse than it looks — trusting a device_id posted alongside a refresh
|
||||
// would let a client move an account's recorded device without ever proving anything,
|
||||
// which is exactly the signal ban evasion is meant to read.
|
||||
test('POST /connect/token refresh_token leaves the recorded device alone', async () => {
|
||||
const first = await postToken(
|
||||
`grant_type=password&username=Player77&password=${LOGIN_PASSWORD}` +
|
||||
'&device_id=dev-77-real&device_class=2'
|
||||
)
|
||||
expect(first.status).toBe(200)
|
||||
|
||||
const refreshed = await postToken(
|
||||
`grant_type=refresh_token&refresh_token=${first.json.refresh_token as string}` +
|
||||
'&device_id=dev-77-spoofed&device_class=9'
|
||||
)
|
||||
expect(refreshed.status).toBe(200)
|
||||
|
||||
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
|
||||
.bind(77)
|
||||
.first<{ data: string }>()
|
||||
const account = JSON.parse(row!.data) as { deviceId: string; deviceClass: number }
|
||||
expect(account.deviceId).toBe('dev-77-real')
|
||||
expect(account.deviceClass).toBe(2)
|
||||
})
|
||||
|
||||
test('POST /connect/token refreshes the stored device on a credential login', async () => {
|
||||
// Account 42 was seeded with no device; a later login records the one it came from.
|
||||
const res = await postToken(
|
||||
@@ -1454,80 +1219,6 @@ describe('auth worker routes', () => {
|
||||
expect((await exports.default.fetch(`${ORIGIN}/role/moderator/99999`)).status).toBe(404)
|
||||
})
|
||||
|
||||
// `/role/{role}?id=` is a BULK FILTER — which of these players hold the role, as an array
|
||||
// of ints — not the single lookup's boolean. Different question, different shape.
|
||||
test('GET /role/developer?id= answers which of the players are developers', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
|
||||
.run()
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4244, username: 'DevPlayer2', isDeveloper: true }))
|
||||
.run()
|
||||
|
||||
// INTS, not strings, and not a boolean.
|
||||
const one = await exports.default.fetch(`${ORIGIN}/role/developer?id=4242`)
|
||||
expect(one.status).toBe(200)
|
||||
expect(await one.json()).toEqual([4242])
|
||||
|
||||
// The subset that holds the role: 42 exists without the flag and 99999 not at all, and
|
||||
// neither is an error — a filter answers "none of them", never 404.
|
||||
const many = await exports.default.fetch(
|
||||
`${ORIGIN}/role/developer?id=42&id=4242&id=99999&id=4244`
|
||||
)
|
||||
expect(many.status).toBe(200)
|
||||
expect(await many.json()).toEqual([4242, 4244])
|
||||
|
||||
// Ordered as asked, so a caller can read the answer against its own list.
|
||||
const reversed = await exports.default.fetch(`${ORIGIN}/role/developer?id=4244&id=4242`)
|
||||
expect(await reversed.json()).toEqual([4244, 4242])
|
||||
|
||||
// A repeated id yields at most one entry.
|
||||
const repeated = await exports.default.fetch(`${ORIGIN}/role/developer?id=4242&id=4242`)
|
||||
expect(await repeated.json()).toEqual([4242])
|
||||
})
|
||||
|
||||
test('GET /role/developer?id= answers [] rather than erroring', async () => {
|
||||
// No ids, only unknown ids, only un-flagged ids, and a non-numeric id all answer an empty
|
||||
// array with a 200 — nothing about this question is a 400 or a 404.
|
||||
for (const query of ['', '?id=99999', '?id=42', '?id=nonsense', '?id=']) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/developer${query}`)
|
||||
expect(res.status, query).toBe(200)
|
||||
expect(await res.json(), query).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
// The repeated parameter is the whole point: reading only the first would silently answer
|
||||
// about one player out of the set.
|
||||
test('GET /role/developer?id= reads every repeated id, not just the first', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
|
||||
.run()
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/developer?id=42&id=4242`)
|
||||
expect(await res.json()).toEqual([4242])
|
||||
})
|
||||
|
||||
test('GET /role/moderator?id= filters on the moderator flag alone', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4343, username: 'ModPlayer', isModerator: true }))
|
||||
.run()
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
|
||||
.run()
|
||||
// The two roles are independent flags: a developer is not implicitly a moderator.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/moderator?id=4242&id=4343&id=42`)
|
||||
expect(await res.json()).toEqual([4343])
|
||||
})
|
||||
|
||||
// The single lookup keeps its own shape — the bulk form must not have changed it.
|
||||
test('GET /role/developer/:id is still a bare boolean, not an array', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
|
||||
.run()
|
||||
expect(await (await exports.default.fetch(`${ORIGIN}/role/developer/4242`)).json()).toBe(true)
|
||||
expect(await (await exports.default.fetch(`${ORIGIN}/role/developer/42`)).json()).toBe(false)
|
||||
expect((await exports.default.fetch(`${ORIGIN}/role/developer/99999`)).status).toBe(404)
|
||||
})
|
||||
|
||||
test('unknown path returns 404', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||
expect(res.status).toBe(404)
|
||||
@@ -1558,9 +1249,7 @@ describe('auth worker routes', () => {
|
||||
'GET /eac/challenge',
|
||||
'GET /oculus/nonce',
|
||||
'GET /privileges/me/restrictions',
|
||||
'GET /role/developer',
|
||||
'GET /role/developer/{id}',
|
||||
'GET /role/moderator',
|
||||
'GET /role/moderator/{id}',
|
||||
'POST /account/me/changepassword',
|
||||
'POST /cachedlogin/forplatformid/{platform}/{id}',
|
||||
@@ -1638,35 +1327,38 @@ describe('CORS', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// A banned account is still issued a token: the game client needs one to reach the api
|
||||
// worker's moderationBlockDetails, which is where the player is shown WHY they are
|
||||
// blocked. The ban is a `report` row with `banned` set (the api worker owns that table)
|
||||
// and is enforced by matchmaking, which refuses every matchmake for a banned player — so
|
||||
// the token gets them to the block screen and no further.
|
||||
// A banned account is refused a token at all — the outer wall of a ban, since with no
|
||||
// token every other worker is shut to it. The ban is a `report` row with `banned` set
|
||||
// (the api worker owns that table); matchmaking enforces the same ban on tokens issued
|
||||
// before it was handed down.
|
||||
describe('banned accounts', () => {
|
||||
test('POST /connect/token issues a token to a banned account', async () => {
|
||||
test('POST /connect/token refuses a password grant from a banned account', async () => {
|
||||
await seedAccount(6101, 'BannedPlayer')
|
||||
await banAccount(6101)
|
||||
|
||||
const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(decodePayload(res.json.access_token as string).sub).toBe('6101')
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error).toBe('invalid_grant')
|
||||
// The exact sentence www's shared auth-messages table keys on to put a real
|
||||
// message in front of the player — changing it silently downgrades that to the
|
||||
// generic "you could not be signed in".
|
||||
expect(res.json.error_description).toBe('this account is banned')
|
||||
})
|
||||
|
||||
test('POST /connect/token issues a token to a banned account logging in by username', async () => {
|
||||
test('POST /connect/token refuses a username login from a banned account', async () => {
|
||||
await seedAccount(6102, 'BannedByName')
|
||||
await banAccount(6102)
|
||||
|
||||
const res = await postToken(
|
||||
`grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(decodePayload(res.json.access_token as string).sub).toBe('6102')
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toBe('this account is banned')
|
||||
})
|
||||
|
||||
// A client that was already signed in when the ban landed refreshes as normal — its
|
||||
// next matchmake is what refuses it, and moderationBlockDetails says why.
|
||||
test('POST /connect/token refreshes a banned account’s session', async () => {
|
||||
// A client that was already signed in when the ban landed still holds a valid refresh
|
||||
// token; redeeming it must not renew the session.
|
||||
test('POST /connect/token refuses to refresh a banned account’s session', async () => {
|
||||
await seedAccount(6103, 'BannedLater')
|
||||
const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`)
|
||||
expect(login.status).toBe(200)
|
||||
@@ -1676,12 +1368,13 @@ describe('banned accounts', () => {
|
||||
const refreshed = await postToken(
|
||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
|
||||
)
|
||||
expect(refreshed.status).toBe(200)
|
||||
expect(decodePayload(refreshed.json.access_token as string).sub).toBe('6103')
|
||||
expect(refreshed.status).toBe(400)
|
||||
expect(refreshed.json.error_description).toBe('this account is banned')
|
||||
})
|
||||
|
||||
// A ban does not loosen the credential check: a wrong password on a banned account is
|
||||
// the ordinary bad-credential refusal.
|
||||
// The ban check runs AFTER the credential check, so a wrong password on a banned
|
||||
// account still answers the ordinary bad-credential refusal — it can't be used to
|
||||
// find out whether an account exists or is banned without knowing its password.
|
||||
test('a wrong password on a banned account is still a credential refusal', async () => {
|
||||
await seedAccount(6104, 'BannedWrongPw')
|
||||
await banAccount(6104)
|
||||
@@ -1691,13 +1384,23 @@ describe('banned accounts', () => {
|
||||
expect(res.json.error_description).toBe('invalid account_id or password')
|
||||
})
|
||||
|
||||
test('a ban that has not expired yet still issues a token', async () => {
|
||||
// A timed ban lifts itself when its expiry passes; nothing clears the flag.
|
||||
test('an expired ban lets the account sign in again', async () => {
|
||||
await seedAccount(6105, 'ServedTime')
|
||||
await banAccount(6105, '2020-01-01T00:00:00.000Z')
|
||||
|
||||
const res = await postToken(`account_id=6105&password=${LOGIN_PASSWORD}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(decodePayload(res.json.access_token as string).sub).toBe('6105')
|
||||
})
|
||||
|
||||
test('a ban that has not expired yet still refuses the login', async () => {
|
||||
await seedAccount(6106, 'StillServing')
|
||||
await banAccount(6106, new Date(Date.now() + 3_600_000).toISOString())
|
||||
|
||||
const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(decodePayload(res.json.access_token as string).sub).toBe('6106')
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toBe('this account is banned')
|
||||
})
|
||||
|
||||
// A report is not a ban until a moderator converts it.
|
||||
@@ -1720,11 +1423,9 @@ describe('banned accounts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The ban follows the player past the account it was written on. SIGNING IN is not where
|
||||
// that is enforced: a linked account gets its token like any other, and `match` holds it to
|
||||
// its own dorm — refusing the token only ever left the client stuck on a failed login. What
|
||||
// IS refused here is a SIGNUP carrying either link, before it mints anything: making a new
|
||||
// account to evade with is a different act from signing into one. See the api worker's
|
||||
// The ban follows the player past the account it was written on: a login from an account
|
||||
// that shares a proven platform identity or an IP with a banned one is refused, and a
|
||||
// signup carrying either is refused before it mints anything. See the api worker's
|
||||
// bans-db.ts for the arms and the BAN_EVASION_MATCH knob.
|
||||
describe('ban evasion at the token endpoint', () => {
|
||||
/** Seed a loginable account carrying the IPs it signed up / last logged in from. */
|
||||
@@ -1744,38 +1445,38 @@ describe('ban evasion at the token endpoint', () => {
|
||||
const login = (id: number, ip?: string) =>
|
||||
postToken(`account_id=${id}&password=${LOGIN_PASSWORD}`, ip)
|
||||
|
||||
test('an account sharing a banned account’s platform identity still signs in', async () => {
|
||||
test('an account sharing a banned account’s platform identity cannot log in', async () => {
|
||||
await account(6301, 'EvaderOne')
|
||||
await linkPlatformIdentity(env.DB, 6301, 0, 'steam-tokenevader')
|
||||
await banAccount(6301)
|
||||
await account(6302, 'EvaderTwo')
|
||||
await linkPlatformIdentity(env.DB, 6302, 0, 'steam-tokenevader')
|
||||
|
||||
// A token, like anyone else: the client has to load in before it can be told anything,
|
||||
// and `match` is what keeps this account out of every room but its dorm.
|
||||
const res = await login(6302)
|
||||
expect(res.status).toBe(200)
|
||||
expect(decodePayload(res.json.access_token as string).sub).toBe('6302')
|
||||
expect(res.status).toBe(400)
|
||||
// A vaguer sentence than a direct ban: this account may belong to somebody else.
|
||||
expect(res.json.error_description).toBe('this device or network is blocked')
|
||||
})
|
||||
|
||||
test('an account sharing a banned account’s IP still signs in', async () => {
|
||||
test('an account sharing a banned account’s IP cannot log in', async () => {
|
||||
await account(6303, 'SameHouseBanned', { signupIp: '203.0.113.30' })
|
||||
await banAccount(6303)
|
||||
await account(6304, 'SameHouseClean', { signupIp: '203.0.113.30' })
|
||||
|
||||
expect((await login(6304)).status).toBe(200)
|
||||
const res = await login(6304)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toBe('this device or network is blocked')
|
||||
})
|
||||
|
||||
// The address the request arrives from counts, so a SIGNUP from the banned network is
|
||||
// caught on the first attempt rather than the second.
|
||||
test('the request’s own IP is matched at signup even when the account has none stored', async () => {
|
||||
// The address the request arrives from counts, so an account that never logged in
|
||||
// from the banned network before is caught on the first attempt rather than the second.
|
||||
test('the request’s own IP is matched even when the account has none stored', async () => {
|
||||
await account(6305, 'BannedAtHome', { signupIp: '203.0.113.31' })
|
||||
await banAccount(6305)
|
||||
await account(6306, 'CleanElsewhere')
|
||||
|
||||
expect((await postToken('grant_type=create_account', '203.0.113.31')).status).toBe(400)
|
||||
// Signing in from that same network is not refused — only minting a new account is.
|
||||
expect((await login(6306, '203.0.113.31')).status).toBe(200)
|
||||
expect((await login(6306, '203.0.113.31')).status).toBe(400)
|
||||
// The same account from any other network signs in normally.
|
||||
expect((await login(6306, '198.51.100.31')).status).toBe(200)
|
||||
})
|
||||
|
||||
@@ -1804,9 +1505,8 @@ describe('ban evasion at the token endpoint', () => {
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
// The knob an operator reaches for when the IP arm locks out real players. It governs the
|
||||
// linked arms wherever they are applied — at signup here, and at every matchmake in `match`.
|
||||
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the platform one', async () => {
|
||||
// The knob an operator reaches for when the IP arm locks out real players.
|
||||
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the direct ban', async () => {
|
||||
const original = env.BAN_EVASION_MATCH
|
||||
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
|
||||
await linkPlatformIdentity(env.DB, 6320, 0, 'steam-knobevader')
|
||||
@@ -1817,141 +1517,19 @@ describe('ban evasion at the token endpoint', () => {
|
||||
|
||||
try {
|
||||
env.BAN_EVASION_MATCH = 'platform'
|
||||
// Signup from the banned IP is open again, since that arm is off...
|
||||
expect((await login(6321)).status).toBe(200)
|
||||
expect((await login(6322)).status).toBe(400)
|
||||
// And signup from that network is open again.
|
||||
expect((await postToken('grant_type=create_account', '203.0.113.50')).status).toBe(200)
|
||||
// ...while the platform arm still refuses one from the banned headset.
|
||||
expect(
|
||||
(await postToken('grant_type=create_account&platform=0&platform_id=steam-knobevader'))
|
||||
.status
|
||||
).toBe(400)
|
||||
|
||||
// Signing in is not what the knob gates: every one of these gets a token whatever it
|
||||
// says — the housemate, the evader, and the banned account itself.
|
||||
for (const arms of ['platform', 'off']) {
|
||||
env.BAN_EVASION_MATCH = arms
|
||||
for (const id of [6320, 6321, 6322]) {
|
||||
expect((await login(id)).status, `${arms}/${id}`).toBe(200)
|
||||
}
|
||||
}
|
||||
env.BAN_EVASION_MATCH = 'off'
|
||||
expect((await login(6322)).status).toBe(200)
|
||||
// The banned account itself is refused whatever the knob says.
|
||||
const banned = await login(6320)
|
||||
expect(banned.status).toBe(400)
|
||||
expect(banned.json.error_description).toBe('this account is banned')
|
||||
} finally {
|
||||
env.BAN_EVASION_MATCH = original
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// A staff sign-in is recorded on the `audit_log` table — the same table `notify` files
|
||||
// every `/internal/*` call on. What those accounts then do is attributed to them; this is
|
||||
// the row that says the session it came from began, and from what device, build and IP.
|
||||
describe('staff login audit', () => {
|
||||
interface AuditRow {
|
||||
player_id: number
|
||||
action: string
|
||||
data: string | null
|
||||
date: string
|
||||
}
|
||||
|
||||
const staffLogins = async (accountId: number): Promise<AuditRow[]> => {
|
||||
const { results } = await env.DB.prepare(
|
||||
`SELECT player_id, action, data, date FROM audit_log
|
||||
WHERE player_id = ?1 AND action = 'staff_login' ORDER BY audit_log_id`
|
||||
)
|
||||
.bind(accountId)
|
||||
.all<AuditRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/** Seed an account with the given flags and a known password. */
|
||||
const seedAccount = async (accountId: number, data: Record<string, unknown>) =>
|
||||
env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId,
|
||||
username: `Staff${accountId}`,
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
...data,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
test('a login by an isModerator account is recorded, with the context of the sign-in', async () => {
|
||||
await seedAccount(9601, { username: 'ModPlayer', isModerator: true })
|
||||
const res = await postToken(
|
||||
`grant_type=password&account_id=9601&password=${LOGIN_PASSWORD}` +
|
||||
'&device_id=dev-mod&device_class=2&ver=20230414',
|
||||
'203.0.113.9'
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const rows = await staffLogins(9601)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(Number.isNaN(Date.parse(rows[0].date))).toBe(false)
|
||||
const data = JSON.parse(rows[0].data!) as Record<string, unknown>
|
||||
expect(data).toMatchObject({
|
||||
ip: '203.0.113.9',
|
||||
roles: ['moderator'],
|
||||
grantType: 'password',
|
||||
username: 'ModPlayer',
|
||||
deviceId: 'dev-mod',
|
||||
deviceClass: 2,
|
||||
version: '20230414',
|
||||
})
|
||||
// The IP LEADS the payload: it is the first thing anyone asks of a staff sign-in, and
|
||||
// the reader of a raw row should not have to hunt for it.
|
||||
expect(Object.keys(data)[0]).toBe('ip')
|
||||
})
|
||||
|
||||
// Both flags on one account record both — the row says which powers the session carries,
|
||||
// not merely that it carries some.
|
||||
test('an account holding both flags records both roles', async () => {
|
||||
await seedAccount(9602, { isDeveloper: true, isModerator: true })
|
||||
expect((await postToken(`account_id=9602&password=${LOGIN_PASSWORD}`)).status).toBe(200)
|
||||
|
||||
const rows = await staffLogins(9602)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect((JSON.parse(rows[0].data!) as { roles: string[] }).roles).toEqual([
|
||||
'developer',
|
||||
'moderator',
|
||||
])
|
||||
})
|
||||
|
||||
// The flags are the whole trigger. An ordinary player signing in is not an audit event,
|
||||
// and recording every login would bury the ones that are.
|
||||
test('an ordinary login records nothing', async () => {
|
||||
await seedAccount(9603, {})
|
||||
expect((await postToken(`account_id=9603&password=${LOGIN_PASSWORD}`)).status).toBe(200)
|
||||
expect(await staffLogins(9603)).toHaveLength(0)
|
||||
})
|
||||
|
||||
// A refresh renews the SAME session every TOKEN_TTL_SECONDS. Recording those would bury
|
||||
// the actual sign-ins under hourly repeats saying nothing new about who came in or from
|
||||
// where — so the login is recorded once, and the refreshes after it are not.
|
||||
test('a refresh_token grant is not a login and is not recorded', async () => {
|
||||
await seedAccount(9604, { isDeveloper: true })
|
||||
const first = await postToken(`account_id=9604&password=${LOGIN_PASSWORD}`)
|
||||
expect(first.status).toBe(200)
|
||||
expect(await staffLogins(9604)).toHaveLength(1)
|
||||
|
||||
const refreshed = await postToken(
|
||||
`grant_type=refresh_token&refresh_token=${first.json.refresh_token as string}`
|
||||
)
|
||||
expect(refreshed.status).toBe(200)
|
||||
expect(await staffLogins(9604)).toHaveLength(1)
|
||||
})
|
||||
|
||||
// The flag is read at LOGIN, exactly as `rn.plus` and the token's `role` claim are, so
|
||||
// granting the role starts the trail at their next sign-in rather than retroactively.
|
||||
test('an account granted the role starts being recorded on its next login', async () => {
|
||||
await seedAccount(9605, {})
|
||||
expect((await postToken(`account_id=9605&password=${LOGIN_PASSWORD}`)).status).toBe(200)
|
||||
expect(await staffLogins(9605)).toHaveLength(0)
|
||||
|
||||
await env.DB.prepare(
|
||||
`UPDATE account SET data = json_set(data, '$.isModerator', json('true')) WHERE account_id = ?1`
|
||||
)
|
||||
.bind(9605)
|
||||
.run()
|
||||
|
||||
expect((await postToken(`account_id=9605&password=${LOGIN_PASSWORD}`)).status).toBe(200)
|
||||
expect(await staffLogins(9605)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"hono": "4.13.5",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -25,6 +25,6 @@
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "26.0.1",
|
||||
"vitest": "4.1.9",
|
||||
"wrangler": "4.128.0"
|
||||
"wrangler": "4.105.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"enabled": false
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
|
||||
+2
-2
@@ -15,8 +15,8 @@ route of its own, bundled rather than an asset, because its file is named differ
|
||||
its path.
|
||||
|
||||
Objects are keyed by prefix — `sigs/` (anti-cheat signatures), `room/` (saved room
|
||||
scenes, and room images by their bare `ImageName`), `invention/` (invention data), `data/`
|
||||
(generic client uploads), `avatar/` (custom avatar item assetbundles) — and served as `application/octet-stream`; the worker never interprets what it hands back.
|
||||
scenes, and room images by their bare `ImageName`), `invention/` (invention data) — and
|
||||
served as `application/octet-stream`; the worker never interprets what it hands back.
|
||||
Reads are unauthenticated: a caller needs the exact key, which only comes from an
|
||||
authenticated call to another worker.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.13.5",
|
||||
"hono": "4.12.27",
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
@@ -31,6 +31,6 @@
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "26.0.1",
|
||||
"vitest": "4.1.9",
|
||||
"wrangler": "4.128.0"
|
||||
"wrangler": "4.105.0"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-28
@@ -329,30 +329,6 @@ const app = new Hono<App>()
|
||||
(c) => serveAsset(c, `data/${c.req.param('id')}`)
|
||||
)
|
||||
|
||||
// Custom avatar item assetbundles by name. A first-party custom avatar item (see the
|
||||
// `api` worker's `custom_avatar_item`) is rendered from its `CurrentSaves`, each of
|
||||
// which names a built Unity assetbundle by bare filename (`UnityAsset`, e.g.
|
||||
// `anx442dm1a79kp9n4kugkbgd0.assetbundle`) that the client fetches from `/avatar/`.
|
||||
// Streamed from R2 under `avatar/`. The rest of the path is matched so a foldered
|
||||
// upload would resolve the same way as the other blob prefixes.
|
||||
.get(
|
||||
'/avatar/:asset{.+}',
|
||||
describeRoute({
|
||||
tags: ['Assets'],
|
||||
summary: 'Serve a custom avatar item assetbundle',
|
||||
description: [
|
||||
'Streams the object stored under `avatar/<asset>` — the built Unity assetbundle the',
|
||||
'client downloads to render a first-party custom avatar item on one body type. The',
|
||||
'name comes from a save’s `UnityAsset` (or `UnityAsset2`) in the item’s `CurrentSaves`',
|
||||
'(see the `api` worker), a bare filename such as `anx442dm1a79kp9n4kugkbgd0.assetbundle`.',
|
||||
'The worker does not interpret the bytes.',
|
||||
].join(' '),
|
||||
parameters: [keyParam('asset', 'The assetbundle filename.', true), ...CONDITIONAL_HEADERS],
|
||||
responses: assetResponses('The assetbundle'),
|
||||
}),
|
||||
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
|
||||
)
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
app.get(
|
||||
@@ -367,13 +343,11 @@ app.get(
|
||||
description: [
|
||||
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
|
||||
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
|
||||
'signatures, saved room scenes, invention data, generic client uploads and custom',
|
||||
'avatar item assetbundles — out of',
|
||||
'signatures, saved room scenes, invention data and generic client uploads — out of',
|
||||
'the shared `recflare-cdn` R2 bucket, plus the JSON config files the client reads',
|
||||
'from `/config/`.',
|
||||
'',
|
||||
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`, `avatar/`) and',
|
||||
'served as',
|
||||
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
|
||||
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
|
||||
'are unauthenticated — a caller needs the exact key, which only comes from an',
|
||||
'authenticated call to another worker.',
|
||||
|
||||
@@ -271,21 +271,6 @@ describe('cdn endpoints', () => {
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /avatar/:asset streams the custom avatar item assetbundle from R2', async () => {
|
||||
// A bare filename — what a first-party item's `CurrentSaves[].UnityAsset` names.
|
||||
const name = 'anx442dm1a79kp9n4kugkbgd0.assetbundle'
|
||||
await env.CDN_ASSETS.put(`avatar/${name}`, new Uint8Array([7, 8, 9]))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/avatar/${name}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('application/octet-stream')
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([7, 8, 9]))
|
||||
})
|
||||
|
||||
test('GET /avatar/:asset 404s when the assetbundle is absent', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/avatar/missing.assetbundle`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -308,7 +293,6 @@ describe('cdn endpoints', () => {
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'GET /',
|
||||
'GET /avatar/{asset}',
|
||||
'GET /config/LoadingScreenTipData',
|
||||
'GET /config/{name}',
|
||||
'GET /data/{id}',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 1,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
@@ -34,7 +34,7 @@
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 1,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
@@ -48,7 +48,7 @@
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 1,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
@@ -139,4 +139,4 @@
|
||||
"PlatformMask": 239,
|
||||
"CreatedAt": "2019-02-28T18:21:25Z"
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -52,7 +52,10 @@
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"enabled": false
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Give a thread its kind. `chat_thread_type` is the client's ChatThreadType enum
|
||||
-- (0 Player · 1 Club · 2 Party), which 0002 left off because every thread this server
|
||||
-- served was a plain player conversation and src/thread-db.ts answered a constant 0.
|
||||
-- Party threads (`POST /thread/party`) are type 2, so the value now varies per row and
|
||||
-- has to be stored. Generated from src/thread-db.ts (THREAD_SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- Defaulted 0 (Player), which is what every existing row is: the column is backfilled by
|
||||
-- the default, not by an UPDATE.
|
||||
--
|
||||
-- No index: it is read alongside the thread row that is already being fetched by primary
|
||||
-- key or by the membership join, never selected on.
|
||||
ALTER TABLE message_thread ADD COLUMN chat_thread_type INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -17,12 +17,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@2toad/profanity": "3.3.0",
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.13.5",
|
||||
"hono": "4.12.27",
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
@@ -34,6 +33,6 @@
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "26.0.1",
|
||||
"vitest": "4.1.9",
|
||||
"wrangler": "4.128.0"
|
||||
"wrangler": "4.105.0"
|
||||
}
|
||||
}
|
||||
|
||||
+21
-323
@@ -2,7 +2,6 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withDatabase } from '@repo/domain'
|
||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
ChatResult,
|
||||
ChatThreadDto,
|
||||
ChatThreadWithMessagesDto,
|
||||
CreatePartyChatResponse,
|
||||
CreateThreadRequest,
|
||||
CreateThreadResponse,
|
||||
FavoriteThreadRequest,
|
||||
@@ -25,8 +23,8 @@ import {
|
||||
json,
|
||||
messageCountParam,
|
||||
NOT_A_MEMBER_RESPONSE,
|
||||
PartyChatThread,
|
||||
PartyInviteSettings,
|
||||
PartyThread,
|
||||
RenameThreadRequest,
|
||||
SendMessageRequest,
|
||||
SendMessageResponse,
|
||||
@@ -38,16 +36,11 @@ import {
|
||||
} from './openapi'
|
||||
import {
|
||||
addThreadMember,
|
||||
ChatThreadType,
|
||||
createThread,
|
||||
getOrCreateThreadWithMembers,
|
||||
getPartyThreadForPlayer,
|
||||
getThreadForPlayer,
|
||||
getThreadMemberIds,
|
||||
getThreadMeta,
|
||||
getThreadsForPlayer,
|
||||
isThreadMember,
|
||||
joinedChatContents,
|
||||
leftChatContents,
|
||||
markThreadRead,
|
||||
postMessage,
|
||||
@@ -61,7 +54,6 @@ import {
|
||||
import type { Context } from 'hono'
|
||||
import type { App, Env } from './context'
|
||||
import type { ChatMessage } from './message-db'
|
||||
import type { ChatThread } from './thread-db'
|
||||
|
||||
/**
|
||||
* Resolve the account id from a Bearer token. Returns `null` when the header is
|
||||
@@ -115,25 +107,6 @@ const CHAT_PLAYER_ALREADY_ON_THREAD = 4
|
||||
*/
|
||||
const PARTY_INVITE_LIFETIME_MINUTES = 60
|
||||
|
||||
/**
|
||||
* Whether a party is still open to newcomers — `GET /thread/party` joins the caller only
|
||||
* inside this window, measured from the thread's `created_at`.
|
||||
*
|
||||
* It is the invite lifetime above, deliberately the same number rather than a second one:
|
||||
* a player joins a party by holding its id in `LatestPartyChat`, which is what an invite
|
||||
* puts there, so the join is the redemption of that invite and can't outlive it. A party
|
||||
* older than the window still belongs to the people already on it — this gates JOINING,
|
||||
* not reading, so nobody's own party expires out from under them.
|
||||
*
|
||||
* Fails CLOSED on a `created_at` that won't parse: no timestamp, no join. Nothing writes
|
||||
* one that can't, and the alternative is an unbounded join window on a corrupt row.
|
||||
*/
|
||||
function isPartyJoinable(createdAt: string, now = Date.now()): boolean {
|
||||
const opened = Date.parse(createdAt)
|
||||
if (Number.isNaN(opened)) return false
|
||||
return now - opened <= PARTY_INVITE_LIFETIME_MINUTES * 60_000
|
||||
}
|
||||
|
||||
/**
|
||||
* Who may start a chat with a player — the client's `ChatPrivacy` enum, served numerically
|
||||
* like every other enum on this build. `Friends` is what a fresh account reports, and what
|
||||
@@ -161,20 +134,6 @@ const CHAT_PRIVACY_NAMES = ['Friends', 'Favorites', 'NoOne'] as const
|
||||
const DM_PRIVACY_KEY = 'directMessagePrivacySetting'
|
||||
const GROUP_PRIVACY_KEY = 'groupChatPrivacySetting'
|
||||
|
||||
/**
|
||||
* Where a player's CURRENT party lives: the thread id of the party they most recently
|
||||
* opened, written by `POST /thread/party` and read back by the GET on the same path.
|
||||
*
|
||||
* It is a player setting rather than a column because the party is a property of the
|
||||
* PLAYER, not of the thread — "which party am I in" has one answer per person, and a
|
||||
* player is in exactly one at a time. The settings bag is already read and written per
|
||||
* player here, the same way the two privacy settings are.
|
||||
*
|
||||
* Nothing clears it: a party the player has left, or one that no longer exists, is
|
||||
* filtered out on the read instead, which also covers an id written by something else.
|
||||
*/
|
||||
const LATEST_PARTY_CHAT_KEY = 'LatestPartyChat'
|
||||
|
||||
/**
|
||||
* A `ChatPrivacy` out of whatever was stored or posted — the member name as the client
|
||||
* sends it (case-insensitively), or the ordinal as the GET serves it, since a value that
|
||||
@@ -241,87 +200,16 @@ async function writeChatPrivacy(
|
||||
accountId: number,
|
||||
settings: Partial<Record<typeof DM_PRIVACY_KEY | typeof GROUP_PRIVACY_KEY, ChatPrivacyValue>>
|
||||
): Promise<void> {
|
||||
const patch: Record<string, string> = {}
|
||||
const merged: Record<string, string> = { ...(await getPlayerSettings(env, accountId)) }
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (value !== undefined) patch[key] = CHAT_PRIVACY_NAMES[value]
|
||||
if (value !== undefined) merged[key] = CHAT_PRIVACY_NAMES[value]
|
||||
}
|
||||
await mergePlayerSettings(env, accountId, patch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge keys into the player's settings map, the way the `playersettings` worker's own PUT
|
||||
* does. Never a whole-map write: the bag holds every setting the player has (OOBE state,
|
||||
* tutorial mask, …) and storing one key on its own would wipe the rest. Values are strings,
|
||||
* which is what that worker stores and what its GET serves back.
|
||||
*/
|
||||
async function mergePlayerSettings(
|
||||
env: Env,
|
||||
accountId: number,
|
||||
patch: Record<string, string>
|
||||
): Promise<void> {
|
||||
const merged: Record<string, string> = { ...(await getPlayerSettings(env, accountId)), ...patch }
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(`player:${accountId}`, JSON.stringify(merged))
|
||||
}
|
||||
|
||||
/**
|
||||
* The thread id in the player's `LatestPartyChat` setting, or null when they have no party
|
||||
* — nothing stored, or something stored that isn't a positive integer (the bag's values are
|
||||
* strings, and this one could have been written by hand).
|
||||
*/
|
||||
async function readLatestPartyChatId(env: Env, accountId: number): Promise<number | null> {
|
||||
const stored = (await getPlayerSettings(env, accountId)) ?? {}
|
||||
const id = Number.parseInt(String(stored[LATEST_PARTY_CHAT_KEY] ?? ''), 10)
|
||||
return Number.isNaN(id) || id <= 0 ? null : id
|
||||
}
|
||||
|
||||
/** The hub is a single global Durable Object instance, as every worker addresses it. */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/**
|
||||
* Push a thread's most recent message to everyone on it. This is how a NEW thread
|
||||
* announces itself.
|
||||
*
|
||||
* The client has exactly two chat channels, `ChatMessageReceived` and `PlayerLeftChat`,
|
||||
* and both carry a MESSAGE: there is no "a thread was opened" or "you were added" frame to
|
||||
* send. So a conversation someone gains access to stays invisible on their client until a
|
||||
* message arrives on it — which is why `/thread/withmembers` used to go unnoticed until the
|
||||
* sender typed something, the thread having been created with only its "started a chat"
|
||||
* notice and that notice never having left the database.
|
||||
*
|
||||
* Sending the notice fixes that without inventing anything: the message being pushed is one
|
||||
* that genuinely exists on the thread. A no-op for a thread with nothing in it.
|
||||
*/
|
||||
async function pushThreadLatestMessage(c: Context<App>, chatThreadId: number): Promise<void> {
|
||||
const [latest] = await getThreadMessages(c.env.DB, chatThreadId, { limit: 1 })
|
||||
if (latest === undefined) return
|
||||
await pushChatMessage(c, latest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce that a player is now on a thread: post the `Player <@U…> joined` notice and push
|
||||
* it to the whole thread.
|
||||
*
|
||||
* The exact shape of the leave route's goodbye, and for the same reasons. Everyone is told,
|
||||
* not just the player who joined: a roster change is the thread's business — the others
|
||||
* need to know who they are talking to — and there is no roster channel to say it on, so
|
||||
* the notice is the message AND the signal. The new member is a member by the time this
|
||||
* runs, so the same push is what puts the conversation on their screen.
|
||||
*
|
||||
* Call it AFTER the membership row exists, or the joiner is left out of the fan-out.
|
||||
*/
|
||||
async function announceJoin(
|
||||
c: Context<App>,
|
||||
chatThreadId: number,
|
||||
playerId: number
|
||||
): Promise<void> {
|
||||
const notice = await postMessage(c.env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
contents: joinedChatContents(playerId),
|
||||
})
|
||||
await pushChatMessage(c, notice)
|
||||
}
|
||||
|
||||
/**
|
||||
* Push ChatMessageReceived to everyone in the thread once a message lands, so the
|
||||
* conversation updates live instead of on the next poll.
|
||||
@@ -415,33 +303,6 @@ function toSentChatMessage(message: ChatMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A thread in the PascalCase shape `POST /thread/party` answers — the client's
|
||||
* CreatePartyChat formatter, which is its own and reads none of the camelCase keys the
|
||||
* thread payloads carry. Ten wire keys; see {@link PartyChatThread} for why the CLR
|
||||
* type's other three never appear.
|
||||
*
|
||||
* `Messages` and `LatestMessage` are both present here, unlike the camelCase pair which
|
||||
* carries one or the other, and `ChatThreadName` goes out NULL when unnamed rather than
|
||||
* as the empty string the camelCase projections must send.
|
||||
*/
|
||||
function toPartyChatThread(thread: ChatThread, messages: ChatMessage[]) {
|
||||
return {
|
||||
ChatThreadId: thread.chatThreadId,
|
||||
ChatThreadType: thread.chatThreadType,
|
||||
LastReadMessageId: thread.lastReadMessageId,
|
||||
Messages: messages.map(toSentChatMessage),
|
||||
LatestMessage: thread.latestMessage === null ? null : toSentChatMessage(thread.latestMessage),
|
||||
PlayerIds: thread.playerIds,
|
||||
// This formatter takes the null; only the camelCase projections have to send ''.
|
||||
ChatThreadName: thread.chatThreadName === '' ? null : thread.chatThreadName,
|
||||
SnoozedUntil: thread.snoozedUntil,
|
||||
IsFavorited: thread.isFavorited,
|
||||
// No thread on this table carries a club — club chat lives in the `clubs` worker.
|
||||
ClubId: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a thread that already exists — every message after the one that
|
||||
* opened the conversation. `/thread/18` is what the client posts; `/thread/18/message` is
|
||||
@@ -746,12 +607,7 @@ const app = new Hono<App>()
|
||||
const members = [...new Set([id, ...(await memberIds(c))])]
|
||||
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
|
||||
|
||||
const { chatThreadId, created } = await getOrCreateThreadWithMembers(c.env.DB, members, id)
|
||||
// A thread nobody has been told about is a thread nobody sees. Push its opening
|
||||
// notice the moment it exists, rather than leaving the conversation to surface on
|
||||
// whatever message happens to follow — there may not be one: this route is also
|
||||
// called with an empty `messageContents`.
|
||||
if (created) await pushThreadLatestMessage(c, chatThreadId)
|
||||
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
|
||||
|
||||
const contents = (await formField(c, 'messageContents'))?.trim()
|
||||
const posted =
|
||||
@@ -807,74 +663,21 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// The caller's current party (`/thread/party?maxCount=1&mode=0`) — the client's
|
||||
// GetPartyChat.
|
||||
//
|
||||
// TWO paths, cheapest first:
|
||||
//
|
||||
// 1. ALREADY IN A PARTY — one D1 query (`getPartyThreadForPlayer`: the membership join,
|
||||
// filtered to party threads, newest first) answers it outright. This is the common
|
||||
// case, every read after the first, and it touches the settings KV not at all.
|
||||
// 2. NOT IN ONE YET — only then is the caller's `LatestPartyChat` player setting read.
|
||||
// The POST below writes that key for the player who OPENED the party; the client
|
||||
// writes it (through `playersettings`) for a player pulled into someone else's. Such
|
||||
// a player holds the key but no membership row — nothing has added them — so a
|
||||
// membership-only read answered them "no party", which is the bug the join fixes.
|
||||
// The read puts them on the thread and then serves it: `GET /thread/party` is how
|
||||
// you enter a party, not merely how you look at one.
|
||||
//
|
||||
// Reaching path 2 means the caller is in NO party, so they cannot already be a member of
|
||||
// the thread the key names — which is why the join here needs no membership check of its
|
||||
// own, and why the age gate below can be unconditional.
|
||||
//
|
||||
// On path 2 the thread is checked to exist, to be a party, and to be YOUNGER THAN THE
|
||||
// INVITE LIFETIME before anyone is added to it, so a key naming a DM, a thread that is
|
||||
// gone, or an hours-old party can't produce a membership row in it.
|
||||
//
|
||||
// The age check gates JOINING only — path 1 never reaches it, so a party keeps being
|
||||
// served to the players already on it however old it is, rather than going dark on them
|
||||
// after an hour. `LatestPartyChat` is the caller's own player setting, which the client
|
||||
// can PUT to anything through the `playersettings` worker, so the key IS the invite here
|
||||
// and the window is what keeps it from being a permanent one. Tighten further (an invite
|
||||
// the party actually issued) if parties ever need to be closed outright.
|
||||
//
|
||||
// SAME PATH, DIFFERENT BODY from the POST: this one is the BARE thread, with no
|
||||
// `{ ChatThread, ChatResult }` wrapper around it. Same ten-key PascalCase projection
|
||||
// inside, so the two share `toPartyChatThread` — but nothing else, which is why these
|
||||
// are two handlers rather than one verb-agnostic one.
|
||||
//
|
||||
// No party answers `{}`: the client parses that as a thread with everything at its
|
||||
// default, which reads as "no party", where a 404 or a null body would fail its
|
||||
// deserializer.
|
||||
//
|
||||
// `maxCount` and `mode` are accepted and ignored. `maxCount=1` is most likely the
|
||||
// number of party chats wanted, which is already what a single-thread body serves; if
|
||||
// it turns out to size `Messages` instead, ignoring it only ever serves MORE history
|
||||
// than asked for, where guessing wrong the other way would truncate the party's
|
||||
// messages to one. `mode` is unknown.
|
||||
// The party thread (`/thread/party?maxCount=1&mode=0`). STUB: the response shape is
|
||||
// unknown — it hasn't been observed off a live client — so this answers an empty
|
||||
// object, which parses as "no party" rather than failing the client's deserializer the
|
||||
// way a 404 or a bare array would. `maxCount` and `mode` are accepted and ignored.
|
||||
// Replace the body once the real shape is captured.
|
||||
.get(
|
||||
'/thread/party',
|
||||
describeRoute({
|
||||
tags: ['Threads'],
|
||||
summary: 'The caller’s current party thread (GetPartyChat)',
|
||||
summary: 'The caller’s party thread (stub)',
|
||||
description: [
|
||||
'The party the caller is currently in: the newest party thread they are a member of,',
|
||||
'answered from a single query. Failing that, the thread named by their own',
|
||||
'`LatestPartyChat` player setting — which `POST /thread/party` writes for the player',
|
||||
'who opened the party and the client writes for a player who joins someone else’s.',
|
||||
'',
|
||||
'That second path JOINS: a caller who is not on any party yet is ADDED to the thread',
|
||||
'their key names and then served it, which is how a player pulled into someone else’s',
|
||||
'party enters it — they hold the key but no membership row, and a membership-only read',
|
||||
'answers them "no party". The thread must exist, be a party, and be younger than the',
|
||||
'60-minute invite lifetime before anyone is added, so a key naming a DM, a deleted',
|
||||
'thread or a stale party answers `{}` and writes nothing. The age gate is on JOINING',
|
||||
'only — a player already on a party is served it however old it is. A join posts a',
|
||||
'"Player <@U…> joined" notice and pushes it to the party, so the people already in it',
|
||||
'see who arrived.',
|
||||
'',
|
||||
'The BARE thread, unlike the POST on the same path, which wraps the same projection in',
|
||||
'`{ ChatThread, ChatResult }`. `maxCount` and `mode` are accepted and ignored.',
|
||||
'STUB — the response shape has not been observed off a live client, so this answers an',
|
||||
'empty object `{}`, which parses as "no party" rather than failing the client’s',
|
||||
'deserializer the way a 404 or a bare array would. `maxCount` and `mode` are accepted and',
|
||||
'ignored. Replace the body once the real shape is captured.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
@@ -882,121 +685,26 @@ const app = new Hono<App>()
|
||||
name: 'maxCount',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Page size the client sends (1). Accepted and ignored',
|
||||
description: 'Page size the client sends (1). Ignored by the stub',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
{
|
||||
name: 'mode',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Unknown mode selector the client sends (0). Accepted and ignored',
|
||||
description: 'Unknown mode selector the client sends (0). Ignored by the stub',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(PartyChatThread, 'The caller’s party, or `{}` when they have none'),
|
||||
200: json(PartyThread, 'Always `{}` — the stub carries no party'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
// Path 1: already in a party. One query, no settings read.
|
||||
let thread = await getPartyThreadForPlayer(c.env.DB, id)
|
||||
|
||||
// Path 2: not in one — the key is the only thing that can name a party to join.
|
||||
if (thread === null) {
|
||||
const chatThreadId = await readLatestPartyChatId(c.env, id)
|
||||
if (chatThreadId === null) return c.json({})
|
||||
|
||||
// Checked BEFORE the join: a gone thread, one of some other type, or a party
|
||||
// too old to still be taking people isn't something to put anybody on —
|
||||
// whoever wrote the key.
|
||||
const meta = await getThreadMeta(c.env.DB, chatThreadId)
|
||||
if (meta === null || meta.chatThreadType !== ChatThreadType.Party) return c.json({})
|
||||
if (!isPartyJoinable(meta.createdAt)) return c.json({})
|
||||
|
||||
await addThreadMember(c.env.DB, chatThreadId, id)
|
||||
// The same announcement the add-member route makes: the party learns someone
|
||||
// walked in. It is also usually a party's FIRST message — one opens empty.
|
||||
await announceJoin(c, chatThreadId, id)
|
||||
thread = await getThreadForPlayer(c.env.DB, chatThreadId, id)
|
||||
if (thread === null) throw new Error(`party thread ${chatThreadId} vanished after join`)
|
||||
}
|
||||
|
||||
const messages = await getThreadMessages(c.env.DB, thread.chatThreadId, {
|
||||
limit: DEFAULT_THREAD_MESSAGE_COUNT,
|
||||
})
|
||||
return c.json(toPartyChatThread(thread, messages))
|
||||
}
|
||||
)
|
||||
|
||||
// Open a party — the client's CreatePartyChat. A thread of type 2
|
||||
// (`ChatThreadType.Party`) holding only the caller, which the client then fills by
|
||||
// inviting people onto it (`POST /thread/{id}/member/{playerId}`). The one place a
|
||||
// thread is opened with a single member: every other create refuses a roster of just
|
||||
// yourself, because a DM with nobody in it is a mistake, whereas a party you are so far
|
||||
// the only member of is exactly what starting one looks like.
|
||||
//
|
||||
// Takes NO query params and NO body — the client posts to the bare path.
|
||||
//
|
||||
// Always a NEW party, never a fetch-or-create: a party is a session, not a standing
|
||||
// conversation with a set of people, so resolving to the one you left this morning
|
||||
// would hand the invitees its history.
|
||||
//
|
||||
// It opens EMPTY — no system "started a chat" notice, unlike every other new thread
|
||||
// here. The observed response carries `Messages: []` with a null `LatestMessage`, so a
|
||||
// notice would be a message the reference doesn't post.
|
||||
//
|
||||
// The body is the PascalCase `{ ChatThread, ChatResult }` wrapper, bare — no
|
||||
// `{ success, error, value }` envelope — and the thread inside it is its own
|
||||
// projection: ten keys, both `Messages` and `LatestMessage`, a null `ChatThreadName`,
|
||||
// and a `ClubId` that exists nowhere else. See `toPartyChatThread`.
|
||||
.post(
|
||||
'/thread/party',
|
||||
describeRoute({
|
||||
tags: ['Threads'],
|
||||
summary: 'Open a party thread for the caller (CreatePartyChat)',
|
||||
description: [
|
||||
'The client’s CreatePartyChat. Opens a thread of type 2 (Party) whose only member is',
|
||||
'the caller — the client fills it by inviting players on afterwards. No query params',
|
||||
'and no body. Always a new party, never a fetch-or-create: a party is a session rather',
|
||||
'than a standing conversation, so an old one would hand the invitees its history. The',
|
||||
'only create that accepts a roster of just the caller, and the only one that opens with',
|
||||
'no messages at all — no “started a chat” notice, matching the observed',
|
||||
'`Messages: []`. Records the new thread as the caller’s `LatestPartyChat` player',
|
||||
'setting, which is where `GET /thread/party` looks for it. Answers the bare PascalCase',
|
||||
'`{ ChatThread, ChatResult }` wrapper, whose thread is a projection of its own — not the',
|
||||
'camelCase shape the other thread routes serve.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(CreatePartyChatResponse, 'The new party thread, empty, with ChatResult 0'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = await createThread(c.env.DB, [id], null, undefined, ChatThreadType.Party)
|
||||
// This is what makes the party findable: the GET resolves the caller's current
|
||||
// party through this key and nothing else. Written before the response, so a
|
||||
// client that opens the party and immediately re-reads it can't miss it.
|
||||
await mergePlayerSettings(c.env, id, { [LATEST_PARTY_CHAT_KEY]: String(chatThreadId) })
|
||||
|
||||
const thread = await getThreadForPlayer(c.env.DB, chatThreadId, id)
|
||||
if (thread === null) throw new Error(`party thread ${chatThreadId} vanished after creation`)
|
||||
// Read the messages back rather than assuming []: the party is empty as it is
|
||||
// created, but the projection shouldn't be the thing that says so.
|
||||
const messages = await getThreadMessages(c.env.DB, chatThreadId, {
|
||||
limit: DEFAULT_THREAD_MESSAGE_COUNT,
|
||||
})
|
||||
return c.json({
|
||||
ChatThread: toPartyChatThread(thread, messages),
|
||||
ChatResult: CHAT_SUCCESS,
|
||||
})
|
||||
return c.json({})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1179,11 +887,7 @@ const app = new Hono<App>()
|
||||
// rather than a lonely thread.
|
||||
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
|
||||
|
||||
const { chatThreadId, created } = await getOrCreateThreadWithMembers(c.env.DB, members, id)
|
||||
// The reported bug: this opened the thread silently, so the other player saw
|
||||
// nothing until the first message landed. The opening notice is what tells them.
|
||||
if (created) await pushThreadLatestMessage(c, chatThreadId)
|
||||
|
||||
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
|
||||
const limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
|
||||
const thread = await threadWithMessages(c, chatThreadId, id, limit)
|
||||
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
|
||||
@@ -1398,10 +1102,7 @@ const app = new Hono<App>()
|
||||
'you’re part of. Answers a bare ChatResult rather than an HTTP status, as the reference',
|
||||
'does: 3 when the caller isn’t a member (which doubles as "no such thread", keeping a',
|
||||
'thread’s existence private), 4 when the target is already on it, 0 on success.',
|
||||
'Idempotent — re-adding an existing member changes nothing. On success a',
|
||||
'"Player <@U…> joined" system notice is posted and pushed to the whole thread: the',
|
||||
'existing members because the roster changed, the new one because that push is what',
|
||||
'puts the conversation on their screen.',
|
||||
'Idempotent — re-adding an existing member changes nothing.',
|
||||
].join(' '),
|
||||
{
|
||||
parameters: [
|
||||
@@ -1431,9 +1132,6 @@ const app = new Hono<App>()
|
||||
}
|
||||
|
||||
await addThreadMember(c.env.DB, chatThreadId, playerId)
|
||||
// Everyone hears about it — the existing members because the roster changed under
|
||||
// them, the new one because this is what puts the conversation on their screen.
|
||||
await announceJoin(c, chatThreadId, playerId)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
}
|
||||
)
|
||||
@@ -1558,4 +1256,4 @@ app.get(
|
||||
)
|
||||
)
|
||||
|
||||
export default withDatabase<Env>(app)
|
||||
export default app
|
||||
|
||||
@@ -86,9 +86,7 @@ const threadBase = {
|
||||
chatThreadName: z
|
||||
.string()
|
||||
.describe('Empty for DMs and unnamed groups — never null (the client dereferences it)'),
|
||||
chatThreadType: z
|
||||
.int()
|
||||
.describe('The ChatThreadType enum, numeric: 0 Player (DMs and groups) · 1 Club · 2 Party'),
|
||||
chatThreadType: z.int().describe('Always 0 — the only type the reference serves'),
|
||||
snoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
|
||||
isFavorited: z.boolean(),
|
||||
}
|
||||
@@ -235,56 +233,11 @@ export const ChatPrivacySettings = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* A thread in the PascalCase spelling the two `/thread/party` routes serve — the client's
|
||||
* CreatePartyChat and GetPartyChat — the THIRD projection of a thread in this worker, and deliberately not
|
||||
* unified with the two camelCase ones ({@link ChatThreadDto}, {@link
|
||||
* ChatThreadWithMessagesDto}): the client has a separate formatter for this response, and
|
||||
* a camelCase body decodes to a thread with every field at its default.
|
||||
*
|
||||
* Ten wire keys, off the client's own formatter. Its CLR type declares thirteen fields:
|
||||
* two are `[IgnoreDataMember]` and one is a plain field rather than an auto-property, so
|
||||
* none of the three ever serialises — don't add them back.
|
||||
*
|
||||
* Differences from the camelCase DTOs beyond the casing:
|
||||
* - `Messages` and `LatestMessage` are BOTH present, where the camelCase pair carries one
|
||||
* or the other. A party opens empty, so they come back `[]` and null.
|
||||
* - `ChatThreadName` is NULL for an unnamed thread, not the empty string the camelCase
|
||||
* projections have to send (the client dereferences that one unchecked; this formatter
|
||||
* takes the null).
|
||||
* - `ClubId` exists only here — null for a party, and for everything this worker serves:
|
||||
* club chat lives in the `clubs` worker and nothing on this table carries a club.
|
||||
*
|
||||
* `GET /thread/party` serves this BARE; the POST wraps it in {@link
|
||||
* CreatePartyChatResponse}. The GET also answers `{}` for a caller with no party, which
|
||||
* decodes to a thread with every field at its default — the client reads that as no party,
|
||||
* where a 404 or a null body would fail its deserializer.
|
||||
* `GET /thread/party` — STUB. The real shape hasn't been observed off a live client, so
|
||||
* the route answers an empty object and this schema says so rather than guessing at
|
||||
* fields. Fill both in together once the real response is captured.
|
||||
*/
|
||||
export const PartyChatThread = z.object({
|
||||
ChatThreadId: z.int(),
|
||||
ChatThreadType: z.int().describe('The ChatThreadType enum: 0 Player · 1 Club · 2 Party'),
|
||||
LastReadMessageId: z.int().describe('0 for a party that was just opened'),
|
||||
Messages: z
|
||||
.array(SentChatMessage)
|
||||
.describe('Empty for a party just opened — nothing is posted into it'),
|
||||
LatestMessage: SentChatMessage.nullable().describe('Null while the thread has no messages'),
|
||||
PlayerIds: z.array(z.int()).describe('Just the caller, until players are invited on'),
|
||||
ChatThreadName: z.string().nullable().describe('NULL when unnamed — not the empty string'),
|
||||
SnoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
|
||||
IsFavorited: z.boolean(),
|
||||
ClubId: z.int().nullable().describe('Always null here — this worker serves no club threads'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /thread/party` — the client's CreatePartyChat. A bare two-key wrapper, PascalCase
|
||||
* like the thread inside it, with no `{ success, error, value }` envelope around it.
|
||||
*
|
||||
* `ChatResult` is the same twenty-member enum {@link ChatResult} records, served
|
||||
* numerically; the create either works or fails the request, so it is always 0 here.
|
||||
*/
|
||||
export const CreatePartyChatResponse = z.object({
|
||||
ChatThread: PartyChatThread,
|
||||
ChatResult: ChatResult,
|
||||
})
|
||||
export const PartyThread = z.object({}).describe('Stub — always empty; the real shape is unknown')
|
||||
|
||||
/** `GET /` — the liveness probe. */
|
||||
export const ServiceStatus = z.object({
|
||||
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
SCHEMA_DDL,
|
||||
} from '../../message-db'
|
||||
import {
|
||||
addThreadMember,
|
||||
ChatThreadType,
|
||||
createThread,
|
||||
joinedChatContents,
|
||||
findThreadWithMembers,
|
||||
getThreadForPlayer,
|
||||
getThreadsForPlayer,
|
||||
@@ -397,107 +394,8 @@ describe('GET /settings/partyinvite', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The GET serves the caller's CURRENT party — the thread named by their `LatestPartyChat`
|
||||
// player setting — as the BARE thread, where the POST wraps the same projection.
|
||||
describe('GET /thread/party', () => {
|
||||
async function settings(playerId: number): Promise<Record<string, string>> {
|
||||
return (
|
||||
(await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
`player:${playerId}`,
|
||||
'json'
|
||||
)) ?? {}
|
||||
)
|
||||
}
|
||||
|
||||
it('answers the party named by LatestPartyChat, bare — no ChatResult wrapper', async () => {
|
||||
const caller = 883201
|
||||
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, {
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
// The whole body is the thread: no `ChatThread`/`ChatResult` keys around it.
|
||||
expect(await res.json()).toEqual({
|
||||
ChatThreadId: ChatThread.ChatThreadId,
|
||||
ChatThreadType: ChatThreadType.Party,
|
||||
LastReadMessageId: 0,
|
||||
Messages: [],
|
||||
LatestMessage: null,
|
||||
PlayerIds: [caller],
|
||||
ChatThreadName: null,
|
||||
SnoozedUntil: null,
|
||||
IsFavorited: false,
|
||||
ClubId: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('POST records the thread id in the caller’s LatestPartyChat setting', async () => {
|
||||
const caller = 883202
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
`player:${caller}`,
|
||||
JSON.stringify({ OobeState: 'Complete' })
|
||||
)
|
||||
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
|
||||
// Merged, not overwritten: the bag holds every other setting the player has.
|
||||
expect(await settings(caller)).toEqual({
|
||||
OobeState: 'Complete',
|
||||
LatestPartyChat: String(ChatThread.ChatThreadId),
|
||||
})
|
||||
})
|
||||
|
||||
it('follows the setting to the newest party after a second one is opened', async () => {
|
||||
const caller = 883203
|
||||
await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) })
|
||||
const second = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
const { ChatThread } = (await second.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
|
||||
const body = (await res.json()) as { ChatThreadId: number }
|
||||
expect(body.ChatThreadId).toBe(ChatThread.ChatThreadId)
|
||||
})
|
||||
|
||||
it('carries the party’s members and messages once it has them', async () => {
|
||||
const caller = 883204
|
||||
const guest = 883205
|
||||
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
await addThreadMember(env.DB, ChatThread.ChatThreadId, guest)
|
||||
const posted = await postMessage(env.DB, {
|
||||
chatThreadId: ChatThread.ChatThreadId,
|
||||
senderPlayerId: caller,
|
||||
contents: '{"Type":0,"Version":1,"Data":"regroup at the bridge"}',
|
||||
})
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
|
||||
const body = (await res.json()) as {
|
||||
PlayerIds: number[]
|
||||
Messages: Array<{ ChatMessageId: number; Contents: string }>
|
||||
LatestMessage: { ChatMessageId: number } | null
|
||||
}
|
||||
expect(body.PlayerIds).toEqual([caller, guest])
|
||||
// PascalCase messages here too — the camelCase spelling is the other projections'.
|
||||
expect(body.Messages).toHaveLength(1)
|
||||
expect(body.Messages[0]?.ChatMessageId).toBe(posted.chatMessageId)
|
||||
expect(body.LatestMessage?.ChatMessageId).toBe(posted.chatMessageId)
|
||||
})
|
||||
|
||||
it('answers {} for a player who has never opened one', async () => {
|
||||
it('answers an empty object', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, {
|
||||
headers: await bearer(883001),
|
||||
})
|
||||
@@ -505,339 +403,12 @@ describe('GET /thread/party', () => {
|
||||
expect(await res.json()).toEqual({})
|
||||
})
|
||||
|
||||
it('JOINS a caller who holds the key but isn’t on the thread yet', async () => {
|
||||
// How a player enters someone else's party: the client points their
|
||||
// LatestPartyChat at it, and they have no membership row until this read.
|
||||
const host = 883210
|
||||
const guest = 883211
|
||||
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(host),
|
||||
})
|
||||
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
`player:${guest}`,
|
||||
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
|
||||
)
|
||||
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, guest)).toBe(false)
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
|
||||
const body = (await res.json()) as { ChatThreadId: number; PlayerIds: number[] }
|
||||
|
||||
// They're on the thread now, and the body they get back says so.
|
||||
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, guest)).toBe(true)
|
||||
expect(body.ChatThreadId).toBe(ChatThread.ChatThreadId)
|
||||
expect(body.PlayerIds).toEqual([host, guest])
|
||||
// The host sees them too — one thread, one roster.
|
||||
expect((await getThreadForPlayer(env.DB, ChatThread.ChatThreadId, host))?.playerIds).toEqual([
|
||||
host,
|
||||
guest,
|
||||
])
|
||||
})
|
||||
|
||||
it('re-joining is a no-op — the roster doesn’t grow on every read', async () => {
|
||||
const caller = 883212
|
||||
await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) })
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
|
||||
}
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
|
||||
expect(((await res.json()) as { PlayerIds: number[] }).PlayerIds).toEqual([caller])
|
||||
})
|
||||
|
||||
it('re-joins a caller who left, while their key still names the party', async () => {
|
||||
// Leaving doesn't clear the key, so the next read walks them back in. Ending a
|
||||
// party is what drops the key (`match`'s POST /player/logout).
|
||||
const caller = 883206
|
||||
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
await removeThreadMember(env.DB, ChatThread.ChatThreadId, caller)
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
|
||||
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
|
||||
ChatThread.ChatThreadId
|
||||
)
|
||||
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, caller)).toBe(true)
|
||||
})
|
||||
|
||||
it('serves the party from D1 alone, with no LatestPartyChat key at all', async () => {
|
||||
// The fast path: a player already on a party is answered from the membership join,
|
||||
// so the settings KV is never read. Proven by deleting the key the POST wrote.
|
||||
const caller = 883230
|
||||
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
await env.RECFLARE_PLAYER_SETTINGS.delete(`player:${caller}`)
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
|
||||
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
|
||||
ChatThread.ChatThreadId
|
||||
)
|
||||
})
|
||||
|
||||
it('serves the NEWEST party when the caller is a member of several', async () => {
|
||||
// Membership outlives a party — nothing removes the row when one ends — so the
|
||||
// party you are in is the most recent one you are on.
|
||||
const caller = 883231
|
||||
await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) })
|
||||
const second = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
const { ChatThread } = (await second.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
|
||||
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
|
||||
ChatThread.ChatThreadId
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers the party the caller is ON over one their key merely names', async () => {
|
||||
// The consequence of checking D1 first: while a membership row survives, a key
|
||||
// pointing somewhere else is not consulted. Switching parties means leaving the old
|
||||
// one (DELETE /thread/{id}/leave), not just repointing the key.
|
||||
const caller = 883232
|
||||
const host = 883233
|
||||
const mine = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
const other = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(host),
|
||||
})
|
||||
const own = (await mine.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
const theirs = (await other.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
`player:${caller}`,
|
||||
JSON.stringify({ LatestPartyChat: String(theirs.ChatThread.ChatThreadId) })
|
||||
)
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
|
||||
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
|
||||
own.ChatThread.ChatThreadId
|
||||
)
|
||||
expect(await isThreadMember(env.DB, theirs.ChatThread.ChatThreadId, caller)).toBe(false)
|
||||
})
|
||||
|
||||
/** Age a party by rewriting its `created_at` — what the join window is measured from. */
|
||||
async function openedMinutesAgo(chatThreadId: number, minutes: number): Promise<void> {
|
||||
await env.DB.prepare('UPDATE message_thread SET created_at = ?2 WHERE chat_thread_id = ?1')
|
||||
.bind(chatThreadId, new Date(Date.now() - minutes * 60_000).toISOString())
|
||||
.run()
|
||||
}
|
||||
|
||||
it('refuses to join a party older than the 60-minute invite lifetime', async () => {
|
||||
const host = 883220
|
||||
const latecomer = 883221
|
||||
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(host),
|
||||
})
|
||||
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
await openedMinutesAgo(ChatThread.ChatThreadId, 61)
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
`player:${latecomer}`,
|
||||
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
|
||||
)
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(latecomer) })
|
||||
expect(await res.json()).toEqual({})
|
||||
// Refused, not quietly joined: no membership row was written.
|
||||
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, latecomer)).toBe(false)
|
||||
})
|
||||
|
||||
it('still joins a party inside the window', async () => {
|
||||
const host = 883222
|
||||
const guest = 883223
|
||||
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(host),
|
||||
})
|
||||
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
await openedMinutesAgo(ChatThread.ChatThreadId, 59)
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
`player:${guest}`,
|
||||
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
|
||||
)
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
|
||||
expect(((await res.json()) as { PlayerIds: number[] }).PlayerIds).toEqual([host, guest])
|
||||
})
|
||||
|
||||
it('keeps serving an aged party to the players already on it', async () => {
|
||||
// The window gates JOINING only — a party doesn't go dark on its own members.
|
||||
const host = 883224
|
||||
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(host),
|
||||
})
|
||||
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
await openedMinutesAgo(ChatThread.ChatThreadId, 240)
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(host) })
|
||||
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
|
||||
ChatThread.ChatThreadId
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to join a party whose created_at won’t parse', async () => {
|
||||
// Fails closed: no timestamp, no join.
|
||||
const host = 883225
|
||||
const stranger = 883226
|
||||
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(host),
|
||||
})
|
||||
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
await env.DB.prepare('UPDATE message_thread SET created_at = ?2 WHERE chat_thread_id = ?1')
|
||||
.bind(ChatThread.ChatThreadId, 'not-a-timestamp')
|
||||
.run()
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
`player:${stranger}`,
|
||||
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
|
||||
)
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(stranger) })
|
||||
expect(await res.json()).toEqual({})
|
||||
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, stranger)).toBe(false)
|
||||
})
|
||||
|
||||
it('answers {} when the setting names a thread that isn’t a party, joining nobody', async () => {
|
||||
// The type check runs BEFORE the join, so a key pointed at someone else's DM can't
|
||||
// put the caller in it.
|
||||
const caller = 883207
|
||||
const dm = await createThread(env.DB, [883208, 883213])
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
`player:${caller}`,
|
||||
JSON.stringify({ LatestPartyChat: String(dm) })
|
||||
)
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
|
||||
expect(await res.json()).toEqual({})
|
||||
expect(await isThreadMember(env.DB, dm, caller)).toBe(false)
|
||||
})
|
||||
|
||||
it('answers {} when the setting names a thread that doesn’t exist', async () => {
|
||||
const caller = 883214
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
`player:${caller}`,
|
||||
JSON.stringify({ LatestPartyChat: '99999' })
|
||||
)
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
|
||||
expect(await res.json()).toEqual({})
|
||||
expect(await isThreadMember(env.DB, 99999, caller)).toBe(false)
|
||||
})
|
||||
|
||||
it('answers {} for an unparseable stored id', async () => {
|
||||
const caller = 883209
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
`player:${caller}`,
|
||||
JSON.stringify({ LatestPartyChat: 'not-an-id' })
|
||||
)
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
|
||||
expect(await res.json()).toEqual({})
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
// The POST on the same path is not a stub: it opens a real thread, of type Party, with
|
||||
// only the caller on it — and answers the client's own PascalCase CreatePartyChat shape,
|
||||
// which is neither of the two camelCase thread projections.
|
||||
describe('POST /thread/party', () => {
|
||||
it('answers the PascalCase { ChatThread, ChatResult } wrapper for an empty party', async () => {
|
||||
const caller = 883101
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { ChatThread: { ChatThreadId: number }; ChatResult: number }
|
||||
// Every wire key, exactly — a party opens empty, unnamed and with no club, and the
|
||||
// client reads none of the camelCase spellings the other thread routes serve.
|
||||
expect(body).toEqual({
|
||||
ChatThread: {
|
||||
ChatThreadId: body.ChatThread.ChatThreadId,
|
||||
ChatThreadType: ChatThreadType.Party,
|
||||
LastReadMessageId: 0,
|
||||
Messages: [],
|
||||
LatestMessage: null,
|
||||
PlayerIds: [caller],
|
||||
ChatThreadName: null,
|
||||
SnoozedUntil: null,
|
||||
IsFavorited: false,
|
||||
ClubId: null,
|
||||
},
|
||||
ChatResult: 0,
|
||||
})
|
||||
|
||||
// And it's a real thread: it reads back through the normal thread routes.
|
||||
const stored = await getThreadForPlayer(env.DB, body.ChatThread.ChatThreadId, caller)
|
||||
expect(stored?.chatThreadType).toBe(ChatThreadType.Party)
|
||||
expect(stored?.playerIds).toEqual([caller])
|
||||
})
|
||||
|
||||
it('posts no “started a chat” notice into the party', async () => {
|
||||
const caller = 883105
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
const { ChatThread } = (await res.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
expect(await getThreadMessages(env.DB, ChatThread.ChatThreadId)).toEqual([])
|
||||
})
|
||||
|
||||
it('opens a NEW party every call rather than resolving to the last one', async () => {
|
||||
const caller = 883102
|
||||
const first = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
const second = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
const a = (await first.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
const b = (await second.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
expect(b.ChatThread.ChatThreadId).not.toBe(a.ChatThread.ChatThreadId)
|
||||
})
|
||||
|
||||
it('keeps party threads out of the DM fetch-or-create', async () => {
|
||||
// A party the caller invited someone onto has the same roster as their DM would.
|
||||
const caller = 883103
|
||||
const other = 883104
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
const { ChatThread } = (await res.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
await addThreadMember(env.DB, ChatThread.ChatThreadId, other)
|
||||
|
||||
const dm = await SELF.fetch(`${ORIGIN}/thread/withmembers`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(caller)), 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ ids: String(other) }),
|
||||
})
|
||||
const opened = (await dm.json()) as { chatThreadId: number; chatThreadType: number }
|
||||
expect(opened.chatThreadId).not.toBe(ChatThread.ChatThreadId)
|
||||
expect(opened.chatThreadType).toBe(ChatThreadType.Player)
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /thread/chatPrivacySetting', () => {
|
||||
it('reports Friends for both settings by default, keyed to the caller', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, {
|
||||
@@ -1420,28 +991,11 @@ describe('ChatMessageReceived push', () => {
|
||||
}
|
||||
|
||||
const sent = await hub.getByName('global').takeSent()
|
||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
|
||||
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
|
||||
true
|
||||
)
|
||||
// TWO waves over one channel, because that channel is all the client has: the
|
||||
// thread's opening notice, which is what makes the new conversation appear at all,
|
||||
// and then the message itself. Each goes to all three members.
|
||||
const notice = sent.filter((n) => (n.data as { senderPlayerId: number }).senderPlayerId === -5)
|
||||
const message = sent.filter(
|
||||
(n) => (n.data as { senderPlayerId: number }).senderPlayerId === caller
|
||||
)
|
||||
for (const wave of [notice, message]) {
|
||||
expect(wave.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
|
||||
}
|
||||
expect(notice[0]!.data).toEqual({
|
||||
chatMessageId: expect.any(Number),
|
||||
chatThreadId: chatThread.chatThreadId,
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
timeSent: expect.any(String),
|
||||
contents: startedChatContents(caller),
|
||||
moderationState: 0,
|
||||
})
|
||||
expect(message[0]!.data).toEqual({
|
||||
expect(sent[0]!.data).toEqual({
|
||||
chatMessageId: chatThread.latestMessage.chatMessageId,
|
||||
chatThreadId: chatThread.chatThreadId,
|
||||
senderPlayerId: caller,
|
||||
@@ -1451,144 +1005,8 @@ describe('ChatMessageReceived push', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes the opening notice even when no message is sent', async () => {
|
||||
// The thread is real whether or not anything was said in it, and a thread nobody
|
||||
// was told about is one nobody sees — the client has no "thread opened" channel.
|
||||
const caller = 886004
|
||||
await send(caller, 'ids=886005&messageContents=')
|
||||
const sent = await hub.getByName('global').takeSent()
|
||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886005])
|
||||
expect((sent[0]!.data as { senderPlayerId: number }).senderPlayerId).toBe(SYSTEM_SENDER_ID)
|
||||
expect((sent[0]!.data as { contents: string }).contents).toBe(startedChatContents(caller))
|
||||
})
|
||||
|
||||
it('pushes nothing when the thread already existed and nothing was said', async () => {
|
||||
// Second call on the same pair: no new thread, no message — nothing to announce.
|
||||
await send(886006, 'ids=886007&messageContents=')
|
||||
await hub.getByName('global').takeSent()
|
||||
await send(886006, 'ids=886007&messageContents=')
|
||||
expect(await hub.getByName('global').takeSent()).toEqual([])
|
||||
})
|
||||
|
||||
async function withMembers(caller: number, body: string): Promise<Response> {
|
||||
return SELF.fetch(`${ORIGIN}/thread/withmembers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(caller)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
it('POST /thread/withmembers announces the thread it opens', async () => {
|
||||
// The reported bug: this opened the conversation silently, so the other player saw
|
||||
// nothing until the first message arrived.
|
||||
const caller = 886010
|
||||
const other = 886011
|
||||
const res = await withMembers(caller, `ids=${other}`)
|
||||
const { chatThreadId } = (await res.json()) as { chatThreadId: number }
|
||||
|
||||
const sent = await hub.getByName('global').takeSent()
|
||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, other])
|
||||
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
|
||||
true
|
||||
)
|
||||
expect(sent[0]!.data).toEqual({
|
||||
chatMessageId: expect.any(Number),
|
||||
chatThreadId,
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
timeSent: expect.any(String),
|
||||
contents: startedChatContents(caller),
|
||||
moderationState: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('POST /thread/withmembers pushes nothing when it resolves to an existing thread', async () => {
|
||||
// Fetch-or-create: the second call opens nothing, so there is nothing to announce
|
||||
// — re-announcing would ping both players every time the screen is opened.
|
||||
const caller = 886012
|
||||
await withMembers(caller, 'ids=886013')
|
||||
await hub.getByName('global').takeSent()
|
||||
await withMembers(caller, 'ids=886013')
|
||||
expect(await hub.getByName('global').takeSent()).toEqual([])
|
||||
})
|
||||
|
||||
it('adding a member announces the join to the WHOLE thread', async () => {
|
||||
const caller = 886014
|
||||
const existing = 886015
|
||||
const added = 886016
|
||||
const res = await withMembers(caller, `ids=${existing}`)
|
||||
const { chatThreadId } = (await res.json()) as { chatThreadId: number }
|
||||
await hub.getByName('global').takeSent()
|
||||
|
||||
await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/member/${added}`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
|
||||
// Everyone: the two who were already there because the roster changed under them,
|
||||
// and the new member because this is what puts the thread on their screen.
|
||||
const sent = await hub.getByName('global').takeSent()
|
||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, existing, added])
|
||||
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
|
||||
true
|
||||
)
|
||||
expect(sent[0]!.data).toMatchObject({
|
||||
chatThreadId,
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
contents: joinedChatContents(added),
|
||||
})
|
||||
|
||||
// The notice is a real message on the thread, not just a frame — the mirror of the
|
||||
// "left" one, and it is the thread's newest.
|
||||
const [newest] = await getThreadMessages(env.DB, chatThreadId, { limit: 1 })
|
||||
expect(newest?.contents).toBe(joinedChatContents(added))
|
||||
})
|
||||
|
||||
it('joining a party announces it to the party', async () => {
|
||||
const host = 886017
|
||||
const guest = 886018
|
||||
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(host),
|
||||
})
|
||||
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
`player:${guest}`,
|
||||
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
|
||||
)
|
||||
await hub.getByName('global').takeSent()
|
||||
|
||||
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
|
||||
|
||||
// The host hears about it too — that is the point of announcing a join.
|
||||
const sent = await hub.getByName('global').takeSent()
|
||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([host, guest])
|
||||
expect(sent[0]!.data).toMatchObject({
|
||||
chatThreadId: ChatThread.ChatThreadId,
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
contents: joinedChatContents(guest),
|
||||
})
|
||||
})
|
||||
|
||||
it('announces a party join once, not on every subsequent read', async () => {
|
||||
// The join happens once; reading your own party afterwards is not a roster change.
|
||||
const host = 886019
|
||||
const guest = 886020
|
||||
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(host),
|
||||
})
|
||||
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
`player:${guest}`,
|
||||
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
|
||||
)
|
||||
|
||||
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
|
||||
await hub.getByName('global').takeSent()
|
||||
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
|
||||
it('pushes nothing when there is no message to push', async () => {
|
||||
await send(886004, 'ids=886005&messageContents=')
|
||||
expect(await hub.getByName('global').takeSent()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -2211,7 +1629,6 @@ describe('openapi', () => {
|
||||
'GET /thread/{id}',
|
||||
'GET /thread/{id}/message',
|
||||
'POST /thread',
|
||||
'POST /thread/party',
|
||||
'POST /thread/withmembers',
|
||||
'POST /thread/{id}',
|
||||
'POST /thread/{id}/favorite',
|
||||
|
||||
+52
-145
@@ -31,7 +31,6 @@ export const THREAD_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS message_thread (
|
||||
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_thread_name TEXT,
|
||||
chat_thread_type INTEGER NOT NULL DEFAULT 0,
|
||||
latest_message_id INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
@@ -71,36 +70,23 @@ export interface ChatThread {
|
||||
*/
|
||||
chatThreadName: string
|
||||
/**
|
||||
* Which kind of conversation this is — the client's `ChatThreadType`: 0 Player,
|
||||
* 1 Club, 2 Party. Player covers both DMs and group chats; nothing here distinguishes
|
||||
* the two. The field has to be present whatever its value: the client deserializes it
|
||||
* as a non-nullable int and drops the whole response when it's missing.
|
||||
* Which kind of conversation this is. Every thread the reference serves here comes back
|
||||
* as 0, and nothing in the worker distinguishes DMs from groups, so it's a constant —
|
||||
* but the field itself has to be present: the client deserializes it as a non-nullable
|
||||
* int and drops the whole response when it's missing.
|
||||
*/
|
||||
chatThreadType: ChatThreadTypeValue
|
||||
chatThreadType: number
|
||||
snoozedUntil: string | null
|
||||
isFavorited: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's `ChatThreadType` enum, stored on the thread and served numerically like
|
||||
* every other enum on this build. A plain conversation — DM or group — is `Player`;
|
||||
* `Party` is what `POST /thread/party` opens. Nothing here serves `Club` yet: club chat
|
||||
* lives in the `clubs` worker, and the member is here so a stored 1 renders as itself
|
||||
* rather than being read as a player thread.
|
||||
*/
|
||||
export const ChatThreadType = {
|
||||
Player: 0,
|
||||
Club: 1,
|
||||
Party: 2,
|
||||
} as const
|
||||
|
||||
export type ChatThreadTypeValue = (typeof ChatThreadType)[keyof typeof ChatThreadType]
|
||||
/** The only thread type the reference ever serves. See `ChatThread.chatThreadType`. */
|
||||
const CHAT_THREAD_TYPE_DEFAULT = 0
|
||||
|
||||
/** The joined row backing a rendered thread, before it's shaped for the client. */
|
||||
interface ThreadRow {
|
||||
chat_thread_id: number
|
||||
chat_thread_name: string | null
|
||||
chat_thread_type: number
|
||||
player_ids: string | null
|
||||
last_read_message_id: number | null
|
||||
snoozed_until: string | null
|
||||
@@ -133,24 +119,30 @@ function toThread(row: ThreadRow): ChatThread {
|
||||
lastReadMessageId: row.last_read_message_id ?? 0,
|
||||
// Null in the column means "unnamed"; the client dereferences it unchecked.
|
||||
chatThreadName: row.chat_thread_name ?? '',
|
||||
chatThreadType: row.chat_thread_type as ChatThreadTypeValue,
|
||||
chatThreadType: CHAT_THREAD_TYPE_DEFAULT,
|
||||
snoozedUntil: row.snoozed_until,
|
||||
isFavorited: row.is_favorited !== 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared projection behind every rendered thread — the list, the single read and the
|
||||
* party read. Membership LEADS the join, so it is the authorization check as well as the
|
||||
* query: a row only exists for a thread `me` is in. The inner ordered subquery around
|
||||
* group_concat is what makes `playerIds` come back sorted rather than in row order.
|
||||
* The thread list as it renders for one player, newest conversation first — the
|
||||
* `?MessageCount=N` page of the thread endpoint.
|
||||
*
|
||||
* Callers append their own WHERE (and ORDER/LIMIT) and bind `?1` onwards from there.
|
||||
* Reads only threads the player is a member of, so the membership join is the
|
||||
* authorization check as well as the query. The inner ordered subquery around
|
||||
* group_concat is what makes `playerIds` come back sorted rather than in row order.
|
||||
*/
|
||||
const THREAD_SELECT = `SELECT
|
||||
export async function getThreadsForPlayer(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
{ limit = 50 }: { limit?: number } = {}
|
||||
): Promise<ChatThread[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT
|
||||
t.chat_thread_id,
|
||||
t.chat_thread_name,
|
||||
t.chat_thread_type,
|
||||
(SELECT group_concat(player_id) FROM
|
||||
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
|
||||
ORDER BY player_id)) AS player_ids,
|
||||
@@ -165,22 +157,7 @@ const THREAD_SELECT = `SELECT
|
||||
msg.moderation_state AS msg_moderation_state
|
||||
FROM thread_member me
|
||||
JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id
|
||||
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id`
|
||||
|
||||
/**
|
||||
* The thread list as it renders for one player, newest conversation first — the
|
||||
* `?MessageCount=N` page of the thread endpoint.
|
||||
*
|
||||
* Membership scopes the query — see {@link THREAD_SELECT}.
|
||||
*/
|
||||
export async function getThreadsForPlayer(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
{ limit = 50 }: { limit?: number } = {}
|
||||
): Promise<ChatThread[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`${THREAD_SELECT}
|
||||
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id
|
||||
WHERE me.player_id = ?1
|
||||
ORDER BY t.latest_message_id DESC
|
||||
LIMIT ?2`
|
||||
@@ -198,7 +175,24 @@ export async function getThreadForPlayer(
|
||||
): Promise<ChatThread | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`${THREAD_SELECT}
|
||||
`SELECT
|
||||
t.chat_thread_id,
|
||||
t.chat_thread_name,
|
||||
(SELECT group_concat(player_id) FROM
|
||||
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
|
||||
ORDER BY player_id)) AS player_ids,
|
||||
me.last_read_message_id,
|
||||
me.snoozed_until,
|
||||
me.is_favorited,
|
||||
msg.chat_message_id AS msg_chat_message_id,
|
||||
msg.chat_thread_id AS msg_chat_thread_id,
|
||||
msg.sender_player_id AS msg_sender_player_id,
|
||||
msg.time_sent AS msg_time_sent,
|
||||
msg.contents AS msg_contents,
|
||||
msg.moderation_state AS msg_moderation_state
|
||||
FROM thread_member me
|
||||
JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id
|
||||
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id
|
||||
WHERE me.chat_thread_id = ?1 AND me.player_id = ?2`
|
||||
)
|
||||
.bind(chatThreadId, playerId)
|
||||
@@ -206,65 +200,6 @@ export async function getThreadForPlayer(
|
||||
return row === null ? null : toThread(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* The party a player is already in, or null — the newest party thread carrying a
|
||||
* membership row for them, rendered exactly as {@link getThreadForPlayer} renders one.
|
||||
*
|
||||
* The fast path behind `GET /thread/party`: a player already on a party is answered from
|
||||
* ONE D1 query, with no player-settings read at all. `LatestPartyChat` is consulted only
|
||||
* when this comes back null — that is, only for a player who has yet to JOIN a party.
|
||||
*
|
||||
* Newest first (by thread id, which is monotonic) because a player can hold membership in
|
||||
* parties they never formally left: the one they are in is the most recent one they are on.
|
||||
*/
|
||||
export async function getPartyThreadForPlayer(
|
||||
db: D1Database,
|
||||
playerId: number
|
||||
): Promise<ChatThread | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`${THREAD_SELECT}
|
||||
WHERE me.player_id = ?1 AND t.chat_thread_type = ?2
|
||||
ORDER BY t.chat_thread_id DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.bind(playerId, ChatThreadType.Party)
|
||||
.first<ThreadRow>()
|
||||
return row === null ? null : toThread(row)
|
||||
}
|
||||
|
||||
/** What a thread IS, without any of what's in it. See {@link getThreadMeta}. */
|
||||
export interface ThreadMeta {
|
||||
chatThreadType: ChatThreadTypeValue
|
||||
/** ISO-8601 UTC, as `created_at` stores it. */
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A thread's kind and age, or null when there is no such thread — the one read here that
|
||||
* does NOT go through membership.
|
||||
*
|
||||
* It exists for the party join (`GET /thread/party`), which has to know a thread is real,
|
||||
* is a party, and is still young enough to join BEFORE it puts the caller on it; every
|
||||
* other read is membership-scoped, and a caller joining a party is by definition not a
|
||||
* member yet. It answers these two fields and nothing else — no name, no roster, no
|
||||
* messages — precisely so it can't become a way to read a thread you aren't in.
|
||||
*/
|
||||
export async function getThreadMeta(
|
||||
db: D1Database,
|
||||
chatThreadId: number
|
||||
): Promise<ThreadMeta | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT chat_thread_type, created_at FROM message_thread WHERE chat_thread_id = ?1'
|
||||
)
|
||||
.bind(chatThreadId)
|
||||
.first<{ chat_thread_type: number; created_at: string }>()
|
||||
return row === null
|
||||
? null
|
||||
: { chatThreadType: row.chat_thread_type as ChatThreadTypeValue, createdAt: row.created_at }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a player may read or post to a thread. Every thread-scoped route gates on
|
||||
* this before touching messages.
|
||||
@@ -308,19 +243,6 @@ export function leftChatContents(playerId: number): string {
|
||||
return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> left` })
|
||||
}
|
||||
|
||||
/**
|
||||
* The counterpart notice when someone is pulled onto a thread or walks into a party:
|
||||
* `Player <@U14922080> joined`. Same `<@U…>` mention token as the other two.
|
||||
*
|
||||
* It carries the roster change as a MESSAGE because that is the only way to carry one: the
|
||||
* client has no join/leave channel, only `ChatMessageReceived` and `PlayerLeftChat`, both
|
||||
* of which take a message. So the notice is both what the thread shows and what tells
|
||||
* everyone — the new member's client included — that the roster moved.
|
||||
*/
|
||||
export function joinedChatContents(playerId: number): string {
|
||||
return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> joined` })
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a thread. An empty name clears it back to unnamed, which renders as the member
|
||||
* list rather than a blank title.
|
||||
@@ -347,24 +269,19 @@ export async function setThreadName(
|
||||
*
|
||||
* Every call opens a *distinct* thread, even for a member set that already has one —
|
||||
* threads are not keyed by their membership, and the same pair may hold several.
|
||||
*
|
||||
* `type` is the thread's kind and defaults to `Player`, which covers DMs and group chats
|
||||
* alike; a party opens as `Party`.
|
||||
*/
|
||||
export async function createThread(
|
||||
db: D1Database,
|
||||
playerIds: number[],
|
||||
name: string | null = null,
|
||||
startedBy?: number,
|
||||
type: ChatThreadTypeValue = ChatThreadType.Player
|
||||
startedBy?: number
|
||||
): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO message_thread (chat_thread_name, chat_thread_type, created_at)
|
||||
VALUES (?1, ?2, ?3)
|
||||
`INSERT INTO message_thread (chat_thread_name, created_at) VALUES (?1, ?2)
|
||||
RETURNING chat_thread_id`
|
||||
)
|
||||
.bind(name, type, new Date().toISOString())
|
||||
.bind(name, new Date().toISOString())
|
||||
.first<{ chat_thread_id: number }>()
|
||||
if (row === null) throw new Error('failed to create chat thread')
|
||||
|
||||
@@ -405,33 +322,27 @@ export async function createThread(
|
||||
* thread is gone are ignored rather than resolved to: matching one would hand back an id
|
||||
* that nothing else in the worker can render, and — since the oldest match wins — it
|
||||
* would keep winning on every subsequent call.
|
||||
*
|
||||
* Matching is also scoped to one `type`: a party whose roster happens to be the people
|
||||
* you are opening a DM with is a different conversation, and handing it back would drop
|
||||
* the DM into the party.
|
||||
*/
|
||||
export async function findThreadWithMembers(
|
||||
db: D1Database,
|
||||
playerIds: number[],
|
||||
type: ChatThreadTypeValue = ChatThreadType.Player
|
||||
playerIds: number[]
|
||||
): Promise<number | null> {
|
||||
const members = [...new Set(playerIds)]
|
||||
if (members.length === 0) return null
|
||||
|
||||
// ?1 is the member count, ?2 the thread type; ?3… are the ids themselves.
|
||||
const placeholders = members.map((_, i) => `?${i + 3}`).join(', ')
|
||||
// ?1 is the member count; ?2… are the ids themselves.
|
||||
const placeholders = members.map((_, i) => `?${i + 2}`).join(', ')
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT m.chat_thread_id FROM thread_member m
|
||||
JOIN message_thread t ON t.chat_thread_id = m.chat_thread_id
|
||||
WHERE t.chat_thread_type = ?2
|
||||
GROUP BY m.chat_thread_id
|
||||
HAVING COUNT(*) = ?1
|
||||
AND COUNT(CASE WHEN m.player_id IN (${placeholders}) THEN 1 END) = ?1
|
||||
ORDER BY m.chat_thread_id
|
||||
LIMIT 1`
|
||||
)
|
||||
.bind(members.length, type, ...members)
|
||||
.bind(members.length, ...members)
|
||||
.first<{ chat_thread_id: number }>()
|
||||
return row?.chat_thread_id ?? null
|
||||
}
|
||||
@@ -441,20 +352,16 @@ export async function findThreadWithMembers(
|
||||
* simultaneous first-messages to the same set can still race into two threads; the
|
||||
* oldest-match rule in `findThreadWithMembers` means both parties converge on one of
|
||||
* them afterwards.
|
||||
*
|
||||
* `created` says which happened. The caller needs it: a thread that was just opened has to
|
||||
* be PUSHED to its members, or it sits on the server unseen until somebody posts to it —
|
||||
* the client has no "you were added to a thread" channel, so the opening notice going out
|
||||
* over the socket is the only thing that makes a new conversation appear.
|
||||
*/
|
||||
export async function getOrCreateThreadWithMembers(
|
||||
db: D1Database,
|
||||
playerIds: number[],
|
||||
startedBy: number
|
||||
): Promise<{ chatThreadId: number; created: boolean }> {
|
||||
const existing = await findThreadWithMembers(db, playerIds)
|
||||
if (existing !== null) return { chatThreadId: existing, created: false }
|
||||
return { chatThreadId: await createThread(db, playerIds, null, startedBy), created: true }
|
||||
): Promise<number> {
|
||||
return (
|
||||
(await findThreadWithMembers(db, playerIds)) ??
|
||||
(await createThread(db, playerIds, null, startedBy))
|
||||
)
|
||||
}
|
||||
|
||||
/** Everyone in a thread, ordered by id — the fan-out list for a push notification. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.13.5",
|
||||
"hono": "4.12.27",
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
@@ -33,6 +33,6 @@
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "26.0.1",
|
||||
"vitest": "4.1.9",
|
||||
"wrangler": "4.128.0"
|
||||
"wrangler": "4.105.0"
|
||||
}
|
||||
}
|
||||
|
||||
+29
-147
@@ -8,7 +8,6 @@ import {
|
||||
ClubMembershipType,
|
||||
ClubVisibility,
|
||||
countClubsByCreator,
|
||||
countClubSubscribers,
|
||||
createClub,
|
||||
createClubAnnouncement,
|
||||
deleteClub,
|
||||
@@ -20,9 +19,6 @@ import {
|
||||
getClubsByMember,
|
||||
getHomeClub,
|
||||
getMembership,
|
||||
getOrCreateSubscriptionClub,
|
||||
getSubscriptionClub,
|
||||
getSubscriptionMemberships,
|
||||
glyphLength,
|
||||
joinClub,
|
||||
leaveClub,
|
||||
@@ -34,9 +30,7 @@ import {
|
||||
setClubAdditionalImage,
|
||||
setHomeClub,
|
||||
setMemberType,
|
||||
subscribeToClub,
|
||||
updateClub,
|
||||
withDatabase,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
@@ -68,26 +62,19 @@ import {
|
||||
ModifyClubRequest,
|
||||
NullEnvelope,
|
||||
SubscriberCountResponse,
|
||||
SubscribeRequest,
|
||||
SubscriptionDetailsResponse,
|
||||
SubscriptionErrorEnvelope,
|
||||
SubscriptionIdEnvelope,
|
||||
SubscriptionMembershipsResponse,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from './openapi'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App, Env } from './context'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
* Clubs Worker. Hosts the club endpoints the game client calls on the `clubs` host:
|
||||
* club creation and editing, membership (join / ask-to-join / leave / ban tiers),
|
||||
* search, announcements, the club gallery, a club's clubhouse room, and each player's
|
||||
* home club, plus subscriptions. A creator's subscribers are the members of their
|
||||
* SUBSCRIPTION CLUB (`ClubType` 1, one per account, reached only through
|
||||
* `/subscription/*`): subscribing is joining it. Everything is D1-backed (the shared
|
||||
* `recflare` database). A subscription club is provisioned by the first
|
||||
* `/subscription/details/:accountId` read or `POST /subscription/:accountId` for that account.
|
||||
* home club. Everything is D1-backed (the shared `recflare` database); the
|
||||
* `/subscription/*` routes are stubs, since there are no subscription clubs yet.
|
||||
*
|
||||
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
|
||||
*/
|
||||
@@ -117,6 +104,7 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
*/
|
||||
const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
|
||||
|
||||
|
||||
/**
|
||||
* The tiers `members/invite` may grant — the real member roles only. Creator (100) is
|
||||
* excluded so an invite can't mint a second owner, and the pending/none/banned states
|
||||
@@ -322,56 +310,33 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// The caller's subscriptions — the subscription clubs they're a member of, one row
|
||||
// each with the creator, the club and its subscriber count; the subscription-club
|
||||
// counterpart of `/club/mine/member`. The client calls it on the clubs host with no
|
||||
// /club prefix, and has been seen calling it WITHOUT an auth header: a missing or
|
||||
// invalid token answers `[]` rather than 401, since the client chokes on null and
|
||||
// this is not worth failing a launch over.
|
||||
// A real Rec Room client endpoint with no backing implementation yet. The
|
||||
// client calls it on the clubs host at /subscription/mine/member (no /club
|
||||
// prefix) and sends no auth header, so it isn't gated. Returns an empty
|
||||
// array = no club subscription memberships (the client chokes on null).
|
||||
.get(
|
||||
'/subscription/mine/member',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'The caller’s subscriptions',
|
||||
summary: 'The caller’s club-subscription memberships',
|
||||
description: [
|
||||
'The subscription clubs the caller is a member of (`POST /subscription/{accountId}`),',
|
||||
'oldest first — the creator, the club and its subscriber count; the subscription-club',
|
||||
'counterpart of `/club/mine/member`. A creator is a member of their own subscription',
|
||||
'club, so it is listed for them too. The client has been seen calling this without',
|
||||
'an auth header, so a missing or invalid token answers `[]` rather than 401 (the',
|
||||
'client chokes on null).',
|
||||
'A real client endpoint with no backing implementation yet. The client calls it on',
|
||||
'the clubs host at `/subscription/mine/member` (no `/club` prefix) and sends no auth',
|
||||
'header, so it isn’t gated. Always `[]` — no subscription memberships (the client',
|
||||
'chokes on null).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(
|
||||
SubscriptionMembershipsResponse,
|
||||
'The caller’s subscriptions; `[]` when unauthenticated'
|
||||
),
|
||||
},
|
||||
responses: { 200: json(JsonArray, 'Always empty for now') },
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.json([])
|
||||
return c.json(await getSubscriptionMemberships(c.env.DB, id))
|
||||
}
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Subscription details for an account: their subscription club and its subscriber
|
||||
// count. This is where subscription clubs come from — the first read for an account
|
||||
// that has none PROVISIONS it (named after the creator, with them as its Creator-tier
|
||||
// member), so the client's "subscribe" button always has a club to post to. Only a
|
||||
// real account gets one; an unknown id reads as club 0. Public.
|
||||
// Subscription details for an account (numeric id) — simulated: no club, no subs.
|
||||
.get(
|
||||
'/subscription/details/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'Subscription details for an account',
|
||||
description: [
|
||||
'The account’s subscription club and its subscriber count (the creator not counted).',
|
||||
'The first read for an account with no subscription club PROVISIONS one — a',
|
||||
'`ClubType` 1 club named after the creator — so `clubId` is only 0 for an id that',
|
||||
'isn’t an account. Public.',
|
||||
].join(' '),
|
||||
description: 'Simulated — no subscription club, no subscribers.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'accountId',
|
||||
@@ -381,17 +346,14 @@ const app = new Hono<App>()
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(SubscriptionDetailsResponse, 'The account’s subscription details') },
|
||||
responses: { 200: json(SubscriptionDetailsResponse, 'Zeroed subscription details') },
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = Number.parseInt(c.req.param('accountId'), 10)
|
||||
const club = await getOrCreateSubscriptionClub(c.env.DB, accountId)
|
||||
return c.json({
|
||||
accountId,
|
||||
clubId: club?.ClubId ?? 0,
|
||||
subscriberCount: club ? await countClubSubscribers(c.env.DB, club.ClubId) : 0,
|
||||
(c) =>
|
||||
c.json({
|
||||
accountId: Number.parseInt(c.req.param('accountId'), 10),
|
||||
clubId: 0,
|
||||
subscriberCount: 0,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Details for a named subscription (e.g. `rrplus`). The client deserializes this
|
||||
@@ -419,85 +381,13 @@ const app = new Hono<App>()
|
||||
(c) => c.json({})
|
||||
)
|
||||
|
||||
// Subscribe to a creator: the caller joins the SUBSCRIPTION CLUB of the account
|
||||
// `accountId` — the `ClubType` 1 club provisioned for them (here too, if nobody has
|
||||
// read their `details` yet) — becoming a Member of it outright. Idempotent:
|
||||
// resubscribing answers the existing membership's id. Refused for one's own club and
|
||||
// for a banned account; an id that isn't an account is a 404. The `roomId` form field
|
||||
// (the room the subscribe was posted from) is accepted and unused. The envelope is the
|
||||
// reference's, with `error` NULL on success rather than the club routes' `""`; `value`
|
||||
// is the membership row's id, which is a guess at what the reference's opaque id was.
|
||||
.post(
|
||||
'/subscription/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'Subscribe to a creator',
|
||||
description: [
|
||||
'The caller joins the subscription club of the account `accountId` as a Member,',
|
||||
'provisioning the club first if that account has none yet. Idempotent: subscribing',
|
||||
'again answers the existing membership’s id. Refused for the caller’s own club and',
|
||||
'for a banned account; an id that isn’t an account is a 404. `roomId` is accepted',
|
||||
'and unused. Note the envelope is the reference’s — `error` and `errorId` are null',
|
||||
'on success, not `""`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'accountId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The creator to subscribe to (digits only)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
requestBody: form(SubscribeRequest, 'The room the subscribe was posted from'),
|
||||
responses: {
|
||||
200: json(SubscriptionIdEnvelope, 'The subscription’s id'),
|
||||
400: json(
|
||||
SubscriptionErrorEnvelope,
|
||||
'The caller’s own club, or the caller is banned from it'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: { description: 'No such account' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const club = await getOrCreateSubscriptionClub(
|
||||
c.env.DB,
|
||||
Number.parseInt(c.req.param('accountId'), 10)
|
||||
)
|
||||
if (club === null) return c.notFound()
|
||||
const result = await subscribeToClub(c.env.DB, club.ClubId, id)
|
||||
if (result === null) return c.notFound()
|
||||
|
||||
const refusal: Record<string, string | undefined> = {
|
||||
notSubscriptionClub: 'That is not a subscription club.',
|
||||
own: 'You cannot subscribe to yourself.',
|
||||
banned: 'You cannot subscribe to this creator.',
|
||||
}
|
||||
const message = refusal[result.outcome]
|
||||
if (message !== undefined) {
|
||||
return c.json({ value: null, success: false, errorId: null, error: message }, 400)
|
||||
}
|
||||
return c.json({ value: result.membershipId ?? 0, success: true, errorId: null, error: null })
|
||||
}
|
||||
)
|
||||
|
||||
// Subscriber count for an account: their subscription club's members, the creator
|
||||
// not counted. Public, a bare JSON integer; an account with no subscription club yet
|
||||
// (nobody has read its `details` or subscribed) has 0 — this read doesn't provision one.
|
||||
// Subscriber count for an account. No club subscriptions yet → 0.
|
||||
.get(
|
||||
'/subscription/subscriberCount/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'Subscriber count for an account',
|
||||
description: [
|
||||
'How many subscribers the account’s subscription club has, its creator not counted.',
|
||||
'Public. A bare JSON integer; an account with no subscription club has 0.',
|
||||
].join(' '),
|
||||
description: 'No club subscriptions yet, so this is always 0. A bare JSON integer.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'accountId',
|
||||
@@ -507,15 +397,9 @@ const app = new Hono<App>()
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(SubscriberCountResponse, 'The subscriber count') },
|
||||
responses: { 200: json(SubscriberCountResponse, 'Always 0') },
|
||||
}),
|
||||
async (c) => {
|
||||
const club = await getSubscriptionClub(
|
||||
c.env.DB,
|
||||
Number.parseInt(c.req.param('accountId'), 10)
|
||||
)
|
||||
return c.json(club ? await countClubSubscribers(c.env.DB, club.ClubId) : 0)
|
||||
}
|
||||
(c) => c.json(0)
|
||||
)
|
||||
|
||||
// The player's clubs that have unread announcements (MyClubsWithUnread-
|
||||
@@ -1682,9 +1566,7 @@ app.get(
|
||||
'membership (join / ask-to-join / leave, with the ban and pending tiers),',
|
||||
'search, announcements, the club gallery and clubhouse room, and each player’s home',
|
||||
'club. Everything is D1-backed on the shared `recflare` database; the',
|
||||
'A creator’s subscribers are the members of their subscription club (`ClubType` 1,',
|
||||
'one per account, provisioned by the first `details` read or subscribe for that account);',
|
||||
'the `/subscription/*` routes read and join those.',
|
||||
'`/subscription/*` routes are stubs, since there are no subscription clubs yet.',
|
||||
'',
|
||||
'Most writes answer the `{ error, success, value }` envelope with HTTP 200, and the',
|
||||
'ones the client re-renders a club screen from carry the club’s FULL details as',
|
||||
@@ -1707,4 +1589,4 @@ app.get(
|
||||
)
|
||||
)
|
||||
|
||||
export default withDatabase<Env>(app)
|
||||
export default app
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -84,256 +84,29 @@ describe('clubs endpoints', () => {
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /subscription/mine/member returns an empty array without a token', async () => {
|
||||
// The client calls this on the clubs host with no /club prefix and no auth.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/subscription/mine/member`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /subscription/details/:subscription returns an empty object', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/subscription/details/rrplus`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({})
|
||||
})
|
||||
|
||||
describe('subscriptions', () => {
|
||||
// A creator's subscribers are the members of their SUBSCRIPTION CLUB (ClubType 1).
|
||||
// Nothing provisions those yet, so the tests seed them the way the eventual
|
||||
// provisioner will: a ClubType 1 club with the creator as its Creator-tier member.
|
||||
let clubId = 0
|
||||
const CREATOR = 8800
|
||||
beforeAll(async () => {
|
||||
const idRow = await env.DB.prepare(
|
||||
'SELECT COALESCE(MAX(club_id), 0) + 1 AS next FROM club'
|
||||
).first<{
|
||||
next: number
|
||||
}>()
|
||||
clubId = idRow?.next ?? 1
|
||||
await env.DB.prepare('INSERT INTO club (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
ClubId: clubId,
|
||||
Name: 'Player8800 subscribers',
|
||||
Description: '',
|
||||
Category: '',
|
||||
Visibility: 1,
|
||||
Joinability: 0,
|
||||
AllowJuniors: true,
|
||||
MainImageName: '',
|
||||
ClubType: 1,
|
||||
ClubhouseRoomId: null,
|
||||
CreatorAccountId: CREATOR,
|
||||
IsRRO: false,
|
||||
MinLevel: 0,
|
||||
State: 0,
|
||||
MemberCount: 1,
|
||||
CreatedAt: '2026-07-01T00:00:00Z',
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, ?2, ?3, ?4)'
|
||||
)
|
||||
.bind(clubId, CREATOR, 100, '2026-07-01T00:00:00Z')
|
||||
.run()
|
||||
})
|
||||
test('GET /subscription/details/:accountId returns simulated details', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/subscription/details/2`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 })
|
||||
})
|
||||
|
||||
type Envelope = { value: number | null; success: boolean; errorId: null; error: string | null }
|
||||
type Membership = { AccountId: number; ClubId: number; SubscriberCount: number }
|
||||
type Details = { accountId: number; clubId: number; subscriberCount: number }
|
||||
|
||||
const subscribe = async (sub: string, id: number | string = CREATOR, body = 'roomId=78') =>
|
||||
exports.default.fetch(`${ORIGIN}/subscription/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
const details = async (accountId: number): Promise<Details> => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/subscription/details/${accountId}`)
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as Details
|
||||
}
|
||||
const count = async (accountId: number): Promise<number> => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/subscription/subscriberCount/${accountId}`)
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as number
|
||||
}
|
||||
const mine = async (headers: Record<string, string> = {}): Promise<Membership[]> => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/subscription/mine/member`, { headers })
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as Membership[]
|
||||
}
|
||||
|
||||
test('an id that isn’t an account reads as club 0 and never gets one', async () => {
|
||||
expect(await details(2)).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 })
|
||||
expect(await details(2)).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 })
|
||||
expect(await count(2)).toBe(0)
|
||||
const { results } = await env.DB.prepare(
|
||||
'SELECT club_id FROM club WHERE creator_account_id = 2'
|
||||
).all()
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
test('the first details read for an account provisions its subscription club', async () => {
|
||||
// Account 9101 exists (seeded) but has no subscription club: the count read
|
||||
// doesn't make one …
|
||||
expect(await count(9101)).toBe(0)
|
||||
expect(
|
||||
await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 9101').first()
|
||||
).toBeNull()
|
||||
|
||||
// … the details read does, and every read after agrees on it.
|
||||
const first = await details(9101)
|
||||
expect(first.clubId).toBeGreaterThan(0)
|
||||
expect(first).toEqual({ accountId: 9101, clubId: first.clubId, subscriberCount: 0 })
|
||||
expect(await details(9101)).toEqual(first)
|
||||
expect(await count(9101)).toBe(0)
|
||||
|
||||
// A ClubType 1 club named after the creator, Private, Open, with the creator as its
|
||||
// Creator-tier (and only) member; out of the creator's own club lists and search.
|
||||
const club = (await env.DB.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||
.bind(first.clubId)
|
||||
.first<{ data: string }>())!
|
||||
expect(JSON.parse(club.data)).toMatchObject({
|
||||
ClubId: first.clubId,
|
||||
Name: 'Player9101',
|
||||
ClubType: 1,
|
||||
Visibility: 0,
|
||||
Joinability: 0,
|
||||
CreatorAccountId: 9101,
|
||||
MemberCount: 1,
|
||||
})
|
||||
const { results: members } = await env.DB.prepare(
|
||||
'SELECT account_id, membership_type FROM club_member WHERE club_id = ?1'
|
||||
)
|
||||
.bind(first.clubId)
|
||||
.all()
|
||||
expect(members).toEqual([{ account_id: 9101, membership_type: 100 }])
|
||||
expect(await mine(await bearer('9101'))).toEqual([
|
||||
{ AccountId: 9101, ClubId: first.clubId, SubscriberCount: 0 },
|
||||
])
|
||||
const created = await exports.default.fetch(`${ORIGIN}/club/mine/created`, {
|
||||
headers: await bearer('9101'),
|
||||
})
|
||||
expect(await created.json()).toEqual([])
|
||||
const search = await exports.default.fetch(`${ORIGIN}/club/search?query=Player9101`)
|
||||
expect(((await search.json()) as { Clubs: unknown[] }).Clubs).toEqual([])
|
||||
|
||||
// Subscribing to the creator lands in the provisioned club.
|
||||
expect((await subscribe('9100', 9101)).status).toBe(200)
|
||||
expect(await details(9101)).toEqual({
|
||||
accountId: 9101,
|
||||
clubId: first.clubId,
|
||||
subscriberCount: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test('a fresh subscription club has its creator as a member but no subscribers', async () => {
|
||||
expect(await details(CREATOR)).toEqual({ accountId: CREATOR, clubId, subscriberCount: 0 })
|
||||
expect(await count(CREATOR)).toBe(0)
|
||||
// The creator is a Creator-tier member of their own club, so it's listed for them.
|
||||
expect(await mine(await bearer(String(CREATOR)))).toEqual([
|
||||
{ AccountId: CREATOR, ClubId: clubId, SubscriberCount: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /subscription/mine/member answers [] without a token', async () => {
|
||||
// The client has been seen calling this with no auth header: `[]`, not 401 (it
|
||||
// chokes on null). A garbage token is the same; so is a player with none.
|
||||
expect(await mine()).toEqual([])
|
||||
expect(await mine({ Authorization: 'Bearer nope' })).toEqual([])
|
||||
expect(await mine(await bearer('8801'))).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /subscription/:accountId joins the creator’s subscription club and the reads follow', async () => {
|
||||
// Auth-gated.
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/subscription/${CREATOR}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'roomId=78',
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
// The reference envelope: `error`/`errorId` null on success, `value` an id.
|
||||
const first = await subscribe('8801')
|
||||
expect(first.status).toBe(200)
|
||||
const created = (await first.json()) as Envelope
|
||||
expect(created).toMatchObject({ success: true, errorId: null, error: null })
|
||||
expect(created.value).toBeGreaterThan(0)
|
||||
|
||||
// The subscriber is a plain Member of the club; the club's count moved.
|
||||
const row = await env.DB.prepare(
|
||||
'SELECT club_member_id, membership_type FROM club_member WHERE club_id = ?1 AND account_id = 8801'
|
||||
)
|
||||
.bind(clubId)
|
||||
.first<{ club_member_id: number; membership_type: number }>()
|
||||
expect(row).toEqual({ club_member_id: created.value, membership_type: 10 })
|
||||
|
||||
// Idempotent: subscribing again (from another room, in the other casing) answers
|
||||
// the same id and nothing doubles.
|
||||
const again = await subscribe('8801', CREATOR, 'RoomId=99')
|
||||
expect(again.status).toBe(200)
|
||||
expect(((await again.json()) as Envelope).value).toBe(created.value)
|
||||
|
||||
// A second subscriber; every read agrees, and the creator isn't counted.
|
||||
expect((await subscribe('8802', CREATOR, '')).status).toBe(200)
|
||||
expect(await count(CREATOR)).toBe(2)
|
||||
expect(await details(CREATOR)).toEqual({ accountId: CREATOR, clubId, subscriberCount: 2 })
|
||||
expect(await mine(await bearer('8801'))).toEqual([
|
||||
{ AccountId: CREATOR, ClubId: clubId, SubscriberCount: 2 },
|
||||
])
|
||||
expect(await mine(await bearer('8802'))).toEqual([
|
||||
{ AccountId: CREATOR, ClubId: clubId, SubscriberCount: 2 },
|
||||
])
|
||||
|
||||
// The club's MemberCount counts the creator too, and the subscription club stays
|
||||
// out of the ordinary my-clubs lists.
|
||||
const club = await env.DB.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||
.bind(clubId)
|
||||
.first<{ data: string }>()
|
||||
expect((JSON.parse(club?.data ?? '{}') as { MemberCount: number }).MemberCount).toBe(3)
|
||||
const listed = await exports.default.fetch(`${ORIGIN}/club/mine/member`, {
|
||||
headers: await bearer('8801'),
|
||||
})
|
||||
expect(await listed.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /subscription/:accountId refuses the creator and the banned, 404s a non-account', async () => {
|
||||
const refused = async (res: Response, error: string): Promise<void> => {
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ value: null, success: false, errorId: null, error })
|
||||
}
|
||||
await refused(await subscribe(String(CREATOR)), 'You cannot subscribe to yourself.')
|
||||
|
||||
// A ban sticks.
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, 8804, -1, ?2)'
|
||||
)
|
||||
.bind(clubId, '2026-07-01T00:00:00Z')
|
||||
.run()
|
||||
await refused(await subscribe('8804'), 'You cannot subscribe to this creator.')
|
||||
|
||||
// An id that isn't an account is a 404 and mints no club; a non-numeric id
|
||||
// doesn't match the route.
|
||||
expect((await subscribe('8801', 999999)).status).toBe(404)
|
||||
expect(
|
||||
await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 999999').first()
|
||||
).toBeNull()
|
||||
expect((await subscribe('8801', 'abc')).status).toBe(404)
|
||||
})
|
||||
|
||||
test('subscribing to an account with no club yet provisions it', async () => {
|
||||
// Account 9100 exists (seeded) and nobody has read its details.
|
||||
expect(
|
||||
await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 9100').first()
|
||||
).toBeNull()
|
||||
const res = await subscribe('8801', 9100)
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as Envelope).success).toBe(true)
|
||||
|
||||
const d = await details(9100)
|
||||
expect(d.clubId).toBeGreaterThan(0)
|
||||
expect(d).toEqual({ accountId: 9100, clubId: d.clubId, subscriberCount: 1 })
|
||||
expect(await mine(await bearer('8801'))).toContainEqual({
|
||||
AccountId: 9100,
|
||||
ClubId: d.clubId,
|
||||
SubscriberCount: 1,
|
||||
})
|
||||
})
|
||||
test('GET /subscription/subscriberCount/:id returns 0', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/subscription/subscriberCount/2`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(0)
|
||||
})
|
||||
|
||||
test('GET /announcements/v2/mine/unread returns []', async () => {
|
||||
@@ -1668,7 +1441,6 @@ describe('clubs endpoints', () => {
|
||||
'POST /club/{clubId}/join',
|
||||
'POST /club/{clubId}/leave',
|
||||
'POST /club/{clubId}/members/leave',
|
||||
'POST /subscription/{accountId}',
|
||||
'PUT /club/home/me',
|
||||
'PUT /club/{clubId}/additionalimage/{index}',
|
||||
'PUT /club/{clubId}/clubhouse',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.13.5",
|
||||
"hono": "4.12.27",
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
@@ -30,6 +30,6 @@
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "26.0.1",
|
||||
"vitest": "4.1.9",
|
||||
"wrangler": "4.128.0"
|
||||
"wrangler": "4.105.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,26 +114,6 @@ const app = new Hono<App>()
|
||||
(c) => c.json(catalog)
|
||||
)
|
||||
|
||||
// Pending purchases the client asks the server to reconcile — platform transactions
|
||||
// it started but never saw finish. Nothing is ever recorded here, so there is never
|
||||
// anything pending and the answer is always an empty list. Accepts GET or POST since
|
||||
// the client may use either.
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/purchase/v1/cleanuppending',
|
||||
describeRoute({
|
||||
tags: ['Purchase'],
|
||||
summary: 'Reconcile pending purchases (no-op)',
|
||||
description: [
|
||||
'Always `[]` — no purchase is ever recorded, so nothing can be left pending. A 404',
|
||||
'here makes the client treat the call as an error, so the empty list is served',
|
||||
'instead. Accepts GET or POST.',
|
||||
].join(' '),
|
||||
responses: { 200: json(JsonArray, 'Always empty (nothing pending)') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Current purchase campaigns (limited-time offers/promos). None exist, and
|
||||
// an empty list is the client's "no active campaigns" state.
|
||||
.get(
|
||||
|
||||
@@ -43,14 +43,6 @@ describe('commerce endpoints', () => {
|
||||
expect(skus[0]).toHaveProperty('skuId')
|
||||
})
|
||||
|
||||
it('GET|POST /purchase/v1/cleanuppending returns []', async () => {
|
||||
for (const method of ['GET', 'POST']) {
|
||||
const res = await SELF.fetch(`${ORIGIN}/purchase/v1/cleanuppending`, { method })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('GET /purchasecampaign/allcurrent/v2 returns []', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/purchasecampaign/allcurrent/v2`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -86,11 +78,9 @@ describe('commerce endpoints', () => {
|
||||
expect([...documented].sort()).toEqual([
|
||||
'GET /',
|
||||
'GET /api/catalog/v1/all',
|
||||
'GET /purchase/v1/cleanuppending',
|
||||
'GET /purchase/v1/hasspentmoney',
|
||||
'GET /purchasecampaign/allcurrent/v2',
|
||||
'GET /reminder/currentTokenBundles/v2',
|
||||
'POST /purchase/v1/cleanuppending',
|
||||
'POST /purchase/v1/initiatepurchase',
|
||||
])
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
"compatibility_date": "2026-06-16",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"upload_source_maps": true,
|
||||
"cache": {
|
||||
"enabled": true
|
||||
},
|
||||
"observability": {
|
||||
"enabled": false
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user