mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
Compare commits
43 Commits
0.0.7
...
role-refactor
| Author | SHA1 | Date | |
|---|---|---|---|
| 7eab298ba1 | |||
| 7add875223 | |||
| 548089a3ec | |||
| f681af34fa | |||
| cdb17f5284 | |||
| db01d2bedd | |||
| 629a702403 | |||
| 261ff21e0d | |||
| 438475e326 | |||
| 87a1cd6b55 | |||
| 9a8fc1cab5 | |||
| 222547ee13 | |||
| 9696c56317 | |||
| a2946fb9f4 | |||
| acf70a083d | |||
| e0f802cee5 | |||
| 3d7ea3cf27 | |||
| 4bc5f51b9c | |||
| fef6754aad | |||
| 30b441aa65 | |||
| d441181b03 | |||
| 20196361c8 | |||
| bc12bb07d4 | |||
| 010c83d3ff | |||
| 36f8b01f83 | |||
| 6adb3ab741 | |||
| 9c63f077b8 | |||
| 2af5d5089d | |||
| cae0ebd4ae | |||
| ab623c376b | |||
| 1e26082f2e | |||
| 7d20f96414 | |||
| 99417e052e | |||
| 8260c5abcd | |||
| 740e9efa09 | |||
| 5928e07509 | |||
| 45a07c6ba5 | |||
| 3be2066526 | |||
| 8e452f23eb | |||
| 02c965c2ee | |||
| e07c52ced7 | |||
| 5aea5a4cf9 | |||
| a620cadde0 |
+52
-5
@@ -87,6 +87,17 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
|
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
|
||||||
# RECFLARE_MAX_CLUBS_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>
|
# 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
|
# 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
|
# with your own: 2=MyHub sends everyone who matchmakes into the Rec Center (room 2) to the
|
||||||
@@ -108,12 +119,21 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# RECFLARE_PHOTON_VOICE_APP_ID=
|
# RECFLARE_PHOTON_VOICE_APP_ID=
|
||||||
# RECFLARE_PHOTON_CHAT_APP_ID=
|
# RECFLARE_PHOTON_CHAT_APP_ID=
|
||||||
|
|
||||||
# The Tachyon voice server (`match`, GET /player/connection-info): the `host:port` the
|
# The pool of Tachyon servers sessions are spread across (`match`, GET
|
||||||
# client is handed as `voiceConnectionInfo`, and its id as `voiceServerId`. EMPTY unless
|
# /player/connection-info): a COMMA-SEPARATED list of `host:port` entries, one of which
|
||||||
# you set them — no separate voice server. Set both or neither; like the Photon ids they
|
# the client is handed as `voiceConnectionInfo`. EMPTY unless you set it — no separate
|
||||||
# are not secrets (the client receives them in the clear).
|
# 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:
|
||||||
# RECFLARE_TACHYON_HOST_PORT=127.0.0.1:7777
|
# RECFLARE_TACHYON_HOST_PORT=127.0.0.1:7777
|
||||||
# RECFLARE_TACHYON_NAME=server-1
|
#
|
||||||
|
# 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 Photon region every session is pinned to (`match`). Unlike the app ids above this
|
# 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
|
# does default, to `us` (us-east1) — an instance stamped with an empty region is one the
|
||||||
@@ -141,3 +161,30 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# Setting them both is what opens web signup; with either missing it stays closed. See
|
# 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
|
# DEPLOYING.md. Accounts are still created by the game either way, and both `auth` account
|
||||||
# caps above apply regardless.
|
# 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.
|
||||||
|
|||||||
@@ -44,6 +44,20 @@
|
|||||||
- Changesets manage versions and changelogs
|
- Changesets manage versions and changelogs
|
||||||
</architecture>
|
</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>
|
<code-style>
|
||||||
- Use tabs for indentation, spaces for alignment
|
- Use tabs for indentation, spaces for alignment
|
||||||
- Type imports use `import type`
|
- Type imports use `import type`
|
||||||
@@ -169,6 +183,13 @@ inconsistency here without checking the client first.
|
|||||||
- The cheer's reply is `{ Success, Message }` — PascalCase, with `Message` NULL on success.
|
- 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;
|
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.
|
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 the vote-to-kick message (`api`: `POST /api/PlayerReporting/v3/voteToKick`,
|
||||||
|
whose `Data` carries `{ PlayerId, Response, GameSessionId }` — `PlayerId` a string inside
|
||||||
|
it, as the reference relays it) and, the same way, a chat message's `Contents`.
|
||||||
- Leaderboard `Rank` (`leaderboard`: `GetRanks`, `GetNearbyScores`, `GetPlayerRank`) is
|
- 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
|
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
|
place and the top of a board must be 0. Its own slice says the same: it asks for the first
|
||||||
|
|||||||
+11
-10
@@ -193,16 +193,17 @@ 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,
|
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.
|
edit the value, then re-deploy the worker that reads them.
|
||||||
|
|
||||||
| `.env` variable | Read by | Default | What it does |
|
| `.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_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_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_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_MAX_UPLOAD_BYTES` | `storage` | `67108864` | Maximum binary upload size; larger files receive HTTP 413. |
|
||||||
| `RECFLARE_PHOTON_REALTIME_APP_ID` | `match` | empty | Your Photon Realtime app id. No app ships with recflare. |
|
| `RECFLARE_ROOM_REDIRECTS` | `match` | unset | Rooms to switch out on matchmake, e.g. `2=MyHub`. |
|
||||||
| `RECFLARE_PHOTON_VOICE_APP_ID` | `match` | empty | Your Photon Voice app id. |
|
| `RECFLARE_PHOTON_REALTIME_APP_ID` | `match` | empty | Your Photon Realtime app id. No app ships with recflare. |
|
||||||
| `RECFLARE_PHOTON_CHAT_APP_ID` | `match` | empty | Your Photon Chat app id. |
|
| `RECFLARE_PHOTON_VOICE_APP_ID` | `match` | empty | Your Photon Voice app id. |
|
||||||
| `RECFLARE_PHOTON_REGION` | `match` | `us` | Photon region every session is pinned to. |
|
| `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:
|
Then deploy just the worker that reads it:
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
|
<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
|
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
|
Cloudflare Workers. It implements the network services the Rec Room client talks
|
||||||
@@ -36,15 +36,24 @@ resulting in bugs getting fixed faster. I hope.
|
|||||||
|
|
||||||
## Game client
|
## Game client
|
||||||
|
|
||||||
See [RecFlare Client](https://github.com/djdevin/recflare-client)
|
See [RecFlare Client](https://github.com/djdevin/recflare-client) for the official RecFlare build.
|
||||||
|
|
||||||
RecFlare is compatible with the
|
These game builds are supported:
|
||||||
[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
|
| Build | Manifest | Support |
|
||||||
different endpoints and response shapes and are not supported.
|
| ------------- | --------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| `20230414` | `7859140924515540835` | **Default**, official — the 2023 build the rest of the stack targets |
|
||||||
|
| `20250718.01` | `1151455856673601091` | **Beta**, official — use the [patch-2025](https://github.com/recflare/patch-2025) patch |
|
||||||
|
| `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.
|
||||||
|
|
||||||
Generally speaking any client that effectively rewrites the nameserver with the
|
Generally speaking any client that effectively rewrites the nameserver with the
|
||||||
right mods can be used with this server.
|
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.
|
||||||
|
|
||||||
## Services
|
## Services
|
||||||
|
|
||||||
@@ -58,9 +67,11 @@ Want to run it yourself? See [DEPLOYING.md](DEPLOYING.md)
|
|||||||
|
|
||||||
### What year is this for?
|
### What year is this for?
|
||||||
|
|
||||||
This works with 2023 clients. It has been tested with manifest `7859140924515540835`. Other clients may not work.
|
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.
|
||||||
|
|
||||||
See the "Client" section above for instructions on how to modify a client to connect to this server.
|
See the "Game client" section above for instructions on how to modify a client to connect to this server.
|
||||||
|
|
||||||
### Can I run this locally on my PC?
|
### Can I run this locally on my PC?
|
||||||
|
|
||||||
@@ -70,11 +81,13 @@ See "Run the development microservices" above. It may be possible later as Wrang
|
|||||||
|
|
||||||
### Can I use this to make my own server?
|
### Can I use this to make my own server?
|
||||||
|
|
||||||
Yes, that's the point. Just set your custom domain and deploy it.
|
Yes, that's the point. See [DEPLOYING.md](DEPLOYING.md)
|
||||||
|
|
||||||
### Is there an admin panel?
|
### Is there an admin panel?
|
||||||
|
|
||||||
Not yet. But there could be.
|
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.
|
||||||
|
|
||||||
### Can I copy this project and modify it?
|
### Can I copy this project and modify it?
|
||||||
|
|
||||||
@@ -89,15 +102,20 @@ keeps those in sync: `pnpm` workspaces share dependencies, `@repo/` packages
|
|||||||
share code, Turborepo runs build/test/lint with a single cached task graph, and
|
share code, Turborepo runs build/test/lint with a single cached task graph, and
|
||||||
cross-service changes land in one atomic commit.
|
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
|
## Credits
|
||||||
|
|
||||||
I started this after the official servers shut down, so I could only see the
|
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
|
||||||
request shapes coming from the game client. I used many different projects as
|
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
|
resources to get response shapes, logic examples, enums, etc. They all had
|
||||||
missing pieces. Again, another reason to come together on one project and
|
missing pieces. Again, another reason to come together on one project and
|
||||||
stop gatekeeping.
|
stop gatekeeping.
|
||||||
|
|
||||||
Unfortunately, they were all leaked code except for
|
- [CannedNet](https://github.com/CannedNet/CannedNet)
|
||||||
[CannedNet](https://github.com/CannedNet/CannedNet), [DorkNet](https://github.com/DorkSquadRR/DorkNet), and jordanparki7's postman
|
- [DorkNet](https://github.com/DorkSquadRR/DorkNet)
|
||||||
collection of RecNet APIs which is gone for some reason. So I will not list the
|
- jordanparki7's postman collection of RecNet APIs which is gone for some reason.
|
||||||
leaks publicly.
|
- 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)
|
||||||
|
|||||||
@@ -15,12 +15,13 @@
|
|||||||
"test": "run-vitest"
|
"test": "run-vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@2toad/profanity": "3.3.0",
|
||||||
"@repo/domain": "workspace:*",
|
"@repo/domain": "workspace:*",
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -32,6 +33,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
CreateAccountResult,
|
CreateAccountResult,
|
||||||
DisplayNameRequest,
|
DisplayNameRequest,
|
||||||
EmailRequest,
|
EmailRequest,
|
||||||
|
EmojiRequest,
|
||||||
form,
|
form,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
IdentityFlagsRequest,
|
IdentityFlagsRequest,
|
||||||
@@ -45,7 +46,9 @@ import {
|
|||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
UsernameRequest,
|
UsernameRequest,
|
||||||
UsernameResult,
|
UsernameResult,
|
||||||
|
WhitelistedEmojis,
|
||||||
} from './openapi'
|
} from './openapi'
|
||||||
|
import { resolveWhitelistedEmoji, WHITELISTED_EMOJIS } from './whitelisted-emojis'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { Account } from '@repo/domain'
|
import type { Account } from '@repo/domain'
|
||||||
@@ -76,7 +79,7 @@ function unauthorized(c: Context<App>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Username changes a fresh account starts with (until one has been consumed). */
|
/** Username changes a fresh account starts with (until one has been consumed). */
|
||||||
const DEFAULT_USERNAME_CHANGES = 1
|
const DEFAULT_USERNAME_CHANGES = 3
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
|
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
|
||||||
@@ -109,8 +112,8 @@ function toAccountDto(account: Account) {
|
|||||||
username: account.username,
|
username: account.username,
|
||||||
displayName: account.displayName,
|
displayName: account.displayName,
|
||||||
profileImage: account.profileImage,
|
profileImage: account.profileImage,
|
||||||
// Nothing writes these yet, and rows stored before they existed have neither
|
// Rows stored before these fields existed have neither key — always emit them as
|
||||||
// key — always emit them as "" rather than letting them go missing.
|
// "" rather than letting them go missing.
|
||||||
bannerImage: account.bannerImage ?? '',
|
bannerImage: account.bannerImage ?? '',
|
||||||
displayEmoji: account.displayEmoji ?? '',
|
displayEmoji: account.displayEmoji ?? '',
|
||||||
isJunior: account.isJunior,
|
isJunior: account.isJunior,
|
||||||
@@ -210,6 +213,24 @@ const app = new Hono<App>()
|
|||||||
(c) => c.json({ service: 'accounts', status: 'ok' })
|
(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 --------------------------------------------------------
|
// ---- Self account --------------------------------------------------------
|
||||||
.get(
|
.get(
|
||||||
'/account/me',
|
'/account/me',
|
||||||
@@ -448,7 +469,9 @@ const app = new Hono<App>()
|
|||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
responses: {
|
responses: {
|
||||||
200: json(SuccessResponse, 'Updated'),
|
200: json(SuccessResponse, 'Updated'),
|
||||||
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
|
400: {
|
||||||
|
description: 'Empty, over 15 characters, non-alphanumeric, or profane (empty body)',
|
||||||
|
},
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -475,7 +498,8 @@ const app = new Hono<App>()
|
|||||||
tags: ['Profile'],
|
tags: ['Profile'],
|
||||||
summary: 'Change username',
|
summary: 'Change username',
|
||||||
description: [
|
description: [
|
||||||
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
|
'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',
|
||||||
'account and requires a remaining change; on success the name is persisted and',
|
'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`',
|
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
|
||||||
'(see the UsernameResult envelope).',
|
'(see the UsernameResult envelope).',
|
||||||
@@ -662,6 +686,46 @@ 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
|
// 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
|
// 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
|
// (the client posts one of their own photos, `sharecamera/<date>/<uuid>.jpg`), so this
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import {
|
|||||||
nameRejection,
|
nameRejection,
|
||||||
} from '@repo/domain'
|
} 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'
|
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +75,7 @@ export const AccountDto = z.object({
|
|||||||
bannerImage: z.string().describe('Profile banner key — always "" (nothing sets it yet)'),
|
bannerImage: z.string().describe('Profile banner key — always "" (nothing sets it yet)'),
|
||||||
displayEmoji: z
|
displayEmoji: z
|
||||||
.string()
|
.string()
|
||||||
.describe('Emoji beside the display name — always "" (nothing sets it yet)'),
|
.describe('Emoji beside the display name, set by PUT /account/me/emoji; "" when unset'),
|
||||||
isJunior: z.boolean(),
|
isJunior: z.boolean(),
|
||||||
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
|
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
|
||||||
personalPronouns: z.int().describe('Pronoun flags bitmask'),
|
personalPronouns: z.int().describe('Pronoun flags bitmask'),
|
||||||
@@ -134,6 +139,15 @@ export const ParentalControl = z.object({ accountId: z.int(), disallowInAppPurch
|
|||||||
*/
|
*/
|
||||||
export const PrivacySettings = z.object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() })
|
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. */
|
/** Root health check. */
|
||||||
export const HealthResponse = z.object({ service: z.literal('accounts'), status: z.literal('ok') })
|
export const HealthResponse = z.object({ service: z.literal('accounts'), status: z.literal('ok') })
|
||||||
|
|
||||||
@@ -158,26 +172,44 @@ export const CreateAccountRequest = z.object({
|
|||||||
* writes the player-facing sentence and there's no reason to write it twice.
|
* 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. */
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
const nameCheck = (label: string, max: number) =>
|
const nameCheck = (label: string, max: number) =>
|
||||||
z
|
z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
const rejection = nameRejection(value, label, max)
|
const rejection = nameRejection(value, label, max)
|
||||||
if (rejection !== null) ctx.addIssue({ code: 'custom', message: rejection })
|
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.` })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const DisplayNameRequest = z.object({
|
export const DisplayNameRequest = z.object({
|
||||||
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
|
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
|
||||||
.min(1)
|
.min(1)
|
||||||
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'),
|
.describe(
|
||||||
|
'Trimmed; letters and digits only, max 15, no profanity. Empty or invalid is rejected (400)'
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const UsernameRequest = z.object({
|
export const UsernameRequest = z.object({
|
||||||
username: nameCheck('username', MAX_USERNAME_LENGTH)
|
username: nameCheck('username', MAX_USERNAME_LENGTH)
|
||||||
.min(1, 'You must enter a username.')
|
.min(1, 'You must enter a username.')
|
||||||
.describe('Trimmed; letters and digits only, max 50. Must be unique and changes must remain'),
|
.describe(
|
||||||
|
'Trimmed; letters and digits only, max 50, no profanity. Must be unique and changes must remain'
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const EmailRequest = z.object({
|
export const EmailRequest = z.object({
|
||||||
@@ -207,6 +239,16 @@ export const BioRequest = z.object({
|
|||||||
bio: z.string().refine(isValidBio).describe('Free text, max 255; empty is allowed'),
|
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({
|
export const ProfileImageRequest = z.object({
|
||||||
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
|
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import '../../accounts.app'
|
|||||||
|
|
||||||
import { SCHEMA_DDL } from '@repo/domain'
|
import { SCHEMA_DDL } from '@repo/domain'
|
||||||
|
|
||||||
|
import { WHITELISTED_EMOJIS } from '../../whitelisted-emojis'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
declare module 'cloudflare:test' {
|
declare module 'cloudflare:test' {
|
||||||
@@ -149,6 +151,15 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(res.status).toBe(401)
|
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 () => {
|
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() })
|
const res = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer() })
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -160,11 +171,11 @@ describe('auth-gated endpoints', () => {
|
|||||||
username: 'Player42',
|
username: 'Player42',
|
||||||
personalPronouns: 0,
|
personalPronouns: 0,
|
||||||
identityFlags: 0,
|
identityFlags: 0,
|
||||||
availableUsernameChanges: 1,
|
availableUsernameChanges: 3,
|
||||||
// An unset email is "", not null — the client reads it as a string, and the
|
// 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.
|
// hub frame this DTO also rides drops null values outright.
|
||||||
email: '',
|
email: '',
|
||||||
// Nothing sets these yet, but the key has to be present — the client reads
|
// Unset on a fresh account, but the key has to be present — the client reads
|
||||||
// both off the account DTO.
|
// both off the account DTO.
|
||||||
bannerImage: '',
|
bannerImage: '',
|
||||||
displayEmoji: '',
|
displayEmoji: '',
|
||||||
@@ -181,7 +192,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
const res = await exports.default.fetch(`${ORIGIN}/parentalcontrol/me`, {
|
const res = await exports.default.fetch(`${ORIGIN}/parentalcontrol/me`, {
|
||||||
headers: await bearer(),
|
headers: await bearer(),
|
||||||
})
|
})
|
||||||
expect(await res.json()).toEqual({ accountId: 42, disallowInAppPurchases: false })
|
expect(await res.json()).toEqual({ accountId: 42, disallowInAppPurchases: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /accountprivacysettings/:id echoes the id with the privacy flags', async () => {
|
test('GET /accountprivacysettings/:id echoes the id with the privacy flags', async () => {
|
||||||
@@ -214,6 +225,32 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(((await me.json()) as { displayName: string }).displayName).toBe('laskdjfasdlfkj')
|
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 () => {
|
test('PUT /account/me/username 401s without a token', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||||
...form({ username: 'whoever' }),
|
...form({ username: 'whoever' }),
|
||||||
@@ -235,7 +272,31 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(body.value).toBe('')
|
expect(body.value).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PUT /account/me/username changes the name, decrements the counter, then blocks', async () => {
|
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 () => {
|
||||||
const headers = {
|
const headers = {
|
||||||
...(await bearer('892')),
|
...(await bearer('892')),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
@@ -260,11 +321,27 @@ describe('auth-gated endpoints', () => {
|
|||||||
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('892') })
|
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('892') })
|
||||||
).json()) as { username: string; availableUsernameChanges: number }
|
).json()) as { username: string; availableUsernameChanges: number }
|
||||||
expect(me.username).toBe('coachx')
|
expect(me.username).toBe('coachx')
|
||||||
expect(me.availableUsernameChanges).toBe(0)
|
expect(me.availableUsernameChanges).toBe(2)
|
||||||
|
|
||||||
// A second change is blocked — no changes remaining (still HTTP 200).
|
// 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).
|
||||||
const blocked = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
const blocked = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||||
...form({ username: 'coachy' }),
|
...form({ username: 'coachq' }),
|
||||||
headers,
|
headers,
|
||||||
})
|
})
|
||||||
expect(blocked.status).toBe(200)
|
expect(blocked.status).toBe(200)
|
||||||
@@ -328,6 +405,91 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(sent.map((n) => (n.data as { bannerImage?: string }).bannerImage)).toContain(key)
|
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 () => {
|
test('PUT /account/me/bannerimage 401s without a token, 400s without an imageName', async () => {
|
||||||
const anon = await exports.default.fetch(`${ORIGIN}/account/me/bannerimage`, {
|
const anon = await exports.default.fetch(`${ORIGIN}/account/me/bannerimage`, {
|
||||||
...form({ imageName: 'x.jpg' }),
|
...form({ imageName: 'x.jpg' }),
|
||||||
@@ -488,6 +650,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'GET /account/{id}',
|
'GET /account/{id}',
|
||||||
'GET /account/{id}/bio',
|
'GET /account/{id}/bio',
|
||||||
'GET /accountprivacysettings/{id}',
|
'GET /accountprivacysettings/{id}',
|
||||||
|
'GET /emojiConfig/whitelistedEmojis',
|
||||||
'GET /parentalcontrol/me',
|
'GET /parentalcontrol/me',
|
||||||
'POST /account/create',
|
'POST /account/create',
|
||||||
'POST /account/me/email',
|
'POST /account/me/email',
|
||||||
@@ -495,6 +658,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'PUT /account/me/bannerimage',
|
'PUT /account/me/bannerimage',
|
||||||
'PUT /account/me/bio',
|
'PUT /account/me/bio',
|
||||||
'PUT /account/me/displayname',
|
'PUT /account/me/displayname',
|
||||||
|
'PUT /account/me/emoji',
|
||||||
'PUT /account/me/identityflags',
|
'PUT /account/me/identityflags',
|
||||||
'PUT /account/me/personalpronouns',
|
'PUT /account/me/personalpronouns',
|
||||||
'PUT /account/me/profileimage',
|
'PUT /account/me/profileimage',
|
||||||
@@ -565,12 +729,12 @@ describe('name, email and bio validation', () => {
|
|||||||
expect(body.value).toBe('')
|
expect(body.value).toBe('')
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rationed change must NOT be spent by a refusal: an account starts with one,
|
// A rationed change must NOT be spent by a refusal: an account starts with three,
|
||||||
// and burning it on a typo would leave the player stuck with a name they never had.
|
// and burning it on a typo would leave the player stuck with a name they never had.
|
||||||
const me = (await (
|
const me = (await (
|
||||||
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('8801') })
|
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('8801') })
|
||||||
).json()) as { availableUsernameChanges: number }
|
).json()) as { availableUsernameChanges: number }
|
||||||
expect(me.availableUsernameChanges).toBe(1)
|
expect(me.availableUsernameChanges).toBe(3)
|
||||||
|
|
||||||
// 50 is the client's own cap, so a name that long has to be accepted.
|
// 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`, {
|
const ok = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||||
@@ -683,3 +847,26 @@ test('POST /account/me/phone stores an E.164 number exactly as the client sends
|
|||||||
// Verbatim — no normalising, no stripping of the +.
|
// Verbatim — no normalising, no stripping of the +.
|
||||||
expect(row?.phone).toBe('+15552223333')
|
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)
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -31,6 +31,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ 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
|
- **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.
|
(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:
|
- **Static data** is served verbatim:
|
||||||
- `src/default-avatar-items.ts` → `GET /api/avatar/v4/items`
|
- `src/default-avatar-items.ts` → `GET /api/avatar/v4/items`
|
||||||
- `src/default-settings.ts` → `GET /api/settings/v2`
|
- `src/default-settings.ts` → `GET /api/settings/v2`
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- 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);
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -34,6 +34,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export type Env = SharedHonoEnv & {
|
|||||||
* for local dev and tests.
|
* for local dev and tests.
|
||||||
*/
|
*/
|
||||||
DOMAIN: string
|
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
|
// Shared rooms database (schema/migrations owned by the `rooms` worker). Used
|
||||||
// read-only here to resolve room roles for `/api/rooms/v1/verifyRole`.
|
// read-only here to resolve room roles for `/api/rooms/v1/verifyRole`.
|
||||||
DB: D1Database
|
DB: D1Database
|
||||||
|
|||||||
@@ -290,9 +290,13 @@ export interface PlayerEventNotification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The client's BASE event — the 17-key shape the browse feed (`GET /api/playerevents/v1`)
|
* 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 room shelf (`.../room/{roomId}`) and the bulk read (`POST|GET .../bulk`) all serve,
|
||||||
* the stored record, but not identical to it — don't unify them:
|
* 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:
|
||||||
*
|
*
|
||||||
* - it drops `State`, which neither the feed nor the envelope carries;
|
* - it drops `State`, which neither the feed nor the envelope carries;
|
||||||
* - it carries `BroadcastingRoomInstanceId`, which the record has no field for (nothing
|
* - it carries `BroadcastingRoomInstanceId`, which the record has no field for (nothing
|
||||||
@@ -300,8 +304,8 @@ export interface PlayerEventNotification {
|
|||||||
* - its `ImageName` is a string: an event with no image reads `""`, where the record holds
|
* - its `ImageName` is a string: an event with no image reads `""`, where the record holds
|
||||||
* null.
|
* null.
|
||||||
*
|
*
|
||||||
* The by-id / bulk / search reads serve the stored RECORD verbatim instead, `State` and
|
* The by-id, search, searchlive and club reads serve the stored RECORD verbatim instead,
|
||||||
* nullable `ImageName` included. Two shapes; keep them apart.
|
* `State` and nullable `ImageName` included. Two shapes; keep them apart.
|
||||||
*/
|
*/
|
||||||
export interface PlayerEventBase extends Omit<PlayerEvent, 'State' | 'ImageName'> {
|
export interface PlayerEventBase extends Omit<PlayerEvent, 'State' | 'ImageName'> {
|
||||||
ImageName: string
|
ImageName: string
|
||||||
@@ -856,9 +860,12 @@ export async function getEventById(db: D1Database, eventId: number): Promise<Pla
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Several events by id — the bulk fetch. Answers in the order the ids were asked for
|
* Several events by id — the bulk fetch behind `POST /api/playerevents/v1/bulk` (the form
|
||||||
* (the client renders them in the order it requested), skipping ids with no row rather
|
* body the client sends) and the query-string GET on the same path. Answers in the order
|
||||||
* than leaving a hole. Duplicated ids resolve to the same event.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
|
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
|
||||||
if (ids.length === 0) return []
|
if (ids.length === 0) return []
|
||||||
@@ -907,7 +914,8 @@ 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.
|
* 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}`).
|
* 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.
|
||||||
*
|
*
|
||||||
* FINISHED events are left out, like the browse feed's: this answers "what can I still turn
|
* 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
|
* up to in this room", and an event that ended last month is not that. Running events count
|
||||||
|
|||||||
+20
-6
@@ -27,13 +27,27 @@ export function unauthorized(c: Context<App>) {
|
|||||||
return c.body(null, 401)
|
return c.body(null, 401)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads the `Ids` form field into a list of integer ids. */
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
export async function parseFormIds(c: Context<App>): Promise<number[]> {
|
export async function parseFormIds(c: Context<App>): Promise<number[]> {
|
||||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
const body = await c.req
|
||||||
const ids = body.Ids
|
.parseBody({ all: true })
|
||||||
if (typeof ids !== 'string') return []
|
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
|
||||||
return ids
|
const raw = [body.Ids, body.ids].flat()
|
||||||
.split(',')
|
return raw
|
||||||
|
.filter((v): v is string => typeof v === 'string')
|
||||||
|
.flatMap((v) => v.split(','))
|
||||||
.map((s) => Number.parseInt(s.trim(), 10))
|
.map((s) => Number.parseInt(s.trim(), 10))
|
||||||
.filter((n) => !Number.isNaN(n))
|
.filter((n) => !Number.isNaN(n))
|
||||||
}
|
}
|
||||||
|
|||||||
+483
-20
@@ -23,8 +23,8 @@ import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domai
|
|||||||
/**
|
/**
|
||||||
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql +
|
* 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
|
* 0008_invention_visibility.sql, sans any seed rows). `is_featured` backs the featured
|
||||||
* feed's query and `is_published`/`hide_from_player` the "may anyone see this" filter
|
* feed's query and `is_published`/`hide_from_player` most of the "may anyone see this"
|
||||||
* every feed shares; json_extract of a JSON `true` is 1, so those columns are 1/0 — and
|
* filter every feed shares (see `VISIBLE_IN_FEEDS`, which also excludes unlisted ones); json_extract of a JSON `true` is 1, so those columns are 1/0 — and
|
||||||
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
|
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
|
||||||
*/
|
*/
|
||||||
export const SCHEMA_DDL: string[] = [
|
export const SCHEMA_DDL: string[] = [
|
||||||
@@ -39,6 +39,15 @@ export const SCHEMA_DDL: string[] = [
|
|||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
|
`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_creator ON invention (creator_player_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_invention_featured ON invention (is_featured)`,
|
`CREATE INDEX IF NOT EXISTS idx_invention_featured ON invention (is_featured)`,
|
||||||
|
`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`). */
|
/** A single saved version of an invention (Rec Room's `RRInventionVersion`). */
|
||||||
@@ -53,6 +62,16 @@ export interface InventionVersion {
|
|||||||
ChipsCost: number
|
ChipsCost: number
|
||||||
CloudVariablesCost: number
|
CloudVariablesCost: number
|
||||||
AICost: 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,9 +122,32 @@ export interface SavedInvention {
|
|||||||
HideFromPlayer: boolean
|
HideFromPlayer: boolean
|
||||||
ReferencedInventions: number[]
|
ReferencedInventions: number[]
|
||||||
/**
|
/**
|
||||||
* Tags served by `v1/details` and written by `v1/settags`. Optional and unset on
|
* The rest of what `v9/save` sends, kept beside `ReferencedInventions` — the field
|
||||||
* save: the real `RRInvention` carries no Tags field and the client sends no tags
|
* they most resemble, and the one this record has always carried on the invention.
|
||||||
* when saving, so an untagged invention's DTO stays identical to the real one.
|
* 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?: InventionTag[]
|
Tags?: InventionTag[]
|
||||||
}
|
}
|
||||||
@@ -130,6 +172,224 @@ export function toSaveResult(invention: SavedInvention): InventionSaveResult {
|
|||||||
return { Status: 0, Invention: invention, InventionVersion: invention.CurrentVersion }
|
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
|
* 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
|
* on the `BlobName` it reads back. Uploads through the `storage` worker already land
|
||||||
@@ -189,6 +449,19 @@ export interface NewInvention {
|
|||||||
aiCost?: number
|
aiCost?: number
|
||||||
creationRoomId?: number | null
|
creationRoomId?: number | null
|
||||||
referencedInventions?: number[]
|
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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,6 +474,11 @@ export interface NewInvention {
|
|||||||
* what narrows `GeneralPermission` down (to UseOnly by default). Trials are allowed.
|
* 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
|
* 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.
|
* 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(
|
export async function createInvention(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -233,6 +511,7 @@ export async function createInvention(
|
|||||||
ChipsCost: input.chipsCost ?? 0,
|
ChipsCost: input.chipsCost ?? 0,
|
||||||
CloudVariablesCost: input.cloudVariablesCost ?? 0,
|
CloudVariablesCost: input.cloudVariablesCost ?? 0,
|
||||||
AICost: input.aiCost ?? 0,
|
AICost: input.aiCost ?? 0,
|
||||||
|
...(input.hasBetaContent === undefined ? {} : { HasBetaContent: input.hasBetaContent }),
|
||||||
},
|
},
|
||||||
Accessibility: 0,
|
Accessibility: 0,
|
||||||
IsPublished: false,
|
IsPublished: false,
|
||||||
@@ -252,6 +531,16 @@ export async function createInvention(
|
|||||||
AllowTrial: true,
|
AllowTrial: true,
|
||||||
HideFromPlayer: false,
|
HideFromPlayer: false,
|
||||||
ReferencedInventions: input.referencedInventions ?? [],
|
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()
|
await db.prepare('INSERT INTO invention (data) VALUES (?1)').bind(JSON.stringify(invention)).run()
|
||||||
return invention
|
return invention
|
||||||
@@ -379,7 +668,7 @@ export async function searchInventions(
|
|||||||
const offset = Math.max(skip, 0)
|
const offset = Math.max(skip, 0)
|
||||||
if (limit === 0) return []
|
if (limit === 0) return []
|
||||||
|
|
||||||
const where = ['is_published = 1', 'hide_from_player = 0']
|
const where = [...VISIBLE_IN_FEEDS]
|
||||||
const binds: Array<string | number> = []
|
const binds: Array<string | number> = []
|
||||||
/** Bind a value and get its placeholder, so the numbering can't drift as terms are added. */
|
/** Bind a value and get its placeholder, so the numbering can't drift as terms are added. */
|
||||||
const bind = (v: string | number): string => `?${binds.push(v)}`
|
const bind = (v: string | number): string => `?${binds.push(v)}`
|
||||||
@@ -421,8 +710,7 @@ async function publicInventions(db: D1Database, featuredOnly = false): Promise<S
|
|||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT data FROM invention
|
`SELECT data FROM invention
|
||||||
WHERE is_published = 1
|
WHERE ${VISIBLE_IN_FEEDS.join(' AND ')}
|
||||||
AND hide_from_player = 0
|
|
||||||
${featuredOnly ? 'AND is_featured = 1' : ''}`
|
${featuredOnly ? 'AND is_featured = 1' : ''}`
|
||||||
)
|
)
|
||||||
.all<InventionRow>()
|
.all<InventionRow>()
|
||||||
@@ -499,10 +787,9 @@ export async function getFeaturedInventions(
|
|||||||
/**
|
/**
|
||||||
* Replace an invention's tags (the `v1/settags` write). Auto tags are the ones the
|
* 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
|
* client derives from the invention itself (Type 2); custom tags are the creator's
|
||||||
* own (Type 0). Both lists are replaced wholesale — auto first, then custom, the
|
* own (Type 0). Both lists are replaced wholesale, normalized as
|
||||||
* order the tags come back in — and are lowercased/trimmed and de-duplicated so
|
* {@link normalizeInventionTags} describes. Returns the stored tag list, or null when
|
||||||
* `details` doesn't echo back near-duplicates. Returns the stored tag list, or null
|
* there's no such invention.
|
||||||
* when there's no such invention.
|
|
||||||
*/
|
*/
|
||||||
export async function setInventionTags(
|
export async function setInventionTags(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -513,6 +800,21 @@ export async function setInventionTags(
|
|||||||
const invention = await getInventionById(db, inventionId)
|
const invention = await getInventionById(db, inventionId)
|
||||||
if (invention === null) return null
|
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 tags: InventionTag[] = []
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
for (const [list, type] of [
|
for (const [list, type] of [
|
||||||
@@ -526,8 +828,6 @@ export async function setInventionTags(
|
|||||||
tags.push({ Tag: tag, Type: type })
|
tags.push({ Tag: tag, Type: type })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeInvention(db, { ...invention, Tags: tags })
|
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,6 +840,9 @@ export async function setInventionTags(
|
|||||||
export const INVENTION_PERMISSION = {
|
export const INVENTION_PERMISSION = {
|
||||||
unassigned: 0,
|
unassigned: 0,
|
||||||
limitedoneuseonly: 10,
|
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,
|
useonly: 20,
|
||||||
editandsave: 40,
|
editandsave: 40,
|
||||||
publish: 60,
|
publish: 60,
|
||||||
@@ -547,6 +850,36 @@ export const INVENTION_PERMISSION = {
|
|||||||
unlimited: 100,
|
unlimited: 100,
|
||||||
} as const
|
} 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.
|
||||||
|
*/
|
||||||
|
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`,
|
* 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.
|
* `edit_and_save`) or the raw number. Undefined when it's neither.
|
||||||
@@ -567,6 +900,13 @@ export interface InventionPatch {
|
|||||||
imageName?: string
|
imageName?: string
|
||||||
allowTrial?: boolean
|
allowTrial?: boolean
|
||||||
generalPermission?: number
|
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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -591,11 +931,27 @@ export async function updateInvention(
|
|||||||
ImageName: patch.imageName ?? invention.ImageName,
|
ImageName: patch.imageName ?? invention.ImageName,
|
||||||
AllowTrial: patch.allowTrial ?? invention.AllowTrial,
|
AllowTrial: patch.allowTrial ?? invention.AllowTrial,
|
||||||
GeneralPermission: patch.generalPermission ?? invention.GeneralPermission,
|
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)
|
await writeInvention(db, updated)
|
||||||
return 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.
|
* 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
|
* Publishing sets the permission other players get (UseOnly unless the creator asks
|
||||||
@@ -605,8 +961,7 @@ export async function updateInvention(
|
|||||||
export async function publishInvention(
|
export async function publishInvention(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
inventionId: number,
|
inventionId: number,
|
||||||
permissionLevel: number | undefined,
|
publish: InventionPublish = {}
|
||||||
price: number | undefined
|
|
||||||
): Promise<SavedInvention | null> {
|
): Promise<SavedInvention | null> {
|
||||||
const invention = await getInventionById(db, inventionId)
|
const invention = await getInventionById(db, inventionId)
|
||||||
if (invention === null) return null
|
if (invention === null) return null
|
||||||
@@ -614,8 +969,13 @@ export async function publishInvention(
|
|||||||
const updated: SavedInvention = {
|
const updated: SavedInvention = {
|
||||||
...invention,
|
...invention,
|
||||||
IsPublished: true,
|
IsPublished: true,
|
||||||
GeneralPermission: permissionLevel ?? INVENTION_PERMISSION.useonly,
|
GeneralPermission: publish.permissionLevel ?? INVENTION_PERMISSION.useonly,
|
||||||
Price: price ?? 0,
|
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.
|
||||||
FirstPublishedAt: invention.FirstPublishedAt ?? new Date().toISOString(),
|
FirstPublishedAt: invention.FirstPublishedAt ?? new Date().toISOString(),
|
||||||
}
|
}
|
||||||
await writeInvention(db, updated)
|
await writeInvention(db, updated)
|
||||||
@@ -638,6 +998,110 @@ export async function setInventionPrice(
|
|||||||
return updated
|
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. */
|
/** The tag filter chips the client offers when browsing inventions. */
|
||||||
export interface InventionTagFilters {
|
export interface InventionTagFilters {
|
||||||
PinnedFilters: string[]
|
PinnedFilters: string[]
|
||||||
@@ -714,8 +1178,7 @@ export async function getInventionsByRoom(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT data FROM invention
|
`SELECT data FROM invention
|
||||||
WHERE json_extract(data, '$.CreationRoomId') = ?1
|
WHERE json_extract(data, '$.CreationRoomId') = ?1
|
||||||
AND is_published = 1
|
AND ${VISIBLE_IN_FEEDS.join(' AND ')}`
|
||||||
AND hide_from_player = 0`
|
|
||||||
)
|
)
|
||||||
.bind(roomId)
|
.bind(roomId)
|
||||||
.all<InventionRow>()
|
.all<InventionRow>()
|
||||||
|
|||||||
+321
-28
@@ -119,12 +119,14 @@ export const SuccessErrorEnvelope = z.object({
|
|||||||
|
|
||||||
// ---- Config ----------------------------------------------------------------
|
// ---- Config ----------------------------------------------------------------
|
||||||
|
|
||||||
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
|
/** `GET /api/config/v1/amplitude` — the client's analytics keys (blank; RudderStack and StatSig off). */
|
||||||
export const AmplitudeConfig = z.object({
|
export const AmplitudeConfig = z.object({
|
||||||
AmplitudeKey: z.string(),
|
AmplitudeKey: z.string(),
|
||||||
StatSigKey: z.string(),
|
|
||||||
RudderStackKey: z.string(),
|
|
||||||
UseRudderStack: z.boolean(),
|
UseRudderStack: z.boolean(),
|
||||||
|
RudderStackKey: z.string(),
|
||||||
|
UseStatSig: z.boolean(),
|
||||||
|
StatSigKey: z.string(),
|
||||||
|
StatSigEnvironment: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** `GET /api/config/v1/azurespeech` — speech-to-text config; `Enabled` is false here. */
|
/** `GET /api/config/v1/azurespeech` — speech-to-text config; `Enabled` is false here. */
|
||||||
@@ -398,9 +400,15 @@ export const CustomAvatarItemResponse = z.object({
|
|||||||
error_id: z.string().nullable(),
|
error_id: z.string().nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** The `Ids` form body the bulk POST endpoints take. */
|
/**
|
||||||
|
* 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`.
|
||||||
|
*/
|
||||||
export const BulkIdsRequest = z.object({
|
export const BulkIdsRequest = z.object({
|
||||||
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
|
Ids: z
|
||||||
|
.union([z.string(), z.array(z.string())])
|
||||||
|
.describe('Repeated (`Ids=101&Ids=102`) or comma-separated (`Ids=1,2,3`)'),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Inventions ------------------------------------------------------------
|
// ---- Inventions ------------------------------------------------------------
|
||||||
@@ -420,6 +428,10 @@ export const InventionVersionDto = z.object({
|
|||||||
ChipsCost: z.int(),
|
ChipsCost: z.int(),
|
||||||
CloudVariablesCost: z.int(),
|
CloudVariablesCost: z.int(),
|
||||||
AICost: 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. */
|
/** A tag on an invention. `Type` 0 = custom (creator-submitted), 2 = auto-derived. */
|
||||||
@@ -456,10 +468,30 @@ export const InventionDto = z.object({
|
|||||||
AllowTrial: z.boolean(),
|
AllowTrial: z.boolean(),
|
||||||
HideFromPlayer: z.boolean(),
|
HideFromPlayer: z.boolean(),
|
||||||
ReferencedInventions: z.array(z.int()),
|
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
|
Tags: z
|
||||||
.array(InventionTagDto)
|
.array(InventionTagDto)
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Unset on save — the real RRInvention carries no Tags field'),
|
.describe(
|
||||||
|
'The real RRInvention carries no Tags field. Unset by `v6/save`; set by `v9/save` ' +
|
||||||
|
'when its `tagsRequest` names at least one tag'
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */
|
/** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */
|
||||||
@@ -469,6 +501,88 @@ export const InventionSaveResult = z.object({
|
|||||||
InventionVersion: InventionVersionDto,
|
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. */
|
/** The tag filter chips on a browse screen, derived from the tags actually in use. */
|
||||||
export const TagFilters = z.object({
|
export const TagFilters = z.object({
|
||||||
PinnedFilters: z.array(z.string()),
|
PinnedFilters: z.array(z.string()),
|
||||||
@@ -484,7 +598,13 @@ export const InventionDetails = z.object({ Tags: z.array(InventionTagDto) })
|
|||||||
|
|
||||||
/** `GET /api/inventions/v1/personaldetails/:id` — the caller's own relation to it. */
|
/** `GET /api/inventions/v1/personaldetails/:id` — the caller's own relation to it. */
|
||||||
export const InventionPersonalDetails = z.object({
|
export const InventionPersonalDetails = z.object({
|
||||||
IsCheering: z.boolean().describe('Always false — nothing can cheer an invention yet'),
|
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'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
|
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
|
||||||
@@ -506,6 +626,76 @@ export const SetTagsResponse = z.object({
|
|||||||
Tags: z.array(z.string()).describe('Auto tags first, then custom'),
|
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/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. */
|
/** `POST /api/inventions/v1/updateprice` JSON body. */
|
||||||
export const UpdatePriceRequest = z.object({
|
export const UpdatePriceRequest = z.object({
|
||||||
InventionId: z.int(),
|
InventionId: z.int(),
|
||||||
@@ -533,6 +723,34 @@ export const SaveInventionRequest = z.object({
|
|||||||
aiCost: z.int().optional(),
|
aiCost: z.int().optional(),
|
||||||
creationRoomId: z.int().optional(),
|
creationRoomId: z.int().optional(),
|
||||||
referencedInventions: z.array(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 ------------------------------------------
|
// ---- Avatar / custom avatar items ------------------------------------------
|
||||||
@@ -807,13 +1025,23 @@ export const PlayerEventDetailsDto = PlayerEventDto.extend({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The client's BASE event, 17 keys — what `GET /api/playerevents/v1` serves, and what the
|
* The client's BASE event, 17 keys — the stored record minus `State`, with `ImageName` as a
|
||||||
* v2 envelope carries once `Tags` is added. The stored record minus `State`, with
|
* string (`""`, not null) and a `BroadcastingRoomInstanceId` (always null — nothing
|
||||||
* `ImageName` as a string (`""`, not null) and a `BroadcastingRoomInstanceId` (always null —
|
* broadcasts an event yet). It is also what the v2 envelope carries once `Tags` is added.
|
||||||
* nothing broadcasts an event yet).
|
|
||||||
*
|
*
|
||||||
* The by-id, bulk and search reads serve the stored RECORD verbatim instead, so don't unify
|
* THREE reads serve exactly this, through one generic helper on the client and one element
|
||||||
* the two.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName: true }).extend({
|
export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName: true }).extend({
|
||||||
ImageName: z.string().describe('Empty string when the event has no image, never null'),
|
ImageName: z.string().describe('Empty string when the event has no image, never null'),
|
||||||
@@ -1036,24 +1264,62 @@ export const VoteToKickReason = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
|
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — the caller's block. With an
|
||||||
* answer (no ban storage yet), mirroring the reference server's stub
|
* account-wide ban in force (a `report` row with `banned` set) it describes that ban:
|
||||||
* `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1) rather than 0,
|
* `IsBan` true, the report's `ReportCategory`, a fixed `Message` of "Rule violation", and
|
||||||
* which is a real category, and `Message` is null — the client distinguishes "no
|
* its span as `TimeoutStartedAt` (the report's `created_at`) plus `Duration` (seconds to
|
||||||
* message" from a blank one, so we send null where the reference sends an empty string.
|
* `ban_expires`; int32 max for a permanent ban). Otherwise it is the "not blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`:
|
||||||
* `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but unset by that stub, so
|
* `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and
|
||||||
* they carry their C# defaults (false / null).
|
* `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.
|
||||||
*/
|
*/
|
||||||
export const ModerationBlockDetails = z.object({
|
export const ModerationBlockDetails = z.object({
|
||||||
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
|
ReportCategory: z
|
||||||
Duration: z.int(),
|
.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'
|
||||||
|
),
|
||||||
GameSessionId: z.int(),
|
GameSessionId: z.int(),
|
||||||
IsBan: z.boolean(),
|
IsHostKick: z.boolean().describe('Always false — no host kick is ever recorded here'),
|
||||||
IsHostKick: z.boolean(),
|
Message: z.string().nullable().describe('“Rule violation” on a ban; null when not blocked'),
|
||||||
IsVoiceModAutoban: z.boolean(),
|
PlayerIdReporter: z
|
||||||
Message: z.string().nullable(),
|
.int()
|
||||||
PlayerIdReporter: z.int().nullable(),
|
.nullable()
|
||||||
TimeoutStartedAt: z.string().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'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1098,6 +1364,33 @@ export const CreateWarningRequest = z.object({
|
|||||||
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
|
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'),
|
||||||
|
Response: z.string().describe('The caller’s own vote, e.g. `True`'),
|
||||||
|
Reason: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('A `voteToKickReasons` label, e.g. `Inactive in games (AFK)`'),
|
||||||
|
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/deviceId` form body — the id rotation the client reports. */
|
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
|
||||||
export const DeviceIdRequest = z.object({
|
export const DeviceIdRequest = z.object({
|
||||||
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
|
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
|
||||||
|
|||||||
+11
-11
@@ -20,14 +20,14 @@
|
|||||||
* one polymorphic id because the keys differ in TYPE: two numbers and a guid.
|
* one polymorphic id because the keys differ in TYPE: two numbers and a guid.
|
||||||
*
|
*
|
||||||
* A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned`
|
* A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned`
|
||||||
* on that same row (see `banFromReport`), so the ban carries the evidence for it. Two
|
* on that same row (see `banFromReport`), so the ban carries the evidence for it. It is
|
||||||
* workers read it — `match` refuses every matchmake for a banned player, and `auth`
|
* ENFORCED by `match`, which refuses every matchmake for a banned player, and DESCRIBED
|
||||||
* refuses to issue them a token at all — both via `isPlayerBanned`. This is distinct
|
* by `/api/PlayerReporting/v1/moderationBlockDetails`, which tells the banned player why
|
||||||
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player
|
* (via `getActiveBan`). `auth` still issues a banned account a token — that is what lets
|
||||||
* out of ONE room, this one out of the game.
|
* 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
|
||||||
* `/api/PlayerReporting/v1/moderationBlockDetails` is NOT wired to it yet and still
|
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player out
|
||||||
* answers "not blocked" unconditionally.
|
* of ONE room, this one out of the game.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,9 +199,9 @@ export async function getActiveBan(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a player is banned right now. The hot-path form of `getActiveBan` — `match`
|
* Whether a player is banned right now. The hot-path form of `getActiveBan`, for a caller
|
||||||
* calls it on every matchmake and `auth` on every token grant, and neither has anything
|
* that has nothing to say about WHICH report did it. `moderationBlockDetails` is the
|
||||||
* to say about WHICH report did it.
|
* caller that does, and reads `getActiveBan` itself.
|
||||||
*/
|
*/
|
||||||
export async function isPlayerBanned(
|
export async function isPlayerBanned(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
|
|||||||
+540
-69
@@ -4,8 +4,10 @@ import { describeRoute } from 'hono-openapi'
|
|||||||
import {
|
import {
|
||||||
CURRENT_OUTFIT_SLOT,
|
CURRENT_OUTFIT_SLOT,
|
||||||
getOutfit,
|
getOutfit,
|
||||||
|
getOutfits,
|
||||||
getOutfitsByAccounts,
|
getOutfitsByAccounts,
|
||||||
inventionDescriptionRejection,
|
inventionDescriptionRejection,
|
||||||
|
inventionLongDescriptionRejection,
|
||||||
inventionNameRejection,
|
inventionNameRejection,
|
||||||
inventionTagRejection,
|
inventionTagRejection,
|
||||||
MAX_BULK_OUTFIT_ACCOUNTS,
|
MAX_BULK_OUTFIT_ACCOUNTS,
|
||||||
@@ -26,6 +28,7 @@ import {
|
|||||||
import { authedId, unauthorized } from '../http'
|
import { authedId, unauthorized } from '../http'
|
||||||
import {
|
import {
|
||||||
createInvention,
|
createInvention,
|
||||||
|
deleteInvention,
|
||||||
getFeaturedInventions,
|
getFeaturedInventions,
|
||||||
getInventionById,
|
getInventionById,
|
||||||
getInventionsByIds,
|
getInventionsByIds,
|
||||||
@@ -35,13 +38,20 @@ import {
|
|||||||
getInventionVersion,
|
getInventionVersion,
|
||||||
getMyInventions,
|
getMyInventions,
|
||||||
getTopInventions,
|
getTopInventions,
|
||||||
|
INVENTION_TAG_RESULT,
|
||||||
|
inventionDeleteResult,
|
||||||
|
inventionSaveV9Failure,
|
||||||
|
isInventionCheered,
|
||||||
|
normalizeInventionTags,
|
||||||
ownsAllInventions,
|
ownsAllInventions,
|
||||||
parsePermissionLevel,
|
parsePermissionLevel,
|
||||||
publishInvention,
|
publishInvention,
|
||||||
searchInventions,
|
searchInventions,
|
||||||
|
setInventionCheer,
|
||||||
setInventionPrice,
|
setInventionPrice,
|
||||||
setInventionTags,
|
setInventionTags,
|
||||||
toSaveResult,
|
toSaveResult,
|
||||||
|
toSaveResultV9,
|
||||||
updateInvention,
|
updateInvention,
|
||||||
} from '../inventions-db'
|
} from '../inventions-db'
|
||||||
import {
|
import {
|
||||||
@@ -54,17 +64,21 @@ import {
|
|||||||
CustomAvatarItemReportRequest,
|
CustomAvatarItemReportRequest,
|
||||||
CustomAvatarItemResponse,
|
CustomAvatarItemResponse,
|
||||||
CustomAvatarItemsPage,
|
CustomAvatarItemsPage,
|
||||||
|
DeleteInventionRequest,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
form,
|
form,
|
||||||
GeneratedGift,
|
GeneratedGift,
|
||||||
GenerateGiftRequest,
|
GenerateGiftRequest,
|
||||||
idParam,
|
idParam,
|
||||||
intQuery,
|
intQuery,
|
||||||
|
InventionCheerRequest,
|
||||||
|
InventionDeleteResult,
|
||||||
InventionDetails,
|
InventionDetails,
|
||||||
InventionDto,
|
InventionDto,
|
||||||
InventionPersonalDetails,
|
InventionPersonalDetails,
|
||||||
InventionReportRequest,
|
InventionReportRequest,
|
||||||
InventionSaveResult,
|
InventionSaveResult,
|
||||||
|
InventionSaveV9Result,
|
||||||
InventionVersionDto,
|
InventionVersionDto,
|
||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
@@ -77,7 +91,9 @@ import {
|
|||||||
OutfitsMeRequest,
|
OutfitsMeRequest,
|
||||||
OutfitsMeResponse,
|
OutfitsMeResponse,
|
||||||
pageParams,
|
pageParams,
|
||||||
|
PublishInventionRequest,
|
||||||
SaveInventionRequest,
|
SaveInventionRequest,
|
||||||
|
SaveInventionV9Request,
|
||||||
SetTagsRequest,
|
SetTagsRequest,
|
||||||
SetTagsResponse,
|
SetTagsResponse,
|
||||||
stringParam,
|
stringParam,
|
||||||
@@ -87,13 +103,15 @@ import {
|
|||||||
TagFilters,
|
TagFilters,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
UpdateCustomAvatarItemRequest,
|
UpdateCustomAvatarItemRequest,
|
||||||
|
UpdateInventionMetadataRequest,
|
||||||
UpdatePriceRequest,
|
UpdatePriceRequest,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
import { createReport } from '../reports-db'
|
import { createReport } from '../reports-db'
|
||||||
|
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
import type { SavedInvention } from '../inventions-db'
|
import type { InventionTag, SavedInvention } from '../inventions-db'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The most ids `POST /api/customAvatarItems/v1/bulk` will resolve. A batch over this answers
|
* The most ids `POST /api/customAvatarItems/v1/bulk` will resolve. A batch over this answers
|
||||||
@@ -133,24 +151,150 @@ async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The gate every invention write runs through: the caller must be signed in, the
|
* 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 the
|
* invention must exist, and it must be theirs. Yields the loaded invention, or why not —
|
||||||
* error response to return as-is (401 / 404 / 403).
|
* 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).
|
||||||
*/
|
*/
|
||||||
async function creatorsInvention(
|
async function creatorsInvention(
|
||||||
c: Context<App>,
|
c: Context<App>,
|
||||||
inventionId: number
|
inventionId: number
|
||||||
): Promise<{ invention: SavedInvention } | { response: Response | Promise<Response> }> {
|
): Promise<{ invention: SavedInvention } | { response: Response | Promise<Response> }> {
|
||||||
const playerId = await authedId(c)
|
const gate = await creatorsInventionResult(c, inventionId)
|
||||||
if (playerId === null) return { response: unauthorized(c) }
|
if ('invention' in gate) return gate
|
||||||
if (Number.isNaN(inventionId)) {
|
if (gate.status === 401) return { response: unauthorized(c) }
|
||||||
return { response: c.json({ error: 'inventionId is required' }, 400) }
|
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 invention = await getInventionById(c.env.DB, inventionId)
|
|
||||||
if (invention === null) return { response: c.notFound() }
|
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
|
||||||
if (invention.CreatorPlayerId !== playerId) {
|
creatorPlayerId,
|
||||||
return { response: c.json({ error: 'Not your invention' }, 403) }
|
inventionDataFilename,
|
||||||
}
|
name,
|
||||||
return { invention }
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -315,6 +459,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
200: json(CustomAvatarItemResponse, 'The created item'),
|
200: json(CustomAvatarItemResponse, 'The created item'),
|
||||||
400: json(CustomAvatarItemResponse, 'Missing or malformed metadata / files'),
|
400: json(CustomAvatarItemResponse, 'Missing or malformed metadata / files'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -340,6 +485,31 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
return fail('BaseAvatarItemColor is required')
|
return fail('BaseAvatarItemColor is required')
|
||||||
if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required')
|
if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required')
|
||||||
if (!(body.design instanceof File)) return fail('design 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
|
// 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
|
// the item's id (chosen here so the keys can carry it). The `img` worker serves
|
||||||
@@ -882,29 +1052,46 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// The caller's outfit wardrobe. An empty list for now — the outfits saved through
|
// The caller's outfit wardrobe — every slot they have saved, ordered by slot. The same
|
||||||
// `PUT /outfits/me` are in the shared `outfit` table already, but which of them
|
// read as `econ`'s `GET /api/avatar/v3/saved`, on the bare path the newer client uses:
|
||||||
// belong in this list (and in what shape) has not been pinned down, so it answers []
|
// both worker's write paths land in the shared `outfit` table, so both list endpoints
|
||||||
// rather than guessing.
|
// 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.
|
||||||
.get(
|
.get(
|
||||||
'/outfits/me/saved',
|
'/outfits/me/saved',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Avatar', '2025'],
|
tags: ['Avatar', '2025'],
|
||||||
summary: 'The caller’s saved outfits',
|
summary: 'The caller’s saved outfits',
|
||||||
description:
|
description:
|
||||||
'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' +
|
'The wardrobe behind the newer outfit screen: every slot the caller has saved, ' +
|
||||||
'through `PUT /outfits/me` are in the shared `outfit` table, but which of them this ' +
|
'ordered by slot, and `[]` when they have saved none. The same rows `econ`’s ' +
|
||||||
'list should carry, and in what shape, is not pinned down yet.',
|
'`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.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
responses: {
|
responses: {
|
||||||
200: json(JsonArray, 'An empty list'),
|
200: json(JsonArray, 'The saved outfits, ordered by slot (empty when none)'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
return c.json([])
|
return c.json(await getOutfits(c.env.DB, id))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1035,23 +1222,26 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// The signed-in player's own relationship to an invention (`/personaldetails/2`)
|
// 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
|
// just whether they're cheering it. Signed-out callers read false: there is no player
|
||||||
// invention yet), so this is always false; it stays a 200 for signed-out callers
|
// whose interaction could be looked up, and the client still needs a flag to render.
|
||||||
// too, since the client only reads the flag.
|
|
||||||
.get(
|
.get(
|
||||||
'/api/inventions/v1/personaldetails/:inventionId{[0-9]+}',
|
'/api/inventions/v1/personaldetails/:inventionId{[0-9]+}',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Inventions'],
|
tags: ['Inventions'],
|
||||||
summary: 'The caller’s own relation to an invention',
|
summary: 'The caller’s own relation to an invention',
|
||||||
description:
|
description:
|
||||||
'Just whether the caller is cheering it. We store no cheers, so it is always false ' +
|
'Whether the caller is cheering this invention. Signed-out callers receive false, ' +
|
||||||
'— and this stays a 200 for signed-out callers too, since the client only reads the ' +
|
'since there is no player interaction to look up.',
|
||||||
'flag.',
|
|
||||||
parameters: [idParam('inventionId', 'Invention id')],
|
parameters: [idParam('inventionId', 'Invention id')],
|
||||||
responses: { 200: json(InventionPersonalDetails, 'Always not cheering') },
|
responses: { 200: json(InventionPersonalDetails, 'The caller’s cheer state') },
|
||||||
}),
|
}),
|
||||||
(c) => c.json({ IsCheering: false })
|
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) })
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// A single version of an invention (`?inventionId=…&version=…`) — the bare
|
// A single version of an invention (`?inventionId=…&version=…`) — the bare
|
||||||
@@ -1216,12 +1406,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
const permissionLevel = c.req.query('permissionLevel')
|
const permissionLevel = c.req.query('permissionLevel')
|
||||||
const price = Number.parseInt(c.req.query('price') ?? '', 10)
|
const price = Number.parseInt(c.req.query('price') ?? '', 10)
|
||||||
|
|
||||||
const published = await publishInvention(
|
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
|
||||||
c.env.DB,
|
permissionLevel:
|
||||||
gate.invention.InventionId,
|
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
|
||||||
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
|
price: Number.isNaN(price) || price < 0 ? undefined : price,
|
||||||
Number.isNaN(price) || price < 0 ? undefined : price
|
})
|
||||||
)
|
|
||||||
return published === null ? c.notFound() : c.json(toSaveResult(published))
|
return published === null ? c.notFound() : c.json(toSaveResult(published))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1568,6 +1757,42 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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 —
|
// Report an invention. Stored in the `report` table the player and event reports use —
|
||||||
// same fields, same moderation life — with `invention_id` set. See
|
// same fields, same moderation life — with `invention_id` set. See
|
||||||
// migrations/0016_report_invention.sql.
|
// migrations/0016_report_invention.sql.
|
||||||
@@ -1670,43 +1895,289 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||||||
|
|
||||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
const outcome = await createInventionFromBody(c, id, body)
|
||||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
if ('rejection' in outcome) return c.json({ error: outcome.rejection }, 400)
|
||||||
|
return c.json(toSaveResult(outcome.invention))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
|
// The same save as the newer client sends it: v6's body plus the invention's
|
||||||
if (!inventionDataFilename) {
|
// references, its long description and display metadata, what the saved blob is, and
|
||||||
return c.json({ error: 'inventionDataFilename is required' }, 400)
|
// 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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",
|
for (const rejection of [
|
||||||
// "No description yet"), so only a supplied one is held to the rules — otherwise
|
name === undefined ? null : inventionNameRejection(name),
|
||||||
// saving an unnamed invention would fail the 3-character minimum on a name the
|
description === undefined ? null : inventionDescriptionRejection(description),
|
||||||
// player never typed.
|
longDescription === undefined
|
||||||
const name = str(body.name)?.trim()
|
? null
|
||||||
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
|
: inventionLongDescriptionRejection(longDescription),
|
||||||
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
|
]) {
|
||||||
|
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
|
||||||
const description = str(body.description)
|
}
|
||||||
const descriptionRejection =
|
|
||||||
description === undefined ? null : inventionDescriptionRejection(description)
|
// A null TagsRequest leaves the stored tags alone, and the reply still reports
|
||||||
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
|
// them: the client reads the list back as the tags the invention now has, not as
|
||||||
|
// the ones this call changed.
|
||||||
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
|
const requested = requestedTags(body.TagsRequest)
|
||||||
creatorPlayerId: id,
|
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||||||
inventionDataFilename,
|
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
imageName: str(body.imageName),
|
longDescription,
|
||||||
instantiationCost: num(body.instantiationCost),
|
imageName: edited('ImageName'),
|
||||||
lightsCost: num(body.lightsCost),
|
tags: requested?.tags,
|
||||||
chipsCost: num(body.chipsCost),
|
|
||||||
cloudVariablesCost: num(body.cloudVariablesCost),
|
|
||||||
aiCost: num(body.aiCost),
|
|
||||||
creationRoomId: num(body.creationRoomId),
|
|
||||||
referencedInventions: Array.isArray(body.referencedInventions)
|
|
||||||
? body.referencedInventions.filter((v): v is number => typeof v === 'number')
|
|
||||||
: undefined,
|
|
||||||
})
|
})
|
||||||
return c.json(toSaveResult(invention))
|
if (updated === null) return c.json(inventionSaveV9Failure('No such invention'))
|
||||||
|
return c.json(
|
||||||
|
toSaveResultV9(
|
||||||
|
updated,
|
||||||
|
updated.Tags ?? [],
|
||||||
|
requested?.tagResult ?? 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()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,17 +29,19 @@ export const configRoutes = new Hono<App>({ strict: false })
|
|||||||
tags: ['Config'],
|
tags: ['Config'],
|
||||||
summary: 'Analytics keys',
|
summary: 'Analytics keys',
|
||||||
description:
|
description:
|
||||||
'The Amplitude / StatSig / RudderStack keys the client initialises its analytics ' +
|
'The Amplitude / RudderStack / StatSig keys the client initialises its analytics ' +
|
||||||
'with. This server collects nothing, so the keys are placeholders and RudderStack ' +
|
'with. This server collects nothing, so the keys are blank and RudderStack and ' +
|
||||||
'is off — but the client needs the object to finish loading.',
|
'StatSig are off — but the client needs the object to finish loading.',
|
||||||
responses: { 200: json(AmplitudeConfig, 'Placeholder analytics keys') },
|
responses: { 200: json(AmplitudeConfig, 'Placeholder analytics keys') },
|
||||||
}),
|
}),
|
||||||
(c) =>
|
(c) =>
|
||||||
c.json({
|
c.json({
|
||||||
AmplitudeKey: 'a',
|
AmplitudeKey: '',
|
||||||
StatSigKey: 'a',
|
|
||||||
RudderStackKey: 'a',
|
|
||||||
UseRudderStack: false,
|
UseRudderStack: false,
|
||||||
|
RudderStackKey: '',
|
||||||
|
UseStatSig: false,
|
||||||
|
StatSigKey: '',
|
||||||
|
StatSigEnvironment: 0,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ import {
|
|||||||
toEventResult,
|
toEventResult,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
} from '../events-db'
|
} from '../events-db'
|
||||||
import { authedId, queryIds, unauthorized } from '../http'
|
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
|
||||||
import {
|
import {
|
||||||
AUTHED,
|
AUTHED,
|
||||||
|
BulkIdsRequest,
|
||||||
form,
|
form,
|
||||||
idParam,
|
idParam,
|
||||||
intQuery,
|
intQuery,
|
||||||
@@ -383,9 +384,12 @@ 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 room's event shelf (`/room/12`) — what is on in this room, current and upcoming.
|
||||||
// A bare array of the stored record, like the multi-club shelf and `/searchlive`: the
|
// A bare array, no envelope: the single-club form's `{ ContinuationToken, Events }` is
|
||||||
// single-club form's `{ ContinuationToken, Events }` envelope is the odd one out, and a
|
// the odd one out, and a room's shelf is small enough that there is nothing to page.
|
||||||
// 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.
|
||||||
.get(
|
.get(
|
||||||
'/api/playerevents/v1/room/:roomId{[0-9]+}',
|
'/api/playerevents/v1/room/:roomId{[0-9]+}',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -393,17 +397,25 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
|||||||
summary: 'Player events in one room',
|
summary: 'Player events in one room',
|
||||||
description:
|
description:
|
||||||
'The events scheduled in a room — the shelf on the room’s page — soonest first. A ' +
|
'The events scheduled in a room — the shelf on the room’s page — soonest first. A ' +
|
||||||
'bare array of the stored record, the same projection `/searchlive` and the ' +
|
'bare array of the client’s BASE event (17 keys — no `State`, `ImageName` as `""` ' +
|
||||||
'multi-club shelf serve.\n\n' +
|
'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' +
|
||||||
'CURRENT and UPCOMING only: the filter is on the END time, so a running event stays ' +
|
'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 ' +
|
'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 ' +
|
'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 ' +
|
'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.',
|
'empty array; the shelf is about events, not about whether the room is real.',
|
||||||
parameters: [idParam('roomId', 'Room id')],
|
parameters: [idParam('roomId', 'Room id')],
|
||||||
responses: { 200: json(PlayerEventDto.array(), 'The room’s current and upcoming events') },
|
responses: {
|
||||||
|
200: json(PlayerEventBaseDto.array(), 'The room’s current and upcoming events'),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => c.json(await getEventsByRoom(c.env.DB, Number.parseInt(c.req.param('roomId'), 10)))
|
async (c) => {
|
||||||
|
const events = await getEventsByRoom(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
|
||||||
|
return c.json(events.map(toEventBase))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Live player-event search (the "happening now" browse query) — events that have
|
// Live player-event search (the "happening now" browse query) — events that have
|
||||||
@@ -457,22 +469,62 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bulk fetch (`?id=1&id=2`) — the events behind a list of ids the client already
|
// Bulk fetch — the events behind a list of ids the client already holds. What the
|
||||||
// holds. Answers in the order asked for; ids with no event are skipped.
|
// client actually calls is the POST, with the ids in a form body
|
||||||
.get(
|
// (`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(
|
||||||
'/api/playerevents/v1/bulk',
|
'/api/playerevents/v1/bulk',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Events'],
|
tags: ['Events'],
|
||||||
summary: 'Several player events by id',
|
summary: 'Several player events by id',
|
||||||
description:
|
description:
|
||||||
'The events behind a list of ids the client already holds (`?id=1&id=2`). Answers ' +
|
'The events behind a list of ids the client already holds, as a form body: `Ids` ' +
|
||||||
'in the order the ids were asked for — the client renders them in request order — ' +
|
'repeated once per id (`Ids=101&Ids=102&Ids=103`), or one comma-separated `Ids=1,2,3`. ' +
|
||||||
'and skips ids with no event rather than leaving a hole, so the result may be ' +
|
'A bare array of the client’s BASE event — the same projection the browse feed and ' +
|
||||||
'shorter than the request. A bare array.',
|
'the room shelf serve, this one filtered to the requested ids.\n\n' +
|
||||||
parameters: [intQuery('id', 'Repeatable event id')],
|
'Answers in the order the ids were asked for and skips ids with no event rather ' +
|
||||||
responses: { 200: json(PlayerEventDto.array(), 'The events that exist, in request order') },
|
'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) => c.json(await getEventsByIds(c.env.DB, queryIds(c)))
|
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.',
|
||||||
|
parameters: [intQuery('id', 'Repeatable event id')],
|
||||||
|
responses: {
|
||||||
|
200: json(PlayerEventBaseDto.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))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// RSVP. One row per player per event, so responding again replaces the previous
|
// RSVP. One row per player per event, so responding again replaces the previous
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
UploadImageRequest,
|
UploadImageRequest,
|
||||||
UploadImageResponse,
|
UploadImageResponse,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
|
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
@@ -210,6 +211,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
200: json(UploadImageResponse, 'The stored bucket key'),
|
200: json(UploadImageResponse, 'The stored bucket key'),
|
||||||
400: json(ErrorResponse, 'No file in the request'),
|
400: json(ErrorResponse, 'No file in the request'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
413: json(ErrorResponse, 'The image exceeds the configured per-file limit'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -221,6 +223,12 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
const candidate = body.image ?? body.file
|
const candidate = body.image ?? body.file
|
||||||
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
|
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
|
||||||
const file = candidate
|
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`),
|
// `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`),
|
||||||
// posted as a multipart field. It carries the metadata we record on the image
|
// posted as a multipart field. It carries the metadata we record on the image
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
import { describeRoute } from 'hono-openapi'
|
||||||
|
|
||||||
|
import {
|
||||||
|
canModerateRoom,
|
||||||
|
deletePresence,
|
||||||
|
getPlayerIdsInInstance,
|
||||||
|
getPresences,
|
||||||
|
getRoomById,
|
||||||
|
getStoredRoomInstance,
|
||||||
|
MessageType,
|
||||||
|
refreshInstanceFullness,
|
||||||
|
} 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 { KickReportCategory } from '../../../notify/src/notification-payloads'
|
||||||
|
import { NotificationType } from '../../../notify/src/notification-types'
|
||||||
import { authedId, authedRoles, unauthorized } from '../http'
|
import { authedId, authedRoles, unauthorized } from '../http'
|
||||||
import {
|
import {
|
||||||
AUTHED,
|
AUTHED,
|
||||||
@@ -9,18 +25,23 @@ import {
|
|||||||
CreateWarningRequest,
|
CreateWarningRequest,
|
||||||
DeviceIdRequest,
|
DeviceIdRequest,
|
||||||
form,
|
form,
|
||||||
|
InstantKickRequest,
|
||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
|
jsonBody,
|
||||||
ModerationBlockDetails,
|
ModerationBlockDetails,
|
||||||
SuccessErrorEnvelope,
|
SuccessErrorEnvelope,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
VoteToKickReason,
|
VoteToKickReason,
|
||||||
|
VoteToKickRequest,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
import { createReport } from '../reports-db'
|
import { createReport, getActiveBan } from '../reports-db'
|
||||||
import { createWarning } from '../warnings-db'
|
import { createWarning } from '../warnings-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
|
import type { ModerationKickPayload } from '../../../notify/src/notification-payloads'
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
|
import type { ReportRow } from '../reports-db'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
|
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
|
||||||
@@ -82,16 +103,209 @@ const VOTE_TO_KICK_REASONS = [
|
|||||||
{ Reason: 'Not following game rules', ReportCategory: 6 },
|
{ Reason: 'Not following game rules', ReportCategory: 6 },
|
||||||
] as const
|
] 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
|
||||||
|
): Promise<void> {
|
||||||
|
const frame: ModerationKickPayload = {
|
||||||
|
ReportCategory: KickReportCategory.Moderator,
|
||||||
|
Duration: 0,
|
||||||
|
GameSessionId: gameSessionId,
|
||||||
|
IsHostKick: true,
|
||||||
|
Message: `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's `Data` says, BEFORE it is serialized. It reaches the
|
||||||
|
* client as an escaped JSON string, never as a nested object — a Message's `Data` is a
|
||||||
|
* string on the wire like every other Message's, and the client's decoder rejects an
|
||||||
|
* object outright: `expected:'String Begin Token', actual:'{'`, which aborts the whole
|
||||||
|
* notification rather than dropping the field. Serialize it with {@link voteToKickData}.
|
||||||
|
*
|
||||||
|
* `PlayerId` is the account id as a STRING — the reference passes the posted form field
|
||||||
|
* straight through, and this mirrors it verbatim.
|
||||||
|
*
|
||||||
|
* `Response` is the empty string even though the caller posted their own vote: the frame
|
||||||
|
* is the PROMPT put to everyone else, so it carries no answer yet. The caller's `Response`
|
||||||
|
* is theirs alone and is not relayed.
|
||||||
|
*/
|
||||||
|
interface VoteToKickData {
|
||||||
|
PlayerId: string
|
||||||
|
Response: string
|
||||||
|
GameSessionId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialize a {@link VoteToKickData} into the escaped JSON string `Data` carries. */
|
||||||
|
const voteToKickData = (data: VoteToKickData): string => JSON.stringify(data)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Message a vote-to-kick frame carries — the same four fields as every other Message
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* `Duration` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
|
||||||
|
* start for the duration. The start is the report's `created_at` — nothing records when
|
||||||
|
* the ban itself was handed down, and the report is the record the ban rests on — and the
|
||||||
|
* duration is the seconds from there to `ban_expires`, so the two sum to the expiry; or
|
||||||
|
* `PERMANENT_BAN_DURATION` when there is none. The category is the one the report was
|
||||||
|
* filed under, so the client's ban screen names the reason. `Message` is a fixed "Rule
|
||||||
|
* violation" rather than the report's `details` — those are the REPORTER's words, and the
|
||||||
|
* banned player isn't shown them, for the same reason `PlayerIdReporter` stays null: the
|
||||||
|
* reporter is not a host who kicked them, and naming them would tell the banned player who
|
||||||
|
* reported them. Everything else keeps its `NOT_BLOCKED` value: the other block kinds and
|
||||||
|
* screen dressings, none of which this server hands out.
|
||||||
|
*/
|
||||||
|
function banBlockDetails(ban: ReportRow) {
|
||||||
|
const startedAt = Date.parse(ban.created_at)
|
||||||
|
const duration =
|
||||||
|
ban.ban_expires === null
|
||||||
|
? PERMANENT_BAN_DURATION
|
||||||
|
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - startedAt) / 1000))
|
||||||
|
return {
|
||||||
|
...NOT_BLOCKED,
|
||||||
|
ReportCategory: ban.report_category,
|
||||||
|
Duration: duration,
|
||||||
|
IsBan: true,
|
||||||
|
Message: 'Rule violation',
|
||||||
|
TimeoutStartedAt: ban.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Player reporting ------------------------------------------------------
|
// ---- Player reporting ------------------------------------------------------
|
||||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
|
// Whether the caller is currently blocked (banned / timed out / host-kicked). The one
|
||||||
// are stored (a report row with `banned` set) and enforced at matchmake and at login,
|
// kind of block this server has is the account-wide ban — a `report` row with `banned`
|
||||||
// but this endpoint is not wired to them, so it's always the "not blocked" answer —
|
// set (see `getActiveBan`), the same row matchmake refuses on — so a caller with one in
|
||||||
// the reference server's stub `ReturnModerationBlockDetails()`.
|
// force gets it described here, and everyone else gets the "not blocked" answer of the
|
||||||
// `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category;
|
// reference server's stub `ReturnModerationBlockDetails()`. This is the screen a banned
|
||||||
// `Message` is null, not the empty string that stub sends — the client distinguishes
|
// player is shown, which is why `auth` still issues them a token: without one the client
|
||||||
// "no message" from a blank one. `IsVoiceModAutoban`/`TimeoutStartedAt` are on the
|
// never gets here, and the ban reads as a failed sign-in.
|
||||||
// DTO but left unset there, so they go out with their C# defaults.
|
// 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.
|
||||||
// The newer client POSTs this with no body despite it being a pure read; it answers
|
// 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.
|
// GET too, so the path is reachable from either build.
|
||||||
.on(
|
.on(
|
||||||
@@ -101,29 +315,40 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
tags: ['Moderation'],
|
tags: ['Moderation'],
|
||||||
summary: 'Whether the caller is blocked',
|
summary: 'Whether the caller is blocked',
|
||||||
description:
|
description:
|
||||||
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' +
|
'Ban / timeout / host-kick state for the caller. The one block this server hands ' +
|
||||||
'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' +
|
'out is the account-wide ban — a `report` row with `banned` set, the same row ' +
|
||||||
'not wired to them, so it is always the “not blocked” answer, following the ' +
|
'matchmake refuses on (login still issues a token, so the client can reach this ' +
|
||||||
'reference server’s stub: `ReportCategory` is `Unknown` (-1) rather than 0, which ' +
|
'screen) — so a caller with one in force gets ' +
|
||||||
'is a real category, and `Message` is null rather than the empty string that stub ' +
|
'`IsBan: true`, the `ReportCategory` the report was filed under, the fixed ' +
|
||||||
'sends — the client distinguishes “no message” from a blank one. ' +
|
'`Message` “Rule violation”, and the block’s span as the pair the client reads ' +
|
||||||
'`IsVoiceModAutoban` and `TimeoutStartedAt` are on the DTO but unset by that ' +
|
'them as: `TimeoutStartedAt` is the report’s `created_at` and `Duration` the ' +
|
||||||
'stub, so they carry their defaults. Answers GET or POST: the newer client POSTs ' +
|
'seconds from there to `ban_expires` (2147483647, the int32 max, for a permanent ' +
|
||||||
'it with no body.',
|
'ban). ' +
|
||||||
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
|
'`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,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
(c) =>
|
async (c) => {
|
||||||
c.json({
|
const id = await authedId(c)
|
||||||
ReportCategory: -1,
|
if (id === null) return unauthorized(c)
|
||||||
Duration: 0,
|
const ban = await getActiveBan(c.env.DB, id)
|
||||||
GameSessionId: 0,
|
return c.json(ban ? banBlockDetails(ban) : NOT_BLOCKED)
|
||||||
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 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
|
// the client renders them in the order they arrive — and the list is grouped by the
|
||||||
@@ -209,10 +434,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
tags: ['Moderation'],
|
tags: ['Moderation'],
|
||||||
summary: 'Submit a player report',
|
summary: 'Submit a player report',
|
||||||
description:
|
description:
|
||||||
'Records a player report in the `report` table; nothing dedupes the rows, and ' +
|
'Records a player report in the `report` table; nothing dedupes the rows. A report ' +
|
||||||
'`moderationBlockDetails` still answers “not blocked” unconditionally. A report ' +
|
|
||||||
'is filed unbanned — a moderator converts one into an account-wide ban by setting ' +
|
'is filed unbanned — a moderator converts one into an account-wide ban by setting ' +
|
||||||
'`banned` on the row, which is what matchmaking and `/connect/token` refuse on.\n\n' +
|
'`banned` on the row, which is what matchmaking refuses on and what ' +
|
||||||
|
'`moderationBlockDetails` describes to the banned player.\n\n' +
|
||||||
'The reporter is the caller (from the bearer token), NOT a body field. Only ' +
|
'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 ' +
|
'`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 ' +
|
'(a report raised outside a room carries no `RoomId`), and those are stored as ' +
|
||||||
@@ -257,6 +482,212 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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. Nothing tallies the votes yet: this relays the prompt and no more.
|
||||||
|
.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' +
|
||||||
|
'`Data` is an ESCAPED JSON STRING — `"{\\"PlayerId\\":\\"205\\",…}"`, not a nested ' +
|
||||||
|
'object. A Message’s `Data` is a string on the wire, and an object there fails the ' +
|
||||||
|
"client’s decoder outright (`expected:'String Begin Token', actual:'{'`), " +
|
||||||
|
'aborting the notification rather than dropping the field. Inside it, `PlayerId` ' +
|
||||||
|
'is the account id as a STRING, as the reference relays it, and `Response` is ' +
|
||||||
|
'empty — the frame is the question, not an answer.\n\n' +
|
||||||
|
'The frames are EPHEMERAL: a vote belongs to the moment it was called, so an ' +
|
||||||
|
'offline player gets nothing rather than a prompt about a dead session on their ' +
|
||||||
|
'next connect.\n\n' +
|
||||||
|
'Nothing is stored — no tally, no report row, and `Reason` is accepted and ' +
|
||||||
|
'unused. Answers the same lowercase `{ success, error }` envelope as the report ' +
|
||||||
|
'write; a hub failure for any recipient is reported honestly as a 500, since ' +
|
||||||
|
'with nothing behind it the frame is the whole delivery.',
|
||||||
|
security: AUTHED,
|
||||||
|
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'),
|
||||||
|
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>)
|
||||||
|
// Kept as posted for the frame — `Data.PlayerId` goes out as the string the
|
||||||
|
// reference relays — but parsed here to check it against presence.
|
||||||
|
const playerIdField = formField(body, c, 'PlayerId')
|
||||||
|
const playerId = asInt(playerIdField)
|
||||||
|
if (playerIdField === undefined || playerId === null) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, {
|
||||||
|
FromPlayerId: voterId,
|
||||||
|
ToPlayerId: toPlayerId,
|
||||||
|
Type: MessageType.VoteToKick,
|
||||||
|
// An escaped JSON STRING, not a nested object — see VoteToKickData.
|
||||||
|
Data: voteToKickData({
|
||||||
|
PlayerId: playerIdField,
|
||||||
|
Response: '',
|
||||||
|
GameSessionId: gameSessionId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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 loses their presence row (they read offline at once and the ' +
|
||||||
|
'instance frees a slot) and gets a `ModerationKick` frame (id 22) — the frame the ' +
|
||||||
|
'client acts on to leave. It is the same frame a room ban sends, but `IsBan` is ' +
|
||||||
|
'false: this only removes them from the session they are in, and nothing stops ' +
|
||||||
|
'them rejoining. The frame is EPHEMERAL — a kick is true of the moment it ' +
|
||||||
|
'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
|
||||||
|
await deletePresence(c.env.DB, playerId)
|
||||||
|
kicked.push(playerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kicked.length > 0) {
|
||||||
|
// The instance just lost players — recompute its fullness so a full room opens
|
||||||
|
// back up, exactly as the `match` worker does when someone logs out.
|
||||||
|
await refreshInstanceFullness(c.env.DB, gameSessionId)
|
||||||
|
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
|
||||||
|
await pushInstantKick(c, kicked, gameSessionId, roomName, moderatorId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true, error: '' })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// A warning handed down by a moderator — the staff-side counterpart to a report.
|
// 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.
|
// Gated on the `moderator` role in the token, not just a valid one.
|
||||||
.post(
|
.post(
|
||||||
|
|||||||
@@ -60,6 +60,40 @@ export function containsSwears(value: string): boolean {
|
|||||||
return value !== '' && filter.exists(value)
|
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`. */
|
/** The mask `POST /api/sanitize/v1` uses when the request names no `ReplacementChar`. */
|
||||||
export const DEFAULT_REPLACEMENT_CHAR = '*'
|
export const DEFAULT_REPLACEMENT_CHAR = '*'
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
PRESENCE_TTL_SECONDS,
|
PRESENCE_TTL_SECONDS,
|
||||||
PROGRESSION_SCHEMA_DDL,
|
PROGRESSION_SCHEMA_DDL,
|
||||||
RELATIONSHIP_SCHEMA_DDL,
|
RELATIONSHIP_SCHEMA_DDL,
|
||||||
|
ROOM_INSTANCE_SCHEMA_DDL,
|
||||||
ROOM_SCHEMA_DDL,
|
ROOM_SCHEMA_DDL,
|
||||||
seedRoomWithSubRooms,
|
seedRoomWithSubRooms,
|
||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
@@ -63,7 +64,11 @@ import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../war
|
|||||||
import type { SavedImage } from '@repo/domain'
|
import type { SavedImage } from '@repo/domain'
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
import type { EventTag, PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db'
|
import type { EventTag, PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db'
|
||||||
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
|
import type {
|
||||||
|
InventionSaveResult,
|
||||||
|
InventionSaveV9Result,
|
||||||
|
SavedInvention,
|
||||||
|
} from '../../inventions-db'
|
||||||
|
|
||||||
declare module 'cloudflare:test' {
|
declare module 'cloudflare:test' {
|
||||||
interface ProvidedEnv extends Env {}
|
interface ProvidedEnv extends Env {}
|
||||||
@@ -90,6 +95,19 @@ const TEST_ROOMS = [
|
|||||||
SubRooms: [{ SubRoomId: 3 }],
|
SubRooms: [{ SubRoomId: 3 }],
|
||||||
Roles: [{ AccountId: 42, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
|
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.
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -128,6 +146,10 @@ beforeAll(async () => {
|
|||||||
// Presence (owned by the rooms worker) — the online-friend count joins onto it.
|
// 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()
|
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.
|
// 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()
|
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
|
||||||
@@ -209,10 +231,12 @@ describe('public endpoints', () => {
|
|||||||
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual({
|
expect(await res.json()).toEqual({
|
||||||
AmplitudeKey: 'a',
|
AmplitudeKey: '',
|
||||||
StatSigKey: 'a',
|
|
||||||
RudderStackKey: 'a',
|
|
||||||
UseRudderStack: false,
|
UseRudderStack: false,
|
||||||
|
RudderStackKey: '',
|
||||||
|
UseStatSig: false,
|
||||||
|
StatSigKey: '',
|
||||||
|
StatSigEnvironment: 0,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -726,32 +750,6 @@ describe('public endpoints', () => {
|
|||||||
expect(charadesWordsFor(new Date('2026-04-01T23:59:59Z'))).toBe(april)
|
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
|
// 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.
|
// 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 () => {
|
test('GET /api/PlayerReporting/v1/voteToKickReasons serves the reasons in order', async () => {
|
||||||
@@ -1568,15 +1566,58 @@ describe('public endpoints', () => {
|
|||||||
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null)
|
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /outfits/me/saved 401s without a token, returns [] with one', async () => {
|
test('GET /outfits/me/saved lists every saved slot, ordered by slot', async () => {
|
||||||
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`)
|
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`)
|
||||||
expect(anon.status).toBe(401)
|
expect(anon.status).toBe(401)
|
||||||
// Empty even for account 42, which saved an outfit through PUT /outfits/me above.
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
|
// A distinct account, so this doesn't depend on what the tests above saved for 42.
|
||||||
headers: await bearer(),
|
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,
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(await res.json()).toEqual([])
|
// 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([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /outfits/bulk serves each account’s worn outfit, keyed by id', async () => {
|
test('POST /outfits/bulk serves each account’s worn outfit, keyed by id', async () => {
|
||||||
@@ -1808,6 +1849,701 @@ describe('public endpoints', () => {
|
|||||||
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
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 () => {
|
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
|
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
|
||||||
// exactly this row) and also creates one of their own.
|
// exactly this row) and also creates one of their own.
|
||||||
@@ -2488,17 +3224,71 @@ describe('public endpoints', () => {
|
|||||||
expect(noId.status).toBe(400)
|
expect(noId.status).toBe(400)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/inventions/v1/personaldetails/:id reports the cheer flag', async () => {
|
test('POST /api/inventions/v1/cheer persists and personaldetails reflects it', async () => {
|
||||||
// No cheer storage yet, so nobody is ever cheering — signed in or not.
|
const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`, {
|
method: 'POST',
|
||||||
headers: await bearer('42'),
|
headers: { ...(await bearer('8200')), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'Cheerable Lamp', inventionDataFilename: 'cheerable.inv' }),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
const invention = ((await saved.json()) as InventionSaveResult).Invention
|
||||||
expect(await res.json()).toEqual({ IsCheering: false })
|
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
|
||||||
|
}
|
||||||
|
|
||||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`)
|
// The write requires a player; the read remains useful to signed-out callers.
|
||||||
expect(anon.status).toBe(200)
|
expect(
|
||||||
expect(await anon.json()).toEqual({ IsCheering: false })
|
(
|
||||||
|
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)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
|
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
|
||||||
@@ -3278,6 +4068,53 @@ describe('custom avatar items', () => {
|
|||||||
expect(await res.json()).toMatchObject({ Success: false, Value: null })
|
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 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 () => {
|
test('POST 401s without a token', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -3286,6 +4123,374 @@ describe('custom avatar items', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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: '' })
|
||||||
|
|
||||||
|
// Presence is deleted, so they read offline at once — and only theirs is.
|
||||||
|
expect(await isPresent(205)).toBe(false)
|
||||||
|
expect(await isPresent(206)).toBe(true)
|
||||||
|
// The instance lost a player, so it is no longer full.
|
||||||
|
expect(await isFull(SESSION)).toBe(false)
|
||||||
|
|
||||||
|
// 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 isPresent(209)).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 isPresent(209)).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 isPresent(42)).toBe(true)
|
||||||
|
expect(await isPresent(43)).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('vote to kick', () => {
|
||||||
|
// Two live sessions, so a vote called in one can be checked against a player in the
|
||||||
|
// other. Nothing reads `room_instance` here — the gate is presence alone.
|
||||||
|
const SESSION = 1014079
|
||||||
|
const OTHER_SESSION = 1014080
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// `Data` is an ESCAPED JSON STRING, not a nested object: an object there fails the
|
||||||
|
// client's decoder (`expected:'String Begin Token', actual:'{'`) and takes the whole
|
||||||
|
// notification with it. `PlayerId` inside it is a STRING, as the reference relays it,
|
||||||
|
// and `Response` is empty because the frame is the question, not an answer.
|
||||||
|
const message = {
|
||||||
|
ephemeral: true,
|
||||||
|
notificationType: 2, // NotificationType.MessageReceived
|
||||||
|
data: {
|
||||||
|
FromPlayerId: 42,
|
||||||
|
Type: MessageType.VoteToKick,
|
||||||
|
Data: `{"PlayerId":"205","Response":"","GameSessionId":${SESSION}}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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('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])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('player reports', () => {
|
describe('player reports', () => {
|
||||||
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
||||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
|
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
|
||||||
@@ -3444,6 +4649,111 @@ describe('player reports', () => {
|
|||||||
test('banFromReport returns null for an unknown report', async () => {
|
test('banFromReport returns null for an unknown report', async () => {
|
||||||
expect(await banFromReport(env.DB, 999_999)).toBeNull()
|
expect(await banFromReport(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 the report's created_at (nothing records when
|
||||||
|
// the ban itself landed), and a permanent ban runs for the largest span the int
|
||||||
|
// holds.
|
||||||
|
test('describes a permanent ban', async () => {
|
||||||
|
await submit(
|
||||||
|
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
|
||||||
|
await bearer()
|
||||||
|
)
|
||||||
|
const [row] = await getReportsAgainst(env.DB, 221)
|
||||||
|
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: row!.created_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 report’s created_at plus the span to expiry', async () => {
|
||||||
|
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
|
||||||
|
const [row] = await getReportsAgainst(env.DB, 222)
|
||||||
|
const banExpires = new Date(Date.parse(row!.created_at) + 3600 * 1000)
|
||||||
|
await banFromReport(env.DB, row!.id, { banExpires: banExpires.toISOString() })
|
||||||
|
|
||||||
|
expect(await details('GET', '222')).toEqual({
|
||||||
|
...NOT_BLOCKED,
|
||||||
|
ReportCategory: 103,
|
||||||
|
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', () => {
|
describe('player warnings', () => {
|
||||||
@@ -4094,6 +5404,26 @@ describe('images', () => {
|
|||||||
expect(res.status).toBe(400)
|
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 () => {
|
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
|
// Seed images in room 54: two public (one with more cheers, of different
|
||||||
// types), one private (hidden), and one in another room (excluded).
|
// types), one private (hidden), and one in another room (excluded).
|
||||||
@@ -4934,6 +6264,16 @@ describe('player events', () => {
|
|||||||
return { ...rest, ImageName: imageName, State: 0 }
|
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
|
// 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.
|
// upcoming/live/finished distinction the browse queries make is real.
|
||||||
let upcoming: PlayerEventEnvelope
|
let upcoming: PlayerEventEnvelope
|
||||||
@@ -5334,7 +6674,61 @@ describe('player events', () => {
|
|||||||
expect(await (await get(path)).json()).toEqual(asRecord(upcoming))
|
expect(await (await get(path)).json()).toEqual(asRecord(upcoming))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/playerevents/v1/bulk answers in request order, skipping unknown ids', async () => {
|
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 () => {
|
||||||
const res = await get(
|
const res = await get(
|
||||||
`/api/playerevents/v1/bulk?id=${clubEvent.PlayerEventId}&id=999999&id=${upcoming.PlayerEventId}`
|
`/api/playerevents/v1/bulk?id=${clubEvent.PlayerEventId}&id=999999&id=${upcoming.PlayerEventId}`
|
||||||
)
|
)
|
||||||
@@ -5345,6 +6739,10 @@ describe('player events', () => {
|
|||||||
clubEvent.PlayerEventId,
|
clubEvent.PlayerEventId,
|
||||||
upcoming.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.
|
// No ids is an empty list, not every event.
|
||||||
expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([])
|
expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([])
|
||||||
@@ -5536,9 +6934,15 @@ describe('player events', () => {
|
|||||||
expect(events.map((e) => e.PlayerEventId)).not.toContain(finished.PlayerEventId)
|
expect(events.map((e) => e.PlayerEventId)).not.toContain(finished.PlayerEventId)
|
||||||
expect(events.map((e) => e.PlayerEventId)).not.toContain(elsewhere.PlayerEventId)
|
expect(events.map((e) => e.PlayerEventId)).not.toContain(elsewhere.PlayerEventId)
|
||||||
|
|
||||||
// A bare array of the STORED record, like `/searchlive` and the multi-club shelf —
|
// The BASE event, 17 keys — the same projection the browse feed and the bulk read
|
||||||
// not the base projection the browse feed serves, and not the single-club envelope.
|
// serve, since the client decodes all three through one helper and one element type.
|
||||||
expect(events[0]).toEqual(asRecord(running, null))
|
// 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 room with nothing scheduled, and a room id nothing knows about, are both empty.
|
// 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([])
|
expect(await (await get('/api/playerevents/v1/room/999999')).json()).toEqual([])
|
||||||
@@ -6300,9 +7704,11 @@ describe('openapi', () => {
|
|||||||
'POST /api/PlayerCheer/v1/create',
|
'POST /api/PlayerCheer/v1/create',
|
||||||
'POST /api/PlayerReporting/v1/deviceId',
|
'POST /api/PlayerReporting/v1/deviceId',
|
||||||
'POST /api/PlayerReporting/v1/hile',
|
'POST /api/PlayerReporting/v1/hile',
|
||||||
|
'POST /api/PlayerReporting/v1/instantKick',
|
||||||
'POST /api/PlayerReporting/v1/moderationBlockDetails',
|
'POST /api/PlayerReporting/v1/moderationBlockDetails',
|
||||||
'POST /api/PlayerReporting/v1/referee',
|
'POST /api/PlayerReporting/v1/referee',
|
||||||
'POST /api/PlayerReporting/v3/create',
|
'POST /api/PlayerReporting/v3/create',
|
||||||
|
'POST /api/PlayerReporting/v3/voteToKick',
|
||||||
'POST /api/avatar/v1/lockeditems/bulk',
|
'POST /api/avatar/v1/lockeditems/bulk',
|
||||||
'POST /api/avatar/v2/gifts/generate',
|
'POST /api/avatar/v2/gifts/generate',
|
||||||
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||||
@@ -6313,17 +7719,22 @@ describe('openapi', () => {
|
|||||||
'POST /api/images/v1/cheer',
|
'POST /api/images/v1/cheer',
|
||||||
'POST /api/images/v4/uploadsaved',
|
'POST /api/images/v4/uploadsaved',
|
||||||
'POST /api/images/v5/cheered/bulk',
|
'POST /api/images/v5/cheered/bulk',
|
||||||
|
'POST /api/inventions/v1/cheer',
|
||||||
'POST /api/inventions/v1/report',
|
'POST /api/inventions/v1/report',
|
||||||
'POST /api/inventions/v1/settags',
|
'POST /api/inventions/v1/settags',
|
||||||
'POST /api/inventions/v1/update',
|
'POST /api/inventions/v1/update',
|
||||||
'POST /api/inventions/v1/updateprice',
|
'POST /api/inventions/v1/updateprice',
|
||||||
|
'POST /api/inventions/v2/delete',
|
||||||
|
'POST /api/inventions/v4/publish',
|
||||||
'POST /api/inventions/v6/save',
|
'POST /api/inventions/v6/save',
|
||||||
|
'POST /api/inventions/v9/save',
|
||||||
'POST /api/messages/v1/friendOnlineStatus',
|
'POST /api/messages/v1/friendOnlineStatus',
|
||||||
'POST /api/messages/v1/sendMultiple',
|
'POST /api/messages/v1/sendMultiple',
|
||||||
'POST /api/messages/v2/send',
|
'POST /api/messages/v2/send',
|
||||||
'POST /api/messages/v3/delete',
|
'POST /api/messages/v3/delete',
|
||||||
'POST /api/playerReputation/v1/bulk',
|
'POST /api/playerReputation/v1/bulk',
|
||||||
'POST /api/playerReputation/v2/bulk',
|
'POST /api/playerReputation/v2/bulk',
|
||||||
|
'POST /api/playerevents/v1/bulk',
|
||||||
'POST /api/playerevents/v1/bulkInvite',
|
'POST /api/playerevents/v1/bulkInvite',
|
||||||
'POST /api/playerevents/v1/report',
|
'POST /api/playerevents/v1/report',
|
||||||
'POST /api/playerevents/v1/respond',
|
'POST /api/playerevents/v1/respond',
|
||||||
@@ -6350,6 +7761,7 @@ describe('openapi', () => {
|
|||||||
'POST /outfits/bulk',
|
'POST /outfits/bulk',
|
||||||
'POST /statsigUserProperties',
|
'POST /statsigUserProperties',
|
||||||
'PUT /api/customAvatarItems/v1/{id}',
|
'PUT /api/customAvatarItems/v1/{id}',
|
||||||
|
'PUT /api/inventions/v2/metadata',
|
||||||
'PUT /api/playerevents/v2/{eventId}/accessibility',
|
'PUT /api/playerevents/v2/{eventId}/accessibility',
|
||||||
'PUT /api/playerevents/v2/{eventId}/description',
|
'PUT /api/playerevents/v2/{eventId}/description',
|
||||||
'PUT /api/playerevents/v2/{eventId}/name',
|
'PUT /api/playerevents/v2/{eventId}/name',
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -925,7 +925,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Key": "Statsig.Enabled",
|
"Key": "Statsig.Enabled",
|
||||||
"Value": "true",
|
"Value": "false",
|
||||||
"ActiveExperiments": null,
|
"ActiveExperiments": null,
|
||||||
"StartTime": null,
|
"StartTime": null,
|
||||||
"EndTime": null
|
"EndTime": null
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ export default defineConfig({
|
|||||||
// GET /all for the whole list (friend-graph changes notify both players),
|
// GET /all for the whole list (friend-graph changes notify both players),
|
||||||
// DELETE to reset it between assertions.
|
// DELETE to reset it between assertions.
|
||||||
//
|
//
|
||||||
// notifyPlayersEphemeral lands in the same list, tagged `ephemeral` and
|
// The ephemeral sends land in the same list, tagged `ephemeral`:
|
||||||
// carrying `playerIds` rather than `playerId` — the two sends differ in
|
// notifyPlayerEphemeral carries `playerId` like the durable send,
|
||||||
// whether an offline recipient gets the frame later, which is a thing worth
|
// notifyPlayersEphemeral carries `playerIds` for the whole batch. Durable
|
||||||
// asserting (a cheer's effect is broadcast to a room this way).
|
// 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: `
|
script: `
|
||||||
import { DurableObject } from 'cloudflare:workers'
|
import { DurableObject } from 'cloudflare:workers'
|
||||||
export class NotificationsHub extends DurableObject {
|
export class NotificationsHub extends DurableObject {
|
||||||
@@ -38,6 +40,10 @@ export default defineConfig({
|
|||||||
this.sent.push({ playerId, notificationType, data })
|
this.sent.push({ playerId, notificationType, data })
|
||||||
return { delivered: 0, queued: true }
|
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) {
|
async notifyPlayersEphemeral(playerIds, notificationType, data) {
|
||||||
this.sent.push({ playerIds, ephemeral: true, notificationType, data })
|
this.sent.push({ playerIds, ephemeral: true, notificationType, data })
|
||||||
return { delivered: 0 }
|
return { delivered: 0 }
|
||||||
|
|||||||
@@ -74,6 +74,10 @@
|
|||||||
"vars": {
|
"vars": {
|
||||||
"ENVIRONMENT": "development", // overridden during deployment
|
"ENVIRONMENT": "development", // overridden during deployment
|
||||||
"SENTRY_RELEASE": "unknown", // overridden during deployment
|
"SENTRY_RELEASE": "unknown", // overridden during deployment
|
||||||
"DOMAIN": "rec.example.com" // base domain; 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -33,6 +33,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-48
@@ -71,19 +71,16 @@ 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'
|
'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 banned account's grant is refused with. A fixed sentence,
|
* The `error_description` a grant is refused with when the caller's account is not itself
|
||||||
* never interpolated with the expiry, because `www`'s shared auth-messages table keys on
|
* banned but shares an identity with one that is (see bans-db's linked arms). A fixed
|
||||||
* this exact string to put a real sentence in front of a player — anything varying would
|
* sentence, because `www`'s shared auth-messages table keys on this exact string to put a
|
||||||
* fall through to the generic "you could not be signed in". Keep the two in sync.
|
* 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
|
||||||
const BANNED_DESCRIPTION = 'this account is banned'
|
* 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.
|
||||||
* 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
|
* A DIRECTLY banned account is not refused here at all — see the token grant.
|
||||||
* 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'
|
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
|
||||||
|
|
||||||
@@ -608,19 +605,22 @@ const app = new Hono<App>()
|
|||||||
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
|
'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.',
|
'client reads it as a claim beside `role`, and it is absent for everyone else.',
|
||||||
'',
|
'',
|
||||||
'**Bans.** Once the grant has resolved an account, a BANNED account is refused a',
|
'**Bans.** A BANNED account still gets a token — every grant, including a refresh.',
|
||||||
'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a',
|
'A ban is a `report` row with `banned` set (the `api` worker owns that table); it',
|
||||||
'`report` row with `banned` set (the `api` worker owns that table); it lifts on its',
|
'lifts on its own when `ban_expires` passes, and never if that is null. The token',
|
||||||
'own when `ban_expires` passes, and never if that is null.',
|
'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 for a banned player, so a token',
|
||||||
|
'gets them as far as that screen and no further.',
|
||||||
'',
|
'',
|
||||||
'The refusal follows the player, not just the account: it also catches an account',
|
'What IS refused here (`invalid_grant`) is ban EVASION: an account that shares a',
|
||||||
'that shares a PROVEN platform identity (a `platform_account` link) or an IP',
|
'PROVEN platform identity (a `platform_account` link) or an IP (`signupIp`/',
|
||||||
'(`signupIp`/`lastLoginIp`, or the address this request came from) with a banned',
|
'`lastLoginIp`, or the address this request came from) with a banned one, and a',
|
||||||
'one, and a `create_account` carrying either is refused BEFORE it mints anything.',
|
'`create_account` carrying either, which is refused BEFORE it mints anything. Such',
|
||||||
'Those two arms are the operator’s `BAN_EVASION_MATCH` knob (`ip`, `platform`, or',
|
'an account has no ban of its own for the block screen to describe, so there is',
|
||||||
'`off`); the ban on the account itself is always enforced. A linked match answers a',
|
'nothing to let it in for. Those two arms are the operator’s `BAN_EVASION_MATCH`',
|
||||||
'deliberately vaguer description than a direct one — the account refused may belong',
|
'knob (`ip`, `platform`, or `off`). The description is deliberately vague — the',
|
||||||
'to a housemate of the banned player rather than to them.',
|
'account refused may belong to a housemate of the banned player rather than to them.',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
requestBody: form(
|
requestBody: form(
|
||||||
TokenRequest,
|
TokenRequest,
|
||||||
@@ -633,7 +633,7 @@ const app = new Hono<App>()
|
|||||||
[
|
[
|
||||||
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
|
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
|
||||||
'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
|
'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
|
||||||
'or a banned account',
|
'or an account sharing a banned one’s device or network',
|
||||||
].join(' ')
|
].join(' ')
|
||||||
),
|
),
|
||||||
500: json(
|
500: json(
|
||||||
@@ -789,9 +789,9 @@ const app = new Hono<App>()
|
|||||||
// via create_account or /account/me/changepassword.
|
// via create_account or /account/me/changepassword.
|
||||||
let accountId: string
|
let accountId: string
|
||||||
if (grantType === 'create_account') {
|
if (grantType === 'create_account') {
|
||||||
// A banned player's next move is a new account, so the ban is checked BEFORE
|
// A banned player's next move is a new account, so the evasion arms are checked
|
||||||
// one is minted — against the only identity a signup has, the IP it came from
|
// BEFORE one is minted — against the only identity a signup has, the IP it came
|
||||||
// and the platform identity it just proved. Refusing after the fact (as the
|
// 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
|
// 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
|
// row behind and burns a slot off both signup caps, so the evader gets to keep
|
||||||
// making them.
|
// making them.
|
||||||
@@ -987,14 +987,19 @@ const app = new Hono<App>()
|
|||||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||||
}
|
}
|
||||||
|
|
||||||
// A banned player gets no token — and with no token every other worker is shut to
|
// A DIRECTLY banned account still gets its token. The client needs one to reach
|
||||||
// them, so this is the outer wall of a ban; matchmaking's refusal is the inner
|
// `api`'s moderationBlockDetails, which is where the player is TOLD they are banned
|
||||||
// one, which still has to exist because a token issued before the ban stays valid
|
// (category, time left, "Rule violation") — refused here, they would only ever see
|
||||||
// until it expires.
|
// a failed sign-in. The ban is enforced by matchmaking instead, which refuses every
|
||||||
|
// matchmake for a banned player, so the token gets them as far as the block screen
|
||||||
|
// and no further. Logged, so the operator can see a banned player signing in.
|
||||||
//
|
//
|
||||||
// Checked once here, after the grant has resolved an account, so it covers every
|
// Ban EVASION is still refused here: an account that merely shares a device or
|
||||||
// grant: password, cached_login and a refresh_token redeemed by a client that has
|
// network with a banned one has no ban of its own for that screen to describe, so
|
||||||
// been running since before the ban. Deliberately AFTER the credential checks —
|
// there is nothing to let it in for — and letting it in is exactly what the evader
|
||||||
|
// wants. Checked once here, after the grant has resolved an account, so it covers
|
||||||
|
// every grant: password, cached_login and a refresh_token redeemed by a client that
|
||||||
|
// has been running since before the ban. Deliberately AFTER the credential checks —
|
||||||
// a wrong password is still "invalid account_id or password", so this can't be
|
// 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.
|
// used to probe whether an account exists or is banned without knowing it.
|
||||||
//
|
//
|
||||||
@@ -1007,8 +1012,8 @@ const app = new Hono<App>()
|
|||||||
identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
|
identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
|
||||||
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
||||||
})
|
})
|
||||||
if (ban) {
|
if (ban && ban.via !== 'account') {
|
||||||
logger.info('token refused: player banned', {
|
logger.info('token refused: ban evasion', {
|
||||||
accountId,
|
accountId,
|
||||||
grantType,
|
grantType,
|
||||||
via: ban.via,
|
via: ban.via,
|
||||||
@@ -1016,13 +1021,15 @@ const app = new Hono<App>()
|
|||||||
reportId: ban.ban.id,
|
reportId: ban.ban.id,
|
||||||
banExpires: ban.ban.ban_expires,
|
banExpires: ban.ban.ban_expires,
|
||||||
})
|
})
|
||||||
return c.json(
|
return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400)
|
||||||
{
|
}
|
||||||
error: 'invalid_grant',
|
if (ban) {
|
||||||
error_description: ban.via === 'account' ? BANNED_DESCRIPTION : BLOCKED_DESCRIPTION,
|
logger.info('token issued to banned account', {
|
||||||
},
|
accountId,
|
||||||
400
|
grantType,
|
||||||
)
|
reportId: ban.ban.id,
|
||||||
|
banExpires: ban.ban.ban_expires,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
|
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
|
||||||
@@ -1040,8 +1047,9 @@ const app = new Hono<App>()
|
|||||||
|
|
||||||
// Stamp the account's elevated roles into the token's `role` claim so the client
|
// 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
|
// authorizes developer/moderator powers from the token itself (not just the
|
||||||
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
|
// /role/* lookups), and its Plus flag into `rn.plus`. One read of the just-resolved
|
||||||
// every login and every refresh_token grant.
|
// account serves both; they thus refresh on every login and every refresh_token
|
||||||
|
// grant.
|
||||||
const roleAccount = await getAccount(c.env.DB, Number(accountId))
|
const roleAccount = await getAccount(c.env.DB, Number(accountId))
|
||||||
// A refresh grant posts no platform of its own, so the identity comes off the
|
// 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.
|
// account — the same read, and the only place the bound identity is authoritative.
|
||||||
@@ -1056,7 +1064,13 @@ const app = new Hono<App>()
|
|||||||
jwtSecret,
|
jwtSecret,
|
||||||
accountRoles(roleAccount),
|
accountRoles(roleAccount),
|
||||||
accountPrivileges(roleAccount),
|
accountPrivileges(roleAccount),
|
||||||
version
|
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
|
||||||
)
|
)
|
||||||
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
||||||
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { resolver } from 'hono-openapi'
|
import { resolver } from 'hono-openapi'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { PlatformType } from '@repo/domain/src/enums'
|
||||||
|
|
||||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,24 +51,13 @@ export function form(schema: z.ZodType, description: string): OpenAPIV3_1.Reques
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PlatformType, the client's platform enum. Declaration order is wire order, and is
|
* PlatformType, the client's platform enum — the single source for the schema and
|
||||||
* the single source for the schema and description below. The `platform` form field
|
* description below. It lives in `@repo/domain` rather than here because the link table
|
||||||
* is posted as the integer; the token's `platform` claim carries the name.
|
* (`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.
|
||||||
*/
|
*/
|
||||||
export const PlatformType = {
|
export { PlatformType } from '@repo/domain/src/enums'
|
||||||
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 —
|
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified —
|
||||||
|
|||||||
@@ -19,8 +19,18 @@
|
|||||||
* A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a
|
* 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
|
* 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.
|
* 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { PlatformType } from '@repo/domain/src/enums'
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
|
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
|
||||||
export const PLATFORM_SCHEMA_DDL: string[] = [
|
export const PLATFORM_SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS platform_account (
|
`CREATE TABLE IF NOT EXISTS platform_account (
|
||||||
@@ -64,6 +74,38 @@ export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (ac
|
|||||||
WHERE json_extract(data, '$.platformId') IS NOT NULL
|
WHERE json_extract(data, '$.platformId') IS NOT NULL
|
||||||
AND json_extract(data, '$.platformId') <> ''`
|
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. */
|
/** One account ↔ platform identity link. */
|
||||||
export interface PlatformLink {
|
export interface PlatformLink {
|
||||||
accountId: number
|
accountId: number
|
||||||
@@ -119,6 +161,9 @@ export async function getLinksForPlatformIdentity(
|
|||||||
platformId: string
|
platformId: string
|
||||||
): Promise<PlatformLink[]> {
|
): Promise<PlatformLink[]> {
|
||||||
if (platformId === '') return []
|
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
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
|
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
|
||||||
@@ -138,9 +183,15 @@ export async function getLinksForPlatformId(
|
|||||||
platformId: string
|
platformId: string
|
||||||
): Promise<PlatformLink[]> {
|
): Promise<PlatformLink[]> {
|
||||||
if (platformId === '') return []
|
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
|
const { results } = await db
|
||||||
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
|
.prepare(
|
||||||
.bind(platformId)
|
`${SELECT_LINK} WHERE platform_id = ?1 AND ${CACHED_LOGIN_FILTER}
|
||||||
|
ORDER BY linked_at, account_id`
|
||||||
|
)
|
||||||
|
.bind(platformId, ...CACHED_LOGIN_PLATFORMS)
|
||||||
.all<LinkRow>()
|
.all<LinkRow>()
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import {
|
|||||||
createReport,
|
createReport,
|
||||||
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||||
} from '../../../../api/src/reports-db'
|
} from '../../../../api/src/reports-db'
|
||||||
|
import { PlatformType } from '../../openapi'
|
||||||
import {
|
import {
|
||||||
|
countAccountsForPlatformIdentity,
|
||||||
getLinksForAccount,
|
getLinksForAccount,
|
||||||
linkPlatformIdentity,
|
linkPlatformIdentity,
|
||||||
PLATFORM_BACKFILL_SQL,
|
PLATFORM_BACKFILL_SQL,
|
||||||
@@ -87,8 +89,8 @@ beforeAll(async () => {
|
|||||||
IsDorm: false,
|
IsDorm: false,
|
||||||
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
|
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
|
||||||
})
|
})
|
||||||
// Report table (owned by the api worker) — a banned account is refused a token, and
|
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
|
||||||
// a ban is a report row with `banned` set.
|
// the token grant reads it for the evasion arms.
|
||||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -421,6 +423,52 @@ 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 20250424.01 build POSTs the picker lookup with a platform-attestation form body
|
// 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
|
// 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.
|
// same list — otherwise the newer client's login screen comes up empty.
|
||||||
@@ -577,6 +625,9 @@ describe('auth worker routes', () => {
|
|||||||
expect(payload.role).not.toContain('junior')
|
expect(payload.role).not.toContain('junior')
|
||||||
// No privileges to carry, so the claim is absent rather than an empty array.
|
// No privileges to carry, so the claim is absent rather than an empty array.
|
||||||
expect(payload['rn.privilege']).toBeUndefined()
|
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')
|
expect(payload.scope).toContain('rn.api')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -615,6 +666,64 @@ describe('auth worker routes', () => {
|
|||||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
|
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 () => {
|
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)')
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
.bind(
|
.bind(
|
||||||
@@ -1327,38 +1436,35 @@ describe('CORS', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// A banned account is refused a token at all — the outer wall of a ban, since with no
|
// A banned account is still issued a token: the game client needs one to reach the api
|
||||||
// token every other worker is shut to it. The ban is a `report` row with `banned` set
|
// worker's moderationBlockDetails, which is where the player is shown WHY they are
|
||||||
// (the api worker owns that table); matchmaking enforces the same ban on tokens issued
|
// blocked. The ban is a `report` row with `banned` set (the api worker owns that table)
|
||||||
// before it was handed down.
|
// 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.
|
||||||
describe('banned accounts', () => {
|
describe('banned accounts', () => {
|
||||||
test('POST /connect/token refuses a password grant from a banned account', async () => {
|
test('POST /connect/token issues a token to a banned account', async () => {
|
||||||
await seedAccount(6101, 'BannedPlayer')
|
await seedAccount(6101, 'BannedPlayer')
|
||||||
await banAccount(6101)
|
await banAccount(6101)
|
||||||
|
|
||||||
const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`)
|
const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`)
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(200)
|
||||||
expect(res.json.error).toBe('invalid_grant')
|
expect(decodePayload(res.json.access_token as string).sub).toBe('6101')
|
||||||
// The exact sentence www's shared auth-messages table keys on to put a real
|
|
||||||
// message in front of the player — changing it silently downgrades that to the
|
|
||||||
// generic "you could not be signed in".
|
|
||||||
expect(res.json.error_description).toBe('this account is banned')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /connect/token refuses a username login from a banned account', async () => {
|
test('POST /connect/token issues a token to a banned account logging in by username', async () => {
|
||||||
await seedAccount(6102, 'BannedByName')
|
await seedAccount(6102, 'BannedByName')
|
||||||
await banAccount(6102)
|
await banAccount(6102)
|
||||||
|
|
||||||
const res = await postToken(
|
const res = await postToken(
|
||||||
`grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}`
|
`grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}`
|
||||||
)
|
)
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(200)
|
||||||
expect(res.json.error_description).toBe('this account is banned')
|
expect(decodePayload(res.json.access_token as string).sub).toBe('6102')
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client that was already signed in when the ban landed still holds a valid refresh
|
// A client that was already signed in when the ban landed refreshes as normal — its
|
||||||
// token; redeeming it must not renew the session.
|
// next matchmake is what refuses it, and moderationBlockDetails says why.
|
||||||
test('POST /connect/token refuses to refresh a banned account’s session', async () => {
|
test('POST /connect/token refreshes a banned account’s session', async () => {
|
||||||
await seedAccount(6103, 'BannedLater')
|
await seedAccount(6103, 'BannedLater')
|
||||||
const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`)
|
const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`)
|
||||||
expect(login.status).toBe(200)
|
expect(login.status).toBe(200)
|
||||||
@@ -1368,13 +1474,12 @@ describe('banned accounts', () => {
|
|||||||
const refreshed = await postToken(
|
const refreshed = await postToken(
|
||||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
|
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
|
||||||
)
|
)
|
||||||
expect(refreshed.status).toBe(400)
|
expect(refreshed.status).toBe(200)
|
||||||
expect(refreshed.json.error_description).toBe('this account is banned')
|
expect(decodePayload(refreshed.json.access_token as string).sub).toBe('6103')
|
||||||
})
|
})
|
||||||
|
|
||||||
// The ban check runs AFTER the credential check, so a wrong password on a banned
|
// A ban does not loosen the credential check: a wrong password on a banned account is
|
||||||
// account still answers the ordinary bad-credential refusal — it can't be used to
|
// the ordinary bad-credential refusal.
|
||||||
// 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 () => {
|
test('a wrong password on a banned account is still a credential refusal', async () => {
|
||||||
await seedAccount(6104, 'BannedWrongPw')
|
await seedAccount(6104, 'BannedWrongPw')
|
||||||
await banAccount(6104)
|
await banAccount(6104)
|
||||||
@@ -1384,23 +1489,13 @@ describe('banned accounts', () => {
|
|||||||
expect(res.json.error_description).toBe('invalid account_id or password')
|
expect(res.json.error_description).toBe('invalid account_id or password')
|
||||||
})
|
})
|
||||||
|
|
||||||
// A timed ban lifts itself when its expiry passes; nothing clears the flag.
|
test('a ban that has not expired yet still issues a token', async () => {
|
||||||
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 seedAccount(6106, 'StillServing')
|
||||||
await banAccount(6106, new Date(Date.now() + 3_600_000).toISOString())
|
await banAccount(6106, new Date(Date.now() + 3_600_000).toISOString())
|
||||||
|
|
||||||
const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`)
|
const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`)
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(200)
|
||||||
expect(res.json.error_description).toBe('this account is banned')
|
expect(decodePayload(res.json.access_token as string).sub).toBe('6106')
|
||||||
})
|
})
|
||||||
|
|
||||||
// A report is not a ban until a moderator converts it.
|
// A report is not a ban until a moderator converts it.
|
||||||
@@ -1425,8 +1520,10 @@ describe('banned accounts', () => {
|
|||||||
|
|
||||||
// The ban follows the player past the account it was written on: a login from an account
|
// 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
|
// 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
|
// signup carrying either is refused before it mints anything. Unlike the banned account
|
||||||
// bans-db.ts for the arms and the BAN_EVASION_MATCH knob.
|
// itself, such an account has no ban of its own for the block screen to describe, so
|
||||||
|
// there is nothing to let it in for. See the api worker's bans-db.ts for the arms and
|
||||||
|
// the BAN_EVASION_MATCH knob.
|
||||||
describe('ban evasion at the token endpoint', () => {
|
describe('ban evasion at the token endpoint', () => {
|
||||||
/** Seed a loginable account carrying the IPs it signed up / last logged in from. */
|
/** Seed a loginable account carrying the IPs it signed up / last logged in from. */
|
||||||
const account = async (id: number, name: string, ips: Record<string, string> = {}) => {
|
const account = async (id: number, name: string, ips: Record<string, string> = {}) => {
|
||||||
@@ -1506,7 +1603,7 @@ describe('ban evasion at the token endpoint', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// The knob an operator reaches for when the IP arm locks out real players.
|
// 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 () => {
|
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the platform one', async () => {
|
||||||
const original = env.BAN_EVASION_MATCH
|
const original = env.BAN_EVASION_MATCH
|
||||||
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
|
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
|
||||||
await linkPlatformIdentity(env.DB, 6320, 0, 'steam-knobevader')
|
await linkPlatformIdentity(env.DB, 6320, 0, 'steam-knobevader')
|
||||||
@@ -1524,10 +1621,9 @@ describe('ban evasion at the token endpoint', () => {
|
|||||||
|
|
||||||
env.BAN_EVASION_MATCH = 'off'
|
env.BAN_EVASION_MATCH = 'off'
|
||||||
expect((await login(6322)).status).toBe(200)
|
expect((await login(6322)).status).toBe(200)
|
||||||
// The banned account itself is refused whatever the knob says.
|
// The banned account itself signs in whatever the knob says — its ban is
|
||||||
const banned = await login(6320)
|
// enforced at matchmake, and the knob only governs the linked arms.
|
||||||
expect(banned.status).toBe(400)
|
expect((await login(6320)).status).toBe(200)
|
||||||
expect(banned.json.error_description).toBe('this account is banned')
|
|
||||||
} finally {
|
} finally {
|
||||||
env.BAN_EVASION_MATCH = original
|
env.BAN_EVASION_MATCH = original
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"workers-tagged-logger": "1.0.1"
|
"workers-tagged-logger": "1.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -25,6 +25,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -31,6 +31,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- 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;
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -33,6 +33,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+321
-20
@@ -16,6 +16,7 @@ import {
|
|||||||
ChatResult,
|
ChatResult,
|
||||||
ChatThreadDto,
|
ChatThreadDto,
|
||||||
ChatThreadWithMessagesDto,
|
ChatThreadWithMessagesDto,
|
||||||
|
CreatePartyChatResponse,
|
||||||
CreateThreadRequest,
|
CreateThreadRequest,
|
||||||
CreateThreadResponse,
|
CreateThreadResponse,
|
||||||
FavoriteThreadRequest,
|
FavoriteThreadRequest,
|
||||||
@@ -23,8 +24,8 @@ import {
|
|||||||
json,
|
json,
|
||||||
messageCountParam,
|
messageCountParam,
|
||||||
NOT_A_MEMBER_RESPONSE,
|
NOT_A_MEMBER_RESPONSE,
|
||||||
|
PartyChatThread,
|
||||||
PartyInviteSettings,
|
PartyInviteSettings,
|
||||||
PartyThread,
|
|
||||||
RenameThreadRequest,
|
RenameThreadRequest,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResponse,
|
SendMessageResponse,
|
||||||
@@ -36,11 +37,16 @@ import {
|
|||||||
} from './openapi'
|
} from './openapi'
|
||||||
import {
|
import {
|
||||||
addThreadMember,
|
addThreadMember,
|
||||||
|
ChatThreadType,
|
||||||
|
createThread,
|
||||||
getOrCreateThreadWithMembers,
|
getOrCreateThreadWithMembers,
|
||||||
getThreadForPlayer,
|
getThreadForPlayer,
|
||||||
getThreadMemberIds,
|
getThreadMemberIds,
|
||||||
|
getPartyThreadForPlayer,
|
||||||
|
getThreadMeta,
|
||||||
getThreadsForPlayer,
|
getThreadsForPlayer,
|
||||||
isThreadMember,
|
isThreadMember,
|
||||||
|
joinedChatContents,
|
||||||
leftChatContents,
|
leftChatContents,
|
||||||
markThreadRead,
|
markThreadRead,
|
||||||
postMessage,
|
postMessage,
|
||||||
@@ -54,6 +60,7 @@ import {
|
|||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App, Env } from './context'
|
import type { App, Env } from './context'
|
||||||
import type { ChatMessage } from './message-db'
|
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
|
* Resolve the account id from a Bearer token. Returns `null` when the header is
|
||||||
@@ -107,6 +114,25 @@ const CHAT_PLAYER_ALREADY_ON_THREAD = 4
|
|||||||
*/
|
*/
|
||||||
const PARTY_INVITE_LIFETIME_MINUTES = 60
|
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
|
* 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
|
* like every other enum on this build. `Friends` is what a fresh account reports, and what
|
||||||
@@ -134,6 +160,20 @@ const CHAT_PRIVACY_NAMES = ['Friends', 'Favorites', 'NoOne'] as const
|
|||||||
const DM_PRIVACY_KEY = 'directMessagePrivacySetting'
|
const DM_PRIVACY_KEY = 'directMessagePrivacySetting'
|
||||||
const GROUP_PRIVACY_KEY = 'groupChatPrivacySetting'
|
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
|
* 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
|
* sends it (case-insensitively), or the ordinal as the GET serves it, since a value that
|
||||||
@@ -200,16 +240,87 @@ async function writeChatPrivacy(
|
|||||||
accountId: number,
|
accountId: number,
|
||||||
settings: Partial<Record<typeof DM_PRIVACY_KEY | typeof GROUP_PRIVACY_KEY, ChatPrivacyValue>>
|
settings: Partial<Record<typeof DM_PRIVACY_KEY | typeof GROUP_PRIVACY_KEY, ChatPrivacyValue>>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const merged: Record<string, string> = { ...(await getPlayerSettings(env, accountId)) }
|
const patch: Record<string, string> = {}
|
||||||
for (const [key, value] of Object.entries(settings)) {
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
if (value !== undefined) merged[key] = CHAT_PRIVACY_NAMES[value]
|
if (value !== undefined) patch[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))
|
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. */
|
/** The hub is a single global Durable Object instance, as every worker addresses it. */
|
||||||
const HUB_INSTANCE = 'global'
|
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
|
* Push ChatMessageReceived to everyone in the thread once a message lands, so the
|
||||||
* conversation updates live instead of on the next poll.
|
* conversation updates live instead of on the next poll.
|
||||||
@@ -303,6 +414,33 @@ 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
|
* 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
|
* opened the conversation. `/thread/18` is what the client posts; `/thread/18/message` is
|
||||||
@@ -607,7 +745,12 @@ const app = new Hono<App>()
|
|||||||
const members = [...new Set([id, ...(await memberIds(c))])]
|
const members = [...new Set([id, ...(await memberIds(c))])]
|
||||||
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
|
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
|
||||||
|
|
||||||
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
|
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 contents = (await formField(c, 'messageContents'))?.trim()
|
const contents = (await formField(c, 'messageContents'))?.trim()
|
||||||
const posted =
|
const posted =
|
||||||
@@ -663,21 +806,74 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// The party thread (`/thread/party?maxCount=1&mode=0`). STUB: the response shape is
|
// The caller's current party (`/thread/party?maxCount=1&mode=0`) — the client's
|
||||||
// unknown — it hasn't been observed off a live client — so this answers an empty
|
// GetPartyChat.
|
||||||
// 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.
|
// TWO paths, cheapest first:
|
||||||
// Replace the body once the real shape is captured.
|
//
|
||||||
|
// 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.
|
||||||
.get(
|
.get(
|
||||||
'/thread/party',
|
'/thread/party',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Threads'],
|
tags: ['Threads'],
|
||||||
summary: 'The caller’s party thread (stub)',
|
summary: 'The caller’s current party thread (GetPartyChat)',
|
||||||
description: [
|
description: [
|
||||||
'STUB — the response shape has not been observed off a live client, so this answers an',
|
'The party the caller is currently in: the newest party thread they are a member of,',
|
||||||
'empty object `{}`, which parses as "no party" rather than failing the client’s',
|
'answered from a single query. Failing that, the thread named by their own',
|
||||||
'deserializer the way a 404 or a bare array would. `maxCount` and `mode` are accepted and',
|
'`LatestPartyChat` player setting — which `POST /thread/party` writes for the player',
|
||||||
'ignored. Replace the body once the real shape is captured.',
|
'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.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -685,26 +881,121 @@ const app = new Hono<App>()
|
|||||||
name: 'maxCount',
|
name: 'maxCount',
|
||||||
in: 'query',
|
in: 'query',
|
||||||
required: false,
|
required: false,
|
||||||
description: 'Page size the client sends (1). Ignored by the stub',
|
description: 'Page size the client sends (1). Accepted and ignored',
|
||||||
schema: { type: 'integer' },
|
schema: { type: 'integer' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'mode',
|
name: 'mode',
|
||||||
in: 'query',
|
in: 'query',
|
||||||
required: false,
|
required: false,
|
||||||
description: 'Unknown mode selector the client sends (0). Ignored by the stub',
|
description: 'Unknown mode selector the client sends (0). Accepted and ignored',
|
||||||
schema: { type: 'integer' },
|
schema: { type: 'integer' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(PartyThread, 'Always `{}` — the stub carries no party'),
|
200: json(PartyChatThread, 'The caller’s party, or `{}` when they have none'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return c.body(null, 401)
|
if (id === null) return c.body(null, 401)
|
||||||
return c.json({})
|
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -887,7 +1178,11 @@ const app = new Hono<App>()
|
|||||||
// rather than a lonely thread.
|
// rather than a lonely thread.
|
||||||
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
|
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
|
||||||
|
|
||||||
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
|
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 limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
|
const limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
|
||||||
const thread = await threadWithMessages(c, chatThreadId, id, limit)
|
const thread = await threadWithMessages(c, chatThreadId, id, limit)
|
||||||
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
|
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
|
||||||
@@ -1102,7 +1397,10 @@ const app = new Hono<App>()
|
|||||||
'you’re part of. Answers a bare ChatResult rather than an HTTP status, as the reference',
|
'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',
|
'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.',
|
'thread’s existence private), 4 when the target is already on it, 0 on success.',
|
||||||
'Idempotent — re-adding an existing member changes nothing.',
|
'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.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
{
|
{
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -1132,6 +1430,9 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
|
|
||||||
await addThreadMember(c.env.DB, chatThreadId, playerId)
|
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)
|
return c.json(CHAT_SUCCESS)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -86,7 +86,9 @@ const threadBase = {
|
|||||||
chatThreadName: z
|
chatThreadName: z
|
||||||
.string()
|
.string()
|
||||||
.describe('Empty for DMs and unnamed groups — never null (the client dereferences it)'),
|
.describe('Empty for DMs and unnamed groups — never null (the client dereferences it)'),
|
||||||
chatThreadType: z.int().describe('Always 0 — the only type the reference serves'),
|
chatThreadType: z
|
||||||
|
.int()
|
||||||
|
.describe('The ChatThreadType enum, numeric: 0 Player (DMs and groups) · 1 Club · 2 Party'),
|
||||||
snoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
|
snoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
|
||||||
isFavorited: z.boolean(),
|
isFavorited: z.boolean(),
|
||||||
}
|
}
|
||||||
@@ -233,11 +235,56 @@ export const ChatPrivacySettings = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET /thread/party` — STUB. The real shape hasn't been observed off a live client, so
|
* A thread in the PascalCase spelling the two `/thread/party` routes serve — the client's
|
||||||
* the route answers an empty object and this schema says so rather than guessing at
|
* CreatePartyChat and GetPartyChat — the THIRD projection of a thread in this worker, and deliberately not
|
||||||
* fields. Fill both in together once the real response is captured.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export const PartyThread = z.object({}).describe('Stub — always empty; the real shape is unknown')
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
/** `GET /` — the liveness probe. */
|
/** `GET /` — the liveness probe. */
|
||||||
export const ServiceStatus = z.object({
|
export const ServiceStatus = z.object({
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import {
|
|||||||
SCHEMA_DDL,
|
SCHEMA_DDL,
|
||||||
} from '../../message-db'
|
} from '../../message-db'
|
||||||
import {
|
import {
|
||||||
|
addThreadMember,
|
||||||
|
ChatThreadType,
|
||||||
createThread,
|
createThread,
|
||||||
|
joinedChatContents,
|
||||||
findThreadWithMembers,
|
findThreadWithMembers,
|
||||||
getThreadForPlayer,
|
getThreadForPlayer,
|
||||||
getThreadsForPlayer,
|
getThreadsForPlayer,
|
||||||
@@ -394,8 +397,107 @@ 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', () => {
|
describe('GET /thread/party', () => {
|
||||||
it('answers an empty object', async () => {
|
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 () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, {
|
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, {
|
||||||
headers: await bearer(883001),
|
headers: await bearer(883001),
|
||||||
})
|
})
|
||||||
@@ -403,12 +505,339 @@ describe('GET /thread/party', () => {
|
|||||||
expect(await res.json()).toEqual({})
|
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 () => {
|
it('401s without a token', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`)
|
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`)
|
||||||
expect(res.status).toBe(401)
|
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', () => {
|
describe('GET /thread/chatPrivacySetting', () => {
|
||||||
it('reports Friends for both settings by default, keyed to the caller', async () => {
|
it('reports Friends for both settings by default, keyed to the caller', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, {
|
const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, {
|
||||||
@@ -991,11 +1420,28 @@ describe('ChatMessageReceived push', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sent = await hub.getByName('global').takeSent()
|
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(
|
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
expect(sent[0]!.data).toEqual({
|
// 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({
|
||||||
chatMessageId: chatThread.latestMessage.chatMessageId,
|
chatMessageId: chatThread.latestMessage.chatMessageId,
|
||||||
chatThreadId: chatThread.chatThreadId,
|
chatThreadId: chatThread.chatThreadId,
|
||||||
senderPlayerId: caller,
|
senderPlayerId: caller,
|
||||||
@@ -1005,8 +1451,144 @@ describe('ChatMessageReceived push', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pushes nothing when there is no message to push', async () => {
|
it('pushes the opening notice even when no message is sent', async () => {
|
||||||
await send(886004, 'ids=886005&messageContents=')
|
// 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) })
|
||||||
expect(await hub.getByName('global').takeSent()).toEqual([])
|
expect(await hub.getByName('global').takeSent()).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1629,6 +2211,7 @@ describe('openapi', () => {
|
|||||||
'GET /thread/{id}',
|
'GET /thread/{id}',
|
||||||
'GET /thread/{id}/message',
|
'GET /thread/{id}/message',
|
||||||
'POST /thread',
|
'POST /thread',
|
||||||
|
'POST /thread/party',
|
||||||
'POST /thread/withmembers',
|
'POST /thread/withmembers',
|
||||||
'POST /thread/{id}',
|
'POST /thread/{id}',
|
||||||
'POST /thread/{id}/favorite',
|
'POST /thread/{id}/favorite',
|
||||||
|
|||||||
+145
-52
@@ -31,6 +31,7 @@ export const THREAD_SCHEMA_DDL: string[] = [
|
|||||||
`CREATE TABLE IF NOT EXISTS message_thread (
|
`CREATE TABLE IF NOT EXISTS message_thread (
|
||||||
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
chat_thread_name TEXT,
|
chat_thread_name TEXT,
|
||||||
|
chat_thread_type INTEGER NOT NULL DEFAULT 0,
|
||||||
latest_message_id INTEGER,
|
latest_message_id INTEGER,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
)`,
|
)`,
|
||||||
@@ -70,23 +71,36 @@ export interface ChatThread {
|
|||||||
*/
|
*/
|
||||||
chatThreadName: string
|
chatThreadName: string
|
||||||
/**
|
/**
|
||||||
* Which kind of conversation this is. Every thread the reference serves here comes back
|
* Which kind of conversation this is — the client's `ChatThreadType`: 0 Player,
|
||||||
* as 0, and nothing in the worker distinguishes DMs from groups, so it's a constant —
|
* 1 Club, 2 Party. Player covers both DMs and group chats; nothing here distinguishes
|
||||||
* but the field itself has to be present: the client deserializes it as a non-nullable
|
* the two. The field has to be present whatever its value: the client deserializes it
|
||||||
* int and drops the whole response when it's missing.
|
* as a non-nullable int and drops the whole response when it's missing.
|
||||||
*/
|
*/
|
||||||
chatThreadType: number
|
chatThreadType: ChatThreadTypeValue
|
||||||
snoozedUntil: string | null
|
snoozedUntil: string | null
|
||||||
isFavorited: boolean
|
isFavorited: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The only thread type the reference ever serves. See `ChatThread.chatThreadType`. */
|
/**
|
||||||
const CHAT_THREAD_TYPE_DEFAULT = 0
|
* 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 joined row backing a rendered thread, before it's shaped for the client. */
|
/** The joined row backing a rendered thread, before it's shaped for the client. */
|
||||||
interface ThreadRow {
|
interface ThreadRow {
|
||||||
chat_thread_id: number
|
chat_thread_id: number
|
||||||
chat_thread_name: string | null
|
chat_thread_name: string | null
|
||||||
|
chat_thread_type: number
|
||||||
player_ids: string | null
|
player_ids: string | null
|
||||||
last_read_message_id: number | null
|
last_read_message_id: number | null
|
||||||
snoozed_until: string | null
|
snoozed_until: string | null
|
||||||
@@ -119,30 +133,24 @@ function toThread(row: ThreadRow): ChatThread {
|
|||||||
lastReadMessageId: row.last_read_message_id ?? 0,
|
lastReadMessageId: row.last_read_message_id ?? 0,
|
||||||
// Null in the column means "unnamed"; the client dereferences it unchecked.
|
// Null in the column means "unnamed"; the client dereferences it unchecked.
|
||||||
chatThreadName: row.chat_thread_name ?? '',
|
chatThreadName: row.chat_thread_name ?? '',
|
||||||
chatThreadType: CHAT_THREAD_TYPE_DEFAULT,
|
chatThreadType: row.chat_thread_type as ChatThreadTypeValue,
|
||||||
snoozedUntil: row.snoozed_until,
|
snoozedUntil: row.snoozed_until,
|
||||||
isFavorited: row.is_favorited !== 0,
|
isFavorited: row.is_favorited !== 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The thread list as it renders for one player, newest conversation first — the
|
* The shared projection behind every rendered thread — the list, the single read and the
|
||||||
* `?MessageCount=N` page of the thread endpoint.
|
* 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
|
||||||
* 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.
|
* group_concat is what makes `playerIds` come back sorted rather than in row order.
|
||||||
|
*
|
||||||
|
* Callers append their own WHERE (and ORDER/LIMIT) and bind `?1` onwards from there.
|
||||||
*/
|
*/
|
||||||
export async function getThreadsForPlayer(
|
const THREAD_SELECT = `SELECT
|
||||||
db: D1Database,
|
|
||||||
playerId: number,
|
|
||||||
{ limit = 50 }: { limit?: number } = {}
|
|
||||||
): Promise<ChatThread[]> {
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT
|
|
||||||
t.chat_thread_id,
|
t.chat_thread_id,
|
||||||
t.chat_thread_name,
|
t.chat_thread_name,
|
||||||
|
t.chat_thread_type,
|
||||||
(SELECT group_concat(player_id) FROM
|
(SELECT group_concat(player_id) FROM
|
||||||
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
|
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
|
||||||
ORDER BY player_id)) AS player_ids,
|
ORDER BY player_id)) AS player_ids,
|
||||||
@@ -157,7 +165,22 @@ export async function getThreadsForPlayer(
|
|||||||
msg.moderation_state AS msg_moderation_state
|
msg.moderation_state AS msg_moderation_state
|
||||||
FROM thread_member me
|
FROM thread_member me
|
||||||
JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id
|
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
|
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}
|
||||||
WHERE me.player_id = ?1
|
WHERE me.player_id = ?1
|
||||||
ORDER BY t.latest_message_id DESC
|
ORDER BY t.latest_message_id DESC
|
||||||
LIMIT ?2`
|
LIMIT ?2`
|
||||||
@@ -175,24 +198,7 @@ export async function getThreadForPlayer(
|
|||||||
): Promise<ChatThread | null> {
|
): Promise<ChatThread | null> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT
|
`${THREAD_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`
|
WHERE me.chat_thread_id = ?1 AND me.player_id = ?2`
|
||||||
)
|
)
|
||||||
.bind(chatThreadId, playerId)
|
.bind(chatThreadId, playerId)
|
||||||
@@ -200,6 +206,65 @@ export async function getThreadForPlayer(
|
|||||||
return row === null ? null : toThread(row)
|
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
|
* Whether a player may read or post to a thread. Every thread-scoped route gates on
|
||||||
* this before touching messages.
|
* this before touching messages.
|
||||||
@@ -243,6 +308,19 @@ export function leftChatContents(playerId: number): string {
|
|||||||
return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> left` })
|
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
|
* Rename a thread. An empty name clears it back to unnamed, which renders as the member
|
||||||
* list rather than a blank title.
|
* list rather than a blank title.
|
||||||
@@ -269,19 +347,24 @@ export async function setThreadName(
|
|||||||
*
|
*
|
||||||
* Every call opens a *distinct* thread, even for a member set that already has one —
|
* 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.
|
* 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(
|
export async function createThread(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
playerIds: number[],
|
playerIds: number[],
|
||||||
name: string | null = null,
|
name: string | null = null,
|
||||||
startedBy?: number
|
startedBy?: number,
|
||||||
|
type: ChatThreadTypeValue = ChatThreadType.Player
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO message_thread (chat_thread_name, created_at) VALUES (?1, ?2)
|
`INSERT INTO message_thread (chat_thread_name, chat_thread_type, created_at)
|
||||||
|
VALUES (?1, ?2, ?3)
|
||||||
RETURNING chat_thread_id`
|
RETURNING chat_thread_id`
|
||||||
)
|
)
|
||||||
.bind(name, new Date().toISOString())
|
.bind(name, type, new Date().toISOString())
|
||||||
.first<{ chat_thread_id: number }>()
|
.first<{ chat_thread_id: number }>()
|
||||||
if (row === null) throw new Error('failed to create chat thread')
|
if (row === null) throw new Error('failed to create chat thread')
|
||||||
|
|
||||||
@@ -322,27 +405,33 @@ export async function createThread(
|
|||||||
* thread is gone are ignored rather than resolved to: matching one would hand back an id
|
* 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
|
* that nothing else in the worker can render, and — since the oldest match wins — it
|
||||||
* would keep winning on every subsequent call.
|
* 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(
|
export async function findThreadWithMembers(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
playerIds: number[]
|
playerIds: number[],
|
||||||
|
type: ChatThreadTypeValue = ChatThreadType.Player
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const members = [...new Set(playerIds)]
|
const members = [...new Set(playerIds)]
|
||||||
if (members.length === 0) return null
|
if (members.length === 0) return null
|
||||||
|
|
||||||
// ?1 is the member count; ?2… are the ids themselves.
|
// ?1 is the member count, ?2 the thread type; ?3… are the ids themselves.
|
||||||
const placeholders = members.map((_, i) => `?${i + 2}`).join(', ')
|
const placeholders = members.map((_, i) => `?${i + 3}`).join(', ')
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT m.chat_thread_id FROM thread_member m
|
`SELECT m.chat_thread_id FROM thread_member m
|
||||||
JOIN message_thread t ON t.chat_thread_id = m.chat_thread_id
|
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
|
GROUP BY m.chat_thread_id
|
||||||
HAVING COUNT(*) = ?1
|
HAVING COUNT(*) = ?1
|
||||||
AND COUNT(CASE WHEN m.player_id IN (${placeholders}) THEN 1 END) = ?1
|
AND COUNT(CASE WHEN m.player_id IN (${placeholders}) THEN 1 END) = ?1
|
||||||
ORDER BY m.chat_thread_id
|
ORDER BY m.chat_thread_id
|
||||||
LIMIT 1`
|
LIMIT 1`
|
||||||
)
|
)
|
||||||
.bind(members.length, ...members)
|
.bind(members.length, type, ...members)
|
||||||
.first<{ chat_thread_id: number }>()
|
.first<{ chat_thread_id: number }>()
|
||||||
return row?.chat_thread_id ?? null
|
return row?.chat_thread_id ?? null
|
||||||
}
|
}
|
||||||
@@ -352,16 +441,20 @@ export async function findThreadWithMembers(
|
|||||||
* simultaneous first-messages to the same set can still race into two threads; the
|
* 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
|
* oldest-match rule in `findThreadWithMembers` means both parties converge on one of
|
||||||
* them afterwards.
|
* 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(
|
export async function getOrCreateThreadWithMembers(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
playerIds: number[],
|
playerIds: number[],
|
||||||
startedBy: number
|
startedBy: number
|
||||||
): Promise<number> {
|
): Promise<{ chatThreadId: number; created: boolean }> {
|
||||||
return (
|
const existing = await findThreadWithMembers(db, playerIds)
|
||||||
(await findThreadWithMembers(db, playerIds)) ??
|
if (existing !== null) return { chatThreadId: existing, created: false }
|
||||||
(await createThread(db, playerIds, null, startedBy))
|
return { chatThreadId: await createThread(db, playerIds, null, startedBy), created: true }
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everyone in a thread, ordered by id — the fan-out list for a push notification. */
|
/** Everyone in a thread, ordered by id — the fan-out list for a push notification. */
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -33,6 +33,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -30,6 +30,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
"compatibility_date": "2026-06-16",
|
"compatibility_date": "2026-06-16",
|
||||||
"compatibility_flags": ["nodejs_compat"],
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
"upload_source_maps": true,
|
"upload_source_maps": true,
|
||||||
|
"cache": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
"observability": {
|
"observability": {
|
||||||
"logs": {
|
"logs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"workers-tagged-logger": "1.0.1"
|
"workers-tagged-logger": "1.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -25,6 +25,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -30,6 +30,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,15 @@ const PAGE_SOURCES = [
|
|||||||
'StoreConsumables',
|
'StoreConsumables',
|
||||||
]
|
]
|
||||||
|
|
||||||
interface Section {
|
/**
|
||||||
id: string
|
* A section as published. The reference captures are camelCase and the hand-authored
|
||||||
sectionType: number
|
* store pages PascalCase; the client's decoder is case-insensitive, so both are served
|
||||||
sectionSubType: string
|
* as-is and the tests read either spelling.
|
||||||
source: string
|
*/
|
||||||
sourceMetadata: string | null
|
type Section = Record<string, unknown>
|
||||||
displayMetadata: string | null
|
|
||||||
|
function field(section: Section, name: string): unknown {
|
||||||
|
return section[name] ?? section[name[0].toUpperCase() + name.slice(1)]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch a page source and return its parsed body. */
|
/** Fetch a page source and return its parsed body. */
|
||||||
@@ -44,16 +46,17 @@ describe('GET /sections/pagesource/:type', () => {
|
|||||||
// is reachable without the worker knowing its name.
|
// is reachable without the worker knowing its name.
|
||||||
it.each(PAGE_SOURCES)('serves %s', async (type) => {
|
it.each(PAGE_SOURCES)('serves %s', async (type) => {
|
||||||
const sections = await pageSource(type)
|
const sections = await pageSource(type)
|
||||||
expect(sections.length).toBeGreaterThan(0)
|
expect(Array.isArray(sections)).toBe(true)
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
expect(typeof section.id).toBe('string')
|
expect(typeof field(section, 'id')).toBe('string')
|
||||||
expect(typeof section.sectionType).toBe('number')
|
expect(typeof field(section, 'sectionType')).toBe('number')
|
||||||
expect(typeof section.source).toBe('string')
|
expect(typeof field(section, 'source')).toBe('string')
|
||||||
// An embedded JSON *string* the client parses itself, not an object — or null,
|
// An embedded JSON *string* the client parses itself, not an object — or null,
|
||||||
// which several store and play-highlight sections use.
|
// which several store and play-highlight sections use.
|
||||||
if (section.displayMetadata !== null) {
|
const display = field(section, 'displayMetadata')
|
||||||
expect(typeof section.displayMetadata).toBe('string')
|
if (display !== null && display !== undefined) {
|
||||||
expect(() => JSON.parse(section.displayMetadata as string)).not.toThrow()
|
expect(typeof display).toBe('string')
|
||||||
|
expect(() => JSON.parse(display as string)).not.toThrow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -81,35 +84,17 @@ describe('GET /sections/pagesource/:type', () => {
|
|||||||
// that this builder would drop — that is the reference's data, not a mistake to fix here.
|
// that this builder would drop — that is the reference's data, not a mistake to fix here.
|
||||||
it('StoreCategories only carries sections the store page builder keeps', async () => {
|
it('StoreCategories only carries sections the store page builder keeps', async () => {
|
||||||
for (const section of await pageSource('StoreCategories')) {
|
for (const section of await pageSource('StoreCategories')) {
|
||||||
expect([4, 13]).toContain(section.sectionType)
|
expect([4, 13]).toContain(field(section, 'sectionType'))
|
||||||
expect(section.displayMetadata).toBeTruthy()
|
const display = field(section, 'displayMetadata')
|
||||||
expect(() => JSON.parse(section.displayMetadata as string)).not.toThrow()
|
expect(display).toBeTruthy()
|
||||||
if (section.sectionType === 13) {
|
expect(() => JSON.parse(display as string)).not.toThrow()
|
||||||
expect(['CuratedList', 'PageSource']).toContain(section.source)
|
if (field(section, 'sectionType') === 13) {
|
||||||
expect(section.sourceMetadata).toBeTruthy()
|
expect(['CuratedList', 'PageSource']).toContain(field(section, 'source'))
|
||||||
|
expect(field(section, 'sourceMetadata')).toBeTruthy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('serves the StoreCategories page', async () => {
|
|
||||||
const sections = await pageSource('StoreCategories')
|
|
||||||
expect(sections[0]).toEqual({
|
|
||||||
id: 'store-featured',
|
|
||||||
// StoreItemsSection: a store CATEGORY is drawn as the product carousel.
|
|
||||||
sectionType: 4,
|
|
||||||
sectionSubType: 'StoreCategory_Featured',
|
|
||||||
source: 'CuratedList',
|
|
||||||
// The curated list the `lists` worker serves from /curatedlists/bulk.
|
|
||||||
sourceMetadata: '17859340',
|
|
||||||
displayMetadata: expect.stringContaining('"DisplayTitle":"Featured"'),
|
|
||||||
})
|
|
||||||
// displayMetadata must be non-empty and parse, or the builder drops the section.
|
|
||||||
const display = JSON.parse(sections[0].displayMetadata as string) as {
|
|
||||||
categoryUriNames: string
|
|
||||||
}
|
|
||||||
expect(display.categoryUriNames).toBe('featured,new')
|
|
||||||
})
|
|
||||||
|
|
||||||
// The asset manifest is case-sensitive and there is no index to fold case against, so
|
// The asset manifest is case-sensitive and there is no index to fold case against, so
|
||||||
// the name has to match the file exactly.
|
// the name has to match the file exactly.
|
||||||
it('404s a name whose case does not match the file', async () => {
|
it('404s a name whose case does not match the file', async () => {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
// nothing documents and the client never asks for. With it, the Worker sees every
|
// nothing documents and the client never asks for. With it, the Worker sees every
|
||||||
// request and the files are reachable only through the binding, i.e. only under
|
// request and the files are reachable only through the binding, i.e. only under
|
||||||
// `/sections/pagesource/:type`.
|
// `/sections/pagesource/:type`.
|
||||||
|
"cache": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
"assets": {
|
"assets": {
|
||||||
"binding": "ASSETS",
|
"binding": "ASSETS",
|
||||||
"directory": "./static",
|
"directory": "./static",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -34,6 +34,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+449
-157
@@ -17,14 +17,14 @@ import {
|
|||||||
setOutfit,
|
setOutfit,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId, validateAndGetRoles, validateAndGetVersion } from '@repo/jwt'
|
import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } from '@repo/jwt'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getCustomAvatarItems,
|
getCustomAvatarItems,
|
||||||
toUgcPurchasable,
|
toUgcPurchasable,
|
||||||
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
|
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
|
||||||
} from '../../api/src/custom-avatar-items-db'
|
} from '../../api/src/custom-avatar-items-db'
|
||||||
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
|
import { getInventionById, toInventionV9, toSaveResult } from '../../api/src/inventions-db'
|
||||||
// The profanity filter behind `api`'s `POST /api/sanitize/v1`, imported rather than copied
|
// The profanity filter behind `api`'s `POST /api/sanitize/v1`, imported rather than copied
|
||||||
// so a gift note is masked by the very same word list every other player-typed string is.
|
// so a gift note is masked by the very same word list every other player-typed string is.
|
||||||
import { censorSwears } from '../../api/src/sanitize'
|
import { censorSwears } from '../../api/src/sanitize'
|
||||||
@@ -40,6 +40,7 @@ import defaultAvatarItems from '../static/default-avatar-items.json'
|
|||||||
import defaultAvatar from '../static/default-avatar.json'
|
import defaultAvatar from '../static/default-avatar.json'
|
||||||
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
|
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
|
||||||
import myProgress from '../static/my-progress.json'
|
import myProgress from '../static/my-progress.json'
|
||||||
|
import questRewards from '../static/quest-rewards.json'
|
||||||
import { getAvatar, setAvatar } from './avatar-db'
|
import { getAvatar, setAvatar } from './avatar-db'
|
||||||
import {
|
import {
|
||||||
ALL_PLATFORMS,
|
ALL_PLATFORMS,
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
isSpendable,
|
isSpendable,
|
||||||
spendCurrency,
|
spendCurrency,
|
||||||
} from './balance-db'
|
} from './balance-db'
|
||||||
|
import { getCatalogItem } from './catalog-db'
|
||||||
// `LEGACY_CLIENT_BUILD` is shared with the storefront generator rather than restated: it picks
|
// `LEGACY_CLIENT_BUILD` is shared with the storefront generator rather than restated: it picks
|
||||||
// which store FILE a caller is served here, and which ITEMS go in that file there. The two must
|
// which store FILE a caller is served here, and which ITEMS go in that file there. The two must
|
||||||
// name the same moment or a build gets a store built to a different cutoff.
|
// name the same moment or a build gets a store built to a different cutoff.
|
||||||
@@ -79,7 +81,9 @@ import {
|
|||||||
BalanceEntry,
|
BalanceEntry,
|
||||||
BulkPurchaseRequest,
|
BulkPurchaseRequest,
|
||||||
BulkPurchaseResponse,
|
BulkPurchaseResponse,
|
||||||
|
BuyInventionRequest,
|
||||||
BuyInventionResponse,
|
BuyInventionResponse,
|
||||||
|
BuyInventionV3Response,
|
||||||
BuyItemRequest,
|
BuyItemRequest,
|
||||||
BuyItemResponse,
|
BuyItemResponse,
|
||||||
ChallengeProgressRequest,
|
ChallengeProgressRequest,
|
||||||
@@ -124,6 +128,7 @@ import { claimReward } from './reward-db'
|
|||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
|
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
|
||||||
import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db'
|
import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db'
|
||||||
|
import type { SavedInvention } from '../../api/src/inventions-db'
|
||||||
import type {
|
import type {
|
||||||
BalanceResponsePayload,
|
BalanceResponsePayload,
|
||||||
PurchaseBalanceModificationPayload,
|
PurchaseBalanceModificationPayload,
|
||||||
@@ -165,16 +170,6 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
|||||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The `role` claim from a Bearer token — the operator-granted roles the auth worker stamps
|
|
||||||
* from the account's flags, so a plain player's token is just `['gameClient']`. `null` when
|
|
||||||
* the request carries no valid token; an empty array means a valid token with no roles.
|
|
||||||
* Shaped to mirror {@link authedId}.
|
|
||||||
*/
|
|
||||||
async function authedRoles(c: Context<App>): Promise<string[] | null> {
|
|
||||||
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The client build this request's token was minted for (`rn.ver`), as a comparable NUMBER —
|
* The client build this request's token was minted for (`rn.ver`), as a comparable NUMBER —
|
||||||
* the leading `YYYYMMDD` of e.g. `20250718.01`, whose `.01` is a same-day rebuild and not a
|
* the leading `YYYYMMDD` of e.g. `20250718.01`, whose `.01` is a same-day rebuild and not a
|
||||||
@@ -413,20 +408,32 @@ async function pushBalancePurchase(
|
|||||||
*/
|
*/
|
||||||
const NOT_AN_INFLUENCER = 0
|
const NOT_AN_INFLUENCER = 0
|
||||||
|
|
||||||
/** The operator-granted role that comes with a complimentary subscription. */
|
|
||||||
const DEVELOPER_ROLE = 'developer'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the caller currently holds a Rec Room Plus subscription — the ONE definition,
|
* Whether the caller holds a Rec Room Plus subscription — the ONE definition, shared by
|
||||||
* shared by `UpdateAndGetSubscription` (which reports it) and the storefront buys (which
|
* `UpdateAndGetSubscription` (which reports it) and the storefront buys (which price off
|
||||||
* price off it via `SubscriberPrices`). Nothing sells subscriptions here, so holding the
|
* it via `SubscriberPrices`). Those two must never disagree: a subscriber whose client
|
||||||
* `developer` role IS the subscription; if a real subscription store ever lands, this is
|
* applied the discount itself and then had the buy refused as a price mismatch is exactly
|
||||||
* the only place that has to learn about it. Read from the token's `role` claim, never the
|
* what one definition prevents.
|
||||||
* body; no or an invalid token is "not subscribed".
|
*
|
||||||
|
* Nothing SELLS subscriptions here. Plus is `account.hasPlus`, claimed on the website by
|
||||||
|
* proving a qualifying role in the community Discord (`www` `POST /api/benefits/claim`),
|
||||||
|
* and it reaches this worker as the token's `rn.plus` claim — stamped by `auth` at login
|
||||||
|
* from that flag. So this is a pure token read: no database, no binding, nothing to load.
|
||||||
|
*
|
||||||
|
* The cost is FRESHNESS, deliberately accepted. The claim is only as current as the token,
|
||||||
|
* which lasts a day and is never refreshed (see TOKEN_TTL_SECONDS), so a player who claims
|
||||||
|
* on the website has to sign in again — and restart the game — before Plus applies. The
|
||||||
|
* website's claim page says so.
|
||||||
|
*
|
||||||
|
* The `developer` role does NOT grant Plus. It used to, as a stand-in while nothing else
|
||||||
|
* could confer it; now that the Discord claim exists, Plus is one thing with one source.
|
||||||
|
* An operator who wants a developer to have it sets `hasPlus` on their account like
|
||||||
|
* anyone else's.
|
||||||
|
*
|
||||||
|
* Never read from the body. No token, or an invalid one, is "not subscribed".
|
||||||
*/
|
*/
|
||||||
async function isSubscriber(c: Context<App>): Promise<boolean> {
|
async function isSubscriber(c: Context<App>): Promise<boolean> {
|
||||||
const roles = await authedRoles(c)
|
return validateAndGetPlus(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
return roles?.includes(DEVELOPER_ROLE) ?? false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `SubscriptionLevel.Gold`. 1 is Platinum. */
|
/** `SubscriptionLevel.Gold`. 1 is Platinum. */
|
||||||
@@ -446,20 +453,21 @@ const SUBSCRIPTION_PLATFORM_ALL = -1
|
|||||||
const STUB_SUBSCRIPTION_ID = 1
|
const STUB_SUBSCRIPTION_ID = 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The complimentary subscription a `developer` account reports — Rec Room Plus, which the
|
* The complimentary subscription a subscriber reports — Rec Room Plus, which the client's
|
||||||
* client's API calls a `CampusCard`.
|
* API calls a `CampusCard`. See `isSubscriber` for who counts as one: a `developer`, or a
|
||||||
|
* player who claimed `hasPlus` with a Discord role on the website.
|
||||||
*
|
*
|
||||||
* Nothing here sells subscriptions, so holding the role IS the subscription: it's how the
|
* Nothing here sells subscriptions, so holding one of those IS the subscription. Every
|
||||||
* paid-tier surfaces get exercised without a store. Every field is computed per call and
|
* field is computed per call and none of it is persisted, so this is not a record of
|
||||||
* none of it is persisted, so this is not a record of anything — revoking the role revokes
|
* anything — dropping the role or the flag drops the subscription, and no expiry sweep or
|
||||||
* the subscription, and no expiry sweep or renewal exists.
|
* renewal exists.
|
||||||
*
|
*
|
||||||
* `ExpirationDate` is a year out from THIS call rather than a fixed date: a hard-coded one
|
* `ExpirationDate` is a year out from THIS call rather than a fixed date: a hard-coded one
|
||||||
* lapses on a day nobody is expecting, and the client would start showing an expired
|
* lapses on a day nobody is expecting, and the client would start showing an expired
|
||||||
* subscription with no way to renew it. `IsAutoRenewing` tells the client the same thing.
|
* subscription with no way to renew it. `IsAutoRenewing` tells the client the same thing.
|
||||||
* The dates are milliseconds-precision ISO like the rest of this worker's timestamps.
|
* The dates are milliseconds-precision ISO like the rest of this worker's timestamps.
|
||||||
*/
|
*/
|
||||||
function developerSubscription(accountId: number) {
|
function plusSubscription(accountId: number) {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
// Calendar arithmetic, not now + 365 days: setUTCFullYear lands on the same date next
|
// Calendar arithmetic, not now + 365 days: setUTCFullYear lands on the same date next
|
||||||
// year whether or not a leap day falls in between.
|
// year whether or not a leap day falls in between.
|
||||||
@@ -496,24 +504,45 @@ function toAvatarV2Dto(avatar: Avatar) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The subset of a storefront catalog (`static/storefronts/sf{N}.json`) that `buyItem`
|
* A storefront catalog entry's `GiftDrop` (`static/storefronts/sf{N}.json`) — what a store
|
||||||
* reads: each store item carries the `GiftDrop` describing what you get, a list of
|
* item hands over. Field-for-field the client's own `GiftDrop` class, in its declared
|
||||||
* `Prices` per currency, and optionally `SubscriberPrices` — the discounted list a Rec Room
|
* order, so a name here is a name the client reads.
|
||||||
* Plus subscriber is shown and pays. The catalogs hold more fields (IsFeatured, …) that
|
*
|
||||||
* the purchase path doesn't need.
|
* REQUIRED vs OPTIONAL is about what this server produces, not what the client declares:
|
||||||
|
* the required eleven are the ones every drop-building helper here sets (a game reward, a
|
||||||
|
* level-up box, a challenge gift — see {@link toGameRewardDrop} and friends), and they are
|
||||||
|
* the only ones the purchase and roll paths read. The rest are optional because nothing
|
||||||
|
* here synthesizes one, whether or not a captured catalog carries it — `GiftDropId`,
|
||||||
|
* `Unique`, `SubscribersOnly`, `ItemSetId` and `ItemSetFriendlyName` are on all 5,875
|
||||||
|
* captured entries, while `TagList`, `CustomAvatarItemId`, `AvatarItemId`,
|
||||||
|
* `EquipmentItemId` and `ThumbnailImageName` are on none of them.
|
||||||
|
*
|
||||||
|
* The store item around it carries `Prices` per currency and optionally `SubscriberPrices`
|
||||||
|
* — the discounted list a Rec Room Plus subscriber is shown and pays. Both hold more
|
||||||
|
* fields (IsFeatured, …) the purchase path doesn't need.
|
||||||
*/
|
*/
|
||||||
interface StoreGiftDrop {
|
interface StoreGiftDrop {
|
||||||
|
/**
|
||||||
|
* The drop's own id. Every captured entry has it equal to the item's
|
||||||
|
* `PurchasableItemId`, which is why the paths that need one (a weekly gift, a skin)
|
||||||
|
* take it off there instead of from here.
|
||||||
|
*/
|
||||||
|
GiftDropId?: number
|
||||||
FriendlyName: string
|
FriendlyName: string
|
||||||
Tooltip: string
|
/**
|
||||||
|
* NULL on 23 captured entries — the client's field is a plain string, but the catalogs
|
||||||
|
* keep null and `""` apart, so a reader passing it on has to collapse it (`?? ''`).
|
||||||
|
*/
|
||||||
|
Tooltip: string | null
|
||||||
|
/** Not on any captured entry; nothing here reads or sets one. */
|
||||||
|
TagList?: string
|
||||||
ConsumableItemDesc: string
|
ConsumableItemDesc: string
|
||||||
AvatarItemDesc: string
|
AvatarItemDesc: string
|
||||||
|
/** A UGC item's guid. Not on any captured entry — the captures predate them. */
|
||||||
|
CustomAvatarItemId?: string | null
|
||||||
AvatarItemType: number | null
|
AvatarItemType: number | null
|
||||||
EquipmentPrefabName: string
|
EquipmentPrefabName: string
|
||||||
EquipmentModificationGuid: string
|
EquipmentModificationGuid: string
|
||||||
Rarity: number
|
|
||||||
Context: number
|
|
||||||
Currency: number
|
|
||||||
CurrencyType: number
|
|
||||||
/**
|
/**
|
||||||
* A QUERY drop — a loot box rather than an item. Its item fields are all empty on
|
* A QUERY drop — a loot box rather than an item. Its item fields are all empty on
|
||||||
* purpose: what the player gets is rolled at grant time from everything of the target
|
* purpose: what the player gets is rolled at grant time from everything of the target
|
||||||
@@ -522,16 +551,37 @@ interface StoreGiftDrop {
|
|||||||
* random 4-star item that you don't have."
|
* random 4-star item that you don't have."
|
||||||
*/
|
*/
|
||||||
IsQuery?: boolean
|
IsQuery?: boolean
|
||||||
|
/** Whether the player may hold only one. Nothing here enforces it. */
|
||||||
|
Unique?: boolean
|
||||||
|
/** Whether only a Rec Room Plus subscriber may buy it. Nothing here enforces it. */
|
||||||
|
SubscribersOnly?: boolean
|
||||||
|
Rarity: number
|
||||||
|
CurrencyType: number
|
||||||
|
Currency: number
|
||||||
|
Context: number
|
||||||
|
/** The set the item belongs to; null on 92 captured entries. */
|
||||||
|
ItemSetId?: number | null
|
||||||
|
ItemSetFriendlyName?: string
|
||||||
|
/** Catalog ids for the item the drop carries. Not on any captured entry. */
|
||||||
|
AvatarItemId?: number | null
|
||||||
|
EquipmentItemId?: number | null
|
||||||
|
/** Not on any captured entry; the client falls back to the item's own thumbnail. */
|
||||||
|
ThumbnailImageName?: string
|
||||||
|
|
||||||
|
// ---- Not part of the client's class -------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The rarity a query drop rolls at, when it differs from the box's own `Rarity`. The
|
* The rarity a query drop rolls at, when it differs from the box's own `Rarity`. The
|
||||||
* sf2 boxes carry both and they agree; sf3's don't carry it at all, hence the fallback
|
* sf2 boxes carry both and they agree; sf3's don't carry it at all, hence the fallback
|
||||||
* to `Rarity`.
|
* to `Rarity`. The client's `GiftDrop` has no such field — it is the catalog's, and
|
||||||
|
* only this server reads it.
|
||||||
*/
|
*/
|
||||||
QueryRedirectRarity?: number
|
QueryRedirectRarity?: number
|
||||||
/**
|
/**
|
||||||
* XP the drop pays out. No storefront catalog sets it — a bought item is an item — but a
|
* XP the drop pays out. Ours, not the client's and not any catalog's — a bought item is
|
||||||
* game reward is XP in a gift box, so the box and its notification carry the amount from
|
* an item, but a game reward is XP in a gift box, so the box and its notification carry
|
||||||
* here. The XP itself is banked in `progression`, not read back off the box.
|
* the amount from here. The XP itself is banked in `progression`, not read back off the
|
||||||
|
* box.
|
||||||
*/
|
*/
|
||||||
Xp?: number
|
Xp?: number
|
||||||
}
|
}
|
||||||
@@ -807,7 +857,7 @@ function toAvatarItem(giftDrop: StoreGiftDrop): AvatarItem {
|
|||||||
AvatarItemDesc: giftDrop.AvatarItemDesc,
|
AvatarItemDesc: giftDrop.AvatarItemDesc,
|
||||||
PlatformMask: -1,
|
PlatformMask: -1,
|
||||||
FriendlyName: giftDrop.FriendlyName,
|
FriendlyName: giftDrop.FriendlyName,
|
||||||
Tooltip: giftDrop.Tooltip,
|
Tooltip: giftDrop.Tooltip ?? '',
|
||||||
Rarity: giftDrop.Rarity,
|
Rarity: giftDrop.Rarity,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -818,7 +868,7 @@ function toEquipment(giftDrop: StoreGiftDrop): Equipment {
|
|||||||
ModificationGuid: giftDrop.EquipmentModificationGuid,
|
ModificationGuid: giftDrop.EquipmentModificationGuid,
|
||||||
PrefabName: giftDrop.EquipmentPrefabName,
|
PrefabName: giftDrop.EquipmentPrefabName,
|
||||||
FriendlyName: giftDrop.FriendlyName,
|
FriendlyName: giftDrop.FriendlyName,
|
||||||
Tooltip: giftDrop.Tooltip,
|
Tooltip: giftDrop.Tooltip ?? '',
|
||||||
Rarity: giftDrop.Rarity,
|
Rarity: giftDrop.Rarity,
|
||||||
PlatformMask: -1,
|
PlatformMask: -1,
|
||||||
Favorited: false,
|
Favorited: false,
|
||||||
@@ -1723,6 +1773,87 @@ function toGameRewardDrop(): StoreGiftDrop {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `static/quest-rewards.json`: the reward table of the live game's activities,
|
||||||
|
* keyed by the `giftContext` the client posts with a game-reward ask (`Dodgeball`,
|
||||||
|
* `Quest_Goblin_S`, `Paintball_Dam`, …). Each row is the gift-drop as the game's own reward
|
||||||
|
* server shaped it — a comma-laden `AvatarItemDesc` (the catalog's `item_key`), or for the
|
||||||
|
* Laser Tag entry a currency payout — with `GiftRarity` and the activity's own `Context`
|
||||||
|
* (8000 for dodgeball, 4003 for the goblin quest's S rank) spelled the way the client's box
|
||||||
|
* reads them. Untyped fields (`Id`, `Level`, `Message`) are carried but unused.
|
||||||
|
*/
|
||||||
|
interface QuestReward {
|
||||||
|
AvatarItemDesc: string
|
||||||
|
ConsumableItemDesc: string
|
||||||
|
EquipmentPrefabName: string
|
||||||
|
EquipmentModificationGuid: string
|
||||||
|
CurrencyType: number
|
||||||
|
Currency: number
|
||||||
|
Xp: number
|
||||||
|
GiftRarity: number
|
||||||
|
Context: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUEST_REWARDS: Record<string, QuestReward[]> = questRewards
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reward an activity pays, when `giftContext` names an entry in `quest-rewards.json`:
|
||||||
|
* one row drawn at random from that key's list, among the rows the player DOESN'T ALREADY
|
||||||
|
* OWN — the table is "what this activity can give you", and handing over a duplicate gives
|
||||||
|
* nothing (the inventory is a set). A currency row is never "owned", so it always stays in
|
||||||
|
* the pool.
|
||||||
|
*
|
||||||
|
* Null for a context the table doesn't know (or one whose every reward the player already
|
||||||
|
* has), which the caller pays as the plain XP box — the cooldown key is the same string
|
||||||
|
* either way, so an unknown or exhausted context is still rate-limited.
|
||||||
|
*/
|
||||||
|
async function pickQuestReward(
|
||||||
|
db: D1Database,
|
||||||
|
accountId: number,
|
||||||
|
giftContext: string
|
||||||
|
): Promise<QuestReward | null> {
|
||||||
|
if (!Object.hasOwn(QUEST_REWARDS, giftContext)) return null
|
||||||
|
const rows = QUEST_REWARDS[giftContext] ?? []
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
const ownedItems = new Set((await getInventory(db, accountId)).map((i) => i.AvatarItemDesc))
|
||||||
|
const ownedGuids = new Set((await getEquipment(db, accountId)).map((e) => e.ModificationGuid))
|
||||||
|
const pool = rows.filter(
|
||||||
|
(r) =>
|
||||||
|
!(r.AvatarItemDesc !== '' && ownedItems.has(r.AvatarItemDesc)) &&
|
||||||
|
!(r.EquipmentModificationGuid !== '' && ownedGuids.has(r.EquipmentModificationGuid))
|
||||||
|
)
|
||||||
|
if (pool.length === 0) {
|
||||||
|
logger.info('quest rewards exhausted for player', { accountId, giftContext })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A quest reward as the gift-drop `grantGiftDrop` hands over. The item fields come off the
|
||||||
|
* row, so the item IS granted — unlike {@link toGameRewardDrop}'s empty box. The catalog
|
||||||
|
* row for the item, when it resolves, supplies what the table doesn't carry (name, tooltip,
|
||||||
|
* `AvatarItemType`), so the inventory entry reads like a bought one rather than blank.
|
||||||
|
* The XP is the flat game-reward amount, not the row's (always 0): the reward is the item,
|
||||||
|
* and the XP is the same pat on the back every claim gets.
|
||||||
|
*/
|
||||||
|
function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): StoreGiftDrop {
|
||||||
|
return {
|
||||||
|
FriendlyName: catalog?.friendly_name ?? '',
|
||||||
|
Tooltip: catalog?.tooltip ?? '',
|
||||||
|
ConsumableItemDesc: reward.ConsumableItemDesc,
|
||||||
|
AvatarItemDesc: reward.AvatarItemDesc,
|
||||||
|
AvatarItemType: catalog?.avatar_item_type ?? null,
|
||||||
|
EquipmentPrefabName: reward.EquipmentPrefabName,
|
||||||
|
EquipmentModificationGuid: reward.EquipmentModificationGuid,
|
||||||
|
Rarity: reward.GiftRarity,
|
||||||
|
Context: reward.Context,
|
||||||
|
Currency: reward.Currency,
|
||||||
|
CurrencyType: reward.CurrencyType,
|
||||||
|
Xp: GAME_REWARD_XP,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The box a CLOTHING level-up hands over: a query drop at the level's own tier, rolled from
|
* The box a CLOTHING level-up hands over: a query drop at the level's own tier, rolled from
|
||||||
* AVATAR ITEMS only. The published table calls these levels "N-Star Clothing", so the prize
|
* AVATAR ITEMS only. The published table calls these levels "N-Star Clothing", so the prize
|
||||||
@@ -2011,6 +2142,120 @@ function listRoute(summary: string, description: string, auth = false) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A completed invention purchase: the invention that changed hands and the buyer's
|
||||||
|
* RESULTING token balance (not the change — see the envelopes both routes build from it).
|
||||||
|
*/
|
||||||
|
interface SettledInventionPurchase {
|
||||||
|
invention: SavedInvention
|
||||||
|
balance: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle an invention purchase — the whole of buyInvention except the envelope it is
|
||||||
|
* announced in, shared by the `v2` GET and the `v3` POST. Every refusal is a `Response`
|
||||||
|
* (the `{ error }` body both routes answer with); a sale is the bought invention and the
|
||||||
|
* buyer's resulting balance, which each route then wraps in ITS OWN shape — the two
|
||||||
|
* clients want different ones, so the money is shared and the projection is not.
|
||||||
|
*
|
||||||
|
* A priced invention is settled player-to-player: the buyer is debited its `Price` in
|
||||||
|
* RecCenterTokens and the CREATOR is credited the same amount — no house cut, so the
|
||||||
|
* tokens are moved rather than minted or burned. A free invention (`Price` 0) skips the
|
||||||
|
* money entirely: nothing is debited and nobody is paid. The stored price is confirmed
|
||||||
|
* against the price the client rendered first, so a stale or tampered client can’t buy
|
||||||
|
* at a price the creator no longer offers (409), and an unaffordable one is a 400 —
|
||||||
|
* the same "Insufficient balance" buyItem answers with.
|
||||||
|
*
|
||||||
|
* Ownership is recorded in `inventory_invention`; the creator is not sold their own
|
||||||
|
* invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
|
||||||
|
* than a second row. The invention’s `NumDownloads` counter is deliberately NOT
|
||||||
|
* bumped: that column lives on the `invention` table the `api` worker owns, and this
|
||||||
|
* worker only reads it.
|
||||||
|
*/
|
||||||
|
async function settleInventionPurchase(
|
||||||
|
c: Context<App>,
|
||||||
|
id: number,
|
||||||
|
inventionId: number,
|
||||||
|
requestedPrice: number
|
||||||
|
): Promise<SettledInventionPurchase | Response> {
|
||||||
|
const invention = await getInventionById(c.env.DB, inventionId)
|
||||||
|
if (invention === null) return c.json({ error: 'Invention not found' }, 404)
|
||||||
|
// An unpublished invention is a draft: it isn't on sale, not even for free.
|
||||||
|
if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403)
|
||||||
|
if (invention.CreatorPlayerId === id) {
|
||||||
|
return c.json({ error: 'Cannot buy your own invention' }, 400)
|
||||||
|
}
|
||||||
|
if (await ownsInvention(c.env.DB, id, inventionId)) {
|
||||||
|
return c.json({ error: 'Already owned' }, 409)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The price the client rendered must still be the stored one: a mismatch is a stale
|
||||||
|
// catalog or a tampered request, never a sale.
|
||||||
|
if (invention.Price !== requestedPrice) {
|
||||||
|
return c.json({ error: 'Price has changed' }, 409)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||||
|
// Inventions are priced in RecCenterTokens only — the store shows no other currency
|
||||||
|
// for them, and `Price` carries no currency of its own to pick a different one from.
|
||||||
|
const price = invention.Price
|
||||||
|
if (price > 0) {
|
||||||
|
// Debit the buyer atomically; false means they couldn't afford it and nothing
|
||||||
|
// changed, so no ownership is recorded and the creator is not paid.
|
||||||
|
const paid = await spendCurrency(
|
||||||
|
c.env.DB,
|
||||||
|
id,
|
||||||
|
CurrencyType.RecCenterTokens,
|
||||||
|
price,
|
||||||
|
startingTokens
|
||||||
|
)
|
||||||
|
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant before paying out: these are three separate D1 writes with no transaction
|
||||||
|
// around them, so order them by what a failure costs. A buyer who paid and got the
|
||||||
|
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
|
||||||
|
// is not.
|
||||||
|
await grantInvention(c.env.DB, id, inventionId)
|
||||||
|
|
||||||
|
if (price > 0) {
|
||||||
|
// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
|
||||||
|
// the balance row, and `ensureStartingBalances` is an INSERT OR IGNORE, so a
|
||||||
|
// creator who had never touched their balance would otherwise have the row created
|
||||||
|
// here and lose their starting tokens forever.
|
||||||
|
await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
|
||||||
|
const creatorBalance = await creditCurrency(
|
||||||
|
c.env.DB,
|
||||||
|
invention.CreatorPlayerId,
|
||||||
|
CurrencyType.RecCenterTokens,
|
||||||
|
price,
|
||||||
|
startingTokens
|
||||||
|
)
|
||||||
|
// The creator is a different, probably-online player with no response to read:
|
||||||
|
// push the sale so it lands on their shown balance without a re-fetch. The frame
|
||||||
|
// carries their resulting TOTAL (what `creditCurrency` returns), not the payout —
|
||||||
|
// sending the payout would set their whole balance to it. A plain update rather
|
||||||
|
// than a purchase frame: they sold, they didn't buy. Best-effort, as everywhere.
|
||||||
|
await pushBalanceUpdate(
|
||||||
|
c,
|
||||||
|
invention.CreatorPlayerId,
|
||||||
|
CurrencyType.RecCenterTokens,
|
||||||
|
creatorBalance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlike buyItem — whose `Balance` is the change applied — the reference server
|
||||||
|
// answers this one with the RESULTING total (a first read seeds the buyer's starting
|
||||||
|
// grant, as everywhere else). The buyer's frame carries that same total, so the body
|
||||||
|
// and the push land the client on one number.
|
||||||
|
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
|
||||||
|
// A free invention moved nothing, so there is no purchase to report.
|
||||||
|
if (price > 0) {
|
||||||
|
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
|
||||||
|
}
|
||||||
|
return { invention, balance }
|
||||||
|
}
|
||||||
|
|
||||||
// strict: false so trailing-slash routes (e.g. `/gifts/consume/`, which the client
|
// strict: false so trailing-slash routes (e.g. `/gifts/consume/`, which the client
|
||||||
// posts with a trailing slash) match either form. Mirrors the `api` worker.
|
// posts with a trailing slash) match either form. Mirrors the `api` worker.
|
||||||
const app = new Hono<App>({ strict: false })
|
const app = new Hono<App>({ strict: false })
|
||||||
@@ -3014,8 +3259,8 @@ const app = new Hono<App>({ strict: false })
|
|||||||
summary: 'Buy a storefront item',
|
summary: 'Buy a storefront item',
|
||||||
description: [
|
description: [
|
||||||
'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice`',
|
'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice`',
|
||||||
'still matches the `Prices` entry — a Rec Room Plus subscriber (the same check as',
|
'still matches the `Prices` entry — a Rec Room Plus subscriber (the same `rn.plus`',
|
||||||
'`UpdateAndGetSubscription`) may pay anywhere from that down to 10% off, since their',
|
'check as `UpdateAndGetSubscription`) may pay anywhere from that down to 10% off, since their',
|
||||||
'client applies the discount itself and not to every item — debits the buyer atomically,',
|
'client applies the discount itself and not to every item — debits the buyer atomically,',
|
||||||
'grants the item (into the inventory or',
|
'grants the item (into the inventory or',
|
||||||
'consumable table), and returns a gift box. A `Gift` block routes the item — and its',
|
'consumable table), and returns a gift box. A `Gift` block routes the item — and its',
|
||||||
@@ -3425,22 +3670,11 @@ const app = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends
|
// Buy an invention. [Authorize]. A GET, despite being a purchase — the 2023 client sends
|
||||||
// `?inventionId=…&requestedPrice=…` with no body, so that's what we answer.
|
// `?inventionId=…&requestedPrice=…` with no body, so that’s what we answer, in the v6 save
|
||||||
//
|
// envelope that build reads. The 2025 build posts to `v3/buyInvention` below and wants a
|
||||||
// A priced invention is settled player-to-player: the buyer is debited its `Price` in
|
// different envelope back; the two share {@link settleInventionPurchase}, which is where
|
||||||
// RecCenterTokens and the CREATOR is credited the same amount — no house cut, so the
|
// the money and the rules live, and build their own bodies from what it returns.
|
||||||
// tokens are moved rather than minted or burned. A free invention (`Price` 0) skips the
|
|
||||||
// money entirely: nothing is debited and nobody is paid. The stored price is confirmed
|
|
||||||
// against the price the client rendered first, so a stale or tampered client can't buy
|
|
||||||
// at a price the creator no longer offers (409), and an unaffordable one is a 400 —
|
|
||||||
// the same "Insufficient balance" buyItem answers with.
|
|
||||||
//
|
|
||||||
// Ownership is recorded in `inventory_invention`; the creator is not sold their own
|
|
||||||
// invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
|
|
||||||
// than a second row. The invention's `NumDownloads` counter is deliberately NOT
|
|
||||||
// bumped: that column lives on the `invention` table the `api` worker owns, and this
|
|
||||||
// worker only reads it.
|
|
||||||
.get(
|
.get(
|
||||||
'/api/storefronts/v2/buyInvention',
|
'/api/storefronts/v2/buyInvention',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -3495,91 +3729,116 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// a priced one then fails the confirmation below rather than selling for nothing.
|
// a priced one then fails the confirmation below rather than selling for nothing.
|
||||||
const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0
|
const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0
|
||||||
|
|
||||||
const invention = await getInventionById(c.env.DB, inventionId)
|
const settled = await settleInventionPurchase(c, id, inventionId, requestedPrice)
|
||||||
if (invention === null) return c.json({ error: 'Invention not found' }, 404)
|
if (settled instanceof Response) return settled
|
||||||
// An unpublished invention is a draft: it isn't on sale, not even for free.
|
|
||||||
if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403)
|
|
||||||
if (invention.CreatorPlayerId === id) {
|
|
||||||
return c.json({ error: 'Cannot buy your own invention' }, 400)
|
|
||||||
}
|
|
||||||
if (await ownsInvention(c.env.DB, id, inventionId)) {
|
|
||||||
return c.json({ error: 'Already owned' }, 409)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The price the client rendered must still be the stored one: a mismatch is a stale
|
|
||||||
// catalog or a tampered request, never a sale.
|
|
||||||
if (invention.Price !== requestedPrice) {
|
|
||||||
return c.json({ error: 'Price has changed' }, 409)
|
|
||||||
}
|
|
||||||
|
|
||||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
|
||||||
// Inventions are priced in RecCenterTokens only — the store shows no other currency
|
|
||||||
// for them, and `Price` carries no currency of its own to pick a different one from.
|
|
||||||
const price = invention.Price
|
|
||||||
if (price > 0) {
|
|
||||||
// Debit the buyer atomically; false means they couldn't afford it and nothing
|
|
||||||
// changed, so no ownership is recorded and the creator is not paid.
|
|
||||||
const paid = await spendCurrency(
|
|
||||||
c.env.DB,
|
|
||||||
id,
|
|
||||||
CurrencyType.RecCenterTokens,
|
|
||||||
price,
|
|
||||||
startingTokens
|
|
||||||
)
|
|
||||||
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grant before paying out: these are three separate D1 writes with no transaction
|
|
||||||
// around them, so order them by what a failure costs. A buyer who paid and got the
|
|
||||||
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
|
|
||||||
// is not.
|
|
||||||
await grantInvention(c.env.DB, id, inventionId)
|
|
||||||
|
|
||||||
if (price > 0) {
|
|
||||||
// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
|
|
||||||
// the balance row, and `ensureStartingBalances` is an INSERT OR IGNORE, so a
|
|
||||||
// creator who had never touched their balance would otherwise have the row created
|
|
||||||
// here and lose their starting tokens forever.
|
|
||||||
await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
|
|
||||||
const creatorBalance = await creditCurrency(
|
|
||||||
c.env.DB,
|
|
||||||
invention.CreatorPlayerId,
|
|
||||||
CurrencyType.RecCenterTokens,
|
|
||||||
price,
|
|
||||||
startingTokens
|
|
||||||
)
|
|
||||||
// The creator is a different, probably-online player with no response to read:
|
|
||||||
// push the sale so it lands on their shown balance without a re-fetch. The frame
|
|
||||||
// carries their resulting TOTAL (what `creditCurrency` returns), not the payout —
|
|
||||||
// sending the payout would set their whole balance to it. A plain update rather
|
|
||||||
// than a purchase frame: they sold, they didn't buy. Best-effort, as everywhere.
|
|
||||||
await pushBalanceUpdate(
|
|
||||||
c,
|
|
||||||
invention.CreatorPlayerId,
|
|
||||||
CurrencyType.RecCenterTokens,
|
|
||||||
creatorBalance
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlike buyItem — whose `Balance` is the change applied — the reference server
|
|
||||||
// answers this one with the RESULTING total (a first read seeds the buyer's starting
|
|
||||||
// grant, as everywhere else). The buyer's frame carries that same total, so the body
|
|
||||||
// and the push land the client on one number.
|
|
||||||
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
|
|
||||||
// A free invention moved nothing, so there is no purchase to report.
|
|
||||||
if (price > 0) {
|
|
||||||
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
|
|
||||||
}
|
|
||||||
return c.json({
|
return c.json({
|
||||||
BalanceUpdateResponse: {
|
BalanceUpdateResponse: {
|
||||||
Balance: balance,
|
Balance: settled.balance,
|
||||||
BalanceType: ALL_PLATFORMS,
|
BalanceType: ALL_PLATFORMS,
|
||||||
CurrencyType: CurrencyType.RecCenterTokens,
|
CurrencyType: CurrencyType.RecCenterTokens,
|
||||||
BalanceUpdates: [{ UpdateResponse: 0, Data: invention }],
|
BalanceUpdates: [{ UpdateResponse: 0, Data: settled.invention }],
|
||||||
|
},
|
||||||
|
// The bare `{ Status, Invention, InventionVersion }` the v6 save serves — this
|
||||||
|
// build's invention endpoints answer in it, and the client re-renders from it.
|
||||||
|
InventionResponse: toSaveResult(settled.invention),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Buy an invention, the way the 2025 client asks for it. [Authorize]. A POST carrying
|
||||||
|
// `{ InventionId, RequestedPrice }` as JSON, where the v2 GET takes query params.
|
||||||
|
//
|
||||||
|
// The PURCHASE is identical — both settle through `settleInventionPurchase` — but the
|
||||||
|
// RESPONSE is not, and that is the whole reason this route exists rather than an alias:
|
||||||
|
// this build wraps the invention in the v9 save envelope and names its balance bucket
|
||||||
|
// `Platform`. See `BuyInventionV3Response`. Both routes stay served: the 2023 build still
|
||||||
|
// sends the GET, and it would not parse this body.
|
||||||
|
.post(
|
||||||
|
'/api/storefronts/v3/buyInvention',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Storefront'],
|
||||||
|
summary: 'Buy an invention (JSON body)',
|
||||||
|
description: [
|
||||||
|
'The same purchase as `GET /api/storefronts/v2/buyInvention` — confirms the client’s',
|
||||||
|
'`RequestedPrice` still matches the invention’s stored `Price`, debits the buyer and',
|
||||||
|
'pays the creator that price in RecCenterTokens (a free invention moves nothing),',
|
||||||
|
'records ownership in `inventory_invention`, and pushes both players a socket frame',
|
||||||
|
'carrying their RESULTING total — but answered in a DIFFERENT envelope, which is why',
|
||||||
|
'the route exists at all: `InventionResponse` is the v9 save’s',
|
||||||
|
'`{ Value, Success, Error, error_id }` (its `InventionVersion` and `TagsResponse` null,',
|
||||||
|
'since a buy mints neither) and the balance half names its bucket `Platform`, not v2’s',
|
||||||
|
'`BalanceType`.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
requestBody: jsonBody(BuyInventionRequest, 'The invention id and the price rendered'),
|
||||||
|
responses: {
|
||||||
|
200: json(BuyInventionV3Response, 'The purchase result (invention + balance)'),
|
||||||
|
400: json(
|
||||||
|
ErrorResponse,
|
||||||
|
'Invalid body, missing InventionId, buying your own, or insufficient balance'
|
||||||
|
),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
403: json(ErrorResponse, 'The invention is not published, so it is not for sale'),
|
||||||
|
404: json(ErrorResponse, 'No such invention'),
|
||||||
|
409: json(ErrorResponse, 'Already owned, or the price has changed'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||||
|
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
||||||
|
return c.json({ error: 'Invalid request body' }, 400)
|
||||||
|
}
|
||||||
|
const inventionId = body.InventionId
|
||||||
|
if (!Number.isInteger(inventionId)) {
|
||||||
|
return c.json({ error: 'InventionId is required' }, 400)
|
||||||
|
}
|
||||||
|
// Read the same way as v2’s query param: an absent or non-integer RequestedPrice is 0,
|
||||||
|
// which only matches a free invention — a priced one then fails the confirmation rather
|
||||||
|
// than selling for nothing.
|
||||||
|
const requestedPrice = Number.isInteger(body.RequestedPrice)
|
||||||
|
? (body.RequestedPrice as number)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const settled = await settleInventionPurchase(c, id, inventionId as number, requestedPrice)
|
||||||
|
if (settled instanceof Response) return settled
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
// The v9 SAVE envelope, not v6's bare `{ Status, Invention, InventionVersion }`:
|
||||||
|
// `Value` under `{ Success, Error, error_id }`, with `Invention` the client's 28-key
|
||||||
|
// `RRInvention`. A buy mints no version and takes no tags, so both of those keys are
|
||||||
|
// present and NULL — which is safe here for the same reason it is on the save: the
|
||||||
|
// client reads `Success` and `Value.Invention` and nothing else. `Value` itself must
|
||||||
|
// never be null under `Success: true` — that dereference is what crashes it.
|
||||||
|
InventionResponse: {
|
||||||
|
Value: {
|
||||||
|
Status: 0,
|
||||||
|
Invention: toInventionV9(settled.invention),
|
||||||
|
InventionVersion: null,
|
||||||
|
TagsResponse: null,
|
||||||
|
},
|
||||||
|
Success: true,
|
||||||
|
Error: null,
|
||||||
|
error_id: null,
|
||||||
|
},
|
||||||
|
// `BalanceResponseDTO`, the same one the bulk purchase answers in — so the bucket key
|
||||||
|
// is `Platform`, NOT the `BalanceType` the v2 body sends. The client's member IS named
|
||||||
|
// `BalanceType`, but it carries a [DataMember] rename to `Platform` and its decoder
|
||||||
|
// drops what it doesn't know, so spelling it `BalanceType` here would land this balance
|
||||||
|
// in bucket 0 beside the real one. `Balance` is the RESULTING total, as in v2.
|
||||||
|
BalanceUpdateResponse: {
|
||||||
|
BalanceUpdates: [{ UpdateResponse: 0, Data: toInventionV9(settled.invention) }],
|
||||||
|
Balance: settled.balance,
|
||||||
|
CurrencyType: CurrencyType.RecCenterTokens,
|
||||||
|
// The capture says 0 (SteamPurchased) because the reference server kept a wallet per
|
||||||
|
// platform. This one keeps ONE bucket and the client SUMS them, so naming 0 here
|
||||||
|
// while every socket frame names -2 is exactly the phantom second balance that
|
||||||
|
// doubled players' tokens twice before. -2, like every other surface.
|
||||||
|
Platform: ALL_PLATFORMS,
|
||||||
},
|
},
|
||||||
// The same `{ Status, Invention, InventionVersion }` envelope the invention
|
|
||||||
// save/read endpoints serve — the client re-renders the invention from it.
|
|
||||||
InventionResponse: toSaveResult(invention),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -3737,6 +3996,11 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// activity of the day is per ACTIVITY, so a player who moves from Soccer to Paintball is
|
// activity of the day is per ACTIVITY, so a player who moves from Soccer to Paintball is
|
||||||
// owed another reward while a second Soccer match inside the hour is not. An ask that
|
// owed another reward while a second Soccer match inside the hour is not. An ask that
|
||||||
// sends no context keys on `''`.
|
// sends no context keys on `''`.
|
||||||
|
//
|
||||||
|
// It also picks the PRIZE: a context that is a key of `static/quest-rewards.json`
|
||||||
|
// (`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity's rewards — an avatar item
|
||||||
|
// granted into the inventory, or Laser Tag's ticket payout — and the box carries it, with
|
||||||
|
// the activity's own `GiftContext`. A context the table doesn't know gets the XP-only box.
|
||||||
.post(
|
.post(
|
||||||
'/api/gamerewards/v1/request',
|
'/api/gamerewards/v1/request',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -3746,8 +4010,10 @@ const app = new Hono<App>({ strict: false })
|
|||||||
'Claims one reward of `rewardType` in `giftContext` per hour per player, recorded in',
|
'Claims one reward of `rewardType` in `giftContext` per hour per player, recorded in',
|
||||||
'`reward_status`. The cooldown is per (type, activity), so a different activity is',
|
'`reward_status`. The cooldown is per (type, activity), so a different activity is',
|
||||||
'owed another reward while the same one is not; an ask with no `giftContext` keys on',
|
'owed another reward while the same one is not; an ask with no `giftContext` keys on',
|
||||||
'the empty context. The reward rides in a gift box, so a claim and a rejected',
|
'the empty context. A `giftContext` that names an activity in `quest-rewards.json`',
|
||||||
'(on-cooldown) ask both answer `[]`.',
|
'(`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity’s rewards and grants it;',
|
||||||
|
'any other claim pays XP only. The reward rides in a gift box, so a claim and a',
|
||||||
|
'rejected (on-cooldown) ask both answer `[]`.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(GameRewardRequest, 'The reward type and its display message'),
|
requestBody: form(GameRewardRequest, 'The reward type and its display message'),
|
||||||
@@ -3774,7 +4040,30 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// Bank the XP first: it is the reward, and the box is the wrapper the client shows.
|
// Bank the XP first: it is the reward, and the box is the wrapper the client shows.
|
||||||
// A failure here must not leave a box promising XP that was never credited.
|
// A failure here must not leave a box promising XP that was never credited.
|
||||||
const { progression, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP)
|
const { progression, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP)
|
||||||
const granted = await grantGiftDrop(c, id, toGameRewardDrop(), message)
|
// An activity the reward table knows pays one of ITS rewards the player lacks — the
|
||||||
|
// item rides in the box and is granted with it. Anything else gets the plain XP box.
|
||||||
|
const questReward = await pickQuestReward(c.env.DB, id, giftContext)
|
||||||
|
const itemKey = questReward?.AvatarItemDesc || questReward?.EquipmentModificationGuid
|
||||||
|
const drop =
|
||||||
|
questReward === null
|
||||||
|
? toGameRewardDrop()
|
||||||
|
: toQuestRewardDrop(questReward, itemKey ? await getCatalogItem(c.env.DB, itemKey) : null)
|
||||||
|
// A currency reward (Laser Tag's tickets) is credited here: `grantGiftDrop` grants
|
||||||
|
// items, not balances. Seed the signup grant first — `creditCurrency` upserts the
|
||||||
|
// row, and a never-touched RecCenterTokens balance would otherwise lose it.
|
||||||
|
if (drop.Currency > 0 && drop.CurrencyType !== CurrencyType.Invalid) {
|
||||||
|
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||||
|
await ensureStartingBalances(c.env.DB, id, startingTokens)
|
||||||
|
const balance = await creditCurrency(
|
||||||
|
c.env.DB,
|
||||||
|
id,
|
||||||
|
drop.CurrencyType,
|
||||||
|
drop.Currency,
|
||||||
|
startingTokens
|
||||||
|
)
|
||||||
|
await pushBalanceUpdate(c, id, drop.CurrencyType, balance)
|
||||||
|
}
|
||||||
|
const granted = await grantGiftDrop(c, id, drop, message)
|
||||||
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
|
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
|
||||||
// Every grant moves the bar, whether or not it crossed a level.
|
// Every grant moves the bar, whether or not it crossed a level.
|
||||||
await pushProgressionUpdate(c, id, progression)
|
await pushProgressionUpdate(c, id, progression)
|
||||||
@@ -3834,26 +4123,29 @@ const app = new Hono<App>({ strict: false })
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Subscription lookup (Rec Room Plus, the client's `CampusCard`). There is no store to
|
// Subscription lookup (Rec Room Plus, the client's `CampusCard`). There is no store to
|
||||||
// buy one from, so the `developer` role stands in for a paid subscription: a developer
|
// buy one from: Plus is claimed on the website by proving a Discord role, and reaches
|
||||||
// reports an active Gold year, everyone else reports none. Nothing is stored — see
|
// this worker as the token's `rn.plus` claim. A caller carrying it reports an active
|
||||||
// `developerSubscription`.
|
// Gold year; everyone else reports none. Nothing about the subscription itself is
|
||||||
|
// stored, and nothing here reads the database — see `isSubscriber` and `plusSubscription`.
|
||||||
//
|
//
|
||||||
// Auth is OPTIONAL, and a missing or invalid token answers "no subscription" rather than
|
// Auth is OPTIONAL, and a missing or invalid token answers "no subscription" rather than
|
||||||
// 401: the client posts this while loading, so an error here can stall its load
|
// 401: the client posts this while loading, so an error here can stall its load
|
||||||
// orchestration, and "you aren't subscribed" is the truthful answer for an anonymous
|
// orchestration, and "you aren't subscribed" is the truthful answer for an anonymous
|
||||||
// caller anyway. The role is read from the token's `role` claim, never from the body.
|
// caller anyway. Never read from the body.
|
||||||
.post(
|
.post(
|
||||||
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Econ'],
|
tags: ['Econ'],
|
||||||
summary: 'Subscription lookup',
|
summary: 'Subscription lookup',
|
||||||
description: [
|
description: [
|
||||||
'The caller’s Rec Room Plus subscription. Nothing sells subscriptions here, so the',
|
'The caller’s Rec Room Plus subscription. Nothing sells subscriptions here: Plus is',
|
||||||
'operator-granted `developer` role stands in for one: a developer’s token reports an',
|
'claimed on the website by proving a qualifying role in the community Discord, and',
|
||||||
'active Gold (`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All),',
|
'arrives as the token’s `rn.plus` claim. A token carrying it reports an active Gold',
|
||||||
'expiring a year from the call, and every other caller gets `{}`. Auth is optional —',
|
'(`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All), expiring a',
|
||||||
'a missing or invalid token reads as “not subscribed”, not 401. Nothing is persisted:',
|
'year from the call; every other caller gets `{}`. The `developer` role does NOT',
|
||||||
'the role IS the subscription, so revoking it revokes this.',
|
'confer it. Auth is optional — a missing or invalid token reads as “not subscribed”,',
|
||||||
|
'not 401. The subscription itself is not persisted, and because the claim is stamped',
|
||||||
|
'at login, a player who has just claimed must sign in again before it appears.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(SubscriptionResponse, 'The subscription, or `{}` for no subscription'),
|
200: json(SubscriptionResponse, 'The subscription, or `{}` for no subscription'),
|
||||||
@@ -3864,7 +4156,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return c.json({})
|
if (id === null) return c.json({})
|
||||||
return c.json({
|
return c.json({
|
||||||
Subscription: developerSubscription(id),
|
Subscription: plusSubscription(id),
|
||||||
PlatformAccountSubscribedPlayerId: null,
|
PlatformAccountSubscribedPlayerId: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,8 +116,9 @@ export const CustomAvatarItemsResponse = z.object({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A Rec Room Plus subscription (the client calls it a `CampusCard`). Nothing here sells one,
|
* A Rec Room Plus subscription (the client calls it a `CampusCard`). Nothing here sells one,
|
||||||
* so this is the complimentary subscription a `developer` account reports — see
|
* so this is the complimentary subscription reported by a caller whose token carries
|
||||||
* `developerSubscription` in econ.app.ts for why each field reads the way it does.
|
* `rn.plus` — stamped from `account.hasPlus`, which the website's Discord benefits claim
|
||||||
|
* sets. See `plusSubscription` in econ.app.ts for why each field reads the way it does.
|
||||||
*/
|
*/
|
||||||
export const SubscriptionDto = z.object({
|
export const SubscriptionDto = z.object({
|
||||||
SubscriptionId: z.int().describe('Placeholder — no subscription is stored'),
|
SubscriptionId: z.int().describe('Placeholder — no subscription is stored'),
|
||||||
@@ -374,10 +375,24 @@ export const BulkPurchaseResponse = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET /api/storefronts/v2/buyInvention` — the purchase result. Two envelopes side by
|
* The JSON body `POST /api/storefronts/v3/buyInvention` takes. The same two values the v2
|
||||||
* side: the balance update (shaped like buyItem's, except `Balance` is the RESULTING
|
* GET reads off the query string (`inventionId`/`requestedPrice`), PascalCase in a body —
|
||||||
* total, not the change, and `Data` is a single invention rather than a gift-drop list)
|
* that is the only difference between the two routes.
|
||||||
* and the invention envelope the invention endpoints already serve.
|
*/
|
||||||
|
export const BuyInventionRequest = z.object({
|
||||||
|
InventionId: z.int().describe('The invention to buy; missing or non-integer is 400'),
|
||||||
|
RequestedPrice: z
|
||||||
|
.int()
|
||||||
|
.optional()
|
||||||
|
.describe('The price the client rendered; a mismatch is 409. Absent reads as 0'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/storefronts/v2/buyInvention` and `POST /api/storefronts/v3/buyInvention` — the
|
||||||
|
* purchase result, identical for both. Two envelopes side by side: the balance update
|
||||||
|
* (shaped like buyItem's, except `Balance` is the RESULTING total, not the change, and
|
||||||
|
* `Data` is a single invention rather than a gift-drop list) and the invention envelope the
|
||||||
|
* invention endpoints already serve.
|
||||||
*/
|
*/
|
||||||
export const BuyInventionResponse = z.object({
|
export const BuyInventionResponse = z.object({
|
||||||
BalanceUpdateResponse: z.object({
|
BalanceUpdateResponse: z.object({
|
||||||
@@ -400,6 +415,54 @@ export const BuyInventionResponse = z.object({
|
|||||||
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
|
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /api/storefronts/v3/buyInvention` — the purchase result the 2025 client wants,
|
||||||
|
* which is NOT v2's despite settling the identical purchase. Two differences, both
|
||||||
|
* recovered from a capture of the real response:
|
||||||
|
*
|
||||||
|
* - `InventionResponse` is the v9 SAVE envelope (`{ Value, Success, Error, error_id }`)
|
||||||
|
* rather than v6's bare `{ Status, Invention, InventionVersion }`, and its `Invention`
|
||||||
|
* is the client's 28-key `RRInvention`. A buy mints no version and takes no tags, so
|
||||||
|
* `InventionVersion` and `TagsResponse` are present and null.
|
||||||
|
* - The balance half is `BalanceResponseDTO`, so the bucket key is `Platform` — the
|
||||||
|
* client's `BalanceType` member under a [DataMember] rename, the same one the bulk
|
||||||
|
* purchase answers in. v2 spells it `BalanceType`; do not unify them.
|
||||||
|
*/
|
||||||
|
export const BuyInventionV3Response = z.object({
|
||||||
|
InventionResponse: z
|
||||||
|
.object({
|
||||||
|
Value: z
|
||||||
|
.object({
|
||||||
|
Status: z.int().describe('0 on success'),
|
||||||
|
Invention: JsonObject.describe('The bought invention as the 28-key `RRInvention`'),
|
||||||
|
InventionVersion: z.null().describe('Always null — a buy mints no version'),
|
||||||
|
TagsResponse: z.null().describe('Always null — a buy takes no tags'),
|
||||||
|
})
|
||||||
|
.describe('Never null under `Success: true` — the client dereferences it unguarded'),
|
||||||
|
Success: z.boolean(),
|
||||||
|
Error: z.string().nullable().describe('Null on success — not `""`'),
|
||||||
|
error_id: z.string().nullable().describe('Always null — no error-id catalog here'),
|
||||||
|
})
|
||||||
|
.describe('The same envelope `POST /api/inventions/v9/save` returns'),
|
||||||
|
BalanceUpdateResponse: z.object({
|
||||||
|
BalanceUpdates: z.array(
|
||||||
|
z.object({
|
||||||
|
UpdateResponse: z.int(),
|
||||||
|
Data: JsonObject.describe('The bought invention, the same `RRInvention` as above'),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
Balance: z.int().describe('The resulting balance — NOT the change, unlike buyItem'),
|
||||||
|
CurrencyType: z.int().describe('2 = RecCenterTokens'),
|
||||||
|
Platform: z
|
||||||
|
.int()
|
||||||
|
.describe(
|
||||||
|
'The balance bucket — the client’s `BalanceType` under a [DataMember] rename. -2, ' +
|
||||||
|
'account-wide: the capture said 0 (SteamPurchased) because the reference server ' +
|
||||||
|
'kept a wallet per platform; this one keeps a single bucket, and the client SUMS them'
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
|
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
|
||||||
/** The JSON body `POST /api/ugcPurchasables/v1/items/bulk` takes. */
|
/** The JSON body `POST /api/ugcPurchasables/v1/items/bulk` takes. */
|
||||||
export const UgcPurchasableBulkRequest = z.object({
|
export const UgcPurchasableBulkRequest = z.object({
|
||||||
@@ -592,7 +655,9 @@ export const GameRewardRequest = z.object({
|
|||||||
giftContext: z
|
giftContext: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('The activity it came from, e.g. `Soccer` — part of the cooldown key'),
|
.describe(
|
||||||
|
'The activity it came from, e.g. `Soccer` — part of the cooldown key. A key of `quest-rewards.json` (`Dodgeball`, `Quest_Goblin_S`, …) also picks the prize from that activity’s table'
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import avatarItemsJson from '../../../static/db/avatar-items.json'
|
|||||||
// caller's build, and these assertions are about the file's CONTENTS.
|
// caller's build, and these assertions are about the file's CONTENTS.
|
||||||
import carriedItems from '../../../static/db/consumables.json'
|
import carriedItems from '../../../static/db/consumables.json'
|
||||||
import skinsJson from '../../../static/db/skins.json'
|
import skinsJson from '../../../static/db/skins.json'
|
||||||
|
import questRewards from '../../../static/quest-rewards.json'
|
||||||
import sf32025 from '../../../static/storefronts/sf3-2025.json'
|
import sf32025 from '../../../static/storefronts/sf3-2025.json'
|
||||||
import sf3 from '../../../static/storefronts/sf3.json'
|
import sf3 from '../../../static/storefronts/sf3.json'
|
||||||
import { SCHEMA_DDL } from '../../avatar-db'
|
import { SCHEMA_DDL } from '../../avatar-db'
|
||||||
@@ -284,12 +285,21 @@ async function bearer(
|
|||||||
sub = '42',
|
sub = '42',
|
||||||
roles?: string[],
|
roles?: string[],
|
||||||
/** The client build to stamp as `rn.ver` — omitted, like a token minted before the claim. */
|
/** The client build to stamp as `rn.ver` — omitted, like a token minted before the claim. */
|
||||||
version?: string
|
version?: string,
|
||||||
|
/**
|
||||||
|
* Stamp `rn.plus`, as auth does for an account with `hasPlus`. This is the ONLY thing
|
||||||
|
* that makes a caller a Rec Room Plus subscriber — the `developer` role does not — so
|
||||||
|
* every subscriber-priced test passes it.
|
||||||
|
*/
|
||||||
|
plus = false
|
||||||
): Promise<Record<string, string>> {
|
): Promise<Record<string, string>> {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
const claims: Record<string, unknown> = { sub, exp: now + 3600 }
|
const claims: Record<string, unknown> = { sub, exp: now + 3600 }
|
||||||
if (roles !== undefined) claims.role = roles
|
if (roles !== undefined) claims.role = roles
|
||||||
if (version !== undefined) claims['rn.ver'] = version
|
if (version !== undefined) claims['rn.ver'] = version
|
||||||
|
// Omitted when false, exactly as generateToken omits it — so these tokens match the
|
||||||
|
// shape of a real non-subscriber's.
|
||||||
|
if (plus) claims['rn.plus'] = true
|
||||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||||
JSON.stringify(claims)
|
JSON.stringify(claims)
|
||||||
)}`
|
)}`
|
||||||
@@ -1769,7 +1779,7 @@ describe('econ endpoints', () => {
|
|||||||
|
|
||||||
test('POST /api/storefronts/v2/buyItem charges a subscriber the SubscriberPrices entry', async () => {
|
test('POST /api/storefronts/v2/buyItem charges a subscriber the SubscriberPrices entry', async () => {
|
||||||
await drainFrames()
|
await drainFrames()
|
||||||
const res = await buy2263(await bearer('322', ['gameClient', 'developer']), 85)
|
const res = await buy2263(await bearer('322', ['gameClient'], undefined, true), 85)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
|
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
|
||||||
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||||
@@ -1784,7 +1794,7 @@ describe('econ endpoints', () => {
|
|||||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
...(await bearer('325', ['gameClient', 'developer'])),
|
...(await bearer('325', ['gameClient'], undefined, true)),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -1806,7 +1816,7 @@ describe('econ endpoints', () => {
|
|||||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
...(await bearer('326', ['gameClient', 'developer'])),
|
...(await bearer('326', ['gameClient'], undefined, true)),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -1827,10 +1837,10 @@ describe('econ endpoints', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/storefronts/v2/buyItem 409s a subscriber below the discount band', async () => {
|
test('POST /api/storefronts/v2/buyItem 409s a subscriber below the discount band', async () => {
|
||||||
const res = await buy2263(await bearer('323', ['gameClient', 'developer']), 84)
|
const res = await buy2263(await bearer('323', ['gameClient'], undefined, true), 84)
|
||||||
expect(res.status).toBe(409)
|
expect(res.status).toBe(409)
|
||||||
// …and above it: a made-up price is a mismatch in either direction.
|
// …and above it: a made-up price is a mismatch in either direction.
|
||||||
const over = await buy2263(await bearer('323', ['gameClient', 'developer']), 96)
|
const over = await buy2263(await bearer('323', ['gameClient'], undefined, true), 96)
|
||||||
expect(over.status).toBe(409)
|
expect(over.status).toBe(409)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2646,6 +2656,168 @@ describe('econ endpoints', () => {
|
|||||||
expect(await getOwnedInventionIds(env.DB, 60)).toEqual([])
|
expect(await getOwnedInventionIds(env.DB, 60)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The 2025 client posts the same purchase as a JSON body — and wants a DIFFERENT response
|
||||||
|
// back: the v9 save envelope, and a balance bucket keyed `Platform`. The settlement is
|
||||||
|
// shared with v2, so these pin the envelope and the money moving, not the rules v2 covers.
|
||||||
|
const buyInventionV3 = async (sub: string, body: unknown) =>
|
||||||
|
exports.default.fetch(`${ORIGIN}/api/storefronts/v3/buyInvention`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/storefronts/v3/buyInvention 401s without a token', async () => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/buyInvention`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ InventionId: 8, RequestedPrice: 0 }),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/storefronts/v3/buyInvention answers the v9 envelope, not v2’s', async () => {
|
||||||
|
const res = await buyInventionV3('55', { InventionId: 8, RequestedPrice: 0 })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
InventionResponse: {
|
||||||
|
Value: {
|
||||||
|
Status: number
|
||||||
|
Invention: Record<string, unknown>
|
||||||
|
InventionVersion: unknown
|
||||||
|
TagsResponse: unknown
|
||||||
|
} | null
|
||||||
|
Success: boolean
|
||||||
|
Error: string | null
|
||||||
|
error_id: string | null
|
||||||
|
}
|
||||||
|
BalanceUpdateResponse: {
|
||||||
|
Balance: number
|
||||||
|
CurrencyType: number
|
||||||
|
Platform: number
|
||||||
|
BalanceType?: number
|
||||||
|
BalanceUpdates: Array<{ UpdateResponse: number; Data: { InventionId: number } }>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The v9 SAVE envelope: `Value` under `{ Success, Error, error_id }`. `Value` is never
|
||||||
|
// null under `Success: true` — the client dereferences `Value.Invention` unguarded.
|
||||||
|
expect(body.InventionResponse).toMatchObject({ Success: true, Error: null, error_id: null })
|
||||||
|
expect(body.InventionResponse.Value?.Status).toBe(0)
|
||||||
|
expect(body.InventionResponse.Value?.Invention.InventionId).toBe(8)
|
||||||
|
expect(body.InventionResponse.Value?.Invention.Name).toBe('Invention 8')
|
||||||
|
// The 28-key `RRInvention`, not the stored record: the version rides nowhere here, and
|
||||||
|
// `IsPublished` is a stored field this projection drops.
|
||||||
|
expect(body.InventionResponse.Value?.Invention.CurrentVersion).toBeUndefined()
|
||||||
|
expect(body.InventionResponse.Value?.Invention.IsPublished).toBeUndefined()
|
||||||
|
expect(body.InventionResponse.Value?.Invention.LatestVersionNumber).toBe(1)
|
||||||
|
// A buy mints no version and takes no tags — present and null, not absent.
|
||||||
|
expect(body.InventionResponse.Value).toHaveProperty('InventionVersion', null)
|
||||||
|
expect(body.InventionResponse.Value).toHaveProperty('TagsResponse', null)
|
||||||
|
|
||||||
|
// `BalanceResponseDTO`: the bucket key is `Platform`. Spelling it `BalanceType` (which is
|
||||||
|
// what v2 sends) would be dropped by the client's decoder and default this balance into
|
||||||
|
// bucket 0, beside the -2 the socket frames set — a phantom second balance.
|
||||||
|
expect(body.BalanceUpdateResponse.Platform).toBe(-2)
|
||||||
|
expect(body.BalanceUpdateResponse.BalanceType).toBeUndefined()
|
||||||
|
// Nothing was debited, so `Balance` is the resulting total, not a change.
|
||||||
|
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS)
|
||||||
|
expect(body.BalanceUpdateResponse.CurrencyType).toBe(CurrencyType.RecCenterTokens)
|
||||||
|
expect(body.BalanceUpdateResponse.BalanceUpdates[0].Data.InventionId).toBe(8)
|
||||||
|
|
||||||
|
expect(await getOwnedInventionIds(env.DB, 55)).toEqual([8])
|
||||||
|
// Owning it is boolean here too — the route shares v2's settlement.
|
||||||
|
expect((await buyInventionV3('55', { InventionId: 8, RequestedPrice: 0 })).status).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET v2 and POST v3 buyInvention answer the SAME buy in different envelopes', async () => {
|
||||||
|
// The one thing that must not drift: two builds buying the same invention get the same
|
||||||
|
// invention back, shaped for each. v2 serves the stored record under a bare status
|
||||||
|
// envelope; v3 serves the 28-key projection under the v9 one. Don't unify them.
|
||||||
|
const v2 = (await (await buyInvention('58', 8)).json()) as {
|
||||||
|
InventionResponse: { Status: number; Invention: Record<string, unknown> }
|
||||||
|
BalanceUpdateResponse: { BalanceType: number; Platform?: number }
|
||||||
|
}
|
||||||
|
const v3 = (await (
|
||||||
|
await buyInventionV3('59', { InventionId: 8, RequestedPrice: 0 })
|
||||||
|
).json()) as {
|
||||||
|
InventionResponse: { Value: { Invention: Record<string, unknown> } | null }
|
||||||
|
BalanceUpdateResponse: { Platform: number; BalanceType?: number }
|
||||||
|
}
|
||||||
|
expect(v2.InventionResponse.Invention.InventionId).toBe(8)
|
||||||
|
expect(v3.InventionResponse.Value?.Invention.InventionId).toBe(8)
|
||||||
|
// v2 keeps the nested version; v3's projection lifts it away entirely.
|
||||||
|
expect(v2.InventionResponse.Invention.CurrentVersion).toBeDefined()
|
||||||
|
expect(v3.InventionResponse.Value?.Invention.CurrentVersion).toBeUndefined()
|
||||||
|
// The bucket is spelled differently on each, and each spells exactly one.
|
||||||
|
expect(v2.BalanceUpdateResponse).toMatchObject({ BalanceType: -2 })
|
||||||
|
expect(v2.BalanceUpdateResponse.Platform).toBeUndefined()
|
||||||
|
expect(v3.BalanceUpdateResponse).toMatchObject({ Platform: -2 })
|
||||||
|
expect(v3.BalanceUpdateResponse.BalanceType).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/storefronts/v3/buyInvention pays the creator and pushes both sides', async () => {
|
||||||
|
await drainFrames()
|
||||||
|
// Creator 999 has already been paid by the v2 tests above, so their resulting total is
|
||||||
|
// read rather than assumed — it is the payout ADDED to whatever they had.
|
||||||
|
const creatorBefore = await getBalance(
|
||||||
|
env.DB,
|
||||||
|
999,
|
||||||
|
CurrencyType.RecCenterTokens,
|
||||||
|
DEFAULT_STARTING_TOKENS
|
||||||
|
)
|
||||||
|
const res = await buyInventionV3('56', { InventionId: 9, RequestedPrice: 250 })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { BalanceUpdateResponse: { Balance: number } }
|
||||||
|
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS - 250)
|
||||||
|
expect(
|
||||||
|
await getBalance(env.DB, 56, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||||
|
).toBe(DEFAULT_STARTING_TOKENS - 250)
|
||||||
|
expect(
|
||||||
|
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||||
|
).toBe(creatorBefore + 250)
|
||||||
|
expect(await getOwnedInventionIds(env.DB, 56)).toEqual([9])
|
||||||
|
|
||||||
|
// Same two frames as the v2 buy, each carrying its player's RESULTING total into the -2
|
||||||
|
// bucket: the creator sold (a plain update), the buyer bought (a purchase frame).
|
||||||
|
expect(await drainFrames()).toEqual([
|
||||||
|
{
|
||||||
|
accountId: 999,
|
||||||
|
notificationType: NotificationType.StorefrontBalanceUpdate,
|
||||||
|
payload: {
|
||||||
|
Balance: creatorBefore + 250,
|
||||||
|
CurrencyType: CurrencyType.RecCenterTokens,
|
||||||
|
Platform: -2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: 56,
|
||||||
|
notificationType: NotificationType.StorefrontBalancePurchase,
|
||||||
|
payload: {
|
||||||
|
BalanceAddType: 1400,
|
||||||
|
Delta: -250,
|
||||||
|
Balance: DEFAULT_STARTING_TOKENS - 250,
|
||||||
|
Platform: -2,
|
||||||
|
CurrencyType: CurrencyType.RecCenterTokens,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/storefronts/v3/buyInvention rejects a stale price and a bad body', async () => {
|
||||||
|
// A body is the only difference from v2, so the price check reads it the same way: an
|
||||||
|
// absent RequestedPrice is 0, which does not match the 250-token invention 9.
|
||||||
|
expect((await buyInventionV3('57', { InventionId: 9 })).status).toBe(409)
|
||||||
|
expect((await buyInventionV3('57', { InventionId: 9, RequestedPrice: 0 })).status).toBe(409)
|
||||||
|
// No InventionId, and no body at all.
|
||||||
|
expect((await buyInventionV3('57', { RequestedPrice: 0 })).status).toBe(400)
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/buyInvention`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer('57')), 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(await getOwnedInventionIds(env.DB, 57)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
|
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
|
||||||
// Buy an item for account 24, then consume the box the way the client does: on the
|
// Buy an item for account 24, then consume the box the way the client does: on the
|
||||||
// econ host, with a form body (`Id=..&UnlockedLevel=..`).
|
// econ host, with a form body (`Id=..&UnlockedLevel=..`).
|
||||||
@@ -3113,6 +3285,7 @@ describe('econ endpoints', () => {
|
|||||||
AvatarItemDesc: string
|
AvatarItemDesc: string
|
||||||
ConsumableItemDesc: string
|
ConsumableItemDesc: string
|
||||||
GiftRarity: number
|
GiftRarity: number
|
||||||
|
GiftContext: number
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3512,6 +3685,94 @@ describe('econ endpoints', () => {
|
|||||||
expect(held.map((cons) => cons.ConsumableItemDesc)).toContain(consumableBox?.ConsumableItemDesc)
|
expect(held.map((cons) => cons.ConsumableItemDesc)).toContain(consumableBox?.ConsumableItemDesc)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('a giftContext naming a quest-rewards.json key pays one of that activity’s rewards', async () => {
|
||||||
|
const request = async (body: string) =>
|
||||||
|
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(await bearer('83')),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
await drainFrames()
|
||||||
|
|
||||||
|
// Quest_Goblin_S: forty avatar-item rewards, all at the goblin quest's S-rank context.
|
||||||
|
const goblin = await request(
|
||||||
|
'rewardType=PostGameActivity&Message=Quest%20complete&giftContext=Quest_Goblin_S'
|
||||||
|
)
|
||||||
|
expect(goblin.status).toBe(200)
|
||||||
|
expect(await goblin.json()).toEqual([])
|
||||||
|
const boxes = await giftBoxes('83')
|
||||||
|
expect(boxes).toHaveLength(1)
|
||||||
|
const box = boxes[0]
|
||||||
|
expect(box).toMatchObject({ Xp: 5, Message: 'Quest complete', GiftContext: 4003 })
|
||||||
|
expect(box?.AvatarItemDesc).not.toBe('')
|
||||||
|
const row = questRewards.Quest_Goblin_S.find((r) => r.AvatarItemDesc === box?.AvatarItemDesc)
|
||||||
|
expect(row).toBeDefined()
|
||||||
|
expect(box?.GiftRarity).toBe(row?.GiftRarity)
|
||||||
|
// …and the item is in the inventory, not just on the box.
|
||||||
|
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||||
|
headers: await bearer('83'),
|
||||||
|
})
|
||||||
|
const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
|
||||||
|
expect(owned.map((i) => i.avatarItemDesc)).toContain(box?.AvatarItemDesc)
|
||||||
|
// The box announces the activity's context, not the generic GameRewards one.
|
||||||
|
const frames = await drainFrames()
|
||||||
|
expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate)
|
||||||
|
expect(frames[0]?.payload).toMatchObject({
|
||||||
|
GiftContext: 4003,
|
||||||
|
AvatarItemDesc: box?.AvatarItemDesc,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Lasertag's single reward is 50 Laser Tag tickets: credited to the balance, no item.
|
||||||
|
const before = await getBalance(
|
||||||
|
env.DB,
|
||||||
|
83,
|
||||||
|
CurrencyType.LaserTagTickets,
|
||||||
|
DEFAULT_STARTING_TOKENS
|
||||||
|
)
|
||||||
|
expect((await request('rewardType=PostGameActivity&giftContext=Lasertag')).status).toBe(200)
|
||||||
|
expect(
|
||||||
|
await getBalance(env.DB, 83, CurrencyType.LaserTagTickets, DEFAULT_STARTING_TOKENS)
|
||||||
|
).toBe(before + 50)
|
||||||
|
const ticketBox = (await giftBoxes('83'))[1]
|
||||||
|
expect(ticketBox).toMatchObject({
|
||||||
|
Currency: 50,
|
||||||
|
CurrencyType: CurrencyType.LaserTagTickets,
|
||||||
|
AvatarItemDesc: '',
|
||||||
|
GiftContext: 9000,
|
||||||
|
})
|
||||||
|
const ticketFrames = await drainFrames()
|
||||||
|
expect(ticketFrames.map((f) => f.notificationType)).toContain(
|
||||||
|
NotificationType.StorefrontBalanceUpdate
|
||||||
|
)
|
||||||
|
|
||||||
|
// An activity the table doesn't know pays the plain XP box, as before. (The LAST box:
|
||||||
|
// the two claims above also crossed level 1, and that level-up box sits in between.)
|
||||||
|
expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200)
|
||||||
|
const plain = (await giftBoxes('83')).at(-1)
|
||||||
|
expect(plain).toMatchObject({ Xp: 5, AvatarItemDesc: '', Currency: 0, GiftContext: 50 })
|
||||||
|
|
||||||
|
// A reward the player already owns is never drawn again: Dodgeball has three rows, so
|
||||||
|
// three claims hand over all three, and a fourth — nothing left to give — pays the
|
||||||
|
// plain XP box rather than a duplicate.
|
||||||
|
const dodgeball = questRewards.Dodgeball.map((r) => r.AvatarItemDesc)
|
||||||
|
const handed: string[] = []
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
await env.DB.prepare(
|
||||||
|
"DELETE FROM reward_status WHERE account_id = 83 AND gift_context = 'Dodgeball'"
|
||||||
|
).run()
|
||||||
|
expect((await request('rewardType=PostGameActivity&giftContext=Dodgeball')).status).toBe(200)
|
||||||
|
const latest = (await giftBoxes('83')).findLast(
|
||||||
|
(b) => b.GiftContext === 8000 || b.GiftContext === 50
|
||||||
|
)
|
||||||
|
if (i < 3) handed.push(latest?.AvatarItemDesc as string)
|
||||||
|
else expect(latest).toMatchObject({ AvatarItemDesc: '', GiftContext: 50 })
|
||||||
|
}
|
||||||
|
expect(handed.toSorted()).toEqual(dodgeball.toSorted())
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => {
|
test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => {
|
||||||
const anon = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
const anon = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -3657,7 +3918,7 @@ describe('econ endpoints', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/CampusCard/v1/UpdateAndGetSubscription gives a developer a Gold year', async () => {
|
test('POST /api/CampusCard/v1/UpdateAndGetSubscription gives a developer a Gold year', async () => {
|
||||||
const res = await getSubscription(await bearer('205', ['gameClient', 'developer']))
|
const res = await getSubscription(await bearer('205', ['gameClient'], undefined, true))
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
Subscription: Record<string, unknown>
|
Subscription: Record<string, unknown>
|
||||||
@@ -3689,7 +3950,7 @@ describe('econ endpoints', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/CampusCard/v1/UpdateAndGetSubscription is {} without the developer role', async () => {
|
test('POST /api/CampusCard/v1/UpdateAndGetSubscription is {} without the developer role', async () => {
|
||||||
// A plain player's token: valid, but no elevated role.
|
// A plain player's token: valid, no elevated role, and no `hasPlus` on the account.
|
||||||
expect(await (await getSubscription(await bearer('206', ['gameClient']))).json()).toEqual({})
|
expect(await (await getSubscription(await bearer('206', ['gameClient']))).json()).toEqual({})
|
||||||
// A token with no `role` claim at all.
|
// A token with no `role` claim at all.
|
||||||
expect(await (await getSubscription(await bearer('206'))).json()).toEqual({})
|
expect(await (await getSubscription(await bearer('206'))).json()).toEqual({})
|
||||||
@@ -3699,6 +3960,56 @@ describe('econ endpoints', () => {
|
|||||||
expect(await anon.json()).toEqual({})
|
expect(await anon.json()).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Plus reaches this worker as the token's `rn.plus` claim, which `auth` stamps from
|
||||||
|
// `account.hasPlus` at login. Nothing here reads the account, so this is the whole
|
||||||
|
// mechanism — and the reason a player who claims on the website has to sign in again.
|
||||||
|
//
|
||||||
|
// The token carries only `gameClient`, exactly as a game client's does.
|
||||||
|
test('POST /api/CampusCard/v1/UpdateAndGetSubscription honours the rn.plus claim', async () => {
|
||||||
|
const res = await getSubscription(await bearer('9208', ['gameClient'], undefined, true))
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { Subscription: Record<string, unknown> }
|
||||||
|
expect(body.Subscription).toMatchObject({
|
||||||
|
SubscriptionId: 1,
|
||||||
|
RecNetPlayerId: 9208,
|
||||||
|
PlatformType: -1,
|
||||||
|
Level: 0,
|
||||||
|
Period: 1,
|
||||||
|
IsAutoRenewing: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// The `developer` role used to BE the subscription, as a stand-in while nothing else
|
||||||
|
// could confer one. Now that Plus has a real source it is one thing with one source, and
|
||||||
|
// an elevated account is not a subscriber unless it also holds `rn.plus`. Pinned because
|
||||||
|
// nothing else would fail if the old shortcut came back: it would silently hand Plus (and
|
||||||
|
// the 10% discount) to every operator account.
|
||||||
|
test('the developer role alone is not a Rec Room Plus subscription', async () => {
|
||||||
|
const dev = await getSubscription(await bearer('9210', ['gameClient', 'developer']))
|
||||||
|
expect(dev.status).toBe(200)
|
||||||
|
expect(await dev.json()).toEqual({})
|
||||||
|
|
||||||
|
// …and it buys nothing at the subscriber price either, so the report and the buy path
|
||||||
|
// agree. 85 is the SubscriberPrices entry for sf300's 2263; 95 is the list price.
|
||||||
|
const discounted = await buy2263(await bearer('9211', ['gameClient', 'developer']), 85)
|
||||||
|
expect(discounted.status).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Plus is priced, not just displayed: the same claim gates the subscriber discount band
|
||||||
|
// on a buy. A subscriber whose client applied the discount itself and then had the
|
||||||
|
// purchase refused as a price mismatch is exactly what one definition prevents, so the
|
||||||
|
// CampusCard report and the buy must never disagree.
|
||||||
|
test('an rn.plus token is charged the subscriber price', async () => {
|
||||||
|
const res = await buy2263(await bearer('9326', ['gameClient'], undefined, true), 85)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
|
||||||
|
|
||||||
|
// The same request without the claim is refused, so the discount really comes from
|
||||||
|
// `rn.plus` and not from the band being open to everyone.
|
||||||
|
const plain = await buy2263(await bearer('9327', ['gameClient']), 85)
|
||||||
|
expect(plain.status).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
test('unknown path returns 404', async () => {
|
test('unknown path returns 404', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
@@ -3786,6 +4097,7 @@ describe('econ endpoints', () => {
|
|||||||
'POST /api/objectives/v1/cleargroup',
|
'POST /api/objectives/v1/cleargroup',
|
||||||
'POST /api/objectives/v1/updateobjective',
|
'POST /api/objectives/v1/updateobjective',
|
||||||
'POST /api/storefronts/v2/buyItem',
|
'POST /api/storefronts/v2/buyItem',
|
||||||
|
'POST /api/storefronts/v3/buyInvention',
|
||||||
'POST /api/ugcPurchasables/v1/items/bulk',
|
'POST /api/ugcPurchasables/v1/items/bulk',
|
||||||
'PUT /api/equipment/v1/update',
|
'PUT /api/equipment/v1/update',
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -32,6 +32,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
@@ -21,7 +21,7 @@
|
|||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -33,6 +33,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"workers-tagged-logger": "1.0.1"
|
"workers-tagged-logger": "1.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -25,6 +25,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@repo/domain": "workspace:*",
|
"@repo/domain": "workspace:*",
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -31,6 +31,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Server statistics sampled over time — one row per sample, as the presence cron
|
||||||
|
-- writes them. Generated from packages/domain/src/stats-db.ts (STAT_SCHEMA_DDL) — keep
|
||||||
|
-- in sync.
|
||||||
|
--
|
||||||
|
-- `stat_type` names what was measured (currently just `online`: the number of live
|
||||||
|
-- `presence` rows, i.e. players online, taken right after the expired ones are swept).
|
||||||
|
-- `value` is the measurement and `datetime` is when it was taken, as an ISO-8601 UTC
|
||||||
|
-- string so it reads directly and sorts lexically.
|
||||||
|
CREATE TABLE IF NOT EXISTS stat (
|
||||||
|
stat_type TEXT NOT NULL,
|
||||||
|
value INTEGER NOT NULL,
|
||||||
|
datetime TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- For "the `online` series over a time range".
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stat_type_datetime ON stat (stat_type, datetime);
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -33,6 +33,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,17 +53,21 @@ export type Env = SharedHonoEnv & {
|
|||||||
/** The Photon Chat application id. Optional; see {@link Env.PHOTON_REALTIME_APP_ID}. */
|
/** The Photon Chat application id. Optional; see {@link Env.PHOTON_REALTIME_APP_ID}. */
|
||||||
PHOTON_CHAT_APP_ID?: string
|
PHOTON_CHAT_APP_ID?: string
|
||||||
/**
|
/**
|
||||||
* The Tachyon voice server the client is handed as `voiceConnectionInfo` on
|
* The pool of Tachyon servers sessions are spread across — a COMMA-SEPARATED list of
|
||||||
* `GET /player/connection-info`, as `host:port` (e.g. `66.228.47.217:7777`).
|
* `host:port` entries (e.g. `66.228.47.217:7777,66.228.47.217:7778,45.79.2.10:7777`).
|
||||||
* Optional, and EMPTY when unset — no separate voice server. Not a secret (the
|
* One entry is a single server, which is the common case. Optional, and EMPTY when
|
||||||
* client receives it in the clear), so a plain var like the Photon ids.
|
* unset — no separate voice server, and the connection info's voice fields stay empty.
|
||||||
|
* Not a secret (the client receives it in the clear), so a plain var like the Photon
|
||||||
|
* ids.
|
||||||
|
*
|
||||||
|
* A room instance is assigned one entry for its lifetime and every player in it is
|
||||||
|
* handed that one, derived from the instance id rather than stored — see
|
||||||
|
* `tachyonServerFor` in match.app.ts, which also explains what changing this list does
|
||||||
|
* to sessions already running. The `voiceServerId` the client displays is GENERATED
|
||||||
|
* from an entry's position (`tachyon-1`, `tachyon-2`, …), so the same address may be
|
||||||
|
* listed twice to model two server slots on one box.
|
||||||
*/
|
*/
|
||||||
TACHYON_HOST_PORT?: string
|
TACHYON_HOST_PORT?: string
|
||||||
/**
|
|
||||||
* The id of that voice server, handed to the client as `voiceServerId` (e.g.
|
|
||||||
* `server-1`). Optional; see {@link Env.TACHYON_HOST_PORT} — set both or neither.
|
|
||||||
*/
|
|
||||||
TACHYON_NAME?: string
|
|
||||||
/**
|
/**
|
||||||
* The Photon region every session is pinned to — both the region named in the connection
|
* The Photon region every session is pinned to — both the region named in the connection
|
||||||
* info and the one stamped on every room instance, which must agree. Optional; unlike the
|
* info and the one stamped on every room instance, which must agree. Optional; unlike the
|
||||||
|
|||||||
+333
-32
@@ -6,6 +6,7 @@ import {
|
|||||||
Accessibility,
|
Accessibility,
|
||||||
areFriends,
|
areFriends,
|
||||||
canManageRoom,
|
canManageRoom,
|
||||||
|
countOnlinePlayers,
|
||||||
createRoomInstance,
|
createRoomInstance,
|
||||||
createRoomInvite,
|
createRoomInvite,
|
||||||
deleteEmptyRoomInstances,
|
deleteEmptyRoomInstances,
|
||||||
@@ -17,6 +18,8 @@ import {
|
|||||||
getExpiredPresenceInstanceIds,
|
getExpiredPresenceInstanceIds,
|
||||||
getFriendIds,
|
getFriendIds,
|
||||||
getJoinableInstance,
|
getJoinableInstance,
|
||||||
|
deleteRoomInvite,
|
||||||
|
getLatestRoomInviteBetween,
|
||||||
getMostActiveClubhouses,
|
getMostActiveClubhouses,
|
||||||
getOrCreateDormRoom,
|
getOrCreateDormRoom,
|
||||||
getPresence,
|
getPresence,
|
||||||
@@ -35,6 +38,7 @@ import {
|
|||||||
MessageType,
|
MessageType,
|
||||||
MOST_ACTIVE_CLUBHOUSE_LIMIT,
|
MOST_ACTIVE_CLUBHOUSE_LIMIT,
|
||||||
recordRoomVisit,
|
recordRoomVisit,
|
||||||
|
recordStat,
|
||||||
refreshInstanceFullness,
|
refreshInstanceFullness,
|
||||||
RoomInstanceType,
|
RoomInstanceType,
|
||||||
setPresence,
|
setPresence,
|
||||||
@@ -163,6 +167,66 @@ function instancePhotonRegion(env: Env): string {
|
|||||||
return varOr(env.PHOTON_REGION, DEFAULT_PHOTON_REGION)
|
return varOr(env.PHOTON_REGION, DEFAULT_PHOTON_REGION)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Tachyon server this deployment can put a session on: where the client connects
|
||||||
|
* (`host:port`) and the cosmetic id it displays for it.
|
||||||
|
*/
|
||||||
|
interface TachyonServer {
|
||||||
|
hostPort: string
|
||||||
|
serverId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Tachyon servers sessions are spread across, from `TACHYON_HOST_PORT` — a
|
||||||
|
* comma-separated list of `host:port` entries. EMPTY when the var is unset: recflare
|
||||||
|
* runs no Tachyon server of its own, and a client handed an address that answers
|
||||||
|
* nothing is worse off than one told there is no voice server at all.
|
||||||
|
*
|
||||||
|
* An entry's POSITION in the list is its identity: `voiceServerId` is generated from it
|
||||||
|
* (`tachyon-1`, `tachyon-2`, …) rather than configured, so the same host may appear
|
||||||
|
* twice and count as two servers — which is what one box running several server slots
|
||||||
|
* looks like from here. The id is cosmetic (the client connects to the address and
|
||||||
|
* never sends the id anywhere), but it is positional, so inserting an entry renames
|
||||||
|
* every server after it.
|
||||||
|
*/
|
||||||
|
function tachyonPool(env: Env): TachyonServer[] {
|
||||||
|
return varOr(env.TACHYON_HOST_PORT, '')
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry !== '')
|
||||||
|
.map((hostPort, i) => ({ hostPort, serverId: `tachyon-${i + 1}` }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the connection info carries when there is no Tachyon server to name. */
|
||||||
|
const NO_TACHYON_SERVER: TachyonServer = { hostPort: '', serverId: '' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Tachyon server a room instance runs on — the whole of the distributed selection,
|
||||||
|
* and deliberately a pure function of the instance id rather than a stored assignment.
|
||||||
|
*
|
||||||
|
* The point of a server assignment is that everyone in one instance is handed the SAME
|
||||||
|
* one: the player whose matchmake created the instance and everyone who joins it later
|
||||||
|
* each call `GET /player/connection-info` separately, so an assignment made per REQUEST
|
||||||
|
* (random, round-robin over a counter, least-loaded) would scatter one session across
|
||||||
|
* the pool. Deriving it from the instance id instead makes every caller compute the same
|
||||||
|
* answer without coordinating, needs no column to persist and no cleanup when the
|
||||||
|
* instance is swept, and answers for instances created before this existed.
|
||||||
|
*
|
||||||
|
* Instance ids are sequential ({@link createRoomInstance} allocates `MAX(id) + 1`), so
|
||||||
|
* the modulo hands successive instances to successive servers: a plain round-robin over
|
||||||
|
* instances, which is the spread a real allocator would aim for anyway. Changing the
|
||||||
|
* pool DOES move live instances — the list is the assignment — so add entries to the
|
||||||
|
* end and expect a session mid-flight to be told a different server when you don't.
|
||||||
|
*
|
||||||
|
* `roomInstanceId` 0 means the caller resolved to no instance at all (they're in no
|
||||||
|
* room, or named one that doesn't exist); they get no server rather than server one.
|
||||||
|
*/
|
||||||
|
function tachyonServerFor(env: Env, roomInstanceId: number): TachyonServer {
|
||||||
|
const pool = tachyonPool(env)
|
||||||
|
if (pool.length === 0 || roomInstanceId <= 0) return NO_TACHYON_SERVER
|
||||||
|
return pool[roomInstanceId % pool.length] ?? NO_TACHYON_SERVER
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Networking feature flags the client reads off its connection info. Verbatim from
|
* Networking feature flags the client reads off its connection info. Verbatim from
|
||||||
* the reference server — the client changes how it replicates based on these, so they
|
* the reference server — the client changes how it replicates based on these, so they
|
||||||
@@ -595,6 +659,63 @@ function crossBuildRefusal(
|
|||||||
return instanceVersion > callerVersion ? MatchmakingErrorCode.UpdateRequired : NO_SUCH_ROOM
|
return instanceVersion > callerVersion ? MatchmakingErrorCode.UpdateRequired : NO_SUCH_ROOM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 2023 client build, as its token's `rn.ver` date stamps it (`20230414`, or a point
|
||||||
|
* release of it). See {@link persistenceVersionRefusal}.
|
||||||
|
*/
|
||||||
|
const BUILD_2023 = 20230414
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first scene persistence version the 2023 client cannot load. A room whose published
|
||||||
|
* scene was saved at this version or later was built on a newer client; the old one fails
|
||||||
|
* to deserialize it.
|
||||||
|
*/
|
||||||
|
const MIN_UNLOADABLE_PERSISTENCE_VERSION_2023 = 227
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The persistence version of the scene a subroom LOADS — the published `CurrentSave`'s,
|
||||||
|
* the same save {@link subRoomDataBlob} serves, falling back to the flat legacy field.
|
||||||
|
* `null` when nothing recorded one (a fresh subroom, or one saved before the field
|
||||||
|
* existed): unknown is not "new".
|
||||||
|
*/
|
||||||
|
function subRoomPersistenceVersion(sub: Record<string, unknown> | undefined): number | null {
|
||||||
|
const save = sub?.CurrentSave
|
||||||
|
if (save && typeof save === 'object') {
|
||||||
|
const v = (save as Record<string, unknown>).PersistenceVersion
|
||||||
|
if (typeof v === 'number') return v
|
||||||
|
}
|
||||||
|
return typeof sub?.PersistenceVersion === 'number' ? sub.PersistenceVersion : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a player on `callerVersion` may enter `room` at all — `null` when they may,
|
||||||
|
* otherwise the code to refuse with.
|
||||||
|
*
|
||||||
|
* A caller on the 2023 build ({@link BUILD_2023}) is refused any room with a subroom whose
|
||||||
|
* published scene is at persistence version {@link MIN_UNLOADABLE_PERSISTENCE_VERSION_2023}
|
||||||
|
* or above: that scene was saved by a newer client and the 2023 one can't load it, so
|
||||||
|
* the honest answer is `UpdateRequired` — "your client can't go there" — rather than
|
||||||
|
* handing out an instance that never finishes loading. The WHOLE room is gated, not just
|
||||||
|
* the requested subroom, since the client walks between subrooms without re-matchmaking.
|
||||||
|
*
|
||||||
|
* Every other build, and a token that names none, passes: the gate is about one known
|
||||||
|
* client, not a general ordering.
|
||||||
|
*/
|
||||||
|
function persistenceVersionRefusal(
|
||||||
|
room: Room,
|
||||||
|
callerVersion: string | null
|
||||||
|
): MatchmakingErrorCode | null {
|
||||||
|
if (buildNumber(callerVersion) !== BUILD_2023) return null
|
||||||
|
const subRooms = (Array.isArray(room.SubRooms) ? room.SubRooms : []) as Array<
|
||||||
|
Record<string, unknown>
|
||||||
|
>
|
||||||
|
const tooNew = subRooms.some((sub) => {
|
||||||
|
const v = subRoomPersistenceVersion(sub)
|
||||||
|
return v !== null && v >= MIN_UNLOADABLE_PERSISTENCE_VERSION_2023
|
||||||
|
})
|
||||||
|
return tooNew ? MatchmakingErrorCode.UpdateRequired : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "This event isn't open to you" — the refusal on a private event the caller wasn't
|
* "This event isn't open to you" — the refusal on a private event the caller wasn't
|
||||||
* invited to. Told plainly rather than hidden behind the opaque NoSuchRoom: a player
|
* invited to. Told plainly rather than hidden behind the opaque NoSuchRoom: a player
|
||||||
@@ -668,14 +789,6 @@ async function gameInviteType(c: Context<App>): Promise<MessageType> {
|
|||||||
*/
|
*/
|
||||||
const GAME_INVITE_V2_INVITE_MODE = InviteMode.PlayTogether
|
const GAME_INVITE_V2_INVITE_MODE = InviteMode.PlayTogether
|
||||||
|
|
||||||
/**
|
|
||||||
* The placeholder `InviteId` on a v2 invite that has no `room_invite` row behind it — the
|
|
||||||
* party fan-out, which invites without recording anything. What the client does with the
|
|
||||||
* field isn't known yet; `POST /invite` passes the real row id (see {@link RoomInvite}),
|
|
||||||
* which is the obvious candidate, and this stands in where there is no row to name.
|
|
||||||
*/
|
|
||||||
const UNKNOWN_INVITE_ID = 0
|
|
||||||
|
|
||||||
/** What an invite points at, in the shape both message versions need to describe it. */
|
/** What an invite points at, in the shape both message versions need to describe it. */
|
||||||
type GameInviteTarget = {
|
type GameInviteTarget = {
|
||||||
/** The raw roomInstanceId string — the WHOLE `Data` of a v1 invite. */
|
/** The raw roomInstanceId string — the WHOLE `Data` of a v1 invite. */
|
||||||
@@ -684,7 +797,7 @@ type GameInviteTarget = {
|
|||||||
roomId: number | null
|
roomId: number | null
|
||||||
/** The instance's `^`-prefixed wire name, `''` when the instance didn't resolve. */
|
/** The instance's `^`-prefixed wire name, `''` when the instance didn't resolve. */
|
||||||
name: string
|
name: string
|
||||||
/** The `room_invite` row this came from, or {@link UNKNOWN_INVITE_ID}. */
|
/** The id of the `room_invite` row this came from — what the invitee redeems. */
|
||||||
inviteId: number
|
inviteId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1064,6 +1177,12 @@ async function readMatchmakeBody(
|
|||||||
* sends, pointing at this instance, so a party matchmake pulls the whole party along. The
|
* sends, pointing at this instance, so a party matchmake pulls the whole party along. The
|
||||||
* leader is skipped (already in). Best-effort per member (sendGameInvite swallows its own
|
* leader is skipped (already in). Best-effort per member (sendGameInvite swallows its own
|
||||||
* failures), and never blocks the matchmake beyond the sends themselves.
|
* failures), and never blocks the matchmake beyond the sends themselves.
|
||||||
|
*
|
||||||
|
* Each member's invite is RECORDED, exactly as `POST /invite` records one, because the row
|
||||||
|
* is what the member redeems the frame against: the 2025 client joins off a party invite
|
||||||
|
* through `/matchmake/invite/{InviteId}` or `/matchmake/v2/player/{leaderId}`, and both
|
||||||
|
* resolve a `room_invite` row. A fan-out that only pushed the frame minted no row, so every
|
||||||
|
* party invite read as expired the moment it arrived while a manual `POST /invite` worked.
|
||||||
*/
|
*/
|
||||||
async function inviteParty(
|
async function inviteParty(
|
||||||
c: Context<App>,
|
c: Context<App>,
|
||||||
@@ -1071,21 +1190,33 @@ async function inviteParty(
|
|||||||
playerIds: number[],
|
playerIds: number[],
|
||||||
instance: RoomInstance
|
instance: RoomInstance
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// The leader's own instance, which every member is being pulled into. Nothing records
|
|
||||||
// a `room_invite` row on this path, so a v2 invite has no real id to name.
|
|
||||||
const target: GameInviteTarget = {
|
|
||||||
instanceId: String(instance.roomInstanceId),
|
|
||||||
roomId: instance.roomId,
|
|
||||||
name: instance.name,
|
|
||||||
inviteId: UNKNOWN_INVITE_ID,
|
|
||||||
}
|
|
||||||
// One read of the leader's token for the whole party — every member gets the same
|
// One read of the leader's token for the whole party — every member gets the same
|
||||||
// message, so the type can't differ between them.
|
// message, so the type can't differ between them.
|
||||||
const type = await gameInviteType(c)
|
const type = await gameInviteType(c)
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
playerIds
|
playerIds
|
||||||
.filter((pid) => pid !== leaderId)
|
.filter((pid) => pid !== leaderId)
|
||||||
.map((pid) => sendGameInvite(c, leaderId, pid, target, type))
|
.map(async (pid) => {
|
||||||
|
// The row before the frame, as `POST /invite` does: the frame names the row's id,
|
||||||
|
// so an invite that couldn't be recorded has nothing to redeem and isn't sent.
|
||||||
|
const invite = await createRoomInvite(c.env.DB, leaderId, pid, instance.roomId)
|
||||||
|
if (invite === null) {
|
||||||
|
logger.error('failed to record party room invite', {
|
||||||
|
fromPlayerId: leaderId,
|
||||||
|
toPlayerId: pid,
|
||||||
|
roomId: instance.roomId,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The leader's own instance, which every member is being pulled into.
|
||||||
|
const target: GameInviteTarget = {
|
||||||
|
instanceId: String(instance.roomInstanceId),
|
||||||
|
roomId: instance.roomId,
|
||||||
|
name: instance.name,
|
||||||
|
inviteId: invite.RoomInviteId,
|
||||||
|
}
|
||||||
|
await sendGameInvite(c, leaderId, pid, target, type)
|
||||||
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,6 +1334,20 @@ async function resolveRoomInstance(
|
|||||||
return { instance: null, errorCode: BANNED_FROM_ROOM }
|
return { instance: null, errorCode: BANNED_FROM_ROOM }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The build this player is on, from their token. A 2023 client can't load a scene
|
||||||
|
// saved at a newer persistence version, so it is refused the room outright (see
|
||||||
|
// persistenceVersionRefusal) before any instance is created or reused.
|
||||||
|
const tokenVersion = await callerVersion(c)
|
||||||
|
const tooNew = persistenceVersionRefusal(room, tokenVersion)
|
||||||
|
if (tooNew !== null) {
|
||||||
|
logger.info('matchmake refused: room persistence version too new for client build', {
|
||||||
|
roomId: f.roomId,
|
||||||
|
ownerId,
|
||||||
|
gameVersion: tokenVersion,
|
||||||
|
})
|
||||||
|
return { instance: null, errorCode: tooNew }
|
||||||
|
}
|
||||||
|
|
||||||
// Never place the player back into the instance they're already in: the client
|
// Never place the player back into the instance they're already in: the client
|
||||||
// keys the room transition off a changing `roomInstanceId`, so re-matchmaking into
|
// keys the room transition off a changing `roomInstanceId`, so re-matchmaking into
|
||||||
// your current instance (e.g. the only public instance of a room you're already in)
|
// your current instance (e.g. the only public instance of a room you're already in)
|
||||||
@@ -1213,10 +1358,10 @@ async function resolveRoomInstance(
|
|||||||
const currentInstanceId = isPrivate
|
const currentInstanceId = isPrivate
|
||||||
? undefined
|
? undefined
|
||||||
: (await getPresence<RoomInstance>(c.env.DB, ownerId))?.roomInstance?.roomInstanceId
|
: (await getPresence<RoomInstance>(c.env.DB, ownerId))?.roomInstance?.roomInstanceId
|
||||||
// The build this player is on, from their token. It scopes the search below and is
|
// The same build, with GAME_VERSION standing in for a token that names none. It scopes
|
||||||
// stamped on the instance when one is created, which is what keeps a session to a
|
// the search below and is stamped on the instance when one is created, which is what
|
||||||
// single client version.
|
// keeps a session to a single client version.
|
||||||
const gameVersion = await callerGameVersion(c)
|
const gameVersion = tokenVersion ?? GAME_VERSION
|
||||||
// Reuse an existing joinable public instance *of the same subroom and the same
|
// Reuse an existing joinable public instance *of the same subroom and the same
|
||||||
// build* — subrooms are separate places, so joining one must never land you in
|
// build* — subrooms are separate places, so joining one must never land you in
|
||||||
// another, and neither must a session running a different version of the room.
|
// another, and neither must a session running a different version of the room.
|
||||||
@@ -2040,6 +2185,143 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The newer client's join-by-player (`/matchmake/v2/player/{playerId}`). Same move as
|
||||||
|
// the v1 follow above — land in the instance the target is standing in — but gated on
|
||||||
|
// the `room_invite` table rather than friendship: the caller must hold a standing
|
||||||
|
// invite FROM the target (the newer client's invite frame doesn't always carry a
|
||||||
|
// redeemable `InviteId` — the party fan-out sends 0 — so it redeems by player instead
|
||||||
|
// of by row id, and this is that path). Everything the target sent stays checkable:
|
||||||
|
// the newest row is enough, since any live row is authorization.
|
||||||
|
//
|
||||||
|
// The row is consumed on a successful join: an invite authorizes one entry, and since
|
||||||
|
// this path follows the target's LIVE presence rather than the room the invite named,
|
||||||
|
// keeping it would leave a standing key into whatever instance they're in later.
|
||||||
|
//
|
||||||
|
// Like the follow and invite paths, this hands out real Photon coordinates without
|
||||||
|
// going through resolveRoomInstance, so it carries its own ban and build checks.
|
||||||
|
// `/matchmake/v2/` answers the PascalCase envelope via `matchmakeResult`, as the v2
|
||||||
|
// room routes do.
|
||||||
|
.post(
|
||||||
|
'/matchmake/v2/player/:playerId{[0-9]+}',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Navigation', '2025'],
|
||||||
|
summary: 'Join the player who invited you (v2)',
|
||||||
|
description: [
|
||||||
|
'Places the caller into the room instance the target player is currently in, read from',
|
||||||
|
'the target’s stored presence. INVITEES ONLY: the caller must hold a `room_invite` row',
|
||||||
|
'FROM the target (as `POST /invite` writes them) — the newer client redeems an invite by',
|
||||||
|
'its sender when the frame carries no usable `RoomInviteId`. The invite is SINGLE-USE:',
|
||||||
|
'a successful join deletes the row, so the same invite can’t be redeemed again into',
|
||||||
|
'wherever that player goes next (a refusal leaves it standing, so a retry still works).',
|
||||||
|
'Answers 40',
|
||||||
|
'(RoomInviteExpired) when no invite stands (expiry deletes rows, so “never invited” and',
|
||||||
|
'“expired” are one answer), 2 (PlayerNotOnline) when the target isn’t in a room, 17',
|
||||||
|
'(AlreadyInTargetInstance) when the caller is already standing there, 3',
|
||||||
|
'(InsufficientSpace) when it filled up, and 55 (BannedFromRoom) when the caller is',
|
||||||
|
'banned from that room.',
|
||||||
|
'',
|
||||||
|
'2025-client route: it answers the PascalCase `ErrorCode`/`RoomInstance` envelope, as',
|
||||||
|
'the other `/matchmake/v2/*` routes do.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
requestBody: form(CorrelationIdRequest, 'The attempt’s CorrelationId'),
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'playerId',
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
description: 'The player to join (digits only)',
|
||||||
|
schema: { type: 'string', pattern: '^[0-9]+$' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: json(
|
||||||
|
MatchmakeV2Response,
|
||||||
|
'The target’s instance, or a null RoomInstance with the refusal code'
|
||||||
|
),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const targetId = Number.parseInt(c.req.param('playerId'), 10)
|
||||||
|
// The gate: a standing invite from the target to the caller. No row means never
|
||||||
|
// invited or already swept — the same answer either way, since expiry deletes
|
||||||
|
// rows. This also refuses joining yourself: nobody holds a self-invite.
|
||||||
|
const invite = await getLatestRoomInviteBetween(c.env.DB, targetId, id)
|
||||||
|
if (invite === null) {
|
||||||
|
logger.info('v2 player matchmake refused: no invite from target', { targetId, id })
|
||||||
|
return matchmakeResult(c, MatchmakingErrorCode.RoomInviteExpired, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the inviter is NOW, straight off their presence row — not the invite's
|
||||||
|
// stored RoomId, which records where they were when they sent it.
|
||||||
|
const targetPresence = await getPresence<RoomInstance>(c.env.DB, targetId)
|
||||||
|
const instance = targetPresence?.roomInstance ?? null
|
||||||
|
if (!instance) {
|
||||||
|
logger.info('v2 player matchmake refused: target is not in a room', { targetId, id })
|
||||||
|
return matchmakeResult(c, MatchmakingErrorCode.PlayerNotOnline, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already standing there: nothing to do, and re-entering would churn presence and
|
||||||
|
// re-fire the friend fan-out for a move that didn't happen.
|
||||||
|
const own = await getPresence<RoomInstance>(c.env.DB, id)
|
||||||
|
if (own?.roomInstance?.roomInstanceId === instance.roomInstanceId) {
|
||||||
|
return matchmakeResult(c, MatchmakingErrorCode.AlreadyInTargetInstance, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real Photon coordinates without resolveRoomInstance, so the room's bans have to
|
||||||
|
// be checked here — otherwise an invite is a way around one.
|
||||||
|
if (await isPlayerBannedFromRoom(c.env.DB, instance.roomId, id)) {
|
||||||
|
logger.info('v2 player matchmake refused: player banned from room', {
|
||||||
|
roomId: instance.roomId,
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
return matchmakeResult(c, BANNED_FROM_ROOM, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nor the build scoping a room matchmake has by construction. Compared against the
|
||||||
|
// TARGET's presence — they're the person actually standing in there.
|
||||||
|
const refusal = crossBuildRefusal(
|
||||||
|
await callerGameVersion(c),
|
||||||
|
targetPresence?.appVersion ?? GAME_VERSION
|
||||||
|
)
|
||||||
|
if (refusal !== null) {
|
||||||
|
logger.info('v2 player matchmake refused: target is on another client build', {
|
||||||
|
roomInstanceId: instance.roomInstanceId,
|
||||||
|
targetId,
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
return matchmakeResult(c, refusal, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fullness read fresh, like the invite path: joins off an invite cluster exactly
|
||||||
|
// when a nearly-full instance is still filling. Null means a synthetic instance
|
||||||
|
// with no row (a dorm), which has no head-count to check.
|
||||||
|
if ((await refreshInstanceFullness(c.env.DB, instance.roomInstanceId)) === true) {
|
||||||
|
logger.info('v2 player matchmake refused: instance is full', {
|
||||||
|
roomInstanceId: instance.roomInstanceId,
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
return matchmakeResult(c, MatchmakingErrorCode.InsufficientSpace, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same instance, same Photon room, stored as the caller's presence so their
|
||||||
|
// heartbeat replays it and their own friend fan-out fires.
|
||||||
|
await enterRoom(c, id, instance)
|
||||||
|
|
||||||
|
// The invite is spent: it was authorization for THIS join, and leaving the row
|
||||||
|
// standing would make it a permanent key into whatever instance the target is in
|
||||||
|
// later — this path reads their live presence, not the room the invite named.
|
||||||
|
// Dropped only once the caller is actually in, so every refusal above (target not
|
||||||
|
// in a room, full, banned, wrong build) leaves the invite redeemable for a retry.
|
||||||
|
await deleteRoomInvite(c.env.DB, invite.RoomInviteId)
|
||||||
|
return matchmakeResult(c, MatchmakingErrorCode.Success, instance)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Accept a game invite and land in the inviter's instance
|
// Accept a game invite and land in the inviter's instance
|
||||||
// (`/matchmake/invite/{roomInviteId}`). The 2025 client's join button on an invite: it
|
// (`/matchmake/invite/{roomInviteId}`). The 2025 client's join button on an invite: it
|
||||||
// carries the `RoomInviteId` minted by `POST /invite`, and this resolves that row to the
|
// carries the `RoomInviteId` minted by `POST /invite`, and this resolves that row to the
|
||||||
@@ -2496,6 +2778,10 @@ const app = new Hono<App>()
|
|||||||
// reads presence and nothing else; we fall back to looking the `roomInstanceId` query
|
// reads presence and nothing else; we fall back to looking the `roomInstanceId` query
|
||||||
// param up when presence has no room (it expires on a TTL, and the client sometimes
|
// param up when presence has no room (it expires on a TTL, and the client sometimes
|
||||||
// asks before matchmaking has landed), and to an empty string when neither resolves.
|
// asks before matchmaking has landed), and to an empty string when neither resolves.
|
||||||
|
//
|
||||||
|
// The Tachyon server is resolved from the same instance ({@link tachyonServerFor}), so
|
||||||
|
// the player who created the session and everyone who joins it later are all sent to
|
||||||
|
// one server without this endpoint having to remember what it told the first caller.
|
||||||
.get(
|
.get(
|
||||||
'/player/connection-info',
|
'/player/connection-info',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -2506,9 +2792,11 @@ const app = new Hono<App>()
|
|||||||
'`{ success, value, error }` envelope: a freshly minted `photonAuthToken`, the',
|
'`{ success, value, error }` envelope: a freshly minted `photonAuthToken`, the',
|
||||||
'Photon application ids, and the `photonRoomId` of the instance the caller is in',
|
'Photon application ids, and the `photonRoomId` of the instance the caller is in',
|
||||||
'(from their presence, falling back to the `roomInstanceId` query param). The voice',
|
'(from their presence, falling back to the `roomInstanceId` query param). The voice',
|
||||||
'fields carry the Tachyon voice server (`TACHYON_HOST_PORT`/`TACHYON_NAME`),',
|
'fields name the Tachyon server that instance was assigned — one entry out of the',
|
||||||
'empty when none is configured. `experiments` carries the',
|
'`TACHYON_HOST_PORT` pool, chosen by instance id so every player in a session is',
|
||||||
'client’s networking flags.',
|
'handed the same one, with a generated `voiceServerId` (`tachyon-1`, `tachyon-2`,',
|
||||||
|
'…). Both are empty when the pool is unset or the caller is in no instance.',
|
||||||
|
'`experiments` carries the client’s networking flags.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -2532,14 +2820,22 @@ const app = new Hono<App>()
|
|||||||
const apps = photonApps(c.env)
|
const apps = photonApps(c.env)
|
||||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
||||||
// Presence first (it's the instance the player is actually in); the query param
|
// Presence first (it's the instance the player is actually in); the query param
|
||||||
// only stands in when there's no live presence to read.
|
// only stands in when there's no live presence to read. The instance id travels
|
||||||
|
// with the Photon room because the Tachyon server is derived from it — resolving
|
||||||
|
// one without the other would hand a joiner the right Photon room on a different
|
||||||
|
// game server than the rest of their session.
|
||||||
|
let roomInstanceId = presence?.roomInstance?.roomInstanceId ?? 0
|
||||||
let photonRoomId = presence?.roomInstance?.photonRoomId ?? ''
|
let photonRoomId = presence?.roomInstance?.photonRoomId ?? ''
|
||||||
if (!photonRoomId) {
|
if (!photonRoomId) {
|
||||||
const requested = Number.parseInt(c.req.query('roomInstanceId') ?? '', 10)
|
const requested = Number.parseInt(c.req.query('roomInstanceId') ?? '', 10)
|
||||||
if (!Number.isNaN(requested)) {
|
if (!Number.isNaN(requested)) {
|
||||||
photonRoomId = (await getRoomInstance(c.env.DB, requested))?.photonRoomId ?? ''
|
const instance = await getRoomInstance(c.env.DB, requested)
|
||||||
|
roomInstanceId = instance?.roomInstanceId ?? 0
|
||||||
|
photonRoomId = instance?.photonRoomId ?? ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The instance's server, the same one every other player in it is handed.
|
||||||
|
const tachyon = tachyonServerFor(c.env, roomInstanceId)
|
||||||
|
|
||||||
// Identifies the player to Photon. Signed with the shared JWT secret; the token's
|
// Identifies the player to Photon. Signed with the shared JWT secret; the token's
|
||||||
// `aud` is the realtime app it's for. Nothing verifies it while Photon is
|
// `aud` is the realtime app it's for. Nothing verifies it while Photon is
|
||||||
@@ -2561,13 +2857,14 @@ const app = new Hono<App>()
|
|||||||
photonAuthToken,
|
photonAuthToken,
|
||||||
...apps,
|
...apps,
|
||||||
photonRoomId,
|
photonRoomId,
|
||||||
// The Tachyon voice server, from the operator's vars — empty strings when
|
// The Tachyon server this instance runs on, picked out of the operator's
|
||||||
// unset (no separate voice server). Empty rather than null: the client's
|
// pool by {@link tachyonServerFor} — empty strings when the pool is empty
|
||||||
|
// or the caller is in no instance. Empty rather than null: the client's
|
||||||
// decoder is likelier to accept a missing-value string than a null on a
|
// decoder is likelier to accept a missing-value string than a null on a
|
||||||
// string field. The presence payload's NULL_CONNECTION_INFO keeps its
|
// string field. The presence payload's NULL_CONNECTION_INFO keeps its
|
||||||
// nulls — that one never carries credentials.
|
// nulls — that one never carries credentials.
|
||||||
voiceConnectionInfo: varOr(c.env.TACHYON_HOST_PORT, ''),
|
voiceConnectionInfo: tachyon.hostPort,
|
||||||
voiceServerId: varOr(c.env.TACHYON_NAME, ''),
|
voiceServerId: tachyon.serverId,
|
||||||
experiments: PHOTON_EXPERIMENTS,
|
experiments: PHOTON_EXPERIMENTS,
|
||||||
},
|
},
|
||||||
error: null,
|
error: null,
|
||||||
@@ -2903,10 +3200,14 @@ async function sweepExpiredPresence(env: Env): Promise<void> {
|
|||||||
for (const instanceId of staleInstanceIds) {
|
for (const instanceId of staleInstanceIds) {
|
||||||
await refreshInstanceFullness(env.DB, instanceId)
|
await refreshInstanceFullness(env.DB, instanceId)
|
||||||
}
|
}
|
||||||
|
// Sample the player count into `stat` — taken after the purge, so it's the live
|
||||||
|
// rows and not the ones that just lapsed. One row per cron run: the `online` series.
|
||||||
|
const online = await countOnlinePlayers(env.DB)
|
||||||
|
await recordStat(env.DB, 'online', online)
|
||||||
// The tagged logger is request-scoped (its middleware never runs for a cron), so
|
// The tagged logger is request-scoped (its middleware never runs for a cron), so
|
||||||
// log plainly here — Workers observability picks it up either way.
|
// log plainly here — Workers observability picks it up either way.
|
||||||
console.log(
|
console.log(
|
||||||
`presence sweep: removed ${removed} expired rows, deleted ${emptyInstanceIds.length} empty instances, refreshed ${staleInstanceIds.length} instances`
|
`presence sweep: removed ${removed} expired rows, deleted ${emptyInstanceIds.length} empty instances, refreshed ${staleInstanceIds.length} instances, ${online} online`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -206,8 +206,9 @@ export const ConnectionExperiments = z.object({
|
|||||||
* recflare; what varies per caller is `photonAuthToken` (minted for them on the spot)
|
* recflare; what varies per caller is `photonAuthToken` (minted for them on the spot)
|
||||||
* and `photonRoomId`, the Photon room of the instance their presence says they're in
|
* and `photonRoomId`, the Photon room of the instance their presence says they're in
|
||||||
* — the same name every other player in that instance is handed. The voice fields name
|
* — the same name every other player in that instance is handed. The voice fields name
|
||||||
* the Tachyon voice server (`TACHYON_HOST_PORT`/`TACHYON_NAME` vars), empty when none
|
* the Tachyon server that instance was assigned out of the `TACHYON_HOST_PORT` pool —
|
||||||
* is configured. `photonRegion` matches the one stamped
|
* likewise the same for everyone in the session — and are empty when the pool is unset
|
||||||
|
* or the caller is in no instance. `photonRegion` matches the one stamped
|
||||||
* on every room instance, so the two can't disagree.
|
* on every room instance, so the two can't disagree.
|
||||||
*/
|
*/
|
||||||
export const ConnectionInfo = z.object({
|
export const ConnectionInfo = z.object({
|
||||||
@@ -219,8 +220,10 @@ export const ConnectionInfo = z.object({
|
|||||||
photonRoomId: z.string().describe('The caller’s current instance; empty when they’re in none'),
|
photonRoomId: z.string().describe('The caller’s current instance; empty when they’re in none'),
|
||||||
voiceConnectionInfo: z
|
voiceConnectionInfo: z
|
||||||
.string()
|
.string()
|
||||||
.describe('The Tachyon voice server, `host:port`; empty when none is configured'),
|
.describe('The instance’s Tachyon server, `host:port`; empty when none is configured'),
|
||||||
voiceServerId: z.string().describe('The Tachyon voice server id; empty when none is configured'),
|
voiceServerId: z
|
||||||
|
.string()
|
||||||
|
.describe('That server’s generated id (`tachyon-1`, …); cosmetic, empty when there is none'),
|
||||||
experiments: ConnectionExperiments,
|
experiments: ConnectionExperiments,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -278,10 +281,11 @@ export const NotifyDisconnectRequest = z.object({
|
|||||||
* matchmakes that post no body at all.
|
* matchmakes that post no body at all.
|
||||||
*
|
*
|
||||||
* This is the whole body of the target-less matchmakes (`/matchmake/dorm`,
|
* This is the whole body of the target-less matchmakes (`/matchmake/dorm`,
|
||||||
* `/matchmake/none`, `/matchmake/player/:id`, `/matchmake/instance/:id`), which is why
|
* `/matchmake/none`, `/matchmake/player/:id`, `/matchmake/v2/player/:id`,
|
||||||
* it's a schema of its own; the room matchmakes extend it. Other fields the client sends
|
* `/matchmake/instance/:id`), which is why it's a schema of its own; the room matchmakes
|
||||||
* (`LoginLock`, `MaxPersistenceVersion`, `VoiceServerVersion`,
|
* extend it. Other fields the client sends (`LoginLock`, `MaxPersistenceVersion`,
|
||||||
* `BypassMovementModeRestriction`) are accepted and ignored.
|
* `VoiceServerVersion`, `BypassMovementModeRestriction`, `PlayerIsPartyMember`) are
|
||||||
|
* accepted and ignored.
|
||||||
*/
|
*/
|
||||||
export const CorrelationIdRequest = z.object({
|
export const CorrelationIdRequest = z.object({
|
||||||
CorrelationId: z.string().optional().describe('Per-attempt GUID; echoed on the response'),
|
CorrelationId: z.string().optional().describe('Per-attempt GUID; echoed on the response'),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
ROOM_SCHEMA_DDL,
|
ROOM_SCHEMA_DDL,
|
||||||
seedRoomWithSubRooms,
|
seedRoomWithSubRooms,
|
||||||
setPresence,
|
setPresence,
|
||||||
|
STAT_SCHEMA_DDL,
|
||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
@@ -124,6 +125,8 @@ beforeAll(async () => {
|
|||||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
// Room invites (owned by this worker) — POST /invite mints a row per invite.
|
// Room invites (owned by this worker) — POST /invite mints a row per invite.
|
||||||
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
// Stats (owned by this worker) — the presence cron samples the online count into it.
|
||||||
|
for (const stmt of STAT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
|
||||||
// Accounts table (owned by the auth worker) — dorm creation reads the username
|
// Accounts table (owned by the auth worker) — dorm creation reads the username
|
||||||
// to name the room. Seed the players the dorm tests authenticate as.
|
// to name the room. Seed the players the dorm tests authenticate as.
|
||||||
@@ -1244,8 +1247,9 @@ describe('auth-gated endpoints', () => {
|
|||||||
// The room the client is told to join has to be the one matchmaking placed
|
// The room the client is told to join has to be the one matchmaking placed
|
||||||
// them in, or they end up alone in a room of their own.
|
// them in, or they end up alone in a room of their own.
|
||||||
photonRoomId: matchmaked.roomInstance.photonRoomId,
|
photonRoomId: matchmaked.roomInstance.photonRoomId,
|
||||||
// Empty strings, not nulls — unlike the presence payload's connection fields,
|
// No TACHYON_HOST_PORT pool configured, so there is no server to name. Empty
|
||||||
// which stay null (they never carry credentials).
|
// strings, not nulls — unlike the presence payload's connection fields, which
|
||||||
|
// stay null (they never carry credentials).
|
||||||
voiceConnectionInfo: '',
|
voiceConnectionInfo: '',
|
||||||
voiceServerId: '',
|
voiceServerId: '',
|
||||||
experiments: {
|
experiments: {
|
||||||
@@ -1374,6 +1378,84 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(body.value.photonRoomId).toBe('')
|
expect(body.value.photonRoomId).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('the Tachyon server is assigned per room instance, not per request', async () => {
|
||||||
|
// tachyon-1/-2 and tachyon-4/-5 are slots on one host apiece: a server id names a
|
||||||
|
// slot, which is why it's generated from the entry's position and not from its
|
||||||
|
// address. The blank entry and the stray spaces below are dropped — a list edited
|
||||||
|
// by hand shouldn't hand anyone an empty address.
|
||||||
|
const pool = [
|
||||||
|
{ voiceConnectionInfo: '198.51.100.10:7777', voiceServerId: 'tachyon-1' },
|
||||||
|
{ voiceConnectionInfo: '198.51.100.10:7778', voiceServerId: 'tachyon-2' },
|
||||||
|
{ voiceConnectionInfo: '198.51.100.11:7777', voiceServerId: 'tachyon-3' },
|
||||||
|
{ voiceConnectionInfo: '203.0.113.20:7777', voiceServerId: 'tachyon-4' },
|
||||||
|
{ voiceConnectionInfo: '203.0.113.20:7778', voiceServerId: 'tachyon-5' },
|
||||||
|
]
|
||||||
|
const original = env.TACHYON_HOST_PORT
|
||||||
|
try {
|
||||||
|
env.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'
|
||||||
|
|
||||||
|
const voiceFor = async (player: string, roomInstanceId?: number) => {
|
||||||
|
const res = await exports.default.fetch(
|
||||||
|
roomInstanceId === undefined
|
||||||
|
? `${ORIGIN}/player/connection-info`
|
||||||
|
: `${ORIGIN}/player/connection-info?roomInstanceId=${roomInstanceId}`,
|
||||||
|
{ headers: await bearer(player) }
|
||||||
|
)
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
value: { voiceConnectionInfo: string; voiceServerId: string }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
voiceConnectionInfo: body.value.voiceConnectionInfo,
|
||||||
|
voiceServerId: body.value.voiceServerId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The player who opened the session reads their server off their presence...
|
||||||
|
const instance = await createRoomInstance(env.DB, {
|
||||||
|
ownerAccountId: 970,
|
||||||
|
roomId: 2,
|
||||||
|
photonRoomId: 'tachyon-instance-a',
|
||||||
|
maxCapacity: 12,
|
||||||
|
})
|
||||||
|
await setPresence(env.DB, {
|
||||||
|
accountId: 970,
|
||||||
|
roomInstance: instance,
|
||||||
|
statusVisibility: 0,
|
||||||
|
deviceClass: 0,
|
||||||
|
vrMovementMode: 1,
|
||||||
|
platform: 0,
|
||||||
|
appVersion: GAME_VERSION,
|
||||||
|
})
|
||||||
|
const assigned = pool[instance.roomInstanceId % pool.length]!
|
||||||
|
expect(await voiceFor('970')).toEqual(assigned)
|
||||||
|
|
||||||
|
// ...and a joiner asking by instance id, before their own presence has landed,
|
||||||
|
// is sent to the same one. Two players in a session on two servers is the whole
|
||||||
|
// failure this is arranged to avoid.
|
||||||
|
expect(await voiceFor('971', instance.roomInstanceId)).toEqual(assigned)
|
||||||
|
|
||||||
|
// The next session opened goes to the next server along — instance ids are
|
||||||
|
// sequential, so the pool is walked round-robin as instances are created.
|
||||||
|
const next = await createRoomInstance(env.DB, {
|
||||||
|
ownerAccountId: 972,
|
||||||
|
roomId: 2,
|
||||||
|
photonRoomId: 'tachyon-instance-b',
|
||||||
|
maxCapacity: 12,
|
||||||
|
})
|
||||||
|
expect(next.roomInstanceId).toBe(instance.roomInstanceId + 1)
|
||||||
|
const alongside = await voiceFor('972', next.roomInstanceId)
|
||||||
|
expect(alongside).toEqual(pool[next.roomInstanceId % pool.length])
|
||||||
|
expect(alongside.voiceServerId).not.toBe(assigned.voiceServerId)
|
||||||
|
|
||||||
|
// A player in no instance gets no server, pool or no pool — there is nothing for
|
||||||
|
// them to be on the same server as, and the fields stay empty strings.
|
||||||
|
expect(await voiceFor('973')).toEqual({ voiceConnectionInfo: '', voiceServerId: '' })
|
||||||
|
} finally {
|
||||||
|
env.TACHYON_HOST_PORT = original
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('re-matchmaking into your current room returns a different instance (id must change)', async () => {
|
test('re-matchmaking into your current room returns a different instance (id must change)', async () => {
|
||||||
// The client keys the room transition off a changing roomInstanceId; handing back
|
// The client keys the room transition off a changing roomInstanceId; handing back
|
||||||
// the instance the player is already in hangs their join. RecCenter (cap 12) so
|
// the instance the player is already in hangs their join. RecCenter (cap 12) so
|
||||||
@@ -1909,6 +1991,30 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
|
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('records an `online` stat sample of the live presence count on each run', async () => {
|
||||||
|
await env.DB.prepare('DELETE FROM stat').run()
|
||||||
|
const before = (await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE expires_at > ?1')
|
||||||
|
.bind(nowSeconds())
|
||||||
|
.first<{ n: number }>())!.n
|
||||||
|
|
||||||
|
const ctx = createExecutionContext()
|
||||||
|
await scheduled(createScheduledController(), env, ctx)
|
||||||
|
await waitOnExecutionContext(ctx)
|
||||||
|
|
||||||
|
const rows = (
|
||||||
|
await env.DB.prepare('SELECT stat_type, value, datetime FROM stat').all<{
|
||||||
|
stat_type: string
|
||||||
|
value: number
|
||||||
|
datetime: string
|
||||||
|
}>()
|
||||||
|
).results
|
||||||
|
expect(rows).toHaveLength(1)
|
||||||
|
expect(rows[0]!.stat_type).toBe('online')
|
||||||
|
expect(rows[0]!.value).toBe(before)
|
||||||
|
// Stamped with the current time, as ISO-8601.
|
||||||
|
expect(Math.abs(Date.parse(rows[0]!.datetime) - Date.now())).toBeLessThan(10_000)
|
||||||
|
})
|
||||||
|
|
||||||
// Age an instance past EMPTY_INSTANCE_GRACE_SECONDS by backdating its `createdAt`
|
// Age an instance past EMPTY_INSTANCE_GRACE_SECONDS by backdating its `createdAt`
|
||||||
// (the generated `created_at` column follows the blob), so the empty-instance sweep
|
// (the generated `created_at` column follows the blob), so the empty-instance sweep
|
||||||
// can be exercised without waiting out the grace window.
|
// can be exercised without waiting out the grace window.
|
||||||
@@ -2444,9 +2550,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
|
|
||||||
// The one frame an invite from a client on `version` pushes, with the RoomInviteId
|
// The one frame an invite from a client on `version` pushes, with the RoomInviteId
|
||||||
// the call answered — a v2 invite names it in its Data.
|
// the call answered — a v2 invite names it in its Data.
|
||||||
const inviteFrom = async (
|
const inviteFrom = async (version?: string): Promise<{ frame: Sent; roomInviteId: number }> => {
|
||||||
version?: string
|
|
||||||
): Promise<{ frame: Sent; roomInviteId: number }> => {
|
|
||||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/invite`, {
|
const res = await exports.default.fetch(`${ORIGIN}/invite`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -2810,6 +2914,14 @@ describe('auth-gated endpoints', () => {
|
|||||||
RoomId: 2,
|
RoomId: 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The fan-out RECORDS each invite, like POST /invite: the row is what the invitee
|
||||||
|
// redeems the frame against, so a frame without one is expired on arrival.
|
||||||
|
const row = await env.DB.prepare(
|
||||||
|
'SELECT room_invite_id, room_id FROM room_invite WHERE from_player_id = 9850 AND to_player_id = 153'
|
||||||
|
).first<{ room_invite_id: number; room_id: number }>()
|
||||||
|
expect(row).not.toBeNull()
|
||||||
|
expect(row?.room_id).toBe(2)
|
||||||
|
|
||||||
// Multiple ids (repeated fields, not comma-separated), de-duplicated, and the leader
|
// Multiple ids (repeated fields, not comma-separated), de-duplicated, and the leader
|
||||||
// themselves is skipped.
|
// themselves is skipped.
|
||||||
await reset()
|
await reset()
|
||||||
@@ -2825,6 +2937,58 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(await sent()).toEqual([])
|
expect(await sent()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('a v2 party matchmake mints invites the members can actually redeem', async () => {
|
||||||
|
// The reported bug: /matchmake/v2/room/:id fanned the party out as frames with no
|
||||||
|
// `room_invite` row behind them, so the member's join answered 40 (RoomInviteExpired)
|
||||||
|
// while a manual POST /invite worked.
|
||||||
|
type Sent = { playerId: number; data: { Type: number; Data: string } }
|
||||||
|
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||||
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
|
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/matchmake/v2/room/2`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(await bearer('9860', '20250718.01')),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
AdditionalPlayerIds: [9861],
|
||||||
|
CorrelationId: '3c60e657-21c4-46be-815c-57ee51add506',
|
||||||
|
JoinMode: 2,
|
||||||
|
InviteMode: 20,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const leader = (await res.json()) as { RoomInstance: { RoomInstanceId: number } }
|
||||||
|
|
||||||
|
// The party member's frame is a v2 invite naming a REAL row id.
|
||||||
|
const frames = (await (await hub().fetch('http://do/all')).json()) as Sent[]
|
||||||
|
const invite = frames.find((f) => f.playerId === 9861)
|
||||||
|
expect(invite?.data.Type).toBe(6) // MessageType.GameInviteV2
|
||||||
|
const { InviteId } = JSON.parse(invite?.data.Data ?? '{}') as { InviteId: number }
|
||||||
|
expect(InviteId).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Redeeming it puts the member in the leader's instance rather than answering 40.
|
||||||
|
const joined = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/matchmake/invite/${InviteId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer('9861', '20250718.01')) },
|
||||||
|
})
|
||||||
|
).json()) as { ErrorCode: number; RoomInstance: { RoomInstanceId: number } | null }
|
||||||
|
expect(joined.ErrorCode).toBe(0)
|
||||||
|
expect(joined.RoomInstance?.RoomInstanceId).toBe(leader.RoomInstance.RoomInstanceId)
|
||||||
|
|
||||||
|
// The by-sender redemption the newer client falls back to works off the same row.
|
||||||
|
await env.DB.prepare('DELETE FROM presence WHERE account_id = 9861').run()
|
||||||
|
const bySender = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/9860`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer('9861', '20250718.01')) },
|
||||||
|
})
|
||||||
|
).json()) as { ErrorCode: number }
|
||||||
|
expect(bySender.ErrorCode).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /matchmake/invite/:id lands the invitee in the inviter’s instance', async () => {
|
test('POST /matchmake/invite/:id lands the invitee in the inviter’s instance', async () => {
|
||||||
// 8801 invites 8802. The invite row is what POST /invite answers with.
|
// 8801 invites 8802. The invite row is what POST /invite answers with.
|
||||||
const instance = await createRoomInstance(env.DB, {
|
const instance = await createRoomInstance(env.DB, {
|
||||||
@@ -2933,6 +3097,146 @@ describe('auth-gated endpoints', () => {
|
|||||||
).toBe(401)
|
).toBe(401)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('POST /matchmake/v2/player/:id joins the inviter, invite row required', async () => {
|
||||||
|
// 8811 stands in an instance and invites 8812 (writing the room_invite row).
|
||||||
|
const instance = await createRoomInstance(env.DB, {
|
||||||
|
ownerAccountId: 8811,
|
||||||
|
roomId: 2,
|
||||||
|
subRoomId: 2,
|
||||||
|
photonRoomId: crypto.randomUUID(),
|
||||||
|
name: '^RecCenter',
|
||||||
|
maxCapacity: 12,
|
||||||
|
})
|
||||||
|
await setPresence(env.DB, {
|
||||||
|
accountId: 8811,
|
||||||
|
roomInstance: instance,
|
||||||
|
statusVisibility: 0,
|
||||||
|
deviceClass: 0,
|
||||||
|
vrMovementMode: 1,
|
||||||
|
platform: 0,
|
||||||
|
appVersion: GAME_VERSION,
|
||||||
|
})
|
||||||
|
|
||||||
|
const join = async (targetId: number, sub: string) =>
|
||||||
|
exports.default.fetch(`${ORIGIN}/matchmake/v2/player/${targetId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(await bearer(sub)),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: 'BypassMovementModeRestriction=False&LoginLock=40bacd8f-7c60-4d49-93f9-462b096602de&VoiceServerVersion=gameserver-2&CorrelationId=82c12c19-a3fc-4734-9abc-e912aeb1f351&MaxPersistenceVersion=227&PlayerIsPartyMember=False',
|
||||||
|
})
|
||||||
|
|
||||||
|
// No invite from the target yet → 40, and nothing about their state leaks.
|
||||||
|
expect(await (await join(8811, '8812')).json()).toMatchObject({
|
||||||
|
ErrorCode: 40,
|
||||||
|
RoomInstance: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
await exports.default.fetch(`${ORIGIN}/invite`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(await bearer('8811')),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: `playerId=8812&roomInstanceId=${instance.roomInstanceId}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// An invite from a DIFFERENT player doesn't authorize this target: 8813 holds no
|
||||||
|
// invite from 8811.
|
||||||
|
expect(await (await join(8811, '8813')).json()).toMatchObject({
|
||||||
|
ErrorCode: 40,
|
||||||
|
RoomInstance: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The invitee lands in the inviter's instance, in the PascalCase v2 envelope with
|
||||||
|
// the CorrelationId echoed back.
|
||||||
|
const ok = await join(8811, '8812')
|
||||||
|
expect(ok.status).toBe(200)
|
||||||
|
const okBody = (await ok.json()) as Record<string, unknown>
|
||||||
|
expect(okBody).toMatchObject({
|
||||||
|
ErrorCode: 0,
|
||||||
|
CorrelationId: '82c12c19-a3fc-4734-9abc-e912aeb1f351',
|
||||||
|
RoomInstance: {
|
||||||
|
RoomInstanceId: instance.roomInstanceId,
|
||||||
|
RoomId: 2,
|
||||||
|
Name: '^RecCenter',
|
||||||
|
MatchmakingPolicy: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// The exact wire shape, confirmed against the live client: the three-key envelope
|
||||||
|
// and the 15-key v2 instance, nothing extra (no Photon coordinates, no DataBlob).
|
||||||
|
expect(Object.keys(okBody).sort()).toEqual(['CorrelationId', 'ErrorCode', 'RoomInstance'])
|
||||||
|
expect(Object.keys(okBody.RoomInstance as object).sort()).toEqual(
|
||||||
|
[
|
||||||
|
'RoomInstanceId',
|
||||||
|
'RoomId',
|
||||||
|
'SubRoomId',
|
||||||
|
'Location',
|
||||||
|
'EventId',
|
||||||
|
'ClubId',
|
||||||
|
'RoomCode',
|
||||||
|
'Name',
|
||||||
|
'MaxCapacity',
|
||||||
|
'IsFull',
|
||||||
|
'IsPrivate',
|
||||||
|
'IsInProgress',
|
||||||
|
'EncryptVoiceChat',
|
||||||
|
'RoomInstanceType',
|
||||||
|
'MatchmakingPolicy',
|
||||||
|
].sort()
|
||||||
|
)
|
||||||
|
|
||||||
|
// The invite was spent by that join: the row is gone, so the same call now reads as
|
||||||
|
// "no invite" (40) rather than authorizing a second entry off the same invite.
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare(
|
||||||
|
'SELECT COUNT(*) AS n FROM room_invite WHERE from_player_id = 8811 AND to_player_id = 8812'
|
||||||
|
).first<{ n: number }>()
|
||||||
|
).toMatchObject({ n: 0 })
|
||||||
|
expect(await (await join(8811, '8812')).json()).toMatchObject({
|
||||||
|
ErrorCode: 40,
|
||||||
|
RoomInstance: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// With a fresh invite, standing there already is 17, not a second join — and a
|
||||||
|
// refusal leaves that invite standing, which the PlayerNotOnline case below redeems.
|
||||||
|
await exports.default.fetch(`${ORIGIN}/invite`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(await bearer('8811')),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: `playerId=8812&roomInstanceId=${instance.roomInstanceId}`,
|
||||||
|
})
|
||||||
|
expect(await (await join(8811, '8812')).json()).toMatchObject({
|
||||||
|
ErrorCode: 17,
|
||||||
|
RoomInstance: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The inviter walking out leaves nothing to join: 2, PlayerNotOnline. (The invitee
|
||||||
|
// is moved out first so the AlreadyIn check doesn't answer ahead of it.)
|
||||||
|
await setPresence(env.DB, {
|
||||||
|
accountId: 8812,
|
||||||
|
roomInstance: null,
|
||||||
|
statusVisibility: 0,
|
||||||
|
deviceClass: 0,
|
||||||
|
vrMovementMode: 1,
|
||||||
|
platform: 0,
|
||||||
|
appVersion: GAME_VERSION,
|
||||||
|
})
|
||||||
|
await env.DB.prepare('DELETE FROM presence WHERE account_id = 8811').run()
|
||||||
|
expect(await (await join(8811, '8812')).json()).toMatchObject({
|
||||||
|
ErrorCode: 2,
|
||||||
|
RoomInstance: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unauthenticated is a 401, not a refusal code.
|
||||||
|
expect(
|
||||||
|
(await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/8811`, { method: 'POST' })).status
|
||||||
|
).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /openapi.json documents every route', async () => {
|
test('GET /openapi.json documents every route', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -2973,6 +3277,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'POST /matchmake/player/{playerId}',
|
'POST /matchmake/player/{playerId}',
|
||||||
'POST /matchmake/room/{roomId}',
|
'POST /matchmake/room/{roomId}',
|
||||||
'POST /matchmake/room/{roomId}/{subRoomId}',
|
'POST /matchmake/room/{roomId}/{subRoomId}',
|
||||||
|
'POST /matchmake/v2/player/{playerId}',
|
||||||
'POST /matchmake/v2/room/{roomId}',
|
'POST /matchmake/v2/room/{roomId}',
|
||||||
'POST /matchmake/v2/room/{roomId}/{subRoomId}',
|
'POST /matchmake/v2/room/{roomId}/{subRoomId}',
|
||||||
'POST /player/exclusivelogin',
|
'POST /player/exclusivelogin',
|
||||||
@@ -3086,6 +3391,85 @@ describe('account bans', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A 2023 client (`rn.ver` 20230414) can't load a scene saved at persistence version 227 or
|
||||||
|
// later, so any room with such a subroom refuses it with UpdateRequired. Every other build
|
||||||
|
// gets in as usual.
|
||||||
|
describe('persistence version gate for the 2023 client', () => {
|
||||||
|
const matchmake = async (roomId: number, sub: string, version?: string) => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/${roomId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(await bearer(sub, version)),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({ JoinMode: '0' }).toString(),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
return (await res.json()) as {
|
||||||
|
errorCode: number
|
||||||
|
result: number
|
||||||
|
roomInstance: { roomId: number } | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Published scene at 227 — the first version the 2023 client can't load.
|
||||||
|
await seedRoomWithSubRooms(env.DB, {
|
||||||
|
RoomId: 7227,
|
||||||
|
Name: 'NewFormatRoom',
|
||||||
|
IsDorm: false,
|
||||||
|
Accessibility: 1,
|
||||||
|
CreatorAccountId: 7300,
|
||||||
|
SubRooms: [
|
||||||
|
{ SubRoomId: 7227, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 10 },
|
||||||
|
{
|
||||||
|
SubRoomId: 7228,
|
||||||
|
UnitySceneId: SECOND_SUBROOM_SCENE,
|
||||||
|
MaxPlayers: 10,
|
||||||
|
CurrentSave: { DataBlob: 'new.room', PersistenceVersion: 227 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as Record<string, unknown>)
|
||||||
|
// Published at 226 — still loadable.
|
||||||
|
await seedRoomWithSubRooms(env.DB, {
|
||||||
|
RoomId: 7226,
|
||||||
|
Name: 'OldFormatRoom',
|
||||||
|
IsDorm: false,
|
||||||
|
Accessibility: 1,
|
||||||
|
CreatorAccountId: 7300,
|
||||||
|
SubRooms: [
|
||||||
|
{
|
||||||
|
SubRoomId: 7226,
|
||||||
|
UnitySceneId: RECCENTER_SCENE,
|
||||||
|
MaxPlayers: 10,
|
||||||
|
CurrentSave: { DataBlob: 'old.room', PersistenceVersion: 226 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as Record<string, unknown>)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the 2023 build is refused a room with a subroom at 227+', async () => {
|
||||||
|
const res = await matchmake(7227, '7301', '20230414')
|
||||||
|
expect(res.errorCode).toBe(16) // UpdateRequired
|
||||||
|
expect(res.result).toBe(16)
|
||||||
|
expect(res.roomInstance).toBeNull()
|
||||||
|
|
||||||
|
// A point release of the same build is the same client.
|
||||||
|
expect((await matchmake(7227, '7302', '20230414.02')).errorCode).toBe(16)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the 2023 build still enters a room saved below 227', async () => {
|
||||||
|
const res = await matchmake(7226, '7303', '20230414')
|
||||||
|
expect(res.errorCode).toBe(0)
|
||||||
|
expect(res.roomInstance?.roomId).toBe(7226)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('newer builds and unversioned tokens are not gated', async () => {
|
||||||
|
expect((await matchmake(7227, '7304', '20250718.01')).errorCode).toBe(0)
|
||||||
|
expect((await matchmake(7227, '7305')).errorCode).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// The ban follows the player past the account it was written on: a new account sharing a
|
// The ban follows the player past the account it was written on: a new account sharing a
|
||||||
// proven platform identity or an IP with a banned one is refused the same way. See
|
// proven platform identity or an IP with a banned one is refused the same way. See
|
||||||
// bans-db.ts in the api worker for the arms and the BAN_EVASION_MATCH knob.
|
// bans-db.ts in the api worker for the arms and the BAN_EVASION_MATCH knob.
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
},
|
},
|
||||||
// The operator's knobs — the room substitutions (ROOM_REDIRECTS), the Photon app ids
|
// The operator's knobs — the room substitutions (ROOM_REDIRECTS), the Photon app ids
|
||||||
// and region (PHOTON_REALTIME_APP_ID, PHOTON_VOICE_APP_ID, PHOTON_CHAT_APP_ID,
|
// and region (PHOTON_REALTIME_APP_ID, PHOTON_VOICE_APP_ID, PHOTON_CHAT_APP_ID,
|
||||||
// PHOTON_REGION), and the Tachyon voice server (TACHYON_HOST_PORT, TACHYON_NAME) —
|
// PHOTON_REGION), and the Tachyon server pool (TACHYON_HOST_PORT) —
|
||||||
// are deliberately NOT set here. They're injected at deploy time from
|
// are deliberately NOT set here. They're injected at deploy time from
|
||||||
// the gitignored .env (RECFLARE_<VAR>, see .env.example), so swapping a room out or
|
// the gitignored .env (RECFLARE_<VAR>, see .env.example), so swapping a room out or
|
||||||
// pointing at your own Photon apps never means editing a versioned file. Unset — the
|
// pointing at your own Photon apps never means editing a versioned file. Unset — the
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"workers-tagged-logger": "1.0.1"
|
"workers-tagged-logger": "1.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -25,6 +25,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -31,6 +31,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"workers-tagged-logger": "1.0.1"
|
"workers-tagged-logger": "1.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -26,6 +26,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,22 @@ export interface HubState {
|
|||||||
pending: Array<{ playerId: number; count: number; latest: string }>
|
pending: Array<{ playerId: number; count: number; latest: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many queued notifications one offline player may accumulate. Past this the OLDEST
|
||||||
|
* are dropped, newest kept.
|
||||||
|
*
|
||||||
|
* The queue used to be unbounded, which is fine while it only holds what one absent player
|
||||||
|
* missed — and is not fine at all when delivery breaks (a reset leaves connection rows with
|
||||||
|
* no sockets, so live players' notifications queue too; see pruneDeadConnections). Then it
|
||||||
|
* grows with every notification the server sends and `flushPending` reads the lot into
|
||||||
|
* memory and writes it to a socket in a single event, which is its own way to take the
|
||||||
|
* object down.
|
||||||
|
*
|
||||||
|
* Newest-wins because these are notifications: a player returning to 500 of them is served
|
||||||
|
* no better by the 501st-oldest, and the recent ones are the ones still worth acting on.
|
||||||
|
*/
|
||||||
|
export const MAX_PENDING_PER_PLAYER = 500
|
||||||
|
|
||||||
/** The Coach system account — the `FromPlayerId` on a coach message (see coachMessageAll). */
|
/** The Coach system account — the `FromPlayerId` on a coach message (see coachMessageAll). */
|
||||||
const COACH_PLAYER_ID = 1
|
const COACH_PLAYER_ID = 1
|
||||||
|
|
||||||
@@ -108,7 +124,13 @@ const COACH_MESSAGE_TYPE = 100
|
|||||||
export class NotificationsHub extends DurableObject<Env> {
|
export class NotificationsHub extends DurableObject<Env> {
|
||||||
constructor(ctx: DurableObjectState, env: Env) {
|
constructor(ctx: DurableObjectState, env: Env) {
|
||||||
super(ctx, env)
|
super(ctx, env)
|
||||||
void ctx.blockConcurrencyWhile(async () => {
|
// Not floated: a rejection here is how a storage fault at wake-up surfaces
|
||||||
|
// ("Internal error in Durable Object storage caused object to be reset"), and an
|
||||||
|
// unhandled one tells us nothing about which object died or why. Catching doesn't
|
||||||
|
// prevent the reset — a throw inside blockConcurrencyWhile resets the object either
|
||||||
|
// way — it just leaves a trace when it happens.
|
||||||
|
ctx
|
||||||
|
.blockConcurrencyWhile(async () => {
|
||||||
this.ctx.storage.sql.exec(`
|
this.ctx.storage.sql.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
connectionId TEXT NOT NULL,
|
connectionId TEXT NOT NULL,
|
||||||
@@ -128,9 +150,64 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_owner_player ON connection_owner(playerId);
|
CREATE INDEX IF NOT EXISTS idx_owner_player ON connection_owner(playerId);
|
||||||
`)
|
`)
|
||||||
|
this.pruneDeadConnections()
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error('hub: storage init failed', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop connection rows with no socket behind them.
|
||||||
|
*
|
||||||
|
* A row is written when a socket is accepted and removed when it closes, so the two
|
||||||
|
* only diverge when sockets die without `webSocketClose` running — which is exactly
|
||||||
|
* what a Durable Object RESET does: the object is rebuilt, every socket is gone, and
|
||||||
|
* `connection_owner` / `subscriptions` still name all of them.
|
||||||
|
*
|
||||||
|
* Left alone those rows are worse than useless. `deliverToPlayer` finds connection ids
|
||||||
|
* for a player, sends to no sockets, and reports 0 delivered — so `notifyPlayer` QUEUES
|
||||||
|
* for players who are online and reconnected long ago, and `pending` grows on every
|
||||||
|
* notification the server sends. One reset then degrades delivery indefinitely.
|
||||||
|
*
|
||||||
|
* Safe at construction because hibernation does NOT lose sockets: `getWebSockets()`
|
||||||
|
* returns them on wake, so anything missing from it is genuinely gone. And a row whose
|
||||||
|
* socket has gone can never deliver anything anyway — dropping it costs nothing.
|
||||||
|
*/
|
||||||
|
private pruneDeadConnections(): void {
|
||||||
|
const live = new Set<string>()
|
||||||
|
for (const ws of this.ctx.getWebSockets()) {
|
||||||
|
const state = ws.deserializeAttachment() as SocketState | null
|
||||||
|
if (state) live.add(state.connectionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const known = this.ctx.storage.sql
|
||||||
|
.exec<{ connectionId: string }>(
|
||||||
|
`SELECT connectionId FROM connection_owner
|
||||||
|
UNION
|
||||||
|
SELECT connectionId FROM subscriptions`
|
||||||
|
)
|
||||||
|
.toArray()
|
||||||
|
.map((r) => r.connectionId)
|
||||||
|
|
||||||
|
const dead = known.filter((connectionId) => !live.has(connectionId))
|
||||||
|
if (dead.length === 0) return
|
||||||
|
|
||||||
|
for (const connectionId of dead) this.forgetConnection(connectionId)
|
||||||
|
console.warn('hub: pruned connections with no live socket', {
|
||||||
|
pruned: dead.length,
|
||||||
|
live: live.size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forget one connection: its ownership row and everything it subscribed to. */
|
||||||
|
private forgetConnection(connectionId: string): void {
|
||||||
|
this.ctx.storage.sql.exec('DELETE FROM subscriptions WHERE connectionId = ?', connectionId)
|
||||||
|
this.ctx.storage.sql.exec('DELETE FROM connection_owner WHERE connectionId = ?', connectionId)
|
||||||
|
}
|
||||||
|
|
||||||
/** WebSocket upgrade entrypoint — the worker forwards `/hub/v1` here. */
|
/** WebSocket upgrade entrypoint — the worker forwards `/hub/v1` here. */
|
||||||
override async fetch(request: Request): Promise<Response> {
|
override async fetch(request: Request): Promise<Response> {
|
||||||
if ((request.headers.get('Upgrade') ?? '').toLowerCase() !== 'websocket') {
|
if ((request.headers.get('Upgrade') ?? '').toLowerCase() !== 'websocket') {
|
||||||
@@ -198,14 +275,7 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
if (state) {
|
if (state) {
|
||||||
// Mirrors OnDisconnected: drop this connection's subscriptions, which
|
// Mirrors OnDisconnected: drop this connection's subscriptions, which
|
||||||
// also removes it from every player's connection set.
|
// also removes it from every player's connection set.
|
||||||
this.ctx.storage.sql.exec(
|
this.forgetConnection(state.connectionId)
|
||||||
'DELETE FROM subscriptions WHERE connectionId = ?',
|
|
||||||
state.connectionId
|
|
||||||
)
|
|
||||||
this.ctx.storage.sql.exec(
|
|
||||||
'DELETE FROM connection_owner WHERE connectionId = ?',
|
|
||||||
state.connectionId
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
ws.close()
|
ws.close()
|
||||||
@@ -384,16 +454,43 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (delivered === 0) {
|
if (delivered === 0) {
|
||||||
this.ctx.storage.sql.exec(
|
this.queuePending(playerId, payload)
|
||||||
'INSERT INTO pending (playerId, payload) VALUES (?, ?)',
|
|
||||||
playerId,
|
|
||||||
payload
|
|
||||||
)
|
|
||||||
return { delivered: 0, queued: true }
|
return { delivered: 0, queued: true }
|
||||||
}
|
}
|
||||||
return { delivered, queued: false }
|
return { delivered, queued: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a notification for a player who wasn't reachable, keeping the queue to
|
||||||
|
* {@link MAX_PENDING_PER_PLAYER}. Trimming is oldest-first and happens on the write, so
|
||||||
|
* the bound holds no matter how the queue got long.
|
||||||
|
*/
|
||||||
|
private queuePending(playerId: number, payload: string): void {
|
||||||
|
this.ctx.storage.sql.exec(
|
||||||
|
'INSERT INTO pending (playerId, payload) VALUES (?, ?)',
|
||||||
|
playerId,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
const trimmed = this.ctx.storage.sql
|
||||||
|
.exec(
|
||||||
|
`DELETE FROM pending
|
||||||
|
WHERE playerId = ?1 AND id NOT IN (
|
||||||
|
SELECT id FROM pending WHERE playerId = ?1 ORDER BY id DESC LIMIT ?2
|
||||||
|
)
|
||||||
|
RETURNING id`,
|
||||||
|
playerId,
|
||||||
|
MAX_PENDING_PER_PLAYER
|
||||||
|
)
|
||||||
|
.toArray().length
|
||||||
|
if (trimmed > 0) {
|
||||||
|
console.warn('hub: pending queue full, dropped oldest notifications', {
|
||||||
|
playerId,
|
||||||
|
dropped: trimmed,
|
||||||
|
cap: MAX_PENDING_PER_PLAYER,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a notification to a player's live sockets and, unlike {@link notifyPlayer},
|
* Send a notification to a player's live sockets and, unlike {@link notifyPlayer},
|
||||||
* NEVER queue it when they're offline — the ephemeral "SendWebsocket" send. For
|
* NEVER queue it when they're offline — the ephemeral "SendWebsocket" send. For
|
||||||
@@ -448,6 +545,28 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
return { sent: this.broadcastToConnected(payload) }
|
return { sent: this.broadcastToConnected(payload) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The targeted form of {@link coachMessageAll}: the same `MessageReceived` frame from
|
||||||
|
* the Coach account (player 1), addressed to one player with a `ToPlayerId` — the shape
|
||||||
|
* `api`'s player-to-player send uses.
|
||||||
|
*
|
||||||
|
* Unlike the broadcast this one QUEUES when the recipient is offline (see
|
||||||
|
* {@link notifyPlayer}). The broadcast is online-only because it has no recipient to
|
||||||
|
* hold anything for; a message written to a named player is worth keeping until they
|
||||||
|
* next connect.
|
||||||
|
*/
|
||||||
|
async coachMessage(
|
||||||
|
playerId: number,
|
||||||
|
content: string
|
||||||
|
): Promise<{ delivered: number; queued: boolean }> {
|
||||||
|
return this.notifyPlayer(playerId, NotificationType.MessageReceived, {
|
||||||
|
FromPlayerId: COACH_PLAYER_ID,
|
||||||
|
ToPlayerId: playerId,
|
||||||
|
Type: COACH_MESSAGE_TYPE,
|
||||||
|
Data: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Broadcast a notification to every connected (handshaken) client. */
|
/** Broadcast a notification to every connected (handshaken) client. */
|
||||||
async broadcast(
|
async broadcast(
|
||||||
notificationType: string | number,
|
notificationType: string | number,
|
||||||
@@ -558,7 +677,16 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
|
|
||||||
let delivered = 0
|
let delivered = 0
|
||||||
for (const connectionId of connectionIds) {
|
for (const connectionId of connectionIds) {
|
||||||
for (const ws of this.ctx.getWebSockets(connectionId)) {
|
const sockets = this.ctx.getWebSockets(connectionId)
|
||||||
|
// A connection id with no socket behind it is a row left over from a close we
|
||||||
|
// never saw — a reset, most likely (see pruneDeadConnections). Repaired the
|
||||||
|
// moment we trip over it, or every later send for this player queues instead of
|
||||||
|
// delivering, forever.
|
||||||
|
if (sockets.length === 0) {
|
||||||
|
this.forgetConnection(connectionId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const ws of sockets) {
|
||||||
ws.send(this.invocation('Notification', [payload]))
|
ws.send(this.invocation('Notification', [payload]))
|
||||||
delivered++
|
delivered++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,62 @@ import type { App } from './context'
|
|||||||
/** The hub state is global → one DO instance. */
|
/** The hub state is global → one DO instance. */
|
||||||
const HUB_INSTANCE = 'global'
|
const HUB_INSTANCE = 'global'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many times to re-issue a hub call Cloudflare aborted mid-flight.
|
||||||
|
*
|
||||||
|
* The platform occasionally resets a Durable Object under us — "Internal error in Durable
|
||||||
|
* Object storage caused object to be reset", carrying `retryable: true` and
|
||||||
|
* `durableObjectReset: true`. It is not a fault in the call: the object is rebuilt from its
|
||||||
|
* last durable state and the same call succeeds. Without a retry it surfaces as a 500 and
|
||||||
|
* the notification is simply lost, which is why these arrive periodically rather than
|
||||||
|
* predictably.
|
||||||
|
*
|
||||||
|
* Two attempts after the first is plenty — a reset that persists past that is an outage,
|
||||||
|
* not a blip, and the caller should hear about it.
|
||||||
|
*/
|
||||||
|
const HUB_RETRIES = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an error is one Cloudflare says to retry. `retryable` is set on the error the
|
||||||
|
* runtime throws; `durableObjectReset` accompanies the reset flavour of it. Anything else —
|
||||||
|
* a bug in a hub method, a bad argument — is thrown straight back, since retrying it would
|
||||||
|
* only produce the same failure more slowly.
|
||||||
|
*/
|
||||||
|
function isRetryableHubError(err: unknown): boolean {
|
||||||
|
if (typeof err !== 'object' || err === null) return false
|
||||||
|
const fields = err as { retryable?: unknown; durableObjectReset?: unknown }
|
||||||
|
return fields.retryable === true || fields.durableObjectReset === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call the hub, retrying a reset. A FRESH stub per attempt: the one that threw is bound to
|
||||||
|
* the object that just died.
|
||||||
|
*
|
||||||
|
* Safe to retry because a reset rolls the object back — the aborted call left nothing
|
||||||
|
* behind — and because every frame the hub sends is a complete, absolute statement (a
|
||||||
|
* notification, not a delta), so a duplicate is at worst a repeat and never a drift.
|
||||||
|
*/
|
||||||
|
async function hubCall<T>(
|
||||||
|
c: Context<App>,
|
||||||
|
call: (hub: ReturnType<App['Bindings']['RECFLARE_NOTIFICATIONS_HUB']['getByName']>) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: unknown
|
||||||
|
for (let attempt = 0; attempt <= HUB_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
return await call(c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE))
|
||||||
|
} catch (err) {
|
||||||
|
if (!isRetryableHubError(err)) throw err
|
||||||
|
lastError = err
|
||||||
|
logger.warn('hub call reset, retrying', {
|
||||||
|
attempt: attempt + 1,
|
||||||
|
of: HUB_RETRIES + 1,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A valid notification `Id` — a client-defined string tag (e.g. "AccountUpdate")
|
* A valid notification `Id` — a client-defined string tag (e.g. "AccountUpdate")
|
||||||
* or a numeric code. An empty string is treated as missing.
|
* or a numeric code. An empty string is treated as missing.
|
||||||
@@ -125,7 +181,9 @@ const app = new Hono<App>()
|
|||||||
// DO's proof of identity, so a client sending its own must not be believed.
|
// DO's proof of identity, so a client sending its own must not be believed.
|
||||||
request.headers.set(OWNER_HEADER, String(playerId))
|
request.headers.set(OWNER_HEADER, String(playerId))
|
||||||
|
|
||||||
return c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).fetch(request)
|
// The upgrade is retried too: it is a bodyless GET, so re-issuing it is free, and a
|
||||||
|
// reset here would otherwise fail the client's connect outright.
|
||||||
|
return hubCall(c, (hub) => hub.fetch(new Request(request)))
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Internal service-to-service send/broadcast --------------------------
|
// ---- Internal service-to-service send/broadcast --------------------------
|
||||||
@@ -144,11 +202,8 @@ const app = new Hono<App>()
|
|||||||
if (!body || typeof body.playerId !== 'number' || !isNotificationType(body.notificationType)) {
|
if (!body || typeof body.playerId !== 'number' || !isNotificationType(body.notificationType)) {
|
||||||
return c.json({ error: 'playerId and notificationType are required' }, 400)
|
return c.json({ error: 'playerId and notificationType are required' }, 400)
|
||||||
}
|
}
|
||||||
const result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
const { playerId, notificationType, data } = body
|
||||||
body.playerId,
|
const result = await hubCall(c, (hub) => hub.notifyPlayer(playerId, notificationType, data))
|
||||||
body.notificationType,
|
|
||||||
body.data
|
|
||||||
)
|
|
||||||
return c.json({ success: true, ...result })
|
return c.json({ success: true, ...result })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -159,10 +214,8 @@ const app = new Hono<App>()
|
|||||||
if (!body || !isNotificationType(body.notificationType)) {
|
if (!body || !isNotificationType(body.notificationType)) {
|
||||||
return c.json({ error: 'notificationType is required' }, 400)
|
return c.json({ error: 'notificationType is required' }, 400)
|
||||||
}
|
}
|
||||||
const result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).broadcast(
|
const { notificationType, data } = body
|
||||||
body.notificationType,
|
const result = await hubCall(c, (hub) => hub.broadcast(notificationType, data))
|
||||||
body.data
|
|
||||||
)
|
|
||||||
return c.json({ success: true, ...result })
|
return c.json({ success: true, ...result })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -171,8 +224,24 @@ const app = new Hono<App>()
|
|||||||
const body = await c.req.json<{ messageContent?: string }>().catch(() => null)
|
const body = await c.req.json<{ messageContent?: string }>().catch(() => null)
|
||||||
const content = typeof body?.messageContent === 'string' ? body.messageContent.trim() : ''
|
const content = typeof body?.messageContent === 'string' ? body.messageContent.trim() : ''
|
||||||
if (content === '') return c.json({ error: 'messageContent is required' }, 400)
|
if (content === '') return c.json({ error: 'messageContent is required' }, 400)
|
||||||
const result =
|
const result = await hubCall(c, (hub) => hub.coachMessageAll(content))
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).coachMessageAll(content)
|
return c.json({ success: true, ...result })
|
||||||
|
})
|
||||||
|
|
||||||
|
// The targeted form of the broadcast above: one coach message to ONE player. Queued
|
||||||
|
// by the hub when they're offline (unlike coach-message-all, which reaches only
|
||||||
|
// whoever is connected), so this arrives either way.
|
||||||
|
.post('/internal/coach-message', async (c) => {
|
||||||
|
const body = await c.req
|
||||||
|
.json<{ playerId?: number; messageContent?: string }>()
|
||||||
|
.catch(() => null)
|
||||||
|
const content = typeof body?.messageContent === 'string' ? body.messageContent.trim() : ''
|
||||||
|
if (typeof body?.playerId !== 'number' || !Number.isInteger(body.playerId)) {
|
||||||
|
return c.json({ error: 'playerId is required' }, 400)
|
||||||
|
}
|
||||||
|
if (content === '') return c.json({ error: 'messageContent is required' }, 400)
|
||||||
|
const { playerId } = body
|
||||||
|
const result = await hubCall(c, (hub) => hub.coachMessage(playerId, content))
|
||||||
return c.json({ success: true, ...result })
|
return c.json({ success: true, ...result })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -180,7 +249,7 @@ const app = new Hono<App>()
|
|||||||
// didn't arrive: which connections are live, which players each one receives for,
|
// didn't arrive: which connections are live, which players each one receives for,
|
||||||
// and what's queued for a player who wasn't reachable.
|
// and what's queued for a player who wasn't reachable.
|
||||||
.get('/internal/hub-state', async (c) => {
|
.get('/internal/hub-state', async (c) => {
|
||||||
return c.json(await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).inspect())
|
return c.json(await hubCall(c, (hub) => hub.inspect()))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Discard queued notifications, for `?playerId=` or — with the explicit `?all=true`,
|
// Discard queued notifications, for `?playerId=` or — with the explicit `?all=true`,
|
||||||
@@ -196,8 +265,7 @@ const app = new Hono<App>()
|
|||||||
return c.json({ error: 'pass playerId, or all=true to clear every queue' }, 400)
|
return c.json({ error: 'pass playerId, or all=true to clear every queue' }, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result =
|
const result = await hubCall(c, (hub) => hub.clearPending(playerId))
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).clearPending(playerId)
|
|
||||||
logger.info('cleared pending notifications', { playerId: playerId ?? null, ...result })
|
logger.info('cleared pending notifications', { playerId: playerId ?? null, ...result })
|
||||||
return c.json({ success: true, ...result })
|
return c.json({ success: true, ...result })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { adminSecretsStore, env } from 'cloudflare:test'
|
import { adminSecretsStore, env, runInDurableObject } from 'cloudflare:test'
|
||||||
import { exports } from 'cloudflare:workers'
|
import { exports } from 'cloudflare:workers'
|
||||||
import { beforeAll, describe, expect, test } from 'vitest'
|
import { beforeAll, describe, expect, test } from 'vitest'
|
||||||
|
|
||||||
import '../../notify.app'
|
import '../../notify.app'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
|
import { MAX_PENDING_PER_PLAYER } from '../../notifications-hub'
|
||||||
|
|
||||||
import type { HubState } from '../../notifications-hub'
|
import type { HubState } from '../../notifications-hub'
|
||||||
|
|
||||||
declare module 'cloudflare:test' {
|
declare module 'cloudflare:test' {
|
||||||
@@ -65,7 +67,12 @@ interface HubRecord {
|
|||||||
async function connect(
|
async function connect(
|
||||||
id: string,
|
id: string,
|
||||||
opts: { headers?: Record<string, string>; query?: string } = {}
|
opts: { headers?: Record<string, string>; query?: string } = {}
|
||||||
): Promise<{ ws: WebSocket; waitFor: (pred: (r: HubRecord) => boolean) => Promise<HubRecord> }> {
|
): Promise<{
|
||||||
|
ws: WebSocket
|
||||||
|
waitFor: (pred: (r: HubRecord) => boolean) => Promise<HubRecord>
|
||||||
|
/** Everything this socket has received so far — for asserting something did NOT arrive. */
|
||||||
|
records: HubRecord[]
|
||||||
|
}> {
|
||||||
// The hub only accepts identified connections, so default to a token; pass explicit
|
// The hub only accepts identified connections, so default to a token; pass explicit
|
||||||
// `headers` (`{}` for none) where the test is about who is connecting.
|
// `headers` (`{}` for none) where the test is about who is connecting.
|
||||||
const auth = opts.headers ?? (await bearer(String(DEFAULT_CONNECT_PLAYER), ['gameClient']))
|
const auth = opts.headers ?? (await bearer(String(DEFAULT_CONNECT_PLAYER), ['gameClient']))
|
||||||
@@ -113,7 +120,7 @@ async function connect(
|
|||||||
ws.send(`{"protocol":"json","version":1}${RS}`)
|
ws.send(`{"protocol":"json","version":1}${RS}`)
|
||||||
await waitFor((r) => r.type === 1 && r.target === 'OnConnect')
|
await waitFor((r) => r.type === 1 && r.target === 'OnConnect')
|
||||||
|
|
||||||
return { ws, waitFor }
|
return { ws, waitFor, records }
|
||||||
}
|
}
|
||||||
|
|
||||||
const send = (ws: WebSocket, msg: HubRecord) => ws.send(JSON.stringify(msg) + RS)
|
const send = (ws: WebSocket, msg: HubRecord) => ws.send(JSON.stringify(msg) + RS)
|
||||||
@@ -337,6 +344,72 @@ describe('notification delivery', () => {
|
|||||||
b.ws.close()
|
b.ws.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('coach-message reaches only the named player, addressed to them', async () => {
|
||||||
|
const playerId = 9010
|
||||||
|
const target = await connect('coach-one', {
|
||||||
|
headers: await bearer(String(playerId), ['gameClient']),
|
||||||
|
})
|
||||||
|
const bystander = await connect('coach-one-bystander')
|
||||||
|
send(target.ws, {
|
||||||
|
type: 1,
|
||||||
|
invocationId: 's',
|
||||||
|
target: 'SubscribeToPlayers',
|
||||||
|
arguments: [{ playerIds: [playerId] }],
|
||||||
|
})
|
||||||
|
await target.waitFor((r) => r.type === 3 && r.invocationId === 's')
|
||||||
|
|
||||||
|
const res = await post('/internal/coach-message', { playerId, messageContent: 'just you' })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toMatchObject({ delivered: 1, queued: false })
|
||||||
|
|
||||||
|
const note = await target.waitFor((r) => r.type === 1 && r.target === 'Notification')
|
||||||
|
const payload = JSON.parse((note.arguments as string[])[0]) as {
|
||||||
|
Id: string
|
||||||
|
Msg: Record<string, unknown>
|
||||||
|
}
|
||||||
|
expect(payload.Id).toBe('2') // MessageReceived
|
||||||
|
// Same Coach frame as the broadcast, plus the recipient the broadcast can't name.
|
||||||
|
expect(payload.Msg).toMatchObject({
|
||||||
|
FromPlayerId: 1,
|
||||||
|
ToPlayerId: playerId,
|
||||||
|
Type: 100,
|
||||||
|
Data: 'just you',
|
||||||
|
})
|
||||||
|
|
||||||
|
// The point of the targeted send: nobody else's socket sees it. The bystander is
|
||||||
|
// subscribed to nothing, and a broadcast would have reached it regardless.
|
||||||
|
expect(bystander.records.some((r) => r.target === 'Notification')).toBe(false)
|
||||||
|
|
||||||
|
target.ws.close()
|
||||||
|
bystander.ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('coach-message queues for a player who is offline', async () => {
|
||||||
|
const playerId = 9011
|
||||||
|
const res = await post('/internal/coach-message', {
|
||||||
|
playerId,
|
||||||
|
messageContent: 'catch you later',
|
||||||
|
})
|
||||||
|
expect(await res.json()).toMatchObject({ delivered: 0, queued: true })
|
||||||
|
|
||||||
|
// Unlike the broadcast, it survives until they connect.
|
||||||
|
const { ws, waitFor } = await connect('coach-one-late')
|
||||||
|
send(ws, { type: 1, target: 'SubscribeToPlayers', arguments: [{ playerIds: [playerId] }] })
|
||||||
|
const note = await waitFor((r) => r.type === 1 && r.target === 'Notification')
|
||||||
|
expect(
|
||||||
|
(JSON.parse((note.arguments as string[])[0]) as { Msg: Record<string, unknown> }).Msg
|
||||||
|
).toMatchObject({ FromPlayerId: 1, ToPlayerId: playerId, Data: 'catch you later' })
|
||||||
|
ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('coach-message 400s without a player or a message', async () => {
|
||||||
|
expect((await post('/internal/coach-message', { messageContent: 'nobody' })).status).toBe(400)
|
||||||
|
expect((await post('/internal/coach-message', { playerId: 9012 })).status).toBe(400)
|
||||||
|
expect(
|
||||||
|
(await post('/internal/coach-message', { playerId: 9012, messageContent: ' ' })).status
|
||||||
|
).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
test('coach-message-all 400s on an empty message', async () => {
|
test('coach-message-all 400s on an empty message', async () => {
|
||||||
const res = await post('/internal/coach-message-all', { messageContent: ' ' })
|
const res = await post('/internal/coach-message-all', { messageContent: ' ' })
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
@@ -582,6 +655,70 @@ describe('clearing pending notifications', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// What a Durable Object RESET leaves behind, and how the hub recovers from it. A reset
|
||||||
|
// kills every socket without running webSocketClose, so the connection rows outlive the
|
||||||
|
// sockets they name — and a stale row makes deliverToPlayer report 0 delivered, which
|
||||||
|
// queues notifications for players who are online.
|
||||||
|
describe('recovering from lost sockets', () => {
|
||||||
|
const hubState = async (): Promise<HubState> =>
|
||||||
|
(await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/internal/hub-state`, {
|
||||||
|
headers: await bearer('1', ['gameClient', 'moderator']),
|
||||||
|
})
|
||||||
|
).json()) as HubState
|
||||||
|
|
||||||
|
test('forgets a connection whose socket is gone, instead of queueing to it forever', async () => {
|
||||||
|
const playerId = 9301
|
||||||
|
const { ws } = await connect('conn-reset', {
|
||||||
|
headers: await bearer(String(playerId), ['gameClient']),
|
||||||
|
})
|
||||||
|
// Kill the socket the way a reset does — no close frame, so the DO never runs
|
||||||
|
// webSocketClose and the row survives.
|
||||||
|
await ws.close()
|
||||||
|
|
||||||
|
// First send after the socket died: nothing to deliver to, so it queues...
|
||||||
|
const first = await post('/internal/notify', { playerId, notificationType: 40, data: {} })
|
||||||
|
expect(await first.json()).toMatchObject({ queued: true, delivered: 0 })
|
||||||
|
|
||||||
|
// ...and the dead row is dropped on the way, so the player no longer looks
|
||||||
|
// connected to anything.
|
||||||
|
const state = await hubState()
|
||||||
|
expect(state.connections.find((c) => c.connectionId === 'conn-reset')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pending queue bound', () => {
|
||||||
|
test('keeps the newest notifications and drops the oldest past the cap', async () => {
|
||||||
|
// Unbounded, this is what a broken delivery path fills up — and what flushPending
|
||||||
|
// then reads into memory in one go. Seeded straight into the object: the point is
|
||||||
|
// the bound, not the 500 HTTP round trips it would take to reach it.
|
||||||
|
const playerId = 9302
|
||||||
|
const stub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||||
|
await runInDurableObject(stub, (_instance, state) => {
|
||||||
|
for (let i = 0; i < MAX_PENDING_PER_PLAYER + 100; i++) {
|
||||||
|
state.storage.sql.exec(
|
||||||
|
'INSERT INTO pending (playerId, payload) VALUES (?, ?)',
|
||||||
|
playerId,
|
||||||
|
JSON.stringify({ Id: '90', Msg: { i } })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// The next queued notification is what enforces the bound.
|
||||||
|
await post('/internal/notify', { playerId, notificationType: 90, data: { last: true } })
|
||||||
|
|
||||||
|
const state = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/internal/hub-state`, {
|
||||||
|
headers: await bearer('1', ['gameClient', 'moderator']),
|
||||||
|
})
|
||||||
|
).json()) as HubState
|
||||||
|
const queued = state.pending.find((p) => p.playerId === playerId)
|
||||||
|
expect(queued?.count).toBe(MAX_PENDING_PER_PLAYER)
|
||||||
|
// Newest-wins: the one just sent survived, the oldest hundred did not.
|
||||||
|
expect(JSON.parse(queued!.latest)).toEqual({ Id: '90', Msg: { last: true } })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// The website's admin controls (maintenance countdown, coach broadcast) are a browser
|
// The website's admin controls (maintenance countdown, coach broadcast) are a browser
|
||||||
// calling `/internal/*` directly rather than through a `www` proxy, so these need CORS.
|
// calling `/internal/*` directly rather than through a `www` proxy, so these need CORS.
|
||||||
describe('CORS', () => {
|
describe('CORS', () => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"workers-tagged-logger": "1.0.1"
|
"workers-tagged-logger": "1.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -25,6 +25,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"workers-tagged-logger": "1.0.1"
|
"workers-tagged-logger": "1.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -26,6 +26,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,18 @@ Player-settings worker served on the `playersettings` subdomain.
|
|||||||
`key=…&value=…` (or a JSON `{key,value}` / array) and **upserts** it into the
|
`key=…&value=…` (or a JSON `{key,value}` / array) and **upserts** it into the
|
||||||
player's settings, keyed by the `sub` claim of the Bearer JWT. Returns `200`.
|
player's settings, keyed by the `sub` claim of the Bearer JWT. Returns `200`.
|
||||||
Persisted in Workers KV (`RECFLARE_PLAYER_SETTINGS`, key `player:<id>`).
|
Persisted in Workers KV (`RECFLARE_PLAYER_SETTINGS`, key `player:<id>`).
|
||||||
|
- `DELETE /playersettings` — `[Authorize]`. Removes a setting from the player's
|
||||||
|
map. The client sends a bare form-urlencoded `key=PlayerShoppingBagId` (no
|
||||||
|
`value`); a JSON body and a `?key=` query param are also read. Deleting a key
|
||||||
|
that isn't stored is a no-op `200`, not a `404`.
|
||||||
|
|
||||||
> A full settings PUT would replace the player's _entire_ settings set on each
|
> A full settings PUT would replace the player's _entire_ settings set on each
|
||||||
> call; we merge instead, so a single-key PUT (e.g. `key=PlayerSessionCount`)
|
> call; we merge instead, so a single-key PUT (e.g. `key=PlayerSessionCount`)
|
||||||
> doesn't wipe the others.
|
> doesn't wipe the others.
|
||||||
|
|
||||||
|
> Emptying the map with DELETE puts the player back to a first read: the next
|
||||||
|
> `GET` re-seeds the defaults.
|
||||||
|
|
||||||
## KV namespace
|
## KV namespace
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -31,6 +31,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,3 +103,23 @@ export const SettingJsonWrite = z.union([
|
|||||||
})
|
})
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form-encoded delete the client actually sends: a bare `key=PlayerShoppingBagId`,
|
||||||
|
* with no `value`. An empty `key` is ignored.
|
||||||
|
*/
|
||||||
|
export const SettingFormDelete = z.object({
|
||||||
|
key: z.string().describe('The setting name to remove; an empty key is ignored'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JSON form of the same delete. Accepted as a bare setting name, a `{ key }` /
|
||||||
|
* `{ Key }` object, or an array of either.
|
||||||
|
*/
|
||||||
|
export const SettingJsonDelete = z.union([
|
||||||
|
z.string(),
|
||||||
|
z.object({ key: z.string().optional(), Key: z.string().optional() }),
|
||||||
|
z.array(
|
||||||
|
z.union([z.string(), z.object({ key: z.string().optional(), Key: z.string().optional() })])
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
HealthResponse,
|
HealthResponse,
|
||||||
json,
|
json,
|
||||||
PlayerSettingEntry,
|
PlayerSettingEntry,
|
||||||
|
SettingFormDelete,
|
||||||
SettingFormWrite,
|
SettingFormWrite,
|
||||||
|
SettingJsonDelete,
|
||||||
SettingJsonWrite,
|
SettingJsonWrite,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
} from './openapi'
|
} from './openapi'
|
||||||
@@ -26,7 +28,7 @@ import type { App } from './context'
|
|||||||
* (`player:{id}`); a player with nothing stored is seeded with the reference defaults on
|
* (`player:{id}`); a player with nothing stored is seeded with the reference defaults on
|
||||||
* their first read.
|
* their first read.
|
||||||
*
|
*
|
||||||
* Both routes are auth-gated on the Bearer JWT issued by the `auth` worker.
|
* Every `/playersettings` route is auth-gated on the Bearer JWT issued by the `auth` worker.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,6 +80,45 @@ async function parseSettings(c: Context<App>): Promise<Array<{ key: string; valu
|
|||||||
return key ? [{ key, value }] : []
|
return key ? [{ key, value }] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the setting name(s) to remove out of a DELETE body. The client sends a bare
|
||||||
|
* form-urlencoded `key=PlayerShoppingBagId` — with no `value`, and (unlike its PUTs) not
|
||||||
|
* always a `content-type` Hono's body parser recognises on a DELETE, so an unparsed body
|
||||||
|
* is re-read as raw text. A JSON body is accepted too, as a bare string, a `{ key }`
|
||||||
|
* object, or an array of either. Blank names are dropped.
|
||||||
|
*/
|
||||||
|
async function parseDeleteKeys(c: Context<App>): Promise<string[]> {
|
||||||
|
const contentType = c.req.header('content-type') ?? ''
|
||||||
|
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
const body = await c.req.json<unknown>().catch(() => null)
|
||||||
|
const list = Array.isArray(body) ? body : body == null ? [] : [body]
|
||||||
|
return list
|
||||||
|
.map((o) => {
|
||||||
|
if (typeof o === 'string') return o
|
||||||
|
const rec = o as Record<string, unknown>
|
||||||
|
const key = rec.key ?? rec.Key
|
||||||
|
return typeof key === 'string' ? key : ''
|
||||||
|
})
|
||||||
|
.filter((k) => k !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick ONE read of the body from the content-type: Hono's parser only recognises the
|
||||||
|
// form types, and re-reading as text after it has cached a FormData re-serialises the
|
||||||
|
// body as multipart, so trying both in turn parses garbage.
|
||||||
|
let key = ''
|
||||||
|
if (contentType.includes('form-data') || contentType.includes('x-www-form-urlencoded')) {
|
||||||
|
const form = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||||
|
if (typeof form.key === 'string') key = form.key
|
||||||
|
} else {
|
||||||
|
key = new URLSearchParams(await c.req.text().catch(() => '')).get('key') ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort, for a client that hangs the name off the URL instead.
|
||||||
|
if (key === '') key = c.req.query('key') ?? ''
|
||||||
|
return key ? [key] : []
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Hono<App>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -180,6 +221,59 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Remove a setting from the caller's map. The client sends `key=PlayerShoppingBagId`
|
||||||
|
// when it drops a value it no longer wants defaulted (a stale shopping bag id, say)
|
||||||
|
// rather than writing an empty string over it.
|
||||||
|
.delete(
|
||||||
|
'/playersettings',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Player Settings'],
|
||||||
|
summary: 'Delete a player setting',
|
||||||
|
description: [
|
||||||
|
'Removes the named setting(s) from the caller’s KV map. The client sends a bare',
|
||||||
|
'form-urlencoded `key=PlayerShoppingBagId` (no `value`); a JSON body — a string, a',
|
||||||
|
'`{ key }` object, or an array of either — and a `?key=` query param are also read.',
|
||||||
|
'Deleting a key that isn’t stored, or sending nothing to delete, is a no-op 200, not a',
|
||||||
|
'404. Empty body on success.',
|
||||||
|
'',
|
||||||
|
'Note that emptying the map entirely puts the player back to a first read: the next',
|
||||||
|
'`GET` re-seeds the defaults.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
requestBody: formOrJson(SettingFormDelete, SettingJsonDelete, 'The setting(s) to remove'),
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Removed, or nothing to remove (empty body)' },
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const keys = await parseDeleteKeys(c)
|
||||||
|
if (keys.length === 0) return c.body(null, 200)
|
||||||
|
|
||||||
|
const kvKey = `player:${id}`
|
||||||
|
const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||||
|
kvKey,
|
||||||
|
'json'
|
||||||
|
)
|
||||||
|
if (!existing) return c.body(null, 200)
|
||||||
|
|
||||||
|
const remaining = { ...existing }
|
||||||
|
let removed = false
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key in remaining) {
|
||||||
|
delete remaining[key]
|
||||||
|
removed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removed) await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(remaining))
|
||||||
|
|
||||||
|
return c.body(null, 200)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// The generated spec. Documentation only — no request is validated against it (see
|
// The generated spec. Documentation only — no request is validated against it (see
|
||||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||||
app.get(
|
app.get(
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ function putForm(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteForm(
|
||||||
|
fields: Record<string, string>,
|
||||||
|
headers: Record<string, string> = {}
|
||||||
|
): RequestInit {
|
||||||
|
return {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
|
||||||
|
body: new URLSearchParams(fields).toString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('playersettings endpoints', () => {
|
describe('playersettings endpoints', () => {
|
||||||
it('GET / reports service status', async () => {
|
it('GET / reports service status', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/`)
|
const res = await SELF.fetch(`${ORIGIN}/`)
|
||||||
@@ -134,6 +145,80 @@ describe('playersettings endpoints', () => {
|
|||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('DELETE /playersettings 401s without a token', async () => {
|
||||||
|
const res = await SELF.fetch(
|
||||||
|
`${ORIGIN}/playersettings`,
|
||||||
|
deleteForm({ key: 'PlayerShoppingBagId' })
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DELETE /playersettings removes the named key and leaves the rest', async () => {
|
||||||
|
await SELF.fetch(
|
||||||
|
`${ORIGIN}/playersettings`,
|
||||||
|
putForm({ key: 'PlayerShoppingBagId', value: 'bag-1' }, await bearer('20'))
|
||||||
|
)
|
||||||
|
await SELF.fetch(
|
||||||
|
`${ORIGIN}/playersettings`,
|
||||||
|
putForm({ key: 'PlayerSessionCount', value: '3' }, await bearer('20'))
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await SELF.fetch(
|
||||||
|
`${ORIGIN}/playersettings`,
|
||||||
|
deleteForm({ key: 'PlayerShoppingBagId' }, await bearer('20'))
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
|
||||||
|
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||||
|
'player:20',
|
||||||
|
'json'
|
||||||
|
)
|
||||||
|
expect(stored).toEqual({ PlayerSessionCount: '3' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DELETE /playersettings reads a body with no content-type', async () => {
|
||||||
|
await SELF.fetch(
|
||||||
|
`${ORIGIN}/playersettings`,
|
||||||
|
putForm({ key: 'PlayerShoppingBagId', value: 'bag-2' }, await bearer('21'))
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/playersettings`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: await bearer('21'),
|
||||||
|
body: 'key=PlayerShoppingBagId',
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
|
||||||
|
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||||
|
'player:21',
|
||||||
|
'json'
|
||||||
|
)
|
||||||
|
expect(stored).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DELETE /playersettings 200s for an unknown key and an empty body', async () => {
|
||||||
|
await SELF.fetch(
|
||||||
|
`${ORIGIN}/playersettings`,
|
||||||
|
putForm({ key: 'A', value: '1' }, await bearer('22'))
|
||||||
|
)
|
||||||
|
|
||||||
|
const unknown = await SELF.fetch(
|
||||||
|
`${ORIGIN}/playersettings`,
|
||||||
|
deleteForm({ key: 'NotStored' }, await bearer('22'))
|
||||||
|
)
|
||||||
|
expect(unknown.status).toBe(200)
|
||||||
|
|
||||||
|
const empty = await SELF.fetch(`${ORIGIN}/playersettings`, deleteForm({}, await bearer('22')))
|
||||||
|
expect(empty.status).toBe(200)
|
||||||
|
|
||||||
|
// Neither call touched the stored map.
|
||||||
|
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||||
|
'player:22',
|
||||||
|
'json'
|
||||||
|
)
|
||||||
|
expect(stored).toEqual({ A: '1' })
|
||||||
|
})
|
||||||
|
|
||||||
it('GET /openapi.json documents every route', async () => {
|
it('GET /openapi.json documents every route', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
|
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -154,7 +239,12 @@ describe('playersettings endpoints', () => {
|
|||||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
expect([...documented].sort()).toEqual(['GET /', 'GET /playersettings', 'PUT /playersettings'])
|
expect([...documented].sort()).toEqual([
|
||||||
|
'DELETE /playersettings',
|
||||||
|
'GET /',
|
||||||
|
'GET /playersettings',
|
||||||
|
'PUT /playersettings',
|
||||||
|
])
|
||||||
|
|
||||||
// Every operation carries a summary — a path present but undescribed is not
|
// Every operation carries a summary — a path present but undescribed is not
|
||||||
// documentation.
|
// documentation.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -33,6 +33,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Per-room leaderboard definitions. Generated from packages/domain/src/rooms-db.ts
|
||||||
|
-- (ROOM_SCHEMA_DDL) — keep in sync.
|
||||||
|
--
|
||||||
|
-- One row per (room, leaderboard). `leaderboard_id` is the client's slot number — small
|
||||||
|
-- ordinals (1, 2, 3…), unique only within the room, which is why the pair is the primary
|
||||||
|
-- key rather than the id alone. Re-posting a slot (POST /rooms/:id/leaderboards/:lid)
|
||||||
|
-- reconfigures it in place; DELETE on the same path removes it.
|
||||||
|
--
|
||||||
|
-- `sort_ascending` stores the client's `sortAscending=True/False` as 1/0; `stat_format`
|
||||||
|
-- is the client's `statFormat` int, echoed back as stored.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_leaderboard (
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
leaderboard_id INTEGER NOT NULL,
|
||||||
|
leaderboard_title TEXT NOT NULL,
|
||||||
|
stat_format INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sort_ascending INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (room_id, leaderboard_id)
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Retire `FriendlyName` from the room blob.
|
||||||
|
--
|
||||||
|
-- The rename route (PUT /rooms/:id/name) briefly wrote `FriendlyName` alongside `Name`,
|
||||||
|
-- and every read defaulted it to `Name`. Neither exists any more: this server serves no
|
||||||
|
-- display name apart from the unique `Name`, and a stored value would otherwise survive in
|
||||||
|
-- the blob forever (`json_set` on rename is gone, so nothing would ever update it again).
|
||||||
|
--
|
||||||
|
-- Strip the key from every room that carries one. `json_remove` on a blob without the key
|
||||||
|
-- is a no-op, so the WHERE only spares the rows that need no rewrite.
|
||||||
|
UPDATE room
|
||||||
|
SET data = json_remove(data, '$.FriendlyName')
|
||||||
|
WHERE json_type(data, '$.FriendlyName') IS NOT NULL;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Room roles as their own table, mirroring 0013's move of tags into `room_tag`. One row
|
||||||
|
-- per (room, account). Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) —
|
||||||
|
-- keep in sync.
|
||||||
|
--
|
||||||
|
-- The row is the client's `Roles` entry shape:
|
||||||
|
-- { "AccountId": …, "Role": …, "LastChangedByAccountId": …, "InvitedRole": … }
|
||||||
|
-- `role` is the member's CURRENT role tier (10 Host, 20 Moderator, 30 CoOwner,
|
||||||
|
-- 255 Creator); `invited_role` is the tier they have been OFFERED but not yet accepted —
|
||||||
|
-- usually higher than `role`, and 0 when no invitation is pending. `last_changed_by` is
|
||||||
|
-- who last touched the row; NULL for creator entries, matching the blob's
|
||||||
|
-- `LastChangedByAccountId: null`.
|
||||||
|
--
|
||||||
|
-- The table is AUTHORITATIVE and the blob's `Roles` key is removed below, the same
|
||||||
|
-- arrangement 0013 gave tags: `serializeRoom` drops `Roles` on write and the reads
|
||||||
|
-- re-attach it (attachRoles), so the room DTO the client sees is unchanged and the two
|
||||||
|
-- copies can't drift. `getContributedRooms` now matches on this table instead of running
|
||||||
|
-- `json_each` over every blob.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_role (
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
role INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_changed_by INTEGER,
|
||||||
|
invited_role INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (room_id, account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Backfill from the blobs, the way 0013 backfilled `room_tag`. `json_each` walks the
|
||||||
|
-- `Roles` array; a room with no array (or a null one) contributes nothing, which is why
|
||||||
|
-- this is a join rather than a correlated subquery. `INSERT OR IGNORE` collapses a blob
|
||||||
|
-- that somehow carries the same account twice — the primary key is (room, account).
|
||||||
|
INSERT OR IGNORE INTO room_role (room_id, account_id, role, last_changed_by, invited_role)
|
||||||
|
SELECT
|
||||||
|
r.room_id,
|
||||||
|
json_extract(t.value, '$.AccountId'),
|
||||||
|
COALESCE(json_extract(t.value, '$.Role'), 0),
|
||||||
|
json_extract(t.value, '$.LastChangedByAccountId'),
|
||||||
|
COALESCE(json_extract(t.value, '$.InvitedRole'), 0)
|
||||||
|
FROM room r, json_each(r.data, '$.Roles') t
|
||||||
|
WHERE json_extract(t.value, '$.AccountId') IS NOT NULL;
|
||||||
|
|
||||||
|
-- Single source of truth: the roles now live in `room_role`, so the copy in the blob
|
||||||
|
-- goes. Leaving it would be a second answer to "who has a role in this room" that only
|
||||||
|
-- the writes through setRoomRole keep current.
|
||||||
|
UPDATE room SET data = json_remove(data, '$.Roles');
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -33,6 +33,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ export const playerIdParam = idParam('playerId', 'The account whose list to read
|
|||||||
/** The `:playerId` path parameter on the unban route. */
|
/** The `:playerId` path parameter on the unban route. */
|
||||||
export const bannedPlayerIdParam = idParam('playerId', 'The banned account to unban')
|
export const bannedPlayerIdParam = idParam('playerId', 'The banned account to unban')
|
||||||
|
|
||||||
|
/** The `:leaderboardId` path parameter on the room leaderboard routes. */
|
||||||
|
export const leaderboardIdParam = idParam(
|
||||||
|
'leaderboardId',
|
||||||
|
'The leaderboard slot within the room — small ordinals (1, 2, 3…), unique per room only'
|
||||||
|
)
|
||||||
|
|
||||||
/** An optional string query parameter. */
|
/** An optional string query parameter. */
|
||||||
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||||
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
|
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
|
||||||
@@ -311,9 +317,6 @@ export const RoomDto = z.object({
|
|||||||
.int()
|
.int()
|
||||||
.nullable()
|
.nullable()
|
||||||
.describe('The room’s published snapshot. Nothing takes snapshots here, so always null'),
|
.describe('The room’s published snapshot. Nothing takes snapshots here, so always null'),
|
||||||
FriendlyName: z
|
|
||||||
.string()
|
|
||||||
.describe('Display name. Nothing sets one apart from `Name` here, so it mirrors `Name`'),
|
|
||||||
CCU: z
|
CCU: z
|
||||||
.int()
|
.int()
|
||||||
.nullable()
|
.nullable()
|
||||||
@@ -609,6 +612,28 @@ export const RoomBanEnvelope = z.object({
|
|||||||
value: RoomBanDto.nullable().describe('Null on a rejection'),
|
value: RoomBanDto.nullable().describe('Null on a rejection'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** `POST /rooms/{roomId}/leaderboards/{leaderboardId}` — configure one leaderboard slot. */
|
||||||
|
export const LeaderboardRequest = z.object({
|
||||||
|
leaderboardTitle: z.string().describe('The title the board displays'),
|
||||||
|
statFormat: z.string().optional().describe('The stat-format int; defaults to 0'),
|
||||||
|
sortAscending: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('`True` / `False` — whether lower scores rank first. Defaults to `False`'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What both leaderboard routes answer: a bare success/failure carrying no entity.
|
||||||
|
* PascalCase `Success`/`Error` with a lowercase `error_id` — the same mixed casing the
|
||||||
|
* unprefixed isBanned envelope has ({@link IsBannedPascalEnvelope}), NOT the room
|
||||||
|
* mutations' lowercase `{ success, error, value }`.
|
||||||
|
*/
|
||||||
|
export const LeaderboardResultEnvelope = z.object({
|
||||||
|
Success: z.boolean(),
|
||||||
|
Error: z.string().nullable().describe('The message shown on a rejection; null on success'),
|
||||||
|
error_id: z.string().nullable().describe('Null. Lowercase, unlike its siblings'),
|
||||||
|
})
|
||||||
|
|
||||||
/** `PUT /rooms/{roomId}/warning`. */
|
/** `PUT /rooms/{roomId}/warning`. */
|
||||||
export const WarningRequest = z.object({
|
export const WarningRequest = z.object({
|
||||||
warningMask: z.string().describe('Content-warning bit flags, as an integer'),
|
warningMask: z.string().describe('Content-warning bit flags, as an integer'),
|
||||||
@@ -849,6 +874,14 @@ export const RoomExperiencePlayer = z
|
|||||||
.array(z.unknown())
|
.array(z.unknown())
|
||||||
.describe('Always empty — no per-room experience is tracked')
|
.describe('Always empty — no per-room experience is tracked')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /showcase/{playerId}` — the rooms a player showcases on their profile. Stubbed
|
||||||
|
* empty; nothing stores a showcase, so the element shape is unknown until something does.
|
||||||
|
*/
|
||||||
|
export const ShowcasedRooms = z
|
||||||
|
.array(z.unknown())
|
||||||
|
.describe('Always empty — no room showcase is stored')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET /rooms/curated_playlists` — the curated room playlists the discovery pages'
|
* `GET /rooms/curated_playlists` — the curated room playlists the discovery pages'
|
||||||
* playlist sections draw from. Nothing curates one on this server, so the list is always
|
* playlist sections draw from. Nothing curates one on this server, so the list is always
|
||||||
|
|||||||
+144
-3
@@ -14,6 +14,7 @@ import {
|
|||||||
countRoomsByCreator,
|
countRoomsByCreator,
|
||||||
createSubRoom,
|
createSubRoom,
|
||||||
deleteRoom,
|
deleteRoom,
|
||||||
|
deleteRoomLeaderboard,
|
||||||
deleteSubRoom,
|
deleteSubRoom,
|
||||||
findSubRoom,
|
findSubRoom,
|
||||||
getBaseRooms,
|
getBaseRooms,
|
||||||
@@ -47,6 +48,7 @@ import {
|
|||||||
searchRooms,
|
searchRooms,
|
||||||
setRoomDescription,
|
setRoomDescription,
|
||||||
setRoomImage,
|
setRoomImage,
|
||||||
|
setRoomLeaderboard,
|
||||||
setRoomName,
|
setRoomName,
|
||||||
setRoomRole,
|
setRoomRole,
|
||||||
setSubRoomPermissions,
|
setSubRoomPermissions,
|
||||||
@@ -90,6 +92,9 @@ import {
|
|||||||
IsBannedPascalEnvelope,
|
IsBannedPascalEnvelope,
|
||||||
json,
|
json,
|
||||||
jsonBody,
|
jsonBody,
|
||||||
|
leaderboardIdParam,
|
||||||
|
LeaderboardRequest,
|
||||||
|
LeaderboardResultEnvelope,
|
||||||
LoadScreenRequest,
|
LoadScreenRequest,
|
||||||
MissingLookupParam,
|
MissingLookupParam,
|
||||||
ModifySubRoomRequest,
|
ModifySubRoomRequest,
|
||||||
@@ -118,6 +123,7 @@ import {
|
|||||||
SaveSubRoomDataRequest,
|
SaveSubRoomDataRequest,
|
||||||
SearchSuggestions,
|
SearchSuggestions,
|
||||||
ServiceStatus,
|
ServiceStatus,
|
||||||
|
ShowcasedRooms,
|
||||||
stringQuery,
|
stringQuery,
|
||||||
SubRoomAccessibilityRequest,
|
SubRoomAccessibilityRequest,
|
||||||
SubRoomDataSaveResponseDto,
|
SubRoomDataSaveResponseDto,
|
||||||
@@ -643,6 +649,15 @@ function roomEnvelope(c: Context<App>, value: unknown, error = '') {
|
|||||||
*/
|
*/
|
||||||
const banEnvelope = roomEnvelope
|
const banEnvelope = roomEnvelope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The envelope both leaderboard routes answer: `{ Success, Error, error_id }`, carrying
|
||||||
|
* no entity. PascalCase with a lowercase `error_id` — the same mixed casing the
|
||||||
|
* unprefixed isBanned route serves — NOT the room mutations' lowercase envelope.
|
||||||
|
*/
|
||||||
|
function leaderboardEnvelope(c: Context<App>, error: string | null = null) {
|
||||||
|
return c.json({ Success: error === null, Error: error, error_id: null })
|
||||||
|
}
|
||||||
|
|
||||||
/** Rooms created/owned by the authed caller (shared by the createdby routes). */
|
/** Rooms created/owned by the authed caller (shared by the createdby routes). */
|
||||||
async function ownedRooms(c: Context<App>) {
|
async function ownedRooms(c: Context<App>) {
|
||||||
const accountId = await authedAccountId(c)
|
const accountId = await authedAccountId(c)
|
||||||
@@ -1268,6 +1283,29 @@ const app = new Hono<App>()
|
|||||||
c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
|
c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A player's showcased rooms — the hand-picked rail the client draws on a profile,
|
||||||
|
// separate from `ownedby/{accountId}` (which is everything public they own). Stub →
|
||||||
|
// empty list: nothing stores a showcase yet, and an empty rail is what a player who
|
||||||
|
// has picked nothing looks like, where a 404 leaves the profile half-drawn. No auth,
|
||||||
|
// matching the profile list it sits beside — a showcase is public by definition.
|
||||||
|
.get(
|
||||||
|
'/showcase/:playerId{[0-9]+}',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Rooms'],
|
||||||
|
summary: 'A player’s showcased rooms',
|
||||||
|
description: [
|
||||||
|
'The rooms a player has showcased on their profile, as a bare array. Nothing stores a',
|
||||||
|
'showcase yet, so this is a stub serving an empty list — which the client reads as',
|
||||||
|
'“nothing showcased”, the same as a player who has picked none. Unlike',
|
||||||
|
'`ownedby/{accountId}`, which lists everything public the account owns, a showcase is',
|
||||||
|
'a chosen subset. No auth: a profile is public.',
|
||||||
|
].join(' '),
|
||||||
|
parameters: [playerIdParam],
|
||||||
|
responses: { 200: json(ShowcasedRooms, 'An empty list') },
|
||||||
|
}),
|
||||||
|
(c) => c.json([])
|
||||||
|
)
|
||||||
|
|
||||||
// Rooms the caller has favorited (from the interaction table). Auth-gated.
|
// Rooms the caller has favorited (from the interaction table). Auth-gated.
|
||||||
// Paginated via skip/take (take defaults to 100). Returns a bare array, like the
|
// Paginated via skip/take (take defaults to 100). Returns a bare array, like the
|
||||||
// other room-source `*by/me` lists the client loads.
|
// other room-source `*by/me` lists the client loads.
|
||||||
@@ -1676,13 +1714,11 @@ const app = new Hono<App>()
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writes `FriendlyName` too — see `setRoomName`. The two are the same string here,
|
|
||||||
// and the client labels the room from the display one.
|
|
||||||
await setRoomName(c.env.DB, roomId, name)
|
await setRoomName(c.env.DB, roomId, name)
|
||||||
// The rename answers a bare `{ Success }` with no room in it, so the client has
|
// The rename answers a bare `{ Success }` with no room in it, so the client has
|
||||||
// nothing to re-render from and kept showing the old name until the push arrived.
|
// nothing to re-render from and kept showing the old name until the push arrived.
|
||||||
// Built from the room already in hand rather than re-read, like the image route's.
|
// Built from the room already in hand rather than re-read, like the image route's.
|
||||||
await pushRoomUpdate(c, accountId, { ...room, Name: name, FriendlyName: name })
|
await pushRoomUpdate(c, accountId, { ...room, Name: name })
|
||||||
return roomResult(c, { Success: true })
|
return roomResult(c, { Success: true })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -2236,6 +2272,111 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Configure one of a room's leaderboard slots (form body `leaderboardTitle` +
|
||||||
|
// `statFormat` + `sortAscending`). Auth-gated (401) and owner/co-owner-only (403).
|
||||||
|
// One row per (room, slot) — re-posting a slot reconfigures it, so the call is
|
||||||
|
// idempotent.
|
||||||
|
.post(
|
||||||
|
'/rooms/:roomId{[0-9]+}/leaderboards/:leaderboardId{[0-9]+}',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Configure a room leaderboard',
|
||||||
|
description: [
|
||||||
|
'Creates or reconfigures one leaderboard slot in the `room_leaderboard` table — one',
|
||||||
|
'row per (room, slot), so re-posting a slot rewrites its title, format and direction',
|
||||||
|
'rather than adding a second. The slot number in the path is the client’s small',
|
||||||
|
'ordinal (1, 2, 3…), unique only within the room. Owner or co-owner only (403',
|
||||||
|
'otherwise).',
|
||||||
|
'',
|
||||||
|
'`statFormat` is stored verbatim (default 0); `sortAscending` is the client’s',
|
||||||
|
'`True`/`False` string (default `False`).',
|
||||||
|
'',
|
||||||
|
'Answers a bare `{ Success, Error, error_id }` — PascalCase with a lowercase',
|
||||||
|
'`error_id`, like the unprefixed isBanned route, carrying no entity. NOT the room',
|
||||||
|
'mutations’ lowercase `{ success, error, value }`.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam, leaderboardIdParam],
|
||||||
|
requestBody: form(LeaderboardRequest, 'The leaderboard configuration'),
|
||||||
|
responses: {
|
||||||
|
200: json(LeaderboardResultEnvelope, 'Stored, or a rejection with `Success: false`'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
403: FORBIDDEN_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
if (!room) return leaderboardEnvelope(c, 'This room does not exist!')
|
||||||
|
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||||
|
// already returned 401 for a missing/invalid token).
|
||||||
|
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||||
|
|
||||||
|
const leaderboardId = Number.parseInt(c.req.param('leaderboardId'), 10)
|
||||||
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
|
const title = typeof body.leaderboardTitle === 'string' ? body.leaderboardTitle : ''
|
||||||
|
const statFormat =
|
||||||
|
typeof body.statFormat === 'string' ? Number.parseInt(body.statFormat, 10) : Number.NaN
|
||||||
|
// The client sends .NET-style `True`/`False`; anything but a `true` reads false.
|
||||||
|
const sortAscending =
|
||||||
|
typeof body.sortAscending === 'string' && body.sortAscending.toLowerCase() === 'true'
|
||||||
|
|
||||||
|
await setRoomLeaderboard(
|
||||||
|
c.env.DB,
|
||||||
|
roomId,
|
||||||
|
leaderboardId,
|
||||||
|
title,
|
||||||
|
Number.isNaN(statFormat) ? 0 : statFormat,
|
||||||
|
sortAscending
|
||||||
|
)
|
||||||
|
return leaderboardEnvelope(c)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Remove one of a room's leaderboard slots. Auth-gated (401) and owner/co-owner-only
|
||||||
|
// (403). The client fires these blindly for every slot when tearing boards down, so a
|
||||||
|
// slot that isn't configured is a rejection envelope, not an HTTP error.
|
||||||
|
.delete(
|
||||||
|
'/rooms/:roomId{[0-9]+}/leaderboards/:leaderboardId{[0-9]+}',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Remove a room leaderboard',
|
||||||
|
description: [
|
||||||
|
'Removes the slot’s `room_leaderboard` row. Owner or co-owner only (403 otherwise).',
|
||||||
|
'',
|
||||||
|
'Removing a slot that isn’t configured is a rejection (`Success: false`), not a',
|
||||||
|
'silent success — the caller asked to undo something that was not there. The client',
|
||||||
|
'deletes slots blindly when tearing boards down and tolerates the refusal.',
|
||||||
|
'',
|
||||||
|
'Answers the same bare `{ Success, Error, error_id }` as the leaderboard write.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam, leaderboardIdParam],
|
||||||
|
responses: {
|
||||||
|
200: json(LeaderboardResultEnvelope, 'Removed, or a rejection with `Success: false`'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
403: FORBIDDEN_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
if (!room) return leaderboardEnvelope(c, 'This room does not exist!')
|
||||||
|
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||||
|
|
||||||
|
const leaderboardId = Number.parseInt(c.req.param('leaderboardId'), 10)
|
||||||
|
const removed = await deleteRoomLeaderboard(c.env.DB, roomId, leaderboardId)
|
||||||
|
if (!removed) return leaderboardEnvelope(c, 'This room has no such leaderboard!')
|
||||||
|
return leaderboardEnvelope(c)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Set a room's content warning: the `WarningMask` bit flags plus an optional
|
// Set a room's content warning: the `WarningMask` bit flags plus an optional
|
||||||
// free-text `CustomWarning`. Auth-gated (401) and owner/co-owner-only (403). Body is
|
// free-text `CustomWarning`. Auth-gated (401) and owner/co-owner-only (403). Body is
|
||||||
// the `warningMask` form field (an integer) and an optional `customWarning` string
|
// the `warningMask` form field (an integer) and an optional `customWarning` string
|
||||||
|
|||||||
@@ -156,16 +156,16 @@ describe('rooms endpoints', () => {
|
|||||||
|
|
||||||
// None of these are stored — the seed blobs predate the keys — so they are defaulted on
|
// None of these are stored — the seed blobs predate the keys — so they are defaulted on
|
||||||
// read. The client's room DTO always carries them, and an ABSENT key is not the same as a
|
// read. The client's room DTO always carries them, and an ABSENT key is not the same as a
|
||||||
// zero/null one to its parser. `FriendlyName` is the one that can't be null: the client
|
// zero/null one to its parser. `FriendlyName` is deliberately NOT among them: this server
|
||||||
// labels the room from it.
|
// does not serve a display name apart from `Name`, and migration 0017 strips any stored one.
|
||||||
it('GET /rooms/:id carries BoostCount, CurrentSnapshotId, FriendlyName and CCU', async () => {
|
it('GET /rooms/:id carries BoostCount, CurrentSnapshotId and CCU, and no FriendlyName', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/rooms/1`)
|
const res = await SELF.fetch(`${ORIGIN}/rooms/1`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as Record<string, unknown>
|
const body = (await res.json()) as Record<string, unknown>
|
||||||
expect(body).toHaveProperty('BoostCount', 0)
|
expect(body).toHaveProperty('BoostCount', 0)
|
||||||
expect(body).toHaveProperty('CurrentSnapshotId', null)
|
expect(body).toHaveProperty('CurrentSnapshotId', null)
|
||||||
expect(body).toHaveProperty('FriendlyName', body.Name)
|
|
||||||
expect(body).toHaveProperty('CCU', null)
|
expect(body).toHaveProperty('CCU', null)
|
||||||
|
expect(body).not.toHaveProperty('FriendlyName')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pinned whole: these are the numbers the client's publish UI counts against, and
|
// Pinned whole: these are the numbers the client's publish UI counts against, and
|
||||||
@@ -195,6 +195,23 @@ describe('rooms endpoints', () => {
|
|||||||
expect(await res.json()).toEqual([])
|
expect(await res.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Stub, same reasoning: the profile asks for a showcase for any player, and an
|
||||||
|
// unregistered path leaves the profile half-drawn. No auth, and no such player is
|
||||||
|
// still [] rather than a 404.
|
||||||
|
it('GET /showcase/:playerId returns [] for any player', async () => {
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/showcase/205`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual([])
|
||||||
|
|
||||||
|
const unknown = await SELF.fetch(`${ORIGIN}/showcase/99999`)
|
||||||
|
expect(unknown.status).toBe(200)
|
||||||
|
expect(await unknown.json()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET /showcase/:playerId 404s on a non-numeric id', async () => {
|
||||||
|
expect((await SELF.fetch(`${ORIGIN}/showcase/abc`)).status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
it('GET /rooms/:id 404s for a room not in D1', async () => {
|
it('GET /rooms/:id 404s for a room not in D1', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/rooms/99999`)
|
const res = await SELF.fetch(`${ORIGIN}/rooms/99999`)
|
||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
@@ -425,6 +442,11 @@ describe('rooms endpoints', () => {
|
|||||||
it('GET /rooms/contributedby/me lists rooms the caller owns or has a role in', async () => {
|
it('GET /rooms/contributedby/me lists rooms the caller owns or has a role in', async () => {
|
||||||
const seed = (data: Record<string, unknown>) =>
|
const seed = (data: Record<string, unknown>) =>
|
||||||
env.DB.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(data)).run()
|
env.DB.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(data)).run()
|
||||||
|
// Roles live in `room_role`, not the blob — the table the query's EXISTS matches on.
|
||||||
|
const grant = (roomId: number, accountId: number, role: number) =>
|
||||||
|
env.DB.prepare('INSERT INTO room_role (room_id, account_id, role) VALUES (?1, ?2, ?3)')
|
||||||
|
.bind(roomId, accountId, role)
|
||||||
|
.run()
|
||||||
|
|
||||||
// A room somebody else made, where 820 is a co-owner...
|
// A room somebody else made, where 820 is a co-owner...
|
||||||
await seed({
|
await seed({
|
||||||
@@ -433,11 +455,9 @@ describe('rooms endpoints', () => {
|
|||||||
CreatorAccountId: 821,
|
CreatorAccountId: 821,
|
||||||
Accessibility: 1,
|
Accessibility: 1,
|
||||||
SubRooms: [],
|
SubRooms: [],
|
||||||
Roles: [
|
|
||||||
{ AccountId: 821, Role: 255 },
|
|
||||||
{ AccountId: 820, Role: 30 },
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
await grant(30401, 821, 255)
|
||||||
|
await grant(30401, 820, 30)
|
||||||
// ...one where they're only a host (every tier counts, not just owner-level)...
|
// ...one where they're only a host (every tier counts, not just owner-level)...
|
||||||
await seed({
|
await seed({
|
||||||
RoomId: 30402,
|
RoomId: 30402,
|
||||||
@@ -447,20 +467,20 @@ describe('rooms endpoints', () => {
|
|||||||
// accessibility is not filtered here.
|
// accessibility is not filtered here.
|
||||||
Accessibility: 0,
|
Accessibility: 0,
|
||||||
SubRooms: [],
|
SubRooms: [],
|
||||||
Roles: [{ AccountId: 820, Role: 10 }],
|
|
||||||
})
|
})
|
||||||
// ...one they created themselves, whose Roles name them as Creator (matched by BOTH
|
await grant(30402, 820, 10)
|
||||||
// halves of the query, so it must still appear exactly once)...
|
// ...one they created themselves, whose role rows name them as Creator (matched by
|
||||||
|
// BOTH halves of the query, so it must still appear exactly once)...
|
||||||
await seed({
|
await seed({
|
||||||
RoomId: 30403,
|
RoomId: 30403,
|
||||||
Name: 'ContribOwn',
|
Name: 'ContribOwn',
|
||||||
CreatorAccountId: 820,
|
CreatorAccountId: 820,
|
||||||
Accessibility: 1,
|
Accessibility: 1,
|
||||||
SubRooms: [],
|
SubRooms: [],
|
||||||
Roles: [{ AccountId: 820, Role: 255 }],
|
|
||||||
})
|
})
|
||||||
// ...one they created that names nobody in Roles at all — the older rooms have no
|
await grant(30403, 820, 255)
|
||||||
// Roles key, and those reach the list on the creator half alone...
|
// ...one they created that has no role rows at all — the older rooms never got
|
||||||
|
// any, and those reach the list on the creator half alone...
|
||||||
await seed({
|
await seed({
|
||||||
RoomId: 30406,
|
RoomId: 30406,
|
||||||
Name: 'ContribOwnNoRoles',
|
Name: 'ContribOwnNoRoles',
|
||||||
@@ -476,18 +496,17 @@ describe('rooms endpoints', () => {
|
|||||||
IsDorm: true,
|
IsDorm: true,
|
||||||
Accessibility: 2,
|
Accessibility: 2,
|
||||||
SubRooms: [],
|
SubRooms: [],
|
||||||
Roles: [{ AccountId: 820, Role: 255 }],
|
|
||||||
})
|
})
|
||||||
// ...one they have nothing to do with, and one with no Roles key at all (the older
|
await grant(30407, 820, 255)
|
||||||
// seeded rooms have none — json_each must drop them, not error).
|
// ...one they have nothing to do with, and one with no role rows at all.
|
||||||
await seed({
|
await seed({
|
||||||
RoomId: 30404,
|
RoomId: 30404,
|
||||||
Name: 'ContribOther',
|
Name: 'ContribOther',
|
||||||
CreatorAccountId: 821,
|
CreatorAccountId: 821,
|
||||||
Accessibility: 1,
|
Accessibility: 1,
|
||||||
SubRooms: [],
|
SubRooms: [],
|
||||||
Roles: [{ AccountId: 822, Role: 30 }],
|
|
||||||
})
|
})
|
||||||
|
await grant(30404, 822, 30)
|
||||||
await seed({ RoomId: 30405, Name: 'ContribNoRoles', CreatorAccountId: 821, SubRooms: [] })
|
await seed({ RoomId: 30405, Name: 'ContribNoRoles', CreatorAccountId: 821, SubRooms: [] })
|
||||||
|
|
||||||
const res = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
|
const res = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
|
||||||
@@ -515,6 +534,7 @@ describe('rooms endpoints', () => {
|
|||||||
// The DB is shared across this file, and these are the only player-made public rooms
|
// The DB is shared across this file, and these are the only player-made public rooms
|
||||||
// in it — leaving them behind changes what the `new`/`community` room feeds serve.
|
// in it — leaving them behind changes what the `new`/`community` room feeds serve.
|
||||||
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30407').run()
|
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30407').run()
|
||||||
|
await env.DB.prepare('DELETE FROM room_role WHERE room_id BETWEEN 30401 AND 30407').run()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('GET /rooms/:roomId/experience serves the fixed XP settings, no auth', async () => {
|
it('GET /rooms/:roomId/experience serves the fixed XP settings, no auth', async () => {
|
||||||
@@ -1818,6 +1838,90 @@ describe('rooms endpoints', () => {
|
|||||||
.run()
|
.run()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('POST/DELETE /rooms/:id/leaderboards/:lid configures and removes a room’s leaderboard slots', async () => {
|
||||||
|
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
|
||||||
|
const del = async (path: string, sub?: string) =>
|
||||||
|
SELF.fetch(`${ORIGIN}${path}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: sub ? await bearer(sub) : {},
|
||||||
|
})
|
||||||
|
const slotsOf = async (roomId: number) =>
|
||||||
|
(
|
||||||
|
await env.DB.prepare(
|
||||||
|
'SELECT leaderboard_id, leaderboard_title, stat_format, sort_ascending FROM room_leaderboard WHERE room_id = ?1 ORDER BY leaderboard_id'
|
||||||
|
)
|
||||||
|
.bind(roomId)
|
||||||
|
.all()
|
||||||
|
).results
|
||||||
|
|
||||||
|
// The real client body, verbatim.
|
||||||
|
const body = { leaderboardTitle: 'full name', statFormat: '1', sortAscending: 'False' }
|
||||||
|
|
||||||
|
// No token → 401 (auth gate).
|
||||||
|
expect((await postForm('/rooms/2/leaderboards/1', body)).status).toBe(401)
|
||||||
|
expect((await del('/rooms/2/leaderboards/1')).status).toBe(401)
|
||||||
|
// A valid token but no role on the room → 403.
|
||||||
|
expect((await postForm('/rooms/2/leaderboards/1', body, '999')).status).toBe(403)
|
||||||
|
expect((await del('/rooms/2/leaderboards/1', '999')).status).toBe(403)
|
||||||
|
// The envelope both routes answer — PascalCase `Success`/`Error`, lowercase
|
||||||
|
// `error_id`, no entity. NOT the room mutations' lowercase `{ success, error, value }`.
|
||||||
|
const OK = { Success: true, Error: null, error_id: null }
|
||||||
|
|
||||||
|
// Unknown room → failure envelope.
|
||||||
|
expect(await (await postForm('/rooms/99999/leaderboards/1', body, '1')).json()).toEqual({
|
||||||
|
Success: false,
|
||||||
|
Error: 'This room does not exist!',
|
||||||
|
error_id: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The owner configures slot 1 — a bare success, with the row persisted.
|
||||||
|
const ok = await postForm('/rooms/2/leaderboards/1', body, '1')
|
||||||
|
expect(ok.status).toBe(200)
|
||||||
|
expect(await ok.json()).toEqual(OK)
|
||||||
|
expect(await slotsOf(2)).toEqual([
|
||||||
|
{ leaderboard_id: 1, leaderboard_title: 'full name', stat_format: 1, sort_ascending: 0 },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Re-posting the slot reconfigures the one row rather than appending — and the
|
||||||
|
// co-owner may do it. `sortAscending=True` parses case-insensitively.
|
||||||
|
const rewrite = await postForm(
|
||||||
|
'/rooms/2/leaderboards/1',
|
||||||
|
{ leaderboardTitle: 'lap time', statFormat: '2', sortAscending: 'True' },
|
||||||
|
'2'
|
||||||
|
)
|
||||||
|
expect(rewrite.status).toBe(200)
|
||||||
|
expect(await rewrite.json()).toEqual(OK)
|
||||||
|
expect(await slotsOf(2)).toEqual([
|
||||||
|
{ leaderboard_id: 1, leaderboard_title: 'lap time', stat_format: 2, sort_ascending: 1 },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Slots are per room: slot 2 here and slot 1 of another room are their own rows.
|
||||||
|
expect((await postForm('/rooms/2/leaderboards/2', body, '1')).status).toBe(200)
|
||||||
|
expect((await postForm('/rooms/3/leaderboards/1', body, '1')).status).toBe(200)
|
||||||
|
expect(await slotsOf(2)).toHaveLength(2)
|
||||||
|
expect(await slotsOf(3)).toHaveLength(1)
|
||||||
|
|
||||||
|
// DELETE removes exactly the named slot.
|
||||||
|
const removed = await del('/rooms/2/leaderboards/1', '1')
|
||||||
|
expect(removed.status).toBe(200)
|
||||||
|
expect(await removed.json()).toEqual(OK)
|
||||||
|
expect(await slotsOf(2)).toEqual([
|
||||||
|
{ leaderboard_id: 2, leaderboard_title: 'full name', stat_format: 1, sort_ascending: 0 },
|
||||||
|
])
|
||||||
|
expect(await slotsOf(3)).toHaveLength(1)
|
||||||
|
|
||||||
|
// Deleting a slot that isn't configured is a rejection, not an HTTP error — the
|
||||||
|
// client tears boards down by deleting every slot blindly.
|
||||||
|
expect(await (await del('/rooms/2/leaderboards/1', '1')).json()).toEqual({
|
||||||
|
Success: false,
|
||||||
|
Error: 'This room has no such leaderboard!',
|
||||||
|
error_id: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clean up the surviving rows so this test leaves no trace.
|
||||||
|
await env.DB.prepare('DELETE FROM room_leaderboard WHERE room_id IN (2, 3)').run()
|
||||||
|
})
|
||||||
|
|
||||||
it('POST /rooms/:id/bans kicks the banned player', async () => {
|
it('POST /rooms/:id/bans kicks the banned player', async () => {
|
||||||
type Sent = { playerId: number; notificationType: string | number; data: unknown }
|
type Sent = { playerId: number; notificationType: string | number; data: unknown }
|
||||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||||
@@ -2915,13 +3019,12 @@ describe('rooms endpoints', () => {
|
|||||||
expect(await bodyOf(ok)).toMatchObject({ Success: true })
|
expect(await bodyOf(ok)).toMatchObject({ Success: true })
|
||||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms?name=RenamedCenter`)).json()) as {
|
const room = (await (await SELF.fetch(`${ORIGIN}/rooms?name=RenamedCenter`)).json()) as {
|
||||||
RoomId: number
|
RoomId: number
|
||||||
FriendlyName: string
|
Name: string
|
||||||
}
|
}
|
||||||
expect(room.RoomId).toBe(2)
|
expect(room.RoomId).toBe(2)
|
||||||
// The DISPLAY name follows the rename. It is otherwise only defaulted to `Name` on
|
expect(room.Name).toBe('RenamedCenter')
|
||||||
// read, so a room that had ever stored one would keep labelling itself with the old
|
// A rename must not resurrect the retired display name.
|
||||||
// name while every name-keyed lookup used the new one.
|
expect(room).not.toHaveProperty('FriendlyName')
|
||||||
expect(room.FriendlyName).toBe('RenamedCenter')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** The hub stub records every notifyPlayer call — see vitest.config.ts. */
|
/** The hub stub records every notifyPlayer call — see vitest.config.ts. */
|
||||||
@@ -2941,14 +3044,11 @@ describe('rooms endpoints', () => {
|
|||||||
|
|
||||||
const sent = await sentNotifications()
|
const sent = await sentNotifications()
|
||||||
expect(sent).toHaveLength(1)
|
expect(sent).toHaveLength(1)
|
||||||
// RoomUpdate, to the OWNER, carrying the room as it now stands — both names.
|
// RoomUpdate, to the OWNER, carrying the room as it now stands.
|
||||||
expect(sent[0].playerId).toBe(1)
|
expect(sent[0].playerId).toBe(1)
|
||||||
expect(sent[0].notificationType).toBe(NotificationType.SubscriptionUpdateRoom)
|
expect(sent[0].notificationType).toBe(NotificationType.SubscriptionUpdateRoom)
|
||||||
expect(sent[0].data).toMatchObject({
|
expect(sent[0].data).toMatchObject({ RoomId: 2, Name: 'PushedRename' })
|
||||||
RoomId: 2,
|
expect(sent[0].data).not.toHaveProperty('FriendlyName')
|
||||||
Name: 'PushedRename',
|
|
||||||
FriendlyName: 'PushedRename',
|
|
||||||
})
|
|
||||||
|
|
||||||
// Put it back for the tests that read room 2 by name.
|
// Put it back for the tests that read room 2 by name.
|
||||||
await putForm('/rooms/2/name', { name: 'RenamedCenter' }, '1')
|
await putForm('/rooms/2/name', { name: 'RenamedCenter' }, '1')
|
||||||
@@ -3972,6 +4072,7 @@ describe('rooms endpoints', () => {
|
|||||||
'DELETE /rooms/{roomId}/bans/{playerId}',
|
'DELETE /rooms/{roomId}/bans/{playerId}',
|
||||||
'DELETE /rooms/{roomId}/interactionby/me/cheer',
|
'DELETE /rooms/{roomId}/interactionby/me/cheer',
|
||||||
'DELETE /rooms/{roomId}/interactionby/me/favorite',
|
'DELETE /rooms/{roomId}/interactionby/me/favorite',
|
||||||
|
'DELETE /rooms/{roomId}/leaderboards/{leaderboardId}',
|
||||||
'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
|
'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
|
||||||
'GET /',
|
'GET /',
|
||||||
'GET /Room_server/rooms/{roomId}/bans/{playerId}/isBanned',
|
'GET /Room_server/rooms/{roomId}/bans/{playerId}/isBanned',
|
||||||
@@ -4007,9 +4108,11 @@ describe('rooms endpoints', () => {
|
|||||||
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/no_unity_assets',
|
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/no_unity_assets',
|
||||||
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}',
|
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}',
|
||||||
'GET /roomserver/rooms/createdby/me',
|
'GET /roomserver/rooms/createdby/me',
|
||||||
|
'GET /showcase/{playerId}',
|
||||||
'POST /rooms/bulk',
|
'POST /rooms/bulk',
|
||||||
'POST /rooms/{roomId}/bans',
|
'POST /rooms/{roomId}/bans',
|
||||||
'POST /rooms/{roomId}/clone',
|
'POST /rooms/{roomId}/clone',
|
||||||
|
'POST /rooms/{roomId}/leaderboards/{leaderboardId}',
|
||||||
'POST /rooms/{roomId}/subrooms',
|
'POST /rooms/{roomId}/subrooms',
|
||||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
|
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
|
||||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/data',
|
'POST /rooms/{roomId}/subrooms/{subRoomId}/data',
|
||||||
|
|||||||
@@ -4563,6 +4563,110 @@
|
|||||||
"LoadScreens": [],
|
"LoadScreens": [],
|
||||||
"RestrictedCircuitsAllowListNames": []
|
"RestrictedCircuitsAllowListNames": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"RoomId": 45,
|
||||||
|
"Name": "RecRally",
|
||||||
|
"Description": "A six-player off-road rally race where you boost, bump, and powerslide your way to the finish line!",
|
||||||
|
"ImageName": "RecRally.jpg",
|
||||||
|
"WarningMask": 8,
|
||||||
|
"CustomWarning": null,
|
||||||
|
"CreatorAccountId": 1,
|
||||||
|
"State": 0,
|
||||||
|
"Accessibility": 1,
|
||||||
|
"PublishState": 0,
|
||||||
|
"SupportsLevelVoting": false,
|
||||||
|
"IsRRO": true,
|
||||||
|
"IsRecRoomApproved": false,
|
||||||
|
"ExcludeFromLists": false,
|
||||||
|
"ExcludeFromSearch": false,
|
||||||
|
"SupportsScreens": true,
|
||||||
|
"SupportsWalkVR": true,
|
||||||
|
"SupportsTeleportVR": true,
|
||||||
|
"SupportsVRLow": true,
|
||||||
|
"SupportsQuest2": true,
|
||||||
|
"SupportsMobile": true,
|
||||||
|
"SupportsJuniors": true,
|
||||||
|
"MinLevel": 0,
|
||||||
|
"AgeRating": 2,
|
||||||
|
"CreatedAt": "2026-02-25T20:10:07.201204Z",
|
||||||
|
"PublishedAt": "2026-02-25T20:10:07.201204Z",
|
||||||
|
"BecameRRStudioRoomAt": null,
|
||||||
|
"Stats": {
|
||||||
|
"CheerCount": 0,
|
||||||
|
"FavoriteCount": 0,
|
||||||
|
"VisitorCount": 0,
|
||||||
|
"VisitCount": 0
|
||||||
|
},
|
||||||
|
"RankingContext": null,
|
||||||
|
"IsDorm": false,
|
||||||
|
"IsPlacePlay": false,
|
||||||
|
"MaxPlayerCalculationMode": 0,
|
||||||
|
"MaxPlayers": 6,
|
||||||
|
"CloningAllowed": true,
|
||||||
|
"DisableMicAutoMute": false,
|
||||||
|
"DisableRoomComments": false,
|
||||||
|
"EncryptVoiceChat": false,
|
||||||
|
"ToxmodEnabled": true,
|
||||||
|
"LoadScreenLocked": false,
|
||||||
|
"UgcVersion": 1,
|
||||||
|
"PersistenceVersion": 0,
|
||||||
|
"UgcSubVersion": null,
|
||||||
|
"MinUgcSubVersion": null,
|
||||||
|
"AutoLocalizeRoom": false,
|
||||||
|
"LocalizationContext": {
|
||||||
|
"TargetLocale": null,
|
||||||
|
"Scope": null,
|
||||||
|
"LocalizedFields": [
|
||||||
|
"Description",
|
||||||
|
"CustomWarning"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"IsDeveloperOwned": true,
|
||||||
|
"RankedEntityId": "45",
|
||||||
|
"BoostCount": 0,
|
||||||
|
"CurrentSnapshotId": null,
|
||||||
|
"SubRooms": [
|
||||||
|
{
|
||||||
|
"SubRoomId": 57,
|
||||||
|
"RoomId": 45,
|
||||||
|
"CreatorAccountId": null,
|
||||||
|
"UnitySceneId": "56193568-9ae0-498c-8a77-4df79dec91f5",
|
||||||
|
"Name": "Home",
|
||||||
|
"CurrentSave": null,
|
||||||
|
"LastModeratedSaveModerationState": 0,
|
||||||
|
"IsSandbox": false,
|
||||||
|
"MaxPlayers": 6,
|
||||||
|
"Accessibility": 1,
|
||||||
|
"ShouldAutoStageSaves": true,
|
||||||
|
"StagedSubRoomDataSaveId": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Roles": [
|
||||||
|
{
|
||||||
|
"AccountId": 1,
|
||||||
|
"Role": 255,
|
||||||
|
"LastChangedByAccountId": null,
|
||||||
|
"InvitedRole": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AccountId": 2,
|
||||||
|
"Role": 30,
|
||||||
|
"LastChangedByAccountId": null,
|
||||||
|
"InvitedRole": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"IsJuniorCreated": false,
|
||||||
|
"Tags": [
|
||||||
|
{
|
||||||
|
"Tag": "rro",
|
||||||
|
"Type": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PromoImages": [],
|
||||||
|
"PromoExternalContent": [],
|
||||||
|
"LoadScreens": [],
|
||||||
|
"RestrictedCircuitsAllowListNames": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"RoomId": 51,
|
"RoomId": 51,
|
||||||
"Name": "DriveIn",
|
"Name": "DriveIn",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"hono-openapi": "1.3.1",
|
"hono-openapi": "1.3.1",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
@@ -31,6 +31,6 @@
|
|||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "26.0.1",
|
"@types/node": "26.0.1",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { HonoApp } from '@repo/hono-helpers'
|
|||||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||||
|
|
||||||
export type Env = SharedHonoEnv & {
|
export type Env = SharedHonoEnv & {
|
||||||
|
/** Maximum accepted binary upload size in bytes. */
|
||||||
|
MAX_UPLOAD_BYTES?: string | number
|
||||||
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
|
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
|
||||||
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
|
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
|
||||||
// signed by `auth` verify here.
|
// signed by `auth` verify here.
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { Hono } from 'hono'
|
|||||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
import {
|
||||||
|
intVar,
|
||||||
|
withCleanSpec,
|
||||||
|
withDefaultCors,
|
||||||
|
withNotFound,
|
||||||
|
withOnError,
|
||||||
|
} from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +63,19 @@ const UPLOAD_EXTENSION: Record<number, string> = {
|
|||||||
5: '.inv',
|
5: '.inv',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Worker must not accept an unbounded user-controlled blob into memory and R2.
|
||||||
|
* Operators can tune this for known room sizes, but an unset or invalid value keeps
|
||||||
|
* the safe 64 MiB default. Non-positive values are invalid rather than disabling the
|
||||||
|
* limit: a public upload endpoint must always have a finite ceiling.
|
||||||
|
*/
|
||||||
|
const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
function maxUploadBytes(value: unknown): number {
|
||||||
|
const configured = intVar(value, DEFAULT_MAX_UPLOAD_BYTES)
|
||||||
|
return configured > 0 ? configured : DEFAULT_MAX_UPLOAD_BYTES
|
||||||
|
}
|
||||||
|
|
||||||
function extensionForFileType(fileType: string): string {
|
function extensionForFileType(fileType: string): string {
|
||||||
return UPLOAD_EXTENSION[Number.parseInt(fileType, 10)] ?? ''
|
return UPLOAD_EXTENSION[Number.parseInt(fileType, 10)] ?? ''
|
||||||
}
|
}
|
||||||
@@ -130,6 +149,7 @@ const app = new Hono<App>()
|
|||||||
200: json(UploadResponse, 'The stored (or echoed) file name'),
|
200: json(UploadResponse, 'The stored (or echoed) file name'),
|
||||||
400: json(ErrorResponse, 'Unknown/missing FileType, or neither a file nor a name'),
|
400: json(ErrorResponse, 'Unknown/missing FileType, or neither a file nor a name'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
413: json(ErrorResponse, 'The binary file exceeds the configured upload limit'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -143,6 +163,11 @@ const app = new Hono<App>()
|
|||||||
const file = Object.values(body).find((v): v is File => v instanceof File)
|
const file = Object.values(body).find((v): v is File => v instanceof File)
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
|
const limit = maxUploadBytes(c.env.MAX_UPLOAD_BYTES)
|
||||||
|
if (file.size > limit) {
|
||||||
|
return c.json({ error: `file exceeds the ${limit}-byte upload limit` }, 413)
|
||||||
|
}
|
||||||
|
|
||||||
const fileType = textField(body, 'filetype') ?? '0'
|
const fileType = textField(body, 'filetype') ?? '0'
|
||||||
const subfolder = subfolderForFileType(fileType)
|
const subfolder = subfolderForFileType(fileType)
|
||||||
if (subfolder === undefined) {
|
if (subfolder === undefined) {
|
||||||
|
|||||||
@@ -134,6 +134,24 @@ it('POST /upload 400s for a binary with an unknown/missing FileType', async () =
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('POST /upload rejects a binary above the configured size limit without storing it', async () => {
|
||||||
|
const original = env.MAX_UPLOAD_BYTES
|
||||||
|
env.MAX_UPLOAD_BYTES = '3'
|
||||||
|
try {
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer(),
|
||||||
|
body: uploadForm('3', new Uint8Array([1, 2, 3, 4])),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(413)
|
||||||
|
expect((await res.json()) as { error: string }).toEqual({
|
||||||
|
error: 'file exceeds the 3-byte upload limit',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
env.MAX_UPLOAD_BYTES = original
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('POST /upload echoes an explicit name when no binary is posted', async () => {
|
it('POST /upload echoes an explicit name when no binary is posted', async () => {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.set('FileType', '3')
|
form.set('FileType', '3')
|
||||||
|
|||||||
@@ -81,6 +81,125 @@ printf '1x0000000000000000000000000000000AA' |
|
|||||||
|
|
||||||
The tests seed the same pair into their own local store in `beforeAll`.
|
The tests seed the same pair into their own local store in `beforeAll`.
|
||||||
|
|
||||||
|
### Benefits claim and Discord
|
||||||
|
|
||||||
|
The **Claim benefits** tab on the account page lets a player prove they hold one of
|
||||||
|
the qualifying roles in the community Discord and, if they do, gives their account Rec
|
||||||
|
Room Plus (`account.hasPlus`). The same panel also renders at `/claim`, which is the
|
||||||
|
app's registered `redirect_uri` — Discord sends the browser back there mid-flow, so
|
||||||
|
that route has to keep working on a cold load even though nothing links to it. It runs a standard
|
||||||
|
OAuth2 **authorization code** flow:
|
||||||
|
|
||||||
|
1. The tab sends the browser to Discord's consent screen, using the URL `www`
|
||||||
|
assembles in `/api/config` plus a `state` nonce the page mints and stashes in
|
||||||
|
`sessionStorage`.
|
||||||
|
2. Discord redirects back to `/claim?code=…&state=…`. The page checks the nonce is
|
||||||
|
the one it minted, strips the query, and posts only the `code` to
|
||||||
|
`POST /api/benefits/claim` with the player's bearer token.
|
||||||
|
3. The worker swaps the code for an access token with the client secret, reads
|
||||||
|
`GET /users/@me/guilds/{guild}/member` to get the player's roles, revokes the
|
||||||
|
token, and — if any one of the configured roles is there — writes `hasPlus` onto
|
||||||
|
the account and links the Discord id.
|
||||||
|
|
||||||
|
**A claim takes effect on the player's next sign-in, not immediately.** `auth` stamps
|
||||||
|
`hasPlus` into every token it mints as the `rn.plus` claim, and `econ` decides the
|
||||||
|
CampusCard and the subscriber discount from that claim alone — no database read on
|
||||||
|
either path. The token the player's game is holding was minted before they claimed, it
|
||||||
|
lasts a day, and the client never refreshes it, so they have to restart Rec Room and
|
||||||
|
sign in again. The claim page says so.
|
||||||
|
|
||||||
|
The browser never holds a Discord access token: the client secret can't ship to a
|
||||||
|
page, which is why this is the second feature (after signup) with a server side.
|
||||||
|
The scopes are `identify` and `guilds.members.read`, which let the token's owner
|
||||||
|
read **their own** membership in one guild — so no bot is needed and this worker
|
||||||
|
holds no credential that could read anyone else's roles.
|
||||||
|
|
||||||
|
The verified Discord id is stored as a link in `platform_account` (the `auth`
|
||||||
|
worker's table of account ↔ external identities, migration 0007) under
|
||||||
|
`PlatformType.Discord` (101) — the same place a Steam or Meta identity lives,
|
||||||
|
because that is what it is. Only `hasPlus` goes on the account itself.
|
||||||
|
|
||||||
|
Nobody logs in with it. `auth`'s `verifyPlatformProof` can prove exactly two
|
||||||
|
platforms (Steam and Meta), so a `cached_login` naming 101 is refused outright, and
|
||||||
|
the login picker filters to those same platforms (`CACHED_LOGIN_PLATFORMS`). That
|
||||||
|
filter matters for privacy as well as correctness: the picker is public and
|
||||||
|
unauthenticated, so without it `GET /cachedlogin/forplatformid/101/<snowflake>`
|
||||||
|
would tell anyone which RecFlare account a given Discord user owns.
|
||||||
|
|
||||||
|
Storing the link there is what makes the claim once-only **per Discord user**, not
|
||||||
|
per account: a second claim from the same Discord member on a different account is
|
||||||
|
refused (409), answered from the table's index rather than a scan of every account
|
||||||
|
blob. Re-claiming on the same account is idempotent — the link is `INSERT OR
|
||||||
|
IGNORE`, so `linkedAt` keeps the first claim's time — so the page is safe to
|
||||||
|
reload. Nothing revokes Plus: losing the role later leaves the flag set, so it
|
||||||
|
records "held the role once", not "holds it today".
|
||||||
|
|
||||||
|
Four settings configure it, and **all four** are required or the claim stays
|
||||||
|
closed (`/api/config` reports `benefitsEnabled: false`, so the SPA hides the tab,
|
||||||
|
and `/api/benefits/claim` returns 403). A half-configured app is
|
||||||
|
treated as unconfigured on purpose: a client id and secret with no guild/roles
|
||||||
|
would authenticate a player and have no question left to ask about them.
|
||||||
|
|
||||||
|
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` — Secrets Store, same account-level
|
||||||
|
store as `JWT_SECRET` and the Turnstile pair. The id is public (it ships to the
|
||||||
|
browser inside the authorize URL) but lives beside its secret so one place
|
||||||
|
configures the feature.
|
||||||
|
- `DISCORD_GUILD_ID` / `DISCORD_BENEFITS_ROLE_IDS` — plain vars in `wrangler.jsonc`,
|
||||||
|
not credentials. Both hold Discord **snowflakes: all digits, no letters**. Turn on
|
||||||
|
Developer Mode in Discord (Settings → Advanced), then right-click the server or the
|
||||||
|
role and Copy ID. These are ids, not names — `Supporter` is what the role is
|
||||||
|
_called_, `1077000000000000002` is what goes in the var — and they're quoted as
|
||||||
|
strings because a snowflake is too large to survive as a JSON number.
|
||||||
|
|
||||||
|
`DISCORD_BENEFITS_ROLE_IDS` is a **list**, separated by commas and/or whitespace, so
|
||||||
|
several tiers can qualify for the same benefit. **Any one** of them is enough — they
|
||||||
|
are alternatives, not requirements:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"DISCORD_BENEFITS_ROLE_IDS": "1077000000000000001,1077000000000000002"
|
||||||
|
```
|
||||||
|
|
||||||
|
Blank entries are dropped, so a trailing comma is harmless. A value that parses to no
|
||||||
|
ids at all counts as unset and closes the claim, rather than opening it with nothing
|
||||||
|
to check against.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
printf '<client id>' |
|
||||||
|
wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_ID --scopes workers --remote
|
||||||
|
printf '<client secret>' |
|
||||||
|
wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_SECRET --scopes workers --remote
|
||||||
|
```
|
||||||
|
|
||||||
|
The two ids are **not secrets**, and setting only the secrets is the usual reason the
|
||||||
|
page never appears. Put them in the root `.env` as operator knobs, where they ride
|
||||||
|
along as `--var` on deploy (see `recflare_vars`), rather than editing `wrangler.jsonc`
|
||||||
|
— that keeps your server's ids out of the repo:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
RECFLARE_DISCORD_GUILD_ID=1077000000000000000
|
||||||
|
RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002
|
||||||
|
```
|
||||||
|
|
||||||
|
Use **commas with no spaces** there. Those knobs become `--var` flags that the deploy
|
||||||
|
script word-splits, so a value containing a space breaks it. (`parseRoleIds` also
|
||||||
|
accepts whitespace, which is fine in `wrangler.jsonc` but not via `.env`.)
|
||||||
|
|
||||||
|
Then redeploy `www` — the Secrets Store `.get()` caches per isolate, so a warm worker
|
||||||
|
won't pick up newly created secrets until it restarts.
|
||||||
|
|
||||||
|
**Diagnosing a claim that won't appear:** fetch `/api/config`. If `benefitsEnabled` is
|
||||||
|
`false`, the gate is closed and it isn't a UI problem — `www` logs
|
||||||
|
`discord is half-configured, so benefit claims are closed` with a flag per input
|
||||||
|
(`hasClientId`, `hasClientSecret`, `hasGuildId`, `roleIdCount`), which names exactly
|
||||||
|
which one is missing. `wrangler tail www` shows it.
|
||||||
|
|
||||||
|
In the [Discord developer portal](https://discord.com/developers/applications),
|
||||||
|
add `https://<your domain>/claim` to the app's **Redirects**. It has to match byte
|
||||||
|
for byte: `www` derives the redirect URI from the incoming request's own origin
|
||||||
|
(never from the request body, which would turn the client secret into a redemption
|
||||||
|
oracle for someone else's app), so add `http://localhost:5173/claim` too if you
|
||||||
|
want the flow to work under `pnpm turbo dev`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Run in dev mode
|
### Run in dev mode
|
||||||
|
|||||||
@@ -18,8 +18,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/domain": "workspace:*",
|
"@repo/domain": "workspace:*",
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"@scalar/api-reference": "1.63.0",
|
"@repo/jwt": "workspace:*",
|
||||||
"hono": "4.12.27",
|
"@scalar/api-reference": "1.67.0",
|
||||||
|
"hono": "4.13.5",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
"workers-tagged-logger": "1.0.1"
|
"workers-tagged-logger": "1.0.1"
|
||||||
@@ -35,6 +36,6 @@
|
|||||||
"@vitejs/plugin-react": "5.2.0",
|
"@vitejs/plugin-react": "5.2.0",
|
||||||
"vite": "6.4.3",
|
"vite": "6.4.3",
|
||||||
"vitest": "4.1.9",
|
"vitest": "4.1.9",
|
||||||
"wrangler": "4.105.0"
|
"wrangler": "4.128.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,13 +32,11 @@ const AUTH_MESSAGES: Record<string, string> = {
|
|||||||
'no linked account for this platform identity':
|
'no linked account for this platform identity':
|
||||||
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
|
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
|
||||||
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
|
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
|
||||||
// Deliberately says nothing about when it lifts: auth sends one fixed description for
|
// An account that shares a device or network with a BANNED one. Phrased for BOTH the
|
||||||
// every ban (see its BANNED_DESCRIPTION), permanent or timed, so there is no expiry
|
|
||||||
// here to quote.
|
|
||||||
'this account is banned': 'This account is banned and cannot be signed in to.',
|
|
||||||
// Not this account, but one it shares a device or network with. Phrased for BOTH the
|
|
||||||
// person evading a ban and the housemate of one — the IP arm cannot tell them apart —
|
// person evading a ban and the housemate of one — the IP arm cannot tell them apart —
|
||||||
// and for both forms, since signup and sign-in send the same description.
|
// and for both forms, since signup and sign-in send the same description. A directly
|
||||||
|
// banned account is not refused a sign-in at all (auth issues it a token so the game
|
||||||
|
// client can show the block screen), which is why there is no "banned" entry here.
|
||||||
'this device or network is blocked':
|
'this device or network is blocked':
|
||||||
'This device or network is blocked. If you think that is a mistake, contact the server operator.',
|
'This device or network is blocked. If you think that is a mistake, contact the server operator.',
|
||||||
}
|
}
|
||||||
|
|||||||
+375
-17
@@ -46,6 +46,18 @@ interface Hosts {
|
|||||||
interface SiteConfig {
|
interface SiteConfig {
|
||||||
signupEnabled: boolean
|
signupEnabled: boolean
|
||||||
turnstileSiteKey: string | null
|
turnstileSiteKey: string | null
|
||||||
|
/**
|
||||||
|
* Whether the Discord-verified benefits claim is configured. False when the operator
|
||||||
|
* has no Discord app/guild/role set, in which case the claim page and its links stay
|
||||||
|
* hidden — the endpoint would refuse anyway.
|
||||||
|
*/
|
||||||
|
benefitsEnabled: boolean
|
||||||
|
/**
|
||||||
|
* The Discord consent URL to send the player to, assembled by `www` (scopes and the
|
||||||
|
* redirect URI are its business, and must match what the claim will accept). Null when
|
||||||
|
* benefits are off. The `state` nonce is appended here — see `startDiscordAuth`.
|
||||||
|
*/
|
||||||
|
discordAuthorizeUrl: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The private self DTO from `accounts` (`GET /account/me`). */
|
/** The private self DTO from `accounts` (`GET /account/me`). */
|
||||||
@@ -495,6 +507,50 @@ const changePassword = (oldPassword: string, newPassword: string): Promise<unkno
|
|||||||
authed: true,
|
authed: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Where this account's benefits stand: `www` reads them off the account row. */
|
||||||
|
interface BenefitsStatus {
|
||||||
|
/** Whether the account already has Rec Room Plus. */
|
||||||
|
hasPlus: boolean
|
||||||
|
/** Whether a Discord identity is already tied to it. Which one is deliberately not served. */
|
||||||
|
linked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two ends of the benefits claim. Both live on `www` rather than on one of the game
|
||||||
|
* workers, because the claim needs the Discord client secret — see www.app.ts.
|
||||||
|
*/
|
||||||
|
const fetchBenefitsStatus = (): Promise<BenefitsStatus> =>
|
||||||
|
call<BenefitsStatus>('/api/benefits/status', { authed: true })
|
||||||
|
|
||||||
|
/** Redeem the code Discord sent us back with. The access token never reaches this page. */
|
||||||
|
const claimBenefits = (code: string): Promise<{ discordUsername?: string }> =>
|
||||||
|
call<{ discordUsername?: string }>('/api/benefits/claim', { json: { code }, authed: true })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage.
|
||||||
|
*
|
||||||
|
* OAuth's `state` only means anything if the same page that minted it is the one that
|
||||||
|
* checks it, so it can't come from the server. sessionStorage rather than localStorage:
|
||||||
|
* it belongs to this tab and this attempt, and it should not outlive the tab that started
|
||||||
|
* the flow.
|
||||||
|
*/
|
||||||
|
const OAUTH_STATE_KEY = 'rf_discord_state'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the browser to Discord's consent screen.
|
||||||
|
*
|
||||||
|
* A real navigation, not a client-side route — Discord is another origin. The `state` is
|
||||||
|
* minted here and stashed for the return leg; `www` built everything else about the URL
|
||||||
|
* (see `/api/config`), so this only ever appends the one parameter it owns.
|
||||||
|
*/
|
||||||
|
function startDiscordAuth(authorizeUrl: string) {
|
||||||
|
const state = crypto.randomUUID()
|
||||||
|
sessionStorage.setItem(OAUTH_STATE_KEY, state)
|
||||||
|
const url = new URL(authorizeUrl)
|
||||||
|
url.searchParams.set('state', state)
|
||||||
|
window.location.assign(url.toString())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate
|
* Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate
|
||||||
* — so a session without the role is rejected there (403) even though the UI shows no
|
* — so a session without the role is rejected there (403) even though the UI shows no
|
||||||
@@ -516,6 +572,42 @@ const coachMessageAll = (messageContent: string): Promise<{ sent?: number }> =>
|
|||||||
authed: true,
|
authed: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same coach message to ONE player. `notify` queues it when they're offline, so
|
||||||
|
* `queued` (rather than a 0 delivery) is what "they weren't online" looks like here —
|
||||||
|
* it still arrives on their next connect, unlike the broadcast.
|
||||||
|
*/
|
||||||
|
const coachMessage = (
|
||||||
|
playerId: number,
|
||||||
|
messageContent: string
|
||||||
|
): Promise<{ delivered?: number; queued?: boolean }> =>
|
||||||
|
call<{ delivered?: number; queued?: boolean }>(`${where().notify}/internal/coach-message`, {
|
||||||
|
json: { playerId, messageContent },
|
||||||
|
authed: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an `@username` to the account id the workers address a player by.
|
||||||
|
*
|
||||||
|
* `accounts` serves no exact-name lookup, so this goes through the PREFIX search and
|
||||||
|
* keeps only an exact (case-insensitive) hit: a prefix match is a different player, and
|
||||||
|
* sending a message to whoever happened to sort first would be worse than refusing. The
|
||||||
|
* exact name always sorts first among its own prefixes, so it's inside the search limit
|
||||||
|
* whenever it exists.
|
||||||
|
*/
|
||||||
|
async function accountIdForUsername(input: string): Promise<number> {
|
||||||
|
const name = input.trim().replace(/^@/, '')
|
||||||
|
if (name === '') throw new Error('Enter a username to send to.')
|
||||||
|
const matches = await call<Array<{ accountId?: number; username?: string }>>(
|
||||||
|
`${where().accounts}/account/search?name=${encodeURIComponent(name)}`
|
||||||
|
)
|
||||||
|
const found = Array.isArray(matches)
|
||||||
|
? matches.find((m) => m.username?.toLowerCase() === name.toLowerCase())
|
||||||
|
: undefined
|
||||||
|
if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`)
|
||||||
|
return found.accountId
|
||||||
|
}
|
||||||
|
|
||||||
/** Minimal history-based router: current pathname + a navigate() that pushes state. */
|
/** Minimal history-based router: current pathname + a navigate() that pushes state. */
|
||||||
function useRouter() {
|
function useRouter() {
|
||||||
const [path, setPath] = useState(() => window.location.pathname)
|
const [path, setPath] = useState(() => window.location.pathname)
|
||||||
@@ -562,6 +654,214 @@ function Link({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The benefits claim itself: where the player stands, and the button that starts (or
|
||||||
|
* re-runs) the Discord round-trip.
|
||||||
|
*
|
||||||
|
* This is BOTH ends of the OAuth round-trip: it sends the player to Discord, and it is
|
||||||
|
* what renders when Discord sends them back. Which half is running is decided by whether
|
||||||
|
* the URL carries a `code`.
|
||||||
|
*
|
||||||
|
* What it never holds is a Discord access token. It forwards the one-time `code` to
|
||||||
|
* `www`, which does the exchange with the client secret and answers with a verdict; that
|
||||||
|
* is the whole reason this one feature has a server side at all.
|
||||||
|
*
|
||||||
|
* Rendered in TWO places, which is why it is a component rather than a page. Its home is
|
||||||
|
* the "Claim benefits" tab in the account dashboard, where someone would go looking for
|
||||||
|
* it. But it also has to render on `/claim`, because that path is Discord's registered
|
||||||
|
* redirect URI — the browser comes back to it with a `?code=`, and it is the only URL a
|
||||||
|
* cold load can land on mid-flow. One component means the two can't drift.
|
||||||
|
*
|
||||||
|
* The effect keys off whether the URL carries a code, so the same code covers both: on
|
||||||
|
* the dashboard there is none, and it just reports status.
|
||||||
|
*/
|
||||||
|
function BenefitsPanel({ account, config }: { account: SelfAccount; config: SiteConfig }) {
|
||||||
|
const [status, setStatus] = useState<BenefitsStatus | undefined>(undefined)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [done, setDone] = useState('')
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
// Shown after a successful claim only. Plus rides on the game's token as `rn.plus`,
|
||||||
|
// stamped at login, so the copy of it the player is holding still says they have none —
|
||||||
|
// and tokens last a day and are never refreshed. Without this line the claim looks like
|
||||||
|
// it silently did nothing, which is the single most likely support question here.
|
||||||
|
const [relogin, setRelogin] = useState(false)
|
||||||
|
// StrictMode runs effects twice in dev, and a Discord code is single-use: the second
|
||||||
|
// run would redeem a spent code and report a failure over a claim that just worked.
|
||||||
|
const redeemed = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const code = params.get('code')
|
||||||
|
const state = params.get('state')
|
||||||
|
const expected = sessionStorage.getItem(OAUTH_STATE_KEY)
|
||||||
|
|
||||||
|
if (code === null) {
|
||||||
|
// Nothing came back from Discord — either the dashboard tab, or `/claim` opened
|
||||||
|
// directly. Just show where they stand. Discord also returns with
|
||||||
|
// `?error=access_denied` when someone cancels: no code, nothing to say, and the
|
||||||
|
// button is right there to try again.
|
||||||
|
void fetchBenefitsStatus()
|
||||||
|
.then(setStatus)
|
||||||
|
.catch(() => setStatus(undefined))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The return leg. Strip the query first, whatever happens next: the code is spent by
|
||||||
|
// the request below, so a reload must not carry it (and a code has no business
|
||||||
|
// sitting in the address bar, or in whatever the player pastes it into). replaceState
|
||||||
|
// rather than a route change, so Back doesn't walk into a used code either.
|
||||||
|
window.history.replaceState(null, '', '/claim')
|
||||||
|
if (redeemed.current) return
|
||||||
|
redeemed.current = true
|
||||||
|
sessionStorage.removeItem(OAUTH_STATE_KEY)
|
||||||
|
|
||||||
|
// The nonce this tab minted must be the one that came back. A mismatch means the
|
||||||
|
// round-trip wasn't started here, which is exactly what `state` exists to catch.
|
||||||
|
if (state === null || expected === null || state !== expected) {
|
||||||
|
setError('That Discord sign-in did not match this browser. Please start again.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPending(true)
|
||||||
|
claimBenefits(code)
|
||||||
|
.then((result) => {
|
||||||
|
setStatus({ hasPlus: true, linked: true })
|
||||||
|
setDone(
|
||||||
|
result.discordUsername
|
||||||
|
? `Verified as ${result.discordUsername} — Rec Room Plus is now on your account.`
|
||||||
|
: 'Verified — Rec Room Plus is now on your account.'
|
||||||
|
)
|
||||||
|
setRelogin(true)
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setPending(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Read into a local so the narrowing survives into the click handlers below.
|
||||||
|
const authorizeUrl = config.discordAuthorizeUrl
|
||||||
|
if (!config.benefitsEnabled || authorizeUrl === null) {
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Claim benefits</h2>
|
||||||
|
<p className="muted">Benefit claims aren’t available on this server right now.</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimed = status?.hasPlus === true
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Rec Room Plus</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Members of our Discord with a supporter role get Rec Room Plus on their account. Verify with
|
||||||
|
Discord and we’ll check your roles — we only ever read your username and which roles you
|
||||||
|
hold in our server.
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
Claiming as <strong>@{account.username}</strong> (#{account.accountId}). A Discord account
|
||||||
|
can claim on one RecFlare account only.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{done && <p className="ok">{done}</p>}
|
||||||
|
{relogin && (
|
||||||
|
<p className="hint">
|
||||||
|
Restart Rec Room and sign in again to pick it up — your game reads Rec Room Plus from the
|
||||||
|
session it signed in with, so it won’t show until then.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pending ? (
|
||||||
|
<p className="muted">Checking your Discord roles…</p>
|
||||||
|
) : claimed ? (
|
||||||
|
// Already claimed. The button stays, because a player whose roles changed (or who
|
||||||
|
// re-linked) can safely run it again — the claim is idempotent on their own
|
||||||
|
// account — but it no longer reads as the thing to do.
|
||||||
|
<>
|
||||||
|
{!done && (
|
||||||
|
<>
|
||||||
|
<p className="ok">Rec Room Plus is active on this account.</p>
|
||||||
|
<p className="hint">
|
||||||
|
If the game doesn’t show it, sign out and back in — Rec Room Plus is read from the
|
||||||
|
session your game signed in with.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button className="linkish" onClick={() => startDiscordAuth(authorizeUrl)}>
|
||||||
|
Re-verify with Discord
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cta discord"
|
||||||
|
onClick={() => startDiscordAuth(authorizeUrl)}
|
||||||
|
>
|
||||||
|
Verify with Discord
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/claim` — the page Discord redirects back to.
|
||||||
|
*
|
||||||
|
* Not linked from anywhere any more: the claim lives in the account dashboard's "Claim
|
||||||
|
* benefits" tab. This route still has to exist and still has to work on a cold load,
|
||||||
|
* because it is the app's registered `redirect_uri` — the browser arrives here from
|
||||||
|
* Discord carrying the `?code=`, with whatever session it has.
|
||||||
|
*
|
||||||
|
* Signing in comes FIRST, and not only because the grant needs an account to land on: the
|
||||||
|
* bearer token is what tells `www` whose row to write, so a claim without one has no
|
||||||
|
* subject. Hence the sign-in card rather than a redirect — someone who arrives here from a
|
||||||
|
* link should be told what this is before being bounced to a login form.
|
||||||
|
*/
|
||||||
|
function ClaimPage({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
navigate,
|
||||||
|
}: {
|
||||||
|
account: SelfAccount | null | undefined
|
||||||
|
config: SiteConfig | undefined
|
||||||
|
navigate: Navigate
|
||||||
|
}) {
|
||||||
|
if (account === undefined || config === undefined) {
|
||||||
|
return (
|
||||||
|
<main className="shell">
|
||||||
|
<p className="muted">Loading…</p>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account === null) {
|
||||||
|
return (
|
||||||
|
<main className="shell">
|
||||||
|
<h1>Claim your benefits</h1>
|
||||||
|
<section className="card">
|
||||||
|
<h2>Sign in first</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Benefits are granted to a RecFlare account, so we need to know which one is yours before
|
||||||
|
you verify with Discord. If you were part-way through a claim, start it again from your
|
||||||
|
account page once you’re signed in.
|
||||||
|
</p>
|
||||||
|
<Link to="/login" navigate={navigate} className="cta">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="shell">
|
||||||
|
<h1>Claim your benefits</h1>
|
||||||
|
<BenefitsPanel account={account} config={config} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The room id in `/rooms/<id>`, or null for any other path. Numeric rather than the
|
* The room id in `/rooms/<id>`, or null for any other path. Numeric rather than the
|
||||||
* room's name: a name is renameable (`PUT /rooms/{id}/name`), so a link someone
|
* room's name: a name is renameable (`PUT /rooms/{id}/name`), so a link someone
|
||||||
@@ -597,7 +897,12 @@ export function App() {
|
|||||||
.catch(() => setAccount(null))
|
.catch(() => setAccount(null))
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setConfig({ signupEnabled: false, turnstileSiteKey: null })
|
setConfig({
|
||||||
|
signupEnabled: false,
|
||||||
|
turnstileSiteKey: null,
|
||||||
|
benefitsEnabled: false,
|
||||||
|
discordAuthorizeUrl: null,
|
||||||
|
})
|
||||||
setAccount(null)
|
setAccount(null)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
@@ -627,7 +932,11 @@ export function App() {
|
|||||||
onAuthed={setAccount}
|
onAuthed={setAccount}
|
||||||
/>
|
/>
|
||||||
) : path === '/account' ? (
|
) : path === '/account' ? (
|
||||||
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
|
<AccountPage account={account} config={config} navigate={navigate} onChange={setAccount} />
|
||||||
|
) : path === '/claim' ? (
|
||||||
|
// Its own page rather than a dashboard tab: this path is Discord's registered
|
||||||
|
// redirect URI, so it has to be one stable URL a cold load can land on.
|
||||||
|
<ClaimPage account={account} config={config} navigate={navigate} />
|
||||||
) : roomId !== null ? (
|
) : roomId !== null ? (
|
||||||
<RoomPage account={account} roomId={roomId} navigate={navigate} />
|
<RoomPage account={account} roomId={roomId} navigate={navigate} />
|
||||||
) : (
|
) : (
|
||||||
@@ -818,7 +1127,7 @@ function Stage({
|
|||||||
trademark stays out of the headline and appears lower down, in
|
trademark stays out of the headline and appears lower down, in
|
||||||
plain nominative use next to the disclaimer. */}
|
plain nominative use next to the disclaimer. */}
|
||||||
<h1 className="stage-title">
|
<h1 className="stage-title">
|
||||||
Play like it's <em>2023</em>.
|
Play <em>today</em>!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="stage-lede">
|
<p className="stage-lede">
|
||||||
The servers you remember, rebuilt and running — free, open source, and up right now.
|
The servers you remember, rebuilt and running — free, open source, and up right now.
|
||||||
@@ -913,7 +1222,7 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
|||||||
return (
|
return (
|
||||||
<section className="about">
|
<section className="about">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="about-title">An open source rebuild of the 2023 servers</h2>
|
<h2 className="about-title">A cloud architected server for the 2023/2025 game clients</h2>
|
||||||
<p className="about-lede">
|
<p className="about-lede">
|
||||||
A free fan project, made by players who missed it. Aiming to be{' '}
|
A free fan project, made by players who missed it. Aiming to be{' '}
|
||||||
<strong>feature-complete</strong> and infinitely scalable —{' '}
|
<strong>feature-complete</strong> and infinitely scalable —{' '}
|
||||||
@@ -1030,10 +1339,12 @@ function LoginPage({
|
|||||||
/** The signed-in account page. Redirects to sign-in when there's no session. */
|
/** The signed-in account page. Redirects to sign-in when there's no session. */
|
||||||
function AccountPage({
|
function AccountPage({
|
||||||
account,
|
account,
|
||||||
|
config,
|
||||||
navigate,
|
navigate,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
account: SelfAccount | null | undefined
|
account: SelfAccount | null | undefined
|
||||||
|
config: SiteConfig | undefined
|
||||||
navigate: Navigate
|
navigate: Navigate
|
||||||
onChange: (a: SelfAccount) => void
|
onChange: (a: SelfAccount) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -1052,7 +1363,7 @@ function AccountPage({
|
|||||||
return (
|
return (
|
||||||
<main className="shell wide">
|
<main className="shell wide">
|
||||||
<h1>My account</h1>
|
<h1>My account</h1>
|
||||||
<Dashboard account={account} navigate={navigate} onChange={onChange} />
|
<Dashboard account={account} config={config} navigate={navigate} onChange={onChange} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1382,10 +1693,10 @@ function BlobUpload({
|
|||||||
<span className="badge beta">Beta</span>
|
<span className="badge beta">Beta</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="muted blob-upload-caveat">
|
<p className="muted blob-upload-caveat">
|
||||||
New and lightly tested. Nothing here checks the file — the server stores whatever it
|
New and lightly tested. Nothing here checks the file — the server stores whatever it is and
|
||||||
is and the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so
|
the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so scene data
|
||||||
scene data from a room built on anything newer may not load at all. Download the save
|
from a room built on anything newer may not load at all. Download the save above and keep it
|
||||||
above and keep it before replacing it.
|
before replacing it.
|
||||||
</p>
|
</p>
|
||||||
<label className="blob-upload-file">
|
<label className="blob-upload-file">
|
||||||
Scene data file
|
Scene data file
|
||||||
@@ -1776,10 +2087,12 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
|||||||
|
|
||||||
function Dashboard({
|
function Dashboard({
|
||||||
account,
|
account,
|
||||||
|
config,
|
||||||
navigate,
|
navigate,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
account: SelfAccount
|
account: SelfAccount
|
||||||
|
config: SiteConfig | undefined
|
||||||
navigate: Navigate
|
navigate: Navigate
|
||||||
onChange: (a: SelfAccount) => void
|
onChange: (a: SelfAccount) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -1800,10 +2113,22 @@ function Dashboard({
|
|||||||
render: () => <EmailForm account={account} onChange={onChange} />,
|
render: () => <EmailForm account={account} onChange={onChange} />,
|
||||||
},
|
},
|
||||||
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
|
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
|
||||||
|
// Only when the operator has Discord configured — otherwise the panel has nothing to
|
||||||
|
// offer and the tab is a promise the server can't keep. The claim also still lives at
|
||||||
|
// /claim, because that URL is Discord's registered redirect and has to keep working.
|
||||||
|
...(config?.benefitsEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'benefits',
|
||||||
|
label: 'Claim benefits',
|
||||||
|
render: () => <BenefitsPanel account={account} config={config} />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(isAdmin()
|
...(isAdmin()
|
||||||
? [
|
? [
|
||||||
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
|
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
|
||||||
{ id: 'coach', label: 'Broadcast message', render: () => <CoachMessageForm /> },
|
{ id: 'coach', label: 'Coach message', render: () => <CoachMessageForm /> },
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
]
|
]
|
||||||
@@ -1938,28 +2263,61 @@ function RoomCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin-only: send a coach/system message to every online player. */
|
/**
|
||||||
|
* Admin-only: send a coach/system message, either to one player by `@username` or to
|
||||||
|
* everyone online.
|
||||||
|
*
|
||||||
|
* The two go to different endpoints because they behave differently, not just in reach:
|
||||||
|
* the broadcast is online-only (nothing holds a message with no addressee), while a named
|
||||||
|
* recipient's message is queued by the hub and delivered whenever they next connect. The
|
||||||
|
* recipient box therefore says which of those the operator is about to do.
|
||||||
|
*/
|
||||||
function CoachMessageForm() {
|
function CoachMessageForm() {
|
||||||
|
const [recipient, setRecipient] = useState('')
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const { pending, error, done, run } = useAction()
|
const { pending, error, done, run } = useAction()
|
||||||
|
// The `@` is how the name is written, not part of it — accepted either way, shown back
|
||||||
|
// with it, and sent without it.
|
||||||
|
const handle = recipient.trim().replace(/^@/, '')
|
||||||
|
const toOne = handle !== ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>Broadcast message</h2>
|
<h2>Coach message</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Send a message from the Coach to every connected player. Players who aren't online
|
Send a message from the Coach to one player, or leave the recipient blank to send it to
|
||||||
won't receive it.
|
every connected player. A broadcast reaches only who is online right now; a message to one
|
||||||
|
player waits for them if they aren't.
|
||||||
</p>
|
</p>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void run(async () => {
|
void run(async () => {
|
||||||
const { sent } = await coachMessageAll(message.trim())
|
const content = message.trim()
|
||||||
|
if (!toOne) {
|
||||||
|
const { sent } = await coachMessageAll(content)
|
||||||
|
setMessage('')
|
||||||
|
return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.`
|
||||||
|
}
|
||||||
|
// Resolved before sending: the workers address players by id, and a name that
|
||||||
|
// matches nobody should be a refusal rather than a message into the void.
|
||||||
|
const { queued } = await coachMessage(await accountIdForUsername(handle), content)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.`
|
return queued === true
|
||||||
|
? `@${handle} is offline — it will arrive when they next connect.`
|
||||||
|
: `Sent to @${handle}.`
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<label>
|
||||||
|
Send to
|
||||||
|
<input
|
||||||
|
value={recipient}
|
||||||
|
placeholder="@username — blank sends to everyone online"
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => setRecipient(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Message
|
Message
|
||||||
<textarea
|
<textarea
|
||||||
@@ -1972,7 +2330,7 @@ function CoachMessageForm() {
|
|||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
{done && <p className="ok">{done}</p>}
|
{done && <p className="ok">{done}</p>}
|
||||||
<button type="submit" disabled={pending}>
|
<button type="submit" disabled={pending}>
|
||||||
{pending ? 'Sending…' : 'Send to all online'}
|
{pending ? 'Sending…' : toOne ? `Send to @${handle}` : 'Send to all online'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -222,7 +222,8 @@ body {
|
|||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The one place the orange carries meaning in the headline: the year it restores. */
|
/* The one accented word in the headline — it carries the whole emphasis, so the markup
|
||||||
|
always wraps exactly one word in <em>. */
|
||||||
.stage-title em {
|
.stage-title em {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
|||||||
+47
-3
@@ -7,9 +7,11 @@ export type Env = SharedHonoEnv & {
|
|||||||
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
|
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
|
||||||
ASSETS: Fetcher
|
ASSETS: Fetcher
|
||||||
/**
|
/**
|
||||||
* The shared `recflare` D1, bound READ-ONLY in practice: the only thing www asks it
|
* The shared `recflare` D1. www asks it two things: the live presence head-count
|
||||||
* is the live presence head-count behind `/server-status`. Every table it can see is
|
* behind `/server-status`, and the caller's `account` row on the benefits claim —
|
||||||
* owned (and migrated) by another worker.
|
* which is also the one place www WRITES (the `hasPlus`/`discordUserId` pair, through
|
||||||
|
* `@repo/domain`'s `updateAccount`, so the blob's shape stays in one module). Every
|
||||||
|
* table it can see is owned (and migrated) by another worker; www never migrates.
|
||||||
*/
|
*/
|
||||||
DB: D1Database
|
DB: D1Database
|
||||||
/**
|
/**
|
||||||
@@ -39,6 +41,48 @@ export type Env = SharedHonoEnv & {
|
|||||||
* failing to resolve closes web signup — see src/turnstile.ts.
|
* failing to resolve closes web signup — see src/turnstile.ts.
|
||||||
*/
|
*/
|
||||||
TURNSTILE_SECRET_KEY: SecretsStoreSecret
|
TURNSTILE_SECRET_KEY: SecretsStoreSecret
|
||||||
|
/**
|
||||||
|
* The HS256 signing key every worker shares, out of the same account-level Secrets
|
||||||
|
* Store. www needs it for ONE thing: the benefits claim is the only route here that
|
||||||
|
* acts on behalf of a specific account (it writes `hasPlus` onto it), so it has to
|
||||||
|
* establish WHICH account is calling rather than take the SPA's word for it. Every
|
||||||
|
* other www route is either anonymous or hands the token straight to another worker.
|
||||||
|
*
|
||||||
|
* Resolve with `await env.JWT_SECRET.get()`; validate through `@repo/jwt` so the
|
||||||
|
* signature/exp checks are the ones every other worker runs.
|
||||||
|
*/
|
||||||
|
JWT_SECRET: SecretsStoreSecret
|
||||||
|
/**
|
||||||
|
* The Discord application's client id. PUBLIC — it ships to the browser, which needs
|
||||||
|
* it to build the authorize URL — but kept in the Secrets Store beside its secret so
|
||||||
|
* one place configures the claim, exactly as TURNSTILE_SITE_KEY is.
|
||||||
|
*/
|
||||||
|
DISCORD_CLIENT_ID: SecretsStoreSecret
|
||||||
|
/**
|
||||||
|
* The Discord application's client secret — what turns an authorization code into an
|
||||||
|
* access token. Never leaves this worker (see src/discord.ts).
|
||||||
|
*/
|
||||||
|
DISCORD_CLIENT_SECRET: SecretsStoreSecret
|
||||||
|
/**
|
||||||
|
* The guild (Discord server) whose membership the benefits claim checks, and the roles
|
||||||
|
* within it that entitle a player to the benefits. Both hold Discord SNOWFLAKES — all
|
||||||
|
* digits, never a role's display name — kept as strings because a snowflake exceeds
|
||||||
|
* 2^53. Plain vars rather than secrets: any member of the server can read these off
|
||||||
|
* their own client, and none of them authorizes anything on its own.
|
||||||
|
*
|
||||||
|
* OPTIONAL because an operator who hasn't set up Discord has neither, and that is a
|
||||||
|
* supported state: it CLOSES the claim (see `discordConfig`) rather than opening an
|
||||||
|
* unverified one.
|
||||||
|
*/
|
||||||
|
DISCORD_GUILD_ID?: string
|
||||||
|
/**
|
||||||
|
* The role ids inside DISCORD_GUILD_ID that grant Rec Room Plus — numeric snowflakes,
|
||||||
|
* separated by commas and/or whitespace, e.g. `"1077000000000000001,1077000000000000002"`.
|
||||||
|
* ANY one of them qualifies, so several tiers (a supporter role, a booster role, staff)
|
||||||
|
* can share the same benefit. Parsed by `parseRoleIds`; a value that parses to no ids at
|
||||||
|
* all closes the claim, exactly as an unset one does.
|
||||||
|
*/
|
||||||
|
DISCORD_BENEFITS_ROLE_IDS?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Variables can be extended */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { logger } from '@repo/hono-helpers'
|
||||||
|
|
||||||
|
import type { Env } from './context'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discord OAuth2, the identity check behind the website's benefits claim.
|
||||||
|
*
|
||||||
|
* A player proves they hold one of the qualifying roles in the community Discord, and
|
||||||
|
* the claim grants them Rec Room Plus (`account.hasPlus`). The proof is a real OAuth2
|
||||||
|
* AUTHORIZATION CODE exchange, not a token the browser hands us: the SPA sends only the
|
||||||
|
* short-lived `code` Discord redirected it back with, and this worker swaps that for an
|
||||||
|
* access token using the client SECRET, which — like the Turnstile secret next door —
|
||||||
|
* can never ship to a page. The access token therefore never exists in the browser at
|
||||||
|
* all, and it is revoked here the moment the roles have been read.
|
||||||
|
*
|
||||||
|
* Roles come from `GET /users/@me/guilds/{guild}/member`, which needs no bot: the
|
||||||
|
* `guilds.members.read` scope lets the TOKEN'S OWNER read their own membership. That is
|
||||||
|
* the whole reason this shape was chosen over a bot token — nothing here has to be in
|
||||||
|
* the guild, and the worker holds no credential that could read anybody else's roles.
|
||||||
|
*
|
||||||
|
* The four settings (client id, client secret, guild, one or more roles) are the switch,
|
||||||
|
* exactly as the Turnstile keypair is for signup: with any of them missing the claim is CLOSED
|
||||||
|
* (`/api/config` says so and `/api/benefits/claim` refuses) rather than open and
|
||||||
|
* unverified. Nothing is ever inferred from the environment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Discord's API, pinned to v10 — the version the endpoints below are documented at. */
|
||||||
|
const API_BASE = 'https://discord.com/api/v10'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the browser is sent to consent. Deliberately NOT under `/api/v10`: the authorize
|
||||||
|
* page is a human-facing page on the main site, and the versioned path serves a redirect
|
||||||
|
* to it at best.
|
||||||
|
*/
|
||||||
|
export const AUTHORIZE_URL = 'https://discord.com/oauth2/authorize'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scopes the claim asks for, in the order Discord shows them on the consent screen.
|
||||||
|
*
|
||||||
|
* - `identify` — the user's own id, which the claim stores as a `PlatformType.Discord`
|
||||||
|
* link on the account to keep itself once-only.
|
||||||
|
* - `guilds.members.read` — their member record (and so their ROLES) in one guild they
|
||||||
|
* are in. Narrower than `guilds`, which lists every server they belong to and is not
|
||||||
|
* needed: the claim asks about exactly one guild.
|
||||||
|
*
|
||||||
|
* A space-joined string because that is how the authorize URL takes them.
|
||||||
|
*/
|
||||||
|
export const SCOPES = 'identify guilds.members.read'
|
||||||
|
|
||||||
|
/** Everything the claim needs configured. Resolved per request; see `discordConfig`. */
|
||||||
|
export interface DiscordConfig {
|
||||||
|
/** The application's client id. PUBLIC — it ships to the browser in the authorize URL. */
|
||||||
|
clientId: string
|
||||||
|
/** The application's client secret. Never leaves this worker. */
|
||||||
|
clientSecret: string
|
||||||
|
/** The guild (server) whose membership is checked. */
|
||||||
|
guildId: string
|
||||||
|
/**
|
||||||
|
* The role ids within that guild that entitle a player to the benefits — Discord
|
||||||
|
* snowflakes, all digits. ANY one of them qualifies: they're alternatives (a supporter
|
||||||
|
* role, a booster role, staff…), not requirements, so this is a set to test membership
|
||||||
|
* against and never an ordered list. Always at least one entry — an empty list closes
|
||||||
|
* the claim (see `discordConfig`).
|
||||||
|
*/
|
||||||
|
roleIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the configured role ids — Discord snowflakes, so each one is ALL DIGITS (a role's
|
||||||
|
* display name is not an id and will never match anything). They stay strings rather than
|
||||||
|
* becoming numbers: a snowflake exceeds 2^53, and they are only ever compared, never done
|
||||||
|
* arithmetic on.
|
||||||
|
*
|
||||||
|
* Separated by commas and/or whitespace, so a value pasted out of Discord one id per line
|
||||||
|
* works as well as `1077000000000000001,1077000000000000002` does; blank entries are
|
||||||
|
* dropped, which is what makes a trailing comma harmless rather than a role id of `''`
|
||||||
|
* that nothing can ever match.
|
||||||
|
*
|
||||||
|
* The digits are not ENFORCED here, deliberately. A typo'd snowflake is indistinguishable
|
||||||
|
* from a real role nobody holds, and both correctly result in a claim being refused, so a
|
||||||
|
* format rule would buy nothing but a way to reject a valid id if Discord ever widens the
|
||||||
|
* format. Misconfiguration shows up as "nobody can claim", which is the safe direction.
|
||||||
|
*/
|
||||||
|
export const parseRoleIds = (raw: string): string[] =>
|
||||||
|
raw
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter((id) => id !== '')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Discord settings, or null when the claim isn't configured — which is what CLOSES
|
||||||
|
* it. All four must be present, and the role list must parse to at least ONE id: a client
|
||||||
|
* id with no roles would authenticate a player and then have no question to ask about
|
||||||
|
* them, and treating that as "configured" would hand Plus to anyone with a Discord
|
||||||
|
* account.
|
||||||
|
*
|
||||||
|
* Which of the four is missing is logged (never their values) because a half-configured
|
||||||
|
* app is otherwise indistinguishable from an operator deliberately leaving benefits off.
|
||||||
|
*
|
||||||
|
* The id and secret come from the account-level Secrets Store the whole monorepo shares,
|
||||||
|
* so they're read per request rather than off `env` as strings; `.get()` caches per
|
||||||
|
* isolate, so changing either needs a `www` redeploy to take effect on a warm worker —
|
||||||
|
* the same caveat TURNSTILE_* and JWT_SECRET carry. The guild and roles are plain vars:
|
||||||
|
* they're server ids visible to every member, not credentials.
|
||||||
|
*/
|
||||||
|
export async function discordConfig(env: Env): Promise<DiscordConfig | null> {
|
||||||
|
const [clientId, clientSecret] = await Promise.all([
|
||||||
|
readSecret(env.DISCORD_CLIENT_ID, 'DISCORD_CLIENT_ID'),
|
||||||
|
readSecret(env.DISCORD_CLIENT_SECRET, 'DISCORD_CLIENT_SECRET'),
|
||||||
|
])
|
||||||
|
const guildId = env.DISCORD_GUILD_ID ?? ''
|
||||||
|
const roleIds = parseRoleIds(env.DISCORD_BENEFITS_ROLE_IDS ?? '')
|
||||||
|
|
||||||
|
if (clientId !== '' && clientSecret !== '' && guildId !== '' && roleIds.length > 0) {
|
||||||
|
return { clientId, clientSecret, guildId, roleIds }
|
||||||
|
}
|
||||||
|
if (clientId !== '' || clientSecret !== '' || guildId !== '' || roleIds.length > 0) {
|
||||||
|
logger.error('discord is half-configured, so benefit claims are closed', {
|
||||||
|
hasClientId: clientId !== '',
|
||||||
|
hasClientSecret: clientSecret !== '',
|
||||||
|
hasGuildId: guildId !== '',
|
||||||
|
// The COUNT, not the ids: a value that parsed to nothing (say, a stray comma) is
|
||||||
|
// indistinguishable from an unset one without it.
|
||||||
|
roleIdCount: roleIds.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Secrets Store value as a string, or '' when it can't be read. The binding is
|
||||||
|
* declared in wrangler.jsonc so it's always on `env`; what varies is whether the store
|
||||||
|
* holds the secret — a missing one throws rather than resolving empty. Mirrors
|
||||||
|
* `turnstile.ts`'s reader, and for the same reason: a store this worker can't read must
|
||||||
|
* close the feature, not 500 the homepage.
|
||||||
|
*/
|
||||||
|
async function readSecret(secret: SecretsStoreSecret, name: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return (await secret.get()) ?? ''
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('failed to read a discord credential from the secrets store', {
|
||||||
|
secret: name,
|
||||||
|
error: String(err),
|
||||||
|
})
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URI Discord redirects back to after consent, derived from the request rather than
|
||||||
|
* configured.
|
||||||
|
*
|
||||||
|
* It must be byte-identical in three places — the authorize URL the browser opens, the
|
||||||
|
* token exchange below, and the app's registered redirect list — or Discord refuses the
|
||||||
|
* exchange. Deriving it from the incoming request's own origin is what keeps the first
|
||||||
|
* two in step across every environment (localhost in dev, the real domain in
|
||||||
|
* production) with nothing to configure, and it is also why the SPA does NOT get to
|
||||||
|
* supply it in the request body: an attacker-supplied redirect would turn this worker's
|
||||||
|
* client secret into a redemption oracle for codes issued to somebody else's app page.
|
||||||
|
*
|
||||||
|
* `/claim` is the SPA route that handles the return; see App.tsx.
|
||||||
|
*/
|
||||||
|
export const redirectUri = (request: Request): string => new URL('/claim', request.url).toString()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swap an authorization code for an access token. Returns null on any refusal — a code
|
||||||
|
* that was already spent, expired (they live ~1 minute), issued to another app, or paired
|
||||||
|
* with a different redirect URI all land here, and none of them is worth telling the
|
||||||
|
* browser apart: the answer is the same, start the flow again.
|
||||||
|
*
|
||||||
|
* The credentials go in the BODY rather than a Basic auth header. Both are legal and
|
||||||
|
* Discord documents the body form.
|
||||||
|
*/
|
||||||
|
export async function exchangeCode(
|
||||||
|
config: DiscordConfig,
|
||||||
|
code: string,
|
||||||
|
redirect: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/oauth2/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: redirect,
|
||||||
|
}).toString(),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
// The body carries an OAuth error code (`invalid_grant`, `invalid_client`) — the
|
||||||
|
// last of which is a misconfiguration, not a player mistake, and this line is the
|
||||||
|
// only place it surfaces. Logged, never relayed: it tells a caller nothing.
|
||||||
|
logger.info('discord refused a code exchange', {
|
||||||
|
status: res.status,
|
||||||
|
body: await res.text().catch(() => ''),
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const token = (await res.json()) as { access_token?: unknown }
|
||||||
|
return typeof token.access_token === 'string' ? token.access_token : null
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('could not reach discord to exchange a code', { error: String(err) })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a member holds ANY of the qualifying roles. Both sides are snowflake id
|
||||||
|
* strings, compared exactly — Discord reports a member's roles as ids, never as names.
|
||||||
|
*
|
||||||
|
* The roles are alternatives (a supporter, a booster and a staff member all qualify), so
|
||||||
|
* this is an intersection test and not a subset one: requiring all of them would mean
|
||||||
|
* nobody ever claimed.
|
||||||
|
*/
|
||||||
|
export const qualifies = (memberRoles: string[], roleIds: string[]): boolean =>
|
||||||
|
memberRoles.some((role) => roleIds.includes(role))
|
||||||
|
|
||||||
|
/** Who claimed, and what they hold in the guild. */
|
||||||
|
export interface GuildMembership {
|
||||||
|
/** The Discord user's id (a snowflake, kept as a string — it exceeds 2^53). */
|
||||||
|
userId: string
|
||||||
|
/** Their Discord username, for the confirmation line. Display only, never stored. */
|
||||||
|
username: string
|
||||||
|
/** Their role ids in the guild. */
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The token owner's membership in the configured guild, or null when they aren't in it
|
||||||
|
* (Discord answers 404) or the call fails.
|
||||||
|
*
|
||||||
|
* `null` deliberately conflates "not a member" with "we couldn't ask". Both mean the same
|
||||||
|
* thing to the claim — no proof was obtained — and a claim that granted benefits when
|
||||||
|
* Discord was unreachable would be worse than one that asks the player to retry.
|
||||||
|
*/
|
||||||
|
export async function fetchGuildMembership(
|
||||||
|
accessToken: string,
|
||||||
|
guildId: string
|
||||||
|
): Promise<GuildMembership | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/users/@me/guilds/${guildId}/member`, {
|
||||||
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
// 404 is the ordinary "they aren't in the server" answer, so it's info, not error.
|
||||||
|
logger.info('discord did not return a guild membership', { status: res.status })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const member = (await res.json()) as {
|
||||||
|
user?: { id?: unknown; username?: unknown }
|
||||||
|
roles?: unknown
|
||||||
|
}
|
||||||
|
const userId = typeof member.user?.id === 'string' ? member.user.id : ''
|
||||||
|
if (userId === '') {
|
||||||
|
logger.error('discord returned a guild member with no user id')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
username: typeof member.user?.username === 'string' ? member.user.username : '',
|
||||||
|
roles: Array.isArray(member.roles)
|
||||||
|
? member.roles.filter((r): r is string => typeof r === 'string')
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('could not reach discord to read a guild membership', { error: String(err) })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the access token back to Discord once the roles have been read.
|
||||||
|
*
|
||||||
|
* Best-effort and deliberately un-awaited-on by the caller's success path: the claim has
|
||||||
|
* already been decided by this point, so a failed revoke must not fail it. It's here
|
||||||
|
* because the token is useless to us after one read and a live token is a liability for
|
||||||
|
* however long it would otherwise last (a week) — this keeps the credential's lifetime
|
||||||
|
* about as long as the request that needed it.
|
||||||
|
*/
|
||||||
|
export async function revokeToken(config: DiscordConfig, accessToken: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/oauth2/token/revoke`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
token: accessToken,
|
||||||
|
token_type_hint: 'access_token',
|
||||||
|
}).toString(),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
logger.info('could not revoke a discord access token', { error: String(err) })
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user