mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+46
-5
@@ -87,6 +87,11 @@ 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
|
||||||
|
|
||||||
# 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 +113,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 +155,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
|
||||||
|
|||||||
+2
-1
@@ -194,10 +194,11 @@ its built-in default: copy the lines you want to change into your `.env`, uncomm
|
|||||||
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_MAX_UPLOAD_BYTES` | `storage` | `67108864` | Maximum binary upload size; larger files receive HTTP 413. |
|
||||||
| `RECFLARE_ROOM_REDIRECTS` | `match` | unset | Rooms to switch out on matchmake, e.g. `2=MyHub`. |
|
| `RECFLARE_ROOM_REDIRECTS` | `match` | unset | Rooms to switch out on matchmake, e.g. `2=MyHub`. |
|
||||||
| `RECFLARE_PHOTON_REALTIME_APP_ID` | `match` | empty | Your Photon Realtime app id. No app ships with recflare. |
|
| `RECFLARE_PHOTON_REALTIME_APP_ID` | `match` | empty | Your Photon Realtime app id. No app ships with recflare. |
|
||||||
| `RECFLARE_PHOTON_VOICE_APP_ID` | `match` | empty | Your Photon Voice app id. |
|
| `RECFLARE_PHOTON_VOICE_APP_ID` | `match` | empty | Your Photon Voice app id. |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -20,7 +20,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",
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,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.
|
||||||
|
|||||||
@@ -149,6 +149,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,7 +169,7 @@ 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: '',
|
||||||
@@ -181,7 +190,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 () => {
|
||||||
@@ -235,7 +244,7 @@ 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 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 +269,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)
|
||||||
@@ -565,12 +590,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`, {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+419
-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[] = [
|
||||||
@@ -53,6 +53,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 +113,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 +163,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 +440,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 +465,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 +502,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 +522,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 +659,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 +701,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 +778,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 +791,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 +819,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 +831,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 +841,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 +891,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 +922,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 +952,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 +960,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 +989,55 @@ 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.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run()
|
||||||
|
return invention
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 +1114,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>()
|
||||||
|
|||||||
+290
-19
@@ -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. */
|
||||||
@@ -420,6 +422,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 +462,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 +495,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()),
|
||||||
@@ -506,6 +614,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 +711,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 ------------------------------------------
|
||||||
@@ -1036,24 +1242,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 +1342,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,
|
||||||
|
|||||||
+463
-61
@@ -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,6 +38,10 @@ import {
|
|||||||
getInventionVersion,
|
getInventionVersion,
|
||||||
getMyInventions,
|
getMyInventions,
|
||||||
getTopInventions,
|
getTopInventions,
|
||||||
|
INVENTION_TAG_RESULT,
|
||||||
|
inventionDeleteResult,
|
||||||
|
inventionSaveV9Failure,
|
||||||
|
normalizeInventionTags,
|
||||||
ownsAllInventions,
|
ownsAllInventions,
|
||||||
parsePermissionLevel,
|
parsePermissionLevel,
|
||||||
publishInvention,
|
publishInvention,
|
||||||
@@ -42,6 +49,7 @@ import {
|
|||||||
setInventionPrice,
|
setInventionPrice,
|
||||||
setInventionTags,
|
setInventionTags,
|
||||||
toSaveResult,
|
toSaveResult,
|
||||||
|
toSaveResultV9,
|
||||||
updateInvention,
|
updateInvention,
|
||||||
} from '../inventions-db'
|
} from '../inventions-db'
|
||||||
import {
|
import {
|
||||||
@@ -54,17 +62,20 @@ import {
|
|||||||
CustomAvatarItemReportRequest,
|
CustomAvatarItemReportRequest,
|
||||||
CustomAvatarItemResponse,
|
CustomAvatarItemResponse,
|
||||||
CustomAvatarItemsPage,
|
CustomAvatarItemsPage,
|
||||||
|
DeleteInventionRequest,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
form,
|
form,
|
||||||
GeneratedGift,
|
GeneratedGift,
|
||||||
GenerateGiftRequest,
|
GenerateGiftRequest,
|
||||||
idParam,
|
idParam,
|
||||||
intQuery,
|
intQuery,
|
||||||
|
InventionDeleteResult,
|
||||||
InventionDetails,
|
InventionDetails,
|
||||||
InventionDto,
|
InventionDto,
|
||||||
InventionPersonalDetails,
|
InventionPersonalDetails,
|
||||||
InventionReportRequest,
|
InventionReportRequest,
|
||||||
InventionSaveResult,
|
InventionSaveResult,
|
||||||
|
InventionSaveV9Result,
|
||||||
InventionVersionDto,
|
InventionVersionDto,
|
||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
@@ -77,7 +88,9 @@ import {
|
|||||||
OutfitsMeRequest,
|
OutfitsMeRequest,
|
||||||
OutfitsMeResponse,
|
OutfitsMeResponse,
|
||||||
pageParams,
|
pageParams,
|
||||||
|
PublishInventionRequest,
|
||||||
SaveInventionRequest,
|
SaveInventionRequest,
|
||||||
|
SaveInventionV9Request,
|
||||||
SetTagsRequest,
|
SetTagsRequest,
|
||||||
SetTagsResponse,
|
SetTagsResponse,
|
||||||
stringParam,
|
stringParam,
|
||||||
@@ -87,13 +100,14 @@ 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 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 +147,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) }
|
||||||
}
|
}
|
||||||
const invention = await getInventionById(c.env.DB, inventionId)
|
|
||||||
if (invention === null) return { response: c.notFound() }
|
/**
|
||||||
if (invention.CreatorPlayerId !== playerId) {
|
* The tags a `{ AutoTags, CustomTags }` request asks for, and whether they were taken —
|
||||||
return { response: c.json({ error: 'Not your invention' }, 403) }
|
* 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 }
|
||||||
}
|
}
|
||||||
return { invention }
|
|
||||||
|
/**
|
||||||
|
* 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 createInvention(c.env.DB, c.env.CDN_ASSETS, {
|
||||||
|
creatorPlayerId,
|
||||||
|
inventionDataFilename,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
imageName: str(body.imageName),
|
||||||
|
instantiationCost: num(body.instantiationCost),
|
||||||
|
lightsCost: num(body.lightsCost),
|
||||||
|
chipsCost: num(body.chipsCost),
|
||||||
|
cloudVariablesCost: num(body.cloudVariablesCost),
|
||||||
|
aiCost: num(body.aiCost),
|
||||||
|
creationRoomId: num(body.creationRoomId),
|
||||||
|
referencedInventions: list(body.referencedInventions, isNumber),
|
||||||
|
ugcVersion: num(body.ugcVersion),
|
||||||
|
hasBetaContent: bool(body.hasBetaContent),
|
||||||
|
referencedUnityAssetIds: list(body.referencedUnityAssetIds, isString),
|
||||||
|
longDescription: str(body.longDescription),
|
||||||
|
displayMetadataJson: str(body.displayMetadataJson),
|
||||||
|
convertedFromInventionId: num(body.convertedFromInventionId),
|
||||||
|
tags: requested.tags,
|
||||||
|
})
|
||||||
|
return { invention, ...requested }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -882,29 +1022,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))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1216,12 +1373,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),
|
||||||
Number.isNaN(price) || price < 0 ? undefined : price
|
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))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1670,43 +1826,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()
|
}
|
||||||
if (!inventionDataFilename) {
|
)
|
||||||
return c.json({ error: 'inventionDataFilename is required' }, 400)
|
|
||||||
}
|
// The same save as the newer client sends it: v6's body plus the invention's
|
||||||
|
// references, its long description and display metadata, what the saved blob is, and
|
||||||
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
|
// the tags — which v6 clients set afterwards through `v1/settags`. It stores the same
|
||||||
// "No description yet"), so only a supplied one is held to the rules — otherwise
|
// record; what differs is the REPLY, which is enveloped. See `InventionSaveV9Result`:
|
||||||
// saving an unnamed invention would fail the 3-character minimum on a name the
|
// the client reads `Success` and then `Value.Invention.InventionId`, and a body that
|
||||||
// player never typed.
|
// isn't this envelope — a bare `{ error }`, or the empty 401 the other routes answer —
|
||||||
const name = str(body.name)?.trim()
|
// takes it down rather than failing it, which is why every branch below answers one.
|
||||||
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
|
.post(
|
||||||
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
|
'/api/inventions/v9/save',
|
||||||
|
describeRoute({
|
||||||
const description = str(body.description)
|
tags: ['Inventions'],
|
||||||
const descriptionRejection =
|
summary: 'Save a new invention (v9)',
|
||||||
description === undefined ? null : inventionDescriptionRejection(description)
|
description:
|
||||||
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
|
'`v6/save` plus the fields the newer client sends: `referencedUnityAssetIds`, ' +
|
||||||
|
'`longDescription`, `displayMetadataJson`, `convertedFromInventionId`, ' +
|
||||||
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
|
'`ugcVersion`, `hasBetaContent`, and a `tagsRequest` carrying the same ' +
|
||||||
creatorPlayerId: id,
|
'`AutoTags`/`CustomTags` lists `v1/settags` takes. Every one is optional and is ' +
|
||||||
inventionDataFilename,
|
'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')
|
||||||
|
|
||||||
|
for (const rejection of [
|
||||||
|
name === undefined ? null : inventionNameRejection(name),
|
||||||
|
description === undefined ? null : inventionDescriptionRejection(description),
|
||||||
|
longDescription === undefined
|
||||||
|
? null
|
||||||
|
: inventionLongDescriptionRejection(longDescription),
|
||||||
|
]) {
|
||||||
|
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A null TagsRequest leaves the stored tags alone, and the reply still reports
|
||||||
|
// them: the client reads the list back as the tags the invention now has, not as
|
||||||
|
// the ones this call changed.
|
||||||
|
const requested = requestedTags(body.TagsRequest)
|
||||||
|
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||||||
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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
// A distinct account, so this doesn't depend on what the tests above saved for 42.
|
||||||
|
const saved = async (sub: string) => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
|
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
|
||||||
headers: await bearer(),
|
headers: await bearer(sub),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual([])
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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.
|
||||||
@@ -3286,6 +4022,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 +4548,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', () => {
|
||||||
@@ -6300,9 +7509,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',
|
||||||
@@ -6317,7 +7528,10 @@ describe('openapi', () => {
|
|||||||
'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',
|
||||||
@@ -6350,6 +7564,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',
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
|
[]
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -448,6 +448,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,
|
||||||
|
|||||||
@@ -176,6 +176,25 @@ const app = new Hono<App>()
|
|||||||
return c.json({ success: true, ...result })
|
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 result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).coachMessage(
|
||||||
|
body.playerId,
|
||||||
|
content
|
||||||
|
)
|
||||||
|
return c.json({ success: true, ...result })
|
||||||
|
})
|
||||||
|
|
||||||
// Read-only view of the hub's routing state, for working out why a notification
|
// Read-only view of the hub's routing state, for working out why a notification
|
||||||
// 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.
|
||||||
|
|||||||
@@ -65,7 +65,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 +118,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 +342,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)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
@@ -1818,6 +1835,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 +3016,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 +3041,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 +4069,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 +4105,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.',
|
||||||
}
|
}
|
||||||
|
|||||||
+374
-16
@@ -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('')
|
setMessage('')
|
||||||
return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.`
|
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('')
|
||||||
|
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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||||
import { beforeAll, expect, it } from 'vitest'
|
import { beforeAll, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { SCHEMA_DDL as ACCOUNT_SCHEMA_DDL, updateAccount } from '@repo/domain/src/accounts-db'
|
||||||
|
import { PlatformType } from '@repo/domain/src/enums'
|
||||||
import { PRESENCE_SCHEMA_DDL, PRESENCE_TTL_SECONDS } from '@repo/domain/src/presence-db'
|
import { PRESENCE_SCHEMA_DDL, PRESENCE_TTL_SECONDS } from '@repo/domain/src/presence-db'
|
||||||
|
import { generateToken } from '@repo/jwt'
|
||||||
|
|
||||||
|
import {
|
||||||
|
CACHED_LOGIN_PLATFORMS,
|
||||||
|
countAccountsForPlatformIdentity,
|
||||||
|
isPlatformIdentityLinked,
|
||||||
|
linkPlatformIdentity,
|
||||||
|
PLATFORM_SCHEMA_DDL,
|
||||||
|
} from '../../../../auth/src/platform-db'
|
||||||
|
import { discordConfig, parseRoleIds, qualifies } from '../../discord'
|
||||||
import { DOCUMENTED_SERVICES } from '../../docs'
|
import { DOCUMENTED_SERVICES } from '../../docs'
|
||||||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
||||||
import { turnstileKeys } from '../../turnstile'
|
import { turnstileKeys } from '../../turnstile'
|
||||||
@@ -21,12 +32,37 @@ declare module 'cloudflare:test' {
|
|||||||
const TEST_SITE_KEY = '1x00000000000000000000AA'
|
const TEST_SITE_KEY = '1x00000000000000000000AA'
|
||||||
const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA'
|
const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA'
|
||||||
|
|
||||||
|
// The shared HS256 key. www verifies tokens itself for exactly one route (the benefits
|
||||||
|
// claim), so the tests have to be able to MINT one — hence a known value here rather than
|
||||||
|
// whatever a deployed store holds.
|
||||||
|
const TEST_JWT_SECRET = 'test-jwt-secret'
|
||||||
|
|
||||||
|
/** A bearer token for `accountId`, signed the way `auth` signs one. */
|
||||||
|
const tokenFor = (accountId: number): Promise<string> =>
|
||||||
|
generateToken(String(accountId), '', 4, TEST_JWT_SECRET)
|
||||||
|
|
||||||
|
// A Discord app that is HALF configured: credentials seeded below, but wrangler.jsonc
|
||||||
|
// leaves DISCORD_GUILD_ID / DISCORD_BENEFITS_ROLE_IDS empty. This is deliberately the most
|
||||||
|
// dangerous half — an operator who registers an app and stops has something that can sign
|
||||||
|
// a player in and no question left to ask about them — so it is the state the route-level
|
||||||
|
// tests pin: the claim must still be CLOSED. The fully-configured path is covered by
|
||||||
|
// unit-testing `discordConfig`, since exercising it end to end would call discord.com.
|
||||||
|
const TEST_DISCORD_CLIENT_ID = 'test-discord-client-id'
|
||||||
|
const TEST_DISCORD_CLIENT_SECRET = 'test-discord-client-secret'
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY)
|
await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY)
|
||||||
await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_KEY)
|
await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_KEY)
|
||||||
|
await adminSecretsStore(env.JWT_SECRET).create(TEST_JWT_SECRET)
|
||||||
|
await adminSecretsStore(env.DISCORD_CLIENT_ID).create(TEST_DISCORD_CLIENT_ID)
|
||||||
|
await adminSecretsStore(env.DISCORD_CLIENT_SECRET).create(TEST_DISCORD_CLIENT_SECRET)
|
||||||
// `presence` is owned (and migrated) by other workers — www only reads it — so the
|
// `presence` is owned (and migrated) by other workers — www only reads it — so the
|
||||||
// table has to be created here for the head-count behind /server-status.
|
// table has to be created here for the head-count behind /server-status.
|
||||||
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()
|
||||||
|
// `account` likewise: owned by `auth`, read and (for the benefits claim) written here.
|
||||||
|
for (const stmt of ACCOUNT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
// And `platform_account`, where a claimed Discord identity is linked.
|
||||||
|
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
|
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
|
||||||
@@ -42,6 +78,11 @@ it('advertises signup and where the other workers live', async () => {
|
|||||||
expect(await res.json()).toEqual({
|
expect(await res.json()).toEqual({
|
||||||
signupEnabled: true,
|
signupEnabled: true,
|
||||||
turnstileSiteKey: TEST_SITE_KEY,
|
turnstileSiteKey: TEST_SITE_KEY,
|
||||||
|
// Closed, because the Discord app here has no guild/role to check against — and with
|
||||||
|
// it closed the SPA is given no authorize URL to send anyone to, so the claim can't
|
||||||
|
// even be started. Note the client id is NOT leaked by a closed config.
|
||||||
|
benefitsEnabled: false,
|
||||||
|
discordAuthorizeUrl: null,
|
||||||
hosts: {
|
hosts: {
|
||||||
auth: 'https://auth.rec.example.com',
|
auth: 'https://auth.rec.example.com',
|
||||||
accounts: 'https://accounts.rec.example.com',
|
accounts: 'https://accounts.rec.example.com',
|
||||||
@@ -281,6 +322,220 @@ it('404s a spec proxy for an unknown service (not an open proxy)', async () => {
|
|||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- Discord benefits claim ------------------------------------------------
|
||||||
|
|
||||||
|
// All four settings are the switch, exactly as the Turnstile keypair is for signup: a
|
||||||
|
// half-configured app must read as OFF. The dangerous half is a client id and secret with
|
||||||
|
// no guild/role — that authenticates a player and then has no question left to ask about
|
||||||
|
// them, so treating it as configured would hand Rec Room Plus to anyone with a Discord
|
||||||
|
// account. Checked directly because the configured path can't be reached from here (it
|
||||||
|
// would call discord.com for real).
|
||||||
|
it('treats a half-configured discord app as benefit claims being off', async () => {
|
||||||
|
const stub = (value: string | null): SecretsStoreSecret =>
|
||||||
|
({ get: async () => value ?? '' }) as SecretsStoreSecret
|
||||||
|
const throws = (): SecretsStoreSecret =>
|
||||||
|
({
|
||||||
|
get: async () => {
|
||||||
|
throw new Error('secret not found')
|
||||||
|
},
|
||||||
|
}) as unknown as SecretsStoreSecret
|
||||||
|
|
||||||
|
const withDiscord = (
|
||||||
|
id: SecretsStoreSecret,
|
||||||
|
secret: SecretsStoreSecret,
|
||||||
|
guildId?: string,
|
||||||
|
roleIds?: string
|
||||||
|
) =>
|
||||||
|
({
|
||||||
|
ENVIRONMENT: 'development',
|
||||||
|
DISCORD_CLIENT_ID: id,
|
||||||
|
DISCORD_CLIENT_SECRET: secret,
|
||||||
|
DISCORD_GUILD_ID: guildId,
|
||||||
|
DISCORD_BENEFITS_ROLE_IDS: roleIds,
|
||||||
|
}) as Env
|
||||||
|
|
||||||
|
const id = stub('client-id')
|
||||||
|
const secret = stub('client-secret')
|
||||||
|
// Snowflakes, as the real vars hold: ids are all digits, never a role's display name.
|
||||||
|
const guild = '1077000000000000000'
|
||||||
|
const role = '1077000000000000001'
|
||||||
|
|
||||||
|
// Nothing at all, and a store this worker can't read: both closed, never a 500.
|
||||||
|
await expect(discordConfig(withDiscord(throws(), throws()))).resolves.toBeNull()
|
||||||
|
await expect(discordConfig(withDiscord(stub(''), stub(''), '', ''))).resolves.toBeNull()
|
||||||
|
// Each single missing piece, including the two that would otherwise grant Plus for a
|
||||||
|
// bare Discord login.
|
||||||
|
await expect(discordConfig(withDiscord(throws(), secret, guild, role))).resolves.toBeNull()
|
||||||
|
await expect(discordConfig(withDiscord(id, throws(), guild, role))).resolves.toBeNull()
|
||||||
|
await expect(discordConfig(withDiscord(id, secret, '', role))).resolves.toBeNull()
|
||||||
|
await expect(discordConfig(withDiscord(id, secret, guild, ''))).resolves.toBeNull()
|
||||||
|
await expect(discordConfig(withDiscord(id, secret))).resolves.toBeNull()
|
||||||
|
// A role list that parses to NO ids is unset, not configured — otherwise a stray comma
|
||||||
|
// left in the var would open the claim with nothing to check against.
|
||||||
|
await expect(discordConfig(withDiscord(id, secret, guild, ' , , '))).resolves.toBeNull()
|
||||||
|
// All four present is the only configured state.
|
||||||
|
await expect(discordConfig(withDiscord(id, secret, guild, role))).resolves.toEqual({
|
||||||
|
clientId: 'client-id',
|
||||||
|
clientSecret: 'client-secret',
|
||||||
|
guildId: guild,
|
||||||
|
roleIds: [role],
|
||||||
|
})
|
||||||
|
// Several qualifying roles is the ordinary case, not a special one.
|
||||||
|
const second = '1077000000000000002'
|
||||||
|
await expect(discordConfig(withDiscord(id, secret, guild, `${role},${second}`))).resolves.toEqual(
|
||||||
|
{
|
||||||
|
clientId: 'client-id',
|
||||||
|
clientSecret: 'client-secret',
|
||||||
|
guildId: guild,
|
||||||
|
roleIds: [role, second],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Several roles can qualify for the same benefit (a supporter role, a booster role,
|
||||||
|
// staff…), so the list is parsed leniently: an operator pasting ids out of Discord gets
|
||||||
|
// one per line, and a trailing comma is a typo rather than a role of '' that nothing
|
||||||
|
// could ever match. Every id is a snowflake — all digits, kept as a string.
|
||||||
|
it('parses a qualifying-role list however an operator writes it', () => {
|
||||||
|
expect(parseRoleIds('1077000000000000001')).toEqual(['1077000000000000001'])
|
||||||
|
expect(parseRoleIds('1077000000000000001,1077000000000000002')).toEqual([
|
||||||
|
'1077000000000000001',
|
||||||
|
'1077000000000000002',
|
||||||
|
])
|
||||||
|
expect(parseRoleIds(' 1077000000000000001 , 1077000000000000002 ')).toEqual([
|
||||||
|
'1077000000000000001',
|
||||||
|
'1077000000000000002',
|
||||||
|
])
|
||||||
|
// Pasted a line at a time, straight out of Discord.
|
||||||
|
expect(parseRoleIds('1077000000000000001\n1077000000000000002\n')).toEqual([
|
||||||
|
'1077000000000000001',
|
||||||
|
'1077000000000000002',
|
||||||
|
])
|
||||||
|
// Kept as STRINGS, never parsed to numbers: a snowflake exceeds 2^53, so
|
||||||
|
// Number('1077000000000000001') would round and stop matching the real role.
|
||||||
|
expect(parseRoleIds('1077000000000000001')[0]).toBe('1077000000000000001')
|
||||||
|
// Nothing to match on — these are the values that must close the claim.
|
||||||
|
expect(parseRoleIds('')).toEqual([])
|
||||||
|
expect(parseRoleIds(' ')).toEqual([])
|
||||||
|
expect(parseRoleIds(',,')).toEqual([])
|
||||||
|
// A trailing separator adds no empty id, which would match no role and never qualify.
|
||||||
|
expect(parseRoleIds('1077000000000000001,')).toEqual(['1077000000000000001'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ANY one of the configured roles qualifies — they are alternatives, not requirements.
|
||||||
|
// Testing for a subset instead would mean a player had to hold every tier at once, i.e.
|
||||||
|
// nobody would ever claim.
|
||||||
|
it('qualifies a member holding any one of the roles', () => {
|
||||||
|
// Ids on both sides — Discord reports a member's roles as snowflakes, never as names.
|
||||||
|
const supporter = '1077000000000000001'
|
||||||
|
const booster = '1077000000000000002'
|
||||||
|
const qualifying = [supporter, booster]
|
||||||
|
|
||||||
|
expect(qualifies([supporter], qualifying)).toBe(true)
|
||||||
|
expect(qualifies([booster], qualifying)).toBe(true)
|
||||||
|
expect(qualifies([booster, supporter], qualifying)).toBe(true)
|
||||||
|
// Holding some other role in the server is not enough.
|
||||||
|
expect(qualifies(['1077000000000000009'], qualifying)).toBe(false)
|
||||||
|
expect(qualifies([], qualifying)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The closed door, from the outside. This must be refused BEFORE the token is looked at,
|
||||||
|
// so an unconfigured server can't be talked into a claim by a valid session.
|
||||||
|
it('refuses a benefits claim when discord is only half configured', async () => {
|
||||||
|
const res = await SELF.fetch('https://example.com/api/benefits/claim', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
authorization: `Bearer ${await tokenFor(4001)}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ code: 'whatever' }),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
expect(await res.json()).toEqual({ error: 'Benefit claims are currently disabled.' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// The benefits routes act on ONE account — the claim writes `hasPlus` onto its row — so
|
||||||
|
// which account it is has to come from a verified token and never from the request. Both
|
||||||
|
// halves of "verified" are pinned: no token, and a token signed with a key this server
|
||||||
|
// doesn't use (i.e. one it never issued).
|
||||||
|
//
|
||||||
|
// Asserted on `/api/benefits/status` because it's the benefits route whose auth gate is
|
||||||
|
// reachable here: the claim refuses on the config gate FIRST (covered above), which is
|
||||||
|
// the right order — an unconfigured server shouldn't be examining credentials for a
|
||||||
|
// feature it doesn't run — but it means an unconfigured project can't observe its 401.
|
||||||
|
it('requires a valid session to read benefits', async () => {
|
||||||
|
const path = 'https://example.com/api/benefits/status'
|
||||||
|
|
||||||
|
// No token at all.
|
||||||
|
expect((await SELF.fetch(path)).status).toBe(401)
|
||||||
|
|
||||||
|
// A token that is well-formed but signed with the wrong key.
|
||||||
|
const forged = await generateToken('4002', '', 4, 'not-the-real-secret')
|
||||||
|
const res = await SELF.fetch(path, { headers: { authorization: `Bearer ${forged}` } })
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
// What the claim page renders before anyone presses anything. `hasPlus` is read off the
|
||||||
|
// account ROW rather than a token claim, because it's set after the browser's token was
|
||||||
|
// issued — a freshly-claimed player's token says nothing about it.
|
||||||
|
it('reports where an account stands on benefits', async () => {
|
||||||
|
const token = await tokenFor(4003)
|
||||||
|
|
||||||
|
// An account with no row at all reads as "nothing claimed" rather than 404ing: every
|
||||||
|
// account has a benefits status, whether or not it has been written to yet.
|
||||||
|
let res = await SELF.fetch('https://example.com/api/benefits/status', {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({ hasPlus: false, linked: false })
|
||||||
|
|
||||||
|
await updateAccount(env.DB, 4003, { hasPlus: true })
|
||||||
|
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001')
|
||||||
|
res = await SELF.fetch('https://example.com/api/benefits/status', {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
// `linked` says THAT a Discord identity is attached, never which one — the id is of no
|
||||||
|
// use to the page, and an account's linked identities have no business on the wire.
|
||||||
|
expect(await res.json()).toEqual({ hasPlus: true, linked: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// The once-only guard the claim is built on. Without it, one Discord member holding the
|
||||||
|
// role could walk it around every RecFlare account they own; with it, the second claim is
|
||||||
|
// refused and the first account keeps the benefit. Exercised at the two lookups the route
|
||||||
|
// asks, since the route's own path to them runs through discord.com.
|
||||||
|
it('tells a repeat claim from a second account claiming the same discord identity', async () => {
|
||||||
|
await updateAccount(env.DB, 4004, { hasPlus: true })
|
||||||
|
await linkPlatformIdentity(env.DB, 4004, PlatformType.Discord, '99002')
|
||||||
|
|
||||||
|
// The identity is taken, so a DIFFERENT account claiming it is the 409 case…
|
||||||
|
await expect(
|
||||||
|
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, '99002')
|
||||||
|
).resolves.toBe(1)
|
||||||
|
await expect(isPlatformIdentityLinked(env.DB, 4005, PlatformType.Discord, '99002')).resolves.toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
// …while the account that already holds it re-claims idempotently, which is what makes
|
||||||
|
// the page safe to reload and a lapsed-then-restored role re-claimable.
|
||||||
|
await expect(isPlatformIdentityLinked(env.DB, 4004, PlatformType.Discord, '99002')).resolves.toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Discord member who has claimed nowhere yet.
|
||||||
|
await expect(
|
||||||
|
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, '99003')
|
||||||
|
).resolves.toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A Discord link must never become a way INTO an account. The login picker is public and
|
||||||
|
// unauthenticated, so listing one would both offer the client an account it can't redeem
|
||||||
|
// (the grant refuses platform 101) and tell anyone which RecFlare account a Discord user
|
||||||
|
// owns — a snowflake is readable by anyone sharing a server with them. `auth` owns that
|
||||||
|
// gate; this pins that the platform www writes to is one the gate actually excludes.
|
||||||
|
it('stores the discord identity on a platform the login picker will not list', () => {
|
||||||
|
expect(CACHED_LOGIN_PLATFORMS).not.toContain(PlatformType.Discord)
|
||||||
|
})
|
||||||
|
|
||||||
// The privacy policy is what the Meta Horizon Store's VRC.Privacy.1–4 checks are run
|
// The privacy policy is what the Meta Horizon Store's VRC.Privacy.1–4 checks are run
|
||||||
// against, and a reviewer only sees the rendered page — so the four things they look
|
// against, and a reviewer only sees the rendered page — so the four things they look
|
||||||
// for are pinned here. If a section is renamed, re-read the VRC before loosening the
|
// for are pinned here. If a section is renamed, re-read the VRC before loosening the
|
||||||
|
|||||||
+206
-1
@@ -1,10 +1,35 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
|
import { getAccount, updateAccount } from '@repo/domain/src/accounts-db'
|
||||||
|
import { PlatformType } from '@repo/domain/src/enums'
|
||||||
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
|
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
|
||||||
import { logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
|
import { logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
|
||||||
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
|
// The `platform_account` link table, owned (and migrated) by the `auth` worker. A claimed
|
||||||
|
// Discord identity is stored there as a PlatformType.Discord link — it is an account ↔
|
||||||
|
// external identity exactly like the Steam and Meta ones, and unlike a field on the
|
||||||
|
// account blob it can answer "is this Discord user already on another account" from an
|
||||||
|
// index. Nobody logs in with it: `auth` refuses a cached_login for any platform it can't
|
||||||
|
// verify, and its picker lists only the platforms that can be (CACHED_LOGIN_PLATFORMS).
|
||||||
|
import {
|
||||||
|
countAccountsForPlatformIdentity,
|
||||||
|
getLinksForAccount,
|
||||||
|
isPlatformIdentityLinked,
|
||||||
|
linkPlatformIdentity,
|
||||||
|
} from '../../auth/src/platform-db'
|
||||||
import { authUnreachable } from './auth-messages'
|
import { authUnreachable } from './auth-messages'
|
||||||
|
import {
|
||||||
|
AUTHORIZE_URL,
|
||||||
|
discordConfig,
|
||||||
|
exchangeCode,
|
||||||
|
fetchGuildMembership,
|
||||||
|
qualifies,
|
||||||
|
redirectUri,
|
||||||
|
revokeToken,
|
||||||
|
SCOPES,
|
||||||
|
} from './discord'
|
||||||
import { docsPage, fetchSpec } from './docs'
|
import { docsPage, fetchSpec } from './docs'
|
||||||
import { privacyPage } from './privacy'
|
import { privacyPage } from './privacy'
|
||||||
import { turnstileKeys, verifyTurnstile } from './turnstile'
|
import { turnstileKeys, verifyTurnstile } from './turnstile'
|
||||||
@@ -21,6 +46,7 @@ import {
|
|||||||
storageBase,
|
storageBase,
|
||||||
} from './upstream'
|
} from './upstream'
|
||||||
|
|
||||||
|
import type { Context } from 'hono'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,8 +63,49 @@ import type { App } from './context'
|
|||||||
* there's no client contract being duplicated.
|
* there's no client contract being duplicated.
|
||||||
* - `/api/config`, which tells the SPA the Turnstile site key and where the other
|
* - `/api/config`, which tells the SPA the Turnstile site key and where the other
|
||||||
* workers live, so one client build works for any operator's domain.
|
* workers live, so one client build works for any operator's domain.
|
||||||
|
* - `/api/benefits/*`, the Discord-verified benefits claim, for the same reason as
|
||||||
|
* signup: the OAuth2 client secret that turns Discord's authorization code into an
|
||||||
|
* access token can't ship to a browser. It is also the only route here that writes to
|
||||||
|
* the database, and so the only one that verifies a token itself (see the section).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account behind a request's bearer token, or null.
|
||||||
|
*
|
||||||
|
* www verifies a token itself for exactly one feature. Everywhere else the browser
|
||||||
|
* carries its token to the worker that owns the data (`accounts`, `rooms`, …) and that
|
||||||
|
* worker does the checking; but the benefits claim WRITES `hasPlus` onto an account row,
|
||||||
|
* and "which account" is the whole question — asking the SPA would let anyone grant Plus
|
||||||
|
* to any id. Same key, same `@repo/jwt` validation (signature, exp) every other worker
|
||||||
|
* runs.
|
||||||
|
*/
|
||||||
|
const claimant = async (c: Context<App>): Promise<number | null> =>
|
||||||
|
validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Discord consent URL the SPA sends the player to, fully assembled here rather than
|
||||||
|
* in the browser — everything in it (the scopes the claim needs, and the redirect URI,
|
||||||
|
* which must match the token exchange byte for byte) is this worker's business, and a
|
||||||
|
* page that built its own could drift from what `/api/benefits/claim` will accept.
|
||||||
|
*
|
||||||
|
* It carries no `state`. That is the SPA's to add and to check on the way back: it's a
|
||||||
|
* per-attempt CSRF nonce, so it has to be minted by the thing that will later verify it
|
||||||
|
* (see App.tsx). Everything else about the request is fixed by the server.
|
||||||
|
*/
|
||||||
|
function authorizeUrl(request: Request, clientId: string): string {
|
||||||
|
const url = new URL(AUTHORIZE_URL)
|
||||||
|
url.search = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: SCOPES,
|
||||||
|
redirect_uri: redirectUri(request),
|
||||||
|
// Skip Discord's "you've already authorized this app, continue?" interstitial on a
|
||||||
|
// repeat claim; the player has already pressed a button that says what this does.
|
||||||
|
prompt: 'none',
|
||||||
|
}).toString()
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Hono<App>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -60,10 +127,16 @@ const app = new Hono<App>()
|
|||||||
// one build works for any operator. The site key is public (it ships in the widget
|
// one build works for any operator. The site key is public (it ships in the widget
|
||||||
// markup either way); the secret never leaves the worker.
|
// markup either way); the secret never leaves the worker.
|
||||||
.get('/api/config', async (c) => {
|
.get('/api/config', async (c) => {
|
||||||
const keys = await turnstileKeys(c.env)
|
const [keys, discord] = await Promise.all([turnstileKeys(c.env), discordConfig(c.env)])
|
||||||
return c.json({
|
return c.json({
|
||||||
signupEnabled: keys !== null,
|
signupEnabled: keys !== null,
|
||||||
turnstileSiteKey: keys?.siteKey ?? null,
|
turnstileSiteKey: keys?.siteKey ?? null,
|
||||||
|
// The benefits claim, on the same terms: open only when it's fully configured, and
|
||||||
|
// the SPA is handed a ready-made consent URL rather than the parts to build one.
|
||||||
|
// Nothing secret is served — the client id inside it is public — and the guild/role
|
||||||
|
// ids never leave the worker, since it's the worker that asks Discord the question.
|
||||||
|
benefitsEnabled: discord !== null,
|
||||||
|
discordAuthorizeUrl: discord ? authorizeUrl(c.req.raw, discord.clientId) : null,
|
||||||
hosts: {
|
hosts: {
|
||||||
auth: authBase(c.env),
|
auth: authBase(c.env),
|
||||||
accounts: accountsBase(c.env),
|
accounts: accountsBase(c.env),
|
||||||
@@ -169,6 +242,138 @@ const app = new Hono<App>()
|
|||||||
return c.json(token)
|
return c.json(token)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- Discord benefits claim ---------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the player's Discord already stands with this account — what the claim page
|
||||||
|
* renders before anyone presses anything, so a player who has already claimed sees
|
||||||
|
* that rather than being walked through the flow again to find out.
|
||||||
|
*
|
||||||
|
* `hasPlus` is read from the account row rather than from a token claim: it is set
|
||||||
|
* here, after the token in the browser was issued, so a freshly-claimed player's token
|
||||||
|
* says nothing about it until they sign in again.
|
||||||
|
*/
|
||||||
|
.get('/api/benefits/status', async (c) => {
|
||||||
|
const accountId = await claimant(c)
|
||||||
|
if (accountId === null) return c.body(null, 401)
|
||||||
|
const [account, links] = await Promise.all([
|
||||||
|
getAccount(c.env.DB, accountId),
|
||||||
|
getLinksForAccount(c.env.DB, accountId),
|
||||||
|
])
|
||||||
|
return c.json({
|
||||||
|
hasPlus: account?.hasPlus ?? false,
|
||||||
|
// Whether this account is already tied to a Discord identity — not WHICH one. The
|
||||||
|
// player knows their own Discord; the id is of no use to the page and every reason
|
||||||
|
// to keep an account's linked identities off the wire.
|
||||||
|
linked: links.some((link) => link.platform === PlatformType.Discord),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeem a Discord authorization code and, if the player holds the configured role in
|
||||||
|
* the configured guild, give the account Rec Room Plus.
|
||||||
|
*
|
||||||
|
* The browser sends ONLY the code. It never sees an access token: the exchange happens
|
||||||
|
* here with the client secret, the roles are read with the resulting token, and the
|
||||||
|
* token is handed straight back to Discord (see discord.ts). The redirect URI is
|
||||||
|
* derived from this request's own origin rather than accepted from the body, so the
|
||||||
|
* secret can't be used to redeem codes issued for somebody else's page.
|
||||||
|
*
|
||||||
|
* The claim is once-only PER DISCORD USER, not per account: the Discord id is stored
|
||||||
|
* beside the flag, and a code from a Discord member who has already claimed elsewhere
|
||||||
|
* is refused. Otherwise one person with the role could walk it around every account
|
||||||
|
* they own. Re-claiming on the same account is allowed and simply re-affirms the flag,
|
||||||
|
* which is what makes the page safe to reload and a lapsed-then-restored role
|
||||||
|
* re-claimable.
|
||||||
|
*
|
||||||
|
* Nothing here ever REVOKES Plus: losing the Discord role later leaves the flag set.
|
||||||
|
* That's deliberate for now — a sweep would need a bot token to enumerate the guild,
|
||||||
|
* which this design specifically avoids — but it does mean the flag records "held the
|
||||||
|
* role once", not "holds it today".
|
||||||
|
*/
|
||||||
|
.post('/api/benefits/claim', async (c) => {
|
||||||
|
const config = await discordConfig(c.env)
|
||||||
|
if (!config) return c.json({ error: 'Benefit claims are currently disabled.' }, 403)
|
||||||
|
|
||||||
|
const accountId = await claimant(c)
|
||||||
|
if (accountId === null) {
|
||||||
|
return c.json({ error: 'Please sign in before claiming your benefits.' }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClaimBody = { code?: string }
|
||||||
|
const { code } = await c.req.json<ClaimBody>().catch(() => ({}) as ClaimBody)
|
||||||
|
if (!code) return c.json({ error: 'No Discord authorization code was provided.' }, 400)
|
||||||
|
|
||||||
|
const accessToken = await exchangeCode(config, code, redirectUri(c.req.raw))
|
||||||
|
// A code lives about a minute and is single-use, so this is far and away the most
|
||||||
|
// likely failure a real player hits — hence a sentence about starting over rather
|
||||||
|
// than a relayed OAuth code, which would tell them nothing.
|
||||||
|
if (accessToken === null) {
|
||||||
|
return c.json(
|
||||||
|
{ error: 'That Discord sign-in could not be completed. Please try again.' },
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await fetchGuildMembership(accessToken, config.guildId)
|
||||||
|
// The token has told us everything it can; hand it back before answering, whatever
|
||||||
|
// the answer turns out to be. Awaited rather than fired into the void so a Worker
|
||||||
|
// that finishes the response can't cancel it.
|
||||||
|
await revokeToken(config, accessToken)
|
||||||
|
|
||||||
|
if (membership === null) {
|
||||||
|
return c.json({ error: 'You are not a member of our Discord server.' }, 403)
|
||||||
|
}
|
||||||
|
// Any ONE of the configured roles qualifies — see `qualifies`. The message stays
|
||||||
|
// singular-ish and names no role: which roles qualify is the operator's business to
|
||||||
|
// advertise in their own server, and listing them here would leak the guild's role
|
||||||
|
// layout to anyone who pressed the button.
|
||||||
|
if (!qualifies(membership.roles, config.roleIds)) {
|
||||||
|
return c.json({ error: 'Your Discord account does not have a qualifying role.' }, 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The once-only guard, asked of the link table: is this Discord identity already on an
|
||||||
|
// account, and is that account someone else's? Re-claiming on the caller's OWN account
|
||||||
|
// is the idempotent case and must fall through — it's how a player whose role lapsed
|
||||||
|
// and came back re-affirms Plus, and it's what makes the page safe to reload.
|
||||||
|
//
|
||||||
|
// `countAccountsForPlatformIdentity` counts EVERY link for the identity, unfiltered by
|
||||||
|
// platform, which is why the claim can use the same helper `auth`'s per-identity
|
||||||
|
// signup cap does.
|
||||||
|
const alreadyMine = await isPlatformIdentityLinked(
|
||||||
|
c.env.DB,
|
||||||
|
accountId,
|
||||||
|
PlatformType.Discord,
|
||||||
|
membership.userId
|
||||||
|
)
|
||||||
|
if (!alreadyMine) {
|
||||||
|
const claimedElsewhere = await countAccountsForPlatformIdentity(
|
||||||
|
c.env.DB,
|
||||||
|
PlatformType.Discord,
|
||||||
|
membership.userId
|
||||||
|
)
|
||||||
|
if (claimedElsewhere > 0) {
|
||||||
|
logger.info('a discord account tried to claim benefits on a second account', {
|
||||||
|
accountId,
|
||||||
|
})
|
||||||
|
return c.json(
|
||||||
|
{ error: 'That Discord account has already claimed benefits on another account.' },
|
||||||
|
409
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both writes are idempotent: the link is INSERT OR IGNORE (so `linkedAt` keeps the
|
||||||
|
// FIRST claim's time), and the flag is already true on a re-claim.
|
||||||
|
await linkPlatformIdentity(c.env.DB, accountId, PlatformType.Discord, membership.userId)
|
||||||
|
await updateAccount(c.env.DB, accountId, { hasPlus: true })
|
||||||
|
logger.info('granted plus from a discord benefits claim', { accountId })
|
||||||
|
|
||||||
|
// The username is echoed for the confirmation line only — it is never stored, and a
|
||||||
|
// Discord member who has since renamed themselves is not a problem to solve here.
|
||||||
|
return c.json({ hasPlus: true, discordUsername: membership.username })
|
||||||
|
})
|
||||||
|
|
||||||
// ---- Privacy policy -----------------------------------------------------
|
// ---- Privacy policy -----------------------------------------------------
|
||||||
// Server-rendered rather than a SPA route so the page has real text without
|
// Server-rendered rather than a SPA route so the page has real text without
|
||||||
// JavaScript: the Meta Horizon Store re-fetches this URL to check the policy is
|
// JavaScript: the Meta Horizon Store re-fetches this URL to check the policy is
|
||||||
|
|||||||
+51
-1
@@ -66,6 +66,37 @@
|
|||||||
"binding": "TURNSTILE_SECRET_KEY",
|
"binding": "TURNSTILE_SECRET_KEY",
|
||||||
"store_id": "local",
|
"store_id": "local",
|
||||||
"secret_name": "TURNSTILE_SECRET_KEY"
|
"secret_name": "TURNSTILE_SECRET_KEY"
|
||||||
|
},
|
||||||
|
// The shared HS256 signing key, bound here for the ONE www route that acts on a
|
||||||
|
// specific account: the benefits claim writes `hasPlus` onto the caller's row, so it
|
||||||
|
// has to verify which account is calling rather than trust the SPA.
|
||||||
|
{
|
||||||
|
"binding": "JWT_SECRET",
|
||||||
|
"store_id": "local",
|
||||||
|
"secret_name": "JWT_SECRET"
|
||||||
|
},
|
||||||
|
// The Discord OAuth2 application behind the benefits claim. Same store, same
|
||||||
|
// public-key-beside-its-secret arrangement as the Turnstile pair: the client id
|
||||||
|
// ships to the browser to build the authorize URL, the secret never leaves the
|
||||||
|
// worker (see src/discord.ts).
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
//
|
||||||
|
// These two PLUS the DISCORD_GUILD_ID / DISCORD_BENEFITS_ROLE_IDS vars below are what
|
||||||
|
// OPENS the claim; with any of the four missing it stays closed, so an operator who
|
||||||
|
// skips this gets no claim rather than one that grants Plus without checking.
|
||||||
|
{
|
||||||
|
"binding": "DISCORD_CLIENT_ID",
|
||||||
|
"store_id": "local",
|
||||||
|
"secret_name": "DISCORD_CLIENT_ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"binding": "DISCORD_CLIENT_SECRET",
|
||||||
|
"store_id": "local",
|
||||||
|
"secret_name": "DISCORD_CLIENT_SECRET"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// The `auth` worker, reached directly instead of over its public hostname. This is
|
// The `auth` worker, reached directly instead of over its public hostname. This is
|
||||||
@@ -97,6 +128,25 @@
|
|||||||
// (see run-wrangler-deploy). www serves these to the SPA via `/api/config`, which
|
// (see run-wrangler-deploy). www serves these to the SPA via `/api/config`, which
|
||||||
// is how one client build works for any operator. For local dev, point it at a
|
// is how one client build works for any operator. For local dev, point it at a
|
||||||
// deployed domain so the page has real workers to call.
|
// deployed domain so the page has real workers to call.
|
||||||
"DOMAIN": "rec.example.com"
|
"DOMAIN": "rec.example.com",
|
||||||
|
// The Discord server the benefits claim checks membership of, and the roles in it
|
||||||
|
// that grant Rec Room Plus. Every value here is a Discord SNOWFLAKE — all digits, no
|
||||||
|
// letters — copied off a client with Developer Mode on (right-click the server or the
|
||||||
|
// role → Copy ID). They are ids, not names: "Supporter" is what the role is called,
|
||||||
|
// 1077000000000000002 is what goes here. Not credentials, so they live in this file
|
||||||
|
// rather than in the Secrets Store; quoted as STRINGS because a snowflake exceeds
|
||||||
|
// 2^53 and would lose precision as a JSON number.
|
||||||
|
//
|
||||||
|
// ROLE_IDS is a LIST, separated by commas and/or whitespace. Any ONE of them
|
||||||
|
// qualifies, so several tiers can share the benefit:
|
||||||
|
//
|
||||||
|
// "DISCORD_BENEFITS_ROLE_IDS": "1077000000000000001,1077000000000000002"
|
||||||
|
//
|
||||||
|
// Empty by default: an operator who hasn't set up a Discord app has no server to
|
||||||
|
// point at, and an empty value (or one that parses to no ids) closes the claim — see
|
||||||
|
// src/discord.ts `discordConfig` — instead of leaving a form that grants Plus to
|
||||||
|
// anyone who signs in with Discord.
|
||||||
|
"DISCORD_GUILD_ID": "",
|
||||||
|
"DISCORD_BENEFITS_ROLE_IDS": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,30 @@ export interface Account {
|
|||||||
* `runx admin grant-moderator`. Absent/false means no role.
|
* `runx admin grant-moderator`. Absent/false means no role.
|
||||||
*/
|
*/
|
||||||
isModerator?: boolean
|
isModerator?: boolean
|
||||||
|
/**
|
||||||
|
* Whether this account has Rec Room Plus — the paid tier the client's API calls a
|
||||||
|
* `CampusCard`. Nothing SELLS one here. Absent/false means no Plus.
|
||||||
|
*
|
||||||
|
* This flag ALONE is what confers it, and it stands on its own: two things set it, and
|
||||||
|
* neither is a precondition of the other.
|
||||||
|
*
|
||||||
|
* - the website's benefits claim (`www` `POST /api/benefits/claim`), where a player
|
||||||
|
* proves a qualifying role in the community Discord. That path also links their
|
||||||
|
* Discord identity into `platform_account` as a `PlatformType.Discord` row — but the
|
||||||
|
* link exists to keep the CLAIM once-only per Discord user, not to justify the flag.
|
||||||
|
* - an operator, via `runx admin grant-plus`, with no Discord anywhere in sight.
|
||||||
|
*
|
||||||
|
* So never read a Discord link as a precondition for Plus, and never revoke one because
|
||||||
|
* the other is missing: a manually granted account has `hasPlus` and no link at all, and
|
||||||
|
* that is a normal, supported state.
|
||||||
|
*
|
||||||
|
* Nothing reads this per request. `auth` stamps it into every token it mints as the
|
||||||
|
* `rn.plus` claim, and `econ` decides the CampusCard and the subscriber discount from
|
||||||
|
* that claim alone — so setting it takes effect on the account's NEXT login, not
|
||||||
|
* immediately. Tokens last a day and the client never refreshes them, so that lag is
|
||||||
|
* real: the website's claim page warns about it, and so does `grant-plus`.
|
||||||
|
*/
|
||||||
|
hasPlus?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AccountRow {
|
interface AccountRow {
|
||||||
|
|||||||
@@ -5,6 +5,39 @@
|
|||||||
* the tsconfig sets `isolatedModules` (which disallows `const enum` across files).
|
* the tsconfig sets `isolatedModules` (which disallows `const enum` across files).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlatformType, the client's platform enum. Declaration order is wire order. The
|
||||||
|
* `platform` form field is posted as the integer; a token's `platform` claim carries it
|
||||||
|
* too. `auth` re-exports this as the source for its OpenAPI schema and description.
|
||||||
|
*
|
||||||
|
* A plain `as const` object rather than an `enum` like its neighbours, and deliberately
|
||||||
|
* so: `auth` builds `PlatformTypeSchema`'s description by walking `Object.entries`, and a
|
||||||
|
* numeric TS enum also emits a REVERSE mapping (`{ '0': 'Steam', Steam: 0, … }`), which
|
||||||
|
* would double every member in the generated spec.
|
||||||
|
*
|
||||||
|
* Everything from `Steam` to `Pico` is a real Rec Room client platform, numbered by the
|
||||||
|
* client. `Discord` is OURS — it is not a platform anyone signs in from, and the client
|
||||||
|
* never sends it. It exists so a verified Discord identity can be stored as an account
|
||||||
|
* link like any other external identity (see `auth`'s platform-db and the website's
|
||||||
|
* benefits claim); it sits at 101, well clear of the client's range, so a future client
|
||||||
|
* platform can be added without colliding with it.
|
||||||
|
*/
|
||||||
|
export const PlatformType = {
|
||||||
|
All: -1,
|
||||||
|
Steam: 0,
|
||||||
|
Oculus: 1,
|
||||||
|
PlayStation: 2,
|
||||||
|
Xbox: 3,
|
||||||
|
RecNet: 4,
|
||||||
|
IOS: 5,
|
||||||
|
GooglePlay: 6,
|
||||||
|
Standalone: 7,
|
||||||
|
Pico: 8,
|
||||||
|
Discord: 101,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
|
||||||
|
|
||||||
/** The kind of a room instance (live session), matching the client's `RoomInstanceType`. */
|
/** The kind of a room instance (live session), matching the client's `RoomInstanceType`. */
|
||||||
export enum RoomInstanceType {
|
export enum RoomInstanceType {
|
||||||
Public = 0,
|
Public = 0,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export * from './room-instance-db'
|
|||||||
export * from './room-comments-db'
|
export * from './room-comments-db'
|
||||||
export * from './room-invites-db'
|
export * from './room-invites-db'
|
||||||
export * from './presence-db'
|
export * from './presence-db'
|
||||||
|
export * from './stats-db'
|
||||||
export * from './gifts-db'
|
export * from './gifts-db'
|
||||||
export * from './inventory-invention-db'
|
export * from './inventory-invention-db'
|
||||||
export * from './lists-db'
|
export * from './lists-db'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Owned inventions on the shared `recflare` D1 database — the inventions a player has
|
* Owned inventions on the shared `recflare` D1 database — the inventions a player has
|
||||||
* bought. One row per (account, invention), written at purchase time by the `econ`
|
* bought. One row per (account, invention), written at purchase time by the `econ`
|
||||||
* worker's `GET /api/storefronts/v2/buyInvention`.
|
* worker's buyInvention — the `v2` GET and the `v3` POST, which share one settlement.
|
||||||
*
|
*
|
||||||
* Only the invention id is stored: the invention record itself lives in the `invention`
|
* Only the invention id is stored: the invention record itself lives in the `invention`
|
||||||
* table, whose schema the `api` worker owns (apps/api/migrations/0002_invention.sql) on
|
* table, whose schema the `api` worker owns (apps/api/migrations/0002_invention.sql) on
|
||||||
|
|||||||
@@ -12,9 +12,21 @@
|
|||||||
const ITERATIONS = 100_000
|
const ITERATIONS = 100_000
|
||||||
|
|
||||||
const b64 = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes))
|
const b64 = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes))
|
||||||
const fromB64 = (s: string): Uint8Array => Uint8Array.from(atob(s), (ch) => ch.charCodeAt(0))
|
const fromB64 = (s: string): Uint8Array<ArrayBuffer> =>
|
||||||
|
Uint8Array.from(atob(s), (ch) => ch.charCodeAt(0))
|
||||||
|
|
||||||
async function deriveBits(password: string, salt: Uint8Array): Promise<Uint8Array> {
|
/**
|
||||||
|
* The salt is `Uint8Array<ArrayBuffer>` rather than a bare `Uint8Array` because the latter
|
||||||
|
* is `Uint8Array<ArrayBufferLike>`, which admits a `SharedArrayBuffer` — and the DOM lib's
|
||||||
|
* `BufferSource` does not. Both callers already produce a plain-ArrayBuffer view
|
||||||
|
* (`getRandomValues` and `fromB64`), so this only writes down what was always true; without
|
||||||
|
* it, any worker whose tsconfig includes the DOM lib (`www`, for its React client) fails to
|
||||||
|
* compile on the `deriveBits` call below.
|
||||||
|
*/
|
||||||
|
async function deriveBits(
|
||||||
|
password: string,
|
||||||
|
salt: Uint8Array<ArrayBuffer>
|
||||||
|
): Promise<Uint8Array<ArrayBuffer>> {
|
||||||
const keyMaterial = await crypto.subtle.importKey(
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
new TextEncoder().encode(password),
|
new TextEncoder().encode(password),
|
||||||
|
|||||||
@@ -33,21 +33,25 @@ export const PRESENCE_TTL_SECONDS = 900
|
|||||||
export const GAME_VERSION = '20230414'
|
export const GAME_VERSION = '20230414'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client builds `/api/versioncheck/v4` answers "current" for. `GAME_VERSION` is the one
|
* Client builds `/api/versioncheck/v4` answers "current" for, in the support tiers the
|
||||||
* the rest of the stack targets and reports for itself; the others are later clients
|
* README publishes. They talk close enough to the same protocol to get past the update
|
||||||
* that talk close enough to the same protocol to get past the update prompt.
|
* prompt; how much beyond that is tested differs by tier:
|
||||||
*
|
*
|
||||||
* DEBUGGING ONLY beyond `GAME_VERSION`: this is not a supported-version list. Nothing
|
* - `GAME_VERSION` (`20230414`, manifest `7859140924515540835`) is the DEFAULT — the
|
||||||
* else in the stack targets those builds, so a client waved through here can still hit
|
* build the rest of the stack targets and the one it reports for itself.
|
||||||
* protocol differences the version check would otherwise have caught. Trim it back to
|
* - `20250718.01` (manifest `1151455856673601091`, reached through the `patch-2025`
|
||||||
* `GAME_VERSION` alone before anyone but us is playing.
|
* patch) is BETA — supported, and the build the "newer than `20230414`" gates in
|
||||||
|
* `econ`/`api`/`rooms` are written and tested against.
|
||||||
|
* - The rest are ALPHA: waved past the version check, but nothing else in the stack
|
||||||
|
* targets them, so they can still hit protocol differences this check would otherwise
|
||||||
|
* have caught. Expect breakage rather than treating a bug there as a regression.
|
||||||
*/
|
*/
|
||||||
export const SUPPORTED_GAME_VERSIONS: string[] = [
|
export const SUPPORTED_GAME_VERSIONS: string[] = [
|
||||||
GAME_VERSION,
|
GAME_VERSION, // default
|
||||||
'20230616',
|
'20250718.01', // beta
|
||||||
'20231207',
|
'20230616', // alpha
|
||||||
'20250424.01',
|
'20231207', // alpha
|
||||||
'20250718.01',
|
'20250424.01', // alpha
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Whether a client-supplied build (the version check's `?v=`) is one we serve. */
|
/** Whether a client-supplied build (the version check's `?v=`) is one we serve. */
|
||||||
|
|||||||
@@ -85,6 +85,38 @@ export async function getRoomInvite(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest live invite from `fromPlayerId` to `toPlayerId`, or null when none stands.
|
||||||
|
*
|
||||||
|
* This is the by-PLAYER-pair lookup behind `POST /matchmake/v2/player/:playerId`, where
|
||||||
|
* the caller redeems "an invite from that player" without holding a `RoomInviteId` (the
|
||||||
|
* newer client's invite frame doesn't always carry a usable one). Newest by id — ids are
|
||||||
|
* AUTOINCREMENT, so the largest is the most recently sent — and, like
|
||||||
|
* {@link getRoomInvite}, a miss covers both "never invited" and "already swept".
|
||||||
|
*/
|
||||||
|
export async function getLatestRoomInviteBetween(
|
||||||
|
db: D1Database,
|
||||||
|
fromPlayerId: number,
|
||||||
|
toPlayerId: number
|
||||||
|
): Promise<RoomInvite | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${SELECT_COLUMNS} FROM room_invite
|
||||||
|
WHERE from_player_id = ?1 AND to_player_id = ?2
|
||||||
|
ORDER BY room_invite_id DESC LIMIT 1`
|
||||||
|
)
|
||||||
|
.bind(fromPlayerId, toPlayerId)
|
||||||
|
.first<RoomInviteRow>()
|
||||||
|
|
||||||
|
if (!row) return null
|
||||||
|
return {
|
||||||
|
RoomInviteId: row.room_invite_id,
|
||||||
|
FromPlayerId: row.from_player_id,
|
||||||
|
ToPlayerId: row.to_player_id,
|
||||||
|
RoomId: row.room_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record an invite from `fromPlayerId` to `toPlayerId` for a room, returning it as the
|
* Record an invite from `fromPlayerId` to `toPlayerId` for a room, returning it as the
|
||||||
* client reads it back. `roomId` is null when the caller's room instance didn't resolve.
|
* client reads it back. `roomId` is null when the caller's room instance didn't resolve.
|
||||||
@@ -115,3 +147,21 @@ export async function createRoomInvite(
|
|||||||
RoomId: row.room_id,
|
RoomId: row.room_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete one invite by its id, answering whether a row was there to delete.
|
||||||
|
*
|
||||||
|
* An invite is single-use: `POST /matchmake/v2/player/:playerId` redeems the newest row
|
||||||
|
* from the target and drops it here once the caller is actually in the instance, so a
|
||||||
|
* standing invite doesn't stay a permanent key into whatever room that player is in
|
||||||
|
* later. Deleting rather than flagging matches the expiry sweep, which is why every
|
||||||
|
* lookup reads a miss as "no longer good" without a status column.
|
||||||
|
*/
|
||||||
|
export async function deleteRoomInvite(db: D1Database, roomInviteId: number): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(`DELETE FROM room_invite WHERE room_invite_id = ?1 RETURNING room_invite_id`)
|
||||||
|
.bind(roomInviteId)
|
||||||
|
.first<{ room_invite_id: number }>()
|
||||||
|
|
||||||
|
return row !== null
|
||||||
|
}
|
||||||
|
|||||||
+102
-20
@@ -103,6 +103,21 @@ export const ROOM_SCHEMA_DDL: string[] = [
|
|||||||
PRIMARY KEY (room_id, banned_player_id)
|
PRIMARY KEY (room_id, banned_player_id)
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id)`,
|
||||||
|
// Per-room leaderboard definitions (migrations/0016_room_leaderboard.sql). One row per
|
||||||
|
// (room, leaderboard): `leaderboard_id` is the client's slot number — small ordinals
|
||||||
|
// (1, 2, 3…), unique only within the room — so the pair is the key, and re-posting a
|
||||||
|
// slot reconfigures it in place rather than appending.
|
||||||
|
//
|
||||||
|
// Deliberately NOT in the room's `data` blob, same reasoning as `room_ban`: the blob is
|
||||||
|
// served verbatim as the room and the client doesn't read leaderboards off it.
|
||||||
|
`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)
|
||||||
|
)`,
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,6 +211,22 @@ export function canManageRoom(room: Room, accountId: number): boolean {
|
|||||||
return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
|
return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an account may MODERATE a room — its creator, or the holder of a role at
|
||||||
|
* Moderator (20) or above. The wider gate that {@link canManageRoom} is the narrow one
|
||||||
|
* of: a moderator polices who is in the room right now (kicking someone out of an
|
||||||
|
* instance) without being trusted to change the room itself, while everyone who can
|
||||||
|
* manage a room can obviously also police it, so CoOwner and Creator pass here too.
|
||||||
|
*
|
||||||
|
* Host (10) is deliberately below the line: it is the "runs this session" tier, which the
|
||||||
|
* client hands out freely, and a kick is a moderation power rather than a hosting one.
|
||||||
|
*/
|
||||||
|
export function canModerateRoom(room: Room, accountId: number): boolean {
|
||||||
|
if (room.CreatorAccountId === accountId) return true
|
||||||
|
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : []
|
||||||
|
return roles.some((r) => r.AccountId === accountId && r.Role >= Role.Moderator)
|
||||||
|
}
|
||||||
|
|
||||||
/** A player banned from a room (a `room_ban` row). */
|
/** A player banned from a room (a `room_ban` row). */
|
||||||
export interface RoomBan {
|
export interface RoomBan {
|
||||||
RoomId: number
|
RoomId: number
|
||||||
@@ -286,6 +317,75 @@ export async function isPlayerBannedFromRoom(
|
|||||||
return row !== null
|
return row !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A room's leaderboard definition — one configured slot (`leaderboard_id` is per-room). */
|
||||||
|
export interface RoomLeaderboard {
|
||||||
|
RoomId: number
|
||||||
|
LeaderboardId: number
|
||||||
|
LeaderboardTitle: string
|
||||||
|
StatFormat: number
|
||||||
|
SortAscending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomLeaderboardRow {
|
||||||
|
room_id: number
|
||||||
|
leaderboard_id: number
|
||||||
|
leaderboard_title: string
|
||||||
|
stat_format: number
|
||||||
|
sort_ascending: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const toRoomLeaderboard = (row: RoomLeaderboardRow): RoomLeaderboard => ({
|
||||||
|
RoomId: row.room_id,
|
||||||
|
LeaderboardId: row.leaderboard_id,
|
||||||
|
LeaderboardTitle: row.leaderboard_title,
|
||||||
|
StatFormat: row.stat_format,
|
||||||
|
SortAscending: row.sort_ascending === 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or reconfigure one of a room's leaderboard slots, returning the stored
|
||||||
|
* definition. One row per (room, leaderboard): re-posting a slot rewrites its title,
|
||||||
|
* format and direction rather than appending a second row, so the call is idempotent.
|
||||||
|
*/
|
||||||
|
export async function setRoomLeaderboard(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
leaderboardId: number,
|
||||||
|
leaderboardTitle: string,
|
||||||
|
statFormat: number,
|
||||||
|
sortAscending: boolean
|
||||||
|
): Promise<RoomLeaderboard> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO room_leaderboard (room_id, leaderboard_id, leaderboard_title, stat_format, sort_ascending)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(room_id, leaderboard_id) DO UPDATE SET
|
||||||
|
leaderboard_title = ?3, stat_format = ?4, sort_ascending = ?5
|
||||||
|
RETURNING *`
|
||||||
|
)
|
||||||
|
.bind(roomId, leaderboardId, leaderboardTitle, statFormat, sortAscending ? 1 : 0)
|
||||||
|
.first<RoomLeaderboardRow>()
|
||||||
|
// RETURNING always yields the upserted row.
|
||||||
|
return toRoomLeaderboard(row!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove one of a room's leaderboard slots, returning the definition that was removed —
|
||||||
|
* or null when the slot wasn't configured, which lets the caller tell a real delete
|
||||||
|
* from a no-op.
|
||||||
|
*/
|
||||||
|
export async function deleteRoomLeaderboard(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
leaderboardId: number
|
||||||
|
): Promise<RoomLeaderboard | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('DELETE FROM room_leaderboard WHERE room_id = ?1 AND leaderboard_id = ?2 RETURNING *')
|
||||||
|
.bind(roomId, leaderboardId)
|
||||||
|
.first<RoomLeaderboardRow>()
|
||||||
|
return row ? toRoomLeaderboard(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone an existing room into a new one owned by `accountId`. Copies the source
|
* Clone an existing room into a new one owned by `accountId`. Copies the source
|
||||||
* room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
|
* room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
|
||||||
@@ -367,23 +467,10 @@ export async function setRoomDescription(
|
|||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Set a room's Name in place (the caller checks ownership + name uniqueness first). */
|
||||||
* Set a room's Name in place (the caller checks ownership + name uniqueness first).
|
|
||||||
*
|
|
||||||
* Writes `FriendlyName` to the same string. That is the DISPLAY name — what the client
|
|
||||||
* labels the room with — and it is only defaulted to `Name` on read
|
|
||||||
* ({@link attachRoomDtoDefaults}), with `??=`, so a room whose blob has ever carried one
|
|
||||||
* keeps it. Renaming without this leaves that room displaying its old name forever while
|
|
||||||
* every name-keyed lookup uses the new one.
|
|
||||||
*
|
|
||||||
* The reference lets a creator set a display name apart from the unique `Name`; nothing
|
|
||||||
* here exposes that, so the two are kept in step rather than allowed to diverge silently.
|
|
||||||
*/
|
|
||||||
export async function setRoomName(db: D1Database, roomId: number, name: string): Promise<void> {
|
export async function setRoomName(db: D1Database, roomId: number, name: string): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare("UPDATE room SET data = json_set(data, '$.Name', ?2) WHERE room_id = ?1")
|
||||||
"UPDATE room SET data = json_set(data, '$.Name', ?2, '$.FriendlyName', ?2) WHERE room_id = ?1"
|
|
||||||
)
|
|
||||||
.bind(roomId, name)
|
.bind(roomId, name)
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
@@ -1062,10 +1149,6 @@ const PUBLIC_WHERE = 'is_dorm IS NOT 1 AND accessibility = 1'
|
|||||||
* it is 0 for every room.
|
* it is 0 for every room.
|
||||||
* - `CurrentSnapshotId` — the room's published snapshot. Nothing takes snapshots, so it is
|
* - `CurrentSnapshotId` — the room's published snapshot. Nothing takes snapshots, so it is
|
||||||
* null, which is also what the reference serves for a room that has none.
|
* null, which is also what the reference serves for a room that has none.
|
||||||
* - `FriendlyName` — the display name, which the reference lets a creator set apart from
|
|
||||||
* the unique `Name`. Nothing sets one here, so it falls back to `Name`; it must never be
|
|
||||||
* null, because the client labels a room from it and renders nothing for a room without
|
|
||||||
* one.
|
|
||||||
* - `CCU` — concurrent users. No live-population counter exists here, so it is null, which
|
* - `CCU` — concurrent users. No live-population counter exists here, so it is null, which
|
||||||
* is what the reference serves when it has no number rather than 0 (a 0 reads as "nobody
|
* is what the reference serves when it has no number rather than 0 (a 0 reads as "nobody
|
||||||
* is in here" in the browse feeds).
|
* is in here" in the browse feeds).
|
||||||
@@ -1076,7 +1159,6 @@ const PUBLIC_WHERE = 'is_dorm IS NOT 1 AND accessibility = 1'
|
|||||||
function attachRoomDtoDefaults(room: Room): void {
|
function attachRoomDtoDefaults(room: Room): void {
|
||||||
room.BoostCount ??= 0
|
room.BoostCount ??= 0
|
||||||
room.CurrentSnapshotId ??= null
|
room.CurrentSnapshotId ??= null
|
||||||
room.FriendlyName ??= room.Name
|
|
||||||
room.CCU ??= null
|
room.CCU ??= null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Server statistics sampled over time (`stat` table). The `match` presence cron
|
||||||
|
* records one `online` sample per run — the count of live `presence` rows once the
|
||||||
|
* expired ones are swept. Migration: apps/match/migrations/0002_stat.sql.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of apps/match/migrations/0002_stat.sql). */
|
||||||
|
export const STAT_SCHEMA_DDL: string[] = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS stat (
|
||||||
|
stat_type TEXT NOT NULL,
|
||||||
|
value INTEGER NOT NULL,
|
||||||
|
datetime TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_stat_type_datetime ON stat (stat_type, datetime)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface StatRow {
|
||||||
|
statType: string
|
||||||
|
value: number
|
||||||
|
datetime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record one sample of `statType`, stamped with the current UTC time (ISO-8601). */
|
||||||
|
export async function recordStat(
|
||||||
|
db: D1Database,
|
||||||
|
statType: string,
|
||||||
|
value: number,
|
||||||
|
now: Date = new Date()
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare('INSERT INTO stat (stat_type, value, datetime) VALUES (?1, ?2, ?3)')
|
||||||
|
.bind(statType, value, now.toISOString())
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Samples of `statType`, oldest first. */
|
||||||
|
export async function getStats(db: D1Database, statType: string, limit = 1000): Promise<StatRow[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT stat_type, value, datetime FROM stat WHERE stat_type = ?1 ORDER BY datetime ASC LIMIT ?2'
|
||||||
|
)
|
||||||
|
.bind(statType, limit)
|
||||||
|
.all<{ stat_type: string; value: number; datetime: string }>()
|
||||||
|
return results.map((r) => ({ statType: r.stat_type, value: r.value, datetime: r.datetime }))
|
||||||
|
}
|
||||||
@@ -61,6 +61,15 @@ export const MIN_INVENTION_NAME_LENGTH = 3
|
|||||||
export const MAX_INVENTION_NAME_LENGTH = 24
|
export const MAX_INVENTION_NAME_LENGTH = 24
|
||||||
export const MAX_INVENTION_DESCRIPTION_LENGTH = 512
|
export const MAX_INVENTION_DESCRIPTION_LENGTH = 512
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The long description — the blurb on an invention's detail page, as opposed to the one
|
||||||
|
* line that fits under a browse tile. The client sends it on `v9/save` and edits it
|
||||||
|
* through `v2/metadata`, and how long it lets one get has not been pinned down, so the cap
|
||||||
|
* here is this server's own: generous enough that no real blurb hits it, small enough that
|
||||||
|
* a record stays a record. It is checked only when a caller actually sends one.
|
||||||
|
*/
|
||||||
|
export const MAX_INVENTION_LONG_DESCRIPTION_LENGTH = 4096
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One invention tag. Short and letters-only because tags are a controlled vocabulary the
|
* One invention tag. Short and letters-only because tags are a controlled vocabulary the
|
||||||
* browse chips are derived from (see `getInventionTagFilters`) — a tag with digits,
|
* browse chips are derived from (see `getInventionTagFilters`) — a tag with digits,
|
||||||
@@ -180,6 +189,14 @@ export function inventionDescriptionRejection(value: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Why a long description can't be stored, or null when it can. */
|
||||||
|
export function inventionLongDescriptionRejection(value: string): string | null {
|
||||||
|
if (glyphLength(value) > MAX_INVENTION_LONG_DESCRIPTION_LENGTH) {
|
||||||
|
return `Invention long descriptions can be at most ${MAX_INVENTION_LONG_DESCRIPTION_LENGTH} characters.`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Why an invention tag is unacceptable, or `null` when it's fine. Pass the NORMALIZED
|
* Why an invention tag is unacceptable, or `null` when it's fine. Pass the NORMALIZED
|
||||||
* tag (trimmed and lowercased, as `setInventionTags` stores it) — checking what was typed
|
* tag (trimmed and lowercased, as `setInventionTags` stores it) — checking what was typed
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "0.2.2",
|
"@hono/standard-validator": "0.2.2",
|
||||||
"hono": "4.12.27",
|
"hono": "4.13.5",
|
||||||
"http-codex": "0.6.7",
|
"http-codex": "0.6.7",
|
||||||
"workers-tagged-logger": "1.0.1",
|
"workers-tagged-logger": "1.0.1",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/domain": "workspace:*",
|
"@repo/domain": "workspace:*",
|
||||||
"hono": "4.12.27"
|
"hono": "4.13.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "4.20260630.1",
|
"@cloudflare/workers-types": "4.20260630.1",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
validateAndGetAccountId,
|
validateAndGetAccountId,
|
||||||
|
validateAndGetPlus,
|
||||||
validateAndGetRoles,
|
validateAndGetRoles,
|
||||||
validateAndGetVersion,
|
validateAndGetVersion,
|
||||||
generateToken,
|
generateToken,
|
||||||
|
|||||||
+46
-3
@@ -52,8 +52,12 @@ export async function validateAndGetAccountId(
|
|||||||
const accountId = await getAccountIdFromToken(token, secret)
|
const accountId = await getAccountIdFromToken(token, secret)
|
||||||
if (!accountId) return null
|
if (!accountId) return null
|
||||||
|
|
||||||
const id = Number.parseInt(accountId, 10)
|
// `parseInt("42junk", 10)` is 42, which lets a malformed subject select a real
|
||||||
return Number.isNaN(id) ? null : id
|
// account. Token subjects are canonical positive base-10 account ids: reject
|
||||||
|
// partial parses, signs, decimals, leading zeroes and values outside JS's safe range.
|
||||||
|
if (!/^[1-9]\d*$/.test(accountId)) return null
|
||||||
|
const id = Number(accountId)
|
||||||
|
return Number.isSafeInteger(id) ? id : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,6 +86,30 @@ export async function validateAndGetRoles(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a request's bearer token says the caller has Rec Room Plus — the `rn.plus`
|
||||||
|
* claim stamped by {@link generateToken} from `account.hasPlus`. This is the ONE way Plus
|
||||||
|
* is decided (see `econ`'s `isSubscriber`); nothing re-reads the account for it, which is
|
||||||
|
* why a freshly-claimed player must sign in again before it applies.
|
||||||
|
*
|
||||||
|
* False for a missing, malformed or expired token, and false for a valid token that
|
||||||
|
* simply carries no claim — the two are not worth telling apart, since neither is a
|
||||||
|
* subscriber. Only a literal `true` counts, so a token carrying some other value in that
|
||||||
|
* key can't read as Plus.
|
||||||
|
*/
|
||||||
|
export async function validateAndGetPlus(request: Request, secret: string): Promise<boolean> {
|
||||||
|
const authHeader = request.headers.get('Authorization')
|
||||||
|
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) return false
|
||||||
|
|
||||||
|
const token = authHeader.slice('bearer '.length)
|
||||||
|
try {
|
||||||
|
const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature
|
||||||
|
return payload['rn.plus'] === true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a request's bearer token and return its `rn.ver` claim — the game build the
|
* Validate a request's bearer token and return its `rn.ver` claim — the game build the
|
||||||
* caller posted to `/connect/token`, stamped by {@link generateToken}. `null` when the
|
* caller posted to `/connect/token`, stamped by {@link generateToken}. `null` when the
|
||||||
@@ -189,7 +217,8 @@ export async function generateToken(
|
|||||||
secret: string,
|
secret: string,
|
||||||
extraRoles: string[] = [],
|
extraRoles: string[] = [],
|
||||||
privileges: string[] = [],
|
privileges: string[] = [],
|
||||||
version: string = GAME_VERSION
|
version: string = GAME_VERSION,
|
||||||
|
hasPlus = false
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
// The client reads `role`/`scope` (and expects a well-formed iss/aud) to
|
// The client reads `role`/`scope` (and expects a well-formed iss/aud) to
|
||||||
@@ -220,6 +249,20 @@ export async function generateToken(
|
|||||||
// `scope`. Omitted entirely when empty, so an unrestricted token is byte-for-byte
|
// `scope`. Omitted entirely when empty, so an unrestricted token is byte-for-byte
|
||||||
// what it was before privileges existed.
|
// what it was before privileges existed.
|
||||||
...(privileges.length > 0 ? { 'rn.privilege': privileges } : {}),
|
...(privileges.length > 0 ? { 'rn.privilege': privileges } : {}),
|
||||||
|
// Whether the account has Rec Room Plus (`account.hasPlus`) — a CLAIM, like
|
||||||
|
// `rn.privilege` and for the same reason: it is ours, the client has never heard of
|
||||||
|
// it, and `scope` is a fixed list the client parses. `econ` reads it to answer the
|
||||||
|
// CampusCard lookup and to price the subscriber discount, which is the whole point
|
||||||
|
// of carrying it here: those calls then need no database read at all.
|
||||||
|
//
|
||||||
|
// Omitted when false, so a non-subscriber's token is byte-for-byte what it was
|
||||||
|
// before Plus existed, and `validateAndGetPlus` reads an absent claim as "no Plus".
|
||||||
|
//
|
||||||
|
// STAMPED AT LOGIN, so it is only as fresh as the token: a player who claims Plus on
|
||||||
|
// the website has to sign in again (and restart the game) before it takes effect.
|
||||||
|
// Tokens last a day and the client does not refresh them — see TOKEN_TTL_SECONDS —
|
||||||
|
// so that wait is real, and it is the accepted trade for making the check free.
|
||||||
|
...(hasPlus ? { 'rn.plus': true } : {}),
|
||||||
scope: TOKEN_SCOPES,
|
scope: TOKEN_SCOPES,
|
||||||
jti: crypto.randomUUID(),
|
jti: crypto.randomUUID(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { D1ExecResult } from '../d1'
|
|||||||
* runx admin clear-password --username alice [--remote]
|
* runx admin clear-password --username alice [--remote]
|
||||||
* runx admin lookup --username alice [--remote]
|
* runx admin lookup --username alice [--remote]
|
||||||
* runx admin grant-developer --account 1 [--revoke] [--remote]
|
* runx admin grant-developer --account 1 [--revoke] [--remote]
|
||||||
|
* runx admin grant-plus --username alice [--revoke] [--remote]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,16 +124,21 @@ const clearPassword = new Command('clear-password')
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a `grant-<role>` command that toggles a boolean role flag on the account
|
* Build a `grant-<thing>` command that toggles a boolean flag on the account blob.
|
||||||
* blob. `jsonKey` is the account field (e.g. `isDeveloper`) — a fixed literal, not
|
* `jsonKey` is the account field (e.g. `isDeveloper`) — a fixed literal, not user input.
|
||||||
* user input. Both the /role/:role lookup and the token's `role` claim read it.
|
*
|
||||||
|
* `noun` is what the flag IS, and it is not always "role": the role flags feed the
|
||||||
|
* /role/:role lookup and the token's `role` claim, while `hasPlus` is an entitlement that
|
||||||
|
* rides on its own `rn.plus` claim and confers no role at all. Getting that word right in
|
||||||
|
* the output is the difference between an operator believing they granted a staff power
|
||||||
|
* and knowing they granted a subscription.
|
||||||
*/
|
*/
|
||||||
function grantRoleCommand(name: string, jsonKey: string, roleLabel: string) {
|
function grantRoleCommand(name: string, jsonKey: string, roleLabel: string, noun = 'role') {
|
||||||
return new Command(name)
|
return new Command(name)
|
||||||
.description(`Grant (or, with --revoke, remove) the ${roleLabel} role on an account`)
|
.description(`Grant (or, with --revoke, remove) ${roleLabel} on an account`)
|
||||||
.option('--account <id>', 'Account id to target')
|
.option('--account <id>', 'Account id to target')
|
||||||
.option('--username <name>', 'Username to target (case-insensitive)')
|
.option('--username <name>', 'Username to target (case-insensitive)')
|
||||||
.option('--revoke', `Remove the ${roleLabel} role instead of granting it`, false)
|
.option('--revoke', `Remove ${roleLabel} instead of granting it`, false)
|
||||||
.option('--local', 'Target the local dev database (the default).', false)
|
.option('--local', 'Target the local dev database (the default).', false)
|
||||||
.option('--remote', 'Target the deployed database instead of the local dev database.', false)
|
.option('--remote', 'Target the deployed database instead of the local dev database.', false)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
@@ -141,10 +147,10 @@ function grantRoleCommand(name: string, jsonKey: string, roleLabel: string) {
|
|||||||
const value = opts.revoke ? 'false' : 'true'
|
const value = opts.revoke ? 'false' : 'true'
|
||||||
const sql = `UPDATE account SET data = json_set(data, '$.${jsonKey}', json('${value}')) WHERE ${where} RETURNING account_id`
|
const sql = `UPDATE account SET data = json_set(data, '$.${jsonKey}', json('${value}')) WHERE ${where} RETURNING account_id`
|
||||||
const verb = opts.revoke ? 'Revoking' : 'Granting'
|
const verb = opts.revoke ? 'Revoking' : 'Granting'
|
||||||
console.log(`${verb} ${roleLabel} role for ${label} on ${target(remote)}`)
|
console.log(`${verb} ${roleLabel} ${noun} for ${label} on ${target(remote)}`)
|
||||||
assertMatched(await execSql(sql, remote), label)
|
assertMatched(await execSql(sql, remote), label)
|
||||||
console.log(
|
console.log(
|
||||||
chalk.green(`✓ ${roleLabel} role ${opts.revoke ? 'revoked' : 'granted'} for ${label}`)
|
chalk.green(`✓ ${roleLabel} ${noun} ${opts.revoke ? 'revoked' : 'granted'} for ${label}`)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -152,6 +158,21 @@ function grantRoleCommand(name: string, jsonKey: string, roleLabel: string) {
|
|||||||
const grantDeveloper = grantRoleCommand('grant-developer', 'isDeveloper', 'developer')
|
const grantDeveloper = grantRoleCommand('grant-developer', 'isDeveloper', 'developer')
|
||||||
const grantModerator = grantRoleCommand('grant-moderator', 'isModerator', 'moderator')
|
const grantModerator = grantRoleCommand('grant-moderator', 'isModerator', 'moderator')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rec Room Plus, the account's `hasPlus` flag. Players normally get it themselves by
|
||||||
|
* claiming a Discord role on the website; this is the operator's way in — and the ONLY
|
||||||
|
* one, since the `developer` role deliberately no longer confers Plus.
|
||||||
|
*
|
||||||
|
* Granting does not take effect until the account's NEXT login: `auth` stamps `hasPlus`
|
||||||
|
* into the token as `rn.plus` when it mints one, and `econ` reads nothing else. Tokens
|
||||||
|
* last a day and the client never refreshes them, so tell the player to restart the game
|
||||||
|
* and sign in again.
|
||||||
|
*
|
||||||
|
* Revoking has the same lag in reverse — a player keeps Plus until their current token
|
||||||
|
* expires. It is not a way to cut someone off immediately.
|
||||||
|
*/
|
||||||
|
const grantPlus = grantRoleCommand('grant-plus', 'hasPlus', 'Rec Room Plus', 'subscription')
|
||||||
|
|
||||||
const lookup = new Command('lookup')
|
const lookup = new Command('lookup')
|
||||||
.description('Print an account by id or username')
|
.description('Print an account by id or username')
|
||||||
.option('--account <id>', 'Account id to look up')
|
.option('--account <id>', 'Account id to look up')
|
||||||
@@ -202,6 +223,7 @@ export const adminCmd = new Command('admin')
|
|||||||
.addCommand(clearPassword)
|
.addCommand(clearPassword)
|
||||||
.addCommand(grantDeveloper)
|
.addCommand(grantDeveloper)
|
||||||
.addCommand(grantModerator)
|
.addCommand(grantModerator)
|
||||||
|
.addCommand(grantPlus)
|
||||||
.addCommand(lookup)
|
.addCommand(lookup)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
'after',
|
'after',
|
||||||
@@ -216,5 +238,6 @@ Examples:
|
|||||||
$ runx admin clear-password --username alice
|
$ runx admin clear-password --username alice
|
||||||
$ runx admin grant-developer --account 1 [--revoke]
|
$ runx admin grant-developer --account 1 [--revoke]
|
||||||
$ runx admin grant-moderator --username alice --remote
|
$ runx admin grant-moderator --username alice --remote
|
||||||
|
$ runx admin grant-plus --username alice # Rec Room Plus; takes effect next login
|
||||||
$ runx admin lookup --username alice --remote`
|
$ runx admin lookup --username alice --remote`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.28.1",
|
||||||
"slugify": "1.6.9",
|
"slugify": "1.6.9",
|
||||||
"wrangler": "4.105.0",
|
"wrangler": "4.128.0",
|
||||||
"yaml": "2.9.0",
|
"yaml": "2.9.0",
|
||||||
"zod": "4.4.3",
|
"zod": "4.4.3",
|
||||||
"zx": "8.8.5"
|
"zx": "8.8.5"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user