mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc43d57172 | |||
| 8b804eaa33 | |||
| 6b7acc9435 | |||
| 93a46871de | |||
| 4111bc49aa | |||
| f6561f1ec9 | |||
| bc96a6245b | |||
| a73dec7c13 | |||
| ae3bef4cc4 | |||
| 1f615bab4f | |||
| a986d012f5 | |||
| b82a5e1dc0 | |||
| 6bfd4d9e50 | |||
| 079c889ccb | |||
| 9f4ce07aca | |||
| dfb1e9ab21 | |||
| 1d08ed8296 | |||
| f3e2ab422c | |||
| aa304dbede | |||
| 10eb89ac12 | |||
| 65611c15d8 | |||
| d6a0e3e6a6 | |||
| 7d300fa836 | |||
| 73bb7c4609 | |||
| dbc6d15ef5 | |||
| db003d54ef | |||
| 05b56e698e | |||
| dee7497fe5 | |||
| 12f6d7ab61 | |||
| 8d1539de03 | |||
| 7e0c26a100 | |||
| f94877347c | |||
| 23bc159c28 | |||
| 70df3cb6cb | |||
| 03b1c59f0d | |||
| d298977790 | |||
| 821bf54b9b | |||
| 108b061019 | |||
| af2a2a0683 | |||
| 339a91735b | |||
| a46f6db9d7 | |||
| b3f1d04823 | |||
| 55cb769de9 | |||
| 4c6b9679e4 | |||
| 6a910b27bd | |||
| 9c8bf7087c | |||
| d5ccad51d3 | |||
| a28b9b4561 | |||
| 8c773da137 | |||
| d5e3d3946e | |||
| beed8451d4 | |||
| 273d62ed80 | |||
| a5136d2bfa | |||
| 460839458c | |||
| 27c45792b8 | |||
| 568717bb53 | |||
| 5ed9e765a5 | |||
| cfc29cb175 | |||
| 82f011c0b0 | |||
| f39db8a15e | |||
| 95386c5de3 | |||
| a400de4a8b | |||
| bceb8f6a4f | |||
| 725540a95b | |||
| 2f6a40279d | |||
| 7ab171b646 | |||
| 23b78104e8 | |||
| 68b98665b2 | |||
| 881663a4fe | |||
| 7ca7aa0666 | |||
| 539e6150b0 | |||
| df618af435 | |||
| ec324558a0 | |||
| 40c38d7a18 | |||
| c853cc1c6f | |||
| 5df06ea168 | |||
| c80a25bd24 | |||
| 2a043d0042 | |||
| 56f7695c71 | |||
| 430825d72e | |||
| 7f497fcee0 | |||
| d56fe3a276 | |||
| dd3bcf6868 | |||
| 9cb1135a12 | |||
| cfde2cebf4 | |||
| 086441f6f5 | |||
| 41fa8b9979 | |||
| 0f301e5788 | |||
| 08cf44991d | |||
| 64bfc9f851 | |||
| 21f7eff384 | |||
| 019e7ade01 | |||
| 7429ba4536 | |||
| 30bb6a131c | |||
| 9ab4199bd5 | |||
| aebd4ca630 | |||
| 86edf0ba66 | |||
| 9eb9476270 | |||
| a4ddef2cf7 | |||
| 52cd808e0a | |||
| 4cef0bdf5b | |||
| 355b459dc9 | |||
| 3319a5d91a | |||
| bbd702faf1 | |||
| 9065bf5e54 | |||
| b77389012e | |||
| 2f734b41ae | |||
| 2ff6526834 | |||
| 710031b2a3 | |||
| f02a75aed4 | |||
| ed155c163c | |||
| 03871dcdd8 | |||
| d86a34fa73 | |||
| bbd38d4aa6 | |||
| 17f60808a7 | |||
| 916e42c33c | |||
| 5345bd0517 | |||
| 489ac04574 | |||
| 0061dc046b | |||
| 4612144880 | |||
| c33919bc67 | |||
| 8314e54439 |
@@ -52,7 +52,28 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
|
||||
# RECFLARE_MAX_ACCOUNTS_PER_IP=3
|
||||
|
||||
# How many rooms one account may create (`rooms`) and how many clubs (`clubs`).
|
||||
# Enforced on creation only — lowering either never touches what players already have,
|
||||
# it just stops new ones. Set either to 0 to turn that cap off.
|
||||
# ...ROOMS counts rooms the account created, minus their auto-provisioned dorm.
|
||||
# ...CLUBS counts clubs the account created (subscription clubs don't count).
|
||||
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
|
||||
# RECFLARE_MAX_CLUBS_PER_ACCOUNT=10
|
||||
|
||||
# RecCenterTokens a new player is granted, the first time their balance is read (`econ`).
|
||||
# 0 means players start broke. Applies only to players who haven't been granted yet —
|
||||
# raising it later does NOT top up existing players.
|
||||
# RECFLARE_STARTING_TOKENS=10000
|
||||
|
||||
# Signup on the website is configured OUTSIDE this file: it's guarded by a Cloudflare
|
||||
# Turnstile widget, and both of that widget's keys live in the shared Secrets Store
|
||||
# (RECFLARE_SECRETS_STORE above), alongside JWT_SECRET — not as vars, not as worker secrets.
|
||||
#
|
||||
# wrangler secrets-store secret create <store-id> --name TURNSTILE_SITE_KEY \
|
||||
# --scopes workers --remote
|
||||
# wrangler secrets-store secret create <store-id> --name TURNSTILE_SECRET_KEY \
|
||||
# --scopes workers --remote
|
||||
#
|
||||
# Setting them both is what opens web signup; with either missing it stays closed. See
|
||||
# DEPLOYING.md. Accounts are still created by the game either way, and both `auth` account
|
||||
# caps above apply regardless.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Regression tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@v3
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Install dependencies
|
||||
run: just install
|
||||
|
||||
- name: Test
|
||||
run: just test
|
||||
@@ -55,6 +55,65 @@
|
||||
- Explicit function return types are optional
|
||||
</code-style>
|
||||
|
||||
<client-contract-notes>
|
||||
Response shapes the Rec Room client depends on. These were found by watching the live
|
||||
client, not by reading a spec: when one is wrong the client renders nothing or hangs
|
||||
rather than erroring, so tests won't catch a regression. Don't "clean up" an
|
||||
inconsistency here without checking the client first.
|
||||
|
||||
- Player image lists (`api`: `/api/images/v5|v4/player/:id`, `/api/images/v3/feed/player/:id`)
|
||||
must use the `toImagesPlayer` projection — `Id` → `SavedImageId`, `Type` →
|
||||
`SavedImageType`, no `TaggedPlayerIds`. Serving the raw `SavedImage` renders blank
|
||||
thumbnails.
|
||||
- The room photo feed (`api`: `/api/images/v4/room/:roomId`) serves the raw `SavedImage`
|
||||
and displays correctly. It is deliberately NOT projected — do not unify these two.
|
||||
- A club's `AdditionalImages` (`clubs`) is an array of whole `SavedImage` records, not
|
||||
image names — a bare string array fails the client's parser ("expected '{'"). The list
|
||||
is packed: removing an image shifts the rest up, never leaving a blank slot.
|
||||
- A room's `LoadScreens` (`rooms`: `PUT /rooms/:id/loadscreen`) is an array — the
|
||||
client's parser wants one — but the client renders only the FIRST entry and only ever
|
||||
posts one. So the endpoint REPLACES the list rather than appending: an appended screen
|
||||
sits unreachable behind the old one and setting a load screen looks like it did
|
||||
nothing. Keep the array shape for eventual multi-screen support.
|
||||
- Endpoints the client re-renders from must return the updated entity, not
|
||||
`{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old
|
||||
clubhouse on screen until it answered the full details envelope.
|
||||
- Every subroom mutation (`rooms`: create, delete, `/subrooms/:sid/clone`,
|
||||
`/subrooms/:sid/accessibility`, `/subrooms/:sid/publish_save`) answers
|
||||
`{ success, error, value }` with the whole updated ROOM — the client re-renders the room
|
||||
from `value`. Notably `value` is the room even for `clone`, whose product is a new
|
||||
SUBROOM; only the room-level `POST /rooms/:id/clone` returns the thing it created.
|
||||
- The room save (`rooms`: `POST /subrooms/:sid/data`) is the ONE exception to that shape:
|
||||
`value` is `{ room, subRoomDataSave }`, and `error` is NULL rather than `""`. The
|
||||
`subRoomDataSave` is camelCase with a different field set from the PascalCase
|
||||
`CurrentSave` embedded in the room (no persistence/OM/UGC versions, no moderation state,
|
||||
no asset arrays; but `unityAsset`/`unityAssetHash`). Don't unify the two projections.
|
||||
- A subroom's saved scene loads from `CurrentSave.DataBlob` (`rooms`: `GET /rooms/:id`),
|
||||
NOT the flat `DataBlob` on the subroom — a subroom with no `CurrentSave` silently loads
|
||||
nothing. The key must be present (null before the first publish); read it via
|
||||
`subRoomDataBlob()` so `match`/`auth` instance payloads resolve it the same way.
|
||||
- A room save (`rooms`: `POST …/subrooms/:sid/data`) publishes only when the body says
|
||||
`AutoPublish: true`; otherwise it STAGES onto `StagedSubRoomDataSaveId` and leaves
|
||||
`CurrentSave` alone, so players keep loading the last published version until the owner
|
||||
posts `…/subrooms/:sid/publish_save` with `subRoomDataSaveId=<id>`. DORMS always
|
||||
publish: no publish step exists in the client for them. Saves live in the
|
||||
`subroom_save` table with globally-unique ids (a bare id has to resolve —
|
||||
`StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so
|
||||
`…/saves` is real history and `publish_save` doubles as restore-a-save. `…/saves` is
|
||||
auth-gated and CREATOR-only (not co-owners) — it lists unpublished staged saves. There
|
||||
is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path.
|
||||
- Matchmaking (`match`: `/matchmake/room/:roomId/:subRoomId`) always serves the PUBLISHED
|
||||
`CurrentSave` blob, creator included. Joining a private instance, the client itself asks
|
||||
the owner whether to load the latest or the published version and resolves it from the
|
||||
`/subrooms/:sid/saves` list — the matchmake call is identical either way. Don't make
|
||||
this server-side: it would put two people in one instance on different versions.
|
||||
- Accessibility is sent as the `RoomAccessibility` enum NAME on
|
||||
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
||||
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
||||
(Private, Public, Unlisted, Dev_only, Dev_Unlisted); parse via `parseAccessibility`,
|
||||
which accepts either form.
|
||||
</client-contract-notes>
|
||||
|
||||
<critical-notes>
|
||||
- TypeScript configs MUST use fully qualified paths: `@repo/typescript-config/base.json` not `./base.json`
|
||||
- Do NOT add 'WebWorker' to TypeScript config - types are in worker-configuration.d.ts or @cloudflare/workers-types
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Admin CLI
|
||||
|
||||
Operator tools for accounts on the shared `recflare` D1 database, exposed as an
|
||||
`admin` command group on the repo's `runx` CLI. Each command shells out to
|
||||
`wrangler d1 execute recflare` — no running worker or auth token needed.
|
||||
|
||||
Run from anywhere in the repo:
|
||||
|
||||
```sh
|
||||
bun runx admin <command> [options]
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `set-password` — set (or replace) an account's login password
|
||||
|
||||
```sh
|
||||
bun runx admin set-password --account 1
|
||||
bun runx admin set-password --username alice --remote
|
||||
```
|
||||
|
||||
The new password is taken from `--password <pw>`, else from piped stdin, else
|
||||
prompted interactively.
|
||||
|
||||
```sh
|
||||
# interactive (prompts, hidden)
|
||||
bun runx admin set-password --account 1
|
||||
|
||||
# non-interactive / scripted
|
||||
echo "s3cret-pw" | bun runx admin set-password --account 1
|
||||
bun runx admin set-password --account 1 --password "s3cret-pw"
|
||||
```
|
||||
|
||||
### `clear-password` — remove an account's password
|
||||
|
||||
Leaves the account with no login credential (only platform login).
|
||||
|
||||
```sh
|
||||
bun runx admin clear-password --username alice
|
||||
```
|
||||
|
||||
### `grant-developer` / `grant-moderator` — grant or revoke a role
|
||||
|
||||
Both are off by default; only these commands set them. A granted role backs its
|
||||
`GET /role/<role>/:id` lookup **and** rides in the login token's `role` claim, so it
|
||||
takes effect on the account's next login or token refresh.
|
||||
|
||||
```sh
|
||||
bun runx admin grant-developer --account 1
|
||||
bun runx admin grant-developer --account 1 --revoke
|
||||
bun runx admin grant-moderator --username alice --remote
|
||||
```
|
||||
|
||||
### `lookup` — print an account
|
||||
|
||||
```sh
|
||||
bun runx admin lookup --account 1
|
||||
bun runx admin lookup --username alice
|
||||
```
|
||||
|
||||
Prints id, username, platform, platform id, created/last-login times, and whether
|
||||
the account has a password, the developer role, and the moderator role.
|
||||
|
||||
## Options
|
||||
|
||||
### Selecting an account
|
||||
|
||||
Every command targets exactly one account, by **either**:
|
||||
|
||||
- `--account <id>` — numeric account id
|
||||
- `--username <name>` — username (case-insensitive)
|
||||
|
||||
### Choosing the database
|
||||
|
||||
- `--local` — the local dev database (**the default**)
|
||||
- `--remote` — the deployed (production) database
|
||||
|
||||
Passing both is an error. `--remote` requires `RECFLARE_D1` in the gitignored root
|
||||
`.env` (see `.env.example`) and a wrangler login with access to the account.
|
||||
|
||||
## Notes
|
||||
|
||||
- Password hashing matches the auth worker exactly (PBKDF2-SHA256), so a password
|
||||
set here verifies at login.
|
||||
- A command that matches no account exits non-zero with `no account found for …`.
|
||||
- Local writes target `apps/auth`'s dev D1 state; run `bun turbo -F auth migrate -- --local`
|
||||
first if the local database hasn't been migrated yet.
|
||||
+83
-6
@@ -43,10 +43,12 @@ services but would require small code changes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- node (modern)
|
||||
- pnpm
|
||||
- bun
|
||||
- jq/awk/sed
|
||||
**You must have all these requirements or RecFlare deployment will fail!**
|
||||
|
||||
- node 24 (https://nodejs.org)
|
||||
- pnpm (install with `npm install -g pnpm`)
|
||||
- bun (https://bun.sh)
|
||||
- jq/awk/sed (on Windows try `winget jq` etc.)
|
||||
- A Cloudflare account with a zone (domain) you control, for deploying.
|
||||
|
||||
Cloudflare's free plan is good enough for testing (100k worker requests/day) but the
|
||||
@@ -65,6 +67,8 @@ We use [Just](https://github.com/casey/just) for convenience. This will install
|
||||
just install
|
||||
```
|
||||
|
||||
You do not have to use `just` but you will have to run things manually with `pnpm`/`bun`.
|
||||
|
||||
**Configure your custom domain:**
|
||||
|
||||
Create a new .env file from the template:
|
||||
@@ -77,11 +81,11 @@ Edit `.env` and set `RECFLARE_DOMAIN` to your domain (or declare it with `export
|
||||
|
||||
(Optional) - per-app subdomain overrides come from
|
||||
`RECFLARE_SUBDOMAINS` (a JSON object, e.g. `'{"playersettings":"settings"}'`). This would be used
|
||||
if you wanted to merge two services together.
|
||||
if you wanted to merge two services together e.g. send `datacollection` calls to `api`.
|
||||
|
||||
**Create the storage resources:**
|
||||
|
||||
The workers bind Cloudflare storage primitive. Create them once against your
|
||||
The workers bind Cloudflare storage primitives. Create them once against your
|
||||
Cloudflare account, then record the IDs in `.env`. The committed `wrangler.jsonc`
|
||||
files carry `"local"` placeholders; the real IDs are spliced in at deploy time, so
|
||||
nothing in version control needs editing. Authenticate wrangler first
|
||||
@@ -104,6 +108,24 @@ binds it so tokens signed by `auth` verify everywhere. Record its id in `.env` a
|
||||
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
|
||||
```
|
||||
|
||||
The same store also holds `META_APP_SECRET`, the app secret from your app's page in
|
||||
the Meta developer dashboard (developers.meta.com). Only the `auth` worker binds it,
|
||||
and only to authenticate itself to Meta when validating a headset login's nonce —
|
||||
unlike Steam's ticket, which verifies offline, a Meta login cannot be checked without
|
||||
it. Create it too:
|
||||
|
||||
```bash
|
||||
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
|
||||
```
|
||||
|
||||
> ⚠️ Both secrets must **exist** in the store or `just deploy` fails on the `auth`
|
||||
> worker — a binding to a missing secret is a deploy error. If you have no Meta app,
|
||||
> create `META_APP_SECRET` with any placeholder value: Meta sign-ins then fail with a
|
||||
> 500 ("Meta platform verification is not configured") and nothing else is affected.
|
||||
> Steam and password sign-ins are unaffected either way. Put the real value in later
|
||||
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
|
||||
> the secret per request.
|
||||
|
||||
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
|
||||
|
||||
```bash
|
||||
@@ -199,6 +221,61 @@ single address, so raise it (or set it to `0`) if real players report being lock
|
||||
> `just deploy`. `.env` is the durable place. Real secrets don't belong there either — they
|
||||
> go in the Cloudflare Secrets Store, like the shared `JWT_SECRET` above.
|
||||
|
||||
### Signing up on the website (Turnstile)
|
||||
|
||||
Players get an account by launching the game, which needs no setup. The website can create
|
||||
one too — that path has no platform identity behind it, so it runs behind a
|
||||
[Turnstile](https://developers.cloudflare.com/turnstile/) bot check and is **closed until
|
||||
you configure one**. Two steps, both one-time:
|
||||
|
||||
1. Create the widget: Cloudflare dashboard → **Turnstile** → **Add widget**, mode
|
||||
**Managed**, hostnames your domain (add `localhost` if you want it in `just dev` against
|
||||
real keys). It gives you a **site key** and a **secret key**.
|
||||
2. Put both in the same Secrets Store the shared `JWT_SECRET` lives in — they're the switch
|
||||
that opens signup, and store values survive deploys:
|
||||
|
||||
```bash
|
||||
wrangler secrets-store secret create <store-id> --name TURNSTILE_SITE_KEY \
|
||||
--scopes workers --remote
|
||||
wrangler secrets-store secret create <store-id> --name TURNSTILE_SECRET_KEY \
|
||||
--scopes workers --remote
|
||||
```
|
||||
|
||||
Then `just deploy -F www`. The site key is public — the browser needs it to render the
|
||||
widget, and gets it from `GET /api/config` — but it lives next to its secret so signup is
|
||||
configured in one place. The secret key never leaves the worker: `/api/signup` verifies the
|
||||
token against Turnstile server-side before it calls `auth`.
|
||||
|
||||
Signup opens only when **both** resolve. With either missing, `/api/config` reports signup
|
||||
closed (the site shows sign-in only) and `POST /api/signup` refuses — a missed step costs
|
||||
you the signup form, never an unprotected one. That is also how you turn signup back off:
|
||||
`wrangler secrets-store secret delete <store-id> --name TURNSTILE_SECRET_KEY --remote`,
|
||||
then redeploy `www` (values are cached per isolate, so a warm worker keeps the old one
|
||||
until fresh isolates start). For local dev, seed the same two names into the local store
|
||||
from `apps/www` — Turnstile's documented always-passes test keypair
|
||||
(`1x00000000000000000000AA` / `1x0000000000000000000000000000000AA`) works there without a
|
||||
widget:
|
||||
|
||||
```bash
|
||||
cd apps/www
|
||||
printf '1x00000000000000000000AA' |
|
||||
wrangler secrets-store secret create local --name TURNSTILE_SITE_KEY --scopes workers
|
||||
printf '1x0000000000000000000000000000000AA' |
|
||||
wrangler secrets-store secret create local --name TURNSTILE_SECRET_KEY --scopes workers
|
||||
```
|
||||
|
||||
Both `auth` account caps above still apply on top of the bot check, and the per-IP one is
|
||||
the only cap that can see a web signup.
|
||||
|
||||
`www` reaches `auth` through a **service binding**, not over `auth.<DOMAIN>`, so that the
|
||||
player's real IP survives the hop: a Worker subrequest to the public hostname re-enters
|
||||
the Cloudflare edge, which rewrites `CF-Connecting-IP` to Cloudflare's own address, and
|
||||
`auth` would then record one shared `signupIp` for every web account and cap the whole
|
||||
internet at three. Two consequences: **deploy `auth` before `www`** on a fresh account
|
||||
(the binding refuses to resolve otherwise), and web accounts created before this change
|
||||
carry that shared address as their permanent `signupIp` — harmless, but they are not
|
||||
counted against any real network.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
|
||||
|
||||
@@ -108,3 +108,11 @@ update *args:
|
||||
[positional-arguments]
|
||||
runx *args:
|
||||
bun runx "$@"
|
||||
|
||||
# Admin account tools (set-password, clear-password, grant-developer, lookup).
|
||||
# Run `just admin --help` for usage and examples. See CLI.md.
|
||||
[group('4. utility')]
|
||||
[positional-arguments]
|
||||
[no-cd]
|
||||
admin *args:
|
||||
bun runx admin "$@"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
|
||||
|
||||

|
||||
|
||||
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
|
||||
Cloudflare Workers. It implements the network services the Rec Room client talks
|
||||
to — accounts, auth, rooms, matchmaking, economy, chat, notifications, and more —
|
||||
@@ -96,6 +98,6 @@ missing pieces. Again, another reason to come together on one project and
|
||||
stop gatekeeping.
|
||||
|
||||
Unfortunately, they were all leaked code except for
|
||||
[CannedNet](https://github.com/CannedNet/CannedNet) and jordanparki7's postman
|
||||
[CannedNet](https://github.com/CannedNet/CannedNet), [DorkNet](https://github.com/DorkSquadRR/DorkNet), and jordanparki7's postman
|
||||
collection of RecNet APIs which is gone for some reason. So I will not list the
|
||||
leaks publicly.
|
||||
|
||||
+91
-29
@@ -1,37 +1,99 @@
|
||||
# accounts
|
||||
|
||||
Accounts Worker served on the `accounts` subdomain. A Hono app for accounts.
|
||||
Database queries are stubbed for now — no real bindings yet.
|
||||
Accounts Worker served on the `accounts` subdomain (`accounts.recflare.net`) — a Hono
|
||||
app for account reads, profile mutations and lookups. Accounts live in the shared
|
||||
`recflare` D1 database, whose `account` schema and migrations are owned by the `auth`
|
||||
worker; this worker binds it read/write.
|
||||
|
||||
## Behavior
|
||||
## Routes
|
||||
|
||||
- **Auth-gated routes** validate the Bearer JWT issued by the `auth` worker
|
||||
(same dev secret, see `src/jwt.ts`) and 401 when it's missing/invalid.
|
||||
- **DB-backed reads** return synthesized default accounts. Every column gets a
|
||||
fallback (`Player{id}`, `DefaultProfileImage.jpg`, etc.), so the stubs return
|
||||
those defaults rather than 404ing on a missing row.
|
||||
- **DB-backed writes** (`create`, the `PUT /account/me/*` mutations) accept the
|
||||
request and ack without persisting. `create` mints a random account id and
|
||||
returns it wrapped in the RecNet result envelope `{ success, value }`.
|
||||
| Method | Path | Auth | Description |
|
||||
| ------ | ------------------------------ | ---- | ------------------------------------------------ |
|
||||
| GET | `/` | | Health check |
|
||||
| GET | `/account/me` | ✓ | The caller's own account (private self DTO) |
|
||||
| GET | `/account/search?name=` | | Prefix-search accounts by username |
|
||||
| GET | `/account/bulk?id=1&id=2,3` | | Look up many accounts by id |
|
||||
| GET | `/account/:id` | | A single public account |
|
||||
| GET | `/account/:id/bio` | | A player's bio |
|
||||
| POST | `/account/create` | | Create an account → `{ success, value }` |
|
||||
| GET | `/parentalcontrol/me` | ✓ | The caller's parental-control flags |
|
||||
| GET | `/accountprivacysettings/:id` | | An account's privacy settings |
|
||||
| PUT | `/account/me/displayname` | ✓ | Set display name |
|
||||
| PUT | `/account/me/username` | ✓ | Change username (unique + change remaining) |
|
||||
| POST | `/account/me/email` | ✓ | Set email |
|
||||
| POST | `/account/me/phone` | ✓ | Set phone number |
|
||||
| PUT | `/account/me/identityflags` | ✓ | Set identity flags bitmask |
|
||||
| PUT | `/account/me/personalpronouns` | ✓ | Set personal pronouns (posted as `pronounFlags`) |
|
||||
| PUT | `/account/me/bio` | ✓ | Set bio |
|
||||
| PUT | `/account/me/profileimage` | ✓ | Set avatar object key |
|
||||
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
||||
|
||||
## Endpoints
|
||||
Auth-gated routes validate the Bearer JWT issued by the `auth` worker and return an
|
||||
empty-body 401 when it's missing or invalid.
|
||||
|
||||
- `GET /` — health check
|
||||
- `GET /account/me` — authed self account (`SelfAccount`)
|
||||
- `GET /account/bulk?id=1&id=2` — accounts for the requested ids
|
||||
- `GET /account/:id` — single account
|
||||
- `GET /account/:id/bio` — player bio
|
||||
- `POST /account/create` — create an account → `{ success, value }`
|
||||
- `GET /parentalcontrol/me` — authed parental-control flags
|
||||
- `PUT /account/me/displayname` — authed, body `displayName`
|
||||
- `PUT /account/me/username` — authed, body `username`
|
||||
- `PUT /account/me/bio` — authed, body `bio`
|
||||
- `PUT /account/me/profileimage` — authed, body `imageName`
|
||||
## API documentation
|
||||
|
||||
## TODO before production
|
||||
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
|
||||
alongside each handler, with the schemas in `src/openapi.ts`.
|
||||
|
||||
- Wire a DB binding (D1/DO) for `Accounts`, `CachedLogins`, `PlayerBios`,
|
||||
`Rooms`/`SubRooms` (the dorm room created on signup).
|
||||
- Make reads 404 on missing rows once real data exists.
|
||||
- Persist the `PUT /account/me/*` mutations.
|
||||
- Move the JWT secret to a shared secret binding (shared with `auth`).
|
||||
**The spec is descriptive, not enforced.** Nothing validates requests against it — same
|
||||
rationale as the `auth` worker: this serves a protocol reverse-engineered from the Rec
|
||||
Room client, the handlers are lenient (form fields are read as
|
||||
`typeof value === 'string' ? value : ''`), and reads fall back to a synthesized default
|
||||
account rather than 404. Read a "required" field as _the client always sends it_, not
|
||||
_the server rejects it if absent_.
|
||||
|
||||
A test asserts that every route the worker serves appears in the spec, so adding a
|
||||
route without documenting it fails rather than silently shipping an incomplete spec.
|
||||
|
||||
## Account shapes
|
||||
|
||||
Two DTOs, both camelCase:
|
||||
|
||||
- **Public** (`toAccountDto`) — returned for any account. Excludes private fields.
|
||||
- **Self** (`toSelfAccountDto`, the `/account/me` shape) — the public DTO plus
|
||||
owner-only `email`, `birthday` and `availableUsernameChanges`.
|
||||
|
||||
Two client-deserializer quirks are load-bearing and deliberate:
|
||||
|
||||
- `juniorState` / `parentAccountId` are **omitted entirely** when unset — emitting
|
||||
`null` makes the client's enum parser throw. `email` / `birthday` aren't enums, so
|
||||
they're kept as `null`.
|
||||
- `GET /accountprivacysettings/:id` never returns a bare `{}` — that fails the client's
|
||||
deserializer ("Deserialization returned null"), so the id is echoed back with recent
|
||||
history reported visible. Nothing stores per-player privacy yet.
|
||||
|
||||
## Missing rows fall back to defaults
|
||||
|
||||
Account reads (`/account/me`, `/account/:id`, `/account/bulk`) never 404 on an unknown
|
||||
id — they synthesize a default account (`defaultAccount`) so every requested id is
|
||||
present in the response. `bulk` in particular guarantees one entry per requested id.
|
||||
|
||||
## Notifications
|
||||
|
||||
Profile mutations persist to the account row and then push through the shared
|
||||
notifications hub (a single global Durable Object owned by the `notify` worker): the
|
||||
owner receives `SelfAccountUpdate` + `AccountUpdate`, and every connected client
|
||||
receives an `AccountUpdate` broadcast. Hub failures are logged and swallowed — the
|
||||
write has already committed, so a hub hiccup must not fail the request.
|
||||
|
||||
This matters most for the mutations whose HTTP response carries no account body
|
||||
(`personalpronouns`, `identityflags`): the client only learns the new value from the
|
||||
pushed update, and since those fields are in the _public_ DTO, every other client needs
|
||||
the broadcast too. `email` and `phone` are private, so they persist without a push.
|
||||
|
||||
## Bindings
|
||||
|
||||
| Binding | Type | Notes |
|
||||
| ---------------------------- | -------------- | ------------------------------------------------------------ |
|
||||
| `DB` | D1 | Shared `recflare` database; `account` schema owned by `auth` |
|
||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
|
||||
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
|
||||
|
||||
This worker has no migrations of its own — the `account` table is created and migrated
|
||||
by `auth` (`apps/auth/migrations/`).
|
||||
|
||||
## Known gaps
|
||||
|
||||
- `POST /account/create` parses `platformId` but doesn't yet persist it, and doesn't
|
||||
create the dorm Room/SubRoom a new account should get.
|
||||
|
||||
@@ -18,8 +18,13 @@
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "0.16.20",
|
||||
|
||||
+530
-159
@@ -1,4 +1,5 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler, validator } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
@@ -10,9 +11,32 @@ import {
|
||||
searchAccounts,
|
||||
updateAccount,
|
||||
} from '@repo/domain'
|
||||
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
AccountDto,
|
||||
BioRequest,
|
||||
BioResponse,
|
||||
CreateAccountRequest,
|
||||
CreateAccountResult,
|
||||
DisplayNameRequest,
|
||||
EmailRequest,
|
||||
form,
|
||||
HealthResponse,
|
||||
IdentityFlagsRequest,
|
||||
json,
|
||||
ParentalControl,
|
||||
PhoneRequest,
|
||||
PrivacySettings,
|
||||
ProfileImageRequest,
|
||||
PronounsRequest,
|
||||
SelfAccountDto,
|
||||
SuccessResponse,
|
||||
UsernameRequest,
|
||||
UsernameResult,
|
||||
} from './openapi'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { Account } from '@repo/domain'
|
||||
import type { App } from './context'
|
||||
@@ -21,9 +45,10 @@ import type { App } from './context'
|
||||
* Account reads/writes are backed by the shared `accounts` table in D1 (schema
|
||||
* owned by the `auth` worker). Accounts not in the table fall back to a
|
||||
* synthesized default (every column has a fallback anyway). Profile mutations
|
||||
* still accept-and-ack (marked `TODO`).
|
||||
* persist to the account row and push an AccountUpdate through the notifications
|
||||
* hub (see `pushAccountUpdate`).
|
||||
*
|
||||
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
|
||||
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -44,12 +69,18 @@ function unauthorized(c: Context<App>) {
|
||||
const DEFAULT_USERNAME_CHANGES = 1
|
||||
|
||||
/**
|
||||
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
|
||||
* On success `value` is the updated account; on error `error` carries the message
|
||||
* and `value` is an empty string.
|
||||
* Username-change result envelope: `{ success, error, value }`. On success `value` is
|
||||
* the updated account; on a refusal `error` carries the message and `value` is an empty
|
||||
* string.
|
||||
*
|
||||
* A refusal is a 400. The body shape is unchanged — anything reading `error` still
|
||||
* works — but it used to come back at HTTP 200, which meant a caller keying off the
|
||||
* status read every refusal as a success. That envelope-at-200 was the reference's
|
||||
* (`RecNet`) convention and is kept by `POST /account/create`; here it was traded for a
|
||||
* status a client can actually branch on.
|
||||
*/
|
||||
function usernameResult(c: Context<App>, error = '', value: unknown = '') {
|
||||
return c.json({ success: error === '', error, value })
|
||||
return c.json({ success: error === '', error, value }, error === '' ? 200 : 400)
|
||||
}
|
||||
|
||||
/** Read a single string field from a form-urlencoded / multipart body. */
|
||||
@@ -87,7 +118,9 @@ function toSelfAccountDto(account: Account) {
|
||||
return {
|
||||
...toAccountDto(account),
|
||||
email: account.email ?? null,
|
||||
birthday: null,
|
||||
// @todo he game client needs this to be set. I forget how birthdays were set, so for now
|
||||
// everyone can be old.
|
||||
birthday: '1904-01-01T00:00:00.000Z',
|
||||
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
|
||||
}
|
||||
}
|
||||
@@ -96,7 +129,7 @@ function toSelfAccountDto(account: Account) {
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/**
|
||||
* Push the notifications that follow an account mutation, mirroring the C#/Go
|
||||
* Push the notifications that follow an account mutation, mirroring the reference
|
||||
* hub behavior: the owner receives `SelfAccountUpdate` and `AccountUpdate`, and
|
||||
* every connected client receives an `AccountUpdate` broadcast. Hub failures are
|
||||
* logged and swallowed — the account write has already committed, so a hub
|
||||
@@ -117,6 +150,12 @@ async function pushAccountUpdate(c: Context<App>, account: Account): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
/** The empty-body 401 every auth-gated route returns; reused across their specs. */
|
||||
const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
|
||||
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -128,207 +167,539 @@ const app = new Hono<App>()
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
// The website (`www`) is a browser origin calling these endpoints directly, the way
|
||||
// rec.net's own site called the game's API — so the responses need CORS headers or
|
||||
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
|
||||
// endpoints authenticate with a bearer token in the `Authorization` header, never a
|
||||
// cookie: a hostile page can't read another origin's stored token, so there is no
|
||||
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
|
||||
.use('*', withDefaultCors())
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
// Root health check.
|
||||
.get('/', (c) => c.json({ service: 'accounts', status: 'ok' }))
|
||||
.get(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Meta'],
|
||||
summary: 'Health check',
|
||||
responses: { 200: json(HealthResponse, 'Service is up') },
|
||||
}),
|
||||
(c) => c.json({ service: 'accounts', status: 'ok' })
|
||||
)
|
||||
|
||||
// ---- Self account --------------------------------------------------------
|
||||
.get('/account/me', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// Load the stored account, falling back to a synthesized default.
|
||||
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
|
||||
return c.json(toSelfAccountDto(account))
|
||||
})
|
||||
.get(
|
||||
'/account/me',
|
||||
describeRoute({
|
||||
tags: ['Self'],
|
||||
summary: 'The caller’s own account',
|
||||
description: [
|
||||
'The private self DTO, including owner-only fields (email, remaining username',
|
||||
'changes). An account with no stored row falls back to a synthesized default.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(SelfAccountDto, 'The caller’s account'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// Load the stored account, falling back to a synthesized default.
|
||||
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
|
||||
return c.json(toSelfAccountDto(account))
|
||||
}
|
||||
)
|
||||
|
||||
// ---- Search --------------------------------------------------------------
|
||||
// Prefix-search accounts by username (`?name=`). Returns a bare array of public
|
||||
// account DTOs, ordered alphabetically. Registered before `/account/:id` so the
|
||||
// static `search` path wins over the param route.
|
||||
.get('/account/search', async (c) => {
|
||||
const name = c.req.query('name') ?? ''
|
||||
const accounts = await searchAccounts(c.env.DB, name)
|
||||
return c.json(accounts.map(toAccountDto))
|
||||
})
|
||||
.get(
|
||||
'/account/search',
|
||||
describeRoute({
|
||||
tags: ['Lookup'],
|
||||
summary: 'Prefix-search accounts by username',
|
||||
description: 'Case-insensitive prefix match on username, ordered alphabetically.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'name',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Username prefix; empty matches nothing meaningful',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(AccountDto.array(), 'Matching public accounts') },
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.query('name') ?? ''
|
||||
const accounts = await searchAccounts(c.env.DB, name)
|
||||
return c.json(accounts.map(toAccountDto))
|
||||
}
|
||||
)
|
||||
|
||||
// ---- Bulk / single lookup ------------------------------------------------
|
||||
// Register the static `bulk` path before the `/account/:id` param route.
|
||||
.get('/account/bulk', async (c) => {
|
||||
// Reads repeated `id` query params; also accept a comma-separated list.
|
||||
const ids =
|
||||
c.req
|
||||
.queries('id')
|
||||
?.flatMap((v) => v.split(','))
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n)) ?? []
|
||||
// Resolve stored accounts, synthesizing a default for any id not in the DB
|
||||
// so every requested id is present in the response.
|
||||
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
|
||||
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
|
||||
})
|
||||
.get(
|
||||
'/account/bulk',
|
||||
describeRoute({
|
||||
tags: ['Lookup'],
|
||||
summary: 'Look up many accounts by id',
|
||||
description: [
|
||||
'Accepts repeated `id` query params and/or comma-separated lists. Every requested',
|
||||
'id appears in the response — ids with no stored row get a synthesized default.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Repeatable; each value may be a comma-separated list of ids',
|
||||
schema: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
|
||||
}),
|
||||
async (c) => {
|
||||
// Reads repeated `id` query params; also accept a comma-separated list.
|
||||
const ids =
|
||||
c.req
|
||||
.queries('id')
|
||||
?.flatMap((v) => v.split(','))
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n)) ?? []
|
||||
// Resolve stored accounts, synthesizing a default for any id not in the DB
|
||||
// so every requested id is present in the response.
|
||||
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
|
||||
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
|
||||
}
|
||||
)
|
||||
|
||||
.get('/account/:id/bio', async (c) => {
|
||||
const accountId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (Number.isNaN(accountId)) return c.body(null, 400)
|
||||
// Bio is stored on the account JSON (set via PUT /account/me/bio).
|
||||
const account = await getAccount(c.env.DB, accountId)
|
||||
return c.json({ accountId, bio: account?.bio ?? '' })
|
||||
})
|
||||
.get(
|
||||
'/account/:id/bio',
|
||||
describeRoute({
|
||||
tags: ['Lookup'],
|
||||
summary: 'A player’s bio',
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Account id; non-numeric is 400',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(BioResponse, 'The bio (empty string when unset)'),
|
||||
400: { description: 'Non-numeric id (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (Number.isNaN(accountId)) return c.body(null, 400)
|
||||
// Bio is stored on the account JSON (set via PUT /account/me/bio).
|
||||
const account = await getAccount(c.env.DB, accountId)
|
||||
return c.json({ accountId, bio: account?.bio ?? '' })
|
||||
}
|
||||
)
|
||||
|
||||
.get('/account/:id', async (c) => {
|
||||
const accountId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (Number.isNaN(accountId)) return c.body(null, 400)
|
||||
// Load the stored account, falling back to a synthesized default.
|
||||
return c.json(
|
||||
toAccountDto((await getAccount(c.env.DB, accountId)) ?? defaultAccount(accountId))
|
||||
)
|
||||
})
|
||||
.get(
|
||||
'/account/:id',
|
||||
describeRoute({
|
||||
tags: ['Lookup'],
|
||||
summary: 'A single public account',
|
||||
description: 'An id with no stored row falls back to a synthesized default account.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Account id; non-numeric is 400',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(AccountDto, 'The public account'),
|
||||
400: { description: 'Non-numeric id (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (Number.isNaN(accountId)) return c.body(null, 400)
|
||||
// Load the stored account, falling back to a synthesized default.
|
||||
return c.json(
|
||||
toAccountDto((await getAccount(c.env.DB, accountId)) ?? defaultAccount(accountId))
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// ---- Create --------------------------------------------------------------
|
||||
.post('/account/create', async (c) => {
|
||||
// Parsed for fidelity; unused until there's a DB to persist CachedLogins.
|
||||
const platform = await formField(c, 'platform')
|
||||
await formField(c, 'platformId')
|
||||
.post(
|
||||
'/account/create',
|
||||
describeRoute({
|
||||
tags: ['Self'],
|
||||
summary: 'Create an account',
|
||||
description: [
|
||||
'Mints a new account with an auto-assigned random username (players don’t choose',
|
||||
'one initially). Not auth-gated. `platformId` is parsed but not yet persisted.',
|
||||
].join(' '),
|
||||
requestBody: form(CreateAccountRequest, 'Platform fields'),
|
||||
responses: { 200: json(CreateAccountResult, 'The created account, in a result envelope') },
|
||||
}),
|
||||
async (c) => {
|
||||
// Parsed for fidelity; unused until there's a DB to persist CachedLogins.
|
||||
const platform = await formField(c, 'platform')
|
||||
await formField(c, 'platformId')
|
||||
|
||||
// Persist a new account with an auto-assigned random username (players
|
||||
// don't choose one initially).
|
||||
const platforms = Number.parseInt(platform, 10)
|
||||
const account = await createAccount(c.env.DB, {
|
||||
platforms: Number.isNaN(platforms) ? 0 : platforms,
|
||||
})
|
||||
// TODO: also create a dorm Room/SubRoom for the new account.
|
||||
return c.json({ success: true, value: toAccountDto(account) })
|
||||
})
|
||||
// Persist a new account with an auto-assigned random username (players
|
||||
// don't choose one initially).
|
||||
const platforms = Number.parseInt(platform, 10)
|
||||
const account = await createAccount(c.env.DB, {
|
||||
platforms: Number.isNaN(platforms) ? 0 : platforms,
|
||||
})
|
||||
// TODO: also create a dorm Room/SubRoom for the new account.
|
||||
return c.json({ success: true, value: toAccountDto(account) })
|
||||
}
|
||||
)
|
||||
|
||||
// ---- Parental control ----------------------------------------------------
|
||||
.get('/parentalcontrol/me', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({ accountId: id, disallowInAppPurchases: false })
|
||||
})
|
||||
.get(
|
||||
'/parentalcontrol/me',
|
||||
describeRoute({
|
||||
tags: ['Self'],
|
||||
summary: 'The caller’s parental-control flags',
|
||||
description: 'Nothing stores parental controls yet; purchases are always allowed.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(ParentalControl, 'Parental-control flags'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({ accountId: id, disallowInAppPurchases: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Privacy settings for an account. A bare `{}` fails the client's deserializer
|
||||
// ("Deserialization returned null") — it needs the fields, so echo the id back and
|
||||
// report recent history as visible. Nothing stores per-player privacy yet.
|
||||
.get('/accountprivacysettings/:id{[0-9]+}', (c) =>
|
||||
c.json({
|
||||
accountId: Number.parseInt(c.req.param('id'), 10),
|
||||
isRecentHistoryVisible: true,
|
||||
})
|
||||
.get(
|
||||
'/accountprivacysettings/:id{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Lookup'],
|
||||
summary: 'An account’s privacy settings',
|
||||
description: [
|
||||
'Nothing stores per-player privacy yet; the id is echoed and recent history is',
|
||||
'reported visible (a bare `{}` fails the client’s deserializer).',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Account id (digits only)',
|
||||
schema: { type: 'string', pattern: '^[0-9]+$' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(PrivacySettings, 'Privacy settings') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
accountId: Number.parseInt(c.req.param('id'), 10),
|
||||
isRecentHistoryVisible: true,
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Profile mutations ---------------------------------------------------
|
||||
// Set the player's display name (persisted on the account row).
|
||||
.put('/account/me/displayname', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const displayName = (await formField(c, 'displayName')).trim()
|
||||
if (displayName === '') return c.body(null, 400)
|
||||
const account = await updateAccount(c.env.DB, id, { displayName })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
.put(
|
||||
'/account/me/displayname',
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Set display name',
|
||||
description: 'Persisted and broadcast via an AccountUpdate notification.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
// An EMPTY 400, which is what this route already answered for an empty name: it
|
||||
// acks with a bare SuccessResponse and has never sent the client a body on
|
||||
// failure, so enforcing the schema doesn't change what a refusal looks like.
|
||||
validator('form', DisplayNameRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const { displayName } = c.req.valid('form')
|
||||
const account = await updateAccount(c.env.DB, id, { displayName })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// Change the caller's username. Rejects a name already taken by another account,
|
||||
// and requires the account to have username changes remaining. On success the
|
||||
// new name is persisted and the remaining-changes counter is decremented.
|
||||
.put('/account/me/username', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
.put(
|
||||
'/account/me/username',
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Change username',
|
||||
description: [
|
||||
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
|
||||
'account and requires a remaining change; on success the name is persisted and',
|
||||
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
|
||||
'(see the UsernameResult envelope).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(UsernameResult, 'The updated account, in the result envelope'),
|
||||
400: json(UsernameResult, 'Refused — `error` carries the reason, `value` is ""'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
// Shape is checked before the handler runs, so a rejected name costs no D1 read and
|
||||
// — the part that matters — can never spend one of the account's rationed changes.
|
||||
// The message is relayed rather than zod's issue array: `nameRejection` writes the
|
||||
// sentence the player reads, and nothing can render an array of issues.
|
||||
// `c` is annotated so the hook's context matches this app's bindings, and `error` is
|
||||
// Standard Schema's flat issue list rather than a zod error object.
|
||||
validator('form', UsernameRequest, (r, c: Context<App>) =>
|
||||
r.success
|
||||
? undefined
|
||||
: usernameResult(c, r.error[0]?.message ?? 'That username cannot be used.')
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const username = (await formField(c, 'username')).trim()
|
||||
if (username === '') return usernameResult(c, 'You must enter a username.')
|
||||
const { username } = c.req.valid('form')
|
||||
|
||||
// Duplicate check first (case-insensitive); keeping your own name is allowed.
|
||||
const existing = await getAccountByUsername(c.env.DB, username)
|
||||
if (existing && existing.accountId !== id) {
|
||||
return usernameResult(c, 'That username is already taken.')
|
||||
// Duplicate check first (case-insensitive); keeping your own name is allowed.
|
||||
const existing = await getAccountByUsername(c.env.DB, username)
|
||||
if (existing && existing.accountId !== id) {
|
||||
return usernameResult(c, 'That username is already taken.')
|
||||
}
|
||||
|
||||
// Then require a remaining change.
|
||||
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
|
||||
const remaining = account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES
|
||||
if (remaining <= 0) {
|
||||
return usernameResult(c, 'You have no username changes remaining.')
|
||||
}
|
||||
|
||||
const updated = await updateAccount(c.env.DB, id, {
|
||||
username,
|
||||
availableUsernameChanges: remaining - 1,
|
||||
})
|
||||
await pushAccountUpdate(c, updated)
|
||||
return usernameResult(c, '', toAccountDto(updated))
|
||||
}
|
||||
|
||||
// Then require a remaining change.
|
||||
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
|
||||
const remaining = account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES
|
||||
if (remaining <= 0) {
|
||||
return usernameResult(c, 'You have no username changes remaining.')
|
||||
}
|
||||
|
||||
const updated = await updateAccount(c.env.DB, id, {
|
||||
username,
|
||||
availableUsernameChanges: remaining - 1,
|
||||
})
|
||||
await pushAccountUpdate(c, updated)
|
||||
return usernameResult(c, '', toAccountDto(updated))
|
||||
})
|
||||
)
|
||||
|
||||
// Set the player's email (persisted on the account row; surfaced by /account/me).
|
||||
.post('/account/me/email', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const email = (await formField(c, 'email')).trim()
|
||||
if (!email.includes('@')) return c.body(null, 400)
|
||||
await updateAccount(c.env.DB, id, { email })
|
||||
return c.json({ success: true })
|
||||
})
|
||||
.post(
|
||||
'/account/me/email',
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Set email',
|
||||
description: 'Persisted; surfaced only by `/account/me`. Not broadcast.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Not a syntactically valid address (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
validator('form', EmailRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const { email } = c.req.valid('form')
|
||||
await updateAccount(c.env.DB, id, { email })
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// Set the player's phone (persisted on the account row).
|
||||
.post('/account/me/phone', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const phone = (await formField(c, 'phone')).trim()
|
||||
if (phone === '') return c.body(null, 400)
|
||||
await updateAccount(c.env.DB, id, { phone })
|
||||
return c.json({ success: true })
|
||||
})
|
||||
.post(
|
||||
'/account/me/phone',
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Set phone number',
|
||||
description: 'Persisted on the account row. Not broadcast.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Empty phone (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
validator('form', PhoneRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const { phone } = c.req.valid('form')
|
||||
await updateAccount(c.env.DB, id, { phone })
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// Set the player's identityFlags bitmask (persisted; surfaced by /account/me).
|
||||
// `identityFlags` is part of the public account DTO, so the update has to be pushed
|
||||
// — see the note on personalpronouns below.
|
||||
.put('/account/me/identityflags', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const identityFlags = Number.parseInt((await formField(c, 'identityFlags')).trim(), 10)
|
||||
if (Number.isNaN(identityFlags)) return c.body(null, 400)
|
||||
const account = await updateAccount(c.env.DB, id, { identityFlags })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
.put(
|
||||
'/account/me/identityflags',
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Set identity flags',
|
||||
description: [
|
||||
'`identityFlags` bitmask. In the public DTO, so the update is broadcast via',
|
||||
'AccountUpdate.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(IdentityFlagsRequest, 'The identityFlags bitmask'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Non-numeric identityFlags (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const identityFlags = Number.parseInt((await formField(c, 'identityFlags')).trim(), 10)
|
||||
if (Number.isNaN(identityFlags)) return c.body(null, 400)
|
||||
const account = await updateAccount(c.env.DB, id, { identityFlags })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// Set the player's personalPronouns (posted as `pronounFlags`; persisted).
|
||||
// The response body carries no account, so the client only learns the new value from
|
||||
// the `SelfAccountUpdate`/`AccountUpdate` the hub pushes — without it the player's own
|
||||
// UI (and every other client, since personalPronouns is in the public DTO) keeps
|
||||
// showing the old pronouns until something else refetches the account.
|
||||
.put('/account/me/personalpronouns', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const personalPronouns = Number.parseInt((await formField(c, 'pronounFlags')).trim(), 10)
|
||||
if (Number.isNaN(personalPronouns)) return c.body(null, 400)
|
||||
const account = await updateAccount(c.env.DB, id, { personalPronouns })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
.put(
|
||||
'/account/me/personalpronouns',
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Set personal pronouns',
|
||||
description: [
|
||||
'Posted as `pronounFlags`. The response carries no account, so the client learns',
|
||||
'the new value only from the broadcast AccountUpdate.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(PronounsRequest, 'The pronounFlags bitmask'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Non-numeric pronounFlags (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const personalPronouns = Number.parseInt((await formField(c, 'pronounFlags')).trim(), 10)
|
||||
if (Number.isNaN(personalPronouns)) return c.body(null, 400)
|
||||
const account = await updateAccount(c.env.DB, id, { personalPronouns })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
.put('/account/me/bio', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const bio = await formField(c, 'bio')
|
||||
const account = await updateAccount(c.env.DB, id, { bio })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
.put(
|
||||
'/account/me/bio',
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Set bio',
|
||||
description: 'Free text up to 255 characters; empty is allowed. Persisted and broadcast.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Bio over 255 characters (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
// Refused rather than truncated: silently storing half a sentence reads as data loss.
|
||||
validator('form', BioRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const { bio } = c.req.valid('form')
|
||||
const account = await updateAccount(c.env.DB, id, { bio })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
.put('/account/me/profileimage', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const imageName = await formField(c, 'imageName')
|
||||
if (!imageName) return c.body(null, 400)
|
||||
// Persist the new avatar key on the account row and fire the AccountUpdate
|
||||
// websocket (the new profileImage rides along in the DTO payload).
|
||||
const account = await updateAccount(c.env.DB, id, { profileImage: imageName })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
.put(
|
||||
'/account/me/profileimage',
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Set profile image',
|
||||
description: 'Persists the avatar object key and broadcasts it in the AccountUpdate payload.',
|
||||
security: AUTHED,
|
||||
requestBody: form(ProfileImageRequest, 'The avatar object key'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Empty imageName (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const imageName = await formField(c, 'imageName')
|
||||
if (!imageName) return c.body(null, 400)
|
||||
// Persist the new avatar key on the account row and fire the AccountUpdate
|
||||
// websocket (the new profileImage rides along in the DTO payload).
|
||||
const account = await updateAccount(c.env.DB, id, { profileImage: imageName })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
app.get(
|
||||
'/openapi.json',
|
||||
describeRoute({ hide: true }),
|
||||
withCleanSpec(
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: 'recflare accounts',
|
||||
version: '1.0.0',
|
||||
description: [
|
||||
'Account reads, profile mutations and lookups for recflare, a private-server',
|
||||
'reimplementation of the Rec Room backend. Accounts live in the shared `recflare`',
|
||||
'D1 database, whose `account` schema is owned by the `auth` worker.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://accounts.recflare.net', description: 'Production' }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
isValidBio,
|
||||
isValidEmail,
|
||||
MAX_DISPLAY_NAME_LENGTH,
|
||||
MAX_USERNAME_LENGTH,
|
||||
nameRejection,
|
||||
} from '@repo/domain'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the accounts worker.
|
||||
*
|
||||
* Most of these are DESCRIPTIVE ONLY: they are passed to `describeRoute` to generate the
|
||||
* spec, and the handler stays lenient. That is deliberate — the Rec Room client is the
|
||||
* real consumer, form fields are read as `typeof value === 'string' ? value : ''`, and
|
||||
* missing or malformed input falls through to a graceful path (or a synthesized default
|
||||
* account) rather than a hard error. A schema that rejected what the client actually
|
||||
* sends would break the game, not protect it.
|
||||
*
|
||||
* The EXCEPTION is the profile mutations a player types into a box — displayName,
|
||||
* username, email, phone, bio. Those carry real rules (see `@repo/domain`), and each is
|
||||
* wired into `hono-openapi`'s `validator()` per route, with tests, exactly as the older
|
||||
* version of this note prescribed. Wiring one up means the schema both validates the
|
||||
* request and generates the spec, so a limit can't be changed in one and not the other —
|
||||
* which is precisely how the documented email limit came to disagree with the real one.
|
||||
*
|
||||
* A validated route drops `requestBody: form(...)` from its `describeRoute`: the
|
||||
* validator registers the body itself, and declaring it twice would emit it twice.
|
||||
*/
|
||||
|
||||
/** Emit a zod schema as an `application/json` response body. */
|
||||
export function json(schema: z.ZodType, description: string) {
|
||||
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a zod schema as a form request body. `describeRoute`'s `requestBody` takes a
|
||||
* plain OpenAPI schema (not a `resolver()`), so convert here. zod's `$schema` key and
|
||||
* `additionalProperties: false` are dropped — these handlers read the fields they know
|
||||
* and ignore the rest, so claiming a closed object would misreport them as stricter
|
||||
* than they are. The client posts both urlencoded and multipart, hence the wildcard.
|
||||
*/
|
||||
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||
return {
|
||||
description,
|
||||
content: {
|
||||
// zod's JSONSchema type is far wider than OpenAPI's SchemaObject; cast at the
|
||||
// boundary (the emitted value is valid OpenAPI 3.1).
|
||||
'application/x-www-form-urlencoded': { schema: jsonSchema as OpenAPIV3_1.SchemaObject },
|
||||
'multipart/form-data': { schema: jsonSchema as OpenAPIV3_1.SchemaObject },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public account DTO (`toAccountDto`) — the camelCase shape returned for any
|
||||
* account, with private fields (email, birthday) excluded. Fields the client parses
|
||||
* as enums are numbers here.
|
||||
*/
|
||||
export const AccountDto = z.object({
|
||||
accountId: z.int(),
|
||||
username: z.string(),
|
||||
displayName: z.string(),
|
||||
profileImage: z.string().describe('Avatar object key'),
|
||||
isJunior: z.boolean(),
|
||||
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
|
||||
personalPronouns: z.int().describe('Pronoun flags bitmask'),
|
||||
identityFlags: z.int().describe('Identity flags bitmask'),
|
||||
createdAt: z.iso.datetime(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The private self DTO (`toSelfAccountDto`, the `/account/me` shape) — the public DTO
|
||||
* plus owner-only fields. `juniorState`/`parentAccountId` are omitted entirely when
|
||||
* unset (emitting `null` makes the client's enum parser throw); `email`/`birthday` are
|
||||
* kept as nullable since they aren't enums.
|
||||
*/
|
||||
export const SelfAccountDto = AccountDto.extend({
|
||||
email: z.string().nullable(),
|
||||
birthday: z.null().describe('Always null — birthday is not stored'),
|
||||
availableUsernameChanges: z.int().describe('Remaining username changes'),
|
||||
})
|
||||
|
||||
/** Player bio, from `GET /account/:id/bio`. */
|
||||
export const BioResponse = z.object({
|
||||
accountId: z.int(),
|
||||
bio: z.string().describe('"" when unset'),
|
||||
})
|
||||
|
||||
/** A bare `{ success: true }` ack, returned by most profile mutations. */
|
||||
export const SuccessResponse = z.object({ success: z.literal(true) })
|
||||
|
||||
/** The RecNet result envelope `{ success, value }` used by create + username change. */
|
||||
export function envelope(value: z.ZodType) {
|
||||
return z.object({
|
||||
success: z.boolean(),
|
||||
value,
|
||||
error: z.string().optional().describe('Present (with success:false) on failure'),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The username-change envelope. Always HTTP 200 even on failure: `success:false` with
|
||||
* a message in `error` and `value` an empty string; on success `value` is the updated
|
||||
* public account.
|
||||
*/
|
||||
export const UsernameResult = envelope(z.union([AccountDto, z.literal('')])).describe(
|
||||
'value is the updated account on success, "" on failure'
|
||||
)
|
||||
|
||||
/** `POST /account/create` response. */
|
||||
export const CreateAccountResult = envelope(AccountDto)
|
||||
|
||||
/** `GET /parentalcontrol/me` response. */
|
||||
export const ParentalControl = z.object({ accountId: z.int(), disallowInAppPurchases: z.boolean() })
|
||||
|
||||
/**
|
||||
* `GET /accountprivacysettings/:id` response. A bare `{}` fails the client's
|
||||
* deserializer, so the id is echoed back and recent history reported visible; nothing
|
||||
* stores per-player privacy yet.
|
||||
*/
|
||||
export const PrivacySettings = z.object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() })
|
||||
|
||||
/** Root health check. */
|
||||
export const HealthResponse = z.object({ service: z.literal('accounts'), status: z.literal('ok') })
|
||||
|
||||
// ---- Request bodies --------------------------------------------------------
|
||||
|
||||
/** `POST /account/create` form body. Both fields are parsed but not yet persisted. */
|
||||
export const CreateAccountRequest = z.object({
|
||||
platform: z.string().optional().describe('PlatformType integer string; defaults to 0'),
|
||||
platformId: z.string().optional().describe('Parsed for fidelity; currently unused'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Single-string form bodies, one per profile mutation.
|
||||
*
|
||||
* These are ENFORCED, not just described: each is handed to hono-openapi's `validator`,
|
||||
* so the same schema both validates the request and generates the spec. Before this they
|
||||
* were documentation only, and the real rule lived in the handler — which meant every
|
||||
* limit had to be edited in two places and nothing caught them disagreeing.
|
||||
*
|
||||
* The rules themselves come from `@repo/domain` so `rooms` and `clubs` can't drift from
|
||||
* `accounts`; `superRefine` is used where the message matters, because `nameRejection`
|
||||
* writes the player-facing sentence and there's no reason to write it twice.
|
||||
*/
|
||||
|
||||
/** Zod check that defers to the shared name rule, message and all. */
|
||||
const nameCheck = (label: string, max: number) =>
|
||||
z.string()
|
||||
.trim()
|
||||
.superRefine((value, ctx) => {
|
||||
const rejection = nameRejection(value, label, max)
|
||||
if (rejection !== null) ctx.addIssue({ code: 'custom', message: rejection })
|
||||
})
|
||||
|
||||
export const DisplayNameRequest = z.object({
|
||||
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
|
||||
.min(1)
|
||||
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'),
|
||||
})
|
||||
|
||||
export const UsernameRequest = z.object({
|
||||
username: nameCheck('username', MAX_USERNAME_LENGTH)
|
||||
.min(1, 'You must enter a username.')
|
||||
.describe(
|
||||
'Trimmed; letters and digits only, max 50. Must be unique and changes must remain'
|
||||
),
|
||||
})
|
||||
|
||||
export const EmailRequest = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(isValidEmail, 'That email address looks wrong.')
|
||||
.describe('A syntactically valid address (RFC 5321/5322, so at most 254); otherwise 400'),
|
||||
})
|
||||
|
||||
export const PhoneRequest = z.object({
|
||||
// No shape rule on purpose: the client sends E.164 (`+15552223333`), which the name
|
||||
// rule above would reject outright by eating the leading `+`.
|
||||
phone: z.string().trim().min(1).describe('Trimmed; empty is rejected (400)'),
|
||||
})
|
||||
|
||||
export const IdentityFlagsRequest = z.object({
|
||||
identityFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
|
||||
})
|
||||
|
||||
export const PronounsRequest = z.object({
|
||||
pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
|
||||
})
|
||||
|
||||
export const BioRequest = z.object({
|
||||
// Not trimmed — a bio is free text, and leading whitespace is the player's business.
|
||||
bio: z.string().refine(isValidBio).describe('Free text, max 255; empty is allowed'),
|
||||
})
|
||||
|
||||
export const ProfileImageRequest = z.object({
|
||||
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
|
||||
})
|
||||
@@ -220,8 +220,9 @@ describe('auth-gated endpoints', () => {
|
||||
...form({ username: 'Coach' }),
|
||||
headers: { ...(await bearer('893')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
// Business errors are HTTP 200 with the { success, error, value } envelope.
|
||||
expect(res.status).toBe(200)
|
||||
// A refusal is a 400 carrying the same { success, error, value } envelope. It used
|
||||
// to be HTTP 200, which read as a success to anything branching on the status.
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { success: boolean; error: string; value: string }
|
||||
expect(body.success).toBe(false)
|
||||
expect(body.error).toMatch(/already taken/i)
|
||||
@@ -260,7 +261,7 @@ describe('auth-gated endpoints', () => {
|
||||
...form({ username: 'coachy' }),
|
||||
headers,
|
||||
})
|
||||
expect(blocked.status).toBe(200)
|
||||
expect(blocked.status).toBe(400)
|
||||
const blockedBody = (await blocked.json()) as { success: boolean; error: string }
|
||||
expect(blockedBody.success).toBe(false)
|
||||
expect(blockedBody.error).toMatch(/no username changes/i)
|
||||
@@ -412,4 +413,225 @@ describe('auth-gated endpoints', () => {
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('888') })
|
||||
expect(((await me.json()) as { email: string }).email).toBe('ners@recroom.com')
|
||||
})
|
||||
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
const spec = (await res.json()) as {
|
||||
openapi: string
|
||||
paths: Record<string, Record<string, { summary?: string }>>
|
||||
}
|
||||
expect(spec.openapi).toMatch(/^3\.1/)
|
||||
|
||||
// The spec route hides itself.
|
||||
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'GET /',
|
||||
'GET /account/bulk',
|
||||
'GET /account/me',
|
||||
'GET /account/search',
|
||||
'GET /account/{id}',
|
||||
'GET /account/{id}/bio',
|
||||
'GET /accountprivacysettings/{id}',
|
||||
'GET /parentalcontrol/me',
|
||||
'POST /account/create',
|
||||
'POST /account/me/email',
|
||||
'POST /account/me/phone',
|
||||
'PUT /account/me/bio',
|
||||
'PUT /account/me/displayname',
|
||||
'PUT /account/me/identityflags',
|
||||
'PUT /account/me/personalpronouns',
|
||||
'PUT /account/me/profileimage',
|
||||
'PUT /account/me/username',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
// documentation.
|
||||
for (const ops of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
// hono-openapi registers a validated form body under `multipart/form-data` only, and
|
||||
// its `media` option can't say otherwise (a precedence bug — see `withCleanSpec`). The
|
||||
// real callers post `application/x-www-form-urlencoded`, so a spec that named only
|
||||
// multipart would tell an integrator to send the one thing nothing here sends.
|
||||
test('GET /openapi.json documents both form content types on validated routes', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
const spec = (await res.json()) as {
|
||||
paths: Record<string, Record<string, { requestBody?: { content: Record<string, unknown> } }>>
|
||||
}
|
||||
|
||||
for (const [path, method] of [
|
||||
['/account/me/email', 'post'],
|
||||
['/account/me/username', 'put'],
|
||||
['/account/me/displayname', 'put'],
|
||||
['/account/me/bio', 'put'],
|
||||
['/account/me/phone', 'post'],
|
||||
] as const) {
|
||||
const content = spec.paths[path]?.[method]?.requestBody?.content ?? {}
|
||||
expect(Object.keys(content).sort(), path).toEqual([
|
||||
'application/x-www-form-urlencoded',
|
||||
'multipart/form-data',
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// The names a player chooses are alphanumeric and length-capped, by the same rule the
|
||||
// `rooms` worker applies (see `nameRejection` in @repo/domain). The three limits come
|
||||
// from the client's own input boxes rather than a round number, so anything stored is
|
||||
// something the game can render and re-edit.
|
||||
//
|
||||
// Server-generated names go around this deliberately — the seeded "Rec Room" account
|
||||
// above has a space in its display name, and dorms are called `@<username>'s Dorm`. The
|
||||
// check belongs at the request handler, not in the db helpers.
|
||||
describe('name, email and bio validation', () => {
|
||||
const authed = async (sub: string) => ({
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
})
|
||||
|
||||
test('PUT /account/me/username refuses anything but letters and digits, max 50', async () => {
|
||||
const headers = await authed('8801')
|
||||
for (const username of ['has space', 'under_score', 'punct!', 'café', 'a'.repeat(51)]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||
...form({ username }),
|
||||
headers,
|
||||
})
|
||||
// Refused by the SCHEMA (see openapi.ts `UsernameRequest`) before the handler
|
||||
// runs — but still in this route's envelope, because the hook puts it there.
|
||||
expect(res.status, username).toBe(400)
|
||||
const body = (await res.json()) as { success: boolean; error: string; value: string }
|
||||
expect(body.success, username).toBe(false)
|
||||
expect(body.error).toMatch(/letters and numbers|at most 50 characters/)
|
||||
expect(body.value).toBe('')
|
||||
}
|
||||
|
||||
// The rationed change must NOT be spent by a refusal: an account starts with one,
|
||||
// and burning it on a typo would leave the player stuck with a name they never had.
|
||||
const me = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('8801') })
|
||||
).json()) as { availableUsernameChanges: number }
|
||||
expect(me.availableUsernameChanges).toBe(1)
|
||||
|
||||
// 50 is the client's own cap, so a name that long has to be accepted.
|
||||
const ok = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||
...form({ username: 'a'.repeat(50) }),
|
||||
headers,
|
||||
})
|
||||
expect(((await ok.json()) as { success: boolean }).success).toBe(true)
|
||||
})
|
||||
|
||||
test('PUT /account/me/displayname refuses anything but letters and digits, max 15', async () => {
|
||||
const headers = await authed('8802')
|
||||
for (const displayName of ['has space', 'punct!', 'a'.repeat(16)]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
...form({ displayName }),
|
||||
headers,
|
||||
})
|
||||
// An empty 400, matching what this route already answers for an empty name —
|
||||
// it acks with a bare `{ success: true }` and has never sent the client a body
|
||||
// on failure.
|
||||
expect(res.status, displayName).toBe(400)
|
||||
}
|
||||
|
||||
// 15 is the client's box, so it must fit.
|
||||
const ok = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
...form({ displayName: 'a'.repeat(15) }),
|
||||
headers,
|
||||
})
|
||||
expect(ok.status).toBe(200)
|
||||
})
|
||||
|
||||
// Syntax comes from the `isemail` package rather than a pattern written here — this is
|
||||
// a contact address nothing is ever sent to in order to prove it, so a hand-rolled
|
||||
// regex only buys more edge cases to get wrong. It enforces the RFC's own
|
||||
// 254-character maximum, which is why there's no separate length check.
|
||||
test('POST /account/me/email requires a syntactically valid address', async () => {
|
||||
const headers = await authed('8803')
|
||||
const bad = [
|
||||
'nope', // no @ at all — what this route used to be the only check for
|
||||
'@example.com', // nothing to deliver to
|
||||
'someone@', // no domain
|
||||
'someone@example.', // empty last label
|
||||
'two words@example.com', // whitespace
|
||||
`${'a'.repeat(250)}@example.com`, // past the RFC's 254
|
||||
]
|
||||
for (const email of bad) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email }),
|
||||
method: 'POST',
|
||||
headers,
|
||||
})
|
||||
expect(res.status, email).toBe(400)
|
||||
}
|
||||
|
||||
// `someone@localhost` is in the ACCEPTED list on purpose: it's valid per the RFC,
|
||||
// and an undeliverable address costs nothing here.
|
||||
for (const email of [
|
||||
'someone@example.com',
|
||||
'first.last+tag@mail.example.co.uk',
|
||||
'someone@localhost',
|
||||
]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email }),
|
||||
method: 'POST',
|
||||
headers,
|
||||
})
|
||||
expect(res.status, email).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
test('PUT /account/me/bio caps the stored text at 255 characters', async () => {
|
||||
const headers = await authed('8804')
|
||||
|
||||
const ok = await exports.default.fetch(`${ORIGIN}/account/me/bio`, {
|
||||
...form({ bio: 'b'.repeat(255) }),
|
||||
headers,
|
||||
})
|
||||
expect(ok.status).toBe(200)
|
||||
|
||||
// Refused rather than truncated — storing half a sentence reads as data loss.
|
||||
const tooLong = await exports.default.fetch(`${ORIGIN}/account/me/bio`, {
|
||||
...form({ bio: 'b'.repeat(256) }),
|
||||
headers,
|
||||
})
|
||||
expect(tooLong.status).toBe(400)
|
||||
|
||||
// The refusal changed nothing: the 255-character bio is still what's stored.
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/8804/bio`)
|
||||
expect(((await me.json()) as { bio: string }).bio).toBe('b'.repeat(255))
|
||||
})
|
||||
})
|
||||
|
||||
// Phone is deliberately NOT held to the name rule above: the client sends E.164
|
||||
// (`+15552223333`), so a letters-and-digits check would reject every real number by
|
||||
// eating the leading `+`. Pinned here because this route sits between two that DID just
|
||||
// get stricter, and the obvious next "cleanup" is to make it match them.
|
||||
test('POST /account/me/phone stores an E.164 number exactly as the client sends it', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/phone`, {
|
||||
...form({ phone: '+15552223333' }),
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('8805')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
// Read from the row: phone is stored but not surfaced by any DTO, so there's no
|
||||
// endpoint to check it through.
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT json_extract(data, '$.phone') AS phone FROM account WHERE json_extract(data, '$.accountId') = 8805"
|
||||
).first<{ phone: string }>()
|
||||
// Verbatim — no normalising, no stripping of the +.
|
||||
expect(row?.phone).toBe('+15552223333')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Player-report storage. Like the relationship table (and unlike the JSON-blob
|
||||
-- tables in this shared database), a report is genuinely columnar, so it gets a
|
||||
-- normal relational table. Owned by the `api` worker; generated from
|
||||
-- src/reports-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- One row per submitted report; nothing updates or dedupes them, so the table is
|
||||
-- an append-only log of what players sent. `reporter_player_id` comes from the
|
||||
-- caller's bearer token, everything else from the form body.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS report (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reporter_player_id INTEGER NOT NULL,
|
||||
reported_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
details TEXT,
|
||||
height_reporter REAL,
|
||||
height_reported REAL,
|
||||
room_id INTEGER,
|
||||
room_instance_type TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Moderator-issued player warnings. The counterpart to the `report` table (0004):
|
||||
-- reports are what players submit, warnings are what a moderator hands down. Also
|
||||
-- columnar rather than a JSON blob, and likewise append-only. Owned by the `api`
|
||||
-- worker; generated from src/warnings-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `moderator_player_id` is the acting moderator, taken from the caller's bearer
|
||||
-- token (the endpoint is gated on the `moderator` role); everything else comes
|
||||
-- from the form body. `display_reason` is what the warned player is shown,
|
||||
-- `moderator_note` is internal.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS warning (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
moderator_player_id INTEGER NOT NULL,
|
||||
warned_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
display_reason TEXT,
|
||||
moderator_note TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Player-event storage (scheduled events: a room, a window of time, and the
|
||||
-- settings the event runs under). Like the image/invention/rooms/accounts tables
|
||||
-- in this shared database, an event is a single JSON blob in the `data` column,
|
||||
-- with queryable fields exposed as SQLite generated (virtual) columns extracted
|
||||
-- from that JSON. Owned by the `api` worker; generated from src/events-db.ts
|
||||
-- (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- The stored blob IS the DTO: every read endpoint serves it verbatim, so the
|
||||
-- PascalCase field set matches Rec Room's `PlayerEvent` exactly. `start_time` /
|
||||
-- `end_time` extract ISO-8601 UTC strings, which compare lexicographically — the
|
||||
-- browse query filters finished events in SQL on that.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
|
||||
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
||||
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
|
||||
start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
|
||||
end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Player-event RSVPs: one row per player per event, recording how they answered
|
||||
-- (`POST /api/playerevents/v1/respond`). Unlike the `event` table next to it, this
|
||||
-- one is genuinely columnar — like the relationship/report tables — so it's a
|
||||
-- normal relational table rather than a JSON blob. Owned by the `api` worker;
|
||||
-- generated from src/events-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go. Only Going
|
||||
-- counts toward the event's `AttendeeCount`, which is recomputed from this table on
|
||||
-- every response. A decline is recorded rather than deleted, so the client can show
|
||||
-- a player their own answer and changing your mind is an UPDATE (the composite
|
||||
-- primary key is what makes the upsert a replace).
|
||||
--
|
||||
-- An event's creator gets a Going row at create time — that's why a fresh event's
|
||||
-- AttendeeCount is 1.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_attendee (
|
||||
event_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
responded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (event_id, player_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Break the two visibility flags out of the invention JSON blob into queryable
|
||||
-- generated columns, the same way 0003 did for `IsFeatured`. `IsPublished` and
|
||||
-- `HideFromPlayer` are always tested together — every feed, the search/browse list and
|
||||
-- the per-room list ask for "published and not hidden" — so they move together.
|
||||
-- Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- SQLite allows ALTER TABLE ADD COLUMN only for VIRTUAL generated columns (a STORED one
|
||||
-- would need rewriting existing rows), which is what we want anyway: the value stays
|
||||
-- derived from `data`, so nothing can drift out of sync with it. json_extract of a JSON
|
||||
-- `true` is 1, so both columns read 1/0 — and NULL for a blob missing the key, which is
|
||||
-- neither 1 nor 0 and so fails both filters exactly as the json_extract predicates it
|
||||
-- replaces did. This is a rename, not a behaviour change.
|
||||
--
|
||||
-- No index: both columns are booleans that are overwhelmingly one value (nearly every
|
||||
-- invention is published and not hidden), so an index on them would be read past rather
|
||||
-- than used. The selective one is idx_invention_featured, added in 0003, which stays.
|
||||
|
||||
ALTER TABLE invention
|
||||
ADD COLUMN is_published INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsPublished')) VIRTUAL;
|
||||
ALTER TABLE invention
|
||||
ADD COLUMN hide_from_player INTEGER GENERATED ALWAYS AS (json_extract(data, '$.HideFromPlayer')) VIRTUAL;
|
||||
@@ -19,8 +19,13 @@
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "0.16.20",
|
||||
|
||||
+52
-1
@@ -1,10 +1,12 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import { avatarRoutes } from './routes/avatar'
|
||||
import { configRoutes } from './routes/config'
|
||||
import { eventRoutes } from './routes/events'
|
||||
import { gameplayRoutes } from './routes/gameplay'
|
||||
import { imageRoutes } from './routes/images'
|
||||
import { inventoryRoutes } from './routes/inventory'
|
||||
@@ -38,6 +40,14 @@ const app = new Hono<App>({ strict: false })
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
// The website (`www`) is a browser origin calling these endpoints directly, the way
|
||||
// rec.net's own site called the game's API — so the responses need CORS headers or
|
||||
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
|
||||
// endpoints authenticate with a bearer token in the `Authorization` header, never a
|
||||
// cookie: a hostile page can't read another origin's stored token, so there is no
|
||||
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
|
||||
.use('*', withDefaultCors())
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
@@ -47,9 +57,50 @@ const app = new Hono<App>({ strict: false })
|
||||
.route('/', progressionRoutes)
|
||||
.route('/', avatarRoutes)
|
||||
.route('/', gameplayRoutes)
|
||||
.route('/', eventRoutes)
|
||||
.route('/', moderationRoutes)
|
||||
.route('/', inventoryRoutes)
|
||||
.route('/', roomRoutes)
|
||||
.route('/', imageRoutes)
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
app.get(
|
||||
'/openapi.json',
|
||||
describeRoute({ hide: true }),
|
||||
withCleanSpec(
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: 'recflare api',
|
||||
version: '1.0.0',
|
||||
description: [
|
||||
'The catch-all Game API for recflare, a private-server reimplementation of the Rec',
|
||||
'Room backend: everything the client calls that has not been split out into its own',
|
||||
'worker yet. Today that is config, the friend graph, inventions, saved photos,',
|
||||
'player events, reputation and the assorted sinks the client hits while loading.',
|
||||
'Relationships, inventions, images and player events are D1-backed; several',
|
||||
'endpoints are still stubs, noted per route.',
|
||||
'',
|
||||
'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,',
|
||||
'equipment, consumables and objectives on `econ`) are already served there — the',
|
||||
'client calls that host and the copy here is a stub, which each route says.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://api.recflare.net', description: 'Production' }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { HonoApp } from '@repo/hono-helpers'
|
||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||
// Type-only import (erased at build) of the DO class owned by the `notify`
|
||||
// worker, so the cross-worker RPC stub is fully typed.
|
||||
import type { NotificationsHub } from '../../notify/src/notifications-hub'
|
||||
|
||||
export type Env = SharedHonoEnv & {
|
||||
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
|
||||
@@ -18,6 +21,12 @@ export type Env = SharedHonoEnv & {
|
||||
// Image bucket (shared with the `img` worker, which serves objects back by
|
||||
// key). Uploaded saved images are written here.
|
||||
IMAGES: R2Bucket
|
||||
// Shared CDN bucket (owned by the `cdn` worker, written by `storage`). Read
|
||||
// here only to hash an invention's uploaded data blob under `invention/`.
|
||||
CDN_ASSETS: R2Bucket
|
||||
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
|
||||
// push RelationshipChanged notifications when a player's relationship changes.
|
||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
/**
|
||||
* Default avatar items for `GET /api/avatar/v4/items`.
|
||||
* Stored as `[AvatarItemDesc, FriendlyName, Rarity?]`
|
||||
* tuples — every entry shares `AvatarItemType: 0`, `PlatformMask: -1`, `Tooltip: ""`,
|
||||
* and `Rarity` defaults to `0`.
|
||||
*/
|
||||
export interface AvatarItem {
|
||||
AvatarItemType: number
|
||||
AvatarItemDesc: string
|
||||
PlatformMask: number
|
||||
FriendlyName: string
|
||||
Tooltip: string
|
||||
Rarity: number
|
||||
}
|
||||
|
||||
type Entry = readonly [desc: string, friendlyName: string, rarity?: number]
|
||||
|
||||
const ENTRIES: readonly Entry[] = [
|
||||
['5d13a7a2-8213-40e6-90a6-efdd76a3fdcb,,,', 'Flowing Hair'],
|
||||
['1d27b674-f9e2-4ffc-9d8c-a58a1be06457,,,', 'Afro Hair'],
|
||||
['d84c0ff9-8fbe-4ed8-abf3-7996e81888ab,,,', 'Large Afro Hair'],
|
||||
['e5b83dfc-b2e1-4dcb-a4ab-9d3a4c8a34ae,,,', 'Long Wavy Hair'],
|
||||
['7dd6f7b0-7ba0-429f-a04f-e32d3a79ee61,,,', 'Short Wavy Hair'],
|
||||
['eb9611c6-bb50-41a2-93e9-7f959815a846,,,', 'Dreads Long Hair'],
|
||||
['1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,,', 'Ponytail Hair'],
|
||||
['a12f724f-4a73-4ab8-aad4-6bfc662b4dd6,,,', 'Undercut Long Hair'],
|
||||
['0753d7a4-8247-4fca-a6fc-359c26086140,,,', 'Fonzie Hair'],
|
||||
['77d3c585-4928-4471-a425-89036efe7299,,,', 'Spiky Hair'],
|
||||
['92302d9d-c527-418c-ac5d-1fa869727505,,,', 'Part Hair'],
|
||||
['f9dd08f8-16d3-4c39-af4f-89f7bb6e80d3,,,', 'Undercut Short Hair'],
|
||||
['b148cb1e-df81-442f-aea6-ab1727aad00e,,,', 'Chunky Afro Hair'],
|
||||
['e36bcd98-7e85-43fa-89f8-57e4ec33823a,,,', 'Bob with Bangs Hair'],
|
||||
['880a3cc0-7407-4b61-b759-f9dd890fe9e5,,,', 'Bob Hair'],
|
||||
['21599b51-c50f-43d8-ac5f-62c30cd02ca5,,,', 'Lori Hair'],
|
||||
['193a3bf9-abc0-4d78-8d63-92046908b1c5,,,', 'Emo Hair'],
|
||||
['79b90274-6eec-4664-acfb-4a123334661e,,,', 'Pig Tails Hair'],
|
||||
['da4e7b34-2095-4a9e-801e-4f409039e0dd,,,', 'Buzz Cut Hair'],
|
||||
['9d9fadb6-97eb-480e-a224-4e0179082071,,,', 'Meatball Buns Hair'],
|
||||
['d8280c0c-d803-4513-be10-a0ba96d8821e,,,', 'Flowhawk Hair'],
|
||||
['e286863c-2967-4d00-b837-b49487b9484a,,,', 'Fauxhawk Hair'],
|
||||
['2cb4f372-3372-4583-8b57-c4e3988e3c28,,,', 'Punky Hair'],
|
||||
['06306723-ca20-4aa6-b7b3-917113f41ac3,,,', 'Cat-Eye Glasses (Red)'],
|
||||
['c70005d5-6276-4a98-acb3-6a77bc19379a,,,', 'Glasses (Teal)'],
|
||||
['8d10cc78-6b00-45f3-affb-205e9cc5b03f,,,', 'Beard (Close)'],
|
||||
['cc96f8a5-bc5b-4f89-83b7-ecd53905ada7,,,', 'Beard (Thick)'],
|
||||
['c6c08eb5-381a-4193-9722-80da95d62abe,,,', 'Business Tie (Black)'],
|
||||
['4d507dfa-4a99-4ac0-8537-229e9dc0eb4a,,,', 'Rec Room Tank Top (Orange)'],
|
||||
['d0a9262f-5504-46a7-bb10-7507503db58e,,,', 'Rec Room Shirt (Crew Neck, White)'],
|
||||
[
|
||||
'd0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,',
|
||||
'Rec Room T-Shirt (Crew Neck, Orange)',
|
||||
],
|
||||
['2e59d8d0-91a0-4449-bfdc-a5d663fd9343,,,', 'Collared Shirt (Plaid, Blue)'],
|
||||
['7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,,,', 'T-Shirt'],
|
||||
['8aa79563-ace1-4ba7-ad0c-f3210a78142f,,,', 'Rec Room Shirt (V-Neck, White)'],
|
||||
[
|
||||
'8aa79563-ace1-4ba7-ad0c-f3210a78142f,95e4cc30-cb68-473d-a395-feadf5b51512,05f0ee6e-c824-470e-9178-5ed576c6fe0c,',
|
||||
'Rec Room T-Shirt (V-Neck, Orange)',
|
||||
],
|
||||
['21caa68e-c3fa-474c-af5e-af1e742b7a60,,,', 'Tennis Skirt (Blue)'],
|
||||
[
|
||||
'21caa68e-c3fa-474c-af5e-af1e742b7a60,c5deba2a-6e35-4b13-8e94-8ba5457f39df,b75ef67d-00c3-4ac1-9b72-212032460294,',
|
||||
'Tennis Skirt (Yellow)',
|
||||
],
|
||||
[
|
||||
'21caa68e-c3fa-474c-af5e-af1e742b7a60,758752bd-db2f-43d2-b580-55b3e1efffd5,b75ef67d-00c3-4ac1-9b72-212032460294,',
|
||||
'Tennis Skirt (Red)',
|
||||
],
|
||||
['2296ed0d-df56-4d46-b33a-aae9230a47fc,,,', 'Zipper Dress (Yellow)'],
|
||||
['ecc1dbe6-ca06-4564-b2a6-30956194d1e9,,,', 'Wristbands (White)'],
|
||||
['71921831-ba6f-408b-a00e-2fd97663636f,,,', 'Wrist Tape (White)'],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,55901f12-d5b5-4fa8-b4c8-e479689ee39d,f600037d-c9c0-43fa-b45b-02f456f9dd5f,',
|
||||
'Collared Shirt (Denim)',
|
||||
],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,bf82f2f6-9af8-431e-a296-0890dea48ba7,d015cae7-a905-49e4-8823-6dec069689a6,',
|
||||
'Collared Shirt (Argyle)',
|
||||
],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,EfdMcnfHt0mr0PQ_maaYOg,DRJcNhkqvkKFEaZpOguR6w,',
|
||||
'Collared Shirt (Flowers, Green)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,6d703981-2734-4c45-8983-cdd5f328902f,a0271cd0-e172-4d3f-aa2f-9806f21a82d2,',
|
||||
'Tank Top (Camo)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,9c03f381-7357-4d0f-8cda-8737d4c43d25,',
|
||||
'Tank Top (Rainbow)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,d2a692e6-e1a9-4cfe-8154-10b52be7f8c8,',
|
||||
'Jersey (Orange)',
|
||||
10,
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,b292eb4b-07e3-4a48-99b5-3c6587a1e02e,',
|
||||
'Tank Top (Dots)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,48abd952-214f-48b2-a8f1-1146f6f69aa2,b78008e8-abbd-4ece-be34-9a911f721fcc,',
|
||||
'Tank Top (Zebra)',
|
||||
],
|
||||
[
|
||||
'14ef6b00-debf-4a85-9755-b4d37df496d3,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Baseball Cap (Red)',
|
||||
],
|
||||
[
|
||||
'14ef6b00-debf-4a85-9755-b4d37df496d3,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Baseball Cap (Blue)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Cream)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Black)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,018a5c07-e956-457d-a540-a5e2cd68da09,',
|
||||
'Headband (Orange, White)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,018a5c07-e956-457d-a540-a5e2cd68da09,',
|
||||
'Headband (Pink, White)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,018a5c07-e956-457d-a540-a5e2cd68da09,',
|
||||
'Headband (Blue, White)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,6dd95046-acf8-42fe-ab78-80a334096a9d,56a92c8d-af53-413e-929e-4a9a3cfad780,',
|
||||
'Headband (Red, White, Blue)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Blue)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Black)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Orange)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Pink)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Red)',
|
||||
],
|
||||
['fcfcaf63-deb4-45f7-b711-c051c9ea45cb,,,', 'Top Bun Hair'],
|
||||
['de0ac50d-2adb-4114-bd2e-68953b13d706,,,', 'Blazer (Blue, White)'],
|
||||
[
|
||||
'de0ac50d-2adb-4114-bd2e-68953b13d706,6f2e74bf-1e95-463d-97db-d5d1a53b2c28,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,',
|
||||
'Blazer (Black, White)',
|
||||
],
|
||||
[
|
||||
'de0ac50d-2adb-4114-bd2e-68953b13d706,9374bf66-2ee5-493b-8439-efce4b201904,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,',
|
||||
'Blazer (Grey, Black)',
|
||||
],
|
||||
[
|
||||
'de0ac50d-2adb-4114-bd2e-68953b13d706,272fe8eb-5061-4729-a7a8-414ff667a82f,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,',
|
||||
'Blazer (Grey, White)',
|
||||
],
|
||||
[
|
||||
'de0ac50d-2adb-4114-bd2e-68953b13d706,0ffad843-d6c9-425a-8686-7217009c867e,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,',
|
||||
'Blazer (Green, Black)',
|
||||
],
|
||||
[
|
||||
'9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,e0397982-c2c2-4733-9a40-46e18675b5af,dafa658e-753b-46cb-bd85-85c1de5e6ea7,',
|
||||
'Button Top (Orange)',
|
||||
],
|
||||
['9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,,,', 'Button Top (Pink)'],
|
||||
[
|
||||
'9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,49f5864f-9d40-497c-88c8-e87f64d41d74,dafa658e-753b-46cb-bd85-85c1de5e6ea7,',
|
||||
'Button Top (Tan)',
|
||||
],
|
||||
[
|
||||
'9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,c5deba2a-6e35-4b13-8e94-8ba5457f39df,dafa658e-753b-46cb-bd85-85c1de5e6ea7,',
|
||||
'Button Top (Yellow)',
|
||||
],
|
||||
[
|
||||
'6d815b35-6f68-4ed4-817d-70f141e1a571,f750de46-3758-4f7d-9709-0a84b1027009,2c8924aa-68f8-4912-9759-18992f72f08a,',
|
||||
'Collared Dress (Blue)',
|
||||
],
|
||||
[
|
||||
'6d815b35-6f68-4ed4-817d-70f141e1a571,d66aa400-aa5a-4539-a25d-5f8ce94dc281,2c8924aa-68f8-4912-9759-18992f72f08a,',
|
||||
'Collared Dress (Green)',
|
||||
],
|
||||
[
|
||||
'6d815b35-6f68-4ed4-817d-70f141e1a571,6564acf1-4d70-4f92-92ac-08e2b76dbb6b,2c8924aa-68f8-4912-9759-18992f72f08a,',
|
||||
'Collared Dress (Purple)',
|
||||
],
|
||||
['6d815b35-6f68-4ed4-817d-70f141e1a571,,,', 'Collared Dress (Red)'],
|
||||
['241506f6-bf88-4b46-b5fe-513a225421f4,,,', 'Half Up Hair'],
|
||||
[
|
||||
'6b9e022c-0b68-48fd-8eca-da8573c18900,d6edbc00-3c1d-4f49-8412-3ef8c7c5f4c2,cf119781-5bd9-4b85-9a0b-12e82e988c23,',
|
||||
'Long Scarf (Blue)',
|
||||
],
|
||||
[
|
||||
'2296ed0d-df56-4d46-b33a-aae9230a47fc,6d703981-2734-4c45-8983-cdd5f328902f,cfabdefe-0890-436e-b2a3-b5c712e22955,',
|
||||
'Zipper Dress (Green)',
|
||||
],
|
||||
[
|
||||
'2296ed0d-df56-4d46-b33a-aae9230a47fc,830be2fa-60a5-48cc-931f-34b670eae4bd,cfabdefe-0890-436e-b2a3-b5c712e22955,',
|
||||
'Zipper Dress (Purple)',
|
||||
],
|
||||
[
|
||||
'2296ed0d-df56-4d46-b33a-aae9230a47fc,bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a,cfabdefe-0890-436e-b2a3-b5c712e22955,',
|
||||
'Zipper Dress (White)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Light Blue)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Green)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Gold)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Tank Top (Black)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Tank Top (Blue)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Tank Top (Orange)',
|
||||
],
|
||||
[
|
||||
'7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Tank Top (Red)',
|
||||
],
|
||||
[
|
||||
'6b9e022c-0b68-48fd-8eca-da8573c18900,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,cf119781-5bd9-4b85-9a0b-12e82e988c23,',
|
||||
'Long Scarf (Purple)',
|
||||
],
|
||||
[
|
||||
'6b9e022c-0b68-48fd-8eca-da8573c18900,6dd95046-acf8-42fe-ab78-80a334096a9d,cf119781-5bd9-4b85-9a0b-12e82e988c23,',
|
||||
'Long Scarf (White)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Blue)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Green)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Red)',
|
||||
],
|
||||
['40528de7-38a3-4a7c-8f93-6d3bfa5573f2,,,', 'Headband (White)'],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Yellow)',
|
||||
],
|
||||
['24a240f4-1574-420b-b898-a7e91f170759,,,', 'Back Bun Hair'],
|
||||
['c45ed7b8-99bd-4a4b-a9ff-e16edf5d7a18,,,', 'High Pony Hair'],
|
||||
[
|
||||
'14ef6b00-debf-4a85-9755-b4d37df496d3,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Baseball Cap (Light Blue)',
|
||||
],
|
||||
[
|
||||
'14ef6b00-debf-4a85-9755-b4d37df496d3,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Baseball Cap (Black)',
|
||||
],
|
||||
[
|
||||
'14ef6b00-debf-4a85-9755-b4d37df496d3,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Baseball Cap (Orange)',
|
||||
],
|
||||
['14ef6b00-debf-4a85-9755-b4d37df496d3,,,', 'Baseball Cap (White)'],
|
||||
[
|
||||
'14ef6b00-debf-4a85-9755-b4d37df496d3,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Baseball Cap (Yellow)',
|
||||
],
|
||||
['896c2491-2f96-4986-9cbd-b3b31ef5d8c5,,,', 'Equestrian Coat (Black)'],
|
||||
[
|
||||
'896c2491-2f96-4986-9cbd-b3b31ef5d8c5,55901f12-d5b5-4fa8-b4c8-e479689ee39d,d344b8cc-85a8-4ace-9f92-38c84f396e99,',
|
||||
'Equestrian Coat (Blue)',
|
||||
],
|
||||
[
|
||||
'896c2491-2f96-4986-9cbd-b3b31ef5d8c5,4828b50c-95b6-466a-bb25-514891d78202,d344b8cc-85a8-4ace-9f92-38c84f396e99,',
|
||||
'Equestrian Coat (Grey)',
|
||||
],
|
||||
[
|
||||
'896c2491-2f96-4986-9cbd-b3b31ef5d8c5,d6823e01-69f0-4f85-b94a-74894356a2cf,d344b8cc-85a8-4ace-9f92-38c84f396e99,',
|
||||
'Equestrian Coat (Maroon)',
|
||||
],
|
||||
['09177621-9ecd-4f6a-b6a5-64490139141d,,,', 'Flat Top Hair'],
|
||||
['95ab7a7c-c35d-4da5-9955-0921064470b6,,,', 'Gekko Hair'],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Grey)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Light Blue)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Orange)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Headband (Pink)',
|
||||
],
|
||||
[
|
||||
'40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,018a5c07-e956-457d-a540-a5e2cd68da09,',
|
||||
'Headband (Red, White)',
|
||||
],
|
||||
['62ce4109-8dee-4895-bf1b-bfa143db4c7e,,,', 'Slim Blazer (Teal)'],
|
||||
[
|
||||
'62ce4109-8dee-4895-bf1b-bfa143db4c7e,cd5d7285-202d-42d0-b93f-04245875793e,0f36bb97-c61b-4281-929f-ff1d0d11be86,',
|
||||
'Slim Blazer (Green)',
|
||||
],
|
||||
[
|
||||
'62ce4109-8dee-4895-bf1b-bfa143db4c7e,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,0f36bb97-c61b-4281-929f-ff1d0d11be86,',
|
||||
'Slim Blazer (Blue)',
|
||||
],
|
||||
['8b9f1413-e786-4a30-946c-9292f207875a,,,', 'Pulp Hair'],
|
||||
['5cd08cfb-c729-4c30-96d9-6a99bb934d91,,,', 'Rec Room Sash'],
|
||||
['1a71064b-794f-40fa-9109-8ad36602b6e1,,,', 'Shagg Hair'],
|
||||
[
|
||||
'84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,761a3193-60f0-4190-80c7-285b8192e794,91a451c1-b285-4c48-b14d-59ded8cc006f,',
|
||||
'Stoll Dress (Blue)',
|
||||
],
|
||||
[
|
||||
'84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,a819f49b-6c7a-49d3-9e6a-d9d79ef5019f,91a451c1-b285-4c48-b14d-59ded8cc006f,',
|
||||
'Stoll Dress (Green)',
|
||||
],
|
||||
[
|
||||
'84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,64850553-cdfe-455a-ac00-dafbe63d613e,91a451c1-b285-4c48-b14d-59ded8cc006f,',
|
||||
'Stoll Dress (Orange)',
|
||||
],
|
||||
['84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,,,', 'Stoll Dress (Pink)'],
|
||||
[
|
||||
'71921831-ba6f-408b-a00e-2fd97663636f,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wrist Tape (Black)',
|
||||
],
|
||||
[
|
||||
'71921831-ba6f-408b-a00e-2fd97663636f,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wrist Tape (Cream)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Grey)',
|
||||
],
|
||||
[
|
||||
'ecc1dbe6-ca06-4564-b2a6-30956194d1e9,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,',
|
||||
'Wristbands (Cream)',
|
||||
],
|
||||
['6b9e022c-0b68-48fd-8eca-da8573c18900,,,', 'Long Scarf (Red)'],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,0iSsaY-HgkmLaRHCn5vEdw,PioQ0o3yP0a6szPZ4EKs2A,',
|
||||
'Collared Shirt (Blue)',
|
||||
],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,jGj28vhq8EGwP2RuM074aQ,PioQ0o3yP0a6szPZ4EKs2A,',
|
||||
'Collared Shirt (Yellow)',
|
||||
],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,kmj5zOjcwku_WWKroCeiVQ,PioQ0o3yP0a6szPZ4EKs2A,',
|
||||
'Collared Shirt (Pink)',
|
||||
],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,FAviMCQ_EE2Mpt6QPo5OEw,PioQ0o3yP0a6szPZ4EKs2A,',
|
||||
'Collared Shirt (Red)',
|
||||
],
|
||||
[
|
||||
'2e59d8d0-91a0-4449-bfdc-a5d663fd9343,MFrcSQ1DYUm8imvy4ypgvw,PioQ0o3yP0a6szPZ4EKs2A,',
|
||||
'Collared Shirt (White)',
|
||||
],
|
||||
[
|
||||
'de0ac50d-2adb-4114-bd2e-68953b13d706,05ac07e1-67f0-486c-abf5-a62866475abb,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,',
|
||||
'Blazer (Black, Cream)',
|
||||
],
|
||||
['0088603e-ec3b-4478-8694-e6fb1989b3f2,,,', 'Angled Bob Hair'],
|
||||
['ffea7a65-613f-4835-921e-6dd15f357b7e,,,', 'Long Bangs Hair'],
|
||||
['45f5e714-8a5f-4385-a97f-675066167011,,,', 'Seventies Stache'],
|
||||
['9bf5d259-7774-4cbe-a90f-7f188cc0dce7,,,', 'Thick Goatee'],
|
||||
['a6cbfe76-534a-4655-a8a8-3fed13d001c7,,,', 'Bald Top Hair'],
|
||||
['CTcrvbo3OEepIV4oW8bx4w,,,', 'Receding Hair'],
|
||||
['-twtjyBdQ02EAdOfBGTiEw,,,', 'Van Dyke Beard'],
|
||||
['45eaab67-19c2-4601-8f80-3565a4dceba4,,,', 'Pompadour Hair'],
|
||||
['c855dcc3-96cb-470d-b159-d37a025a47d1,,,', 'Dutch Braid Hair'],
|
||||
['d7730a9e-78a1-4356-bc09-6b066615850b,,,', 'Afro Updo Hair'],
|
||||
['8c35c804-e8d5-49d2-8d5a-ea19fb70bfa6,,,', 'Pencil Bun Hair'],
|
||||
['5beeb4c4-f276-4eae-87aa-9302e45b05b7,,,', 'Cornrows Hair'],
|
||||
[
|
||||
'b6rLwzD4NkKV7xKn9ZYVkA,sxUE0iOSZEmezm54T7xI3Q,tlpa7195x0CkmSjpR1RArQ,',
|
||||
'Rec Room Hoodie - Pride (Rainbow Pride)',
|
||||
],
|
||||
['fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,,,', 'Winged Hat - Pride (Rainbow Pride)'],
|
||||
[
|
||||
'b6rLwzD4NkKV7xKn9ZYVkA,D_Xmo0rOzkS-kgq1CYXt3g,tnCJp2eDI0SwjVfJMhk3LQ,',
|
||||
'Rec Room Hoodie - Pride (Trans Pride)',
|
||||
],
|
||||
[
|
||||
'fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,knXPidb-Rkayfc3kSHfZeQ,1yMyo6oTjU-VAygoeWaohQ,',
|
||||
'Winged Hat - Pride (Trans Pride)',
|
||||
],
|
||||
['88b6ddeb-a455-460d-91d9-a4569ef6903c,,,', 'Square Earrings '],
|
||||
['0abb6b08-20ce-444f-879e-0d1344df096c,,,', 'Round Earrings'],
|
||||
['9b5bde11-7408-4798-9fcb-c7ec175444df,,,', 'Hoop Earrings'],
|
||||
]
|
||||
|
||||
export const DEFAULT_AVATAR_ITEMS: readonly AvatarItem[] = ENTRIES.map(
|
||||
([AvatarItemDesc, FriendlyName, rarity]) => ({
|
||||
AvatarItemType: 0,
|
||||
AvatarItemDesc,
|
||||
PlatformMask: -1,
|
||||
FriendlyName,
|
||||
Tooltip: '',
|
||||
Rarity: rarity ?? 0,
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* Player-event storage on the shared `recflare` D1 database. Each event is a single
|
||||
* JSON blob in the `data` column; queryable fields (id, creator, club, start time)
|
||||
* are SQLite generated (virtual) columns extracted from that JSON — the same
|
||||
* JSON-blob pattern the image/invention/rooms/accounts tables use.
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0006_event.sql and
|
||||
* 0007_event_attendee.sql, applied under its own `migrations_table` so they don't
|
||||
* clash with the other workers' migrations on the shared database).
|
||||
*
|
||||
* The stored record IS the DTO: every read endpoint serves the blob verbatim, so the
|
||||
* field set and casing here are exactly what the client parses. Timestamps are
|
||||
* normalized to `2020-11-29T22:00:00Z` (no fractional seconds) to match.
|
||||
*
|
||||
* RSVPs live alongside in `event_attendee`, one row per player per event. That one is
|
||||
* genuinely columnar (like the relationship/report tables), so it's a normal
|
||||
* relational table rather than a JSON blob.
|
||||
*/
|
||||
|
||||
import {
|
||||
glyphLength,
|
||||
MAX_EVENT_DESCRIPTION_LENGTH,
|
||||
MAX_EVENT_NAME_LENGTH,
|
||||
} from '@repo/domain'
|
||||
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any
|
||||
* seed rows).
|
||||
*/
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS event (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
|
||||
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
||||
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
|
||||
start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
|
||||
end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time)`,
|
||||
`CREATE TABLE IF NOT EXISTS event_attendee (
|
||||
event_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
responded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (event_id, player_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* How a player answered an event invitation — the `Type` on
|
||||
* `POST /api/playerevents/v1/respond`, stored as `event_attendee.status`.
|
||||
*
|
||||
* Only `going` counts toward an event's `AttendeeCount`: interested is a maybe, and
|
||||
* declining is recorded rather than deleted so the client can show the player their own
|
||||
* answer (and so changing your mind is an update, not an insert).
|
||||
*/
|
||||
export const EVENT_RESPONSE = {
|
||||
going: 0,
|
||||
interested: 1,
|
||||
cantGo: 2,
|
||||
} as const
|
||||
|
||||
/** The response types, for validating an incoming `Type`. */
|
||||
const EVENT_RESPONSE_VALUES: number[] = Object.values(EVENT_RESPONSE)
|
||||
|
||||
/** Whether a number is one of the three response types. */
|
||||
export function isEventResponseType(value: number): boolean {
|
||||
return EVENT_RESPONSE_VALUES.includes(value)
|
||||
}
|
||||
|
||||
/** One player's answer to one event. */
|
||||
export interface EventAttendeeRow {
|
||||
event_id: number
|
||||
player_id: number
|
||||
status: number
|
||||
responded_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A scheduled player event (Rec Room's `PlayerEvent`) — a room, a window of time and
|
||||
* the settings the event runs under. Served verbatim by every read endpoint.
|
||||
*
|
||||
* `SubRoomId`/`ClubId`/`ImageName` are genuinely nullable: an event can name the room
|
||||
* without pinning a subroom, needn't belong to a club, and has no banner until one is
|
||||
* uploaded. The three `*Permissions`/`State`/`Accessibility` ints are stored as the
|
||||
* client sends them — their enums aren't reversed yet, so nothing here interprets
|
||||
* them beyond the defaults below.
|
||||
*/
|
||||
export interface PlayerEvent {
|
||||
PlayerEventId: number
|
||||
CreatorPlayerId: number
|
||||
ImageName: string | null
|
||||
RoomId: number
|
||||
SubRoomId: number | null
|
||||
ClubId: number | null
|
||||
Name: string
|
||||
Description: string
|
||||
/** ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`). */
|
||||
StartTime: string
|
||||
EndTime: string
|
||||
AttendeeCount: number
|
||||
State: number
|
||||
Accessibility: number
|
||||
IsMultiInstance: boolean
|
||||
SupportMultiInstanceRoomChat: boolean
|
||||
DefaultBroadcastPermissions: number
|
||||
CanRequestBroadcastPermissions: number
|
||||
}
|
||||
|
||||
interface EventRow {
|
||||
data: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The envelope the create/update writes answer with — the event nested under a status,
|
||||
* rather than the bare record the read endpoints serve. `Result` is 0 on success.
|
||||
*
|
||||
* `TagModifyResult` is always null: the real API reports the outcome of the tag edit
|
||||
* that rides along with the write, and we store no event tags (see the tag-filter
|
||||
* chips, which are static). The field stays present because the client's parser
|
||||
* expects it.
|
||||
*/
|
||||
export interface PlayerEventResult {
|
||||
Result: number
|
||||
TagModifyResult: null
|
||||
PlayerEvent: PlayerEvent
|
||||
}
|
||||
|
||||
/** Wrap a stored event in the write envelope. */
|
||||
export function toEventResult(event: PlayerEvent): PlayerEventResult {
|
||||
return { Result: 0, TagModifyResult: null, PlayerEvent: event }
|
||||
}
|
||||
|
||||
/**
|
||||
* The projection of an event carried on a hub notification frame (`PlayerEventCreated`
|
||||
* and its siblings). Deliberately NOT the stored record, in three ways — don't unify
|
||||
* them:
|
||||
*
|
||||
* - it is camelCase, where the record and every read endpoint are PascalCase;
|
||||
* - it carries `tags` and `broadcastingRoomInstanceId`, which the record has no fields
|
||||
* for (no event tags are stored, and nothing broadcasts an event yet, so both are
|
||||
* empty/null), and drops `State`;
|
||||
* - its timestamps are padded to .NET tick precision (`…T19:00:00.0000000Z`) while the
|
||||
* record stores them bare. That asymmetry is the reference server's: its notification
|
||||
* frames carry the padded form and its event reads don't.
|
||||
*/
|
||||
export interface PlayerEventNotification {
|
||||
tags: Array<{ tag: string; type: number }>
|
||||
playerEventId: number
|
||||
creatorPlayerId: number
|
||||
roomId: number
|
||||
subRoomId: number | null
|
||||
clubId: number | null
|
||||
name: string
|
||||
description: string
|
||||
imageName: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
attendeeCount: number
|
||||
accessibility: number
|
||||
isMultiInstance: boolean
|
||||
supportMultiInstanceRoomChat: boolean
|
||||
defaultBroadcastPermissions: number
|
||||
canRequestBroadcastPermissions: number
|
||||
broadcastingRoomInstanceId: number | null
|
||||
}
|
||||
|
||||
/** Pad a stored timestamp out to .NET tick precision (seven fractional digits). */
|
||||
function toTickPrecision(iso: string): string {
|
||||
const match = /^(.*?)(?:\.(\d+))?Z$/.exec(iso)
|
||||
if (match === null) return iso
|
||||
return `${match[1]}.${(match[2] ?? '').padEnd(7, '0').slice(0, 7)}Z`
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored event into its notification frame. `imageName` becomes an empty
|
||||
* string rather than null when the event has no banner: the frame carries `""`, and a
|
||||
* null wouldn't survive the trip anyway — the hub drops null values from `Msg`.
|
||||
*/
|
||||
export function toEventNotification(event: PlayerEvent): PlayerEventNotification {
|
||||
return {
|
||||
tags: [],
|
||||
playerEventId: event.PlayerEventId,
|
||||
creatorPlayerId: event.CreatorPlayerId,
|
||||
roomId: event.RoomId,
|
||||
subRoomId: event.SubRoomId,
|
||||
clubId: event.ClubId,
|
||||
name: event.Name,
|
||||
description: event.Description,
|
||||
imageName: event.ImageName ?? '',
|
||||
startTime: toTickPrecision(event.StartTime),
|
||||
endTime: toTickPrecision(event.EndTime),
|
||||
attendeeCount: event.AttendeeCount,
|
||||
accessibility: event.Accessibility,
|
||||
isMultiInstance: event.IsMultiInstance,
|
||||
supportMultiInstanceRoomChat: event.SupportMultiInstanceRoomChat,
|
||||
defaultBroadcastPermissions: event.DefaultBroadcastPermissions,
|
||||
canRequestBroadcastPermissions: event.CanRequestBroadcastPermissions,
|
||||
broadcastingRoomInstanceId: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a timestamp to the form the client sends and reads back —
|
||||
* `2020-11-29T22:00:00Z`, with no fractional seconds. `toISOString()` always emits
|
||||
* milliseconds, which the samples never carry, so they're trimmed.
|
||||
*/
|
||||
function eventTime(ms: number): string {
|
||||
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields a create or update supplies, camelCased. Every one is optional: create
|
||||
* defaults what's missing, and update leaves anything absent at its stored value —
|
||||
* which is why the nullable ids are `number | null` rather than merely absent, so a
|
||||
* posted `"ClubId": null` can genuinely clear a club.
|
||||
*/
|
||||
export interface EventInput {
|
||||
imageName?: string | null
|
||||
roomId?: number
|
||||
subRoomId?: number | null
|
||||
clubId?: number | null
|
||||
name?: string
|
||||
description?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
state?: number
|
||||
accessibility?: number
|
||||
isMultiInstance?: boolean
|
||||
supportMultiInstanceRoomChat?: boolean
|
||||
defaultBroadcastPermissions?: number
|
||||
canRequestBroadcastPermissions?: number
|
||||
}
|
||||
|
||||
/** Read a value as an integer, or undefined when absent / not a number. */
|
||||
function asInt(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value)
|
||||
if (typeof value === 'string') {
|
||||
const n = Number.parseInt(value, 10)
|
||||
if (!Number.isNaN(n)) return n
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a posted event body into an {@link EventInput}.
|
||||
*
|
||||
* Accepts the event's fields either at the top level or nested under `PlayerEvent`:
|
||||
* the client posts the same envelope it reads back, and both forms are in circulation.
|
||||
* A field the body doesn't carry stays undefined (create defaults it, update keeps the
|
||||
* stored value); an explicit `null` on one of the nullable ids is preserved so it can
|
||||
* clear the value. Timestamps are normalized here, so an unparseable one is dropped
|
||||
* rather than stored.
|
||||
*/
|
||||
/**
|
||||
* Why a parsed event body can't be stored, or `null` when it's fine.
|
||||
*
|
||||
* Length only. An event name is a title, not an identifier — "Building a Better Room
|
||||
* Using Trigonometry" is a real one — so the alphanumeric rule the account and room
|
||||
* names carry would be wrong here. Absent fields are skipped: an update posts only what
|
||||
* it changes, and create defaults a missing name rather than refusing it.
|
||||
*
|
||||
* The name is measured AFTER trimming, matching what create/update actually store.
|
||||
*/
|
||||
export function eventInputRejection(input: EventInput): string | null {
|
||||
const name = input.name?.trim()
|
||||
if (name !== undefined && glyphLength(name) > MAX_EVENT_NAME_LENGTH) {
|
||||
return `Event names can be at most ${MAX_EVENT_NAME_LENGTH} characters.`
|
||||
}
|
||||
if (
|
||||
input.description !== undefined &&
|
||||
glyphLength(input.description) > MAX_EVENT_DESCRIPTION_LENGTH
|
||||
) {
|
||||
return `Event descriptions can be at most ${MAX_EVENT_DESCRIPTION_LENGTH} characters.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function parseEventBody(body: unknown): EventInput {
|
||||
const outer = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>
|
||||
const nested = outer.PlayerEvent
|
||||
const obj = (typeof nested === 'object' && nested !== null ? nested : outer) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
const has = (key: string): boolean => Object.hasOwn(obj, key)
|
||||
// A nullable id: absent leaves it alone, an explicit null clears it.
|
||||
const nullableInt = (key: string): number | null | undefined => {
|
||||
if (!has(key)) return undefined
|
||||
return obj[key] === null ? null : asInt(obj[key])
|
||||
}
|
||||
const time = (key: string): string | undefined => {
|
||||
const raw = obj[key]
|
||||
if (typeof raw !== 'string') return undefined
|
||||
const parsed = Date.parse(raw)
|
||||
return Number.isNaN(parsed) ? undefined : eventTime(parsed)
|
||||
}
|
||||
const bool = (key: string): boolean | undefined => {
|
||||
const raw = obj[key]
|
||||
if (typeof raw === 'boolean') return raw
|
||||
if (raw === 'true') return true
|
||||
if (raw === 'false') return false
|
||||
return undefined
|
||||
}
|
||||
// The banner name: same absent/null distinction as the nullable ids.
|
||||
const nullableString = (key: string): string | null | undefined => {
|
||||
if (!has(key)) return undefined
|
||||
if (obj[key] === null) return null
|
||||
return typeof obj[key] === 'string' ? (obj[key] as string) : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
imageName: nullableString('ImageName'),
|
||||
roomId: asInt(obj.RoomId),
|
||||
subRoomId: nullableInt('SubRoomId'),
|
||||
clubId: nullableInt('ClubId'),
|
||||
name: typeof obj.Name === 'string' ? obj.Name : undefined,
|
||||
description: typeof obj.Description === 'string' ? obj.Description : undefined,
|
||||
startTime: time('StartTime'),
|
||||
endTime: time('EndTime'),
|
||||
state: asInt(obj.State),
|
||||
accessibility: asInt(obj.Accessibility),
|
||||
isMultiInstance: bool('IsMultiInstance'),
|
||||
supportMultiInstanceRoomChat: bool('SupportMultiInstanceRoomChat'),
|
||||
defaultBroadcastPermissions: asInt(obj.DefaultBroadcastPermissions),
|
||||
canRequestBroadcastPermissions: asInt(obj.CanRequestBroadcastPermissions),
|
||||
}
|
||||
}
|
||||
|
||||
/** How long an event runs when the body names a start but no end. */
|
||||
const DEFAULT_DURATION_MS = 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Insert a new event, returning the stored record.
|
||||
*
|
||||
* Lenient about what the body carries, like the other writes here: an event with no
|
||||
* name or no time window is defaulted rather than rejected, because a rejection the
|
||||
* client can't render is worse than a placeholder the creator can edit. `State` starts
|
||||
* at 0 (scheduled). The creator comes from the bearer token, never the body.
|
||||
*
|
||||
* The creator is recorded as Going in `event_attendee`, which is what makes
|
||||
* `AttendeeCount` start at 1: the count is derived from that table, so the creator
|
||||
* needs a row there for the number to stay right once other players respond.
|
||||
*/
|
||||
export async function createEvent(
|
||||
db: D1Database,
|
||||
creatorPlayerId: number,
|
||||
input: EventInput
|
||||
): Promise<PlayerEvent> {
|
||||
// Sequential id: one past the current max (the table starts empty).
|
||||
const row = await db
|
||||
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM event')
|
||||
.first<{ next: number }>()
|
||||
const now = Date.now()
|
||||
const startTime = input.startTime ?? eventTime(now)
|
||||
const event: PlayerEvent = {
|
||||
PlayerEventId: row?.next ?? 1,
|
||||
CreatorPlayerId: creatorPlayerId,
|
||||
ImageName: input.imageName ?? null,
|
||||
RoomId: input.roomId ?? 0,
|
||||
SubRoomId: input.subRoomId ?? null,
|
||||
ClubId: input.clubId ?? null,
|
||||
Name: input.name?.trim() || 'Untitled Event',
|
||||
Description: input.description ?? '',
|
||||
StartTime: startTime,
|
||||
EndTime: input.endTime ?? eventTime(Date.parse(startTime) + DEFAULT_DURATION_MS),
|
||||
AttendeeCount: 1,
|
||||
State: input.state ?? 0,
|
||||
Accessibility: input.accessibility ?? 1,
|
||||
IsMultiInstance: input.isMultiInstance ?? false,
|
||||
SupportMultiInstanceRoomChat: input.supportMultiInstanceRoomChat ?? false,
|
||||
DefaultBroadcastPermissions: input.defaultBroadcastPermissions ?? 0,
|
||||
CanRequestBroadcastPermissions: input.canRequestBroadcastPermissions ?? 0,
|
||||
}
|
||||
await db.batch([
|
||||
db.prepare('INSERT INTO event (data) VALUES (?1)').bind(JSON.stringify(event)),
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
)
|
||||
.bind(event.PlayerEventId, creatorPlayerId, EVENT_RESPONSE.going, eventTime(now)),
|
||||
])
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a player's answer to an event, replacing whatever they said before — one row
|
||||
* per player per event, so changing your mind is an update rather than a second RSVP.
|
||||
* The event's `AttendeeCount` is recomputed from the table afterwards.
|
||||
*
|
||||
* Returns the updated event, or null when there's no such event. Anyone who can see an
|
||||
* event may respond to it, the creator included (they're already Going from create, and
|
||||
* nothing stops them declining their own event).
|
||||
*/
|
||||
export async function setEventResponse(
|
||||
db: D1Database,
|
||||
eventId: number,
|
||||
playerId: number,
|
||||
status: number
|
||||
): Promise<PlayerEvent | null> {
|
||||
const event = await getEventById(db, eventId)
|
||||
if (event === null) return null
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4`
|
||||
)
|
||||
.bind(eventId, playerId, status, eventTime(Date.now()))
|
||||
.run()
|
||||
|
||||
const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
|
||||
await writeEvent(db, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
/** How many players said they're Going — an event's `AttendeeCount`. */
|
||||
export async function countGoing(db: D1Database, eventId: number): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT COUNT(*) AS going FROM event_attendee WHERE event_id = ?1 AND status = ?2')
|
||||
.bind(eventId, EVENT_RESPONSE.going)
|
||||
.first<{ going: number }>()
|
||||
return row?.going ?? 0
|
||||
}
|
||||
|
||||
/** One player's answer to one event, or null when they haven't responded. */
|
||||
export async function getEventResponse(
|
||||
db: D1Database,
|
||||
eventId: number,
|
||||
playerId: number
|
||||
): Promise<EventAttendeeRow | null> {
|
||||
return db
|
||||
.prepare('SELECT * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2')
|
||||
.bind(eventId, playerId)
|
||||
.first<EventAttendeeRow>()
|
||||
}
|
||||
|
||||
/** Everyone who answered an event, in the order they responded. Backs a future guest list. */
|
||||
export async function getEventAttendees(
|
||||
db: D1Database,
|
||||
eventId: number
|
||||
): Promise<EventAttendeeRow[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT * FROM event_attendee WHERE event_id = ?1 ORDER BY responded_at, player_id')
|
||||
.bind(eventId)
|
||||
.all<EventAttendeeRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/** Overwrite an event's stored blob in place. */
|
||||
async function writeEvent(db: D1Database, event: PlayerEvent): Promise<void> {
|
||||
await db
|
||||
.prepare('UPDATE event SET data = ?1 WHERE id = ?2')
|
||||
.bind(JSON.stringify(event), event.PlayerEventId)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an edit to an event. Only the fields the body carried change; everything else
|
||||
* keeps its stored value, so a partial post can't blank out the rest of the event.
|
||||
* The id, the creator and the attendee count are not editable — ownership doesn't
|
||||
* transfer and RSVPs aren't set by hand. Returns the updated event, or null when
|
||||
* there's no such row.
|
||||
*/
|
||||
export async function updateEvent(
|
||||
db: D1Database,
|
||||
eventId: number,
|
||||
input: EventInput
|
||||
): Promise<PlayerEvent | null> {
|
||||
const event = await getEventById(db, eventId)
|
||||
if (event === null) return null
|
||||
|
||||
const updated: PlayerEvent = {
|
||||
...event,
|
||||
ImageName: input.imageName === undefined ? event.ImageName : input.imageName,
|
||||
RoomId: input.roomId ?? event.RoomId,
|
||||
SubRoomId: input.subRoomId === undefined ? event.SubRoomId : input.subRoomId,
|
||||
ClubId: input.clubId === undefined ? event.ClubId : input.clubId,
|
||||
Name: input.name?.trim() || event.Name,
|
||||
Description: input.description ?? event.Description,
|
||||
StartTime: input.startTime ?? event.StartTime,
|
||||
EndTime: input.endTime ?? event.EndTime,
|
||||
State: input.state ?? event.State,
|
||||
Accessibility: input.accessibility ?? event.Accessibility,
|
||||
IsMultiInstance: input.isMultiInstance ?? event.IsMultiInstance,
|
||||
SupportMultiInstanceRoomChat:
|
||||
input.supportMultiInstanceRoomChat ?? event.SupportMultiInstanceRoomChat,
|
||||
DefaultBroadcastPermissions:
|
||||
input.defaultBroadcastPermissions ?? event.DefaultBroadcastPermissions,
|
||||
CanRequestBroadcastPermissions:
|
||||
input.canRequestBroadcastPermissions ?? event.CanRequestBroadcastPermissions,
|
||||
}
|
||||
await writeEvent(db, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
/** One event by id, or null when there's no such row. */
|
||||
export async function getEventById(db: D1Database, eventId: number): Promise<PlayerEvent | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM event WHERE id = ?1')
|
||||
.bind(eventId)
|
||||
.first<EventRow>()
|
||||
return row ? (JSON.parse(row.data) as PlayerEvent) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Several events by id — the bulk fetch. Answers in the order the ids were asked for
|
||||
* (the client renders them in the order it requested), skipping ids with no row rather
|
||||
* than leaving a hole. Duplicated ids resolve to the same event.
|
||||
*/
|
||||
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
|
||||
if (ids.length === 0) return []
|
||||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT data FROM event WHERE id IN (${placeholders})`)
|
||||
.bind(...ids)
|
||||
.all<EventRow>()
|
||||
const byId = new Map<number, PlayerEvent>()
|
||||
for (const r of results) {
|
||||
const event = JSON.parse(r.data) as PlayerEvent
|
||||
byId.set(event.PlayerEventId, event)
|
||||
}
|
||||
return ids.map((id) => byId.get(id)).filter((e): e is PlayerEvent => e !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* The events a player created — their "my events" list, soonest first. Uses the
|
||||
* creator_player_id index; the per-player set is small, so ordering is done in memory.
|
||||
*/
|
||||
export async function getEventsByCreator(
|
||||
db: D1Database,
|
||||
creatorPlayerId: number
|
||||
): Promise<PlayerEvent[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM event WHERE creator_player_id = ?1')
|
||||
.bind(creatorPlayerId)
|
||||
.all<EventRow>()
|
||||
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
|
||||
}
|
||||
|
||||
/**
|
||||
* The events belonging to a set of clubs — the events shelf on a club's page, soonest
|
||||
* first. Selected on the indexed club_id column. An empty id list is an empty shelf
|
||||
* rather than every event.
|
||||
*/
|
||||
export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promise<PlayerEvent[]> {
|
||||
if (clubIds.length === 0) return []
|
||||
const placeholders = clubIds.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT data FROM event WHERE club_id IN (${placeholders})`)
|
||||
.bind(...clubIds)
|
||||
.all<EventRow>()
|
||||
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
|
||||
}
|
||||
|
||||
/**
|
||||
* The events happening right now — started and not yet finished. Backs the "happening
|
||||
* now" browse query. Both bounds compare lexicographically on the generated ISO-8601
|
||||
* columns, so the whole filter stays in SQL.
|
||||
*/
|
||||
export async function getLiveEvents(db: D1Database, now = Date.now()): Promise<PlayerEvent[]> {
|
||||
const at = eventTime(now)
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM event WHERE start_time <= ?1 AND end_time >= ?1')
|
||||
.bind(at)
|
||||
.all<EventRow>()
|
||||
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
|
||||
}
|
||||
|
||||
/** Soonest start first; ties broken by id so paging is stable. */
|
||||
function bySoonest(a: PlayerEvent, b: PlayerEvent): number {
|
||||
return a.StartTime.localeCompare(b.StartTime) || a.PlayerEventId - b.PlayerEventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Event search — the browse query on the player-events screen. `query` is matched
|
||||
* case-insensitively against the name and description, term by term; an empty query
|
||||
* browses everything upcoming. Paginated via skip/take, soonest first.
|
||||
*
|
||||
* Events that have already finished are excluded: this backs a browse screen, where a
|
||||
* name match on something that ended last month is noise. The per-event history a
|
||||
* creator wants comes from `getEventsByCreator`, which keeps them.
|
||||
*/
|
||||
export async function searchEvents(
|
||||
db: D1Database,
|
||||
query: string,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<PlayerEvent[]> {
|
||||
// end_time is a generated column of an ISO-8601 UTC string, so it compares
|
||||
// lexicographically — the filter stays in SQL.
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM event WHERE end_time >= ?1')
|
||||
.bind(eventTime(Date.now()))
|
||||
.all<EventRow>()
|
||||
let events = results.map((r) => JSON.parse(r.data) as PlayerEvent)
|
||||
|
||||
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
for (const term of terms) {
|
||||
events = events.filter(
|
||||
(e) => e.Name.toLowerCase().includes(term) || e.Description.toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
|
||||
return events.sort(bySoonest).slice(skip, skip + take)
|
||||
}
|
||||
+14
-5
@@ -1,4 +1,4 @@
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
@@ -12,6 +12,16 @@ export async function authedId(c: Context<App>): Promise<number | null> {
|
||||
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 (a plain player's token is just `['gameClient']`).
|
||||
* `null` when the request carries no valid token, which callers treat as a 401; an
|
||||
* empty array means a valid token with no roles. Shaped to mirror {@link authedId}.
|
||||
*/
|
||||
export async function authedRoles(c: Context<App>): Promise<string[] | null> {
|
||||
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
||||
export function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
@@ -28,14 +38,13 @@ export async function parseFormIds(c: Context<App>): Promise<number[]> {
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
}
|
||||
|
||||
/** Read integer ids from repeated/comma-separated `id` query params. The 2023
|
||||
* client passes these to the bulk GET endpoints (e.g. `?id=1&id=2`). */
|
||||
/** Read integer ids from repeated `id` query params. The 2023 client passes these to
|
||||
* the bulk GET endpoints as one value per id (`?id=1&id=2`), never comma-separated. */
|
||||
export function queryIds(c: Context<App>): number[] {
|
||||
return (
|
||||
c.req
|
||||
.queries('id')
|
||||
?.flatMap((v) => v.split(','))
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
?.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n)) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
+167
-12
@@ -21,11 +21,40 @@ export const SCHEMA_DDL: string[] = [
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`,
|
||||
// A player's interaction with a saved image — one row per (player, image). Only
|
||||
// `cheered` for now; named generically so other per-user interactions (e.g.
|
||||
// favorited) can be added as columns. This worker writes it (cheer endpoints) and
|
||||
// keeps the image's denormalized `CheerCount` in sync from it. Schema owned by the
|
||||
// `img` worker (migrations/0002_image_interaction.sql) — keep in sync.
|
||||
`CREATE TABLE IF NOT EXISTS image_interaction (
|
||||
player_id INTEGER NOT NULL,
|
||||
saved_image_id INTEGER NOT NULL,
|
||||
cheered INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT,
|
||||
PRIMARY KEY (player_id, saved_image_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* Saved-image categories from the reference's `SavedImageType` enum — the value of a
|
||||
* stored image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives
|
||||
* here in the image data layer so both the upload route and the slideshow query share
|
||||
* one definition.
|
||||
*/
|
||||
export const SavedImageType = {
|
||||
None: 0,
|
||||
ShareCamera: 1,
|
||||
OutfitThumbnail: 2,
|
||||
RoomThumbnail: 3,
|
||||
ProfileThumbnail: 4,
|
||||
InventionThumbnail: 5,
|
||||
} as const
|
||||
|
||||
/** A stored image record (the client-facing SavedImage shape). */
|
||||
export interface SavedImage {
|
||||
Id: number
|
||||
/** A {@link SavedImageType} value. */
|
||||
Type: number
|
||||
Accessibility: number
|
||||
AccessibilityLocked: boolean
|
||||
@@ -81,6 +110,72 @@ export async function createImage(db: D1Database, input: NewImage): Promise<Save
|
||||
return image
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute an image's `CheerCount` from the `image_interaction` rows and write it
|
||||
* back into the blob (nothing reads a generated column for it, but the client-facing
|
||||
* blob must stay accurate). CAST to INTEGER: D1 binds a JS number as a SQLite REAL,
|
||||
* which json_set would otherwise store as `"CheerCount":3.0`. Returns the fresh count.
|
||||
*/
|
||||
async function syncImageCheerCount(db: D1Database, savedImageId: number): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS n FROM image_interaction WHERE saved_image_id = ?1 AND cheered = 1'
|
||||
)
|
||||
.bind(savedImageId)
|
||||
.first<{ n: number }>()
|
||||
const count = row?.n ?? 0
|
||||
await db
|
||||
.prepare(
|
||||
"UPDATE image SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1"
|
||||
)
|
||||
.bind(savedImageId, count)
|
||||
.run()
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear) a player's cheer on a saved image — upserts the one row per
|
||||
* (player, image) — then resyncs the image's `CheerCount`. Idempotent: re-cheering
|
||||
* an already-cheered image is a no-op on the count.
|
||||
*/
|
||||
export async function setImageCheer(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
savedImageId: number,
|
||||
cheer: boolean
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO image_interaction (player_id, saved_image_id, cheered, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(player_id, saved_image_id) DO UPDATE SET cheered = ?3`
|
||||
)
|
||||
.bind(playerId, savedImageId, cheer ? 1 : 0, new Date().toISOString())
|
||||
.run()
|
||||
await syncImageCheerCount(db, savedImageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the given saved-image ids the player has cheered — the set of cheered
|
||||
* ids (a subset of `ids`). Backs the bulk `cheered` lookup. Empty input → empty set.
|
||||
*/
|
||||
export async function getCheeredImageIds(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
ids: number[]
|
||||
): Promise<Set<number>> {
|
||||
if (ids.length === 0) return new Set()
|
||||
const inList = ids.map((_, i) => `?${i + 2}`).join(',')
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT saved_image_id AS id FROM image_interaction
|
||||
WHERE player_id = ?1 AND cheered = 1 AND saved_image_id IN (${inList})`
|
||||
)
|
||||
.bind(playerId, ...ids)
|
||||
.all<{ id: number }>()
|
||||
return new Set(results.map((r) => r.id))
|
||||
}
|
||||
|
||||
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
|
||||
export async function getImageByName(db: D1Database, name: string): Promise<SavedImage | null> {
|
||||
const row = await db
|
||||
@@ -90,6 +185,19 @@ export async function getImageByName(db: D1Database, name: string): Promise<Save
|
||||
return row ? (JSON.parse(row.data) as SavedImage) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an image's metadata row plus any per-player interactions (cheers) recorded
|
||||
* against it, in one batch — the row keyed by ImageName (the R2 key), its interactions
|
||||
* by the image's `Id`. Authorization and removing the object from R2 are the caller's
|
||||
* responsibility (see the deletesaved route).
|
||||
*/
|
||||
export async function deleteImage(db: D1Database, image: SavedImage): Promise<void> {
|
||||
await db.batch([
|
||||
db.prepare('DELETE FROM image WHERE image_name = ?1').bind(image.ImageName),
|
||||
db.prepare('DELETE FROM image_interaction WHERE saved_image_id = ?1').bind(image.Id),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* The public images taken in a room, for the room's photo feed. Only publicly
|
||||
* accessible images (Accessibility === 1) are returned. `filter` narrows by
|
||||
@@ -119,9 +227,7 @@ export async function getImagesByRoom(
|
||||
|
||||
if (filter > 0) images = images.filter((img) => img.Type === filter)
|
||||
|
||||
images.sort(
|
||||
sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst
|
||||
)
|
||||
images.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
|
||||
|
||||
return images.slice(skip, skip + take)
|
||||
}
|
||||
@@ -153,8 +259,55 @@ export async function getImagesByPlayer(
|
||||
.slice(skip, skip + take)
|
||||
}
|
||||
|
||||
/** Default number of recent images the slideshow feed returns. */
|
||||
export const SLIDESHOW_LIMIT = 130
|
||||
/**
|
||||
* The client-facing projection of a saved image for the player photo lists (the
|
||||
* reference's `ImagesPlayer`). Same data as the stored record, but the id and type
|
||||
* are renamed — `Id` → `SavedImageId`, `Type` → `SavedImageType` — and the tagged
|
||||
* player ids aren't part of it. The client deserializes into this shape, so a raw
|
||||
* SavedImage leaves it without an image id and its thumbnails come up blank.
|
||||
*/
|
||||
export interface ImagesPlayer {
|
||||
Accessibility: number
|
||||
AccessibilityLocked: boolean
|
||||
CheerCount: number
|
||||
CommentCount: number
|
||||
CreatedAt: string
|
||||
Description: string | null
|
||||
ImageName: string
|
||||
PlayerEventId: number | null
|
||||
PlayerId: number
|
||||
RoomId: number | null
|
||||
SavedImageId: number
|
||||
SavedImageType: number
|
||||
}
|
||||
|
||||
/** Project a stored image to the client's ImagesPlayer shape. */
|
||||
export function toImagesPlayer(img: SavedImage): ImagesPlayer {
|
||||
return {
|
||||
Accessibility: img.Accessibility,
|
||||
AccessibilityLocked: img.AccessibilityLocked,
|
||||
CheerCount: img.CheerCount,
|
||||
CommentCount: img.CommentCount,
|
||||
CreatedAt: img.CreatedAt,
|
||||
Description: img.Description,
|
||||
ImageName: img.ImageName,
|
||||
PlayerEventId: img.PlayerEventId,
|
||||
PlayerId: img.PlayerId,
|
||||
RoomId: img.RoomId,
|
||||
SavedImageId: img.Id,
|
||||
SavedImageType: img.Type,
|
||||
}
|
||||
}
|
||||
|
||||
/** How many recent images the slideshow feed returns when the caller doesn't say. */
|
||||
export const SLIDESHOW_LIMIT = 10
|
||||
|
||||
/**
|
||||
* The most a caller can ask the slideshow feed for. The endpoint is public and
|
||||
* unauthenticated, so the cap is what keeps an arbitrary `take` from turning into a
|
||||
* scan of the whole image table plus the two batched joins behind it.
|
||||
*/
|
||||
export const SLIDESHOW_MAX_LIMIT = 100
|
||||
|
||||
/** The slideshow projection of an image — creator username + room name joined in. */
|
||||
export interface SlideshowImage {
|
||||
@@ -200,11 +353,12 @@ async function getRoomNames(db: D1Database, ids: number[]): Promise<Map<number,
|
||||
}
|
||||
|
||||
/**
|
||||
* The global slideshow feed — the most recent publicly-listable images across all
|
||||
* rooms (Accessibility 0 or 1), newest first, capped at `limit`. Each row is joined
|
||||
* to its creator's username and (if any) its room's name. Returns the projected
|
||||
* SlideshowImage shape. Usernames/room names are resolved in two batched lookups to
|
||||
* avoid an N+1 across the (at most `limit`) images.
|
||||
* The global slideshow feed — the most recent publicly-listable ShareCamera photos
|
||||
* across all rooms (Accessibility 0 or 1, Type 1), newest first, capped at `limit`.
|
||||
* Only ShareCamera images are surfaced (not room/profile/invention thumbnails). Each
|
||||
* row is joined to its creator's username and (if any) its room's name. Returns the
|
||||
* projected SlideshowImage shape. Usernames/room names are resolved in two batched
|
||||
* lookups to avoid an N+1 across the (at most `limit`) images.
|
||||
*/
|
||||
export async function getSlideshowImages(
|
||||
db: D1Database,
|
||||
@@ -214,9 +368,10 @@ export async function getSlideshowImages(
|
||||
.prepare(
|
||||
`SELECT data FROM image
|
||||
WHERE json_extract(data, '$.Accessibility') IN (0, 1)
|
||||
ORDER BY id DESC LIMIT ?1`
|
||||
AND json_extract(data, '$.Type') = ?1
|
||||
ORDER BY id DESC LIMIT ?2`
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(SavedImageType.ShareCamera, limit)
|
||||
.all<ImageRow>()
|
||||
const images = results.map((r) => JSON.parse(r.data) as SavedImage)
|
||||
|
||||
|
||||
+145
-36
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Saved-invention storage on the shared `recflare` D1 database. Each invention is
|
||||
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId)
|
||||
* are SQLite generated (virtual) columns extracted from that JSON — the same
|
||||
* JSON-blob pattern the image/rooms/accounts tables use.
|
||||
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId, the
|
||||
* visibility flags) are SQLite generated (virtual) columns extracted from that JSON —
|
||||
* the same JSON-blob pattern the image/rooms/accounts tables use.
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0002_invention.sql,
|
||||
* applied under its own `migrations_table`). The invention's data file itself is
|
||||
@@ -12,19 +12,29 @@
|
||||
* The stored/returned DTO mirrors Rec Room's `RRInvention` (PascalCase), including
|
||||
* the nested `CurrentVersion` that carries the blob name and per-version costs —
|
||||
* shaped after a real `GET /api/inventions/v1?inventionId=…` response.
|
||||
*
|
||||
* Who OWNS an invention is a separate table (`inventory_invention`, written by the
|
||||
* `econ` worker at purchase time); this module only reads it — to fold bought inventions
|
||||
* into the caller's own list, and to rank the "top today" feed by what players actually
|
||||
* picked up today. See @repo/domain's inventory-invention-db.ts.
|
||||
*/
|
||||
import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domain'
|
||||
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql,
|
||||
* sans any seed rows). `is_featured` backs the featured feed's query; json_extract
|
||||
* of a JSON `true` is 1, so the column is 1/0.
|
||||
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql +
|
||||
* 0008_invention_visibility.sql, sans any seed rows). `is_featured` backs the featured
|
||||
* feed's query and `is_published`/`hide_from_player` the "may anyone see this" filter
|
||||
* every feed shares; json_extract of a JSON `true` is 1, so those columns are 1/0 — and
|
||||
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
|
||||
*/
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS invention (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.InventionId')) VIRTUAL,
|
||||
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
|
||||
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL
|
||||
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
|
||||
is_published INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsPublished')) VIRTUAL,
|
||||
hide_from_player INTEGER GENERATED ALWAYS AS (json_extract(data, '$.HideFromPlayer')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
|
||||
@@ -130,6 +140,37 @@ function inventionBlobName(filename: string): string {
|
||||
return filename.toLowerCase().endsWith('.inv') ? filename : `${filename}.inv`
|
||||
}
|
||||
|
||||
/** Base64 — the encoding the real API's hash fields (`BlobHash`) come back in. */
|
||||
function toBase64(bytes: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The hash of an invention's data blob: its SHA-256, base64-encoded, matching the
|
||||
* real API's `BlobHash`. Read from the checksum the `storage` worker records at
|
||||
* upload time, so this is normally a HEAD with no body transfer; a blob stored
|
||||
* before that (or by anything else) is downloaded and digested instead.
|
||||
*
|
||||
* Null when the blob isn't in the bucket — a metadata-only save names a file that
|
||||
* was never uploaded, and a hash of nothing would be worse than the absent hash the
|
||||
* field already allows for.
|
||||
*/
|
||||
export async function inventionBlobHash(
|
||||
bucket: R2Bucket,
|
||||
blobName: string
|
||||
): Promise<string | null> {
|
||||
const key = `invention/${inventionBlobName(blobName)}`
|
||||
const head = await bucket.head(key)
|
||||
if (head === null) return null
|
||||
const recorded = head.checksums.sha256
|
||||
if (recorded !== undefined) return toBase64(recorded)
|
||||
|
||||
const object = await bucket.get(key)
|
||||
return object === null
|
||||
? null
|
||||
: toBase64(await crypto.subtle.digest('SHA-256', await object.arrayBuffer()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields the client supplies on save (camelCase); everything else is defaulted here.
|
||||
* `inventionDataFilename` is the one the caller must supply — an invention with no
|
||||
@@ -163,6 +204,7 @@ export interface NewInvention {
|
||||
*/
|
||||
export async function createInvention(
|
||||
db: D1Database,
|
||||
bucket: R2Bucket,
|
||||
input: NewInvention
|
||||
): Promise<SavedInvention> {
|
||||
// Sequential id: one past the current max (the table starts empty).
|
||||
@@ -171,6 +213,7 @@ export async function createInvention(
|
||||
.first<{ next: number }>()
|
||||
const inventionId = row?.next ?? 1
|
||||
const now = new Date().toISOString()
|
||||
const blobName = inventionBlobName(input.inventionDataFilename)
|
||||
const invention: SavedInvention = {
|
||||
InventionId: inventionId,
|
||||
ReplicationId: crypto.randomUUID(),
|
||||
@@ -183,8 +226,8 @@ export async function createInvention(
|
||||
InventionId: inventionId,
|
||||
ReplicationId: crypto.randomUUID(),
|
||||
VersionNumber: 1,
|
||||
BlobName: inventionBlobName(input.inventionDataFilename),
|
||||
BlobHash: null,
|
||||
BlobName: blobName,
|
||||
BlobHash: await inventionBlobHash(bucket, blobName),
|
||||
InstantiationCost: input.instantiationCost ?? 0,
|
||||
LightsCost: input.lightsCost ?? 0,
|
||||
ChipsCost: input.chipsCost ?? 0,
|
||||
@@ -232,6 +275,32 @@ export async function getInventionsByCreator(
|
||||
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The player's "my inventions" shelf (`v2/mine`): everything they created, plus
|
||||
* everything they BOUGHT. Ownership of a bought invention lives in the
|
||||
* `inventory_invention` table the `econ` worker writes at purchase time — a creator is
|
||||
* never listed there (they own theirs through `CreatorPlayerId`), so the two sets are
|
||||
* disjoint in practice and merged by id anyway.
|
||||
*
|
||||
* Bought inventions are returned whatever their state: unpublished or hidden since the
|
||||
* purchase, they are still on the shelf of the player who paid for them. An owned id
|
||||
* with no invention row left (deleted) simply drops out. Newest first, like the other
|
||||
* invention lists; not paginated.
|
||||
*/
|
||||
export async function getMyInventions(db: D1Database, playerId: number): Promise<SavedInvention[]> {
|
||||
const [created, ownedIds] = await Promise.all([
|
||||
getInventionsByCreator(db, playerId),
|
||||
getOwnedInventionIds(db, playerId),
|
||||
])
|
||||
const bought = await getInventionsByIds(db, ownedIds)
|
||||
|
||||
const byId = new Map<number, SavedInvention>()
|
||||
for (const invention of [...created, ...bought]) byId.set(invention.InventionId, invention)
|
||||
return [...byId.values()].sort(
|
||||
(a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invention search — the browse/search list the client shows when picking an
|
||||
* invention to spawn. Only published, non-hidden inventions are visible here (a
|
||||
@@ -271,50 +340,73 @@ export async function searchInventions(
|
||||
* ones via the indexed `is_featured` column.
|
||||
*/
|
||||
async function publicInventions(db: D1Database, featuredOnly = false): Promise<SavedInvention[]> {
|
||||
// json_extract of a JSON `true` is 1, so these filters stay in SQL.
|
||||
// All three are generated columns off the JSON blob, so the filter stays in SQL.
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM invention
|
||||
WHERE json_extract(data, '$.IsPublished') = 1
|
||||
AND json_extract(data, '$.HideFromPlayer') = 0
|
||||
WHERE is_published = 1
|
||||
AND hide_from_player = 0
|
||||
${featuredOnly ? 'AND is_featured = 1' : ''}`
|
||||
)
|
||||
.all<InventionRow>()
|
||||
return results.map((r) => JSON.parse(r.data) as SavedInvention)
|
||||
}
|
||||
|
||||
/** Engagement score used to rank the top feed (downloads weigh most, then cheers). */
|
||||
function topScore(invention: SavedInvention): number {
|
||||
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
|
||||
return (
|
||||
n(invention.NumDownloads) * 3 +
|
||||
n(invention.CheerCount) * 2 +
|
||||
n(invention.NumPlayersHaveUsedInRoom)
|
||||
)
|
||||
/** Length of the "today" window — a trailing day, not the calendar one. */
|
||||
const TOP_TODAY_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/** 24 hours ago, as the ISO timestamp `acquired_at` is compared against. */
|
||||
function startOfWindow(): string {
|
||||
return new Date(Date.now() - TOP_TODAY_WINDOW_MS).toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The "top today" feed — published inventions ranked by engagement. The real feed
|
||||
* ranks by *today's* activity; we don't track per-day counters, so this ranks by
|
||||
* lifetime engagement instead. Ties fall back to invention id so paging is stable.
|
||||
* Paginated via skip/take; returns a bare array, like the other invention feeds.
|
||||
* The "top today" feed — the inventions other players picked up in the last 24 hours,
|
||||
* most first.
|
||||
*
|
||||
* Ranked from the acquisitions the `econ` worker records in `inventory_invention` at
|
||||
* purchase time, grouped by invention, rather than from the lifetime counters on the
|
||||
* invention itself: those never reset, so "top today" used to mean "top ever" and the
|
||||
* shelf only changed when something overtook a total built up over months.
|
||||
*
|
||||
* "Today" is a TRAILING 24 hours, not the calendar UTC day, so the feed doesn't empty
|
||||
* itself at midnight UTC and slowly refill through the small hours — it always covers a
|
||||
* full day's worth of activity. It is still genuinely a window: an invention nobody has
|
||||
* picked up since yesterday falls off, and the feed IS EMPTY when nothing at all was
|
||||
* acquired in a day. Nothing stands in for it, the same way the featured feed serves
|
||||
* nothing while nothing is curated.
|
||||
*
|
||||
* An acquired invention that has since been unpublished or hidden drops out: this is a
|
||||
* public feed, so it is filtered like every other one. Paginated via skip/take AFTER
|
||||
* that filtering, so a hidden invention doesn't leave a hole in a page.
|
||||
*/
|
||||
export async function getTopInventions(
|
||||
db: D1Database,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<SavedInvention[]> {
|
||||
const inventions = await publicInventions(db)
|
||||
return inventions
|
||||
.sort((a, b) => topScore(b) - topScore(a) || b.InventionId - a.InventionId)
|
||||
.slice(skip, skip + take)
|
||||
const counts = await getInventionAcquisitionCounts(db, startOfWindow())
|
||||
if (counts.length === 0) return []
|
||||
|
||||
// getInventionsByIds answers in the order it is asked, so the ranking survives the
|
||||
// load; ids with no invention row left (deleted) simply drop out.
|
||||
const ranked = await getInventionsByIds(
|
||||
db,
|
||||
counts.map((c) => c.inventionId)
|
||||
)
|
||||
return ranked.filter((i) => i.IsPublished && !i.HideFromPlayer).slice(skip, skip + take)
|
||||
}
|
||||
|
||||
/**
|
||||
* The featured feed — published inventions flagged `IsFeatured`, newest first.
|
||||
* Selected on the indexed `is_featured` column rather than by parsing every public
|
||||
* invention. Nothing sets that flag yet, so this falls back to the top feed rather
|
||||
* than handing the client an empty shelf; once inventions are curated it serves them.
|
||||
* invention.
|
||||
*
|
||||
* Curated means curated: when nothing is flagged this serves an EMPTY list rather than
|
||||
* standing in the top feed. It used to fall back, from when no invention could be
|
||||
* featured at all, but a fallback makes the shelf lie — the client labels these as
|
||||
* hand-picked, and a feed that silently becomes "top today" hides the fact that nobody
|
||||
* has picked anything.
|
||||
*/
|
||||
export async function getFeaturedInventions(
|
||||
db: D1Database,
|
||||
@@ -322,7 +414,6 @@ export async function getFeaturedInventions(
|
||||
take: number
|
||||
): Promise<SavedInvention[]> {
|
||||
const featured = await publicInventions(db, true)
|
||||
if (featured.length === 0) return getTopInventions(db, skip, take)
|
||||
return featured
|
||||
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
||||
.slice(skip, skip + take)
|
||||
@@ -546,8 +637,8 @@ export async function getInventionsByRoom(
|
||||
.prepare(
|
||||
`SELECT data FROM invention
|
||||
WHERE json_extract(data, '$.CreationRoomId') = ?1
|
||||
AND json_extract(data, '$.IsPublished') = 1
|
||||
AND json_extract(data, '$.HideFromPlayer') = 0`
|
||||
AND is_published = 1
|
||||
AND hide_from_player = 0`
|
||||
)
|
||||
.bind(roomId)
|
||||
.all<InventionRow>()
|
||||
@@ -568,20 +659,38 @@ export async function getInventionsByRoom(
|
||||
*/
|
||||
export async function getInventionVersion(
|
||||
db: D1Database,
|
||||
bucket: R2Bucket,
|
||||
inventionId: number,
|
||||
versionNumber: number
|
||||
): Promise<InventionVersion | null> {
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
return invention.CurrentVersionNumber === versionNumber ? invention.CurrentVersion : null
|
||||
if (invention.CurrentVersionNumber !== versionNumber) return null
|
||||
|
||||
// A version saved before its blob finished uploading (or before we hashed on
|
||||
// save at all) carries no hash. Hash it now and keep the result, so the other
|
||||
// invention endpoints serve it too and this stays a one-time cost per blob.
|
||||
// ModifiedAt is deliberately left alone: reading a version is not an edit.
|
||||
if (invention.CurrentVersion.BlobHash === null) {
|
||||
const hash = await inventionBlobHash(bucket, invention.CurrentVersion.BlobName)
|
||||
if (hash !== null) {
|
||||
invention.CurrentVersion = { ...invention.CurrentVersion, BlobHash: hash }
|
||||
await storeInvention(db, invention)
|
||||
}
|
||||
}
|
||||
return invention.CurrentVersion
|
||||
}
|
||||
|
||||
/** Persist an edited invention record, bumping ModifiedAt. */
|
||||
async function writeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
|
||||
const updated: SavedInvention = { ...invention, ModifiedAt: new Date().toISOString() }
|
||||
await storeInvention(db, { ...invention, ModifiedAt: new Date().toISOString() })
|
||||
}
|
||||
|
||||
/** Write a record back as it stands — for changes that aren't edits (see above). */
|
||||
async function storeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
|
||||
await db
|
||||
.prepare('UPDATE invention SET data = ?1 WHERE id = ?2')
|
||||
.bind(JSON.stringify(updated), invention.InventionId)
|
||||
.bind(JSON.stringify(invention), invention.InventionId)
|
||||
.run()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the api worker.
|
||||
*
|
||||
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
|
||||
* rationale as the auth/accounts/match/econ workers: a reverse-engineered protocol,
|
||||
* lenient handlers, no runtime validation.
|
||||
*
|
||||
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
|
||||
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
|
||||
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
|
||||
* schema inline, which renders correctly in any tool.
|
||||
*/
|
||||
|
||||
/** Emit a zod schema as an `application/json` response body. */
|
||||
export function json(schema: z.ZodType, description: string) {
|
||||
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||
}
|
||||
|
||||
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
|
||||
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||
return jsonSchema as OpenAPIV3_1.SchemaObject
|
||||
}
|
||||
|
||||
/** A form-urlencoded / multipart request body (the client posts both). */
|
||||
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
const s = toOpenApiSchema(schema)
|
||||
return {
|
||||
description,
|
||||
content: {
|
||||
'application/x-www-form-urlencoded': { schema: s },
|
||||
'multipart/form-data': { schema: s },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** An `application/json` request body. */
|
||||
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
|
||||
}
|
||||
|
||||
/** The empty-body 401 the auth-gated routes return. */
|
||||
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
|
||||
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
export const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
/** An integer path parameter (ids are constrained to `[0-9]+` by the route pattern). */
|
||||
export function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'path', required: true, description, schema: { type: 'integer' } }
|
||||
}
|
||||
|
||||
/** A string path parameter. */
|
||||
export function stringParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'path', required: true, description, schema: { type: 'string' } }
|
||||
}
|
||||
|
||||
/** An optional string query parameter. */
|
||||
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
|
||||
}
|
||||
|
||||
/** An optional integer query parameter (`skip` / `take` / `sort` / `filter`). */
|
||||
export function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'query', required: false, description, schema: { type: 'integer' } }
|
||||
}
|
||||
|
||||
/** The `skip`/`take` pair every paginated feed accepts. */
|
||||
export function pageParams(defaultTake: number): OpenAPIV3_1.ParameterObject[] {
|
||||
return [
|
||||
intQuery('skip', 'How many entries to skip (default 0)'),
|
||||
intQuery('take', `How many entries to return (default ${defaultTake})`),
|
||||
]
|
||||
}
|
||||
|
||||
// ---- Loose shapes ----------------------------------------------------------
|
||||
// Several routes serve opaque static config blobs (the game configs, the charades word
|
||||
// list) or empty-list stubs. Modelling every field adds noise without value, so these
|
||||
// use deliberately loose schemas.
|
||||
|
||||
/** An opaque JSON object (a static config blob, a stub, …). */
|
||||
export const JsonObject = z.record(z.string(), z.unknown())
|
||||
/** An opaque JSON array (a static list served verbatim, or an empty-list stub). */
|
||||
export const JsonArray = z.array(z.unknown())
|
||||
|
||||
/** A bare JSON boolean — several routes answer `true`/`false` with no envelope. */
|
||||
export const BareBoolean = z.boolean()
|
||||
|
||||
/** A bare JSON string (`POST /api/sanitize/v1` echoes one back). */
|
||||
export const BareString = z.string()
|
||||
|
||||
/** The `{ error }` body the 400 / 403 branches return. */
|
||||
export const ErrorResponse = z.object({ error: z.string() })
|
||||
|
||||
/**
|
||||
* The `{ success, error }` envelope the report / warning writes and the message send
|
||||
* answer with — `error` is an empty string on success, never null, and the rejected
|
||||
* branches use the same shape so there is only one thing to parse.
|
||||
*/
|
||||
export const SuccessErrorEnvelope = z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().describe('Empty string when the call succeeded'),
|
||||
})
|
||||
|
||||
// ---- Config ----------------------------------------------------------------
|
||||
|
||||
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
|
||||
export const AmplitudeConfig = z.object({
|
||||
AmplitudeKey: z.string(),
|
||||
StatSigKey: z.string(),
|
||||
RudderStackKey: z.string(),
|
||||
UseRudderStack: z.boolean(),
|
||||
})
|
||||
|
||||
/** `GET /api/config/v1/azurespeech` — speech-to-text config; `Enabled` is false here. */
|
||||
export const AzureSpeechConfig = z.object({
|
||||
Key: z.string(),
|
||||
Region: z.string(),
|
||||
Enabled: z.boolean(),
|
||||
})
|
||||
|
||||
/** `GET /api/config/v1/backtrace` — the client's crash-reporter budget and filters. */
|
||||
export const BacktraceConfig = z.object({
|
||||
ReportBudget: z.int(),
|
||||
FilterType: z.int(),
|
||||
SampleRate: z.int(),
|
||||
LogLineCount: z.int(),
|
||||
CaptureNativeCrashes: z.int(),
|
||||
AMRThresholdMS: z.int(),
|
||||
MessageCount: z.int(),
|
||||
MessageRegex: z.string(),
|
||||
VersionRegex: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/config/v2` — the big client config blob (a static asset), with
|
||||
* `ShareBaseUrl` derived from the deploy-time base domain.
|
||||
*/
|
||||
export const ApiConfigV2 = JsonObject.describe(
|
||||
'The static client config, plus a ShareBaseUrl templated from the deploy domain'
|
||||
)
|
||||
|
||||
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build matches GAME_VERSION. */
|
||||
export const VersionCheck = z.object({
|
||||
VersionStatus: z.int().describe('0 = current, 1 = client on a different build'),
|
||||
UpdateNotificationStage: z.int(),
|
||||
IsVersionIslanded: z.boolean(),
|
||||
IsCrossPlayDisabled: z.boolean(),
|
||||
})
|
||||
|
||||
// ---- Social ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The per-player relationship projection (`RelationshipResponse`). `PlayerID` is the
|
||||
* OTHER player; the type and flags are taken from the caller's own side of the row, so
|
||||
* the two players in a pair see different projections of it.
|
||||
*/
|
||||
export const RelationshipDto = z.object({
|
||||
PlayerID: z.int().describe('The other player in the pair'),
|
||||
RelationshipType: z
|
||||
.int()
|
||||
.describe('0 = none, 1 = friend request sent, 2 = friend request received, 3 = friend'),
|
||||
Favorited: z.int().describe('0/1 — the caller‘s own flag'),
|
||||
Ignored: z.int().describe('0/1 — the caller‘s own flag'),
|
||||
Muted: z.int().describe('0/1 — the caller‘s own flag'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/messages/v2/send` form body — a message sent to another player. Everything
|
||||
* is a string on the wire (it's form-encoded). The sender is NOT in the body — it's
|
||||
* taken from the bearer token.
|
||||
*/
|
||||
export const SendMessageRequest = z.object({
|
||||
ToPlayerId: z.string().describe('Account id of the recipient'),
|
||||
Type: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Message-model type, e.g. `10`. Passed through unmapped; defaults to 0'),
|
||||
Data: z.string().optional().describe('The message payload; often empty'),
|
||||
})
|
||||
|
||||
/** The `{ Success, Message }` ack the flag toggles answer with. */
|
||||
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
|
||||
|
||||
/**
|
||||
* One entry of `GET /api/relationships/mutualfriends` — a friend both players share.
|
||||
* A trimmed account card, not a relationship: no relationship type or flags.
|
||||
*/
|
||||
export const MutualFriendDto = z.object({
|
||||
AccountId: z.int(),
|
||||
Username: z.string(),
|
||||
DisplayName: z.string(),
|
||||
ProfileImage: z.string().describe('The image name; an empty string when the account has none'),
|
||||
})
|
||||
|
||||
// ---- Progression -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A player's reputation (cheer counters). Nobody has earned cheers yet, so every
|
||||
* counter is 0 and everyone has their full credit. `SelectedCheer` is an int (0 = none),
|
||||
* not null, and `IsCheerful` is true — the client reads it to decide whether the player
|
||||
* may hand out cheers at all.
|
||||
*/
|
||||
export const ReputationDto = z.object({
|
||||
AccountId: z.int(),
|
||||
IsCheerful: z.boolean(),
|
||||
Noteriety: z.int(),
|
||||
SelectedCheer: z.int().describe('0 = none selected'),
|
||||
CheerCredit: z.int(),
|
||||
CheerGeneral: z.int(),
|
||||
CheerHelpful: z.int(),
|
||||
CheerCreative: z.int(),
|
||||
CheerGreatHost: z.int(),
|
||||
CheerSportsman: z.int(),
|
||||
SubscriberCount: z.int(),
|
||||
SubscribedCount: z.int(),
|
||||
})
|
||||
|
||||
/** A player's level/XP (`/api/players/v1/progression/:id`). */
|
||||
export const ProgressionDto = z.object({
|
||||
PlayerId: z.int(),
|
||||
Level: z.int(),
|
||||
XP: z.int(),
|
||||
})
|
||||
|
||||
/** The `Ids` form body the bulk POST endpoints take. */
|
||||
export const BulkIdsRequest = z.object({
|
||||
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
|
||||
})
|
||||
|
||||
// ---- Inventions ------------------------------------------------------------
|
||||
|
||||
/** One version of an invention — carries the blob name the client downloads. */
|
||||
export const InventionVersionDto = z.object({
|
||||
InventionId: z.int(),
|
||||
ReplicationId: z.string(),
|
||||
VersionNumber: z.int(),
|
||||
BlobName: z.string().describe('The `.inv` key in the storage worker‘s bucket'),
|
||||
BlobHash: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Base64 SHA-256 of the blob; null when it was never uploaded'),
|
||||
InstantiationCost: z.int(),
|
||||
LightsCost: z.int(),
|
||||
ChipsCost: z.int(),
|
||||
CloudVariablesCost: z.int(),
|
||||
AICost: z.int(),
|
||||
})
|
||||
|
||||
/** A tag on an invention. `Type` 0 = custom (creator-submitted), 2 = auto-derived. */
|
||||
export const InventionTagDto = z.object({
|
||||
Tag: z.string(),
|
||||
Type: z.int().describe('0 = custom, 2 = auto'),
|
||||
})
|
||||
|
||||
/** A stored invention record (the reference's `RRInvention`). */
|
||||
export const InventionDto = z.object({
|
||||
InventionId: z.int(),
|
||||
ReplicationId: z.string(),
|
||||
CreatorPlayerId: z.int(),
|
||||
Name: z.string(),
|
||||
Description: z.string(),
|
||||
ImageName: z.string(),
|
||||
CurrentVersionNumber: z.int(),
|
||||
CurrentVersion: InventionVersionDto,
|
||||
Accessibility: z.int(),
|
||||
IsPublished: z.boolean().describe('Unpublished inventions are visible only to their creator'),
|
||||
IsFeatured: z.boolean(),
|
||||
ModifiedAt: z.string(),
|
||||
CreatedAt: z.string(),
|
||||
FirstPublishedAt: z.string().nullable(),
|
||||
CreationRoomId: z.int(),
|
||||
NumPlayersHaveUsedInRoom: z.int(),
|
||||
NumDownloads: z.int(),
|
||||
CheerCount: z.int(),
|
||||
CreatorPermission: z.int(),
|
||||
GeneralPermission: z.int().describe('What other players may do with it once published'),
|
||||
IsAGInvention: z.boolean(),
|
||||
IsCertifiedInvention: z.boolean(),
|
||||
Price: z.int(),
|
||||
AllowTrial: z.boolean(),
|
||||
HideFromPlayer: z.boolean(),
|
||||
ReferencedInventions: z.array(z.int()),
|
||||
Tags: z
|
||||
.array(InventionTagDto)
|
||||
.optional()
|
||||
.describe('Unset on save — the real RRInvention carries no Tags field'),
|
||||
})
|
||||
|
||||
/** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */
|
||||
export const InventionSaveResult = z.object({
|
||||
Status: z.int().describe('0 = success'),
|
||||
Invention: InventionDto,
|
||||
InventionVersion: InventionVersionDto,
|
||||
})
|
||||
|
||||
/** The tag filter chips on a browse screen, derived from the tags actually in use. */
|
||||
export const TagFilters = z.object({
|
||||
PinnedFilters: z.array(z.string()),
|
||||
PopularFilters: z.array(z.string()),
|
||||
TrendingFilters: z
|
||||
.array(z.string())
|
||||
.nullable()
|
||||
.describe('Null — needs recent-activity data we don‘t keep'),
|
||||
})
|
||||
|
||||
/** `GET /api/inventions/v1/details` — an invention's detail card is just its tags. */
|
||||
export const InventionDetails = z.object({ Tags: z.array(InventionTagDto) })
|
||||
|
||||
/** `GET /api/inventions/v1/personaldetails/:id` — the caller's own relation to it. */
|
||||
export const InventionPersonalDetails = z.object({
|
||||
IsCheering: z.boolean().describe('Always false — nothing can cheer an invention yet'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
|
||||
export const SetTagsRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
AutoTags: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Client-derived tags (Type 2); each at most 15 letters once lowercased'),
|
||||
CustomTags: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Creator-submitted tags (Type 0); each at most 15 letters once lowercased'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */
|
||||
export const SetTagsResponse = z.object({
|
||||
Result: z.int().describe('0 = success'),
|
||||
Tags: z.array(z.string()).describe('Auto tags first, then custom'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v1/updateprice` JSON body. */
|
||||
export const UpdatePriceRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
Price: z.int().describe('Must be >= 0'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v6/save` JSON body — camelCase, unlike the read shapes. */
|
||||
export const SaveInventionRequest = z.object({
|
||||
inventionDataFilename: z
|
||||
.string()
|
||||
.describe('The blob uploaded through the storage worker; the one required field'),
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('3–24 chars: letters, digits, spaces, dashes, colons. Omitted/blank ⇒ “Untitled”'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('At most 512 chars. Omitted/blank ⇒ “No description yet”'),
|
||||
imageName: z.string().optional(),
|
||||
instantiationCost: z.int().optional(),
|
||||
lightsCost: z.int().optional(),
|
||||
chipsCost: z.int().optional(),
|
||||
cloudVariablesCost: z.int().optional(),
|
||||
aiCost: z.int().optional(),
|
||||
creationRoomId: z.int().optional(),
|
||||
referencedInventions: z.array(z.int()).optional(),
|
||||
})
|
||||
|
||||
// ---- Avatar / custom avatar items ------------------------------------------
|
||||
|
||||
/** `POST /api/avatar/v2/gifts/generate` — a generated gift box (always a token gift). */
|
||||
export const GeneratedGift = z.object({
|
||||
Id: z.int().describe('Always 0 — gifts generated here are not persisted'),
|
||||
FromPlayerId: z.int(),
|
||||
ConsumableItemDesc: z.string(),
|
||||
AvatarItemDesc: z.string(),
|
||||
FriendlyName: z.string(),
|
||||
AvatarItemType: z.int(),
|
||||
EquipmentPrefabName: z.string(),
|
||||
EquipmentModificationGuid: z.string(),
|
||||
CurrencyType: z.int(),
|
||||
Currency: z.int().describe('A random token amount'),
|
||||
Xp: z.int(),
|
||||
Level: z.int(),
|
||||
Platform: z.int(),
|
||||
PlatformsToSpawnOn: z.int(),
|
||||
BalanceType: z.int(),
|
||||
GiftContext: z.int(),
|
||||
GiftRarity: z.int(),
|
||||
Message: z.string(),
|
||||
})
|
||||
|
||||
/** `POST /api/avatar/v2/gifts/generate` form body. */
|
||||
export const GenerateGiftRequest = z.object({
|
||||
GiftContext: z.string().optional().describe('Where the gift was earned'),
|
||||
Message: z.string().optional(),
|
||||
Xp: z.string().optional(),
|
||||
})
|
||||
|
||||
/** A paginated custom-avatar-item page (no storage yet, so always empty). */
|
||||
export const CustomAvatarItemsPage = z.object({
|
||||
Results: JsonArray,
|
||||
TotalResults: z.int(),
|
||||
})
|
||||
|
||||
/** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */
|
||||
export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.null() })
|
||||
|
||||
// ---- Gameplay --------------------------------------------------------------
|
||||
|
||||
/** `POST /api/sanitize/v1` JSON body — the text to clean. */
|
||||
export const SanitizeRequest = z.object({ Value: z.string() })
|
||||
|
||||
/** `POST /api/sanitize/v1/isPure` — whether the text is clean (always true here). */
|
||||
export const IsPureResponse = z.object({ IsPure: z.boolean() })
|
||||
|
||||
/** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */
|
||||
export const KeepsakeConfig = z.object({
|
||||
KeepsakeFeatureEnabled: z.boolean(),
|
||||
KeepsakeRoomLimit: z.int(),
|
||||
SocialXpBoostEnabled: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* A scheduled player event (Rec Room's `PlayerEvent`) — the record every read endpoint
|
||||
* serves verbatim. The `State` / `Accessibility` / `*Permissions` ints are stored and
|
||||
* echoed as the client sends them; their enums aren't reversed yet.
|
||||
*/
|
||||
export const PlayerEventDto = z.object({
|
||||
PlayerEventId: z.int(),
|
||||
CreatorPlayerId: z.int(),
|
||||
ImageName: z.string().nullable().describe('Banner image; null until one is uploaded'),
|
||||
RoomId: z.int(),
|
||||
SubRoomId: z.int().nullable().describe('Null when the event doesn’t pin a subroom'),
|
||||
ClubId: z.int().nullable().describe('Null when the event isn’t a club’s'),
|
||||
Name: z.string(),
|
||||
Description: z.string(),
|
||||
StartTime: z.string().describe('ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`)'),
|
||||
EndTime: z.string().describe('ISO 8601 UTC, seconds precision'),
|
||||
AttendeeCount: z.int().describe('Starts at 1 — the creator attends their own event'),
|
||||
State: z.int().describe('0 = scheduled'),
|
||||
Accessibility: z.int(),
|
||||
IsMultiInstance: z.boolean(),
|
||||
SupportMultiInstanceRoomChat: z.boolean(),
|
||||
DefaultBroadcastPermissions: z.int(),
|
||||
CanRequestBroadcastPermissions: z.int(),
|
||||
})
|
||||
|
||||
/** The `{ Result, TagModifyResult, PlayerEvent }` envelope the event writes answer with. */
|
||||
export const PlayerEventResultDto = z.object({
|
||||
Result: z.int().describe('0 = success'),
|
||||
TagModifyResult: z
|
||||
.null()
|
||||
.describe('Always null — the write carries no tag edit, as no event tags are stored'),
|
||||
PlayerEvent: PlayerEventDto,
|
||||
})
|
||||
|
||||
/**
|
||||
* The JSON body of an event create / update. Every field is optional: create defaults
|
||||
* what's missing, update leaves anything absent at its stored value. The fields may be
|
||||
* posted at the top level or nested under `PlayerEvent` — the client posts back the
|
||||
* same envelope it read — and both forms are accepted. `PlayerEventId`,
|
||||
* `CreatorPlayerId` and `AttendeeCount` are ignored if present: the id is assigned
|
||||
* here, the creator comes from the bearer token, and RSVPs aren't set by hand.
|
||||
*/
|
||||
export const PlayerEventRequest = PlayerEventDto.partial().extend({
|
||||
PlayerEvent: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('The event’s fields, if nested rather than posted at the top level'),
|
||||
})
|
||||
|
||||
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
|
||||
export const PlayerEventRespondRequest = z.object({
|
||||
PlayerEventId: z.int(),
|
||||
Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'),
|
||||
})
|
||||
|
||||
/** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */
|
||||
export const PlayerEventsAll = z.object({
|
||||
Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'),
|
||||
Responses: JsonArray.describe(
|
||||
'Events the caller RSVP’d to — always empty; RSVPs are stored, but this field’s ' +
|
||||
'entry shape has not been observed yet'
|
||||
),
|
||||
})
|
||||
|
||||
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
|
||||
export const PlayerEventsPage = z.object({
|
||||
ContinuationToken: z.string().describe('Empty = no next page'),
|
||||
Events: JsonArray,
|
||||
})
|
||||
|
||||
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both null (no subs yet). */
|
||||
export const SubscriptionResponse = z.object({
|
||||
subscription: z.null(),
|
||||
platformAccountSubscribedPlayerId: z.null(),
|
||||
})
|
||||
|
||||
// ---- Moderation ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `GET /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
|
||||
* answer (no ban storage yet). `ReportCategory` is -1 (no category) rather than 0,
|
||||
* which is a real category; `Message` is null, not an empty string — the client
|
||||
* distinguishes "no message" from a blank one.
|
||||
*/
|
||||
export const ModerationBlockDetails = z.object({
|
||||
ReportCategory: z.int().describe('-1 = no category (0 is a real one)'),
|
||||
Duration: z.int(),
|
||||
GameSessionId: z.int(),
|
||||
IsBan: z.boolean(),
|
||||
IsHostKick: z.boolean(),
|
||||
IsVoiceModAutoban: z.boolean(),
|
||||
Message: z.string().nullable(),
|
||||
PlayerIdReporter: z.int().nullable(),
|
||||
TimeoutStartedAt: z.string().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/PlayerReporting/v3/create` form body — a player report. Everything is a
|
||||
* string on the wire (it's form-encoded); only `PlayerIdReported` is required. The
|
||||
* reporter is NOT in the body — it's taken from the bearer token.
|
||||
*/
|
||||
export const CreateReportRequest = z.object({
|
||||
PlayerIdReported: z.string().describe('Account id of the player being reported'),
|
||||
ReportCategory: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The reason picked in the report UI, e.g. `100`. Stored verbatim; unmapped'),
|
||||
Details: z.string().optional().describe('The free-text description the reporter typed'),
|
||||
HeightReporter: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Reporter’s player height in metres at report time, e.g. `1.64`'),
|
||||
HeightReported: z.string().optional().describe('Reported player’s height in metres'),
|
||||
RoomId: z.string().optional().describe('Room the report was raised in, if any'),
|
||||
RoomInstanceType: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything
|
||||
* is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
|
||||
* moderator is NOT in the body — it's taken from the bearer token.
|
||||
*/
|
||||
export const CreateWarningRequest = z.object({
|
||||
WarnedPlayerId: z.string().describe('Account id of the player being warned'),
|
||||
ReportCategory: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The reason category, e.g. `101`. Stored verbatim; unmapped'),
|
||||
DisplayReason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('What the warned player is shown, e.g. `Sexual gestures`'),
|
||||
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
|
||||
})
|
||||
|
||||
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
|
||||
export const DeviceIdRequest = z.object({
|
||||
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
|
||||
newDeviceId: z.string().optional(),
|
||||
platform: z.string().optional(),
|
||||
})
|
||||
|
||||
// ---- Rooms -----------------------------------------------------------------
|
||||
|
||||
/** `GET /api/quickPlay/v1/getandclear` — a pending quick-play action; all null = none. */
|
||||
export const QuickPlayResponse = z.object({
|
||||
RoomName: z.string().nullable(),
|
||||
ActionCode: z.string().nullable(),
|
||||
TargetPlayerId: z.int().nullable(),
|
||||
})
|
||||
|
||||
/** `POST /api/rooms/v1/verifyRole` form body. */
|
||||
export const VerifyRoleRequest = z.object({
|
||||
roomId: z.string(),
|
||||
role: z.string().describe('The minimum role level required'),
|
||||
context: z.string().optional().describe('e.g. MakerPen — accepted and ignored'),
|
||||
})
|
||||
|
||||
// ---- Images ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A stored image record. Note the room photo feed (`/api/images/v4/room/:roomId`)
|
||||
* serves this shape raw, while the player lists serve the `ImagesPlayer` projection
|
||||
* below — deliberately different, see the client-contract notes in CLAUDE.md.
|
||||
*/
|
||||
export const SavedImageDto = z.object({
|
||||
Id: z.int(),
|
||||
Type: z.int().describe('SavedImageType: 1 = share camera, 3 = room, 4 = profile, …'),
|
||||
Accessibility: z.int(),
|
||||
AccessibilityLocked: z.boolean(),
|
||||
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
|
||||
Description: z.string().nullable(),
|
||||
PlayerId: z.int(),
|
||||
TaggedPlayerIds: z.array(z.int()),
|
||||
RoomId: z.int().nullable(),
|
||||
PlayerEventId: z.int().nullable(),
|
||||
CreatedAt: z.string(),
|
||||
CheerCount: z.int(),
|
||||
CommentCount: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The client's `ImagesPlayer` projection — the same record with `Id` → `SavedImageId`,
|
||||
* `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed
|
||||
* MUST serve this: the raw SavedImage renders blank thumbnails.
|
||||
*/
|
||||
export const ImagesPlayerDto = z.object({
|
||||
SavedImageId: z.int(),
|
||||
SavedImageType: z.int(),
|
||||
Accessibility: z.int(),
|
||||
AccessibilityLocked: z.boolean(),
|
||||
CheerCount: z.int(),
|
||||
CommentCount: z.int(),
|
||||
CreatedAt: z.string(),
|
||||
Description: z.string().nullable(),
|
||||
ImageName: z.string(),
|
||||
PlayerEventId: z.int().nullable(),
|
||||
PlayerId: z.int(),
|
||||
RoomId: z.int().nullable(),
|
||||
})
|
||||
|
||||
/** One entry in the anonymous slideshow feed, joined to its creator and room. */
|
||||
export const SlideshowImageDto = z.object({
|
||||
SavedImageId: z.int(),
|
||||
ImageName: z.string(),
|
||||
Username: z.string(),
|
||||
RoomName: z.string().nullable(),
|
||||
RoomId: z.int().nullable(),
|
||||
SavedImageType: z.int(),
|
||||
PlayerEventId: z.int().nullable(),
|
||||
Accessibility: z.int(),
|
||||
PlayerIds: z.array(z.int()),
|
||||
})
|
||||
|
||||
/** `GET /api/images/v1/slideshow` — the feed plus a short cache hint. */
|
||||
export const SlideshowResponse = z.object({
|
||||
Images: z.array(SlideshowImageDto),
|
||||
ValidTill: z.string().describe('ISO timestamp ~2 minutes out; the client refreshes against it'),
|
||||
})
|
||||
|
||||
/** `POST /api/images/v4/uploadsaved` multipart body. */
|
||||
export const UploadImageRequest = z.object({
|
||||
image: z.string().describe('The image file (`file` is accepted too)'),
|
||||
imgMeta: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'A JSON `SavedImageMetaDTO`: { playerIds, savedImageType, roomId, playerEventId, accessibility, description }'
|
||||
),
|
||||
})
|
||||
|
||||
/** `POST /api/images/v4/uploadsaved` — the stored bucket key. */
|
||||
export const UploadImageResponse = z.object({
|
||||
ImageName: z.string().describe('The bucket key; the img worker serves the object by it'),
|
||||
})
|
||||
|
||||
/** `DELETE /api/images/v1/deletesaved` JSON body. */
|
||||
export const DeleteImageRequest = z.object({ ImageName: z.string() })
|
||||
|
||||
/** `POST /api/images/v1/cheer` JSON body. */
|
||||
export const CheerImageRequest = z.object({
|
||||
SavedImageId: z.int(),
|
||||
Cheer: z.boolean().describe('True to cheer, false to un-cheer'),
|
||||
})
|
||||
|
||||
/** The bare `{ success: true }` ack the image writes answer with. */
|
||||
export const SuccessResponse = z.object({ success: z.boolean() })
|
||||
|
||||
/** One entry of `GET /api/images/v5/cheered/bulk`, one per requested id, in order. */
|
||||
export const CheeredEntry = z.object({
|
||||
SavedImageId: z.int(),
|
||||
IsCheered: z.boolean(),
|
||||
})
|
||||
@@ -14,7 +14,7 @@
|
||||
* workers' migrations that share the database).
|
||||
*/
|
||||
|
||||
/** Relationship state from the perspective of the player asking (mirror of the C# enum). */
|
||||
/** Relationship state from the perspective of the player asking (mirrors the reference). */
|
||||
export enum RelationshipType {
|
||||
None = 0,
|
||||
FriendRequestSent = 1,
|
||||
@@ -53,7 +53,7 @@ interface RelationshipRow {
|
||||
target_muted: number
|
||||
}
|
||||
|
||||
/** The per-player relationship projection returned to the client (the C# RelationshipResponse). */
|
||||
/** The per-player relationship projection returned to the client (RelationshipResponse). */
|
||||
export interface RelationshipResponse {
|
||||
Favorited: number
|
||||
Ignored: number
|
||||
@@ -62,6 +62,34 @@ export interface RelationshipResponse {
|
||||
RelationshipType: RelationshipType
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a friend-graph mutation. These changes are visible to BOTH players, and
|
||||
* each sees a different projection of the same row (the target of a request sees
|
||||
* `FriendRequestReceived` where the sender sees `Sent`), so callers get both — `self` for
|
||||
* the HTTP response and the acting player's notification, `other` for the target's.
|
||||
*
|
||||
* `changed` is false when the mutation was a no-op: re-sending a request that's already
|
||||
* outstanding, befriending someone you're already friends with, accepting something that
|
||||
* isn't pending. Nothing was written, so no RelationshipChanged notification should go out
|
||||
* (the reference server is likewise silent on its no-change branch).
|
||||
*/
|
||||
export interface RelationshipChange {
|
||||
self: RelationshipResponse
|
||||
other: RelationshipResponse
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
/** The projection reported for a pair with no stored relationship. */
|
||||
function noneResponse(otherId: number): RelationshipResponse {
|
||||
return {
|
||||
PlayerID: otherId,
|
||||
RelationshipType: RelationshipType.None,
|
||||
Favorited: 0,
|
||||
Ignored: 0,
|
||||
Muted: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Flip a pending request to the other side's point of view; Friend/None are symmetric. */
|
||||
function flipType(type: number): RelationshipType {
|
||||
if (type === RelationshipType.FriendRequestSent) return RelationshipType.FriendRequestReceived
|
||||
@@ -85,6 +113,16 @@ function toResponse(row: RelationshipRow, playerId: number): RelationshipRespons
|
||||
}
|
||||
}
|
||||
|
||||
/** Project a written row for both players in the pair. */
|
||||
function toChange(
|
||||
row: RelationshipRow,
|
||||
playerId: number,
|
||||
otherId: number,
|
||||
changed: boolean
|
||||
): RelationshipChange {
|
||||
return { self: toResponse(row, playerId), other: toResponse(row, otherId), changed }
|
||||
}
|
||||
|
||||
/** Find the single row for an unordered pair (either direction), or null. */
|
||||
async function findPair(db: D1Database, a: number, b: number): Promise<RelationshipRow | null> {
|
||||
return db
|
||||
@@ -98,7 +136,10 @@ async function findPair(db: D1Database, a: number, b: number): Promise<Relations
|
||||
|
||||
/**
|
||||
* All of a player's relationships, projected from that player's point of view.
|
||||
* `None` rows are omitted (a removed friend leaves no relationship to report).
|
||||
*
|
||||
* `None` rows are included: they are how an unfriending, or an ignore/mute of someone you
|
||||
* were never friends with, is recorded, and they still carry that player's
|
||||
* favorited/ignored/muted flags. Dropping them would lose the flags on the client.
|
||||
*/
|
||||
export async function getRelationshipsForPlayer(
|
||||
db: D1Database,
|
||||
@@ -111,9 +152,49 @@ export async function getRelationshipsForPlayer(
|
||||
)
|
||||
.bind(playerId)
|
||||
.all<RelationshipRow>()
|
||||
return results
|
||||
.filter((row) => row.relationship_type !== RelationshipType.None)
|
||||
.map((row) => toResponse(row, playerId))
|
||||
return results.map((row) => toResponse(row, playerId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids of everyone a player is actually friends with — `Friend` rows only, from
|
||||
* either side of the pair (the row records one direction, the friendship is mutual).
|
||||
* Pending requests and `None` rows are excluded, unlike
|
||||
* {@link getRelationshipsForPlayer}, which reports the whole graph.
|
||||
*/
|
||||
export async function getFriendIds(db: D1Database, playerId: number): Promise<number[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT CASE WHEN requester_id = ?1 THEN target_id ELSE requester_id END AS id
|
||||
FROM relationship
|
||||
WHERE relationship_type = ?2 AND (requester_id = ?1 OR target_id = ?1)`
|
||||
)
|
||||
.bind(playerId, RelationshipType.Friend)
|
||||
.all<{ id: number }>()
|
||||
return results.map((r) => r.id)
|
||||
}
|
||||
|
||||
/** How many mutual friends the mutual-friends lookup will return at most. */
|
||||
export const MUTUAL_FRIENDS_LIMIT = 100
|
||||
|
||||
/**
|
||||
* The ids two players are both friends with — the intersection of their friend lists,
|
||||
* ascending and capped at {@link MUTUAL_FRIENDS_LIMIT}. Each player's friends are a
|
||||
* small set, so the intersection is done in memory rather than as a SQL INTERSECT.
|
||||
*/
|
||||
export async function getMutualFriendIds(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
otherId: number
|
||||
): Promise<number[]> {
|
||||
const [mine, theirs] = await Promise.all([
|
||||
getFriendIds(db, playerId),
|
||||
getFriendIds(db, otherId),
|
||||
])
|
||||
const ours = new Set(theirs)
|
||||
return mine
|
||||
.filter((id) => ours.has(id))
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, MUTUAL_FRIENDS_LIMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,14 +202,14 @@ export async function getRelationshipsForPlayer(
|
||||
* requester. Inserts a new row or, if one already exists for the pair (either
|
||||
* direction), rewrites it so the requester is normalized to `requesterId` and
|
||||
* the flags are preserved for whichever side each player is on. Returns the
|
||||
* relationship from `requesterId`'s point of view.
|
||||
* row as written, for the caller to project onto whichever side it needs.
|
||||
*/
|
||||
async function upsertPair(
|
||||
db: D1Database,
|
||||
requesterId: number,
|
||||
targetId: number,
|
||||
type: RelationshipType
|
||||
): Promise<RelationshipResponse> {
|
||||
): Promise<RelationshipRow> {
|
||||
const existing = await findPair(db, requesterId, targetId)
|
||||
if (!existing) {
|
||||
await db
|
||||
@@ -138,7 +219,17 @@ async function upsertPair(
|
||||
)
|
||||
.bind(requesterId, targetId, type)
|
||||
.run()
|
||||
return { PlayerID: targetId, RelationshipType: type, Favorited: 0, Ignored: 0, Muted: 0 }
|
||||
return {
|
||||
requester_id: requesterId,
|
||||
target_id: targetId,
|
||||
relationship_type: type,
|
||||
requester_favorited: 0,
|
||||
requester_ignored: 0,
|
||||
requester_muted: 0,
|
||||
target_favorited: 0,
|
||||
target_ignored: 0,
|
||||
target_muted: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Keep each player's flags with that player as the row is normalized to
|
||||
@@ -175,93 +266,117 @@ async function upsertPair(
|
||||
)
|
||||
.run()
|
||||
return {
|
||||
PlayerID: targetId,
|
||||
RelationshipType: type,
|
||||
Favorited: reqFlags.favorited,
|
||||
Ignored: reqFlags.ignored,
|
||||
Muted: reqFlags.muted,
|
||||
requester_id: requesterId,
|
||||
target_id: targetId,
|
||||
relationship_type: type,
|
||||
requester_favorited: reqFlags.favorited,
|
||||
requester_ignored: reqFlags.ignored,
|
||||
requester_muted: reqFlags.muted,
|
||||
target_favorited: tgtFlags.favorited,
|
||||
target_ignored: tgtFlags.ignored,
|
||||
target_muted: tgtFlags.muted,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a friend request from `requesterId` to `targetId`. If the target already
|
||||
* has a pending request out to the requester, the two become friends instead
|
||||
* (the request crosses an existing one). Already-friends is left unchanged.
|
||||
* Returns the relationship from the requester's point of view.
|
||||
* (the request crosses an existing one). Already-friends, and re-sending a request
|
||||
* that's already outstanding, are no-ops.
|
||||
*/
|
||||
export async function sendFriendRequest(
|
||||
db: D1Database,
|
||||
requesterId: number,
|
||||
targetId: number
|
||||
): Promise<RelationshipResponse> {
|
||||
): Promise<RelationshipChange> {
|
||||
const existing = await findPair(db, requesterId, targetId)
|
||||
if (existing) {
|
||||
if (existing.relationship_type === RelationshipType.Friend) {
|
||||
return toResponse(existing, requesterId)
|
||||
// Already friends, or we already have a request out to them — nothing to write.
|
||||
if (
|
||||
existing.relationship_type === RelationshipType.Friend ||
|
||||
(existing.requester_id === requesterId &&
|
||||
existing.relationship_type === RelationshipType.FriendRequestSent)
|
||||
) {
|
||||
return toChange(existing, requesterId, targetId, false)
|
||||
}
|
||||
// The target already requested us → crossing requests become a friendship.
|
||||
if (
|
||||
existing.requester_id === targetId &&
|
||||
existing.relationship_type === RelationshipType.FriendRequestSent
|
||||
) {
|
||||
return upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||
return toChange(row, requesterId, targetId, true)
|
||||
}
|
||||
}
|
||||
return upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
|
||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
|
||||
return toChange(row, requesterId, targetId, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* `accepterId` accepts a pending friend request from `otherId`. Only upgrades to
|
||||
* Friend when a request from `otherId` is actually pending; otherwise the
|
||||
* current state is returned unchanged. Returns the relationship from the
|
||||
* accepter's point of view.
|
||||
* Friend when a request from `otherId` is actually pending; otherwise the current
|
||||
* state is returned as a no-op. (The reference server answers 403 there instead;
|
||||
* we stay lenient, but either way nothing changed.)
|
||||
*/
|
||||
export async function acceptFriendRequest(
|
||||
db: D1Database,
|
||||
accepterId: number,
|
||||
otherId: number
|
||||
): Promise<RelationshipResponse> {
|
||||
): Promise<RelationshipChange> {
|
||||
const existing = await findPair(db, accepterId, otherId)
|
||||
if (
|
||||
existing &&
|
||||
existing.requester_id === otherId &&
|
||||
existing.relationship_type === RelationshipType.FriendRequestSent
|
||||
) {
|
||||
// upsertPair projects for the requester (otherId); the accepter is the target,
|
||||
// so re-project the written row from the accepter's point of view.
|
||||
await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
|
||||
const updated = await findPair(db, accepterId, otherId)
|
||||
if (updated) return toResponse(updated, accepterId)
|
||||
const row = await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
|
||||
return toChange(row, accepterId, otherId, true)
|
||||
}
|
||||
return existing
|
||||
? toResponse(existing, accepterId)
|
||||
: { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 }
|
||||
? toChange(existing, accepterId, otherId, false)
|
||||
: { self: noneResponse(otherId), other: noneResponse(accepterId), changed: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly make `requesterId` and `targetId` friends (no pending request step).
|
||||
* Returns the relationship from the requester's point of view.
|
||||
*/
|
||||
export async function addFriend(
|
||||
db: D1Database,
|
||||
requesterId: number,
|
||||
targetId: number
|
||||
): Promise<RelationshipResponse> {
|
||||
return upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||
): Promise<RelationshipChange> {
|
||||
const existing = await findPair(db, requesterId, targetId)
|
||||
if (existing && existing.relationship_type === RelationshipType.Friend) {
|
||||
return toChange(existing, requesterId, targetId, false)
|
||||
}
|
||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||
return toChange(row, requesterId, targetId, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any relationship between the two players (unfriend / cancel request /
|
||||
* decline). Deletes the row entirely so neither side reports a relationship.
|
||||
* decline).
|
||||
*
|
||||
* The row is set to `None` rather than deleted, matching the reference server: the
|
||||
* per-player favorited/ignored/muted flags live on that row and must survive an
|
||||
* unfriending (someone you ignored stays ignored after you drop them as a friend).
|
||||
*/
|
||||
export async function removeFriend(db: D1Database, a: number, b: number): Promise<void> {
|
||||
export async function removeFriend(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
otherId: number
|
||||
): Promise<RelationshipChange> {
|
||||
await db
|
||||
.prepare(
|
||||
`DELETE FROM relationship
|
||||
`UPDATE relationship SET relationship_type = ?3
|
||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||
)
|
||||
.bind(a, b)
|
||||
.bind(playerId, otherId, RelationshipType.None)
|
||||
.run()
|
||||
const updated = await findPair(db, playerId, otherId)
|
||||
return updated
|
||||
? toChange(updated, playerId, otherId, true)
|
||||
: { self: noneResponse(otherId), other: noneResponse(playerId), changed: true }
|
||||
}
|
||||
|
||||
/** A per-player relationship flag — each is stored on the player's own side of the row. */
|
||||
@@ -308,7 +423,5 @@ export async function setRelationshipFlag(
|
||||
}
|
||||
|
||||
const updated = await findPair(db, playerId, otherId)
|
||||
return updated
|
||||
? toResponse(updated, playerId)
|
||||
: { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 }
|
||||
return updated ? toResponse(updated, playerId) : noneResponse(otherId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Player-report storage on the shared `recflare` D1 database.
|
||||
*
|
||||
* Like the relationship table (and unlike the JSON-blob tables here — rooms /
|
||||
* accounts / image / invention), a report is genuinely columnar, so it gets a
|
||||
* normal relational table. Rows are append-only: nothing updates or dedupes a
|
||||
* report, so the table is a log of exactly what players submitted.
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
|
||||
* applied under its own `migrations_table` so it doesn't clash with the other
|
||||
* workers' migrations that share the database).
|
||||
*
|
||||
* Nothing acts on the rows yet — `/api/PlayerReporting/v1/moderationBlockDetails`
|
||||
* still answers "not blocked" unconditionally; this is the record that a future
|
||||
* moderation flow would read.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0004_report.sql, sans seed rows). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS report (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reporter_player_id INTEGER NOT NULL,
|
||||
reported_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
details TEXT,
|
||||
height_reporter REAL,
|
||||
height_reported REAL,
|
||||
room_id INTEGER,
|
||||
room_instance_type TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`,
|
||||
]
|
||||
|
||||
/** A stored report row (snake_case columns, one row per submission). */
|
||||
export interface ReportRow {
|
||||
id: number
|
||||
reporter_player_id: number
|
||||
reported_player_id: number
|
||||
report_category: number
|
||||
details: string | null
|
||||
/** Player height in metres, as the client measured it at report time. */
|
||||
height_reporter: number | null
|
||||
height_reported: number | null
|
||||
room_id: number | null
|
||||
/** The instance's `RoomInstanceType` name, e.g. `Public`. Stored verbatim. */
|
||||
room_instance_type: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A report as submitted — everything but the reporter (which comes from the bearer
|
||||
* token) and the timestamp. Only the reported player is required; the client omits
|
||||
* fields it has no value for (a report raised outside a room carries no `RoomId`),
|
||||
* so the rest are optional and stored as NULL when absent.
|
||||
*/
|
||||
export interface NewReport {
|
||||
reporterPlayerId: number
|
||||
reportedPlayerId: number
|
||||
reportCategory?: number
|
||||
details?: string | null
|
||||
heightReporter?: number | null
|
||||
heightReported?: number | null
|
||||
roomId?: number | null
|
||||
roomInstanceType?: string | null
|
||||
}
|
||||
|
||||
/** Record a submitted report, returning the stored row (with its assigned id). */
|
||||
export async function createReport(db: D1Database, input: NewReport): Promise<ReportRow> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO report (
|
||||
reporter_player_id, reported_player_id, report_category, details,
|
||||
height_reporter, height_reported, room_id, room_instance_type, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
input.reporterPlayerId,
|
||||
input.reportedPlayerId,
|
||||
input.reportCategory ?? 0,
|
||||
input.details ?? null,
|
||||
input.heightReporter ?? null,
|
||||
input.heightReported ?? null,
|
||||
input.roomId ?? null,
|
||||
input.roomInstanceType ?? null,
|
||||
new Date().toISOString()
|
||||
)
|
||||
.first<ReportRow>()
|
||||
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
||||
// from having to handle an impossible null.
|
||||
return row!
|
||||
}
|
||||
|
||||
/** Every report filed against a player, newest first. Backs a future moderation view. */
|
||||
export async function getReportsAgainst(db: D1Database, playerId: number): Promise<ReportRow[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT * FROM report WHERE reported_player_id = ?1 ORDER BY id DESC')
|
||||
.bind(playerId)
|
||||
.all<ReportRow>()
|
||||
return results
|
||||
}
|
||||
+660
-219
@@ -1,16 +1,23 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import {
|
||||
inventionDescriptionRejection,
|
||||
inventionNameRejection,
|
||||
inventionTagRejection,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
createInvention,
|
||||
getFeaturedInventions,
|
||||
getInventionById,
|
||||
getInventionsByCreator,
|
||||
getInventionsByIds,
|
||||
getInventionsByRoom,
|
||||
getInventionTagFilters,
|
||||
getInventionTags,
|
||||
getInventionVersion,
|
||||
getMyInventions,
|
||||
getTopInventions,
|
||||
parsePermissionLevel,
|
||||
publishInvention,
|
||||
@@ -20,6 +27,34 @@ import {
|
||||
toSaveResult,
|
||||
updateInvention,
|
||||
} from '../inventions-db'
|
||||
import {
|
||||
AUTHED,
|
||||
BareBoolean,
|
||||
CustomAvatarItemsPage,
|
||||
ErrorResponse,
|
||||
form,
|
||||
GeneratedGift,
|
||||
GenerateGiftRequest,
|
||||
idParam,
|
||||
intQuery,
|
||||
InventionDetails,
|
||||
InventionDto,
|
||||
InventionPersonalDetails,
|
||||
InventionSaveResult,
|
||||
InventionVersionDto,
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
pageParams,
|
||||
SaveInventionRequest,
|
||||
SetTagsRequest,
|
||||
SetTagsResponse,
|
||||
stringQuery,
|
||||
SuccessValueEnvelope,
|
||||
TagFilters,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UpdatePriceRequest,
|
||||
} from '../openapi'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
@@ -48,137 +83,299 @@ async function creatorsInvention(
|
||||
}
|
||||
|
||||
// ---- Avatar gifts ----------------------------------------------------------
|
||||
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`)
|
||||
// live in the `econ` worker, which the client calls on the econ host — not here.
|
||||
// Only the gift generate/consume actions remain on this worker.
|
||||
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`) and
|
||||
// gift-box consume live in the `econ` worker, which the client calls on the econ host
|
||||
// — not here. Only the gift `generate` action remains on this worker.
|
||||
export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
.post('/api/avatar/v2/gifts/generate', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
.post(
|
||||
'/api/avatar/v2/gifts/generate',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Generate a gift box',
|
||||
description:
|
||||
'Mint the gift box a player earned (levelling up, a room reward). With no ' +
|
||||
'EarnableRewards catalog wired up this always falls back to a token gift of a ' +
|
||||
'random amount, and the box is not persisted — its `Id` is 0 and it cannot be ' +
|
||||
'opened through the `econ` worker’s consume endpoint.',
|
||||
security: AUTHED,
|
||||
requestBody: form(GenerateGiftRequest, 'Where the gift was earned'),
|
||||
responses: {
|
||||
200: json(GeneratedGift, 'The generated (unpersisted) gift'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const giftContext =
|
||||
typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0
|
||||
const message = typeof body.Message === 'string' ? body.Message : ''
|
||||
const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const giftContext =
|
||||
typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0
|
||||
const message = typeof body.Message === 'string' ? body.Message : ''
|
||||
const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0
|
||||
|
||||
// No EarnableRewards binding → always fall back to a token gift.
|
||||
const tokenAmounts = [10, 25, 50, 100, 250, 500]
|
||||
const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)]
|
||||
// No EarnableRewards binding → always fall back to a token gift.
|
||||
const tokenAmounts = [10, 25, 50, 100, 250, 500]
|
||||
const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)]
|
||||
|
||||
return c.json({
|
||||
Id: 0, // TODO: real id once gifts are persisted
|
||||
FromPlayerId: 1,
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: '',
|
||||
FriendlyName: '',
|
||||
AvatarItemType: 0,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
CurrencyType: 2,
|
||||
Currency: currency,
|
||||
Xp: xp,
|
||||
Level: 0,
|
||||
Platform: -1,
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: 0,
|
||||
GiftContext: giftContext,
|
||||
GiftRarity: 20,
|
||||
Message: message,
|
||||
})
|
||||
})
|
||||
.post('/api/avatar/v2/gifts/consume', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0
|
||||
if (giftId === 0) return c.json({ success: false, error: 'Invalid gift ID' }, 400)
|
||||
// No DB → gift can never be found.
|
||||
return c.json({ success: false, error: 'Gift not found' }, 404)
|
||||
})
|
||||
return c.json({
|
||||
Id: 0, // TODO: real id once gifts are persisted
|
||||
FromPlayerId: 1,
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: '',
|
||||
FriendlyName: '',
|
||||
AvatarItemType: 0,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
CurrencyType: 2,
|
||||
Currency: currency,
|
||||
Xp: xp,
|
||||
Level: 0,
|
||||
Platform: -1,
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: 0,
|
||||
GiftContext: giftContext,
|
||||
GiftRarity: 20,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Custom avatar item gates — real Rec Room client endpoints with no backing
|
||||
// implementation yet; we enable them. Flip to `false` to disable the
|
||||
// corresponding flow. `isCreationAllowedForAccount` wraps its answer in the
|
||||
// success/value envelope; the other two return a bare JSON boolean.
|
||||
.get('/api/customAvatarItems/v1/isCreationAllowedForAccount', (c) =>
|
||||
c.json({ success: true, value: null })
|
||||
.get(
|
||||
'/api/customAvatarItems/v1/isCreationAllowedForAccount',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'May this account create custom items?',
|
||||
description:
|
||||
'A feature gate with no backing implementation — we answer yes. Note this one ' +
|
||||
'wraps its answer in the `{ success, value }` envelope while the two gates below ' +
|
||||
'return a bare boolean.',
|
||||
responses: { 200: json(SuccessValueEnvelope, 'Allowed') },
|
||||
}),
|
||||
(c) => c.json({ success: true, value: null })
|
||||
)
|
||||
.get(
|
||||
'/api/customAvatarItems/v1/isCreationEnabled',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Is custom-item creation enabled?',
|
||||
description: 'A server-wide feature gate. Enabled; flip to `false` to disable the flow.',
|
||||
responses: { 200: json(BareBoolean, 'A bare `true`') },
|
||||
}),
|
||||
(c) => c.json(true)
|
||||
)
|
||||
.get(
|
||||
'/api/customAvatarItems/v1/isRenderingEnabled',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Is custom-item rendering enabled?',
|
||||
description: 'A server-wide feature gate. Enabled; flip to `false` to disable the flow.',
|
||||
responses: { 200: json(BareBoolean, 'A bare `true`') },
|
||||
}),
|
||||
(c) => c.json(true)
|
||||
)
|
||||
|
||||
// The featured custom-avatar-item feed. No curated items yet → an empty list.
|
||||
.get(
|
||||
'/api/customAvatarItems/v1/featured',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Featured custom avatar items',
|
||||
description: 'The curated feed. Nothing is curated yet, so it is empty.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// The "hot" (trending) custom-avatar-item feed. No items yet → an empty list.
|
||||
.get(
|
||||
'/api/customAvatarItems/v1/hot',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Trending custom avatar items',
|
||||
description: 'The “hot” feed. No custom items exist yet, so it is empty.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get('/api/customAvatarItems/v1/isCreationEnabled', (c) => c.json(true))
|
||||
.get('/api/customAvatarItems/v1/isRenderingEnabled', (c) => c.json(true))
|
||||
|
||||
// Custom avatar items created by a given account. No storage yet → an empty
|
||||
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
|
||||
.get('/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}', (c) =>
|
||||
c.json({ Results: [], TotalResults: 0 })
|
||||
.get(
|
||||
'/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'A creator’s custom avatar items',
|
||||
description:
|
||||
'The items an account has authored. Nothing stores custom items yet, so this is an ' +
|
||||
'empty page — in the same shape as the `econ` worker’s `customAvatarItems/v1/owned`.',
|
||||
parameters: [idParam('accountId', 'Creator account id')],
|
||||
responses: { 200: json(CustomAvatarItemsPage, 'An empty page') },
|
||||
}),
|
||||
(c) => c.json({ Results: [], TotalResults: 0 })
|
||||
)
|
||||
|
||||
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
|
||||
// or 404 when there's no such invention.
|
||||
.get('/api/inventions/v1', async (c) => {
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
const invention = await getInventionById(c.env.DB, inventionId)
|
||||
return invention ? c.json(invention) : c.notFound()
|
||||
})
|
||||
.get(
|
||||
'/api/inventions/v1',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'One invention by id',
|
||||
description: 'The stored `RRInvention`. Public — an unpublished invention is served too.',
|
||||
parameters: [intQuery('inventionId', 'Invention id; required')],
|
||||
responses: {
|
||||
200: json(InventionDto, 'The invention'),
|
||||
400: json(ErrorResponse, 'Missing or non-numeric inventionId'),
|
||||
404: { description: 'No such invention' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
const invention = await getInventionById(c.env.DB, inventionId)
|
||||
return invention ? c.json(invention) : c.notFound()
|
||||
}
|
||||
)
|
||||
|
||||
// The tag filter chips on the invention browse screen. Derived from the tags in
|
||||
// use on published inventions — most popular first, top few pinned. Public.
|
||||
.get('/api/inventions/v1/tagfilters', async (c) => c.json(await getInventionTagFilters(c.env.DB)))
|
||||
.get(
|
||||
'/api/inventions/v1/tagfilters',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Invention browse filter chips',
|
||||
description:
|
||||
'The filter chips on the invention browse screen, derived from the tags actually in ' +
|
||||
'use on published inventions — most popular first, the top few pinned. ' +
|
||||
'`TrendingFilters` is null: that needs recent-activity data we do not keep, and the ' +
|
||||
'client treats null as absent.',
|
||||
responses: { 200: json(TagFilters, 'The chips in use') },
|
||||
}),
|
||||
async (c) => c.json(await getInventionTagFilters(c.env.DB))
|
||||
)
|
||||
|
||||
// A batch of inventions by id (`?id=1&id=2`, and each `id` may itself be a
|
||||
// comma-separated list). Unknown ids are dropped rather than 404ing, and an empty
|
||||
// request is an empty list. Auth is optional and only widens what you see: an
|
||||
// unpublished invention comes back only to its creator. Bare array.
|
||||
.get('/api/inventions/v2/batch', async (c) => {
|
||||
const ids = c.req
|
||||
.queries('id')
|
||||
?.flatMap((raw) => raw.split(','))
|
||||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
||||
.filter((id) => !Number.isNaN(id))
|
||||
if (ids === undefined || ids.length === 0) return c.json([])
|
||||
.get(
|
||||
'/api/inventions/v2/batch',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Inventions by id, in bulk',
|
||||
description:
|
||||
'Look up several inventions at once. Unknown ids are dropped rather than 404ing, ' +
|
||||
'and an empty request is an empty list. Auth is optional and only widens what you ' +
|
||||
'see: an unpublished invention comes back only to its creator.',
|
||||
parameters: [intQuery('id', 'Repeatable; each value may be a comma-separated list of ids')],
|
||||
responses: { 200: json(InventionDto.array(), 'The inventions the caller may see') },
|
||||
}),
|
||||
async (c) => {
|
||||
const ids = c.req
|
||||
.queries('id')
|
||||
?.flatMap((raw) => raw.split(','))
|
||||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
||||
.filter((id) => !Number.isNaN(id))
|
||||
if (ids === undefined || ids.length === 0) return c.json([])
|
||||
|
||||
const playerId = await authedId(c)
|
||||
const inventions = await getInventionsByIds(c.env.DB, ids)
|
||||
return c.json(
|
||||
inventions.filter(
|
||||
(i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId)
|
||||
const playerId = await authedId(c)
|
||||
const inventions = await getInventionsByIds(c.env.DB, ids)
|
||||
return c.json(
|
||||
inventions.filter(
|
||||
(i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId)
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// A room's inventions (`?id=76`) — published inventions created in that room,
|
||||
// newest first. Paginated via skip/take (take defaults to 100). Bare array.
|
||||
.get('/api/inventions/v1/room', async (c) => {
|
||||
const roomId = Number.parseInt(c.req.query('id') ?? '', 10)
|
||||
if (Number.isNaN(roomId)) return c.json({ error: 'id is required' }, 400)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getInventionsByRoom(c.env.DB, roomId, skip, take))
|
||||
})
|
||||
.get(
|
||||
'/api/inventions/v1/room',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'A room’s inventions',
|
||||
description: 'Published inventions created in that room, newest first.',
|
||||
parameters: [intQuery('id', 'Room id; required'), ...pageParams(100)],
|
||||
responses: {
|
||||
200: json(InventionDto.array(), 'The room’s inventions'),
|
||||
400: json(ErrorResponse, 'Missing or non-numeric id'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const roomId = Number.parseInt(c.req.query('id') ?? '', 10)
|
||||
if (Number.isNaN(roomId)) return c.json({ error: 'id is required' }, 400)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getInventionsByRoom(c.env.DB, roomId, skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// The signed-in player's own relationship to an invention (`/personaldetails/2`)
|
||||
// — just whether they're cheering it. We store no cheers (nothing can cheer an
|
||||
// invention yet), so this is always false; it stays a 200 for signed-out callers
|
||||
// too, since the client only reads the flag.
|
||||
.get('/api/inventions/v1/personaldetails/:inventionId{[0-9]+}', (c) =>
|
||||
c.json({ IsCheering: false })
|
||||
.get(
|
||||
'/api/inventions/v1/personaldetails/:inventionId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The caller’s own relation to an invention',
|
||||
description:
|
||||
'Just whether the caller is cheering it. We store no cheers, so it is always false ' +
|
||||
'— and this stays a 200 for signed-out callers too, since the client only reads the ' +
|
||||
'flag.',
|
||||
parameters: [idParam('inventionId', 'Invention id')],
|
||||
responses: { 200: json(InventionPersonalDetails, 'Always not cheering') },
|
||||
}),
|
||||
(c) => c.json({ IsCheering: false })
|
||||
)
|
||||
|
||||
// A single version of an invention (`?inventionId=…&version=…`) — the bare
|
||||
// RRInventionVersion, which carries the blob name the client downloads. Public.
|
||||
// Only the current version exists (nothing writes version history yet), so any
|
||||
// other version number 404s rather than naming a blob that isn't there.
|
||||
.get('/api/inventions/v1/version', async (c) => {
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
|
||||
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
|
||||
// RRInventionVersion, which carries the blob name the client downloads and the
|
||||
// SHA-256 of that blob. Public. Only the current version exists (nothing writes
|
||||
// version history yet), so any other version number 404s rather than naming a
|
||||
// blob that isn't there.
|
||||
.get(
|
||||
'/api/inventions/v1/version',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'One version of an invention',
|
||||
description:
|
||||
'The bare `RRInventionVersion`, which carries the blob name the client downloads ' +
|
||||
'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
|
||||
'never uploaded). Only the current version exists — nothing writes version ' +
|
||||
'history yet — so any other version number 404s rather than naming a blob that ' +
|
||||
'is not there.',
|
||||
parameters: [
|
||||
intQuery('inventionId', 'Invention id; required'),
|
||||
intQuery('version', 'Version number; required'),
|
||||
],
|
||||
responses: {
|
||||
200: json(InventionVersionDto, 'The version'),
|
||||
400: json(ErrorResponse, 'Missing inventionId or version'),
|
||||
404: { description: 'No such invention, or not the current version' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
|
||||
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
|
||||
|
||||
const version = await getInventionVersion(c.env.DB, inventionId, versionNumber)
|
||||
return version === null ? c.notFound() : c.json(version)
|
||||
})
|
||||
const version = await getInventionVersion(
|
||||
c.env.DB,
|
||||
c.env.CDN_ASSETS,
|
||||
inventionId,
|
||||
versionNumber
|
||||
)
|
||||
return version === null ? c.notFound() : c.json(version)
|
||||
}
|
||||
)
|
||||
|
||||
// Edit an invention's metadata. A GET that writes — that's what the client sends
|
||||
// (`?inventionId=1&description=my+description`), with the fields to change as
|
||||
@@ -187,67 +384,150 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
// empty `description` clears it, but an empty `name`/`imageName` is ignored
|
||||
// rather than blanking the invention. Publishing and pricing are separate
|
||||
// endpoints. Auth-gated, creator only; answers the save envelope.
|
||||
.get('/api/inventions/v1/update', async (c) => {
|
||||
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
|
||||
if ('response' in gate) return gate.response
|
||||
.get(
|
||||
'/api/inventions/v1/update',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Edit an invention’s metadata',
|
||||
description:
|
||||
'A GET that writes — that is what the client sends, with the fields to change as ' +
|
||||
'query params. Absent params keep their stored value. An empty `description` ' +
|
||||
'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' +
|
||||
'invention. A supplied name/description must satisfy the same rules `v6/save` ' +
|
||||
'enforces. Publishing and pricing are separate endpoints.',
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
intQuery('inventionId', 'Invention id; required'),
|
||||
stringQuery('name', '3–24 chars, letters/digits/spaces/dashes/colons; empty is ignored'),
|
||||
stringQuery('description', 'Max 512 chars; present-but-empty clears it'),
|
||||
stringQuery('imageName', 'New thumbnail; empty is ignored'),
|
||||
stringQuery('allowTrial', '`true`/`1` to allow trials'),
|
||||
stringQuery('permission', 'A name like `useonly`, or the raw permission number'),
|
||||
],
|
||||
responses: {
|
||||
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
|
||||
400: json(ErrorResponse, 'A supplied name or description breaks its rule'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
|
||||
if ('response' in gate) return gate.response
|
||||
|
||||
// Query params arrive as strings; only the ones actually present are applied.
|
||||
const nonEmpty = (name: string): string | undefined => {
|
||||
const v = c.req.query(name)?.trim()
|
||||
return v === undefined || v === '' ? undefined : v
|
||||
// Query params arrive as strings; only the ones actually present are applied.
|
||||
const nonEmpty = (name: string): string | undefined => {
|
||||
const v = c.req.query(name)?.trim()
|
||||
return v === undefined || v === '' ? undefined : v
|
||||
}
|
||||
const allowTrial = c.req.query('allowTrial')
|
||||
const permission = c.req.query('permission')
|
||||
|
||||
// Only a name that's actually being changed is checked — an absent or empty one
|
||||
// keeps the stored name, which was already validated when it was set.
|
||||
const name = nonEmpty('name')
|
||||
const nameRejection = name === undefined ? null : inventionNameRejection(name)
|
||||
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
|
||||
|
||||
// The description is checked on presence, not emptiness: empty is how a creator
|
||||
// clears it, and the length rule accepts that.
|
||||
const description = c.req.query('description')
|
||||
const descriptionRejection =
|
||||
description === undefined ? null : inventionDescriptionRejection(description)
|
||||
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
|
||||
|
||||
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||||
name,
|
||||
// Present-but-empty clears the description, so this checks presence.
|
||||
description,
|
||||
imageName: nonEmpty('imageName'),
|
||||
allowTrial:
|
||||
allowTrial === undefined
|
||||
? undefined
|
||||
: allowTrial.toLowerCase() === 'true' || allowTrial === '1',
|
||||
generalPermission: permission === undefined ? undefined : parsePermissionLevel(permission),
|
||||
})
|
||||
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
|
||||
}
|
||||
const allowTrial = c.req.query('allowTrial')
|
||||
const permission = c.req.query('permission')
|
||||
|
||||
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||||
name: nonEmpty('name'),
|
||||
// Present-but-empty clears the description, so this checks presence.
|
||||
description: c.req.query('description'),
|
||||
imageName: nonEmpty('imageName'),
|
||||
allowTrial:
|
||||
allowTrial === undefined
|
||||
? undefined
|
||||
: allowTrial.toLowerCase() === 'true' || allowTrial === '1',
|
||||
generalPermission: permission === undefined ? undefined : parsePermissionLevel(permission),
|
||||
})
|
||||
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
|
||||
})
|
||||
)
|
||||
|
||||
// Publish an invention — this is what puts it into search and the feeds. Sets the
|
||||
// permission other players get (`permissionLevel`, defaulting to UseOnly) and its
|
||||
// `price`. Auth-gated, creator only; answers the save envelope.
|
||||
.get('/api/inventions/v3/publish', async (c) => {
|
||||
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
|
||||
if ('response' in gate) return gate.response
|
||||
.get(
|
||||
'/api/inventions/v3/publish',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Publish an invention',
|
||||
description:
|
||||
'What puts an invention into search and the feeds. Sets the permission other ' +
|
||||
'players get (defaulting to UseOnly) and its price. Another GET that writes.',
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
intQuery('inventionId', 'Invention id; required'),
|
||||
stringQuery('permissionLevel', 'A name like `useonly`, or the raw number'),
|
||||
intQuery('price', 'Price in tokens; negative is ignored'),
|
||||
],
|
||||
responses: {
|
||||
200: json(InventionSaveResult, 'The published invention, in the save envelope'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
|
||||
if ('response' in gate) return gate.response
|
||||
|
||||
const permissionLevel = c.req.query('permissionLevel')
|
||||
const price = Number.parseInt(c.req.query('price') ?? '', 10)
|
||||
const permissionLevel = c.req.query('permissionLevel')
|
||||
const price = Number.parseInt(c.req.query('price') ?? '', 10)
|
||||
|
||||
const published = await publishInvention(
|
||||
c.env.DB,
|
||||
gate.invention.InventionId,
|
||||
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
|
||||
Number.isNaN(price) || price < 0 ? undefined : price
|
||||
)
|
||||
return published === null ? c.notFound() : c.json(toSaveResult(published))
|
||||
})
|
||||
const published = await publishInvention(
|
||||
c.env.DB,
|
||||
gate.invention.InventionId,
|
||||
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
|
||||
Number.isNaN(price) || price < 0 ? undefined : price
|
||||
)
|
||||
return published === null ? c.notFound() : c.json(toSaveResult(published))
|
||||
}
|
||||
)
|
||||
|
||||
// Set an invention's price. Unlike update/publish this one POSTs a JSON body.
|
||||
// Auth-gated, creator only; answers the save envelope.
|
||||
.post('/api/inventions/v1/updateprice', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||||
.post(
|
||||
'/api/inventions/v1/updateprice',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Set an invention’s price',
|
||||
description:
|
||||
'Unlike update/publish, this one POSTs a JSON body. Creator only; a negative price ' +
|
||||
'is rejected.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(UpdatePriceRequest, 'The invention and its new price'),
|
||||
responses: {
|
||||
200: json(InventionSaveResult, 'The repriced invention, in the save envelope'),
|
||||
400: json(ErrorResponse, 'Unparseable body, or a price below 0'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||||
|
||||
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
const gate = await creatorsInvention(c, inventionId)
|
||||
if ('response' in gate) return gate.response
|
||||
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
const gate = await creatorsInvention(c, inventionId)
|
||||
if ('response' in gate) return gate.response
|
||||
|
||||
const price = typeof body.Price === 'number' ? body.Price : Number.NaN
|
||||
if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400)
|
||||
const price = typeof body.Price === 'number' ? body.Price : Number.NaN
|
||||
if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400)
|
||||
|
||||
const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
|
||||
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
|
||||
})
|
||||
const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
|
||||
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
|
||||
}
|
||||
)
|
||||
|
||||
// Replace an invention's tags. `CustomTags` are the creator's own (Type 0),
|
||||
// `AutoTags` the ones the client derives from the invention (Type 2); both lists
|
||||
@@ -255,69 +535,191 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
// invention. Answers `{ Result, Tags }` — `Result` 0 is success, and `Tags` is the
|
||||
// flat list of tag *names* (auto first, then custom); the typed `{ Tag, Type }`
|
||||
// objects are what `v1/details` serves.
|
||||
.post('/api/inventions/v1/settags', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||||
.post(
|
||||
'/api/inventions/v1/settags',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Replace an invention’s tags',
|
||||
description:
|
||||
'`CustomTags` are the creator’s own (Type 0), `AutoTags` the ones the client ' +
|
||||
'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' +
|
||||
'only.\n\n' +
|
||||
'Every tag in either list must be at most 15 letters (a–z once lowercased); one ' +
|
||||
'that isn’t fails the whole call, so no tag is ever silently dropped.\n\n' +
|
||||
'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' +
|
||||
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
|
||||
responses: {
|
||||
200: json(SetTagsResponse, 'The resulting tag names'),
|
||||
400: json(ErrorResponse, 'Unparseable body, or a tag that breaks the rule'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||||
|
||||
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
const gate = await creatorsInvention(c, inventionId)
|
||||
if ('response' in gate) return gate.response
|
||||
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
const gate = await creatorsInvention(c, inventionId)
|
||||
if ('response' in gate) return gate.response
|
||||
|
||||
const strings = (v: unknown): string[] =>
|
||||
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
|
||||
const strings = (v: unknown): string[] =>
|
||||
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
|
||||
|
||||
const tags = await setInventionTags(
|
||||
c.env.DB,
|
||||
gate.invention.InventionId,
|
||||
strings(body.AutoTags),
|
||||
strings(body.CustomTags)
|
||||
)
|
||||
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
|
||||
})
|
||||
const autoTags = strings(body.AutoTags)
|
||||
const customTags = strings(body.CustomTags)
|
||||
|
||||
// Both lists are held to the tag rule, and one bad tag fails the whole call rather
|
||||
// than being dropped — a silently missing tag looks to the creator like a tag that
|
||||
// saved. Checked against the normalized form `setInventionTags` will store, so the
|
||||
// rejection quotes the tag as it would have been stored, not as it was typed.
|
||||
// Blanks are skipped, not rejected: the store already drops them, and the client
|
||||
// pads its list with empties.
|
||||
for (const raw of [...autoTags, ...customTags]) {
|
||||
const tag = raw.trim().toLowerCase()
|
||||
if (tag === '') continue
|
||||
const rejection = inventionTagRejection(tag)
|
||||
if (rejection !== null) {
|
||||
return c.json({ error: `${rejection} (“${tag}”)` }, 400)
|
||||
}
|
||||
}
|
||||
|
||||
const tags = await setInventionTags(
|
||||
c.env.DB,
|
||||
gate.invention.InventionId,
|
||||
autoTags,
|
||||
customTags
|
||||
)
|
||||
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
|
||||
}
|
||||
)
|
||||
|
||||
// An invention's detail card (`?inventionId=…`) — just its tags, as `{ Tags }`.
|
||||
// Untagged inventions report an empty list. 404s on unknown ids.
|
||||
.get('/api/inventions/v1/details', async (c) => {
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
const tags = await getInventionTags(c.env.DB, inventionId)
|
||||
return tags === null ? c.notFound() : c.json({ Tags: tags })
|
||||
})
|
||||
.get(
|
||||
'/api/inventions/v1/details',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'An invention’s detail card',
|
||||
description:
|
||||
'Which in practice is just its tags, as typed `{ Tag, Type }` objects. An untagged ' +
|
||||
'invention reports an empty list.',
|
||||
parameters: [intQuery('inventionId', 'Invention id; required')],
|
||||
responses: {
|
||||
200: json(InventionDetails, 'The invention’s tags'),
|
||||
400: json(ErrorResponse, 'Missing or non-numeric inventionId'),
|
||||
404: { description: 'No such invention' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
const tags = await getInventionTags(c.env.DB, inventionId)
|
||||
return tags === null ? c.notFound() : c.json({ Tags: tags })
|
||||
}
|
||||
)
|
||||
|
||||
// The "top today" invention feed — published inventions ranked by engagement
|
||||
// (lifetime, not per-day: we keep no daily counters). Paginated via skip/take
|
||||
// (take defaults to 50, as the client asks for). Bare array.
|
||||
.get('/api/inventions/v1/toptoday', async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
|
||||
return c.json(await getTopInventions(c.env.DB, skip, take))
|
||||
})
|
||||
// The "top today" invention feed — the inventions most acquired in the last 24 hours,
|
||||
// counted from the purchase rows the `econ` worker writes. A real day window, so an
|
||||
// empty list is a quiet day rather than a bug. Paginated via skip/take (take defaults
|
||||
// to 50, as the client asks for). Bare array.
|
||||
.get(
|
||||
'/api/inventions/v1/toptoday',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The “top today” feed',
|
||||
description:
|
||||
'Published inventions ranked by how many players acquired them in the last 24 ' +
|
||||
'hours, counted from the purchase records — free grants included, one per ' +
|
||||
'player per invention. Genuinely a window: an invention nobody has picked up ' +
|
||||
'since yesterday falls off, and a day with no acquisitions at all serves an ' +
|
||||
'empty list. It trails the clock rather than resetting at midnight.',
|
||||
parameters: pageParams(50),
|
||||
responses: { 200: json(InventionDto.array(), 'The top inventions') },
|
||||
}),
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
|
||||
return c.json(await getTopInventions(c.env.DB, skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// The featured invention feed — curated (`IsFeatured`) inventions, falling back
|
||||
// to the top feed while nothing is curated. Bare array, like toptoday.
|
||||
.get('/api/inventions/v1/featured', async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
|
||||
return c.json(await getFeaturedInventions(c.env.DB, skip, take))
|
||||
})
|
||||
// The featured invention feed — the curated (`IsFeatured`) inventions and nothing
|
||||
// else, newest first. Empty until someone flags one. Bare array, like toptoday.
|
||||
.get(
|
||||
'/api/inventions/v1/featured',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The featured feed',
|
||||
description:
|
||||
'Curated (`IsFeatured`) inventions, newest first — published and non-hidden only. ' +
|
||||
'Serves an empty list while nothing is flagged rather than standing in the top ' +
|
||||
'feed: the client presents these as hand-picked, so a fallback would be a lie.',
|
||||
parameters: pageParams(50),
|
||||
responses: { 200: json(InventionDto.array(), 'The featured inventions') },
|
||||
}),
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
|
||||
return c.json(await getFeaturedInventions(c.env.DB, skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// Invention search/browse: published inventions matching `value` (matched against
|
||||
// name + description; absent → browse everything published), newest first.
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
.get('/api/inventions/v2/search', async (c) => {
|
||||
const value = c.req.query('value') ?? ''
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await searchInventions(c.env.DB, value, skip, take))
|
||||
})
|
||||
.get(
|
||||
'/api/inventions/v2/search',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Search / browse inventions',
|
||||
description:
|
||||
'Published inventions matching `value` (matched against name and description), ' +
|
||||
'newest first. An absent `value` browses everything published — that is the ' +
|
||||
'browse screen’s initial request.',
|
||||
parameters: [
|
||||
stringQuery('value', 'Search text; absent browses everything'),
|
||||
...pageParams(100),
|
||||
],
|
||||
responses: { 200: json(InventionDto.array(), 'The matching inventions') },
|
||||
}),
|
||||
async (c) => {
|
||||
const value = c.req.query('value') ?? ''
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await searchInventions(c.env.DB, value, skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// The signed-in player's saved inventions ("my inventions"), newest first.
|
||||
// Auth-gated; returns a bare array (empty when the player has saved none).
|
||||
.get('/api/inventions/v2/mine', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getInventionsByCreator(c.env.DB, id))
|
||||
})
|
||||
// The signed-in player's invention shelf ("my inventions"), newest first — the ones
|
||||
// they created AND the ones they bought (`inventory_invention`, written by the `econ`
|
||||
// worker's buyInvention). A bought invention stays on the shelf whatever happens to it
|
||||
// afterwards: unpublished or hidden since, the buyer paid for it.
|
||||
// Auth-gated; returns a bare array (empty when the player has neither).
|
||||
.get(
|
||||
'/api/inventions/v2/mine',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The caller’s own inventions',
|
||||
description:
|
||||
'“My inventions”, newest first — the ones the caller created plus the ones they ' +
|
||||
'bought. Includes unpublished ones, which nobody else can see, and keeps a bought ' +
|
||||
'invention listed even if it has since been unpublished or hidden. Not paginated.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(InventionDto.array(), 'The caller’s inventions'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getMyInventions(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Save an invention's metadata. The data file itself is uploaded separately
|
||||
// through the `storage` worker and referenced here by `inventionDataFilename` —
|
||||
@@ -325,36 +727,75 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
// omitted name/description is defaulted rather than rejected. Auth-gated; returns
|
||||
// the `{ Status, Invention, InventionVersion }` envelope the client expects (the
|
||||
// invention carries its assigned inventionId).
|
||||
.post('/api/inventions/v6/save', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
.post(
|
||||
'/api/inventions/v6/save',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Save a new invention',
|
||||
description:
|
||||
'Records an invention’s metadata. The data file itself is uploaded separately ' +
|
||||
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
|
||||
'one required field, since an invention with no data blob is unusable. An omitted ' +
|
||||
'name/description is defaulted rather than rejected; a supplied one must be 3–24 ' +
|
||||
'characters of letters, digits, spaces, dashes and colons (name) or at most 512 ' +
|
||||
'characters (description).\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(SaveInventionRequest, 'The invention metadata (camelCase)'),
|
||||
responses: {
|
||||
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
|
||||
400: json(
|
||||
ErrorResponse,
|
||||
'Unparseable body, no inventionDataFilename, or an invalid name/description'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
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) return c.json({ error: 'Invalid request body' }, 400)
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||||
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||
|
||||
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
|
||||
if (!inventionDataFilename) {
|
||||
return c.json({ error: 'inventionDataFilename is required' }, 400)
|
||||
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
|
||||
if (!inventionDataFilename) {
|
||||
return c.json({ error: 'inventionDataFilename is required' }, 400)
|
||||
}
|
||||
|
||||
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
|
||||
// "No description yet"), so only a supplied one is held to the rules — otherwise
|
||||
// saving an unnamed invention would fail the 3-character minimum on a name the
|
||||
// player never typed.
|
||||
const name = str(body.name)?.trim()
|
||||
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
|
||||
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
|
||||
|
||||
const description = str(body.description)
|
||||
const descriptionRejection =
|
||||
description === undefined ? null : inventionDescriptionRejection(description)
|
||||
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
|
||||
|
||||
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
|
||||
creatorPlayerId: id,
|
||||
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: Array.isArray(body.referencedInventions)
|
||||
? body.referencedInventions.filter((v): v is number => typeof v === 'number')
|
||||
: undefined,
|
||||
})
|
||||
return c.json(toSaveResult(invention))
|
||||
}
|
||||
|
||||
const invention = await createInvention(c.env.DB, {
|
||||
creatorPlayerId: id,
|
||||
inventionDataFilename,
|
||||
name: str(body.name),
|
||||
description: str(body.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: Array.isArray(body.referencedInventions)
|
||||
? body.referencedInventions.filter((v): v is number => typeof v === 'number')
|
||||
: undefined,
|
||||
})
|
||||
return c.json(toSaveResult(invention))
|
||||
})
|
||||
)
|
||||
|
||||
+120
-37
@@ -1,56 +1,139 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { GAME_VERSION } from '@repo/domain'
|
||||
|
||||
import apiConfigV2 from '../../static/api-config-v2.json'
|
||||
import gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
|
||||
import {
|
||||
AmplitudeConfig,
|
||||
ApiConfigV2,
|
||||
AzureSpeechConfig,
|
||||
BacktraceConfig,
|
||||
json,
|
||||
JsonObject,
|
||||
VersionCheck,
|
||||
} from '../openapi'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
// ---- Config / version ------------------------------------------------------
|
||||
export const configRoutes = new Hono<App>({ strict: false })
|
||||
.get('/api/config/v1/amplitude', (c) =>
|
||||
c.json({
|
||||
AmplitudeKey: 'a',
|
||||
StatSigKey: 'a',
|
||||
RudderStackKey: 'a',
|
||||
UseRudderStack: false,
|
||||
})
|
||||
.get(
|
||||
'/api/config/v1/amplitude',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Analytics keys',
|
||||
description:
|
||||
'The Amplitude / StatSig / RudderStack keys the client initialises its analytics ' +
|
||||
'with. This server collects nothing, so the keys are placeholders and RudderStack ' +
|
||||
'is off — but the client needs the object to finish loading.',
|
||||
responses: { 200: json(AmplitudeConfig, 'Placeholder analytics keys') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
AmplitudeKey: 'a',
|
||||
StatSigKey: 'a',
|
||||
RudderStackKey: 'a',
|
||||
UseRudderStack: false,
|
||||
})
|
||||
)
|
||||
.get('/api/config/v1/azurespeech', (c) =>
|
||||
c.json({
|
||||
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
|
||||
Region: 'eastus',
|
||||
Enabled: false,
|
||||
})
|
||||
.get(
|
||||
'/api/config/v1/azurespeech',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Speech-to-text config',
|
||||
description:
|
||||
'Azure Speech credentials for the client’s voice transcription. `Enabled` is false ' +
|
||||
'here, so the key and region are never used.',
|
||||
responses: { 200: json(AzureSpeechConfig, 'Speech config, disabled') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
|
||||
Region: 'eastus',
|
||||
Enabled: false,
|
||||
})
|
||||
)
|
||||
.get('/api/config/v1/backtrace', (c) =>
|
||||
c.json({
|
||||
ReportBudget: 125,
|
||||
FilterType: 0,
|
||||
SampleRate: 1,
|
||||
LogLineCount: 50,
|
||||
CaptureNativeCrashes: 1,
|
||||
AMRThresholdMS: 0,
|
||||
MessageCount: 1000,
|
||||
MessageRegex:
|
||||
"^.*$",
|
||||
VersionRegex: '.*',
|
||||
})
|
||||
.get(
|
||||
'/api/config/v1/backtrace',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Crash reporter config',
|
||||
description:
|
||||
'Budget, sampling and log-capture settings for the client’s Backtrace crash ' +
|
||||
'reporter. Nothing on this server receives the reports.',
|
||||
responses: { 200: json(BacktraceConfig, 'Crash reporter settings') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
ReportBudget: 125,
|
||||
FilterType: 0,
|
||||
SampleRate: 1,
|
||||
LogLineCount: 50,
|
||||
CaptureNativeCrashes: 1,
|
||||
AMRThresholdMS: 0,
|
||||
MessageCount: 1000,
|
||||
MessageRegex: '^.*$',
|
||||
VersionRegex: '.*',
|
||||
})
|
||||
)
|
||||
// ShareBaseUrl is derived from the deploy-time base domain; the rest of the
|
||||
// config is static.
|
||||
.get('/api/config/v2', (c) =>
|
||||
c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` })
|
||||
.get(
|
||||
'/api/config/v2',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'The main client config blob',
|
||||
description:
|
||||
'The large feature-switch / endpoint config the client reads at startup. Served ' +
|
||||
'from a static asset, except `ShareBaseUrl`, which is templated from the ' +
|
||||
'deploy-time base domain so share links point at this deployment.',
|
||||
responses: { 200: json(ApiConfigV2, 'The client config') },
|
||||
}),
|
||||
(c) => c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` })
|
||||
)
|
||||
.get('/api/versioncheck/v4', (c) =>
|
||||
c.json({
|
||||
VersionStatus: 0,
|
||||
UpdateNotificationStage: 0,
|
||||
IsVersionIslanded: false,
|
||||
IsCrossPlayDisabled: false,
|
||||
})
|
||||
.get(
|
||||
'/api/versioncheck/v4',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Client version check',
|
||||
description:
|
||||
'Whether the client build is current. Compares the client’s `?v=` build against ' +
|
||||
'our target `GAME_VERSION`: `VersionStatus` is 0 when they match, 1 when the ' +
|
||||
'client is on a different build.',
|
||||
responses: { 200: json(VersionCheck, 'Version status') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
VersionStatus: c.req.query('v') === GAME_VERSION ? 0 : 1,
|
||||
UpdateNotificationStage: 0,
|
||||
IsVersionIslanded: false,
|
||||
IsCrossPlayDisabled: false,
|
||||
})
|
||||
)
|
||||
.get(
|
||||
'/api/gameconfigs/v1/all',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Per-game configuration',
|
||||
description: 'An opaque static catalog of per-game settings, served verbatim.',
|
||||
responses: { 200: json(JsonObject, 'The game config catalog') },
|
||||
}),
|
||||
(c) => c.json(gameConfigsV1All)
|
||||
)
|
||||
.get('/api/gameconfigs/v1/all', (c) => c.json(gameConfigsV1All))
|
||||
|
||||
// Voice chat config. The client fetches it to set up voice.
|
||||
// No reference shape, so return an empty object until the client needs fields.
|
||||
.get('/voice/config', (c) => c.json({}))
|
||||
.get(
|
||||
'/voice/config',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Voice chat config',
|
||||
description:
|
||||
'Fetched by the client while setting up voice. We have no reference shape for it, ' +
|
||||
'so it stays an empty object until the client is observed needing a field.',
|
||||
responses: { 200: json(JsonObject, 'An empty object') },
|
||||
}),
|
||||
(c) => c.json({})
|
||||
)
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
||||
// as a value — the enum has no runtime dependencies.
|
||||
import { NotificationType } from '../../../notify/src/notification-types'
|
||||
import {
|
||||
createEvent,
|
||||
getEventById,
|
||||
getEventsByClubs,
|
||||
getEventsByCreator,
|
||||
getEventsByIds,
|
||||
getLiveEvents,
|
||||
isEventResponseType,
|
||||
eventInputRejection,
|
||||
parseEventBody,
|
||||
searchEvents,
|
||||
setEventResponse,
|
||||
toEventNotification,
|
||||
toEventResult,
|
||||
updateEvent,
|
||||
} from '../events-db'
|
||||
import { authedId, queryIds, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
idParam,
|
||||
intQuery,
|
||||
json,
|
||||
jsonBody,
|
||||
pageParams,
|
||||
PlayerEventDto,
|
||||
PlayerEventRequest,
|
||||
PlayerEventRespondRequest,
|
||||
PlayerEventResultDto,
|
||||
PlayerEventsAll,
|
||||
PlayerEventsPage,
|
||||
stringQuery,
|
||||
TagFilters,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
import type { PlayerEvent } from '../events-db'
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/**
|
||||
* Push a `PlayerEventCreated` notification for a freshly scheduled event to its
|
||||
* creator — what makes the event appear on their own screen without a refetch.
|
||||
*
|
||||
* Hub failures are logged and swallowed: the event is already stored, so a hub hiccup
|
||||
* must not fail the create. Note the frame carries the camelCase
|
||||
* {@link toEventNotification} projection, not the PascalCase record the response does.
|
||||
*/
|
||||
async function notifyEventCreated(c: Context<App>, event: PlayerEvent): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
event.CreatorPlayerId,
|
||||
NotificationType.PlayerEventCreated,
|
||||
{ ...toEventNotification(event) }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push PlayerEventCreated notification', {
|
||||
playerEventId: event.PlayerEventId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Player events — scheduled events players and clubs host in a room.
|
||||
*
|
||||
* D1-backed (the `event` table, owned by this worker; see events-db.ts). The stored
|
||||
* blob IS the DTO, so every read here serves it verbatim; only the create/update
|
||||
* writes wrap it, in the `{ Result, TagModifyResult, PlayerEvent }` envelope.
|
||||
*
|
||||
* Watch the response shapes: the two club feeds deliberately differ (bare array for
|
||||
* the multi-club form, paged envelope for the single-club one) and the client chokes
|
||||
* if they're unified.
|
||||
*/
|
||||
export const eventRoutes = new Hono<App>({ strict: false })
|
||||
.get(
|
||||
'/api/playerevents/v1/all',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'The caller’s player events',
|
||||
description:
|
||||
'Events the player created and events they have RSVP’d to. `Created` is served ' +
|
||||
'from the event table, soonest first.\n\n' +
|
||||
'`Responses` is still always empty. RSVPs ARE stored now (see ' +
|
||||
'`/api/playerevents/v1/respond` and the `event_attendee` table) — what isn’t known ' +
|
||||
'is the shape this field wants: whether an entry is a bare event like `Created`, ' +
|
||||
'or the event plus the answer, which is the useful thing to render. Serving the ' +
|
||||
'wrong one renders nothing rather than erroring, so it stays empty until a real ' +
|
||||
'response is observed.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(PlayerEventsAll, 'The caller’s created events, and an empty RSVP list'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({ Created: await getEventsByCreator(c.env.DB, id), Responses: [] })
|
||||
}
|
||||
)
|
||||
|
||||
// The tag filter chips on the player-events browse screen. Static: these are the
|
||||
// categories the client offers when creating an event, so the list doesn't depend on
|
||||
// what's stored. `TrendingFilters` is null even in the reference — it needs
|
||||
// recent-activity data we don't keep, and the client renders no trending row for null.
|
||||
.get(
|
||||
'/api/playerevents/v1/tagfilters',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Player-event filter chips',
|
||||
description:
|
||||
'The filter chips on the player-events browse screen — the event categories the ' +
|
||||
'client offers. Static: the same set regardless of what is stored. ' +
|
||||
'`TrendingFilters` is null even in the reference (it needs recent-activity data), ' +
|
||||
'and the client renders no trending row for null.',
|
||||
security: AUTHED,
|
||||
responses: { 200: json(TagFilters, 'The filter chips'), 401: UNAUTHORIZED_RESPONSE },
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({
|
||||
PinnedFilters: [
|
||||
'workshops',
|
||||
'celebration',
|
||||
'game',
|
||||
'meetup',
|
||||
'performance',
|
||||
'coop',
|
||||
'grandopening',
|
||||
'class',
|
||||
'competition',
|
||||
],
|
||||
PopularFilters: [
|
||||
'workshops',
|
||||
'celebration',
|
||||
'class',
|
||||
'coop',
|
||||
'competition',
|
||||
'game',
|
||||
'grandopening',
|
||||
'meetup',
|
||||
'performance',
|
||||
],
|
||||
TrendingFilters: null,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
|
||||
// page. A bare array: the client deserializes this one as a list, and chokes on the
|
||||
// `{ ContinuationToken, Events }` envelope the single-club form uses.
|
||||
.get(
|
||||
'/api/playerevents/v1/clubs',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Player events across several clubs',
|
||||
description:
|
||||
'The events shelf for a set of clubs (`?id=1&id=2`), soonest first. This form ' +
|
||||
'returns a BARE ARRAY — the client deserializes it as a list and chokes on the ' +
|
||||
'paged envelope the single-club form below uses. Do not unify the two. No ids ' +
|
||||
'means an empty shelf, not every event.',
|
||||
parameters: [intQuery('id', 'Repeatable club id')],
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The clubs’ events') },
|
||||
}),
|
||||
async (c) => c.json(await getEventsByClubs(c.env.DB, queryIds(c)))
|
||||
)
|
||||
|
||||
// The same feed for a single club (`/club/1`) — the form the reference serves,
|
||||
// which *does* wrap the events with a paging cursor (empty = no next page).
|
||||
.get(
|
||||
'/api/playerevents/v1/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Player events for one club',
|
||||
description:
|
||||
'The same feed for a single club — and this form DOES wrap the events with a ' +
|
||||
'paging cursor, matching the reference. The cursor is always empty: a club’s event ' +
|
||||
'list is small enough to serve in one page.',
|
||||
parameters: [idParam('clubId', 'Club id')],
|
||||
responses: { 200: json(PlayerEventsPage, 'The club’s events, in a single page') },
|
||||
}),
|
||||
async (c) => {
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const events = await getEventsByClubs(c.env.DB, [clubId])
|
||||
return c.json({ ContinuationToken: '', Events: events })
|
||||
}
|
||||
)
|
||||
|
||||
// Live player-event search (the "happening now" browse query) — events that have
|
||||
// started and not yet finished. A bare array, like the multi-club feed.
|
||||
.get(
|
||||
'/api/playerevents/v1/searchlive',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Live player events',
|
||||
description:
|
||||
'The "happening now" row on the player-events browse screen: events that have ' +
|
||||
'started and not yet ended, soonest first. A bare array.',
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The events running right now') },
|
||||
}),
|
||||
async (c) => c.json(await getLiveEvents(c.env.DB))
|
||||
)
|
||||
|
||||
// Event search — the browse query. Text is matched term by term against name and
|
||||
// description; finished events are left out (this backs a browse screen).
|
||||
.get(
|
||||
'/api/playerevents/v1/search',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Search player events',
|
||||
description:
|
||||
'The browse query on the player-events screen. `query` is matched ' +
|
||||
'case-insensitively against the event name and description, term by term; an empty ' +
|
||||
'query browses everything upcoming. Events that have already finished are left ' +
|
||||
'out — a name match on something that ended last month is noise on a browse ' +
|
||||
'screen. Soonest first, paginated via skip/take. A bare array.',
|
||||
parameters: [
|
||||
stringQuery('query', 'Search text; every term must match the name or description'),
|
||||
...pageParams(50),
|
||||
],
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The matching events') },
|
||||
}),
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
|
||||
return c.json(await searchEvents(c.env.DB, c.req.query('query') ?? '', skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// Bulk fetch (`?id=1&id=2`) — the events behind a list of ids the client already
|
||||
// holds. Answers in the order asked for; ids with no event are skipped.
|
||||
.get(
|
||||
'/api/playerevents/v1/bulk',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Several player events by id',
|
||||
description:
|
||||
'The events behind a list of ids the client already holds (`?id=1&id=2`). Answers ' +
|
||||
'in the order the ids were asked for — the client renders them in request order — ' +
|
||||
'and skips ids with no event rather than leaving a hole, so the result may be ' +
|
||||
'shorter than the request. A bare array.',
|
||||
parameters: [intQuery('id', 'Repeatable event id')],
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The events that exist, in request order') },
|
||||
}),
|
||||
async (c) => c.json(await getEventsByIds(c.env.DB, queryIds(c)))
|
||||
)
|
||||
|
||||
// RSVP. One row per player per event, so responding again replaces the previous
|
||||
// answer rather than stacking up. Note this is the v1 path while create/update are
|
||||
// v2 — that's how the client calls them.
|
||||
.post(
|
||||
'/api/playerevents/v1/respond',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Answer a player event',
|
||||
description:
|
||||
'Records how the caller is answering an event — `Type` is 0 Going, 1 Interested, ' +
|
||||
'2 Can’t go. Responding again replaces the previous answer; there is one row per ' +
|
||||
'player per event, and a decline is recorded rather than deleted so the client can ' +
|
||||
'show a player what they said.\n\n' +
|
||||
'Only Going counts toward the event’s `AttendeeCount`, which is recomputed from ' +
|
||||
'the RSVP table on every response. Anyone may respond, the creator included — ' +
|
||||
'they are already Going from create, and nothing stops them declining their own ' +
|
||||
'event. Answers the same `{ Result, TagModifyResult, PlayerEvent }` envelope the ' +
|
||||
'v2 writes do, carrying the event with its updated count, so the client can ' +
|
||||
're-render from the response.\n\n' +
|
||||
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400; an ' +
|
||||
'unknown event is a 404.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'),
|
||||
400: { description: 'Missing `PlayerEventId` or an unknown `Type` (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: { description: 'No such event (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req
|
||||
.json<{ PlayerEventId?: unknown; Type?: unknown }>()
|
||||
.catch(() => ({}) as { PlayerEventId?: unknown; Type?: unknown })
|
||||
const eventId = Number(body.PlayerEventId)
|
||||
const type = Number(body.Type)
|
||||
// Both are rejected rather than defaulted: an unrecognized answer stored as
|
||||
// Going would silently inflate the count.
|
||||
if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400)
|
||||
|
||||
const updated = await setEventResponse(c.env.DB, eventId, id, type)
|
||||
return updated === null ? c.body(null, 404) : c.json(toEventResult(updated))
|
||||
}
|
||||
)
|
||||
|
||||
// Create. The creator comes from the bearer token, never the body — posting someone
|
||||
// else's `CreatorPlayerId` doesn't make it theirs.
|
||||
.post(
|
||||
'/api/playerevents/v2',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Create a player event',
|
||||
description:
|
||||
'Schedules a new event. The creator is taken from the bearer token, never the ' +
|
||||
'body; the id is assigned here. Lenient about the rest, like the other writes ' +
|
||||
'here — a missing name becomes “Untitled Event” and a missing time window becomes ' +
|
||||
'an hour from now, rather than an error the client can’t render.\n\n' +
|
||||
'`State` starts at 0, and the creator is recorded as Going in the RSVP table — ' +
|
||||
'which is what makes `AttendeeCount` start at 1, since that count is derived from ' +
|
||||
'the table. Answers the `{ Result, TagModifyResult, PlayerEvent }` envelope — NOT ' +
|
||||
'the bare event the read endpoints serve.\n\n' +
|
||||
'Also pushes a `PlayerEventCreated` (80) hub notification to the creator, carrying ' +
|
||||
'the event in its camelCase notification projection. A hub failure is logged and ' +
|
||||
'swallowed — the event is already stored by then.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PlayerEventRequest, 'The event to schedule'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The created event'),
|
||||
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = await c.req.json<unknown>().catch(() => ({}))
|
||||
const input = parseEventBody(body)
|
||||
// The one thing this route isn't lenient about. Everything else here defaults a
|
||||
// missing or unusable field, but a name or description past the stored length
|
||||
// can't be defaulted into something sensible — and truncating a player's event
|
||||
// description silently is worse than refusing it.
|
||||
if (eventInputRejection(input) !== null) return c.body(null, 400)
|
||||
const event = await createEvent(c.env.DB, id, input)
|
||||
await notifyEventCreated(c, event)
|
||||
return c.json(toEventResult(event))
|
||||
}
|
||||
)
|
||||
|
||||
// Update. Creator-only, and a partial body only changes what it carries.
|
||||
.post(
|
||||
'/api/playerevents/v2/:eventId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Update a player event',
|
||||
description:
|
||||
'Edits an event the caller created. Only the fields the body carries change; ' +
|
||||
'everything else keeps its stored value, so a partial post can’t blank out the ' +
|
||||
'rest of the event. A posted `null` on `ImageName` / `SubRoomId` / `ClubId` does ' +
|
||||
'clear it.\n\n' +
|
||||
'The id, the creator and the attendee count are not editable: ownership doesn’t ' +
|
||||
'transfer and RSVPs aren’t set by hand. Creator only — anyone else gets 403, and ' +
|
||||
'an unknown event is 404. Answers the same envelope as create.',
|
||||
security: AUTHED,
|
||||
parameters: [idParam('eventId', 'Event id')],
|
||||
requestBody: jsonBody(PlayerEventRequest, 'The fields to change'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The updated event'),
|
||||
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'Not the event’s creator (empty body)' },
|
||||
404: { description: 'No such event (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const eventId = Number.parseInt(c.req.param('eventId'), 10)
|
||||
const existing = await getEventById(c.env.DB, eventId)
|
||||
if (existing === null) return c.body(null, 404)
|
||||
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
|
||||
|
||||
const body = await c.req.json<unknown>().catch(() => ({}))
|
||||
const input = parseEventBody(body)
|
||||
if (eventInputRejection(input) !== null) return c.body(null, 400)
|
||||
const updated = await updateEvent(c.env.DB, eventId, input)
|
||||
// updateEvent only returns null when the row vanished, which the read above rules out.
|
||||
return c.json(toEventResult(updated!))
|
||||
}
|
||||
)
|
||||
|
||||
// A single event. Registered last so the literal `/bulk` and `/search` paths above
|
||||
// are matched first; the `[0-9]+` constraint keeps them apart regardless.
|
||||
.get(
|
||||
'/api/playerevents/v1/:eventId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'One player event',
|
||||
description:
|
||||
'A single event by id, served as the bare record — no envelope, unlike the ' +
|
||||
'create/update writes. 404 when there is no such event.',
|
||||
parameters: [idParam('eventId', 'Event id')],
|
||||
responses: {
|
||||
200: json(PlayerEventDto, 'The event'),
|
||||
404: { description: 'No such event (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const event = await getEventById(c.env.DB, Number.parseInt(c.req.param('eventId'), 10))
|
||||
return event === null ? c.body(null, 404) : c.json(event)
|
||||
}
|
||||
)
|
||||
+144
-33
@@ -1,4 +1,21 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import charadesWords from '../../static/charades.json'
|
||||
import communityBoard from '../../static/community-board.json'
|
||||
import {
|
||||
BareString,
|
||||
idParam,
|
||||
IsPureResponse,
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
JsonObject,
|
||||
KeepsakeConfig,
|
||||
SanitizeRequest,
|
||||
stringParam,
|
||||
SubscriptionResponse,
|
||||
} from '../openapi'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
@@ -7,49 +24,143 @@ import type { App } from '../context'
|
||||
export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||
// Text sanitization (display names, room names, chat). `v1` echoes the input
|
||||
// value back; `isPure` reports the text is clean.
|
||||
.post('/api/sanitize/v1', async (c) => {
|
||||
const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown })
|
||||
return c.json(typeof body.Value === 'string' ? body.Value : '')
|
||||
})
|
||||
.post('/api/sanitize/v1/isPure', (c) => c.json({ IsPure: true }))
|
||||
.post(
|
||||
'/api/sanitize/v1',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Sanitize a string',
|
||||
description:
|
||||
'Runs display names, room names and chat through the profanity filter. There is ' +
|
||||
'no filter here — the input `Value` is echoed back verbatim as a bare JSON string ' +
|
||||
'(an empty string if the body has no `Value`).',
|
||||
requestBody: jsonBody(SanitizeRequest, 'The text to clean'),
|
||||
responses: { 200: json(BareString, 'The input text, unchanged (a bare JSON string)') },
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown })
|
||||
return c.json(typeof body.Value === 'string' ? body.Value : '')
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/api/sanitize/v1/isPure',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Whether a string is clean',
|
||||
description: 'The yes/no form of the filter. Always `true` — nothing is filtered here.',
|
||||
requestBody: jsonBody(SanitizeRequest, 'The text to check'),
|
||||
responses: { 200: json(IsPureResponse, 'Always pure') },
|
||||
}),
|
||||
(c) => c.json({ IsPure: true })
|
||||
)
|
||||
|
||||
// ---- Activities -----------------------------------------------------------
|
||||
// Word bank for the Charades activity. The client requests the list by
|
||||
// activity name (`.../words/Charades`); other activities have no data yet.
|
||||
.get(
|
||||
'/api/activities/charades/v1/words/:activity',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'An activity’s word bank',
|
||||
description:
|
||||
'The words the Charades activity draws from. The client asks by activity name ' +
|
||||
'(`.../words/Charades`); the name is not matched on, so every activity gets the ' +
|
||||
'charades list — no other activity has data yet.',
|
||||
parameters: [stringParam('activity', 'Activity name, e.g. `Charades`. Not matched on.')],
|
||||
responses: { 200: json(JsonArray, 'The word list') },
|
||||
}),
|
||||
(c) => c.json(charadesWords)
|
||||
)
|
||||
|
||||
// Keepsakes (room mementos). Stubbed empty.
|
||||
.get('/api/keepsakes/globalconfig', (c) =>
|
||||
c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false })
|
||||
.get(
|
||||
'/api/keepsakes/globalconfig',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Keepsake feature switches',
|
||||
description:
|
||||
'Whether keepsakes (room mementos) are on and how many a room may hold. The ' +
|
||||
'feature reports as enabled, but nothing stores keepsakes yet.',
|
||||
responses: { 200: json(KeepsakeConfig, 'The keepsake config') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false })
|
||||
)
|
||||
.get(
|
||||
'/api/keepsakes/rooms/:roomId',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'A room’s keepsakes',
|
||||
description:
|
||||
'No keepsake storage yet. Answers 204 with no body rather than an empty list — ' +
|
||||
'that is what the reference does, and the client treats a body here as data.',
|
||||
parameters: [idParam('roomId', 'Room id')],
|
||||
responses: { 204: { description: 'No keepsakes (empty body)' } },
|
||||
}),
|
||||
(c) => c.body(null, 204)
|
||||
)
|
||||
.get(
|
||||
'/api/keepsakes/categories',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Keepsake categories',
|
||||
description: 'No keepsake catalog yet, so this is an empty list.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get('/api/keepsakes/rooms/:roomId', (c) => c.body(null, 204))
|
||||
.get('/api/keepsakes/categories', (c) => c.json([]))
|
||||
|
||||
// ---- Objectives / events / rewards ---------------------------------------
|
||||
// Objectives live on the `econ` host (`updateobjective` / `myprogress`), which is
|
||||
// where the client calls them — they are not served here.
|
||||
.get('/api/communityboard/v2/current', (c) => c.json({})) // TODO: hydrate from JSON/communityboard.json
|
||||
.get('/api/playerevents/v1/all', (c) => c.json({ Created: [], Responses: [] }))
|
||||
|
||||
// The tag filter chips on the player-events browse screen. Derived from the tags in
|
||||
// use across events — we store no events, so there are no chips to offer.
|
||||
// `TrendingFilters` is null even in the reference (it needs recent-activity data).
|
||||
.get('/api/playerevents/v1/tagfilters', (c) =>
|
||||
c.json({ PinnedFilters: [], PopularFilters: [], TrendingFilters: null })
|
||||
.get(
|
||||
'/api/communityboard/v2/current',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'The current community board',
|
||||
description:
|
||||
'The rotating community board on the home screen — featured player, featured room ' +
|
||||
'group, announcement and image strips. Served verbatim from a static blob.',
|
||||
responses: { 200: json(JsonObject, 'The community board') },
|
||||
}),
|
||||
(c) => c.json(communityBoard)
|
||||
)
|
||||
|
||||
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
|
||||
// page. A bare array: the client deserializes this one as a list, and chokes on the
|
||||
// `{ ContinuationToken, Events }` envelope the single-club form uses. No
|
||||
// player-event storage yet, so the feed is empty.
|
||||
.get('/api/playerevents/v1/clubs', (c) => c.json([]))
|
||||
|
||||
// The same feed for a single club (`/club/1`) — the form the reference serves,
|
||||
// which *does* wrap the events with a paging cursor (empty = no next page).
|
||||
.get('/api/playerevents/v1/club/:clubId{[0-9]+}', (c) =>
|
||||
c.json({ ContinuationToken: '', Events: [] })
|
||||
)
|
||||
.get('/api/announcement/v1/get', (c) => c.json([])) // TODO: hydrate from JSON/announcements.json
|
||||
// Player events live in their own controller (routes/events.ts) — they're D1-backed
|
||||
// now, unlike the stubs around them here.
|
||||
.get(
|
||||
'/api/announcement/v1/get',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Announcements',
|
||||
description: 'The announcement banners on the home screen. Not hydrated yet.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
) // TODO: hydrate from JSON/announcements.json
|
||||
|
||||
// GameSight attribution/analytics event sink. Accept and ack without persisting.
|
||||
.post('/api/gamesight/event', (c) => c.body(null, 200))
|
||||
.post(
|
||||
'/api/gamesight/event',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Analytics event sink',
|
||||
description:
|
||||
'The client’s GameSight attribution/analytics events. Accepted and dropped — ' +
|
||||
'nothing is persisted. Answers 200 with an empty body.',
|
||||
responses: { 200: { description: 'Accepted (empty body)' } },
|
||||
}),
|
||||
(c) => c.body(null, 200)
|
||||
)
|
||||
|
||||
// ---- Subscription ---------------------------------------------------------
|
||||
.post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) =>
|
||||
c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||||
.post(
|
||||
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'The caller’s subscription',
|
||||
description:
|
||||
'Rec Room Plus subscription state. There are no subscriptions on this server, so ' +
|
||||
'both fields are null. Also served by the `econ` worker on its own host.',
|
||||
responses: { 200: json(SubscriptionResponse, 'No subscription') },
|
||||
}),
|
||||
(c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||||
)
|
||||
|
||||
+393
-131
@@ -1,27 +1,47 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
createImage,
|
||||
deleteImage,
|
||||
getCheeredImageIds,
|
||||
getImageByName,
|
||||
getImagesByPlayer,
|
||||
getImagesByRoom,
|
||||
getPlayerFeed,
|
||||
getSlideshowImages,
|
||||
SavedImageType,
|
||||
setImageCheer,
|
||||
SLIDESHOW_LIMIT,
|
||||
SLIDESHOW_MAX_LIMIT,
|
||||
toImagesPlayer,
|
||||
} from '../images-db'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
CheeredEntry,
|
||||
CheerImageRequest,
|
||||
DeleteImageRequest,
|
||||
ErrorResponse,
|
||||
form,
|
||||
idParam,
|
||||
ImagesPlayerDto,
|
||||
intQuery,
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
pageParams,
|
||||
SavedImageDto,
|
||||
SlideshowResponse,
|
||||
stringQuery,
|
||||
SuccessResponse,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UploadImageRequest,
|
||||
UploadImageResponse,
|
||||
} from '../openapi'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
/** Saved-image categories from the C# `SavedImageType` enum (`imgMeta.savedImageType`). */
|
||||
const SavedImageType = {
|
||||
None: 0,
|
||||
ShareCamera: 1,
|
||||
OutfitThumbnail: 2,
|
||||
RoomThumbnail: 3,
|
||||
ProfileThumbnail: 4,
|
||||
InventionThumbnail: 5,
|
||||
} as const
|
||||
|
||||
/** Bucket folder each SavedImageType is stored under; unknown types fall back to `none`. */
|
||||
const typeFolder: Record<number, string> = {
|
||||
[SavedImageType.None]: 'none',
|
||||
@@ -34,147 +54,389 @@ const typeFolder: Record<number, string> = {
|
||||
|
||||
// ---- Images ----------------------------------------------------------------
|
||||
export const imageRoutes = new Hono<App>({ strict: false })
|
||||
.get('/api/images/v2/named', (c) => c.json([])) // TODO: hydrate from JSON/namedimages.json
|
||||
.post('/api/images/v4/uploadsaved', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
.get(
|
||||
'/api/images/v2/named',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Named images',
|
||||
description:
|
||||
'The named-image catalog (UI art the client looks up by name). Not hydrated yet.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
) // TODO: hydrate from JSON/namedimages.json
|
||||
.post(
|
||||
'/api/images/v4/uploadsaved',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Upload a saved image',
|
||||
description:
|
||||
'Stores a photo in the shared image bucket under a random key, foldered by image ' +
|
||||
'type and upload date (e.g. `sharecamera/2026-06-15/…`) so the bucket stays ' +
|
||||
'browsable. The returned `ImageName` is that key — the `img` worker serves the ' +
|
||||
'object back by it, slashes and all.\n\n' +
|
||||
'The `imgMeta` multipart field is a JSON `SavedImageMetaDTO` describing the upload; ' +
|
||||
'malformed JSON is tolerated and the image is still stored, just untyped. A ' +
|
||||
'`savedImageType` of 4 (profile thumbnail) additionally becomes the account’s ' +
|
||||
'avatar, persisted on the account row.',
|
||||
security: AUTHED,
|
||||
requestBody: form(UploadImageRequest, 'The image file plus its metadata'),
|
||||
responses: {
|
||||
200: json(UploadImageResponse, 'The stored bucket key'),
|
||||
400: json(ErrorResponse, 'No file in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
// The client posts the file as `image`; accept `file` too for safety.
|
||||
const candidate = body.image ?? body.file
|
||||
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
|
||||
const file = candidate
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
// The client posts the file as `image`; accept `file` too for safety.
|
||||
const candidate = body.image ?? body.file
|
||||
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
|
||||
const file = candidate
|
||||
|
||||
// `imgMeta` is a JSON blob describing the upload (the C# `SavedImageMetaDTO`),
|
||||
// posted as a multipart field. It carries the metadata we record on the image
|
||||
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
|
||||
let meta: Record<string, unknown> = {}
|
||||
if (typeof body.imgMeta === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(body.imgMeta)
|
||||
if (parsed && typeof parsed === 'object') meta = parsed as Record<string, unknown>
|
||||
} catch {
|
||||
// Malformed imgMeta — treat as an untyped upload (still stored).
|
||||
// `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`),
|
||||
// posted as a multipart field. It carries the metadata we record on the image
|
||||
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
|
||||
let meta: Record<string, unknown> = {}
|
||||
if (typeof body.imgMeta === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(body.imgMeta)
|
||||
if (parsed && typeof parsed === 'object') meta = parsed as Record<string, unknown>
|
||||
} catch {
|
||||
// Malformed imgMeta — treat as an untyped upload (still stored).
|
||||
}
|
||||
}
|
||||
// imgMeta shape: {playerIds, savedImageType, roomId, playerEventId, accessibility}.
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||
const savedImageType = num(meta.savedImageType) ?? SavedImageType.None
|
||||
// roomId / playerEventId use 0 or -1 as "none" — store null in that case.
|
||||
const roomId = num(meta.roomId)
|
||||
const playerEventId = num(meta.playerEventId)
|
||||
|
||||
const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
|
||||
const dot = file.name.lastIndexOf('.')
|
||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''
|
||||
const extension = valid.includes(ext) ? ext : '.jpg'
|
||||
|
||||
// Store the upload in the shared image bucket under a random key, foldered by
|
||||
// the image type and then the upload date (e.g. `sharecamera/2026-06-15/`) so
|
||||
// the bucket stays browsable over time. The `img` worker serves it back by that
|
||||
// key (slashes and all), which is the returned ImageName.
|
||||
const typePrefix = (typeFolder[savedImageType] ?? typeFolder[SavedImageType.None]) + '/'
|
||||
const datePrefix = new Date().toISOString().slice(0, 10) + '/'
|
||||
const name = typePrefix + datePrefix + crypto.randomUUID() + extension
|
||||
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
|
||||
httpMetadata: { contentType: file.type || 'image/jpeg' },
|
||||
})
|
||||
|
||||
// A profile thumbnail becomes the account's avatar — persist it on the
|
||||
// account row (a JSON blob in the shared accounts table) so it sticks.
|
||||
if (savedImageType === SavedImageType.ProfileThumbnail) {
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE account SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1"
|
||||
)
|
||||
.bind(id, name)
|
||||
.run()
|
||||
}
|
||||
|
||||
// Record the image metadata (the `image` table the img worker owns), pulling
|
||||
// the fields the client provided in imgMeta.
|
||||
await createImage(c.env.DB, {
|
||||
imageName: name,
|
||||
playerId: id,
|
||||
type: savedImageType,
|
||||
accessibility: num(meta.accessibility),
|
||||
roomId: roomId !== undefined && roomId > 0 ? roomId : null,
|
||||
description: typeof meta.description === 'string' ? meta.description : null,
|
||||
taggedPlayerIds: Array.isArray(meta.playerIds)
|
||||
? meta.playerIds.filter((v): v is number => typeof v === 'number')
|
||||
: undefined,
|
||||
playerEventId: playerEventId !== undefined && playerEventId > 0 ? playerEventId : null,
|
||||
})
|
||||
|
||||
return c.json({ ImageName: name })
|
||||
}
|
||||
// imgMeta shape: {playerIds, savedImageType, roomId, playerEventId, accessibility}.
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||
const savedImageType = num(meta.savedImageType) ?? SavedImageType.None
|
||||
// roomId / playerEventId use 0 or -1 as "none" — store null in that case.
|
||||
const roomId = num(meta.roomId)
|
||||
const playerEventId = num(meta.playerEventId)
|
||||
)
|
||||
|
||||
const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
|
||||
const dot = file.name.lastIndexOf('.')
|
||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''
|
||||
const extension = valid.includes(ext) ? ext : '.jpg'
|
||||
// Delete one of the caller's saved images ({ ImageName }). Auth-gated. Looks the
|
||||
// image up by name, refuses unless the caller took it (PlayerId), then removes the
|
||||
// metadata row (and its cheers) and the object from R2. 404 for an unknown image,
|
||||
// 403 for someone else's.
|
||||
.delete(
|
||||
'/api/images/v1/deletesaved',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Delete one of the caller’s photos',
|
||||
description:
|
||||
'Looks the image up by name and refuses unless the caller took it, then removes ' +
|
||||
'the metadata row (and its cheers) and the object from the bucket. The metadata ' +
|
||||
'goes first; the R2 delete is idempotent, so a missing object is fine.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(DeleteImageRequest, 'The image to delete'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Deleted'),
|
||||
400: json(ErrorResponse, 'No ImageName given'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s image'),
|
||||
404: { description: 'No image by that name' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
// Store the upload in the shared image bucket under a random key, foldered by
|
||||
// the image type and then the upload date (e.g. `sharecamera/2026-06-15/`) so
|
||||
// the bucket stays browsable over time. The `img` worker serves it back by that
|
||||
// key (slashes and all), which is the returned ImageName.
|
||||
const typePrefix = (typeFolder[savedImageType] ?? typeFolder[SavedImageType.None]) + '/'
|
||||
const datePrefix = new Date().toISOString().slice(0, 10) + '/'
|
||||
const name = typePrefix + datePrefix + crypto.randomUUID() + extension
|
||||
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
|
||||
httpMetadata: { contentType: file.type || 'image/jpeg' },
|
||||
})
|
||||
const body = (await c.req.json().catch(() => null)) as { ImageName?: unknown } | null
|
||||
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
|
||||
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
|
||||
|
||||
// A profile thumbnail becomes the account's avatar — persist it on the
|
||||
// account row (a JSON blob in the shared accounts table) so it sticks.
|
||||
if (savedImageType === SavedImageType.ProfileThumbnail) {
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE account SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1"
|
||||
)
|
||||
.bind(id, name)
|
||||
.run()
|
||||
const image = await getImageByName(c.env.DB, imageName)
|
||||
if (!image) return c.notFound()
|
||||
if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403)
|
||||
|
||||
// Drop the metadata (and cheers) first, then the object. An R2 delete is
|
||||
// idempotent, so a missing object is fine.
|
||||
await deleteImage(c.env.DB, image)
|
||||
await c.env.IMAGES.delete(imageName)
|
||||
|
||||
return c.json({ success: true })
|
||||
}
|
||||
|
||||
// Record the image metadata (the `image` table the img worker owns), pulling
|
||||
// the fields the client provided in imgMeta.
|
||||
await createImage(c.env.DB, {
|
||||
imageName: name,
|
||||
playerId: id,
|
||||
type: savedImageType,
|
||||
accessibility: num(meta.accessibility),
|
||||
roomId: roomId !== undefined && roomId > 0 ? roomId : null,
|
||||
description: typeof meta.description === 'string' ? meta.description : null,
|
||||
taggedPlayerIds: Array.isArray(meta.playerIds)
|
||||
? meta.playerIds.filter((v): v is number => typeof v === 'number')
|
||||
: undefined,
|
||||
playerEventId: playerEventId !== undefined && playerEventId > 0 ? playerEventId : null,
|
||||
})
|
||||
|
||||
return c.json({ ImageName: name })
|
||||
})
|
||||
)
|
||||
|
||||
// A room's photo feed — the public images taken in that room. `sort` orders the
|
||||
// feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType
|
||||
// (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
.get('/api/images/v4/room/:roomId{[0-9]+}', async (c) => {
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||
const filter = Number.parseInt(c.req.query('filter') ?? '0', 10) || 0
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take))
|
||||
})
|
||||
.get(
|
||||
'/api/images/v4/room/:roomId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'A room’s photo feed',
|
||||
description:
|
||||
'The public images taken in that room.\n\n' +
|
||||
'This feed serves the RAW `SavedImage` record — unlike the player photo lists ' +
|
||||
'below, which must serve the `ImagesPlayer` projection. The inconsistency is real ' +
|
||||
'and load-bearing: both render correctly as they are, and unifying them breaks one ' +
|
||||
'of them.',
|
||||
parameters: [
|
||||
idParam('roomId', 'Room id'),
|
||||
intQuery('sort', '1 = most cheered; anything else = newest first'),
|
||||
intQuery('filter', 'Narrow by SavedImageType; 0 = all'),
|
||||
...pageParams(100),
|
||||
],
|
||||
responses: { 200: json(SavedImageDto.array(), 'The room’s photos') },
|
||||
}),
|
||||
async (c) => {
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||
const filter = Number.parseInt(c.req.query('filter') ?? '0', 10) || 0
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// A player's photos — the public images that player has taken, newest first.
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
.get('/api/images/v4/player/:playerId{[0-9]+}', async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getImagesByPlayer(c.env.DB, playerId, 0, skip, take))
|
||||
})
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array of the
|
||||
// client's ImagesPlayer projection (SavedImageId/SavedImageType, not Id/Type).
|
||||
.get(
|
||||
'/api/images/v4/player/:playerId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'A player’s photos',
|
||||
description:
|
||||
'The public images that player has taken, newest first. Serves the client’s ' +
|
||||
'`ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) ' +
|
||||
'— the raw `SavedImage` renders blank thumbnails here.',
|
||||
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
|
||||
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s photos') },
|
||||
}),
|
||||
async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
}
|
||||
)
|
||||
|
||||
// A player's photos with a sort option. `sort` orders the list (1 = most
|
||||
// cheered, else newest). Paginated via skip/take (take defaults to 100). Bare array.
|
||||
.get('/api/images/v5/player/:playerId{[0-9]+}', async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getImagesByPlayer(c.env.DB, playerId, sort, skip, take))
|
||||
})
|
||||
.get(
|
||||
'/api/images/v5/player/:playerId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'A player’s photos, sortable',
|
||||
description: 'v4 plus a `sort` option. Same `ImagesPlayer` projection — see the note on v4.',
|
||||
parameters: [
|
||||
idParam('playerId', 'Account id'),
|
||||
intQuery('sort', '1 = most cheered; anything else = newest first'),
|
||||
...pageParams(100),
|
||||
],
|
||||
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s photos') },
|
||||
}),
|
||||
async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
}
|
||||
)
|
||||
|
||||
// A player's photo feed — the public images they took plus ones they're tagged
|
||||
// in, newest first. Paginated via skip/take (take defaults to 100). Bare array.
|
||||
.get('/api/images/v3/feed/player/:playerId{[0-9]+}', async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take))
|
||||
})
|
||||
// in, newest first. Paginated via skip/take (take defaults to 100). Bare array of
|
||||
// the same ImagesPlayer projection the player photo lists use.
|
||||
.get(
|
||||
'/api/images/v3/feed/player/:playerId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'A player’s photo feed',
|
||||
description:
|
||||
'The public images they took PLUS the ones they are tagged in, newest first — the ' +
|
||||
'photo tab on a profile. Same `ImagesPlayer` projection as the player photo lists.',
|
||||
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
|
||||
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s feed') },
|
||||
}),
|
||||
async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const images = await getPlayerFeed(c.env.DB, playerId, skip, take)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
}
|
||||
)
|
||||
|
||||
// Global slideshow feed — the most recent publicly-listable images (Accessibility
|
||||
// 0 or 1) across all rooms, newest first, each joined to its creator's username
|
||||
// and room name. Auth-gated. Returns `{ Images, ValidTill }`, where ValidTill is a
|
||||
// short (2-minute) cache hint the client refreshes against.
|
||||
.get('/api/images/v1/slideshow', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const Images = await getSlideshowImages(c.env.DB)
|
||||
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
|
||||
return c.json({ Images, ValidTill })
|
||||
})
|
||||
// Global slideshow feed — the most recent publicly-listable ShareCamera photos
|
||||
// (Accessibility 0 or 1, Type 1) across all rooms, newest first, each joined to its
|
||||
// creator's username and room name. Public (no auth): it only surfaces already-public
|
||||
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
|
||||
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
|
||||
// Serves 10 by default and never more than SLIDESHOW_MAX_LIMIT (100): it's public and
|
||||
// unauthenticated, so an unclamped `take` would let anyone ask for the whole image
|
||||
// table — and the callers that rotate one photo at a time (the website's hero) don't
|
||||
// want a long feed anyway.
|
||||
.get(
|
||||
'/api/images/v1/slideshow',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'The global slideshow feed',
|
||||
description:
|
||||
'The most recent publicly-listable ShareCamera photos across all rooms, newest ' +
|
||||
'first, each joined to its creator’s username and room name.\n\n' +
|
||||
'Deliberately public — it surfaces only already-public images and backs the ' +
|
||||
'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' +
|
||||
'client refreshes against.',
|
||||
parameters: [
|
||||
intQuery(
|
||||
'take',
|
||||
`How many photos to return (default ${SLIDESHOW_LIMIT}, capped at ${SLIDESHOW_MAX_LIMIT})`
|
||||
),
|
||||
],
|
||||
responses: { 200: json(SlideshowResponse, 'The feed plus its cache hint') },
|
||||
}),
|
||||
async (c) => {
|
||||
// Junk, zero and negative takes fall back to the default rather than 400ing or
|
||||
// serving an empty stage — the caller is a homepage, and no photos reads as the
|
||||
// server being down.
|
||||
const asked = Number.parseInt(c.req.query('take') ?? '', 10)
|
||||
const take = asked > 0 ? Math.min(asked, SLIDESHOW_MAX_LIMIT) : SLIDESHOW_LIMIT
|
||||
const Images = await getSlideshowImages(c.env.DB, take)
|
||||
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
|
||||
return c.json({ Images, ValidTill })
|
||||
}
|
||||
)
|
||||
|
||||
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
|
||||
// there's no metadata row for that name.
|
||||
.get('/api/images/v6', async (c) => {
|
||||
const name = c.req.query('name') ?? ''
|
||||
if (name === '') return c.json({ error: 'name is required' }, 400)
|
||||
const image = await getImageByName(c.env.DB, name)
|
||||
return image ? c.json(image) : c.notFound()
|
||||
})
|
||||
.get(
|
||||
'/api/images/v6',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Image metadata by filename',
|
||||
description:
|
||||
'The stored `SavedImage` record for a bucket key. 404s when the object exists but ' +
|
||||
'has no metadata row.',
|
||||
parameters: [stringQuery('name', 'The image name (bucket key); required')],
|
||||
responses: {
|
||||
200: json(SavedImageDto, 'The image record'),
|
||||
400: json(ErrorResponse, 'No name given'),
|
||||
404: { description: 'No metadata for that name' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.query('name') ?? ''
|
||||
if (name === '') return c.json({ error: 'name is required' }, 400)
|
||||
const image = await getImageByName(c.env.DB, name)
|
||||
return image ? c.json(image) : c.notFound()
|
||||
}
|
||||
)
|
||||
|
||||
// Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Stubbed
|
||||
// for now — accepted but not persisted; cheer storage is still TBD.
|
||||
.post('/api/images/v1/cheer', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// TODO: record the cheer against the image once cheer storage is designed.
|
||||
await c.req.json().catch(() => null)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
// Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Persists the
|
||||
// caller's cheer to `image_interaction` and resyncs the image's CheerCount.
|
||||
.post(
|
||||
'/api/images/v1/cheer',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Cheer or un-cheer a photo',
|
||||
description:
|
||||
'Persists the caller’s cheer and resyncs the image’s `CheerCount`. A body naming no ' +
|
||||
'`SavedImageId` is accepted and ignored — the ack is the same either way.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(CheerImageRequest, 'The image and the new cheer state'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Recorded'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = (await c.req.json().catch(() => null)) as {
|
||||
SavedImageId?: number
|
||||
Cheer?: boolean
|
||||
} | null
|
||||
if (body && typeof body.SavedImageId === 'number') {
|
||||
await setImageCheer(c.env.DB, id, body.SavedImageId, body.Cheer === true)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// Whether the caller has cheered each of the given saved-image ids (`?id=55&id=54`,
|
||||
// and each `id` may itself be a comma-separated list). Auth-gated. Returns one
|
||||
// `{ SavedImageId, IsCheered }` per requested id, in order.
|
||||
.get(
|
||||
'/api/images/v5/cheered/bulk',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Which photos the caller has cheered',
|
||||
description:
|
||||
'One `{ SavedImageId, IsCheered }` per requested id, in request order — the client ' +
|
||||
'fills in the cheer buttons on a photo grid from this.',
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
intQuery('id', 'Repeatable; each value may be a comma-separated list of image ids'),
|
||||
],
|
||||
responses: {
|
||||
200: json(CheeredEntry.array(), 'One entry per requested id, in order'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const ids =
|
||||
c.req
|
||||
.queries('id')
|
||||
?.flatMap((raw) => raw.split(','))
|
||||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
||||
.filter((imageId) => !Number.isNaN(imageId)) ?? []
|
||||
const cheered = await getCheeredImageIds(c.env.DB, id, ids)
|
||||
return c.json(
|
||||
ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) }))
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import { AUTHED, json, JsonArray, UNAUTHORIZED_RESPONSE } from '../openapi'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
// ---- Inventory -------------------------------------------------------------
|
||||
// The equipment/consumables the client actually reads are served by the `econ` worker,
|
||||
// on the econ host. These are the same paths on this host, kept as stubs because some
|
||||
// client builds probe them here first.
|
||||
export const inventoryRoutes = new Hono<App>({ strict: false })
|
||||
.get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
|
||||
.get('/api/consumables/v2/getUnlocked', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([]) // TODO: query ConsumableItems
|
||||
})
|
||||
.get(
|
||||
'/api/equipment/v2/getUnlocked',
|
||||
describeRoute({
|
||||
tags: ['Inventory'],
|
||||
summary: 'Unlocked equipment',
|
||||
description:
|
||||
'A stub on this host — the real inventory lives in the `econ` worker, which serves ' +
|
||||
'this same path with the player’s equipment. Always an empty list here, and ' +
|
||||
'unlike the econ route it does not require a token.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/api/consumables/v2/getUnlocked',
|
||||
describeRoute({
|
||||
tags: ['Inventory'],
|
||||
summary: 'Unlocked consumables',
|
||||
description:
|
||||
'A stub on this host — the real consumables live in the `econ` worker. Auth-gated ' +
|
||||
'even so, then always an empty list.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(JsonArray, 'An empty list'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([]) // TODO: query ConsumableItems
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,28 +1,231 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { authedId, authedRoles, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BareBoolean,
|
||||
CreateReportRequest,
|
||||
CreateWarningRequest,
|
||||
DeviceIdRequest,
|
||||
form,
|
||||
json,
|
||||
JsonArray,
|
||||
ModerationBlockDetails,
|
||||
SuccessErrorEnvelope,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
import { createReport } from '../reports-db'
|
||||
import { createWarning } from '../warnings-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
|
||||
/**
|
||||
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
|
||||
* worker stamps from an account's isModerator/isDeveloper flags (see the admin CLI's
|
||||
* `grant-moderator` / `grant-developer`). Same set the `notify` / `www` workers gate
|
||||
* their admin surfaces on: a warning is a moderation action, but staff hold both.
|
||||
*/
|
||||
const MODERATOR_ROLES = new Set(['moderator', 'developer'])
|
||||
|
||||
/**
|
||||
* Read one field of a submitted form. The client posts these form-encoded, but the
|
||||
* same names also arrive as a query string on some builds, so both are accepted.
|
||||
*/
|
||||
function formField(
|
||||
body: Record<string, unknown>,
|
||||
c: Context<App>,
|
||||
name: string
|
||||
): string | undefined {
|
||||
const raw = body[name]
|
||||
if (typeof raw === 'string' && raw !== '') return raw
|
||||
return c.req.query(name) || undefined
|
||||
}
|
||||
|
||||
/** Parse a field as an integer, or null when absent / not a number. */
|
||||
const asInt = (v: string | undefined): number | null => {
|
||||
if (v === undefined) return null
|
||||
const n = Number.parseInt(v, 10)
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
/** Parse a field as a float (the reported heights), or null when absent / not a number. */
|
||||
const asFloat = (v: string | undefined): number | null => {
|
||||
if (v === undefined) return null
|
||||
const n = Number.parseFloat(v)
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
// ---- Player reporting ------------------------------------------------------
|
||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
|
||||
// ban storage yet, so this is always the "not blocked" answer. `ReportCategory` is
|
||||
// -1 (no category) rather than 0, which is a real category; `Message` is null, not
|
||||
// an empty string — the client distinguishes "no message" from a blank one.
|
||||
.get('/api/PlayerReporting/v1/moderationBlockDetails', (c) =>
|
||||
c.json({
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
.get(
|
||||
'/api/PlayerReporting/v1/moderationBlockDetails',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Whether the caller is blocked',
|
||||
description:
|
||||
'Ban / timeout / host-kick state for the caller. There is no ban storage yet, so ' +
|
||||
'this is always the “not blocked” answer. Two details matter to the client: ' +
|
||||
'`ReportCategory` is -1 (no category) rather than 0, which is a real category, and ' +
|
||||
'`Message` is null rather than an empty string — the client distinguishes “no ' +
|
||||
'message” from a blank one.',
|
||||
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
)
|
||||
.get(
|
||||
'/api/PlayerReporting/v1/voteToKickReasons',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Vote-to-kick reasons',
|
||||
description:
|
||||
'The reasons offered when starting a vote-to-kick. Not hydrated yet, so the list ' +
|
||||
'is empty.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
) // TODO: hydrate from JSON/vtkreasons.json
|
||||
.post(
|
||||
'/api/PlayerReporting/v1/hile',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Report submission sink',
|
||||
description:
|
||||
'A player report. Nothing stores reports, so this accepts whatever it is sent and ' +
|
||||
'answers a bare `false`.',
|
||||
responses: { 200: json(BareBoolean, 'A bare JSON `false`') },
|
||||
}),
|
||||
(c) => c.json(false)
|
||||
)
|
||||
|
||||
// The report the client actually submits. Auth-gated: the reporter is taken from
|
||||
// the bearer token rather than the body, so a report can't be filed as someone else.
|
||||
.post(
|
||||
'/api/PlayerReporting/v3/create',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Submit a player report',
|
||||
description:
|
||||
'Records a player report in the `report` table — an append-only log; nothing ' +
|
||||
'dedupes or acts on the rows yet, and `moderationBlockDetails` still answers ' +
|
||||
'“not blocked” unconditionally.\n\n' +
|
||||
'The reporter is the caller (from the bearer token), NOT a body field. Only ' +
|
||||
'`PlayerIdReported` is required; the client omits whatever it has no value for ' +
|
||||
'(a report raised outside a room carries no `RoomId`), and those are stored as ' +
|
||||
'NULL. `ReportCategory` and `RoomInstanceType` are stored verbatim — neither ' +
|
||||
'enum is mapped here. A `RoomId` of 0 or below means “no room”.\n\n' +
|
||||
'Answers the real service’s `{ success, error }` envelope, where `error` is an ' +
|
||||
'empty string rather than null. The rejected branch uses the same envelope so ' +
|
||||
'the client only ever parses one shape.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateReportRequest, 'The report'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No `PlayerIdReported` in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const reporterId = await authedId(c)
|
||||
if (reporterId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const reportedPlayerId = asInt(formField(body, c, 'PlayerIdReported'))
|
||||
if (reportedPlayerId === null) {
|
||||
return c.json({ success: false, error: 'PlayerIdReported is required' }, 400)
|
||||
}
|
||||
|
||||
// 0 / -1 are the client's "no room" values — store null rather than a bogus id.
|
||||
const roomId = asInt(formField(body, c, 'RoomId'))
|
||||
|
||||
await createReport(c.env.DB, {
|
||||
reporterPlayerId: reporterId,
|
||||
reportedPlayerId,
|
||||
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
|
||||
details: formField(body, c, 'Details') ?? null,
|
||||
heightReporter: asFloat(formField(body, c, 'HeightReporter')),
|
||||
heightReported: asFloat(formField(body, c, 'HeightReported')),
|
||||
roomId: roomId !== null && roomId > 0 ? roomId : null,
|
||||
roomInstanceType: formField(body, c, 'RoomInstanceType') ?? null,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// A warning handed down by a moderator — the staff-side counterpart to a report.
|
||||
// Gated on the `moderator` role in the token, not just a valid one.
|
||||
.post(
|
||||
'/api/playerwarnings',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Issue a player warning',
|
||||
description:
|
||||
'Records a moderator-issued warning in the `warning` table — an append-only log ' +
|
||||
'like `report`; nothing dispatches the warning to the player or acts on the rows ' +
|
||||
'yet.\n\n' +
|
||||
'**Staff only.** The token must carry the `moderator` or `developer` role (granted ' +
|
||||
'per account by the operator, see the admin CLI’s `grant-moderator` / ' +
|
||||
'`grant-developer`); a valid token with neither gets a 403. The acting moderator ' +
|
||||
'is the caller, NOT a body field.\n\n' +
|
||||
'Only `WarnedPlayerId` is required; the rest are stored as NULL when absent. ' +
|
||||
'`ReportCategory` is stored verbatim — the enum is not mapped here. ' +
|
||||
'`DisplayReason` is what the warned player would be shown; `ModeratorNote` is ' +
|
||||
'internal and never surfaced to them.\n\n' +
|
||||
'Answers the same `{ success, error }` envelope as the report write, with `error` ' +
|
||||
'an empty string rather than null — including on the rejected branches, so there ' +
|
||||
'is only one shape to parse.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateWarningRequest, 'The warning'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No `WarnedPlayerId` in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(SuccessErrorEnvelope, 'A valid token with neither staff role'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const moderatorId = await authedId(c)
|
||||
if (moderatorId === null) return unauthorized(c)
|
||||
|
||||
const roles = await authedRoles(c)
|
||||
if (!roles?.some((role) => MODERATOR_ROLES.has(role))) {
|
||||
return c.json({ success: false, error: 'Forbidden' }, 403)
|
||||
}
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const warnedPlayerId = asInt(formField(body, c, 'WarnedPlayerId'))
|
||||
if (warnedPlayerId === null) {
|
||||
return c.json({ success: false, error: 'WarnedPlayerId is required' }, 400)
|
||||
}
|
||||
|
||||
await createWarning(c.env.DB, {
|
||||
moderatorPlayerId: moderatorId,
|
||||
warnedPlayerId,
|
||||
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
|
||||
displayReason: formField(body, c, 'DisplayReason') ?? null,
|
||||
moderatorNote: formField(body, c, 'ModeratorNote') ?? null,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
.get('/api/PlayerReporting/v1/voteToKickReasons', (c) => c.json([])) // TODO: hydrate from JSON/vtkreasons.json
|
||||
.post('/api/PlayerReporting/v1/hile', (c) => c.json(false))
|
||||
|
||||
// The client reporting its device id (form-encoded `oldDeviceId`, `newDeviceId`,
|
||||
// `platform`), rotating from the id it thinks we hold to the current one. Carries no
|
||||
@@ -34,4 +237,25 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
// https://github.com/djdevin/recnet-plugin we disable the device ID check to enable
|
||||
// account creation. Nothing in the logs, client just hangs, who knows what it is
|
||||
// waiting for.
|
||||
.post('/api/PlayerReporting/v1/deviceId', (c) => c.json([]));
|
||||
.post(
|
||||
'/api/PlayerReporting/v1/deviceId',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Device id rotation (known broken)',
|
||||
description:
|
||||
'The client reporting its device id, rotating from the one it thinks we hold to ' +
|
||||
'the current one. It carries no bearer token and fires *before* account creation, ' +
|
||||
'so there is no caller to attribute the id to and nothing to store it against — ' +
|
||||
'we accept it and drop it.\n\n' +
|
||||
'**Known broken.** No response shape found so far keeps the client happy: it ' +
|
||||
'hangs during account creation with nothing in the logs. The real service answers ' +
|
||||
'a `{ success, error }` envelope; we currently answer an empty array, which does ' +
|
||||
'not help either. The workaround is to disable the device-id check client-side ' +
|
||||
'(see [recnet-plugin](https://github.com/djdevin/recnet-plugin)).',
|
||||
requestBody: form(DeviceIdRequest, 'The id rotation'),
|
||||
responses: {
|
||||
200: json(JsonArray, 'An empty array — see the note above; this is not the real shape'),
|
||||
},
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { parseFormIds, queryIds } from '../http'
|
||||
import {
|
||||
BulkIdsRequest,
|
||||
form,
|
||||
idParam,
|
||||
intQuery,
|
||||
json,
|
||||
JsonArray,
|
||||
ProgressionDto,
|
||||
ReputationDto,
|
||||
} from '../openapi'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
@@ -27,39 +38,148 @@ function defaultReputation(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The repeated `id` query param the 2023 client uses on the bulk GET forms — each value
|
||||
* may itself be a comma-separated list, so `?id=1,2&id=3` is three ids.
|
||||
*/
|
||||
const BULK_ID_QUERY = [
|
||||
intQuery('id', 'Repeated once per account id (`?id=1&id=2`); not comma-separated'),
|
||||
]
|
||||
|
||||
/** The `Ids` form body the bulk POST forms take. */
|
||||
const BULK_ID_BODY = form(BulkIdsRequest, 'The account ids to look up')
|
||||
|
||||
// ---- Reputation / progression ----------------------------------------------
|
||||
export const progressionRoutes = new Hono<App>({ strict: false })
|
||||
.get('/api/playerReputation/v1/:id', (c) =>
|
||||
c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
|
||||
.get(
|
||||
'/api/playerReputation/v1/:id',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'A player’s reputation',
|
||||
description:
|
||||
'The cheer counters shown on a player’s profile. No cheers are stored yet, so ' +
|
||||
'every player gets the same all-zero record with full cheer credit.',
|
||||
parameters: [idParam('id', 'Account id')],
|
||||
responses: { 200: json(ReputationDto, 'The player’s reputation') },
|
||||
}),
|
||||
(c) => c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
|
||||
)
|
||||
.get(
|
||||
'/api/players/v1/progression/:id',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'A player’s level and XP',
|
||||
description: 'Nothing awards XP yet, so everyone is level 1 with 0 XP.',
|
||||
parameters: [idParam('id', 'Account id')],
|
||||
responses: { 200: json(ProgressionDto, 'The player’s progression') },
|
||||
}),
|
||||
(c) => {
|
||||
const id = Number.parseInt(c.req.param('id'), 10)
|
||||
return c.json({ PlayerId: id, Level: 1, XP: 0 })
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/api/playerReputation/v1/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Reputations in bulk (v1)',
|
||||
description:
|
||||
'The older bulk form, superseded by v2. It answers an empty list rather than ' +
|
||||
'synthesizing defaults — the client only uses v2.',
|
||||
requestBody: BULK_ID_BODY,
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get('/api/players/v1/progression/:id', (c) => {
|
||||
const id = Number.parseInt(c.req.param('id'), 10)
|
||||
return c.json({ PlayerId: id, Level: 1, XP: 0 })
|
||||
})
|
||||
.post('/api/playerReputation/v1/bulk', (c) => c.json([])) // TODO: hydrate from JSON/bulkprogression.json
|
||||
// Synthesize a default reputation per requested id (the intended behavior;
|
||||
// the DB-less fallback reads a static JSON file instead).
|
||||
.post('/api/playerReputation/v2/bulk', async (c) => {
|
||||
const ids = await parseFormIds(c)
|
||||
return c.json(ids.map(defaultReputation))
|
||||
})
|
||||
.post(
|
||||
'/api/playerReputation/v2/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Reputations in bulk',
|
||||
description:
|
||||
'One default reputation per requested id, in request order. Ids that name no ' +
|
||||
'account still get a record — the client renders a profile card from it.',
|
||||
requestBody: BULK_ID_BODY,
|
||||
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
|
||||
}),
|
||||
async (c) => {
|
||||
const ids = await parseFormIds(c)
|
||||
return c.json(ids.map(defaultReputation))
|
||||
}
|
||||
)
|
||||
// The 2023 client calls this as a GET with repeated `id` query params.
|
||||
.get('/api/playerReputation/v2/bulk', (c) => c.json(queryIds(c).map(defaultReputation)))
|
||||
.post('/api/players/v1/progression/bulk', async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
})
|
||||
.get(
|
||||
'/api/playerReputation/v2/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Reputations in bulk (GET form)',
|
||||
description:
|
||||
'What the 2023 client sends: the same bulk lookup with the ids as repeated query ' +
|
||||
'params instead of a form body.',
|
||||
parameters: BULK_ID_QUERY,
|
||||
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
|
||||
}),
|
||||
(c) => c.json(queryIds(c).map(defaultReputation))
|
||||
)
|
||||
.post(
|
||||
'/api/players/v1/progression/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Progressions in bulk (v1)',
|
||||
description: 'No progression is stored yet, so this is an empty list.',
|
||||
requestBody: BULK_ID_BODY,
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
// v2 is identical to v1 — same form-id parse + PlayerProgressions query.
|
||||
.post('/api/players/v2/progression/bulk', async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
})
|
||||
.post(
|
||||
'/api/players/v2/progression/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Progressions in bulk (v2)',
|
||||
description: 'Identical to v1 — same ids in, same empty list out.',
|
||||
requestBody: BULK_ID_BODY,
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
// The 2023 client calls this as a GET with repeated `id` query params.
|
||||
// Return a default progression per requested id.
|
||||
.get('/api/players/v2/progression/bulk', (c) =>
|
||||
c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
|
||||
.get(
|
||||
'/api/players/v2/progression/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Progressions in bulk (GET form)',
|
||||
description:
|
||||
'What the 2023 client sends. Unlike the POST forms this one does answer — a ' +
|
||||
'default level-1 progression per requested id, in request order.',
|
||||
parameters: BULK_ID_QUERY,
|
||||
responses: { 200: json(ProgressionDto.array(), 'One progression per requested id') },
|
||||
}),
|
||||
(c) => c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
|
||||
)
|
||||
.post(
|
||||
'/api/v1/progression/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Progressions in bulk (unversioned path)',
|
||||
description:
|
||||
'An older unversioned path some client builds still call. Same empty answer as ' +
|
||||
'the versioned POST forms.',
|
||||
requestBody: BULK_ID_BODY,
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
.post('/api/v1/progression/bulk', async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
})
|
||||
|
||||
+129
-50
@@ -1,42 +1,101 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { getRoomById } from '@repo/domain'
|
||||
|
||||
import { authedId } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BareBoolean,
|
||||
form,
|
||||
json,
|
||||
JsonArray,
|
||||
QuickPlayResponse,
|
||||
TagFilters,
|
||||
VerifyRoleRequest,
|
||||
} from '../openapi'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
// ---- Room keys / quick play / rooms ----------------------------------------
|
||||
export const roomRoutes = new Hono<App>({ strict: false })
|
||||
.get('/api/roomkeys/v1/mine', (c) => c.json([]))
|
||||
.get('/api/roomkeys/v1/room', (c) => c.json([]))
|
||||
.get('/api/quickPlay/v1/getandclear', (c) =>
|
||||
c.json({ RoomName: null, ActionCode: null, TargetPlayerId: null })
|
||||
.get(
|
||||
'/api/roomkeys/v1/mine',
|
||||
describeRoute({
|
||||
tags: ['Rooms'],
|
||||
summary: 'The caller’s room keys',
|
||||
description: 'Nothing issues room keys yet, so this is an empty list.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/api/roomkeys/v1/room',
|
||||
describeRoute({
|
||||
tags: ['Rooms'],
|
||||
summary: 'A room’s keys',
|
||||
description: 'Nothing issues room keys yet, so this is an empty list.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/api/quickPlay/v1/getandclear',
|
||||
describeRoute({
|
||||
tags: ['Rooms'],
|
||||
summary: 'Take the pending quick-play action',
|
||||
description:
|
||||
'A read-and-clear of whatever quick-play action is queued for the caller (joining ' +
|
||||
'a friend, an invite deep link). Nothing queues one yet, so all three fields are ' +
|
||||
'null — which the client reads as “nothing to do”.',
|
||||
responses: { 200: json(QuickPlayResponse, 'All null — no pending action') },
|
||||
}),
|
||||
(c) => c.json({ RoomName: null, ActionCode: null, TargetPlayerId: null })
|
||||
)
|
||||
|
||||
// Room search filters. The client deserializes this into an object (not an
|
||||
// array) — shape from the 2025 reference.
|
||||
.get('/api/rooms/v1/filters', (c) =>
|
||||
c.json({
|
||||
PinnedFilters: [
|
||||
'recroomoriginal',
|
||||
'community',
|
||||
'featured',
|
||||
'quest',
|
||||
'pvp',
|
||||
'hangout',
|
||||
'game',
|
||||
'art',
|
||||
'store',
|
||||
'tutorial',
|
||||
'fandom',
|
||||
'performance',
|
||||
'action',
|
||||
'horror',
|
||||
],
|
||||
PopularFilters: ['pvp', 'quest', 'game', 'hangout', 'art'],
|
||||
TrendingFilters: ['roleplay', 'nomp', 'rp', 'casual', 'fun', 'action', 'military', 'sports'],
|
||||
})
|
||||
.get(
|
||||
'/api/rooms/v1/filters',
|
||||
describeRoute({
|
||||
tags: ['Rooms'],
|
||||
summary: 'Room browse filter chips',
|
||||
description:
|
||||
'The filter chips on the room browse screen. Static, taken from the 2025 ' +
|
||||
'reference. The client deserializes this as an OBJECT, not an array — and unlike ' +
|
||||
'the invention/event filters, `TrendingFilters` here is a real list.',
|
||||
responses: { 200: json(TagFilters, 'The filter chips') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
PinnedFilters: [
|
||||
'recroomoriginal',
|
||||
'community',
|
||||
'featured',
|
||||
'quest',
|
||||
'pvp',
|
||||
'hangout',
|
||||
'game',
|
||||
'art',
|
||||
'store',
|
||||
'tutorial',
|
||||
'fandom',
|
||||
'performance',
|
||||
'action',
|
||||
'horror',
|
||||
],
|
||||
PopularFilters: ['pvp', 'quest', 'game', 'hangout', 'art'],
|
||||
TrendingFilters: [
|
||||
'roleplay',
|
||||
'nomp',
|
||||
'rp',
|
||||
'casual',
|
||||
'fun',
|
||||
'action',
|
||||
'military',
|
||||
'sports',
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
// Verify the caller holds at least `role` in a room. Params come from the form
|
||||
@@ -44,29 +103,49 @@ export const roomRoutes = new Hono<App>({ strict: false })
|
||||
// room creator always passes; otherwise the caller needs a Roles entry with
|
||||
// `Role >= role`. Any failure (no token, unknown room, insufficient role) is
|
||||
// `false`. The `context` field (e.g. MakerPen) is accepted and ignored.
|
||||
.post('/api/rooms/v1/verifyRole', async (c) => {
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const param = (name: string): string => {
|
||||
const form = body[name]
|
||||
if (typeof form === 'string' && form !== '') return form
|
||||
return c.req.query(name) ?? ''
|
||||
.post(
|
||||
'/api/rooms/v1/verifyRole',
|
||||
describeRoute({
|
||||
tags: ['Rooms'],
|
||||
summary: 'Verify the caller’s role in a room',
|
||||
description:
|
||||
'Whether the caller holds at least `role` in the room — the gate the client checks ' +
|
||||
'before letting someone into the Maker Pen. The room’s creator always passes; ' +
|
||||
'anyone else needs a `Roles` entry at that level or higher.\n\n' +
|
||||
'Answers a bare `true`/`false`, and every failure is `false` rather than an error ' +
|
||||
'status: no token, an unknown room, and an insufficient role are indistinguishable ' +
|
||||
'to the client. Params are read from the form body, falling back to the query ' +
|
||||
'string. Room data is read from the shared rooms database (owned by the `rooms` ' +
|
||||
'worker).',
|
||||
security: AUTHED,
|
||||
requestBody: form(VerifyRoleRequest, 'The room and the role level to check'),
|
||||
responses: { 200: json(BareBoolean, 'Whether the caller holds the role') },
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const param = (name: string): string => {
|
||||
// (named `fromBody` rather than `form` — the openapi helper owns that name here)
|
||||
const fromBody = body[name]
|
||||
if (typeof fromBody === 'string' && fromBody !== '') return fromBody
|
||||
return c.req.query(name) ?? ''
|
||||
}
|
||||
const roomId = Number.parseInt(param('roomId'), 10)
|
||||
const role = Number.parseInt(param('role'), 10)
|
||||
|
||||
const accountId = await authedId(c)
|
||||
if (accountId === null || Number.isNaN(roomId)) return c.json(false)
|
||||
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return c.json(false)
|
||||
|
||||
// The creator always passes.
|
||||
if (room.CreatorAccountId === accountId) return c.json(true)
|
||||
|
||||
// Otherwise the caller needs a room role at least as high as requested.
|
||||
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Record<string, unknown>>) : []
|
||||
const hasRole = roles.some(
|
||||
(r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0)
|
||||
)
|
||||
return c.json(hasRole)
|
||||
}
|
||||
const roomId = Number.parseInt(param('roomId'), 10)
|
||||
const role = Number.parseInt(param('role'), 10)
|
||||
|
||||
const accountId = await authedId(c)
|
||||
if (accountId === null || Number.isNaN(roomId)) return c.json(false)
|
||||
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return c.json(false)
|
||||
|
||||
// The creator always passes.
|
||||
if (room.CreatorAccountId === accountId) return c.json(true)
|
||||
|
||||
// Otherwise the caller needs a room role at least as high as requested.
|
||||
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Record<string, unknown>>) : []
|
||||
const hasRole = roles.some(
|
||||
(r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0)
|
||||
)
|
||||
return c.json(hasRole)
|
||||
})
|
||||
)
|
||||
|
||||
+477
-71
@@ -1,10 +1,33 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { getAccountsByIds } from '@repo/domain'
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
||||
// as a value — the enum has no runtime dependencies.
|
||||
import { NotificationType } from '../../../notify/src/notification-types'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
AckResponse,
|
||||
AUTHED,
|
||||
ErrorResponse,
|
||||
form,
|
||||
intQuery,
|
||||
json,
|
||||
JsonArray,
|
||||
MutualFriendDto,
|
||||
RelationshipDto,
|
||||
SendMessageRequest,
|
||||
SuccessErrorEnvelope,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
import {
|
||||
acceptFriendRequest,
|
||||
addFriend,
|
||||
getMutualFriendIds,
|
||||
getRelationshipsForPlayer,
|
||||
MUTUAL_FRIENDS_LIMIT,
|
||||
removeFriend,
|
||||
sendFriendRequest,
|
||||
setRelationshipFlag,
|
||||
@@ -12,6 +35,72 @@ import {
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
import type {
|
||||
RelationshipChange,
|
||||
RelationshipFlag,
|
||||
RelationshipResponse,
|
||||
} from '../relationships-db'
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/**
|
||||
* Push a `RelationshipChanged` notification carrying `rel` to one player. Hub failures are
|
||||
* logged and swallowed — the DB write has already committed, so a hub hiccup must not fail
|
||||
* the request.
|
||||
*/
|
||||
async function notifyRelationship(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
rel: RelationshipResponse
|
||||
): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
playerId,
|
||||
NotificationType.RelationshipChanged,
|
||||
{ ...rel }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push RelationshipChanged notification', {
|
||||
playerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify both players of a friend-graph change, each with the relationship projected from
|
||||
* their own point of view — the target of a request sees `FriendRequestReceived` where the
|
||||
* sender sees `Sent`, so the two payloads differ. A no-op mutation notifies nobody.
|
||||
*/
|
||||
async function notifyBoth(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
otherId: number,
|
||||
change: RelationshipChange
|
||||
): Promise<void> {
|
||||
if (!change.changed) return
|
||||
await notifyRelationship(c, playerId, change.self)
|
||||
await notifyRelationship(c, otherId, change.other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a per-player relationship flag toggle (favorited/ignored/muted). The flags are
|
||||
* private to the caller's own side of the row, so only the caller is notified. The
|
||||
* resulting relationship rides the notification and the HTTP body is just the
|
||||
* `{ Success, Message }` ack.
|
||||
*/
|
||||
async function applyFlag(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
otherId: number,
|
||||
flag: RelationshipFlag,
|
||||
value: boolean
|
||||
): Promise<Response> {
|
||||
const rel = await setRelationshipFlag(c.env.DB, playerId, otherId, flag, value)
|
||||
await notifyRelationship(c, playerId, rel)
|
||||
return c.json({ Success: true, Message: '' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the other player's id from a relationship-mutation request. The exact wire
|
||||
@@ -39,94 +128,411 @@ async function targetPlayerId(c: Context<App>): Promise<number | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* How every relationship mutation names its target. The handler is liberal — it also
|
||||
* accepts `PlayerId`/`playerId`/`Id` from a JSON or form body — but the client sends the
|
||||
* query param, so that's what the spec documents.
|
||||
*/
|
||||
const TARGET_PARAMS = [
|
||||
intQuery('id', 'The other player. The client uses this form.'),
|
||||
intQuery('playerId', 'Accepted as an alias for `id`'),
|
||||
]
|
||||
|
||||
/**
|
||||
* A `describeRoute` spec for one of the four friend-graph mutations. These change state
|
||||
* both players can see, so each also pushes a RelationshipChanged notification to both
|
||||
* sides; the HTTP body is the caller's own projection.
|
||||
*/
|
||||
function friendMutation(summary: string, description: string) {
|
||||
return describeRoute({
|
||||
tags: ['Social'],
|
||||
summary,
|
||||
description,
|
||||
security: AUTHED,
|
||||
parameters: TARGET_PARAMS,
|
||||
responses: {
|
||||
200: json(RelationshipDto, 'The relationship, from the caller’s point of view'),
|
||||
400: json(ErrorResponse, 'No target id, or the caller targeting themselves'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A `describeRoute` spec for a per-side flag toggle (favorite / ignore / mute and their
|
||||
* inverses). The write lands on the caller's own side of the row, so only the caller is
|
||||
* notified — and the resulting relationship rides that notification, not the response,
|
||||
* which is just the ack.
|
||||
*/
|
||||
function flagToggle(summary: string, description: string) {
|
||||
return describeRoute({
|
||||
tags: ['Social'],
|
||||
summary,
|
||||
description,
|
||||
security: AUTHED,
|
||||
parameters: TARGET_PARAMS,
|
||||
responses: {
|
||||
200: json(AckResponse, 'The ack; the relationship arrives over the notification hub'),
|
||||
400: json(ErrorResponse, 'No target id, or the caller targeting themselves'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Social ----------------------------------------------------------------
|
||||
export const socialRoutes = new Hono<App>({ strict: false })
|
||||
// The authed player's relationships, projected from their point of view — a bare
|
||||
// array of RelationshipResponse. Auth-gated.
|
||||
.get('/api/relationships/v2/get', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getRelationshipsForPlayer(c.env.DB, id))
|
||||
})
|
||||
.get(
|
||||
'/api/relationships/v2/get',
|
||||
describeRoute({
|
||||
tags: ['Social'],
|
||||
summary: 'The caller’s relationships',
|
||||
description:
|
||||
'Every relationship the signed-in player has, projected from their point of view — ' +
|
||||
'a bare array. `None` rows are included: that is how an unfriending, or an ' +
|
||||
'ignore/mute of someone you were never friends with, is recorded, and they still ' +
|
||||
'carry the caller’s favorited/ignored/muted flags.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(RelationshipDto.array(), 'The caller’s relationships'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getRelationshipsForPlayer(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// The friends the caller and another player have in common. Unlike the other
|
||||
// relationship routes this answers account cards, not relationships — it's what the
|
||||
// client shows on someone else's profile.
|
||||
.get(
|
||||
'/api/relationships/mutualfriends',
|
||||
describeRoute({
|
||||
tags: ['Social'],
|
||||
summary: 'Friends in common with another player',
|
||||
description:
|
||||
'The accounts the caller and `id` are both friends with — a bare array, ascending ' +
|
||||
`by account id and capped at ${MUTUAL_FRIENDS_LIMIT}. Only real friendships count; ` +
|
||||
'pending requests on either side are ignored.\n\n' +
|
||||
'Answers an empty array rather than an error for the degenerate cases: no target ' +
|
||||
'id, an id of 0 or below, or the caller asking for mutuals with themselves. ' +
|
||||
'Mutual ids with no account row are dropped, so the list can be shorter than the ' +
|
||||
'intersection.\n\n' +
|
||||
'Each entry is a trimmed account card. `ProfileImage` is an empty string, never ' +
|
||||
'null, when the account has no image.',
|
||||
security: AUTHED,
|
||||
parameters: [intQuery('id', 'The other player')],
|
||||
responses: {
|
||||
200: json(MutualFriendDto.array(), 'The shared friends; empty when there are none'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const raw = c.req.query('id')
|
||||
const otherId = raw === undefined ? Number.NaN : Number.parseInt(raw, 10)
|
||||
// Nothing to intersect: no/garbage id, a non-positive one, or the caller
|
||||
// themselves. An empty list, not an error — this feeds a profile panel.
|
||||
if (Number.isNaN(otherId) || otherId <= 0 || otherId === id) return c.json([])
|
||||
|
||||
const mutualIds = await getMutualFriendIds(c.env.DB, id, otherId)
|
||||
const accounts = await getAccountsByIds(c.env.DB, mutualIds)
|
||||
return c.json(
|
||||
accounts
|
||||
.map((a) => ({
|
||||
AccountId: a.accountId,
|
||||
Username: a.username,
|
||||
DisplayName: a.displayName,
|
||||
ProfileImage: a.profileImage ?? '',
|
||||
}))
|
||||
// getAccountsByIds doesn't promise an order; keep the ascending one.
|
||||
.sort((a, b) => a.AccountId - b.AccountId)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// A message from one player to another — the "invite me!" style prompts the client
|
||||
// sends. Nothing is stored: the message IS the notification, pushed to the
|
||||
// recipient's hub connection (and queued by the hub if they're offline).
|
||||
.post(
|
||||
'/api/messages/v2/send',
|
||||
describeRoute({
|
||||
tags: ['Social'],
|
||||
summary: 'Send a message to another player',
|
||||
description:
|
||||
'Pushes a `MessageReceived` notification to `ToPlayerId` carrying the message — ' +
|
||||
'the same frame the Coach broadcast sends (see the `notify` worker’s ' +
|
||||
'`coachMessageAll`), except `FromPlayerId` is the caller rather than the Coach ' +
|
||||
'account and it goes to one player. The hub queues it when the recipient is ' +
|
||||
'offline, so it arrives on their next connect.\n\n' +
|
||||
'Nothing is persisted here — there is no message store, the notification is the ' +
|
||||
'whole delivery. The sender is the caller (from the bearer token), NOT a body ' +
|
||||
'field. `Type` is a Message-model type (a different enum from `NotificationType`) ' +
|
||||
'passed through unmapped, defaulting to 0; `Data` is the payload and is commonly ' +
|
||||
'empty.\n\n' +
|
||||
'Answers the same `{ success, error }` envelope as the report / warning writes, ' +
|
||||
'`error` an empty string on success. A hub failure is reported honestly as a 500 ' +
|
||||
'with `success: false` — with no store behind it, a swallowed error would be a ' +
|
||||
'silently dropped message.',
|
||||
security: AUTHED,
|
||||
requestBody: form(SendMessageRequest, 'The message'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No `ToPlayerId` in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const fromPlayerId = await authedId(c)
|
||||
if (fromPlayerId === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
const toPlayerId = Number.parseInt(str(body.ToPlayerId) ?? '', 10)
|
||||
if (Number.isNaN(toPlayerId)) {
|
||||
return c.json({ success: false, error: 'ToPlayerId is required' }, 400)
|
||||
}
|
||||
|
||||
// The Message the notification carries. Mirrors the coach message's shape with
|
||||
// a real sender and recipient; `Data` stays a string, empty included (the hub
|
||||
// drops only null/undefined from the frame).
|
||||
const message = {
|
||||
FromPlayerId: fromPlayerId,
|
||||
ToPlayerId: toPlayerId,
|
||||
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0,
|
||||
Data: str(body.Data) ?? '',
|
||||
}
|
||||
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
toPlayerId,
|
||||
NotificationType.MessageReceived,
|
||||
message
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push MessageReceived notification', {
|
||||
toPlayerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
|
||||
}
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// Send a friend request to another player (the target arrives as `?id=`). The
|
||||
// client calls this as a GET; the mutations accept GET or POST (the Go handlers
|
||||
// matched any method). Auth-gated. Returns the resulting relationship from the
|
||||
// caller's point of view.
|
||||
.on(['GET', 'POST'], '/api/relationships/v2/sendfriendrequest', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return c.json(await sendFriendRequest(c.env.DB, id, target))
|
||||
})
|
||||
//
|
||||
// The four friend-graph mutations below change state both players can see, so each
|
||||
// notifies BOTH sides with their own projection (see notifyBoth) on top of the HTTP
|
||||
// response. A no-op — re-sending an outstanding request, accepting nothing pending —
|
||||
// notifies nobody.
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v2/sendfriendrequest',
|
||||
friendMutation(
|
||||
'Send a friend request',
|
||||
'Offer friendship to another player. Re-sending an outstanding request is a no-op ' +
|
||||
'and notifies nobody.'
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
const change = await sendFriendRequest(c.env.DB, id, target)
|
||||
await notifyBoth(c, id, target, change)
|
||||
return c.json(change.self)
|
||||
}
|
||||
)
|
||||
|
||||
// Accept a pending friend request from another player (`?id=`). Auth-gated.
|
||||
.on(['GET', 'POST'], '/api/relationships/v2/acceptfriendrequest', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return c.json(await acceptFriendRequest(c.env.DB, id, target))
|
||||
})
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v2/acceptfriendrequest',
|
||||
friendMutation(
|
||||
'Accept a friend request',
|
||||
'Turn a pending incoming request into a friendship. Accepting nothing pending is a ' +
|
||||
'no-op and notifies nobody.'
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
const change = await acceptFriendRequest(c.env.DB, id, target)
|
||||
await notifyBoth(c, id, target, change)
|
||||
return c.json(change.self)
|
||||
}
|
||||
)
|
||||
|
||||
// Remove a friend / cancel a request / decline a request (`?id=`). Auth-gated.
|
||||
.on(['GET', 'POST'], '/api/relationships/v2/removefriend', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
await removeFriend(c.env.DB, id, target)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
// Remove a friend / cancel a request / decline a request (`?id=`). The row is kept as
|
||||
// a None relationship so the per-side flags survive (see removeFriend). Auth-gated.
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v2/removefriend',
|
||||
friendMutation(
|
||||
'Unfriend, or cancel/decline a request',
|
||||
'All three are the same operation. The row is kept as a `None` relationship so the ' +
|
||||
'per-side favorited/ignored/muted flags survive.'
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
const change = await removeFriend(c.env.DB, id, target)
|
||||
await notifyBoth(c, id, target, change)
|
||||
return c.json(change.self)
|
||||
}
|
||||
)
|
||||
|
||||
// Directly add another player as a friend, no pending-request step (`?id=`). Auth-gated.
|
||||
.on(['GET', 'POST'], '/api/relationships/v2/addfriend', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return c.json(await addFriend(c.env.DB, id, target))
|
||||
})
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v2/addfriend',
|
||||
friendMutation(
|
||||
'Befriend directly',
|
||||
'Become friends with no pending-request step. Already being friends is a no-op.'
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
const change = await addFriend(c.env.DB, id, target)
|
||||
await notifyBoth(c, id, target, change)
|
||||
return c.json(change.self)
|
||||
}
|
||||
)
|
||||
|
||||
// Ignore / mute another player (target arrives as `PlayerId` in the POST body).
|
||||
// These set a per-player flag on the *caller's* side of the relationship row,
|
||||
// creating a bare (None) row when the pair aren't otherwise related — so you can
|
||||
// ignore/mute someone you've never friended. Auth-gated. Returns the resulting
|
||||
// relationship from the caller's point of view.
|
||||
.on(['GET', 'POST'], '/api/relationships/v1/ignore', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return c.json(await setRelationshipFlag(c.env.DB, id, target, 'ignored', true))
|
||||
})
|
||||
.on(['GET', 'POST'], '/api/relationships/v1/mute', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return c.json(await setRelationshipFlag(c.env.DB, id, target, 'muted', true))
|
||||
})
|
||||
// Ignore / mute another player, and their inverses unignore / unmute (target
|
||||
// arrives as `PlayerId` in the POST body). These set a per-player flag on the
|
||||
// *caller's* side of the relationship row, creating a bare (None) row when the
|
||||
// pair aren't otherwise related — so you can ignore/mute someone you've never
|
||||
// friended. The un- variants just clear the same flag. Auth-gated. The resulting
|
||||
// relationship is delivered via a RelationshipChanged hub notification (see
|
||||
// applyFlag); the HTTP body is just the { Success, Message } ack.
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v1/ignore',
|
||||
flagToggle(
|
||||
'Ignore a player',
|
||||
'Sets the caller’s `ignored` flag. Ignoring someone you have no relationship with ' +
|
||||
'creates a bare (`None`) row to hold the flag.'
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return applyFlag(c, id, target, 'ignored', true)
|
||||
}
|
||||
)
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v1/unignore',
|
||||
flagToggle('Stop ignoring a player', 'Clears the caller’s `ignored` flag.'),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return applyFlag(c, id, target, 'ignored', false)
|
||||
}
|
||||
)
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v1/mute',
|
||||
flagToggle(
|
||||
'Mute a player',
|
||||
'Sets the caller’s `muted` flag. Like ignore, this works on a player you have no ' +
|
||||
'relationship with.'
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return applyFlag(c, id, target, 'muted', true)
|
||||
}
|
||||
)
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v1/unmute',
|
||||
flagToggle('Unmute a player', 'Clears the caller’s `muted` flag.'),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return applyFlag(c, id, target, 'muted', false)
|
||||
}
|
||||
)
|
||||
|
||||
// Favorite / unfavorite another player (the client calls these as a GET with the
|
||||
// target in `?id=`). Same per-side flag mechanics as ignore/mute above: the write
|
||||
// lands on the *caller's* side of the row, and favoriting someone you have no
|
||||
// relationship with creates a bare (None) row. Auth-gated.
|
||||
.on(['GET', 'POST'], '/api/relationships/v1/favorite', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return c.json(await setRelationshipFlag(c.env.DB, id, target, 'favorited', true))
|
||||
})
|
||||
.on(['GET', 'POST'], '/api/relationships/v1/unfavorite', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return c.json(await setRelationshipFlag(c.env.DB, id, target, 'favorited', false))
|
||||
})
|
||||
// relationship with creates a bare (None) row. Auth-gated. Result rides a
|
||||
// RelationshipChanged notification; the body is the { Success, Message } ack.
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v1/favorite',
|
||||
flagToggle(
|
||||
'Favorite a player',
|
||||
'Sets the caller’s `favorited` flag — what pins a player to the top of their friends ' +
|
||||
'list. Works on a player you have no relationship with.'
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return applyFlag(c, id, target, 'favorited', true)
|
||||
}
|
||||
)
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v1/unfavorite',
|
||||
flagToggle('Unfavorite a player', 'Clears the caller’s `favorited` flag.'),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return applyFlag(c, id, target, 'favorited', false)
|
||||
}
|
||||
)
|
||||
|
||||
.get('/api/messages/v2/get', (c) => c.json([]))
|
||||
.get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([]))
|
||||
.get(
|
||||
'/api/messages/v2/get',
|
||||
describeRoute({
|
||||
tags: ['Social'],
|
||||
summary: 'Direct messages',
|
||||
description: 'There is no message store yet, so this is always an empty list.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/api/messages/v1/favoriteFriendOnlineStatus',
|
||||
describeRoute({
|
||||
tags: ['Social'],
|
||||
summary: 'Online status of favorited friends',
|
||||
description:
|
||||
'Presence for the caller’s favorited friends. Presence lives in the `match` ' +
|
||||
'worker and is not joined in here yet, so this is an empty list.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
@@ -2,13 +2,31 @@ import { adminSecretsStore, env } from 'cloudflare:test'
|
||||
import { exports } from 'cloudflare:workers'
|
||||
import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
GAME_VERSION,
|
||||
grantInvention,
|
||||
INVENTORY_INVENTION_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import '../../api.app'
|
||||
|
||||
import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
|
||||
import {
|
||||
countGoing,
|
||||
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
|
||||
getEventAttendees,
|
||||
getEventResponse,
|
||||
} from '../../events-db'
|
||||
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
|
||||
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
||||
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
|
||||
import { getReportsAgainst, SCHEMA_DDL as REPORTS_SCHEMA_DDL } from '../../reports-db'
|
||||
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
import type { PlayerEvent, PlayerEventResult } from '../../events-db'
|
||||
import type { SavedImage } from '../../images-db'
|
||||
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
|
||||
|
||||
@@ -42,16 +60,13 @@ const TEST_ROOMS = [
|
||||
beforeAll(async () => {
|
||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||
await env.DB.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS room (
|
||||
data TEXT NOT NULL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
||||
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
|
||||
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
const insert = env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
|
||||
await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r))))
|
||||
// The rooms worker's schema (room + interaction) — reading a room aggregates its
|
||||
// cheer/favorite Stats from `interaction`, so both tables have to be here.
|
||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Subrooms live in their own table now; getRoomById hydrates from it, so create it and
|
||||
// split each seeded room's subrooms into it (mirrors the rooms worker's 0007 migration).
|
||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const r of TEST_ROOMS) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
||||
|
||||
// Accounts table (matching the auth worker's migration) — uploadsaved records
|
||||
// profile thumbnails on the account row. Seed the account the test token (sub
|
||||
@@ -77,6 +92,18 @@ beforeAll(async () => {
|
||||
|
||||
// Inventions table (owned by the api worker) — invention save/mine use it.
|
||||
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Bought-invention ownership (owned by the econ worker) — `v2/mine` folds it in.
|
||||
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Reports table (owned by the api worker) — player reports are recorded here.
|
||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Warnings table (owned by the api worker) — moderator-issued warnings land here.
|
||||
for (const stmt of WARNINGS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Player events table (owned by the api worker) — scheduled events live here.
|
||||
for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
|
||||
@@ -90,10 +117,13 @@ function b64url(input: ArrayBuffer | string): string {
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
async function bearer(sub = '42'): Promise<Record<string, string>> {
|
||||
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
|
||||
// off, the token carries none, which is what a plain player's looks like to the
|
||||
// role-gated routes.
|
||||
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||
JSON.stringify({ sub, exp: now + 3600 })
|
||||
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
|
||||
)}`
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -106,6 +136,12 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
|
||||
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
|
||||
}
|
||||
|
||||
/** Base64 SHA-256 — the form an invention version's `BlobHash` takes. */
|
||||
async function base64Sha256(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||
}
|
||||
|
||||
describe('public endpoints', () => {
|
||||
test('GET /api/config/v1/amplitude', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
|
||||
@@ -135,11 +171,16 @@ describe('public endpoints', () => {
|
||||
expect(body).toMatchObject({ ReportBudget: 125, VersionRegex: '.*' })
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/v4', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4`)
|
||||
test('GET /api/versioncheck/v4 reports current for the matching build', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${GAME_VERSION}`)
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
||||
})
|
||||
|
||||
test('GET /api/relationships/v2/get returns empty array for a player with none', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, {
|
||||
headers: await bearer('99999'),
|
||||
@@ -179,28 +220,13 @@ describe('public endpoints', () => {
|
||||
expect(reps.map((r) => r.AccountId)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/tagfilters returns empty filter chips', async () => {
|
||||
// No player-event storage → no tags in use → no chips. Trending is null.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/tagfilters`)
|
||||
test('GET /api/activities/charades/v1/words/Charades returns the word bank', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/activities/charades/v1/words/Charades`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
PinnedFilters: [],
|
||||
PopularFilters: [],
|
||||
TrendingFilters: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/clubs returns an empty event list', async () => {
|
||||
// The client deserializes this as a bare array — an envelope here fails with
|
||||
// "expected:'[', actual:'{'". No player-event storage yet → empty.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/clubs?id=1&id=2`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
|
||||
// The single-club form does wrap its events with a paging cursor.
|
||||
const one = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/club/1`)
|
||||
expect(one.status).toBe(200)
|
||||
expect(await one.json()).toEqual({ ContinuationToken: '', Events: [] })
|
||||
const words = (await res.json()) as Array<{ Id: number; Difficulty: number; EN_US: string }>
|
||||
expect(Array.isArray(words)).toBe(true)
|
||||
expect(words.length).toBeGreaterThan(0)
|
||||
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
|
||||
})
|
||||
|
||||
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
|
||||
@@ -235,7 +261,7 @@ describe('public endpoints', () => {
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /api/playerReputation/v2/bulk returns a reputation per id', async () => {
|
||||
@@ -295,6 +321,18 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toBe(true)
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v1/featured returns []', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/featured`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v1/hot returns []', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/hot`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v2/fromCreator/:id returns an empty paginated result', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -388,7 +426,7 @@ describe('public endpoints', () => {
|
||||
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Already .inv', inventionDataFilename: '2026-07-12/x.inv' }),
|
||||
body: JSON.stringify({ name: 'Already Suffixed', inventionDataFilename: '2026-07-12/x.inv' }),
|
||||
})
|
||||
expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe(
|
||||
'2026-07-12/x.inv'
|
||||
@@ -420,6 +458,49 @@ describe('public endpoints', () => {
|
||||
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v2/mine lists bought inventions alongside the caller’s own', async () => {
|
||||
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
|
||||
// exactly this row) and also creates one of their own.
|
||||
const save = async (sub: string, name: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, inventionDataFilename: `${name}.inv` }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as InventionSaveResult).Invention
|
||||
}
|
||||
const mine = async (sub: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as SavedInvention[]
|
||||
}
|
||||
|
||||
const bought = await save('6100', 'bought-invention')
|
||||
const own = await save('6101', 'own-invention')
|
||||
await grantInvention(env.DB, 6101, bought.InventionId)
|
||||
|
||||
// Newest first, whichever set it came from: 6101 saved theirs after buying.
|
||||
const list = await mine('6101')
|
||||
expect(list.map((i) => i.InventionId)).toEqual([own.InventionId, bought.InventionId])
|
||||
// A bought invention is still the creator's — it is listed, not re-attributed.
|
||||
expect(list.find((i) => i.InventionId === bought.InventionId)?.CreatorPlayerId).toBe(6100)
|
||||
// It is unpublished (a fresh save is), and stays on the buyer's shelf regardless.
|
||||
expect(list.find((i) => i.InventionId === bought.InventionId)?.IsPublished).toBe(false)
|
||||
|
||||
// The seller's own list is unaffected by the sale.
|
||||
expect((await mine('6100')).map((i) => i.InventionId)).toEqual([bought.InventionId])
|
||||
|
||||
// An ownership row pointing at an invention that no longer exists just drops out.
|
||||
await grantInvention(env.DB, 6101, 999_888)
|
||||
expect((await mine('6101')).map((i) => i.InventionId)).toEqual([
|
||||
own.InventionId,
|
||||
bought.InventionId,
|
||||
])
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
@@ -451,6 +532,51 @@ describe('public endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save enforces the name and description rules', async () => {
|
||||
const save = async (fields: Record<string, unknown>): Promise<Response> =>
|
||||
exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('6262')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inventionDataFilename: 'a.inv', ...fields }),
|
||||
})
|
||||
|
||||
// A name is 3–24 characters of letters, digits, spaces, dashes and colons.
|
||||
expect((await save({ name: 'ab' })).status).toBe(400)
|
||||
expect((await save({ name: 'a'.repeat(25) })).status).toBe(400)
|
||||
expect((await save({ name: 'Rocket!' })).status).toBe(400)
|
||||
expect((await save({ name: 'Café Lamp' })).status).toBe(400)
|
||||
const ok = await save({ name: 'Rocket Sofa-Bed 2' })
|
||||
expect(ok.status).toBe(200)
|
||||
expect(((await ok.json()) as InventionSaveResult).Invention.Name).toBe('Rocket Sofa-Bed 2')
|
||||
|
||||
// The rejection carries the player-facing sentence, not a code.
|
||||
const short = await save({ name: 'ab' })
|
||||
expect((await short.json()) as { error: string }).toEqual({
|
||||
error: 'Invention names must be at least 3 characters.',
|
||||
})
|
||||
|
||||
// A description is prose: any characters, at most 512 of them.
|
||||
expect((await save({ name: 'Long Winded', description: 'x'.repeat(513) })).status).toBe(400)
|
||||
expect((await save({ name: 'Long Winded', description: 'x'.repeat(512) })).status).toBe(200)
|
||||
expect((await save({ name: 'Punctuated', description: 'Yes! It’s 100% good.' })).status).toBe(
|
||||
200
|
||||
)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save accepts the client’s auto-generated timestamp name', async () => {
|
||||
// The real client names an unnamed invention after the moment it was saved
|
||||
// (`071126 13:10:50`, captured from a live save), so the colon is in the allowed name
|
||||
// charset on purpose. Dropping it from the pattern would 400 every unnamed save the
|
||||
// game makes — this test is what would catch that.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('6363')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inventionDataFilename: 'a.inv', name: '071126 13:10:50' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as InventionSaveResult).Invention.Name).toBe('071126 13:10:50')
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
|
||||
expect(res.status).toBe(404)
|
||||
@@ -530,6 +656,39 @@ describe('public endpoints', () => {
|
||||
})
|
||||
expect(await replaced.json()).toEqual({ Result: 0, Tags: ['modern', 'bed'] })
|
||||
|
||||
// A tag is at most 15 letters once lowercased. One bad tag in either list fails the
|
||||
// whole call — nothing is dropped silently — and leaves the stored tags alone.
|
||||
const punctuated = await settags({
|
||||
InventionId: Invention.InventionId,
|
||||
CustomTags: ['racing', 'Cool Stuff!'],
|
||||
})
|
||||
expect(punctuated.status).toBe(400)
|
||||
expect((await punctuated.json()) as { error: string }).toEqual({
|
||||
error: 'Invention tags can only contain letters. (“cool stuff!”)',
|
||||
})
|
||||
expect(
|
||||
(await settags({ InventionId: Invention.InventionId, AutoTags: ['a'.repeat(16)] })).status
|
||||
).toBe(400)
|
||||
expect(
|
||||
(await settags({ InventionId: Invention.InventionId, CustomTags: ['tag2'] })).status
|
||||
).toBe(400)
|
||||
const stillThere = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}`
|
||||
)
|
||||
expect(await stillThere.json()).toEqual({
|
||||
Tags: [
|
||||
{ Tag: 'modern', Type: 0 },
|
||||
{ Tag: 'bed', Type: 0 },
|
||||
],
|
||||
})
|
||||
|
||||
// Blank entries are skipped rather than rejected: the store already drops them.
|
||||
const padded = await settags({
|
||||
InventionId: Invention.InventionId,
|
||||
CustomTags: ['modern', '', ' '],
|
||||
})
|
||||
expect(await padded.json()).toEqual({ Result: 0, Tags: ['modern'] })
|
||||
|
||||
// Only the creator may retag; unknown inventions 404; no token → 401.
|
||||
const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999')
|
||||
expect(notMine.status).toBe(403)
|
||||
@@ -729,6 +888,12 @@ describe('public endpoints', () => {
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
|
||||
// The data file is uploaded (via the storage worker) before the metadata save,
|
||||
// so the version carries its hash from the start. No sha256 recorded on this
|
||||
// object — the api worker digests the blob itself in that case.
|
||||
const data = new Uint8Array([1, 2, 3, 4])
|
||||
await env.CDN_ASSETS.put('invention/2026-07-12/lamp.inv', data)
|
||||
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
|
||||
@@ -740,7 +905,8 @@ describe('public endpoints', () => {
|
||||
})
|
||||
const { Invention } = (await save.json()) as InventionSaveResult
|
||||
|
||||
// The bare RRInventionVersion — the blob name is what the client downloads.
|
||||
// The bare RRInventionVersion — the blob name is what the client downloads,
|
||||
// BlobHash the base64 SHA-256 of what it will download.
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
|
||||
)
|
||||
@@ -749,6 +915,7 @@ describe('public endpoints', () => {
|
||||
InventionId: Invention.InventionId,
|
||||
VersionNumber: 1,
|
||||
BlobName: '2026-07-12/lamp.inv',
|
||||
BlobHash: await base64Sha256(data),
|
||||
InstantiationCost: 42,
|
||||
})
|
||||
|
||||
@@ -771,6 +938,44 @@ describe('public endpoints', () => {
|
||||
expect(noId.status).toBe(400)
|
||||
})
|
||||
|
||||
test('BlobHash is null until the blob exists, then backfilled onto the invention', async () => {
|
||||
// Saved before the upload landed: nothing to hash, so the field stays null
|
||||
// rather than carrying a hash of something the client can't download.
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('7474')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Late Lamp', inventionDataFilename: '2026-07-12/late.inv' }),
|
||||
})
|
||||
const { Invention, InventionVersion } = (await save.json()) as InventionSaveResult
|
||||
expect(InventionVersion.BlobHash).toBeNull()
|
||||
|
||||
const version = async (): Promise<Record<string, unknown>> => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
|
||||
)
|
||||
return (await res.json()) as Record<string, unknown>
|
||||
}
|
||||
expect((await version()).BlobHash).toBeNull()
|
||||
|
||||
// Once the blob is there the hash resolves — here from the checksum recorded at
|
||||
// upload time (what the storage worker puts), not by digesting the body.
|
||||
const data = new Uint8Array([9, 8, 7])
|
||||
await env.CDN_ASSETS.put('invention/2026-07-12/late.inv', data, {
|
||||
sha256: await crypto.subtle.digest('SHA-256', data),
|
||||
})
|
||||
const hash = await base64Sha256(data)
|
||||
expect((await version()).BlobHash).toBe(hash)
|
||||
|
||||
// And it's kept, so the other invention endpoints serve it too — without the
|
||||
// read counting as an edit (ModifiedAt is untouched).
|
||||
const details = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${Invention.InventionId}`
|
||||
)
|
||||
const stored = (await details.json()) as SavedInvention
|
||||
expect(stored.CurrentVersion.BlobHash).toBe(hash)
|
||||
expect(stored.ModifiedAt).toBe(Invention.ModifiedAt)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
@@ -812,6 +1017,16 @@ describe('public endpoints', () => {
|
||||
const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult
|
||||
expect(cleared.Invention).toMatchObject({ Description: '', Name: 'Draft Lamp' })
|
||||
|
||||
// A supplied name/description is held to the same rules as the save path, and a
|
||||
// rejected edit changes nothing.
|
||||
expect((await update('name=xy')).status).toBe(400)
|
||||
expect((await update(`name=${encodeURIComponent('Lamp?')}`)).status).toBe(400)
|
||||
expect((await update(`description=${'x'.repeat(513)}`)).status).toBe(400)
|
||||
const unchanged = (await (await update('permission=20')).json()) as InventionSaveResult
|
||||
expect(unchanged.Invention).toMatchObject({ Name: 'Draft Lamp', Description: '' })
|
||||
const renamed = (await (await update('name=Draft-Lamp%20Two')).json()) as InventionSaveResult
|
||||
expect(renamed.Invention.Name).toBe('Draft-Lamp Two')
|
||||
|
||||
// allowTrial takes true/1.
|
||||
const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult
|
||||
expect(trial.Invention.AllowTrial).toBe(true)
|
||||
@@ -923,12 +1138,15 @@ describe('public endpoints', () => {
|
||||
const ids = async (res: Response): Promise<number[]> =>
|
||||
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
|
||||
|
||||
// Nothing is flagged IsFeatured yet → featured falls back to the top feed.
|
||||
const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
|
||||
const beforeFeatured = await ids(
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`)
|
||||
// Both feeds start EMPTY, for different reasons: nothing is flagged IsFeatured, and
|
||||
// the only inventions acquired so far in this file are an unpublished one and an id
|
||||
// with no invention row — neither of which a public feed may show.
|
||||
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))).toEqual(
|
||||
[]
|
||||
)
|
||||
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))).toEqual(
|
||||
[]
|
||||
)
|
||||
expect(beforeFeatured).toEqual(beforeTop)
|
||||
|
||||
const feedInvention = (
|
||||
id: number,
|
||||
@@ -966,19 +1184,42 @@ describe('public endpoints', () => {
|
||||
.run()
|
||||
}
|
||||
|
||||
// Top: engagement-ranked, so the biggest download counts lead.
|
||||
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
|
||||
expect(top.slice(0, 3)).toEqual([202, 203, 201])
|
||||
expect(top).not.toContain(204)
|
||||
expect(top).not.toContain(205)
|
||||
// Recent acquisitions, which is what "top today" now counts: 201 picked up by three
|
||||
// players, 203 by one. 204/205 are acquired too — an unpublished and a hidden
|
||||
// invention can still be owned — and must not surface in a public feed.
|
||||
for (const accountId of [7001, 7002, 7003]) await grantInvention(env.DB, accountId, 201)
|
||||
await grantInvention(env.DB, 7001, 203)
|
||||
await grantInvention(env.DB, 7001, 204)
|
||||
await grantInvention(env.DB, 7002, 205)
|
||||
// 202 was acquired 25 hours ago, just past the trailing 24-hour window, so it is out —
|
||||
// the feed really does forget, rather than accumulating every acquisition ever.
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)'
|
||||
)
|
||||
.bind(7004, 202, new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString())
|
||||
.run()
|
||||
|
||||
// Featured: only the flagged, visible inventions — newest first.
|
||||
// Top: most acquisitions in the window first. Download counts no longer rank anything —
|
||||
// 202 has the biggest of them and is absent entirely.
|
||||
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
|
||||
expect(top).toEqual([201, 203])
|
||||
|
||||
// Featured: only the flagged, visible inventions — newest first. 201 is published but
|
||||
// unflagged, so it stays out however popular it is.
|
||||
const featured = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))
|
||||
expect(featured).toEqual([203, 202])
|
||||
|
||||
// skip/take paginate the top feed.
|
||||
// skip/take paginate both feeds.
|
||||
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
|
||||
expect(await ids(page)).toEqual([203])
|
||||
// Pagination happens after the visibility filter, so the hidden/unpublished
|
||||
// acquisitions don't leave holes in a page.
|
||||
const firstPage = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?take=1`)
|
||||
expect(await ids(firstPage)).toEqual([201])
|
||||
const featuredPage = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/featured?skip=1&take=1`
|
||||
)
|
||||
expect(await ids(featuredPage)).toEqual([202])
|
||||
})
|
||||
|
||||
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
|
||||
@@ -1010,6 +1251,179 @@ describe('auth-gated endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('player reports', () => {
|
||||
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
|
||||
test('POST /api/PlayerReporting/v3/create records the report', async () => {
|
||||
const res = await submit(
|
||||
{
|
||||
PlayerIdReported: '205',
|
||||
ReportCategory: '100',
|
||||
Details: 'ya know',
|
||||
HeightReporter: '1.64',
|
||||
HeightReported: '1.65',
|
||||
RoomId: '58',
|
||||
RoomInstanceType: 'Public',
|
||||
},
|
||||
await bearer()
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
// `error` is an empty string, not null — the real service's envelope.
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
const [row] = await getReportsAgainst(env.DB, 205)
|
||||
expect(row).toMatchObject({
|
||||
// The reporter is the token's subject, not a body field.
|
||||
reporter_player_id: 42,
|
||||
reported_player_id: 205,
|
||||
report_category: 100,
|
||||
details: 'ya know',
|
||||
height_reporter: 1.64,
|
||||
height_reported: 1.65,
|
||||
room_id: 58,
|
||||
room_instance_type: 'Public',
|
||||
})
|
||||
expect(row?.created_at).toBeTruthy()
|
||||
})
|
||||
|
||||
// Everything but the reported player is optional — a report raised outside a room
|
||||
// carries no RoomId, and 0 means "no room" rather than room zero.
|
||||
test('POST /api/PlayerReporting/v3/create stores absent fields as null', async () => {
|
||||
const res = await submit({ PlayerIdReported: '206', RoomId: '0' }, await bearer())
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const [row] = await getReportsAgainst(env.DB, 206)
|
||||
expect(row).toMatchObject({
|
||||
reporter_player_id: 42,
|
||||
reported_player_id: 206,
|
||||
report_category: 0,
|
||||
details: null,
|
||||
height_reporter: null,
|
||||
height_reported: null,
|
||||
room_id: null,
|
||||
room_instance_type: null,
|
||||
})
|
||||
})
|
||||
|
||||
// Append-only: a second report against the same player is a second row.
|
||||
test('POST /api/PlayerReporting/v3/create appends rather than dedupes', async () => {
|
||||
await submit({ PlayerIdReported: '207', Details: 'first' }, await bearer())
|
||||
await submit({ PlayerIdReported: '207', Details: 'second' }, await bearer())
|
||||
const rows = await getReportsAgainst(env.DB, 207)
|
||||
expect(rows).toHaveLength(2)
|
||||
// Newest first.
|
||||
expect(rows.map((r) => r.details)).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
test('POST /api/PlayerReporting/v3/create 401s without a bearer token', async () => {
|
||||
const res = await submit({ PlayerIdReported: '205' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/PlayerReporting/v3/create 400s without a reported player', async () => {
|
||||
const res = await submit({ Details: 'ya know' }, await bearer())
|
||||
expect(res.status).toBe(400)
|
||||
// Same envelope as the success branch — the client parses only one shape.
|
||||
expect(await res.json()).toEqual({ success: false, error: 'PlayerIdReported is required' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('player warnings', () => {
|
||||
const MOD = ['gameClient', 'moderator']
|
||||
|
||||
const issue = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/playerwarnings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
|
||||
test('POST /api/playerwarnings records the warning', async () => {
|
||||
const res = await issue(
|
||||
{
|
||||
WarnedPlayerId: '205',
|
||||
ReportCategory: '101',
|
||||
DisplayReason: 'Sexual gestures',
|
||||
ModeratorNote: 'dfg',
|
||||
},
|
||||
await bearer('42', MOD)
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
const [row] = await getWarningsAgainst(env.DB, 205)
|
||||
expect(row).toMatchObject({
|
||||
// The moderator is the token's subject, not a body field.
|
||||
moderator_player_id: 42,
|
||||
warned_player_id: 205,
|
||||
report_category: 101,
|
||||
display_reason: 'Sexual gestures',
|
||||
moderator_note: 'dfg',
|
||||
})
|
||||
expect(row?.created_at).toBeTruthy()
|
||||
})
|
||||
|
||||
test('POST /api/playerwarnings stores absent fields as null', async () => {
|
||||
const res = await issue({ WarnedPlayerId: '206' }, await bearer('42', MOD))
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const [row] = await getWarningsAgainst(env.DB, 206)
|
||||
expect(row).toMatchObject({
|
||||
warned_player_id: 206,
|
||||
report_category: 0,
|
||||
display_reason: null,
|
||||
moderator_note: null,
|
||||
})
|
||||
})
|
||||
|
||||
// Append-only, like reports: warning the same player twice is two rows.
|
||||
test('POST /api/playerwarnings appends rather than dedupes', async () => {
|
||||
await issue({ WarnedPlayerId: '207', ModeratorNote: 'first' }, await bearer('42', MOD))
|
||||
await issue({ WarnedPlayerId: '207', ModeratorNote: 'second' }, await bearer('42', MOD))
|
||||
const rows = await getWarningsAgainst(env.DB, 207)
|
||||
expect(rows).toHaveLength(2)
|
||||
// Newest first.
|
||||
expect(rows.map((r) => r.moderator_note)).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
test('POST /api/playerwarnings 401s without a bearer token', async () => {
|
||||
const res = await issue({ WarnedPlayerId: '205' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
// A valid token is not enough — a plain player's carries neither staff role.
|
||||
// Nothing is written on the rejected branch.
|
||||
test('POST /api/playerwarnings 403s without a staff role', async () => {
|
||||
for (const roles of [undefined, ['gameClient']]) {
|
||||
const res = await issue({ WarnedPlayerId: '208' }, await bearer('42', roles))
|
||||
expect(res.status).toBe(403)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
expect(await getWarningsAgainst(env.DB, 208)).toHaveLength(0)
|
||||
})
|
||||
|
||||
// `developer` gets in as well as `moderator` — staff hold both.
|
||||
test('POST /api/playerwarnings accepts the developer role', async () => {
|
||||
const res = await issue(
|
||||
{ WarnedPlayerId: '209' },
|
||||
await bearer('42', ['gameClient', 'developer'])
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await getWarningsAgainst(env.DB, 209)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('POST /api/playerwarnings 400s without a warned player', async () => {
|
||||
const res = await issue({ ModeratorNote: 'dfg' }, await bearer('42', MOD))
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'WarnedPlayerId is required' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rooms', () => {
|
||||
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
|
||||
const verify = async (fields: Record<string, string>, sub?: string): Promise<boolean> => {
|
||||
@@ -1044,6 +1458,7 @@ describe('images', () => {
|
||||
test('POST /api/images/v4/uploadsaved stores the file in R2 and returns its name', async () => {
|
||||
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])
|
||||
const fd = new FormData()
|
||||
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 })) // ShareCamera
|
||||
fd.append('image', new File([bytes], 'avatar.png', { type: 'image/png' }))
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
|
||||
@@ -1053,8 +1468,9 @@ describe('images', () => {
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const { ImageName } = (await res.json()) as { ImageName: string }
|
||||
// Keyed by <type>/<date>/<uuid>.<ext> (the type folder mirrors the CDN layout).
|
||||
expect(ImageName).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/
|
||||
/^sharecamera\/\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/
|
||||
)
|
||||
|
||||
// The object is in the shared bucket under that key.
|
||||
@@ -1072,10 +1488,7 @@ describe('images', () => {
|
||||
expect(meta.CheerCount).toBe(0)
|
||||
})
|
||||
|
||||
test('GET /api/images/v1/slideshow is auth-gated and joins username + room name', async () => {
|
||||
// No token → 401.
|
||||
expect((await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`)).status).toBe(401)
|
||||
|
||||
test('GET /api/images/v1/slideshow is public and joins username + room name', async () => {
|
||||
// Seed a public image (Accessibility 1) taken in RecCenter (room 2) by account 42.
|
||||
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
|
||||
.bind(
|
||||
@@ -1097,9 +1510,8 @@ describe('images', () => {
|
||||
)
|
||||
.run()
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`, {
|
||||
headers: await bearer(),
|
||||
})
|
||||
// No token — the slideshow is public.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Images: Array<Record<string, unknown>>
|
||||
@@ -1119,26 +1531,116 @@ describe('images', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/images/v1/cheer is auth-gated and stubs success', async () => {
|
||||
const body = JSON.stringify({ SavedImageId: 2, Cheer: true })
|
||||
// The feed is public and unauthenticated, so `take` is clamped rather than trusted:
|
||||
// without the cap a single anonymous request could pull the whole image table through
|
||||
// the two joins behind it.
|
||||
test('GET /api/images/v1/slideshow serves 10 by default and caps take at 100', async () => {
|
||||
// 120 public ShareCamera photos — more than both the default and the cap.
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await createImage(env.DB, { imageName: `bulkslide${i}.jpg`, playerId: 42 })
|
||||
}
|
||||
const feed = async (query: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow${query}`)
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as { Images: unknown[] }).Images.length
|
||||
}
|
||||
|
||||
expect(await feed('')).toBe(10)
|
||||
expect(await feed('?take=25')).toBe(25)
|
||||
expect(await feed('?take=500')).toBe(100)
|
||||
// Junk and non-positive takes fall back rather than erroring or emptying the stage.
|
||||
expect(await feed('?take=0')).toBe(10)
|
||||
expect(await feed('?take=-5')).toBe(10)
|
||||
expect(await feed('?take=lots')).toBe(10)
|
||||
})
|
||||
|
||||
test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
|
||||
// Seed an image to cheer.
|
||||
// Its own player id: 700's photos are asserted on exactly in the player-list test.
|
||||
const img = await createImage(env.DB, { imageName: 'cheerme.jpg', playerId: 7001 })
|
||||
const cheerBody = JSON.stringify({ SavedImageId: img.Id, Cheer: true })
|
||||
|
||||
// No token → 401.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
body: cheerBody,
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
// With a token → accepted.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
|
||||
|
||||
const cheer = async (cheerVal: boolean, sub = '42') =>
|
||||
exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ SavedImageId: img.Id, Cheer: cheerVal }),
|
||||
})
|
||||
const cheerCount = async (): Promise<number> => {
|
||||
const row = await env.DB.prepare('SELECT data FROM image WHERE id = ?1')
|
||||
.bind(img.Id)
|
||||
.first<{ data: string }>()
|
||||
return (JSON.parse(row!.data) as { CheerCount: number }).CheerCount
|
||||
}
|
||||
|
||||
// Account 42 cheers → CheerCount syncs to 1 (a real integer, not 1.0).
|
||||
expect((await cheer(true)).status).toBe(200)
|
||||
const rawAfter = await env.DB.prepare('SELECT data FROM image WHERE id = ?1')
|
||||
.bind(img.Id)
|
||||
.first<{ data: string }>()
|
||||
expect(rawAfter!.data).toContain('"CheerCount":1')
|
||||
expect(rawAfter!.data).not.toContain('"CheerCount":1.0')
|
||||
expect(await cheerCount()).toBe(1)
|
||||
|
||||
// Re-cheering is idempotent on the count.
|
||||
await cheer(true)
|
||||
expect(await cheerCount()).toBe(1)
|
||||
|
||||
// Un-cheer → count back to 0.
|
||||
await cheer(false)
|
||||
expect(await cheerCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('GET /api/images/v5/cheered/bulk reports per-id cheer state for the caller (auth-gated)', async () => {
|
||||
const img = await createImage(env.DB, { imageName: 'bulkcheer.jpg', playerId: 701 })
|
||||
const other = 999999
|
||||
|
||||
// No token → 401.
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}/api/images/v5/cheered/bulk?id=${img.Id}`)).status
|
||||
).toBe(401)
|
||||
|
||||
const bulk = async (sub: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(
|
||||
`${ORIGIN}/api/images/v5/cheered/bulk?id=${img.Id}&id=${other}`,
|
||||
{ headers: await bearer(sub) }
|
||||
)
|
||||
).json()) as Array<{ SavedImageId: number; IsCheered: boolean }>
|
||||
|
||||
// Before cheering: one entry per requested id, in order, all false.
|
||||
expect(await bulk('42')).toEqual([
|
||||
{ SavedImageId: img.Id, IsCheered: false },
|
||||
{ SavedImageId: other, IsCheered: false },
|
||||
])
|
||||
|
||||
// Account 42 cheers the image.
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
|
||||
body,
|
||||
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ SavedImageId: img.Id, Cheer: true }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
// The cheerer sees it cheered; a different player does not.
|
||||
expect((await bulk('42')).find((x) => x.SavedImageId === img.Id)?.IsCheered).toBe(true)
|
||||
expect((await bulk('43')).find((x) => x.SavedImageId === img.Id)?.IsCheered).toBe(false)
|
||||
|
||||
// No ids → empty array.
|
||||
const empty = await exports.default.fetch(`${ORIGIN}/api/images/v5/cheered/bulk`, {
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
expect(await empty.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => {
|
||||
@@ -1200,8 +1702,9 @@ describe('images', () => {
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const { ImageName } = (await res.json()) as { ImageName: string }
|
||||
// Type 4 → the `profile/` type folder, then <date>/<uuid>.<ext>.
|
||||
expect(ImageName).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$/
|
||||
/^profile\/\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$/
|
||||
)
|
||||
|
||||
// The account row now points its profileImage at the uploaded key.
|
||||
@@ -1211,6 +1714,62 @@ describe('images', () => {
|
||||
expect(JSON.parse(row!.data).profileImage).toBe(ImageName)
|
||||
})
|
||||
|
||||
test('DELETE /api/images/v1/deletesaved removes the owner’s image (row + cheers + R2)', async () => {
|
||||
const ImageName = 'sharecamera/2026-07-17/delete-me.jpg'
|
||||
await env.IMAGES.put(ImageName, new Uint8Array([1, 2, 3]))
|
||||
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
Id: 8100,
|
||||
Type: 1,
|
||||
Accessibility: 1,
|
||||
AccessibilityLocked: false,
|
||||
ImageName,
|
||||
Description: null,
|
||||
PlayerId: 42, // owned by the default bearer account
|
||||
TaggedPlayerIds: [],
|
||||
RoomId: null,
|
||||
PlayerEventId: null,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
CheerCount: 1,
|
||||
CommentCount: 0,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO image_interaction (player_id, saved_image_id, cheered) VALUES (99, 8100, 1)'
|
||||
).run()
|
||||
|
||||
const del = (headers: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify({ ImageName }),
|
||||
})
|
||||
|
||||
// No token → 401; a different account → 403 (still present afterwards).
|
||||
expect((await del({})).status).toBe(401)
|
||||
expect((await del(await bearer('43'))).status).toBe(403)
|
||||
expect(await getImageByName(env.DB, ImageName)).not.toBeNull()
|
||||
|
||||
// Unknown image → 404.
|
||||
const unknown = await exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', ...(await bearer('42')) },
|
||||
body: JSON.stringify({ ImageName: 'sharecamera/nope.jpg' }),
|
||||
})
|
||||
expect(unknown.status).toBe(404)
|
||||
|
||||
// Owner → 200, and the row, its cheers, and the R2 object are all gone.
|
||||
expect((await del(await bearer('42'))).status).toBe(200)
|
||||
expect(await getImageByName(env.DB, ImageName)).toBeNull()
|
||||
expect(await env.IMAGES.get(ImageName)).toBeNull()
|
||||
const cheers = await env.DB.prepare(
|
||||
'SELECT COUNT(*) AS n FROM image_interaction WHERE saved_image_id = 8100'
|
||||
).first<{ n: number }>()
|
||||
expect(cheers!.n).toBe(0)
|
||||
})
|
||||
|
||||
test('POST /api/images/v4/uploadsaved 401s without a bearer token', async () => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' }))
|
||||
@@ -1323,23 +1882,47 @@ describe('images', () => {
|
||||
seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }),
|
||||
])
|
||||
|
||||
// The lists serve the client's ImagesPlayer projection: the id and type are
|
||||
// SavedImageId/SavedImageType, and TaggedPlayerIds isn't part of it.
|
||||
type ImagesPlayer = { SavedImageId: number; SavedImageType: number; ImageName: string }
|
||||
|
||||
// v4/player → only photos 700 *took*, public, newest first.
|
||||
const mine = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`)
|
||||
).json()) as SavedImage[]
|
||||
expect(mine.map((i) => i.Id)).toEqual([202, 201])
|
||||
).json()) as ImagesPlayer[]
|
||||
expect(mine.map((i) => i.SavedImageId)).toEqual([202, 201])
|
||||
expect(mine[0]).toEqual({
|
||||
Accessibility: 1,
|
||||
AccessibilityLocked: false,
|
||||
CheerCount: 0,
|
||||
CommentCount: 0,
|
||||
CreatedAt: '2026-04-01T00:00:00.000Z',
|
||||
Description: null,
|
||||
ImageName: 'p202.jpg',
|
||||
PlayerEventId: null,
|
||||
PlayerId: 700,
|
||||
RoomId: null,
|
||||
SavedImageId: 202,
|
||||
SavedImageType: 1,
|
||||
})
|
||||
|
||||
// take paginates.
|
||||
const one = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700?take=1`)
|
||||
).json()) as SavedImage[]
|
||||
expect(one.map((i) => i.Id)).toEqual([202])
|
||||
).json()) as ImagesPlayer[]
|
||||
expect(one.map((i) => i.SavedImageId)).toEqual([202])
|
||||
|
||||
// v5/player is the same list with a sort option (0 = newest first).
|
||||
const sorted = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v5/player/700?sort=0`)
|
||||
).json()) as ImagesPlayer[]
|
||||
expect(sorted.map((i) => i.SavedImageId)).toEqual([202, 201])
|
||||
|
||||
// v3/feed/player → photos taken *or* tagged in, newest first (204 is newest).
|
||||
const feed = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700?take=100`)
|
||||
).json()) as SavedImage[]
|
||||
expect(feed.map((i) => i.Id)).toEqual([204, 202, 201])
|
||||
).json()) as ImagesPlayer[]
|
||||
expect(feed.map((i) => i.SavedImageId)).toEqual([204, 202, 201])
|
||||
|
||||
// A player with no photos → empty array on both.
|
||||
expect(
|
||||
@@ -1371,6 +1954,60 @@ describe('relationships', () => {
|
||||
return (await res.json()) as Rel[]
|
||||
}
|
||||
|
||||
// Standard ack the flag endpoints (favorite/ignore/mute + inverses) now return —
|
||||
// the relationship detail rides a RelationshipChanged hub notification instead.
|
||||
const ACK = { Success: true, Message: '' }
|
||||
|
||||
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
|
||||
type Notification = {
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: { PlayerID: number; RelationshipType: number; Favorited: number; Ignored: number }
|
||||
}
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
|
||||
/** Drop everything the hub stub has recorded so far. */
|
||||
async function resetNotifications() {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Every notification pushed since the last reset, in order. */
|
||||
async function sentNotifications(): Promise<Notification[]> {
|
||||
return (await (await hub().fetch('http://do/all')).json()) as Notification[]
|
||||
}
|
||||
|
||||
// POST a flag mutation the real client way (form body `PlayerId=<id>`), returning
|
||||
// the parsed ack body.
|
||||
async function ackFlag(path: string, sub: string, playerId: number) {
|
||||
return (await (
|
||||
await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `PlayerId=${playerId}`,
|
||||
})
|
||||
).json()) as { Success: boolean; Message: string }
|
||||
}
|
||||
|
||||
// A player's own-side flags read straight from the relationship row — the flag
|
||||
// endpoints return only an ack, so the effect is verified against the row itself.
|
||||
async function ownFlags(playerId: number, otherId: number) {
|
||||
const row = (await env.DB.prepare(
|
||||
`SELECT requester_id, requester_favorited, requester_ignored, requester_muted,
|
||||
target_favorited, target_ignored, target_muted
|
||||
FROM relationship
|
||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||
)
|
||||
.bind(playerId, otherId)
|
||||
.first()) as Record<string, number> | null
|
||||
if (!row) return null
|
||||
const isRequester = row.requester_id === playerId
|
||||
return {
|
||||
Favorited: isRequester ? row.requester_favorited : row.target_favorited,
|
||||
Ignored: isRequester ? row.requester_ignored : row.target_ignored,
|
||||
Muted: isRequester ? row.requester_muted : row.target_muted,
|
||||
}
|
||||
}
|
||||
|
||||
test('GET /api/relationships/v2/get is auth-gated', async () => {
|
||||
expect((await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`)).status).toBe(401)
|
||||
})
|
||||
@@ -1418,10 +2055,27 @@ describe('relationships', () => {
|
||||
{ PlayerID: 500, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
])
|
||||
|
||||
// 500 removes → neither side has a relationship.
|
||||
// 500 removes → both sides drop to None. The row is kept (that's where the
|
||||
// per-side flags live), so v2/get still reports the pair, now as None (0).
|
||||
expect((await mutate('/api/relationships/v2/removefriend', '500', 501)).status).toBe(200)
|
||||
expect(await relationships('500')).toEqual([])
|
||||
expect(await relationships('501')).toEqual([])
|
||||
expect(await relationships('500')).toEqual([
|
||||
{ PlayerID: 501, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
])
|
||||
expect(await relationships('501')).toEqual([
|
||||
{ PlayerID: 500, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
test('removefriend keeps the caller’s ignore flag', async () => {
|
||||
// 760 befriends 761 then ignores them; dropping the friendship must not
|
||||
// un-ignore them (the flag lives on the row the removal downgrades to None).
|
||||
await mutate('/api/relationships/v2/addfriend', '760', 761)
|
||||
await ackFlag('/api/relationships/v1/ignore', '760', 761)
|
||||
await mutate('/api/relationships/v2/removefriend', '760', 761)
|
||||
expect(await ownFlags(760, 761)).toMatchObject({ Ignored: 1 })
|
||||
expect(await relationships('760')).toEqual([
|
||||
{ PlayerID: 761, RelationshipType: 0, Favorited: 0, Ignored: 1, Muted: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
test('addfriend makes them friends directly', async () => {
|
||||
@@ -1450,64 +2104,57 @@ describe('relationships', () => {
|
||||
|
||||
test('v1 ignore/mute set the caller’s own side of the relationship', async () => {
|
||||
type FullRel = { PlayerID: number; RelationshipType: number; Ignored: number; Muted: number }
|
||||
// POST the real client shape: form body `PlayerId=<id>`.
|
||||
const flag = async (path: string, sub: string, playerId: number) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `PlayerId=${playerId}`,
|
||||
})
|
||||
).json()) as FullRel
|
||||
|
||||
// 700 ignores 701 with no prior relationship → a bare None row, the caller's side flagged.
|
||||
expect(await flag('/api/relationships/v1/ignore', '700', 701)).toMatchObject({
|
||||
PlayerID: 701,
|
||||
RelationshipType: 0,
|
||||
Ignored: 1,
|
||||
Muted: 0,
|
||||
})
|
||||
// 700 ignores 701 with no prior relationship → a bare None row, the caller's side
|
||||
// flagged. The response is now just the ack; the flag is verified on the row.
|
||||
expect(await ackFlag('/api/relationships/v1/ignore', '700', 701)).toEqual(ACK)
|
||||
expect(await ownFlags(700, 701)).toMatchObject({ Ignored: 1, Muted: 0 })
|
||||
// 700 then mutes 701 → same row, mute added, the earlier ignore preserved.
|
||||
expect(await flag('/api/relationships/v1/mute', '700', 701)).toMatchObject({
|
||||
PlayerID: 701,
|
||||
Ignored: 1,
|
||||
Muted: 1,
|
||||
})
|
||||
expect(await ackFlag('/api/relationships/v1/mute', '700', 701)).toEqual(ACK)
|
||||
expect(await ownFlags(700, 701)).toMatchObject({ Ignored: 1, Muted: 1 })
|
||||
|
||||
// The tricky case: the caller is the row's TARGET. 710 sends 711 a request
|
||||
// (710 = requester); 711 ignoring 710 must flag the target side, not the requester's.
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '710', 711)
|
||||
expect(await flag('/api/relationships/v1/ignore', '711', 710)).toMatchObject({
|
||||
PlayerID: 710,
|
||||
RelationshipType: 2, // 711 sees 710's request as Received
|
||||
Ignored: 1,
|
||||
})
|
||||
expect(await ackFlag('/api/relationships/v1/ignore', '711', 710)).toEqual(ACK)
|
||||
// 711 sees 710's request as Received (2) with their own Ignored set.
|
||||
expect((await relationships('711')) as unknown as FullRel[]).toEqual([
|
||||
expect.objectContaining({ PlayerID: 710, RelationshipType: 2, Ignored: 1 }),
|
||||
])
|
||||
// 710's own side is untouched — the requester never ignored anyone.
|
||||
const view710 = (await relationships('710')) as unknown as FullRel[]
|
||||
expect(view710).toEqual([
|
||||
expect((await relationships('710')) as unknown as FullRel[]).toEqual([
|
||||
expect.objectContaining({ PlayerID: 711, RelationshipType: 1, Ignored: 0 }),
|
||||
])
|
||||
})
|
||||
|
||||
test('v1 unignore/unmute clear the caller’s own flags independently', async () => {
|
||||
// 800 ignores and mutes 801 (bare None row, both flags on the caller's side).
|
||||
await ackFlag('/api/relationships/v1/ignore', '800', 801)
|
||||
await ackFlag('/api/relationships/v1/mute', '800', 801)
|
||||
expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 1, Muted: 1 })
|
||||
// unignore clears only Ignored; the mute is left in place.
|
||||
expect(await ackFlag('/api/relationships/v1/unignore', '800', 801)).toEqual(ACK)
|
||||
expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 0, Muted: 1 })
|
||||
// unmute then clears Muted too.
|
||||
expect(await ackFlag('/api/relationships/v1/unmute', '800', 801)).toEqual(ACK)
|
||||
expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 0, Muted: 0 })
|
||||
})
|
||||
|
||||
test('v1 favorite/unfavorite toggle the caller’s own side, leaving the friendship intact', async () => {
|
||||
// 720 and 721 are friends; 720 favorites 721 — the real client shape, a GET with `?id=`.
|
||||
await mutate('/api/relationships/v2/addfriend', '720', 721)
|
||||
expect(
|
||||
(await (await mutate('/api/relationships/v1/favorite', '720', 721)).json()) as Rel
|
||||
).toMatchObject({ PlayerID: 721, RelationshipType: 3, Favorited: 1 })
|
||||
|
||||
expect(await (await mutate('/api/relationships/v1/favorite', '720', 721)).json()).toEqual(ACK)
|
||||
// 720's own side is favorited; the friendship is intact.
|
||||
expect(await relationships('720')).toEqual([
|
||||
{ PlayerID: 721, RelationshipType: 3, Favorited: 1, Ignored: 0, Muted: 0 },
|
||||
])
|
||||
// Favoriting is one-sided: 721 does not see themselves as having favorited 720.
|
||||
expect(await relationships('721')).toEqual([
|
||||
{ PlayerID: 720, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
])
|
||||
|
||||
// Unfavorite clears the flag but keeps the friendship.
|
||||
expect(
|
||||
(await (await mutate('/api/relationships/v1/unfavorite', '720', 721)).json()) as Rel
|
||||
).toMatchObject({ PlayerID: 721, RelationshipType: 3, Favorited: 0 })
|
||||
expect(await (await mutate('/api/relationships/v1/unfavorite', '720', 721)).json()).toEqual(ACK)
|
||||
expect(await relationships('720')).toEqual([
|
||||
{ PlayerID: 721, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
])
|
||||
@@ -1515,14 +2162,906 @@ describe('relationships', () => {
|
||||
|
||||
test('favoriting a player you have no relationship with is allowed', async () => {
|
||||
// Mirrors ignore/mute: a bare None row is created with the caller's side flagged.
|
||||
expect(
|
||||
(await (await mutate('/api/relationships/v1/favorite', '730', 731)).json()) as Rel
|
||||
).toMatchObject({ PlayerID: 731, RelationshipType: 0, Favorited: 1 })
|
||||
// A None row is not reported as a relationship by v2/get.
|
||||
expect(await relationships('730')).toEqual([])
|
||||
expect(await (await mutate('/api/relationships/v1/favorite', '730', 731)).json()).toEqual(ACK)
|
||||
expect(await ownFlags(730, 731)).toMatchObject({ Favorited: 1 })
|
||||
// The bare None row is reported by v2/get — it carries the flag.
|
||||
expect(await relationships('730')).toEqual([
|
||||
{ PlayerID: 731, RelationshipType: 0, Favorited: 1, Ignored: 0, Muted: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
test('a self-targeted favorite is rejected', async () => {
|
||||
expect((await mutate('/api/relationships/v1/favorite', '740', 740)).status).toBe(400)
|
||||
})
|
||||
|
||||
test('a flag change pushes a RelationshipChanged notification with the relationship', async () => {
|
||||
// The relationship detail now rides a hub notification instead of the response.
|
||||
// The notify DO is stubbed to record its last notifyPlayer call (see vitest.config).
|
||||
await ackFlag('/api/relationships/v1/favorite', '750', 751)
|
||||
const res = await env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/last')
|
||||
const last = (await res.json()) as {
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: { PlayerID: number; Favorited: number; RelationshipType: number }
|
||||
}
|
||||
expect(last.playerId).toBe(750) // sent to the caller
|
||||
expect(last.notificationType).toBe(1) // NotificationType.RelationshipChanged
|
||||
expect(last.data).toMatchObject({ PlayerID: 751, Favorited: 1, RelationshipType: 0 })
|
||||
})
|
||||
|
||||
test('sendfriendrequest notifies both players with their own projection', async () => {
|
||||
await resetNotifications()
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '770', 771)
|
||||
|
||||
// Both sides hear about it, each seeing the other player and their own side's
|
||||
// type: the sender Sent (1), the recipient Received (2).
|
||||
expect(await sentNotifications()).toEqual([
|
||||
{
|
||||
playerId: 770,
|
||||
notificationType: 1,
|
||||
data: { PlayerID: 771, RelationshipType: 1, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
},
|
||||
{
|
||||
playerId: 771,
|
||||
notificationType: 1,
|
||||
data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('accepting notifies both players as Friend', async () => {
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '780', 781)
|
||||
await resetNotifications()
|
||||
await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780)
|
||||
|
||||
const sent = await sentNotifications()
|
||||
expect(sent).toHaveLength(2)
|
||||
// Friend (3) is symmetric, so both sides see the same type, each pointing at the other.
|
||||
expect(sent).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
playerId: 780,
|
||||
data: expect.objectContaining({ PlayerID: 781, RelationshipType: 3 }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
playerId: 781,
|
||||
data: expect.objectContaining({ PlayerID: 780, RelationshipType: 3 }),
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
test('removefriend notifies both players with None', async () => {
|
||||
await mutate('/api/relationships/v2/addfriend', '790', 791)
|
||||
await resetNotifications()
|
||||
await mutate('/api/relationships/v2/removefriend', '790', 791)
|
||||
|
||||
const sent = await sentNotifications()
|
||||
expect(sent).toHaveLength(2)
|
||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([790, 791])
|
||||
for (const n of sent) expect(n.data.RelationshipType).toBe(0)
|
||||
})
|
||||
|
||||
test('a no-op friend request notifies nobody', async () => {
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '810', 811)
|
||||
await resetNotifications()
|
||||
|
||||
// Re-sending an already-outstanding request writes nothing, so nothing is pushed.
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '810', 811)
|
||||
expect(await sentNotifications()).toEqual([])
|
||||
|
||||
// Likewise accepting something that isn't pending (810 has no request to accept).
|
||||
await mutate('/api/relationships/v2/acceptfriendrequest', '810', 811)
|
||||
expect(await sentNotifications()).toEqual([])
|
||||
})
|
||||
|
||||
test('crossing requests notify both players as Friend', async () => {
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '820', 821)
|
||||
await resetNotifications()
|
||||
// 821's request crosses 820's → an immediate friendship, both sides told.
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '821', 820)
|
||||
|
||||
const sent = await sentNotifications()
|
||||
expect(sent).toHaveLength(2)
|
||||
for (const n of sent) expect(n.data.RelationshipType).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('messages', () => {
|
||||
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
|
||||
type Sent = {
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: { FromPlayerId: number; ToPlayerId: number; Type: number; Data: string }
|
||||
}
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
const pushed = async (): Promise<Sent[]> =>
|
||||
(await (await hub().fetch('http://do/all')).json()) as Sent[]
|
||||
|
||||
const send = async (fields: Record<string, string>, headers?: Record<string, string>) => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
return exports.default.fetch(`${ORIGIN}/api/messages/v2/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
}
|
||||
|
||||
// NotificationType.MessageReceived — the same frame the Coach broadcast uses.
|
||||
const MESSAGE_RECEIVED = 2
|
||||
|
||||
test('POST /api/messages/v2/send pushes MessageReceived to the recipient', async () => {
|
||||
const res = await send({ ToPlayerId: '2', Type: '10', Data: '' }, await bearer('42'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
expect(await pushed()).toEqual([
|
||||
{
|
||||
// Delivered to the recipient, not the sender.
|
||||
playerId: 2,
|
||||
notificationType: MESSAGE_RECEIVED,
|
||||
// FromPlayerId is the token's subject, not a body field.
|
||||
data: { FromPlayerId: 42, ToPlayerId: 2, Type: 10, Data: '' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('POST /api/messages/v2/send defaults Type and Data when omitted', async () => {
|
||||
const res = await send({ ToPlayerId: '2' }, await bearer('42'))
|
||||
expect(res.status).toBe(200)
|
||||
expect((await pushed())[0]?.data).toEqual({
|
||||
FromPlayerId: 42,
|
||||
ToPlayerId: 2,
|
||||
Type: 0,
|
||||
Data: '',
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/messages/v2/send 400s without a recipient, pushing nothing', async () => {
|
||||
const res = await send({ Type: '10' }, await bearer('42'))
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'ToPlayerId is required' })
|
||||
expect(await pushed()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /api/messages/v2/send is auth-gated', async () => {
|
||||
const res = await send({ ToPlayerId: '2' })
|
||||
expect(res.status).toBe(401)
|
||||
expect(await pushed()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutual friends', () => {
|
||||
// High, distinct ids so the friendships seeded here don't collide with the
|
||||
// relationship tests above.
|
||||
const CALLER = 800
|
||||
const OTHER = 801
|
||||
|
||||
type Card = { AccountId: number; Username: string; DisplayName: string; ProfileImage: string }
|
||||
|
||||
const mutuals = async (query: string, sub = String(CALLER)): Promise<Response> =>
|
||||
exports.default.fetch(`${ORIGIN}/api/relationships/mutualfriends${query}`, {
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
const rel = (a: number, b: number, type = 3) =>
|
||||
env.DB.prepare(
|
||||
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
|
||||
).bind(a, b, type)
|
||||
// 804 has no profileImage key at all — the projection must still answer a
|
||||
// string. 806 is deliberately given no account row.
|
||||
const account = (id: number, extra: Record<string, unknown>) =>
|
||||
env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)').bind(
|
||||
JSON.stringify({ accountId: id, username: `P${id}`, displayName: `Player ${id}`, ...extra })
|
||||
)
|
||||
|
||||
await env.DB.batch([
|
||||
account(CALLER, { profileImage: 'p800.jpg' }),
|
||||
account(OTHER, { profileImage: 'p801.jpg' }),
|
||||
account(802, { profileImage: 'p802.jpg' }),
|
||||
account(803, { profileImage: 'p803.jpg' }),
|
||||
account(804, {}),
|
||||
// Seeded 804-first so the ascending order of the answer is the code's doing,
|
||||
// not the insertion order's.
|
||||
rel(CALLER, 804),
|
||||
rel(802, CALLER), // friendship recorded from the other direction
|
||||
rel(CALLER, 803),
|
||||
rel(CALLER, 806),
|
||||
rel(OTHER, 804), // shared → in the answer
|
||||
rel(OTHER, 802), // shared → in the answer
|
||||
rel(803, OTHER, 1), // only a pending request → NOT a friend of OTHER
|
||||
rel(OTHER, 806), // shared, but 806 has no account row → dropped
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /api/relationships/mutualfriends returns the shared friends', async () => {
|
||||
const res = await mutuals(`?id=${OTHER}`)
|
||||
expect(res.status).toBe(200)
|
||||
const cards = (await res.json()) as Card[]
|
||||
// 803 is only a pending request on OTHER's side, and 806 has no account row.
|
||||
expect(cards.map((p) => p.AccountId)).toEqual([802, 804])
|
||||
expect(cards[0]).toEqual({
|
||||
AccountId: 802,
|
||||
Username: 'P802',
|
||||
DisplayName: 'Player 802',
|
||||
ProfileImage: 'p802.jpg',
|
||||
})
|
||||
// No stored image → an empty string, never null/undefined.
|
||||
expect(cards[1]?.ProfileImage).toBe('')
|
||||
})
|
||||
|
||||
// The degenerate cases answer an empty list rather than an error — this feeds a
|
||||
// profile panel, which would otherwise have nothing to render.
|
||||
// `?id=` is the only accepted form — `?playerId=` reads as no id at all.
|
||||
test('GET /api/relationships/mutualfriends answers [] for a missing/self/bad id', async () => {
|
||||
for (const query of ['', '?id=0', '?id=-5', '?id=abc', `?id=${CALLER}`, `?playerId=${OTHER}`]) {
|
||||
const res = await mutuals(query)
|
||||
expect(res.status, query).toBe(200)
|
||||
expect(await res.json(), query).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
// Symmetric: 802 and 803 aren't friends with each other, but both are friends with
|
||||
// 800, so 800 is what they have in common.
|
||||
test('GET /api/relationships/mutualfriends works between two other players', async () => {
|
||||
const cards = (await (await mutuals('?id=803', '802')).json()) as Card[]
|
||||
expect(cards.map((p) => p.AccountId)).toEqual([CALLER])
|
||||
})
|
||||
|
||||
test('GET /api/relationships/mutualfriends answers [] with nothing in common', async () => {
|
||||
// 809 has no relationships at all.
|
||||
const cards = (await (await mutuals('?id=809', '802')).json()) as Card[]
|
||||
expect(cards).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/relationships/mutualfriends is auth-gated', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/mutualfriends?id=${OTHER}`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('player events', () => {
|
||||
const HOUR = 60 * 60 * 1000
|
||||
/** Seconds precision, no milliseconds — the form the client sends and reads back. */
|
||||
const at = (offsetMs: number): string =>
|
||||
new Date(Date.now() + offsetMs).toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
|
||||
const post = async (path: string, body: unknown, sub = '42'): Promise<Response> =>
|
||||
exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const create = async (body: unknown, sub = '42'): Promise<PlayerEvent> => {
|
||||
const res = await post('/api/playerevents/v2', body, sub)
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as PlayerEventResult).PlayerEvent
|
||||
}
|
||||
|
||||
const get = async (path: string, sub?: string): Promise<Response> =>
|
||||
exports.default.fetch(`${ORIGIN}${path}`, sub ? { headers: await bearer(sub) } : undefined)
|
||||
|
||||
// The fixture set every test below reads. Times are relative to the run so the
|
||||
// upcoming/live/finished distinction the browse queries make is real.
|
||||
let upcoming: PlayerEvent
|
||||
let clubEvent: PlayerEvent
|
||||
let liveEvent: PlayerEvent
|
||||
let pastEvent: PlayerEvent
|
||||
|
||||
beforeAll(async () => {
|
||||
// Posted nested under `PlayerEvent` — the envelope form the client sends back.
|
||||
upcoming = await create({
|
||||
PlayerEvent: {
|
||||
ImageName: 'e63dcbffe8d14a7696bea7117dc3dd28.jpg',
|
||||
RoomId: 10916706,
|
||||
SubRoomId: 11195660,
|
||||
ClubId: null,
|
||||
Name: 'Building a Better Room Using Trigonometry',
|
||||
Description: '',
|
||||
StartTime: at(HOUR),
|
||||
EndTime: at(2 * HOUR),
|
||||
State: 0,
|
||||
Accessibility: 1,
|
||||
IsMultiInstance: false,
|
||||
SupportMultiInstanceRoomChat: true,
|
||||
DefaultBroadcastPermissions: 0,
|
||||
CanRequestBroadcastPermissions: 0,
|
||||
},
|
||||
})
|
||||
// …and this one at the top level, the other form in circulation.
|
||||
clubEvent = await create({
|
||||
RoomId: 23570830,
|
||||
ClubId: 7,
|
||||
Name: 'DUNGEONS Escape ROOM',
|
||||
Description: 'Try and escape the DUNGEONS with upto 4 players!',
|
||||
StartTime: at(3 * HOUR),
|
||||
EndTime: at(4 * HOUR),
|
||||
CanRequestBroadcastPermissions: 2147483647,
|
||||
})
|
||||
liveEvent = await create(
|
||||
{ RoomId: 3, ClubId: 7, Name: 'Live Jam', StartTime: at(-HOUR), EndTime: at(HOUR) },
|
||||
'43'
|
||||
)
|
||||
pastEvent = await create({
|
||||
RoomId: 3,
|
||||
Name: 'Trigonometry Retrospective',
|
||||
StartTime: at(-3 * HOUR),
|
||||
EndTime: at(-2 * HOUR),
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/tagfilters serves the event categories, auth-gated', async () => {
|
||||
expect((await get('/api/playerevents/v1/tagfilters')).status).toBe(401)
|
||||
|
||||
const res = await get('/api/playerevents/v1/tagfilters', '42')
|
||||
expect(res.status).toBe(200)
|
||||
// Static — the categories the client offers, not derived from stored events.
|
||||
// Trending is null even in the reference: it needs recent-activity data.
|
||||
expect(await res.json()).toEqual({
|
||||
PinnedFilters: [
|
||||
'workshops',
|
||||
'celebration',
|
||||
'game',
|
||||
'meetup',
|
||||
'performance',
|
||||
'coop',
|
||||
'grandopening',
|
||||
'class',
|
||||
'competition',
|
||||
],
|
||||
PopularFilters: [
|
||||
'workshops',
|
||||
'celebration',
|
||||
'class',
|
||||
'coop',
|
||||
'competition',
|
||||
'game',
|
||||
'grandopening',
|
||||
'meetup',
|
||||
'performance',
|
||||
],
|
||||
TrendingFilters: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2 creates an event, auth-gated', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v2`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
|
||||
// The stored record carries exactly the client's field set — nothing more.
|
||||
expect(upcoming).toEqual({
|
||||
PlayerEventId: upcoming.PlayerEventId,
|
||||
CreatorPlayerId: 42,
|
||||
ImageName: 'e63dcbffe8d14a7696bea7117dc3dd28.jpg',
|
||||
RoomId: 10916706,
|
||||
SubRoomId: 11195660,
|
||||
ClubId: null,
|
||||
Name: 'Building a Better Room Using Trigonometry',
|
||||
Description: '',
|
||||
StartTime: at(HOUR),
|
||||
EndTime: at(2 * HOUR),
|
||||
AttendeeCount: 1,
|
||||
State: 0,
|
||||
Accessibility: 1,
|
||||
IsMultiInstance: false,
|
||||
SupportMultiInstanceRoomChat: true,
|
||||
DefaultBroadcastPermissions: 0,
|
||||
CanRequestBroadcastPermissions: 0,
|
||||
})
|
||||
// Timestamps come back at seconds precision, as the client sends them.
|
||||
expect(upcoming.StartTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
|
||||
})
|
||||
|
||||
// The one thing the event writes are strict about. Everything else here defaults a
|
||||
// missing or unusable field (a nameless event becomes "Untitled Event"), but a name or
|
||||
// description past the stored length can't be defaulted into anything sensible, and
|
||||
// truncating a player's description silently is worse than refusing the write.
|
||||
//
|
||||
// Deliberately length ONLY: an event name is a title, not an identifier — the fixture
|
||||
// above is called "Building a Better Room Using Trigonometry" — so the alphanumeric
|
||||
// rule that guards usernames and room names would be wrong here.
|
||||
test('POST /api/playerevents/v2 caps the name at 64 and the description at 512', async () => {
|
||||
expect((await post('/api/playerevents/v2', { Name: 'n'.repeat(65), RoomId: 3 })).status).toBe(
|
||||
400
|
||||
)
|
||||
expect((await post('/api/playerevents/v2', { Name: 'n'.repeat(64), RoomId: 3 })).status).toBe(
|
||||
200
|
||||
)
|
||||
|
||||
const withDescription = (description: string) =>
|
||||
post('/api/playerevents/v2', { Name: 'Described', RoomId: 3, Description: description })
|
||||
expect((await withDescription('d'.repeat(513))).status).toBe(400)
|
||||
expect((await withDescription('d'.repeat(512))).status).toBe(200)
|
||||
// Counted in code points, so an emoji costs one character rather than two.
|
||||
expect((await withDescription('🎉'.repeat(512))).status).toBe(200)
|
||||
|
||||
// Spaces and punctuation stay fine — this is a title, not an identifier.
|
||||
expect(
|
||||
(await post('/api/playerevents/v2', { Name: "Bob's Big Night (2)!", RoomId: 3 })).status
|
||||
).toBe(200)
|
||||
|
||||
// The update path enforces the same limits, and a refusal leaves the event alone.
|
||||
const event = await create({ Name: 'EditMe', RoomId: 3 })
|
||||
const tooLong = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
|
||||
Name: 'n'.repeat(65),
|
||||
})
|
||||
expect(tooLong.status).toBe(400)
|
||||
const after = await get(`/api/playerevents/v1/${event.PlayerEventId}`)
|
||||
expect(((await after.json()) as PlayerEvent).Name).toBe('EditMe')
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2 answers the write envelope, not the bare event', async () => {
|
||||
const res = await post('/api/playerevents/v2', { Name: 'Enveloped', RoomId: 3 })
|
||||
const body = (await res.json()) as PlayerEventResult
|
||||
expect(body.Result).toBe(0)
|
||||
// Always null: no event tags are stored, but the field has to be present.
|
||||
expect(body.TagModifyResult).toBeNull()
|
||||
expect(body.PlayerEvent.Name).toBe('Enveloped')
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2 pushes a PlayerEventCreated notification to the creator', async () => {
|
||||
// The notify DO is stubbed to record its last notifyPlayer call (see vitest.config).
|
||||
const event = await create({
|
||||
RoomId: 58,
|
||||
Name: 'Open Mic',
|
||||
Description: 'come hang',
|
||||
StartTime: at(HOUR),
|
||||
EndTime: at(3 * HOUR),
|
||||
})
|
||||
const res = await env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/last')
|
||||
const last = (await res.json()) as {
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
expect(last.playerId).toBe(42) // the creator
|
||||
expect(last.notificationType).toBe(80) // NotificationType.PlayerEventCreated
|
||||
|
||||
// camelCase, unlike the PascalCase record the response carries; `tags` and
|
||||
// `broadcastingRoomInstanceId` don't exist on the record, and `State` is dropped.
|
||||
// The real hub strips the null values from the frame before it goes on the wire.
|
||||
expect(last.data).toEqual({
|
||||
tags: [],
|
||||
playerEventId: event.PlayerEventId,
|
||||
creatorPlayerId: 42,
|
||||
roomId: 58,
|
||||
subRoomId: null,
|
||||
clubId: null,
|
||||
name: 'Open Mic',
|
||||
description: 'come hang',
|
||||
imageName: '', // empty string, not the record's null
|
||||
startTime: `${event.StartTime.slice(0, -1)}.0000000Z`,
|
||||
endTime: `${event.EndTime.slice(0, -1)}.0000000Z`,
|
||||
attendeeCount: 1,
|
||||
accessibility: 1,
|
||||
isMultiInstance: false,
|
||||
supportMultiInstanceRoomChat: false,
|
||||
defaultBroadcastPermissions: 0,
|
||||
canRequestBroadcastPermissions: 0,
|
||||
broadcastingRoomInstanceId: null,
|
||||
})
|
||||
// Tick precision on the frame; the stored record keeps its bare form.
|
||||
expect(event.StartTime).toMatch(/:\d{2}Z$/)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2 takes the creator from the token, not the body', async () => {
|
||||
const event = await create({ Name: 'Not Yours', RoomId: 3, CreatorPlayerId: 999 })
|
||||
expect(event.CreatorPlayerId).toBe(42)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2 defaults an empty body rather than rejecting it', async () => {
|
||||
const event = await create({})
|
||||
expect(event).toMatchObject({
|
||||
Name: 'Untitled Event',
|
||||
Description: '',
|
||||
RoomId: 0,
|
||||
SubRoomId: null,
|
||||
ClubId: null,
|
||||
ImageName: null,
|
||||
AttendeeCount: 1,
|
||||
State: 0,
|
||||
Accessibility: 1,
|
||||
IsMultiInstance: false,
|
||||
SupportMultiInstanceRoomChat: false,
|
||||
DefaultBroadcastPermissions: 0,
|
||||
CanRequestBroadcastPermissions: 0,
|
||||
})
|
||||
// A start with no end runs for an hour.
|
||||
expect(Date.parse(event.EndTime) - Date.parse(event.StartTime)).toBe(HOUR)
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/:eventId serves the bare event', async () => {
|
||||
const res = await get(`/api/playerevents/v1/${upcoming.PlayerEventId}`)
|
||||
expect(res.status).toBe(200)
|
||||
// No envelope here — unlike the writes.
|
||||
expect(await res.json()).toEqual(upcoming)
|
||||
|
||||
expect((await get('/api/playerevents/v1/999999')).status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/bulk answers in request order, skipping unknown ids', async () => {
|
||||
const res = await get(
|
||||
`/api/playerevents/v1/bulk?id=${clubEvent.PlayerEventId}&id=999999&id=${upcoming.PlayerEventId}`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const events = (await res.json()) as PlayerEvent[]
|
||||
// Request order, not id order — and the missing id leaves no hole.
|
||||
expect(events.map((e) => e.PlayerEventId)).toEqual([
|
||||
clubEvent.PlayerEventId,
|
||||
upcoming.PlayerEventId,
|
||||
])
|
||||
|
||||
// No ids is an empty list, not every event.
|
||||
expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/search matches name and description, skipping finished events', async () => {
|
||||
const search = async (qs: string): Promise<PlayerEvent[]> =>
|
||||
(await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[]
|
||||
|
||||
// Every term has to match, across name OR description.
|
||||
expect((await search('?query=dungeons+escape')).map((e) => e.PlayerEventId)).toEqual([
|
||||
clubEvent.PlayerEventId,
|
||||
])
|
||||
// …matched case-insensitively, and against the description too.
|
||||
expect((await search('?query=upto%204%20players')).map((e) => e.PlayerEventId)).toEqual([
|
||||
clubEvent.PlayerEventId,
|
||||
])
|
||||
|
||||
// `pastEvent` matches on name but has already ended, so the browse query drops it.
|
||||
const trig = await search('?query=trigonometry')
|
||||
expect(trig.map((e) => e.PlayerEventId)).toEqual([upcoming.PlayerEventId])
|
||||
expect(trig.map((e) => e.PlayerEventId)).not.toContain(pastEvent.PlayerEventId)
|
||||
|
||||
// Soonest first, and take/skip page through that order.
|
||||
const all = await search('')
|
||||
const starts = all.map((e) => e.StartTime)
|
||||
expect([...starts].sort()).toEqual(starts)
|
||||
expect(await search('?take=1')).toEqual([all[0]])
|
||||
expect(await search('?skip=1&take=1')).toEqual([all[1]])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/searchlive serves what is running right now', async () => {
|
||||
const res = await get('/api/playerevents/v1/searchlive')
|
||||
expect(res.status).toBe(200)
|
||||
const ids = ((await res.json()) as PlayerEvent[]).map((e) => e.PlayerEventId)
|
||||
expect(ids).toContain(liveEvent.PlayerEventId)
|
||||
// Started in an hour / finished already — neither is live.
|
||||
expect(ids).not.toContain(upcoming.PlayerEventId)
|
||||
expect(ids).not.toContain(pastEvent.PlayerEventId)
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/clubs is a bare array; /club/:id is a paged envelope', async () => {
|
||||
// The client deserializes the multi-club form as a list — an envelope here fails
|
||||
// with "expected:'[', actual:'{'". Do not unify the two.
|
||||
const many = await get('/api/playerevents/v1/clubs?id=7&id=8')
|
||||
expect(many.status).toBe(200)
|
||||
const events = (await many.json()) as PlayerEvent[]
|
||||
expect(events.map((e) => e.PlayerEventId)).toEqual([
|
||||
liveEvent.PlayerEventId, // started an hour ago — soonest first
|
||||
clubEvent.PlayerEventId,
|
||||
])
|
||||
|
||||
// The single-club form does wrap its events with a paging cursor.
|
||||
const one = await get('/api/playerevents/v1/club/7')
|
||||
expect(one.status).toBe(200)
|
||||
expect(await one.json()).toEqual({ ContinuationToken: '', Events: events })
|
||||
|
||||
// A club with no events, and the no-ids case.
|
||||
expect(await (await get('/api/playerevents/v1/club/8')).json()).toEqual({
|
||||
ContinuationToken: '',
|
||||
Events: [],
|
||||
})
|
||||
expect(await (await get('/api/playerevents/v1/clubs')).json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/all lists the caller’s own events, auth-gated', async () => {
|
||||
expect((await get('/api/playerevents/v1/all')).status).toBe(401)
|
||||
|
||||
const mine = (await (await get('/api/playerevents/v1/all', '42')).json()) as {
|
||||
Created: PlayerEvent[]
|
||||
Responses: unknown[]
|
||||
}
|
||||
const ids = mine.Created.map((e) => e.PlayerEventId)
|
||||
expect(ids).toContain(upcoming.PlayerEventId)
|
||||
// 43 created that one, not 42.
|
||||
expect(ids).not.toContain(liveEvent.PlayerEventId)
|
||||
// Finished events stay in the creator's own list — only the browse queries drop them.
|
||||
expect(ids).toContain(pastEvent.PlayerEventId)
|
||||
// Nothing records an RSVP yet.
|
||||
expect(mine.Responses).toEqual([])
|
||||
|
||||
const theirs = (await (await get('/api/playerevents/v1/all', '43')).json()) as {
|
||||
Created: PlayerEvent[]
|
||||
}
|
||||
expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId])
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => {
|
||||
const respond = async (body: unknown, sub = '42'): Promise<Response> =>
|
||||
post('/api/playerevents/v1/respond', body, sub)
|
||||
|
||||
const event = await create({ RoomId: 3, Name: 'RSVP Test', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
// The creator is Going from create, which is where the initial 1 comes from.
|
||||
expect(event.AttendeeCount).toBe(1)
|
||||
expect(await countGoing(env.DB, id)).toBe(1)
|
||||
|
||||
// 43 says Going → 2 attendees, and the envelope carries the updated event.
|
||||
const res = await respond({ PlayerEventId: id, Type: 0 }, '43')
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as PlayerEventResult
|
||||
expect(body.Result).toBe(0)
|
||||
expect(body.PlayerEvent.AttendeeCount).toBe(2)
|
||||
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({
|
||||
event_id: id,
|
||||
player_id: 43,
|
||||
status: 0,
|
||||
})
|
||||
|
||||
// Changing the answer REPLACES it — one row per player, not a second RSVP.
|
||||
const changed = await respond({ PlayerEventId: id, Type: 2 }, '43')
|
||||
expect(((await changed.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
|
||||
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({ player_id: 43, status: 2 })
|
||||
expect((await getEventAttendees(env.DB, id)).map((a) => a.player_id)).toEqual([42, 43])
|
||||
|
||||
// Interested is a maybe — recorded, but not counted.
|
||||
await respond({ PlayerEventId: id, Type: 1 }, '43')
|
||||
expect(await countGoing(env.DB, id)).toBe(1)
|
||||
|
||||
// And the count sticks on the stored event, not just the response.
|
||||
const fetched = (await (await get(`/api/playerevents/v1/${id}`)).json()) as PlayerEvent
|
||||
expect(fetched.AttendeeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/respond rejects a bad body, an unknown event and no token', async () => {
|
||||
const event = await create({ RoomId: 3, Name: 'Guarded' })
|
||||
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/respond`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ PlayerEventId: event.PlayerEventId, Type: 0 }),
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
|
||||
// An unrecognized Type is rejected rather than defaulted — stored as Going it
|
||||
// would silently inflate the count.
|
||||
expect((await post('/api/playerevents/v1/respond', { PlayerEventId: 1, Type: 7 })).status).toBe(
|
||||
400
|
||||
)
|
||||
expect((await post('/api/playerevents/v1/respond', { Type: 0 })).status).toBe(400)
|
||||
expect((await post('/api/playerevents/v1/respond', {})).status).toBe(400)
|
||||
expect(
|
||||
(await post('/api/playerevents/v1/respond', { PlayerEventId: 999999, Type: 0 })).status
|
||||
).toBe(404)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2/:eventId edits only what the body carries, creator-only', async () => {
|
||||
const event = await create({
|
||||
RoomId: 5,
|
||||
SubRoomId: 6,
|
||||
ClubId: 9,
|
||||
Name: 'Original',
|
||||
Description: 'Original description',
|
||||
StartTime: at(5 * HOUR),
|
||||
EndTime: at(6 * HOUR),
|
||||
})
|
||||
const path = `/api/playerevents/v2/${event.PlayerEventId}`
|
||||
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}${path}`, { method: 'POST', body: '{}' })).status
|
||||
).toBe(401)
|
||||
// 43 didn't create it.
|
||||
expect((await post(path, { Name: 'Hijacked' }, '43')).status).toBe(403)
|
||||
expect((await post('/api/playerevents/v2/999999', { Name: 'Nope' })).status).toBe(404)
|
||||
|
||||
const res = await post(path, { Name: 'Renamed' })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as PlayerEventResult
|
||||
expect(body.Result).toBe(0)
|
||||
// Only the name moved; a partial post can't blank out the rest.
|
||||
expect(body.PlayerEvent).toEqual({ ...event, Name: 'Renamed' })
|
||||
|
||||
// And it stuck.
|
||||
expect(await (await get(`/api/playerevents/v1/${event.PlayerEventId}`)).json()).toEqual(
|
||||
body.PlayerEvent
|
||||
)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2/:eventId clears a nullable id when the body sends null', async () => {
|
||||
const event = await create({ RoomId: 5, SubRoomId: 6, ClubId: 9, Name: 'Clearable' })
|
||||
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
|
||||
// Nested form again, and an explicit null — absent leaves the value alone,
|
||||
// null genuinely clears it.
|
||||
PlayerEvent: { ClubId: null, ImageName: null },
|
||||
})
|
||||
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
|
||||
expect(updated.ClubId).toBeNull()
|
||||
expect(updated.ImageName).toBeNull()
|
||||
expect(updated.SubRoomId).toBe(6)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2/:eventId cannot move ownership or the attendee count', async () => {
|
||||
const event = await create({ RoomId: 5, Name: 'Fixed' })
|
||||
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
|
||||
PlayerEventId: 424242,
|
||||
CreatorPlayerId: 43,
|
||||
AttendeeCount: 500,
|
||||
})
|
||||
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
|
||||
expect(updated.PlayerEventId).toBe(event.PlayerEventId)
|
||||
expect(updated.CreatorPlayerId).toBe(42)
|
||||
expect(updated.AttendeeCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openapi', () => {
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
const spec = (await res.json()) as {
|
||||
openapi: string
|
||||
paths: Record<string, Record<string, { summary?: string }>>
|
||||
}
|
||||
expect(spec.openapi).toMatch(/^3\.1/)
|
||||
|
||||
// The spec route hides itself.
|
||||
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
|
||||
// `.on(['GET','POST'], …)` relationship routes contribute both methods.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'DELETE /api/images/v1/deletesaved',
|
||||
'GET /api/PlayerReporting/v1/moderationBlockDetails',
|
||||
'GET /api/PlayerReporting/v1/voteToKickReasons',
|
||||
'GET /api/activities/charades/v1/words/{activity}',
|
||||
'GET /api/announcement/v1/get',
|
||||
'GET /api/communityboard/v2/current',
|
||||
'GET /api/config/v1/amplitude',
|
||||
'GET /api/config/v1/azurespeech',
|
||||
'GET /api/config/v1/backtrace',
|
||||
'GET /api/config/v2',
|
||||
'GET /api/consumables/v2/getUnlocked',
|
||||
'GET /api/customAvatarItems/v1/featured',
|
||||
'GET /api/customAvatarItems/v1/hot',
|
||||
'GET /api/customAvatarItems/v1/isCreationAllowedForAccount',
|
||||
'GET /api/customAvatarItems/v1/isCreationEnabled',
|
||||
'GET /api/customAvatarItems/v1/isRenderingEnabled',
|
||||
'GET /api/customAvatarItems/v2/fromCreator/{accountId}',
|
||||
'GET /api/equipment/v2/getUnlocked',
|
||||
'GET /api/gameconfigs/v1/all',
|
||||
'GET /api/images/v1/slideshow',
|
||||
'GET /api/images/v2/named',
|
||||
'GET /api/images/v3/feed/player/{playerId}',
|
||||
'GET /api/images/v4/player/{playerId}',
|
||||
'GET /api/images/v4/room/{roomId}',
|
||||
'GET /api/images/v5/cheered/bulk',
|
||||
'GET /api/images/v5/player/{playerId}',
|
||||
'GET /api/images/v6',
|
||||
'GET /api/inventions/v1',
|
||||
'GET /api/inventions/v1/details',
|
||||
'GET /api/inventions/v1/featured',
|
||||
'GET /api/inventions/v1/personaldetails/{inventionId}',
|
||||
'GET /api/inventions/v1/room',
|
||||
'GET /api/inventions/v1/tagfilters',
|
||||
'GET /api/inventions/v1/toptoday',
|
||||
'GET /api/inventions/v1/update',
|
||||
'GET /api/inventions/v1/version',
|
||||
'GET /api/inventions/v2/batch',
|
||||
'GET /api/inventions/v2/mine',
|
||||
'GET /api/inventions/v2/search',
|
||||
'GET /api/inventions/v3/publish',
|
||||
'GET /api/keepsakes/categories',
|
||||
'GET /api/keepsakes/globalconfig',
|
||||
'GET /api/keepsakes/rooms/{roomId}',
|
||||
'GET /api/messages/v1/favoriteFriendOnlineStatus',
|
||||
'GET /api/messages/v2/get',
|
||||
'GET /api/playerReputation/v1/{id}',
|
||||
'GET /api/playerReputation/v2/bulk',
|
||||
'GET /api/playerevents/v1/all',
|
||||
'GET /api/playerevents/v1/bulk',
|
||||
'GET /api/playerevents/v1/club/{clubId}',
|
||||
'GET /api/playerevents/v1/clubs',
|
||||
'GET /api/playerevents/v1/search',
|
||||
'GET /api/playerevents/v1/searchlive',
|
||||
'GET /api/playerevents/v1/tagfilters',
|
||||
'GET /api/playerevents/v1/{eventId}',
|
||||
'GET /api/players/v1/progression/{id}',
|
||||
'GET /api/players/v2/progression/bulk',
|
||||
'GET /api/quickPlay/v1/getandclear',
|
||||
'GET /api/relationships/mutualfriends',
|
||||
'GET /api/relationships/v1/favorite',
|
||||
'GET /api/relationships/v1/ignore',
|
||||
'GET /api/relationships/v1/mute',
|
||||
'GET /api/relationships/v1/unfavorite',
|
||||
'GET /api/relationships/v1/unignore',
|
||||
'GET /api/relationships/v1/unmute',
|
||||
'GET /api/relationships/v2/acceptfriendrequest',
|
||||
'GET /api/relationships/v2/addfriend',
|
||||
'GET /api/relationships/v2/get',
|
||||
'GET /api/relationships/v2/removefriend',
|
||||
'GET /api/relationships/v2/sendfriendrequest',
|
||||
'GET /api/roomkeys/v1/mine',
|
||||
'GET /api/roomkeys/v1/room',
|
||||
'GET /api/rooms/v1/filters',
|
||||
'GET /api/versioncheck/v4',
|
||||
'GET /voice/config',
|
||||
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
||||
'POST /api/PlayerReporting/v1/deviceId',
|
||||
'POST /api/PlayerReporting/v1/hile',
|
||||
'POST /api/PlayerReporting/v3/create',
|
||||
'POST /api/avatar/v2/gifts/generate',
|
||||
'POST /api/gamesight/event',
|
||||
'POST /api/images/v1/cheer',
|
||||
'POST /api/images/v4/uploadsaved',
|
||||
'POST /api/inventions/v1/settags',
|
||||
'POST /api/inventions/v1/updateprice',
|
||||
'POST /api/inventions/v6/save',
|
||||
'POST /api/messages/v2/send',
|
||||
'POST /api/playerReputation/v1/bulk',
|
||||
'POST /api/playerReputation/v2/bulk',
|
||||
'POST /api/playerevents/v1/respond',
|
||||
'POST /api/playerevents/v2',
|
||||
'POST /api/playerevents/v2/{eventId}',
|
||||
'POST /api/players/v1/progression/bulk',
|
||||
'POST /api/players/v2/progression/bulk',
|
||||
'POST /api/playerwarnings',
|
||||
'POST /api/relationships/v1/favorite',
|
||||
'POST /api/relationships/v1/ignore',
|
||||
'POST /api/relationships/v1/mute',
|
||||
'POST /api/relationships/v1/unfavorite',
|
||||
'POST /api/relationships/v1/unignore',
|
||||
'POST /api/relationships/v1/unmute',
|
||||
'POST /api/relationships/v2/acceptfriendrequest',
|
||||
'POST /api/relationships/v2/addfriend',
|
||||
'POST /api/relationships/v2/removefriend',
|
||||
'POST /api/relationships/v2/sendfriendrequest',
|
||||
'POST /api/rooms/v1/verifyRole',
|
||||
'POST /api/sanitize/v1',
|
||||
'POST /api/sanitize/v1/isPure',
|
||||
'POST /api/v1/progression/bulk',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — an undescribed one renders as a bare path.
|
||||
for (const [path, ops] of Object.entries(spec.paths)) {
|
||||
for (const [method, op] of Object.entries(ops)) {
|
||||
expect(op.summary, `${method.toUpperCase()} ${path} has no summary`).toBeTruthy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Schemas are inlined rather than $ref'd into components: a `.meta({ id })`'d schema
|
||||
// used in a response emits a $ref this hono-openapi + zod v4 setup does not always
|
||||
// hoist, leaving a dangling reference that breaks the docs UI.
|
||||
test('the spec has no $refs', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
const raw = await res.text()
|
||||
expect(raw.match(/\$ref/g)).toBeNull()
|
||||
})
|
||||
|
||||
// `z.int()` carries the safe-integer range as its bounds, which Scalar would
|
||||
// otherwise show as the example value for every integer field (-9007199254740991).
|
||||
// withCleanSpec() supplies a placeholder instead; this guards the wrapper staying
|
||||
// wired up.
|
||||
test('integer fields carry a placeholder example', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
const raw = await res.text()
|
||||
const integers = raw.match(/"type":"integer"/g) ?? []
|
||||
expect(integers.length).toBeGreaterThan(0)
|
||||
expect(raw.match(/"example":12345/g)?.length).toBe(integers.length)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Moderator-issued player warnings on the shared `recflare` D1 database.
|
||||
*
|
||||
* The counterpart to the `report` table (see reports-db.ts): a report is what a
|
||||
* player submits, a warning is what a moderator hands down. Same shape of storage —
|
||||
* columnar rather than a JSON blob, append-only, nothing dedupes or acts on the
|
||||
* rows yet.
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0005_warning.sql,
|
||||
* applied under its own `migrations_table` so it doesn't clash with the other
|
||||
* workers' migrations that share the database).
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0005_warning.sql, sans seed rows). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS warning (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
moderator_player_id INTEGER NOT NULL,
|
||||
warned_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
display_reason TEXT,
|
||||
moderator_note TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id)`,
|
||||
]
|
||||
|
||||
/** A stored warning row (snake_case columns, one row per warning issued). */
|
||||
export interface WarningRow {
|
||||
id: number
|
||||
/** The moderator who issued it, from their bearer token. */
|
||||
moderator_player_id: number
|
||||
warned_player_id: number
|
||||
report_category: number
|
||||
/** What the warned player is shown, e.g. `Sexual gestures`. */
|
||||
display_reason: string | null
|
||||
/** Internal note — never surfaced to the warned player. */
|
||||
moderator_note: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A warning as issued — everything but the moderator (which comes from the bearer
|
||||
* token) and the timestamp. Only the warned player is required; the rest are
|
||||
* optional and stored as NULL when absent.
|
||||
*/
|
||||
export interface NewWarning {
|
||||
moderatorPlayerId: number
|
||||
warnedPlayerId: number
|
||||
reportCategory?: number
|
||||
displayReason?: string | null
|
||||
moderatorNote?: string | null
|
||||
}
|
||||
|
||||
/** Record an issued warning, returning the stored row (with its assigned id). */
|
||||
export async function createWarning(db: D1Database, input: NewWarning): Promise<WarningRow> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO warning (
|
||||
moderator_player_id, warned_player_id, report_category,
|
||||
display_reason, moderator_note, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
input.moderatorPlayerId,
|
||||
input.warnedPlayerId,
|
||||
input.reportCategory ?? 0,
|
||||
input.displayReason ?? null,
|
||||
input.moderatorNote ?? null,
|
||||
new Date().toISOString()
|
||||
)
|
||||
.first<WarningRow>()
|
||||
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
||||
// from having to handle an impossible null.
|
||||
return row!
|
||||
}
|
||||
|
||||
/** Every warning issued against a player, newest first. Backs a future moderation view. */
|
||||
export async function getWarningsAgainst(db: D1Database, playerId: number): Promise<WarningRow[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT * FROM warning WHERE warned_player_id = ?1 ORDER BY id DESC')
|
||||
.bind(playerId)
|
||||
.all<WarningRow>()
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,6162 @@
|
||||
[
|
||||
{
|
||||
"Id": 1,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "David Bowie"
|
||||
},
|
||||
{
|
||||
"Id": 2,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Guitar"
|
||||
},
|
||||
{
|
||||
"Id": 3,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hipster"
|
||||
},
|
||||
{
|
||||
"Id": 4,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Boxing"
|
||||
},
|
||||
{
|
||||
"Id": 5,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Alien"
|
||||
},
|
||||
{
|
||||
"Id": 6,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Car Crash"
|
||||
},
|
||||
{
|
||||
"Id": 7,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pole Vault"
|
||||
},
|
||||
{
|
||||
"Id": 8,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spaghetti"
|
||||
},
|
||||
{
|
||||
"Id": 9,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Titanic"
|
||||
},
|
||||
{
|
||||
"Id": 10,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Video Game"
|
||||
},
|
||||
{
|
||||
"Id": 11,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Moon Landing"
|
||||
},
|
||||
{
|
||||
"Id": 12,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Avatar"
|
||||
},
|
||||
{
|
||||
"Id": 13,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Coal Miner"
|
||||
},
|
||||
{
|
||||
"Id": 14,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Castaway"
|
||||
},
|
||||
{
|
||||
"Id": 15,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "League of Legends"
|
||||
},
|
||||
{
|
||||
"Id": 16,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Minecraft"
|
||||
},
|
||||
{
|
||||
"Id": 17,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pirate"
|
||||
},
|
||||
{
|
||||
"Id": 18,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Submarine"
|
||||
},
|
||||
{
|
||||
"Id": 19,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Luke Skywalker"
|
||||
},
|
||||
{
|
||||
"Id": 20,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Princess Leia"
|
||||
},
|
||||
{
|
||||
"Id": 21,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spiderman"
|
||||
},
|
||||
{
|
||||
"Id": 22,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Superman"
|
||||
},
|
||||
{
|
||||
"Id": 23,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wrestling"
|
||||
},
|
||||
{
|
||||
"Id": 24,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Campfire"
|
||||
},
|
||||
{
|
||||
"Id": 25,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Milking a Cow"
|
||||
},
|
||||
{
|
||||
"Id": 26,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mario"
|
||||
},
|
||||
{
|
||||
"Id": 27,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "The Wizard of Oz"
|
||||
},
|
||||
{
|
||||
"Id": 28,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Walking Stick"
|
||||
},
|
||||
{
|
||||
"Id": 29,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Prison"
|
||||
},
|
||||
{
|
||||
"Id": 30,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cereal"
|
||||
},
|
||||
{
|
||||
"Id": 31,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shot Put"
|
||||
},
|
||||
{
|
||||
"Id": 32,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tsunami"
|
||||
},
|
||||
{
|
||||
"Id": 33,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bulldozer"
|
||||
},
|
||||
{
|
||||
"Id": 34,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Javelin Throw"
|
||||
},
|
||||
{
|
||||
"Id": 35,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Espresso"
|
||||
},
|
||||
{
|
||||
"Id": 36,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Aroma"
|
||||
},
|
||||
{
|
||||
"Id": 37,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Salt and Pepper"
|
||||
},
|
||||
{
|
||||
"Id": 38,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Toast"
|
||||
},
|
||||
{
|
||||
"Id": 39,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ostrich"
|
||||
},
|
||||
{
|
||||
"Id": 40,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "False Teeth"
|
||||
},
|
||||
{
|
||||
"Id": 41,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lumber Jack"
|
||||
},
|
||||
{
|
||||
"Id": 42,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Vacuum"
|
||||
},
|
||||
{
|
||||
"Id": 43,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Heavy"
|
||||
},
|
||||
{
|
||||
"Id": 44,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pet"
|
||||
},
|
||||
{
|
||||
"Id": 45,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fan"
|
||||
},
|
||||
{
|
||||
"Id": 46,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Saxophone"
|
||||
},
|
||||
{
|
||||
"Id": 47,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Antlers"
|
||||
},
|
||||
{
|
||||
"Id": 48,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jumping Jack "
|
||||
},
|
||||
{
|
||||
"Id": 49,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shark "
|
||||
},
|
||||
{
|
||||
"Id": 50,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Alligator "
|
||||
},
|
||||
{
|
||||
"Id": 51,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chair "
|
||||
},
|
||||
{
|
||||
"Id": 52,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Robot "
|
||||
},
|
||||
{
|
||||
"Id": 53,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scissors "
|
||||
},
|
||||
{
|
||||
"Id": 54,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cheek "
|
||||
},
|
||||
{
|
||||
"Id": 55,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jump "
|
||||
},
|
||||
{
|
||||
"Id": 56,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ice Cream Cone "
|
||||
},
|
||||
{
|
||||
"Id": 57,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Plane "
|
||||
},
|
||||
{
|
||||
"Id": 58,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Basketball "
|
||||
},
|
||||
{
|
||||
"Id": 59,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Camera "
|
||||
},
|
||||
{
|
||||
"Id": 60,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eat "
|
||||
},
|
||||
{
|
||||
"Id": 62,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blink "
|
||||
},
|
||||
{
|
||||
"Id": 63,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Balloon "
|
||||
},
|
||||
{
|
||||
"Id": 64,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Book"
|
||||
},
|
||||
{
|
||||
"Id": 65,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Glasses "
|
||||
},
|
||||
{
|
||||
"Id": 66,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sneeze "
|
||||
},
|
||||
{
|
||||
"Id": 67,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mouth "
|
||||
},
|
||||
{
|
||||
"Id": 68,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Football "
|
||||
},
|
||||
{
|
||||
"Id": 69,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Telephone "
|
||||
},
|
||||
{
|
||||
"Id": 70,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tail "
|
||||
},
|
||||
{
|
||||
"Id": 71,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Turtle"
|
||||
},
|
||||
{
|
||||
"Id": 72,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Baby "
|
||||
},
|
||||
{
|
||||
"Id": 73,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ear "
|
||||
},
|
||||
{
|
||||
"Id": 74,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Monkey "
|
||||
},
|
||||
{
|
||||
"Id": 75,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hopscotch "
|
||||
},
|
||||
{
|
||||
"Id": 76,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ping Pong "
|
||||
},
|
||||
{
|
||||
"Id": 77,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Snowball "
|
||||
},
|
||||
{
|
||||
"Id": 78,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Roof "
|
||||
},
|
||||
{
|
||||
"Id": 79,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fly "
|
||||
},
|
||||
{
|
||||
"Id": 80,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fang "
|
||||
},
|
||||
{
|
||||
"Id": 81,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Piano "
|
||||
},
|
||||
{
|
||||
"Id": 82,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lipstick "
|
||||
},
|
||||
{
|
||||
"Id": 83,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hula Hoop "
|
||||
},
|
||||
{
|
||||
"Id": 84,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Penguin "
|
||||
},
|
||||
{
|
||||
"Id": 85,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Banana Peel "
|
||||
},
|
||||
{
|
||||
"Id": 86,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Whisper "
|
||||
},
|
||||
{
|
||||
"Id": 87,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Popsicle "
|
||||
},
|
||||
{
|
||||
"Id": 88,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Frankenstein "
|
||||
},
|
||||
{
|
||||
"Id": 89,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Earthquake "
|
||||
},
|
||||
{
|
||||
"Id": 90,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Road "
|
||||
},
|
||||
{
|
||||
"Id": 91,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Alarm Clock "
|
||||
},
|
||||
{
|
||||
"Id": 92,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dog Leash "
|
||||
},
|
||||
{
|
||||
"Id": 93,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chop "
|
||||
},
|
||||
{
|
||||
"Id": 94,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pajamas "
|
||||
},
|
||||
{
|
||||
"Id": 95,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Slam Dunk "
|
||||
},
|
||||
{
|
||||
"Id": 96,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Seashell "
|
||||
},
|
||||
{
|
||||
"Id": 97,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jog "
|
||||
},
|
||||
{
|
||||
"Id": 98,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Seesaw "
|
||||
},
|
||||
{
|
||||
"Id": 99,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Nap "
|
||||
},
|
||||
{
|
||||
"Id": 100,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cheerleader "
|
||||
},
|
||||
{
|
||||
"Id": 101,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blind "
|
||||
},
|
||||
{
|
||||
"Id": 102,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Beg "
|
||||
},
|
||||
{
|
||||
"Id": 103,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "CPR"
|
||||
},
|
||||
{
|
||||
"Id": 104,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shopping Cart "
|
||||
},
|
||||
{
|
||||
"Id": 105,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Limbo "
|
||||
},
|
||||
{
|
||||
"Id": 106,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Newspaper "
|
||||
},
|
||||
{
|
||||
"Id": 107,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Twist "
|
||||
},
|
||||
{
|
||||
"Id": 108,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rhinoceros "
|
||||
},
|
||||
{
|
||||
"Id": 109,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cow "
|
||||
},
|
||||
{
|
||||
"Id": 110,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tickle "
|
||||
},
|
||||
{
|
||||
"Id": 111,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fetch "
|
||||
},
|
||||
{
|
||||
"Id": 112,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Violin "
|
||||
},
|
||||
{
|
||||
"Id": 113,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cage "
|
||||
},
|
||||
{
|
||||
"Id": 114,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cello "
|
||||
},
|
||||
{
|
||||
"Id": 115,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Braid "
|
||||
},
|
||||
{
|
||||
"Id": 116,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Trumpet "
|
||||
},
|
||||
{
|
||||
"Id": 117,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mop "
|
||||
},
|
||||
{
|
||||
"Id": 118,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shovel"
|
||||
},
|
||||
{
|
||||
"Id": 119,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cash "
|
||||
},
|
||||
{
|
||||
"Id": 120,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Soap "
|
||||
},
|
||||
{
|
||||
"Id": 121,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wink "
|
||||
},
|
||||
{
|
||||
"Id": 122,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tree "
|
||||
},
|
||||
{
|
||||
"Id": 124,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Windmill "
|
||||
},
|
||||
{
|
||||
"Id": 125,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Party "
|
||||
},
|
||||
{
|
||||
"Id": 126,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lawn Mower "
|
||||
},
|
||||
{
|
||||
"Id": 127,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wig "
|
||||
},
|
||||
{
|
||||
"Id": 128,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Handcuffs "
|
||||
},
|
||||
{
|
||||
"Id": 129,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sunburn "
|
||||
},
|
||||
{
|
||||
"Id": 130,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hair Dryer "
|
||||
},
|
||||
{
|
||||
"Id": 131,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shadow "
|
||||
},
|
||||
{
|
||||
"Id": 132,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sip "
|
||||
},
|
||||
{
|
||||
"Id": 133,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bathroom "
|
||||
},
|
||||
{
|
||||
"Id": 134,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scale "
|
||||
},
|
||||
{
|
||||
"Id": 135,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cowboy "
|
||||
},
|
||||
{
|
||||
"Id": 136,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Doghouse "
|
||||
},
|
||||
{
|
||||
"Id": 137,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Knot "
|
||||
},
|
||||
{
|
||||
"Id": 138,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Owl "
|
||||
},
|
||||
{
|
||||
"Id": 139,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wrench "
|
||||
},
|
||||
{
|
||||
"Id": 140,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Story "
|
||||
},
|
||||
{
|
||||
"Id": 141,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Whisk "
|
||||
},
|
||||
{
|
||||
"Id": 142,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spine "
|
||||
},
|
||||
{
|
||||
"Id": 143,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Artist "
|
||||
},
|
||||
{
|
||||
"Id": 144,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gingerbread Man "
|
||||
},
|
||||
{
|
||||
"Id": 145,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spider Web "
|
||||
},
|
||||
{
|
||||
"Id": 146,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lung "
|
||||
},
|
||||
{
|
||||
"Id": 147,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Washing Machine "
|
||||
},
|
||||
{
|
||||
"Id": 148,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Torch "
|
||||
},
|
||||
{
|
||||
"Id": 149,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eraser "
|
||||
},
|
||||
{
|
||||
"Id": 150,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mouse Trap "
|
||||
},
|
||||
{
|
||||
"Id": 151,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Swordfish "
|
||||
},
|
||||
{
|
||||
"Id": 152,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Flamingo "
|
||||
},
|
||||
{
|
||||
"Id": 153,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cast "
|
||||
},
|
||||
{
|
||||
"Id": 154,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Honk "
|
||||
},
|
||||
{
|
||||
"Id": 155,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mirror "
|
||||
},
|
||||
{
|
||||
"Id": 156,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Volcano"
|
||||
},
|
||||
{
|
||||
"Id": 157,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Flag "
|
||||
},
|
||||
{
|
||||
"Id": 158,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shipwreck "
|
||||
},
|
||||
{
|
||||
"Id": 159,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Olympics "
|
||||
},
|
||||
{
|
||||
"Id": 160,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Satellite "
|
||||
},
|
||||
{
|
||||
"Id": 161,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Evolution "
|
||||
},
|
||||
{
|
||||
"Id": 162,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pendulum"
|
||||
},
|
||||
{
|
||||
"Id": 163,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "The Mighty Ducks "
|
||||
},
|
||||
{
|
||||
"Id": 164,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Indiana Jones "
|
||||
},
|
||||
{
|
||||
"Id": 165,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Angels in the Outfield "
|
||||
},
|
||||
{
|
||||
"Id": 166,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "E.T. "
|
||||
},
|
||||
{
|
||||
"Id": 167,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dumbo "
|
||||
},
|
||||
{
|
||||
"Id": 168,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Batman "
|
||||
},
|
||||
{
|
||||
"Id": 169,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mary Poppins "
|
||||
},
|
||||
{
|
||||
"Id": 170,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Star Wars "
|
||||
},
|
||||
{
|
||||
"Id": 171,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "James Bond "
|
||||
},
|
||||
{
|
||||
"Id": 172,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "The Lion King"
|
||||
},
|
||||
{
|
||||
"Id": 173,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "The Matrix"
|
||||
},
|
||||
{
|
||||
"Id": 174,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Godzilla"
|
||||
},
|
||||
{
|
||||
"Id": 175,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Golf"
|
||||
},
|
||||
{
|
||||
"Id": 176,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Headphones"
|
||||
},
|
||||
{
|
||||
"Id": 177,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Necktie"
|
||||
},
|
||||
{
|
||||
"Id": 178,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Paintbrush"
|
||||
},
|
||||
{
|
||||
"Id": 179,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mime"
|
||||
},
|
||||
{
|
||||
"Id": 180,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rubber Ducky "
|
||||
},
|
||||
{
|
||||
"Id": 181,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Yolk "
|
||||
},
|
||||
{
|
||||
"Id": 182,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Exercise "
|
||||
},
|
||||
{
|
||||
"Id": 183,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dizzy "
|
||||
},
|
||||
{
|
||||
"Id": 184,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Barber "
|
||||
},
|
||||
{
|
||||
"Id": 185,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jazz "
|
||||
},
|
||||
{
|
||||
"Id": 186,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Download "
|
||||
},
|
||||
{
|
||||
"Id": 187,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ski Goggles "
|
||||
},
|
||||
{
|
||||
"Id": 188,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Oar "
|
||||
},
|
||||
{
|
||||
"Id": 189,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shampoo "
|
||||
},
|
||||
{
|
||||
"Id": 190,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Think "
|
||||
},
|
||||
{
|
||||
"Id": 191,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Darts "
|
||||
},
|
||||
{
|
||||
"Id": 192,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shower Curtain "
|
||||
},
|
||||
{
|
||||
"Id": 193,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Extension Cord "
|
||||
},
|
||||
{
|
||||
"Id": 194,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Birthday "
|
||||
},
|
||||
{
|
||||
"Id": 195,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pocket "
|
||||
},
|
||||
{
|
||||
"Id": 196,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Zipper "
|
||||
},
|
||||
{
|
||||
"Id": 197,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fiddle "
|
||||
},
|
||||
{
|
||||
"Id": 198,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pail "
|
||||
},
|
||||
{
|
||||
"Id": 199,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Baguette "
|
||||
},
|
||||
{
|
||||
"Id": 200,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rib "
|
||||
},
|
||||
{
|
||||
"Id": 201,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sushi "
|
||||
},
|
||||
{
|
||||
"Id": 202,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ceiling Fan "
|
||||
},
|
||||
{
|
||||
"Id": 204,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Disco"
|
||||
},
|
||||
{
|
||||
"Id": 205,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Castle"
|
||||
},
|
||||
{
|
||||
"Id": 206,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Post Office "
|
||||
},
|
||||
{
|
||||
"Id": 207,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chess "
|
||||
},
|
||||
{
|
||||
"Id": 208,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Puppet "
|
||||
},
|
||||
{
|
||||
"Id": 209,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Kangaroo"
|
||||
},
|
||||
{
|
||||
"Id": 210,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dentist"
|
||||
},
|
||||
{
|
||||
"Id": 211,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bear Trap"
|
||||
},
|
||||
{
|
||||
"Id": 212,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cabin "
|
||||
},
|
||||
{
|
||||
"Id": 213,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cruise Ship "
|
||||
},
|
||||
{
|
||||
"Id": 214,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Saturn"
|
||||
},
|
||||
{
|
||||
"Id": 215,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "N64"
|
||||
},
|
||||
{
|
||||
"Id": 216,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dream "
|
||||
},
|
||||
{
|
||||
"Id": 217,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Recycle "
|
||||
},
|
||||
{
|
||||
"Id": 218,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Raft "
|
||||
},
|
||||
{
|
||||
"Id": 219,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gold "
|
||||
},
|
||||
{
|
||||
"Id": 220,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Plank "
|
||||
},
|
||||
{
|
||||
"Id": 221,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cliff "
|
||||
},
|
||||
{
|
||||
"Id": 222,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cape"
|
||||
},
|
||||
{
|
||||
"Id": 223,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Safe"
|
||||
},
|
||||
{
|
||||
"Id": 224,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Picnic "
|
||||
},
|
||||
{
|
||||
"Id": 225,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Boa Constrictor "
|
||||
},
|
||||
{
|
||||
"Id": 227,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hurdle "
|
||||
},
|
||||
{
|
||||
"Id": 229,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Loveseat "
|
||||
},
|
||||
{
|
||||
"Id": 230,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Harry Potter"
|
||||
},
|
||||
{
|
||||
"Id": 231,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Cool Runnings"
|
||||
},
|
||||
{
|
||||
"Id": 232,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bobsled "
|
||||
},
|
||||
{
|
||||
"Id": 233,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hot Tub "
|
||||
},
|
||||
{
|
||||
"Id": 234,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Firefighter"
|
||||
},
|
||||
{
|
||||
"Id": 235,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cell Phone"
|
||||
},
|
||||
{
|
||||
"Id": 236,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Coach"
|
||||
},
|
||||
{
|
||||
"Id": 237,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Moth"
|
||||
},
|
||||
{
|
||||
"Id": 238,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wooly Mammoth "
|
||||
},
|
||||
{
|
||||
"Id": 239,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bonfire"
|
||||
},
|
||||
{
|
||||
"Id": 240,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Laptop"
|
||||
},
|
||||
{
|
||||
"Id": 241,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bicycle"
|
||||
},
|
||||
{
|
||||
"Id": 242,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Subway"
|
||||
},
|
||||
{
|
||||
"Id": 243,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cat"
|
||||
},
|
||||
{
|
||||
"Id": 244,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ant"
|
||||
},
|
||||
{
|
||||
"Id": 245,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Zebra"
|
||||
},
|
||||
{
|
||||
"Id": 246,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pizza"
|
||||
},
|
||||
{
|
||||
"Id": 247,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Baseball Bat"
|
||||
},
|
||||
{
|
||||
"Id": 248,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Car"
|
||||
},
|
||||
{
|
||||
"Id": 249,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pig"
|
||||
},
|
||||
{
|
||||
"Id": 250,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bull"
|
||||
},
|
||||
{
|
||||
"Id": 251,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Calculator"
|
||||
},
|
||||
{
|
||||
"Id": 252,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Octopus"
|
||||
},
|
||||
{
|
||||
"Id": 253,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cheese"
|
||||
},
|
||||
{
|
||||
"Id": 254,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fish Tank"
|
||||
},
|
||||
{
|
||||
"Id": 255,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Starfish"
|
||||
},
|
||||
{
|
||||
"Id": 256,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eggs"
|
||||
},
|
||||
{
|
||||
"Id": 257,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Toothbrush"
|
||||
},
|
||||
{
|
||||
"Id": 258,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lemon"
|
||||
},
|
||||
{
|
||||
"Id": 259,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Computer"
|
||||
},
|
||||
{
|
||||
"Id": 260,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Present"
|
||||
},
|
||||
{
|
||||
"Id": 261,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Key"
|
||||
},
|
||||
{
|
||||
"Id": 262,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Silverware"
|
||||
},
|
||||
{
|
||||
"Id": 263,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Water"
|
||||
},
|
||||
{
|
||||
"Id": 264,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Broken Heart"
|
||||
},
|
||||
{
|
||||
"Id": 265,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Love"
|
||||
},
|
||||
{
|
||||
"Id": 266,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Silly String"
|
||||
},
|
||||
{
|
||||
"Id": 267,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Checkers"
|
||||
},
|
||||
{
|
||||
"Id": 268,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gumball Machine"
|
||||
},
|
||||
{
|
||||
"Id": 269,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "City"
|
||||
},
|
||||
{
|
||||
"Id": 270,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spider"
|
||||
},
|
||||
{
|
||||
"Id": 271,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wagon"
|
||||
},
|
||||
{
|
||||
"Id": 272,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tackle"
|
||||
},
|
||||
{
|
||||
"Id": 273,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Princess"
|
||||
},
|
||||
{
|
||||
"Id": 274,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Crown"
|
||||
},
|
||||
{
|
||||
"Id": 275,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mermaid"
|
||||
},
|
||||
{
|
||||
"Id": 276,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mushroom"
|
||||
},
|
||||
{
|
||||
"Id": 278,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mountain"
|
||||
},
|
||||
{
|
||||
"Id": 279,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Brush"
|
||||
},
|
||||
{
|
||||
"Id": 280,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Airport"
|
||||
},
|
||||
{
|
||||
"Id": 281,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bridge"
|
||||
},
|
||||
{
|
||||
"Id": 282,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lamp"
|
||||
},
|
||||
{
|
||||
"Id": 283,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Skate"
|
||||
},
|
||||
{
|
||||
"Id": 284,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tank"
|
||||
},
|
||||
{
|
||||
"Id": 285,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Kitten"
|
||||
},
|
||||
{
|
||||
"Id": 286,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Grapes"
|
||||
},
|
||||
{
|
||||
"Id": 287,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Knight"
|
||||
},
|
||||
{
|
||||
"Id": 288,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cave"
|
||||
},
|
||||
{
|
||||
"Id": 289,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eagle"
|
||||
},
|
||||
{
|
||||
"Id": 290,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rope"
|
||||
},
|
||||
{
|
||||
"Id": 291,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Farmer"
|
||||
},
|
||||
{
|
||||
"Id": 292,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sidewalk"
|
||||
},
|
||||
{
|
||||
"Id": 293,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Comb"
|
||||
},
|
||||
{
|
||||
"Id": 294,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rake"
|
||||
},
|
||||
{
|
||||
"Id": 295,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Swiss Cheese"
|
||||
},
|
||||
{
|
||||
"Id": 296,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pool"
|
||||
},
|
||||
{
|
||||
"Id": 297,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Door"
|
||||
},
|
||||
{
|
||||
"Id": 298,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Skateboard"
|
||||
},
|
||||
{
|
||||
"Id": 299,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rose"
|
||||
},
|
||||
{
|
||||
"Id": 300,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rabbit"
|
||||
},
|
||||
{
|
||||
"Id": 301,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Nest"
|
||||
},
|
||||
{
|
||||
"Id": 302,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Root"
|
||||
},
|
||||
{
|
||||
"Id": 303,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bride"
|
||||
},
|
||||
{
|
||||
"Id": 305,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Horn"
|
||||
},
|
||||
{
|
||||
"Id": 306,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Island"
|
||||
},
|
||||
{
|
||||
"Id": 307,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Paw"
|
||||
},
|
||||
{
|
||||
"Id": 308,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rowboat"
|
||||
},
|
||||
{
|
||||
"Id": 309,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tie"
|
||||
},
|
||||
{
|
||||
"Id": 310,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Radio"
|
||||
},
|
||||
{
|
||||
"Id": 311,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Handle"
|
||||
},
|
||||
{
|
||||
"Id": 313,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rain"
|
||||
},
|
||||
{
|
||||
"Id": 314,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Elevator"
|
||||
},
|
||||
{
|
||||
"Id": 315,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Saddle"
|
||||
},
|
||||
{
|
||||
"Id": 316,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Upside Down"
|
||||
},
|
||||
{
|
||||
"Id": 317,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Elephant"
|
||||
},
|
||||
{
|
||||
"Id": 318,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Baseball"
|
||||
},
|
||||
{
|
||||
"Id": 319,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ship"
|
||||
},
|
||||
{
|
||||
"Id": 320,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hot Dog"
|
||||
},
|
||||
{
|
||||
"Id": 321,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Building"
|
||||
},
|
||||
{
|
||||
"Id": 322,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Crab"
|
||||
},
|
||||
{
|
||||
"Id": 323,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bathtub"
|
||||
},
|
||||
{
|
||||
"Id": 324,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Alarm"
|
||||
},
|
||||
{
|
||||
"Id": 325,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Golf Ball"
|
||||
},
|
||||
{
|
||||
"Id": 326,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tree House"
|
||||
},
|
||||
{
|
||||
"Id": 327,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Battleship"
|
||||
},
|
||||
{
|
||||
"Id": 328,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Birdhouse"
|
||||
},
|
||||
{
|
||||
"Id": 329,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Yo–Yo"
|
||||
},
|
||||
{
|
||||
"Id": 330,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Belt"
|
||||
},
|
||||
{
|
||||
"Id": 331,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Candy"
|
||||
},
|
||||
{
|
||||
"Id": 332,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fishing"
|
||||
},
|
||||
{
|
||||
"Id": 333,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Drink"
|
||||
},
|
||||
{
|
||||
"Id": 334,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Globe"
|
||||
},
|
||||
{
|
||||
"Id": 335,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Garage"
|
||||
},
|
||||
{
|
||||
"Id": 336,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blizzard"
|
||||
},
|
||||
{
|
||||
"Id": 337,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bumblebee"
|
||||
},
|
||||
{
|
||||
"Id": 338,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Thorn"
|
||||
},
|
||||
{
|
||||
"Id": 339,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Leapfrog"
|
||||
},
|
||||
{
|
||||
"Id": 340,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mother"
|
||||
},
|
||||
{
|
||||
"Id": 341,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Father"
|
||||
},
|
||||
{
|
||||
"Id": 342,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dog"
|
||||
},
|
||||
{
|
||||
"Id": 343,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Deodorant"
|
||||
},
|
||||
{
|
||||
"Id": 344,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chocolate"
|
||||
},
|
||||
{
|
||||
"Id": 345,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cup"
|
||||
},
|
||||
{
|
||||
"Id": 346,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Church"
|
||||
},
|
||||
{
|
||||
"Id": 347,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spray"
|
||||
},
|
||||
{
|
||||
"Id": 348,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Grass"
|
||||
},
|
||||
{
|
||||
"Id": 349,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tissue"
|
||||
},
|
||||
{
|
||||
"Id": 350,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chicken"
|
||||
},
|
||||
{
|
||||
"Id": 351,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Song"
|
||||
},
|
||||
{
|
||||
"Id": 352,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Forest"
|
||||
},
|
||||
{
|
||||
"Id": 353,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pillow"
|
||||
},
|
||||
{
|
||||
"Id": 354,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Plant"
|
||||
},
|
||||
{
|
||||
"Id": 355,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bed"
|
||||
},
|
||||
{
|
||||
"Id": 356,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Notebook"
|
||||
},
|
||||
{
|
||||
"Id": 357,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ruler"
|
||||
},
|
||||
{
|
||||
"Id": 358,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Speaker"
|
||||
},
|
||||
{
|
||||
"Id": 359,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stool"
|
||||
},
|
||||
{
|
||||
"Id": 361,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Crutch"
|
||||
},
|
||||
{
|
||||
"Id": 362,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Butterfly"
|
||||
},
|
||||
{
|
||||
"Id": 363,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cane"
|
||||
},
|
||||
{
|
||||
"Id": 364,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Flower"
|
||||
},
|
||||
{
|
||||
"Id": 365,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Walker"
|
||||
},
|
||||
{
|
||||
"Id": 367,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gas"
|
||||
},
|
||||
{
|
||||
"Id": 368,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Perfume"
|
||||
},
|
||||
{
|
||||
"Id": 369,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Corn"
|
||||
},
|
||||
{
|
||||
"Id": 370,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Time"
|
||||
},
|
||||
{
|
||||
"Id": 371,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jack and the Bean Stalk"
|
||||
},
|
||||
{
|
||||
"Id": 372,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "United States"
|
||||
},
|
||||
{
|
||||
"Id": 373,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Directions"
|
||||
},
|
||||
{
|
||||
"Id": 375,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Apple"
|
||||
},
|
||||
{
|
||||
"Id": 376,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hat"
|
||||
},
|
||||
{
|
||||
"Id": 377,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jacket"
|
||||
},
|
||||
{
|
||||
"Id": 378,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Poison"
|
||||
},
|
||||
{
|
||||
"Id": 379,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shirt"
|
||||
},
|
||||
{
|
||||
"Id": 380,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jeans"
|
||||
},
|
||||
{
|
||||
"Id": 381,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Angry"
|
||||
},
|
||||
{
|
||||
"Id": 382,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ankle"
|
||||
},
|
||||
{
|
||||
"Id": 383,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Time"
|
||||
},
|
||||
{
|
||||
"Id": 384,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cry"
|
||||
},
|
||||
{
|
||||
"Id": 385,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cake"
|
||||
},
|
||||
{
|
||||
"Id": 386,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Watering Can"
|
||||
},
|
||||
{
|
||||
"Id": 387,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Boat"
|
||||
},
|
||||
{
|
||||
"Id": 389,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Star Trek"
|
||||
},
|
||||
{
|
||||
"Id": 391,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pinocchio"
|
||||
},
|
||||
{
|
||||
"Id": 392,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "WALL-E"
|
||||
},
|
||||
{
|
||||
"Id": 393,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Balcony"
|
||||
},
|
||||
{
|
||||
"Id": 394,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Lemonade Stand"
|
||||
},
|
||||
{
|
||||
"Id": 395,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Juggle"
|
||||
},
|
||||
{
|
||||
"Id": 396,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Knitting"
|
||||
},
|
||||
{
|
||||
"Id": 397,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Double-sided Tape"
|
||||
},
|
||||
{
|
||||
"Id": 398,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chainsaw"
|
||||
},
|
||||
{
|
||||
"Id": 399,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Barista"
|
||||
},
|
||||
{
|
||||
"Id": 400,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Old Faithful"
|
||||
},
|
||||
{
|
||||
"Id": 401,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Road Rage"
|
||||
},
|
||||
{
|
||||
"Id": 402,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Assembly Line"
|
||||
},
|
||||
{
|
||||
"Id": 403,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Horsepower"
|
||||
},
|
||||
{
|
||||
"Id": 404,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Amputation"
|
||||
},
|
||||
{
|
||||
"Id": 405,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Rage Quit"
|
||||
},
|
||||
{
|
||||
"Id": 407,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "University"
|
||||
},
|
||||
{
|
||||
"Id": 408,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Committee"
|
||||
},
|
||||
{
|
||||
"Id": 410,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Hedge Trimmer"
|
||||
},
|
||||
{
|
||||
"Id": 411,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Thermostat"
|
||||
},
|
||||
{
|
||||
"Id": 412,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Duel"
|
||||
},
|
||||
{
|
||||
"Id": 413,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shooting Star"
|
||||
},
|
||||
{
|
||||
"Id": 415,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pogo Stick"
|
||||
},
|
||||
{
|
||||
"Id": 417,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Katniss Everdeen"
|
||||
},
|
||||
{
|
||||
"Id": 418,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rocket Man"
|
||||
},
|
||||
{
|
||||
"Id": 419,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Monster Truck"
|
||||
},
|
||||
{
|
||||
"Id": 420,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "King Kong"
|
||||
},
|
||||
{
|
||||
"Id": 422,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Vampire"
|
||||
},
|
||||
{
|
||||
"Id": 423,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Peter Pan"
|
||||
},
|
||||
{
|
||||
"Id": 424,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Captain Hook"
|
||||
},
|
||||
{
|
||||
"Id": 425,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Three Little Pigs"
|
||||
},
|
||||
{
|
||||
"Id": 426,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Big Bad Wolf"
|
||||
},
|
||||
{
|
||||
"Id": 427,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ninja"
|
||||
},
|
||||
{
|
||||
"Id": 428,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scarecrow"
|
||||
},
|
||||
{
|
||||
"Id": 429,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tin Man"
|
||||
},
|
||||
{
|
||||
"Id": 430,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Devil"
|
||||
},
|
||||
{
|
||||
"Id": 432,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Darth Vader"
|
||||
},
|
||||
{
|
||||
"Id": 433,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wolverine"
|
||||
},
|
||||
{
|
||||
"Id": 434,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cyclops"
|
||||
},
|
||||
{
|
||||
"Id": 435,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Medusa"
|
||||
},
|
||||
{
|
||||
"Id": 436,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Drums"
|
||||
},
|
||||
{
|
||||
"Id": 437,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dice"
|
||||
},
|
||||
{
|
||||
"Id": 438,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Police Officer"
|
||||
},
|
||||
{
|
||||
"Id": 439,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Singer"
|
||||
},
|
||||
{
|
||||
"Id": 440,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Judge"
|
||||
},
|
||||
{
|
||||
"Id": 442,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gandalf"
|
||||
},
|
||||
{
|
||||
"Id": 443,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ghost"
|
||||
},
|
||||
{
|
||||
"Id": 444,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dragon"
|
||||
},
|
||||
{
|
||||
"Id": 445,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Santa Claus"
|
||||
},
|
||||
{
|
||||
"Id": 446,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tooth Fairy"
|
||||
},
|
||||
{
|
||||
"Id": 447,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hospital"
|
||||
},
|
||||
{
|
||||
"Id": 451,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mickey Mouse"
|
||||
},
|
||||
{
|
||||
"Id": 452,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cat in the Hat"
|
||||
},
|
||||
{
|
||||
"Id": 453,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Doormat"
|
||||
},
|
||||
{
|
||||
"Id": 454,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Windowsill"
|
||||
},
|
||||
{
|
||||
"Id": 455,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Trapeze"
|
||||
},
|
||||
{
|
||||
"Id": 456,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Unicycle"
|
||||
},
|
||||
{
|
||||
"Id": 457,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "SCUBA"
|
||||
},
|
||||
{
|
||||
"Id": 458,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Snorkle"
|
||||
},
|
||||
{
|
||||
"Id": 460,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tiger"
|
||||
},
|
||||
{
|
||||
"Id": 461,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Feather"
|
||||
},
|
||||
{
|
||||
"Id": 462,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Racket"
|
||||
},
|
||||
{
|
||||
"Id": 463,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Build"
|
||||
},
|
||||
{
|
||||
"Id": 464,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Weightlifter"
|
||||
},
|
||||
{
|
||||
"Id": 465,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Emoji"
|
||||
},
|
||||
{
|
||||
"Id": 466,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Plug"
|
||||
},
|
||||
{
|
||||
"Id": 467,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lightning Bolt"
|
||||
},
|
||||
{
|
||||
"Id": 468,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Zeus"
|
||||
},
|
||||
{
|
||||
"Id": 469,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Caterpillar"
|
||||
},
|
||||
{
|
||||
"Id": 470,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tornado"
|
||||
},
|
||||
{
|
||||
"Id": 471,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cupid"
|
||||
},
|
||||
{
|
||||
"Id": 472,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Climb"
|
||||
},
|
||||
{
|
||||
"Id": 473,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Monkey Bars"
|
||||
},
|
||||
{
|
||||
"Id": 474,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Slide"
|
||||
},
|
||||
{
|
||||
"Id": 475,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Microscope"
|
||||
},
|
||||
{
|
||||
"Id": 477,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Breakfast"
|
||||
},
|
||||
{
|
||||
"Id": 478,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Parachute"
|
||||
},
|
||||
{
|
||||
"Id": 479,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Helicopter"
|
||||
},
|
||||
{
|
||||
"Id": 480,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jump Rope"
|
||||
},
|
||||
{
|
||||
"Id": 481,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Beauty and the Beast"
|
||||
},
|
||||
{
|
||||
"Id": 482,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mad Hatter"
|
||||
},
|
||||
{
|
||||
"Id": 483,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Queen of Hearts"
|
||||
},
|
||||
{
|
||||
"Id": 484,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Unicorn"
|
||||
},
|
||||
{
|
||||
"Id": 485,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tow Truck"
|
||||
},
|
||||
{
|
||||
"Id": 486,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fountain"
|
||||
},
|
||||
{
|
||||
"Id": 487,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Drop Anchor"
|
||||
},
|
||||
{
|
||||
"Id": 488,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Snowman"
|
||||
},
|
||||
{
|
||||
"Id": 489,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Moose"
|
||||
},
|
||||
{
|
||||
"Id": 490,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gnome"
|
||||
},
|
||||
{
|
||||
"Id": 491,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rainbow"
|
||||
},
|
||||
{
|
||||
"Id": 492,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pork Chop"
|
||||
},
|
||||
{
|
||||
"Id": 493,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jewelry"
|
||||
},
|
||||
{
|
||||
"Id": 494,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Throne"
|
||||
},
|
||||
{
|
||||
"Id": 495,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Witch"
|
||||
},
|
||||
{
|
||||
"Id": 496,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "UFO"
|
||||
},
|
||||
{
|
||||
"Id": 497,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Three Musketeers"
|
||||
},
|
||||
{
|
||||
"Id": 498,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pie"
|
||||
},
|
||||
{
|
||||
"Id": 499,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Brain"
|
||||
},
|
||||
{
|
||||
"Id": 500,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Snail"
|
||||
},
|
||||
{
|
||||
"Id": 501,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Robin Hood"
|
||||
},
|
||||
{
|
||||
"Id": 502,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stegosaurus"
|
||||
},
|
||||
{
|
||||
"Id": 503,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Willy Wonka"
|
||||
},
|
||||
{
|
||||
"Id": 504,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mechanic"
|
||||
},
|
||||
{
|
||||
"Id": 505,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Coconut"
|
||||
},
|
||||
{
|
||||
"Id": 506,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cookie Monster"
|
||||
},
|
||||
{
|
||||
"Id": 507,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Giraffe"
|
||||
},
|
||||
{
|
||||
"Id": 508,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Skull"
|
||||
},
|
||||
{
|
||||
"Id": 509,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "DJ"
|
||||
},
|
||||
{
|
||||
"Id": 510,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Joust"
|
||||
},
|
||||
{
|
||||
"Id": 511,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Telescope"
|
||||
},
|
||||
{
|
||||
"Id": 512,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Beehive"
|
||||
},
|
||||
{
|
||||
"Id": 513,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mummy"
|
||||
},
|
||||
{
|
||||
"Id": 514,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Nail Polish"
|
||||
},
|
||||
{
|
||||
"Id": 515,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scorpion"
|
||||
},
|
||||
{
|
||||
"Id": 516,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Swing"
|
||||
},
|
||||
{
|
||||
"Id": 517,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Boomerang"
|
||||
},
|
||||
{
|
||||
"Id": 518,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "South America"
|
||||
},
|
||||
{
|
||||
"Id": 519,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Asia"
|
||||
},
|
||||
{
|
||||
"Id": 520,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Africa"
|
||||
},
|
||||
{
|
||||
"Id": 521,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Australia"
|
||||
},
|
||||
{
|
||||
"Id": 522,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Antarctica"
|
||||
},
|
||||
{
|
||||
"Id": 523,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Whale"
|
||||
},
|
||||
{
|
||||
"Id": 524,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dolphin"
|
||||
},
|
||||
{
|
||||
"Id": 525,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Narwhal"
|
||||
},
|
||||
{
|
||||
"Id": 526,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Disc Golf"
|
||||
},
|
||||
{
|
||||
"Id": 527,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Plumber"
|
||||
},
|
||||
{
|
||||
"Id": 528,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chef"
|
||||
},
|
||||
{
|
||||
"Id": 529,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Walking the Dog"
|
||||
},
|
||||
{
|
||||
"Id": 530,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fairy Tale"
|
||||
},
|
||||
{
|
||||
"Id": 531,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Treadmill"
|
||||
},
|
||||
{
|
||||
"Id": 532,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Groundhog"
|
||||
},
|
||||
{
|
||||
"Id": 533,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Porcupine"
|
||||
},
|
||||
{
|
||||
"Id": 534,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Centaur"
|
||||
},
|
||||
{
|
||||
"Id": 535,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Soda"
|
||||
},
|
||||
{
|
||||
"Id": 536,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cookies"
|
||||
},
|
||||
{
|
||||
"Id": 537,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tea"
|
||||
},
|
||||
{
|
||||
"Id": 538,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stove"
|
||||
},
|
||||
{
|
||||
"Id": 539,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Oven"
|
||||
},
|
||||
{
|
||||
"Id": 540,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cooking"
|
||||
},
|
||||
{
|
||||
"Id": 541,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Soup"
|
||||
},
|
||||
{
|
||||
"Id": 542,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Kite"
|
||||
},
|
||||
{
|
||||
"Id": 543,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Trombone"
|
||||
},
|
||||
{
|
||||
"Id": 544,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Canoe"
|
||||
},
|
||||
{
|
||||
"Id": 545,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hamburger"
|
||||
},
|
||||
{
|
||||
"Id": 546,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pilot"
|
||||
},
|
||||
{
|
||||
"Id": 547,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Caesar"
|
||||
},
|
||||
{
|
||||
"Id": 548,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Humpty Dumpty"
|
||||
},
|
||||
{
|
||||
"Id": 549,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bob Ross"
|
||||
},
|
||||
{
|
||||
"Id": 550,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Walrus"
|
||||
},
|
||||
{
|
||||
"Id": 551,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pillow Fight"
|
||||
},
|
||||
{
|
||||
"Id": 552,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Remote"
|
||||
},
|
||||
{
|
||||
"Id": 553,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hanger"
|
||||
},
|
||||
{
|
||||
"Id": 554,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Thermometer"
|
||||
},
|
||||
{
|
||||
"Id": 555,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Polka Dots"
|
||||
},
|
||||
{
|
||||
"Id": 556,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Contract"
|
||||
},
|
||||
{
|
||||
"Id": 557,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Signature"
|
||||
},
|
||||
{
|
||||
"Id": 558,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Surgeon"
|
||||
},
|
||||
{
|
||||
"Id": 559,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sleeve"
|
||||
},
|
||||
{
|
||||
"Id": 560,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stapler"
|
||||
},
|
||||
{
|
||||
"Id": 561,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chicken Pox"
|
||||
},
|
||||
{
|
||||
"Id": 562,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sherlock Holmes"
|
||||
},
|
||||
{
|
||||
"Id": 563,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sponge"
|
||||
},
|
||||
{
|
||||
"Id": 564,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Umpire"
|
||||
},
|
||||
{
|
||||
"Id": 565,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pearl"
|
||||
},
|
||||
{
|
||||
"Id": 566,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sewing Machine"
|
||||
},
|
||||
{
|
||||
"Id": 567,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Freezer"
|
||||
},
|
||||
{
|
||||
"Id": 568,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Belly Button"
|
||||
},
|
||||
{
|
||||
"Id": 569,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Watermelon"
|
||||
},
|
||||
{
|
||||
"Id": 570,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Slot Machine"
|
||||
},
|
||||
{
|
||||
"Id": 571,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wallet"
|
||||
},
|
||||
{
|
||||
"Id": 572,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Keyboard"
|
||||
},
|
||||
{
|
||||
"Id": 573,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chimney"
|
||||
},
|
||||
{
|
||||
"Id": 574,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Smoke"
|
||||
},
|
||||
{
|
||||
"Id": 575,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fleas"
|
||||
},
|
||||
{
|
||||
"Id": 576,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Banjo"
|
||||
},
|
||||
{
|
||||
"Id": 577,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Razor"
|
||||
},
|
||||
{
|
||||
"Id": 578,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Badminton"
|
||||
},
|
||||
{
|
||||
"Id": 579,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stairs"
|
||||
},
|
||||
{
|
||||
"Id": 580,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dial"
|
||||
},
|
||||
{
|
||||
"Id": 581,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Waterski"
|
||||
},
|
||||
{
|
||||
"Id": 582,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lick"
|
||||
},
|
||||
{
|
||||
"Id": 584,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Yo-Yo"
|
||||
},
|
||||
{
|
||||
"Id": 585,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bubble"
|
||||
},
|
||||
{
|
||||
"Id": 586,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "High Heels"
|
||||
},
|
||||
{
|
||||
"Id": 587,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Echo"
|
||||
},
|
||||
{
|
||||
"Id": 588,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bandage"
|
||||
},
|
||||
{
|
||||
"Id": 589,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Excalibur"
|
||||
},
|
||||
{
|
||||
"Id": 590,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mosquito"
|
||||
},
|
||||
{
|
||||
"Id": 591,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Envelope"
|
||||
},
|
||||
{
|
||||
"Id": 592,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bowl"
|
||||
},
|
||||
{
|
||||
"Id": 593,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bowling Pin"
|
||||
},
|
||||
{
|
||||
"Id": 594,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bowling Ball"
|
||||
},
|
||||
{
|
||||
"Id": 595,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Whistle"
|
||||
},
|
||||
{
|
||||
"Id": 596,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Diamond"
|
||||
},
|
||||
{
|
||||
"Id": 597,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Suspenders"
|
||||
},
|
||||
{
|
||||
"Id": 598,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hairspray"
|
||||
},
|
||||
{
|
||||
"Id": 599,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Calendar"
|
||||
},
|
||||
{
|
||||
"Id": 600,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Monk"
|
||||
},
|
||||
{
|
||||
"Id": 601,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Screwdriver"
|
||||
},
|
||||
{
|
||||
"Id": 602,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Earring"
|
||||
},
|
||||
{
|
||||
"Id": 603,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fireworks"
|
||||
},
|
||||
{
|
||||
"Id": 604,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Waffle"
|
||||
},
|
||||
{
|
||||
"Id": 605,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bell"
|
||||
},
|
||||
{
|
||||
"Id": 606,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Drive"
|
||||
},
|
||||
{
|
||||
"Id": 607,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Collar"
|
||||
},
|
||||
{
|
||||
"Id": 608,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Money"
|
||||
},
|
||||
{
|
||||
"Id": 609,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Toe"
|
||||
},
|
||||
{
|
||||
"Id": 610,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Balance Beam"
|
||||
},
|
||||
{
|
||||
"Id": 611,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Treasure Chest"
|
||||
},
|
||||
{
|
||||
"Id": 612,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sleeping Bag"
|
||||
},
|
||||
{
|
||||
"Id": 613,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tent"
|
||||
},
|
||||
{
|
||||
"Id": 614,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bib"
|
||||
},
|
||||
{
|
||||
"Id": 615,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Buffet"
|
||||
},
|
||||
{
|
||||
"Id": 616,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Crystal"
|
||||
},
|
||||
{
|
||||
"Id": 617,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cymbals"
|
||||
},
|
||||
{
|
||||
"Id": 618,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Nametag"
|
||||
},
|
||||
{
|
||||
"Id": 619,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Beak"
|
||||
},
|
||||
{
|
||||
"Id": 620,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pacifier"
|
||||
},
|
||||
{
|
||||
"Id": 621,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Umbrella"
|
||||
},
|
||||
{
|
||||
"Id": 622,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shoe"
|
||||
},
|
||||
{
|
||||
"Id": 623,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gorilla"
|
||||
},
|
||||
{
|
||||
"Id": 624,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Earmuffs"
|
||||
},
|
||||
{
|
||||
"Id": 625,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Idea"
|
||||
},
|
||||
{
|
||||
"Id": 626,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Finish Line"
|
||||
},
|
||||
{
|
||||
"Id": 627,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Angel"
|
||||
},
|
||||
{
|
||||
"Id": 628,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Turtleneck"
|
||||
},
|
||||
{
|
||||
"Id": 629,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Kiss"
|
||||
},
|
||||
{
|
||||
"Id": 630,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chopsticks"
|
||||
},
|
||||
{
|
||||
"Id": 631,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Coffee"
|
||||
},
|
||||
{
|
||||
"Id": 632,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Whip"
|
||||
},
|
||||
{
|
||||
"Id": 633,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Apron"
|
||||
},
|
||||
{
|
||||
"Id": 634,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mole"
|
||||
},
|
||||
{
|
||||
"Id": 635,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pour"
|
||||
},
|
||||
{
|
||||
"Id": 636,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Marbles"
|
||||
},
|
||||
{
|
||||
"Id": 637,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cork"
|
||||
},
|
||||
{
|
||||
"Id": 638,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lollipop"
|
||||
},
|
||||
{
|
||||
"Id": 639,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rat"
|
||||
},
|
||||
{
|
||||
"Id": 640,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jackhammer"
|
||||
},
|
||||
{
|
||||
"Id": 641,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bat"
|
||||
},
|
||||
{
|
||||
"Id": 642,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Binoculars"
|
||||
},
|
||||
{
|
||||
"Id": 643,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tattoo"
|
||||
},
|
||||
{
|
||||
"Id": 644,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cards"
|
||||
},
|
||||
{
|
||||
"Id": 645,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bark"
|
||||
},
|
||||
{
|
||||
"Id": 646,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eyelash"
|
||||
},
|
||||
{
|
||||
"Id": 647,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sunglasses"
|
||||
},
|
||||
{
|
||||
"Id": 648,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Drill"
|
||||
},
|
||||
{
|
||||
"Id": 649,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wash"
|
||||
},
|
||||
{
|
||||
"Id": 650,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Closet"
|
||||
},
|
||||
{
|
||||
"Id": 651,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tennis"
|
||||
},
|
||||
{
|
||||
"Id": 652,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scarf"
|
||||
},
|
||||
{
|
||||
"Id": 653,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Antenna"
|
||||
},
|
||||
{
|
||||
"Id": 654,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Aquarium"
|
||||
},
|
||||
{
|
||||
"Id": 655,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Refrigerator"
|
||||
},
|
||||
{
|
||||
"Id": 656,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Muscle"
|
||||
},
|
||||
{
|
||||
"Id": 657,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Iron"
|
||||
},
|
||||
{
|
||||
"Id": 658,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Film"
|
||||
},
|
||||
{
|
||||
"Id": 659,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pipe"
|
||||
},
|
||||
{
|
||||
"Id": 660,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blindfold"
|
||||
},
|
||||
{
|
||||
"Id": 661,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stork"
|
||||
},
|
||||
{
|
||||
"Id": 662,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Deer"
|
||||
},
|
||||
{
|
||||
"Id": 663,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bad Breath"
|
||||
},
|
||||
{
|
||||
"Id": 664,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Garden"
|
||||
},
|
||||
{
|
||||
"Id": 665,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tug of War"
|
||||
},
|
||||
{
|
||||
"Id": 666,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sandwich"
|
||||
},
|
||||
{
|
||||
"Id": 667,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Glove"
|
||||
},
|
||||
{
|
||||
"Id": 668,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fence"
|
||||
},
|
||||
{
|
||||
"Id": 669,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Heartburn"
|
||||
},
|
||||
{
|
||||
"Id": 670,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Moustache"
|
||||
},
|
||||
{
|
||||
"Id": 671,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hose"
|
||||
},
|
||||
{
|
||||
"Id": 672,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hypnotize"
|
||||
},
|
||||
{
|
||||
"Id": 673,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gong"
|
||||
},
|
||||
{
|
||||
"Id": 674,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hammer"
|
||||
},
|
||||
{
|
||||
"Id": 675,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mailbox"
|
||||
},
|
||||
{
|
||||
"Id": 676,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Luggage"
|
||||
},
|
||||
{
|
||||
"Id": 677,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Carrot"
|
||||
},
|
||||
{
|
||||
"Id": 678,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Seatbelt"
|
||||
},
|
||||
{
|
||||
"Id": 679,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Clown"
|
||||
},
|
||||
{
|
||||
"Id": 680,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Slingshot"
|
||||
},
|
||||
{
|
||||
"Id": 681,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jukebox"
|
||||
},
|
||||
{
|
||||
"Id": 682,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Skeleton"
|
||||
},
|
||||
{
|
||||
"Id": 683,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bait"
|
||||
},
|
||||
{
|
||||
"Id": 684,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ribbon"
|
||||
},
|
||||
{
|
||||
"Id": 685,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Attic"
|
||||
},
|
||||
{
|
||||
"Id": 686,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Branch"
|
||||
},
|
||||
{
|
||||
"Id": 687,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Candy Cane"
|
||||
},
|
||||
{
|
||||
"Id": 688,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rug"
|
||||
},
|
||||
{
|
||||
"Id": 689,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hourglass"
|
||||
},
|
||||
{
|
||||
"Id": 690,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stethoscope"
|
||||
},
|
||||
{
|
||||
"Id": 691,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Potholder"
|
||||
},
|
||||
{
|
||||
"Id": 692,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pumpkin"
|
||||
},
|
||||
{
|
||||
"Id": 693,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lasso"
|
||||
},
|
||||
{
|
||||
"Id": 694,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cannon"
|
||||
},
|
||||
{
|
||||
"Id": 695,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Harp"
|
||||
},
|
||||
{
|
||||
"Id": 696,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tambourine"
|
||||
},
|
||||
{
|
||||
"Id": 697,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Poison Ivy"
|
||||
},
|
||||
{
|
||||
"Id": 698,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Snowflake"
|
||||
},
|
||||
{
|
||||
"Id": 699,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Punching Bag"
|
||||
},
|
||||
{
|
||||
"Id": 700,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Puzzle"
|
||||
},
|
||||
{
|
||||
"Id": 701,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Broom"
|
||||
},
|
||||
{
|
||||
"Id": 702,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Earth"
|
||||
},
|
||||
{
|
||||
"Id": 703,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sky"
|
||||
},
|
||||
{
|
||||
"Id": 704,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Statue"
|
||||
},
|
||||
{
|
||||
"Id": 705,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Doll"
|
||||
},
|
||||
{
|
||||
"Id": 706,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Necklace"
|
||||
},
|
||||
{
|
||||
"Id": 707,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Popcorn"
|
||||
},
|
||||
{
|
||||
"Id": 708,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lock"
|
||||
},
|
||||
{
|
||||
"Id": 709,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sofa"
|
||||
},
|
||||
{
|
||||
"Id": 710,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Magnifying Glass"
|
||||
},
|
||||
{
|
||||
"Id": 711,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Surf"
|
||||
},
|
||||
{
|
||||
"Id": 712,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Magic"
|
||||
},
|
||||
{
|
||||
"Id": 713,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Walkie-Talkie"
|
||||
},
|
||||
{
|
||||
"Id": 714,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Floss"
|
||||
},
|
||||
{
|
||||
"Id": 715,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Knife"
|
||||
},
|
||||
{
|
||||
"Id": 716,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bread"
|
||||
},
|
||||
{
|
||||
"Id": 717,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Thumbtack"
|
||||
},
|
||||
{
|
||||
"Id": 718,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lighter"
|
||||
},
|
||||
{
|
||||
"Id": 719,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Teapot"
|
||||
},
|
||||
{
|
||||
"Id": 720,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Curls"
|
||||
},
|
||||
{
|
||||
"Id": 721,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Periscope"
|
||||
},
|
||||
{
|
||||
"Id": 722,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fingerprint"
|
||||
},
|
||||
{
|
||||
"Id": 723,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mask"
|
||||
},
|
||||
{
|
||||
"Id": 724,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tape Measure"
|
||||
},
|
||||
{
|
||||
"Id": 725,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scrub"
|
||||
},
|
||||
{
|
||||
"Id": 726,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Motorcycle"
|
||||
},
|
||||
{
|
||||
"Id": 727,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fur"
|
||||
},
|
||||
{
|
||||
"Id": 728,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Toothpaste"
|
||||
},
|
||||
{
|
||||
"Id": 729,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stilts"
|
||||
},
|
||||
{
|
||||
"Id": 730,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Straw"
|
||||
},
|
||||
{
|
||||
"Id": 731,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Doorknob"
|
||||
},
|
||||
{
|
||||
"Id": 732,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Train"
|
||||
},
|
||||
{
|
||||
"Id": 733,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ukulele"
|
||||
},
|
||||
{
|
||||
"Id": 734,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Nerd"
|
||||
},
|
||||
{
|
||||
"Id": 735,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bucket"
|
||||
},
|
||||
{
|
||||
"Id": 736,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Conductor"
|
||||
},
|
||||
{
|
||||
"Id": 737,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Palace"
|
||||
},
|
||||
{
|
||||
"Id": 738,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Giant"
|
||||
},
|
||||
{
|
||||
"Id": 739,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Goblin"
|
||||
},
|
||||
{
|
||||
"Id": 740,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ketchup"
|
||||
},
|
||||
{
|
||||
"Id": 741,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Taxi"
|
||||
},
|
||||
{
|
||||
"Id": 742,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gravestone"
|
||||
},
|
||||
{
|
||||
"Id": 743,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Croquet"
|
||||
},
|
||||
{
|
||||
"Id": 744,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tarzan"
|
||||
},
|
||||
{
|
||||
"Id": 745,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ballerina"
|
||||
},
|
||||
{
|
||||
"Id": 746,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Volleyball"
|
||||
},
|
||||
{
|
||||
"Id": 747,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Braces"
|
||||
},
|
||||
{
|
||||
"Id": 748,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fork"
|
||||
},
|
||||
{
|
||||
"Id": 749,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bubble Gum"
|
||||
},
|
||||
{
|
||||
"Id": 750,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Worm"
|
||||
},
|
||||
{
|
||||
"Id": 751,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ladder"
|
||||
},
|
||||
{
|
||||
"Id": 752,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dynamite"
|
||||
},
|
||||
{
|
||||
"Id": 753,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Flashlight"
|
||||
},
|
||||
{
|
||||
"Id": 754,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Basement"
|
||||
},
|
||||
{
|
||||
"Id": 755,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blanket"
|
||||
},
|
||||
{
|
||||
"Id": 756,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tire"
|
||||
},
|
||||
{
|
||||
"Id": 757,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Whiskers"
|
||||
},
|
||||
{
|
||||
"Id": 758,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Freckle"
|
||||
},
|
||||
{
|
||||
"Id": 759,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Backpack"
|
||||
},
|
||||
{
|
||||
"Id": 760,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Candle"
|
||||
},
|
||||
{
|
||||
"Id": 761,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jockey"
|
||||
},
|
||||
{
|
||||
"Id": 762,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stamp"
|
||||
},
|
||||
{
|
||||
"Id": 763,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Television"
|
||||
},
|
||||
{
|
||||
"Id": 764,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Xylophone"
|
||||
},
|
||||
{
|
||||
"Id": 765,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Helmet"
|
||||
},
|
||||
{
|
||||
"Id": 766,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Nightmare"
|
||||
},
|
||||
{
|
||||
"Id": 767,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Map"
|
||||
},
|
||||
{
|
||||
"Id": 768,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Accordion"
|
||||
},
|
||||
{
|
||||
"Id": 770,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Butter"
|
||||
},
|
||||
{
|
||||
"Id": 771,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tripod"
|
||||
},
|
||||
{
|
||||
"Id": 772,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spoon"
|
||||
},
|
||||
{
|
||||
"Id": 773,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Microphone"
|
||||
},
|
||||
{
|
||||
"Id": 774,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mascara"
|
||||
},
|
||||
{
|
||||
"Id": 775,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sideburns"
|
||||
},
|
||||
{
|
||||
"Id": 776,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wheelchair"
|
||||
},
|
||||
{
|
||||
"Id": 777,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pinwheel"
|
||||
},
|
||||
{
|
||||
"Id": 778,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Badge"
|
||||
},
|
||||
{
|
||||
"Id": 779,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Brick"
|
||||
},
|
||||
{
|
||||
"Id": 780,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Trash Can"
|
||||
},
|
||||
{
|
||||
"Id": 781,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hydrant"
|
||||
},
|
||||
{
|
||||
"Id": 782,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Axe"
|
||||
},
|
||||
{
|
||||
"Id": 783,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Swan"
|
||||
},
|
||||
{
|
||||
"Id": 784,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blood"
|
||||
},
|
||||
{
|
||||
"Id": 785,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Electricity"
|
||||
},
|
||||
{
|
||||
"Id": 786,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Caveman"
|
||||
},
|
||||
{
|
||||
"Id": 787,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Camel"
|
||||
},
|
||||
{
|
||||
"Id": 788,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Santa"
|
||||
},
|
||||
{
|
||||
"Id": 789,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shooting Star"
|
||||
},
|
||||
{
|
||||
"Id": 790,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gift"
|
||||
},
|
||||
{
|
||||
"Id": 794,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Baseball"
|
||||
},
|
||||
{
|
||||
"Id": 795,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Strawberry"
|
||||
},
|
||||
{
|
||||
"Id": 797,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Piano"
|
||||
},
|
||||
{
|
||||
"Id": 798,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Paparazzi"
|
||||
},
|
||||
{
|
||||
"Id": 799,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tricycle"
|
||||
},
|
||||
{
|
||||
"Id": 800,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fire Truck"
|
||||
},
|
||||
{
|
||||
"Id": 801,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ambulance"
|
||||
},
|
||||
{
|
||||
"Id": 802,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Crane"
|
||||
},
|
||||
{
|
||||
"Id": 803,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "C3PO"
|
||||
},
|
||||
{
|
||||
"Id": 804,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "USS Enterprise"
|
||||
},
|
||||
{
|
||||
"Id": 805,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spock"
|
||||
},
|
||||
{
|
||||
"Id": 806,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Captain America"
|
||||
},
|
||||
{
|
||||
"Id": 807,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "The Hulk"
|
||||
},
|
||||
{
|
||||
"Id": 808,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hawkeye"
|
||||
},
|
||||
{
|
||||
"Id": 809,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Thor"
|
||||
},
|
||||
{
|
||||
"Id": 810,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Iron Man"
|
||||
},
|
||||
{
|
||||
"Id": 812,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Race Car"
|
||||
},
|
||||
{
|
||||
"Id": 813,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Basketball Hoop"
|
||||
},
|
||||
{
|
||||
"Id": 814,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gazebo"
|
||||
},
|
||||
{
|
||||
"Id": 815,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Waterfall"
|
||||
},
|
||||
{
|
||||
"Id": 816,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Armadillo"
|
||||
},
|
||||
{
|
||||
"Id": 817,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Anteater"
|
||||
},
|
||||
{
|
||||
"Id": 818,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Elbow"
|
||||
},
|
||||
{
|
||||
"Id": 819,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Knee"
|
||||
},
|
||||
{
|
||||
"Id": 820,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cut"
|
||||
},
|
||||
{
|
||||
"Id": 821,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Top"
|
||||
},
|
||||
{
|
||||
"Id": 822,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Maze"
|
||||
},
|
||||
{
|
||||
"Id": 823,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Light Bulb"
|
||||
},
|
||||
{
|
||||
"Id": 824,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Kermit the Frog"
|
||||
},
|
||||
{
|
||||
"Id": 825,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "North America"
|
||||
},
|
||||
{
|
||||
"Id": 826,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Europe"
|
||||
},
|
||||
{
|
||||
"Id": 827,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Construction Worker"
|
||||
},
|
||||
{
|
||||
"Id": 828,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Doctor"
|
||||
},
|
||||
{
|
||||
"Id": 829,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cheetah"
|
||||
},
|
||||
{
|
||||
"Id": 830,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ram"
|
||||
},
|
||||
{
|
||||
"Id": 831,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Robe"
|
||||
},
|
||||
{
|
||||
"Id": 832,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Peas"
|
||||
},
|
||||
{
|
||||
"Id": 833,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bow Tie"
|
||||
},
|
||||
{
|
||||
"Id": 834,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Samurai"
|
||||
},
|
||||
{
|
||||
"Id": 835,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Viking"
|
||||
},
|
||||
{
|
||||
"Id": 836,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lighthouse"
|
||||
},
|
||||
{
|
||||
"Id": 837,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sunscreen"
|
||||
},
|
||||
{
|
||||
"Id": 838,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Archer"
|
||||
},
|
||||
{
|
||||
"Id": 839,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Leaf"
|
||||
},
|
||||
{
|
||||
"Id": 840,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Acorn"
|
||||
},
|
||||
{
|
||||
"Id": 841,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Squirrel"
|
||||
},
|
||||
{
|
||||
"Id": 842,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Meatball"
|
||||
},
|
||||
{
|
||||
"Id": 843,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eyebrow"
|
||||
},
|
||||
{
|
||||
"Id": 844,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eye"
|
||||
},
|
||||
{
|
||||
"Id": 845,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dish"
|
||||
},
|
||||
{
|
||||
"Id": 846,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Frog"
|
||||
},
|
||||
{
|
||||
"Id": 847,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lizard"
|
||||
},
|
||||
{
|
||||
"Id": 848,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Game of Thrones"
|
||||
},
|
||||
{
|
||||
"Id": 849,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hulk"
|
||||
},
|
||||
{
|
||||
"Id": 850,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Black Panther"
|
||||
},
|
||||
{
|
||||
"Id": 851,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Soccer"
|
||||
},
|
||||
{
|
||||
"Id": 852,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tetris"
|
||||
},
|
||||
{
|
||||
"Id": 853,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Zelda"
|
||||
},
|
||||
{
|
||||
"Id": 854,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Trampoline"
|
||||
},
|
||||
{
|
||||
"Id": 855,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Roller Coaster"
|
||||
},
|
||||
{
|
||||
"Id": 856,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ferris Wheel"
|
||||
},
|
||||
{
|
||||
"Id": 857,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pin"
|
||||
},
|
||||
{
|
||||
"Id": 858,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Baker"
|
||||
},
|
||||
{
|
||||
"Id": 859,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Restaurant"
|
||||
},
|
||||
{
|
||||
"Id": 860,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bank"
|
||||
},
|
||||
{
|
||||
"Id": 861,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "School"
|
||||
},
|
||||
{
|
||||
"Id": 862,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rocket"
|
||||
},
|
||||
{
|
||||
"Id": 863,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jupiter"
|
||||
},
|
||||
{
|
||||
"Id": 864,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eclipse"
|
||||
},
|
||||
{
|
||||
"Id": 865,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Moon"
|
||||
},
|
||||
{
|
||||
"Id": 866,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sun"
|
||||
},
|
||||
{
|
||||
"Id": 867,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Comet"
|
||||
},
|
||||
{
|
||||
"Id": 868,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "VR"
|
||||
},
|
||||
{
|
||||
"Id": 869,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fidget Spinner"
|
||||
},
|
||||
{
|
||||
"Id": 870,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Laser"
|
||||
},
|
||||
{
|
||||
"Id": 871,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Elf"
|
||||
},
|
||||
{
|
||||
"Id": 872,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Zombie"
|
||||
},
|
||||
{
|
||||
"Id": 873,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pegasus"
|
||||
},
|
||||
{
|
||||
"Id": 874,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Noodles"
|
||||
},
|
||||
{
|
||||
"Id": 875,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "French Fries"
|
||||
},
|
||||
{
|
||||
"Id": 876,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Break"
|
||||
},
|
||||
{
|
||||
"Id": 877,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Smash"
|
||||
},
|
||||
{
|
||||
"Id": 878,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pop"
|
||||
},
|
||||
{
|
||||
"Id": 879,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Orange"
|
||||
},
|
||||
{
|
||||
"Id": 880,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pear"
|
||||
},
|
||||
{
|
||||
"Id": 881,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Potato"
|
||||
},
|
||||
{
|
||||
"Id": 882,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tomato"
|
||||
},
|
||||
{
|
||||
"Id": 883,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Broccoli"
|
||||
},
|
||||
{
|
||||
"Id": 884,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eggplant"
|
||||
},
|
||||
{
|
||||
"Id": 885,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Donut"
|
||||
},
|
||||
{
|
||||
"Id": 886,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bagel"
|
||||
},
|
||||
{
|
||||
"Id": 887,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Seed"
|
||||
},
|
||||
{
|
||||
"Id": 888,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Salad"
|
||||
},
|
||||
{
|
||||
"Id": 889,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fried Chicken"
|
||||
},
|
||||
{
|
||||
"Id": 890,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bacon"
|
||||
},
|
||||
{
|
||||
"Id": 891,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Seal"
|
||||
},
|
||||
{
|
||||
"Id": 892,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Polar Bear"
|
||||
},
|
||||
{
|
||||
"Id": 893,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Centipede"
|
||||
},
|
||||
{
|
||||
"Id": 894,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ladybug"
|
||||
},
|
||||
{
|
||||
"Id": 895,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Grasshopper"
|
||||
},
|
||||
{
|
||||
"Id": 896,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shrimp"
|
||||
},
|
||||
{
|
||||
"Id": 897,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Electric Eel"
|
||||
},
|
||||
{
|
||||
"Id": 898,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lobster"
|
||||
},
|
||||
{
|
||||
"Id": 899,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hammerhead Shark"
|
||||
},
|
||||
{
|
||||
"Id": 900,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Peacock"
|
||||
},
|
||||
{
|
||||
"Id": 901,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dove"
|
||||
},
|
||||
{
|
||||
"Id": 902,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Raven"
|
||||
},
|
||||
{
|
||||
"Id": 903,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rattlesnake"
|
||||
},
|
||||
{
|
||||
"Id": 904,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cobra"
|
||||
},
|
||||
{
|
||||
"Id": 905,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fox"
|
||||
},
|
||||
{
|
||||
"Id": 906,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Frozen"
|
||||
},
|
||||
{
|
||||
"Id": 907,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lion"
|
||||
},
|
||||
{
|
||||
"Id": 908,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Actor"
|
||||
},
|
||||
{
|
||||
"Id": 909,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Add"
|
||||
},
|
||||
{
|
||||
"Id": 910,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Alice in Wonderland"
|
||||
},
|
||||
{
|
||||
"Id": 911,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Anchor"
|
||||
},
|
||||
{
|
||||
"Id": 912,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Army"
|
||||
},
|
||||
{
|
||||
"Id": 913,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Art"
|
||||
},
|
||||
{
|
||||
"Id": 914,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Astronaut"
|
||||
},
|
||||
{
|
||||
"Id": 915,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ball"
|
||||
},
|
||||
{
|
||||
"Id": 916,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ballet"
|
||||
},
|
||||
{
|
||||
"Id": 917,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Banana"
|
||||
},
|
||||
{
|
||||
"Id": 918,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Barbeque"
|
||||
},
|
||||
{
|
||||
"Id": 919,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Barbie"
|
||||
},
|
||||
{
|
||||
"Id": 920,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Barn"
|
||||
},
|
||||
{
|
||||
"Id": 921,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bart Simpson"
|
||||
},
|
||||
{
|
||||
"Id": 922,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Basket"
|
||||
},
|
||||
{
|
||||
"Id": 923,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Battery"
|
||||
},
|
||||
{
|
||||
"Id": 924,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Beach"
|
||||
},
|
||||
{
|
||||
"Id": 925,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Beard"
|
||||
},
|
||||
{
|
||||
"Id": 926,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bedbug"
|
||||
},
|
||||
{
|
||||
"Id": 927,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Beetle"
|
||||
},
|
||||
{
|
||||
"Id": 928,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Big Dipper"
|
||||
},
|
||||
{
|
||||
"Id": 929,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bikini"
|
||||
},
|
||||
{
|
||||
"Id": 930,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bite"
|
||||
},
|
||||
{
|
||||
"Id": 931,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blacksmith"
|
||||
},
|
||||
{
|
||||
"Id": 932,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Blimp"
|
||||
},
|
||||
{
|
||||
"Id": 933,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Block"
|
||||
},
|
||||
{
|
||||
"Id": 934,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bluetooth"
|
||||
},
|
||||
{
|
||||
"Id": 935,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Board Game"
|
||||
},
|
||||
{
|
||||
"Id": 936,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bone"
|
||||
},
|
||||
{
|
||||
"Id": 937,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bonnet"
|
||||
},
|
||||
{
|
||||
"Id": 938,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bookend"
|
||||
},
|
||||
{
|
||||
"Id": 939,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bottle"
|
||||
},
|
||||
{
|
||||
"Id": 940,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bounce"
|
||||
},
|
||||
{
|
||||
"Id": 941,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bow"
|
||||
},
|
||||
{
|
||||
"Id": 942,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Box"
|
||||
},
|
||||
{
|
||||
"Id": 943,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Boyfriend"
|
||||
},
|
||||
{
|
||||
"Id": 944,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bugs Bunny"
|
||||
},
|
||||
{
|
||||
"Id": 945,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bunk Bed"
|
||||
},
|
||||
{
|
||||
"Id": 946,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Burrito"
|
||||
},
|
||||
{
|
||||
"Id": 947,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Butcher"
|
||||
},
|
||||
{
|
||||
"Id": 948,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Button"
|
||||
},
|
||||
{
|
||||
"Id": 949,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Buzz Lightyear"
|
||||
},
|
||||
{
|
||||
"Id": 950,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Camp"
|
||||
},
|
||||
{
|
||||
"Id": 951,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Carpenter"
|
||||
},
|
||||
{
|
||||
"Id": 952,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Carry"
|
||||
},
|
||||
{
|
||||
"Id": 953,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cashier"
|
||||
},
|
||||
{
|
||||
"Id": 954,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "CD"
|
||||
},
|
||||
{
|
||||
"Id": 955,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cell"
|
||||
},
|
||||
{
|
||||
"Id": 956,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chain"
|
||||
},
|
||||
{
|
||||
"Id": 957,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chandelier"
|
||||
},
|
||||
{
|
||||
"Id": 958,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chart"
|
||||
},
|
||||
{
|
||||
"Id": 959,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cheeseburger"
|
||||
},
|
||||
{
|
||||
"Id": 960,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chemistry"
|
||||
},
|
||||
{
|
||||
"Id": 961,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Chocolate Chip"
|
||||
},
|
||||
{
|
||||
"Id": 962,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cinderella"
|
||||
},
|
||||
{
|
||||
"Id": 963,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Circuit"
|
||||
},
|
||||
{
|
||||
"Id": 964,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Circus"
|
||||
},
|
||||
{
|
||||
"Id": 965,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Close"
|
||||
},
|
||||
{
|
||||
"Id": 966,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cloud"
|
||||
},
|
||||
{
|
||||
"Id": 967,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Coat"
|
||||
},
|
||||
{
|
||||
"Id": 968,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cockroach"
|
||||
},
|
||||
{
|
||||
"Id": 969,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Color"
|
||||
},
|
||||
{
|
||||
"Id": 970,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Compass"
|
||||
},
|
||||
{
|
||||
"Id": 971,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Constellation"
|
||||
},
|
||||
{
|
||||
"Id": 972,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Copy"
|
||||
},
|
||||
{
|
||||
"Id": 973,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Corndog"
|
||||
},
|
||||
{
|
||||
"Id": 974,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Count Dracula"
|
||||
},
|
||||
{
|
||||
"Id": 975,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Country"
|
||||
},
|
||||
{
|
||||
"Id": 976,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cucumber"
|
||||
},
|
||||
{
|
||||
"Id": 977,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cupcake"
|
||||
},
|
||||
{
|
||||
"Id": 978,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Curtains"
|
||||
},
|
||||
{
|
||||
"Id": 979,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Cymbal"
|
||||
},
|
||||
{
|
||||
"Id": 980,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Daffy Duck"
|
||||
},
|
||||
{
|
||||
"Id": 981,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dance"
|
||||
},
|
||||
{
|
||||
"Id": 982,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dimple"
|
||||
},
|
||||
{
|
||||
"Id": 983,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dinner"
|
||||
},
|
||||
{
|
||||
"Id": 984,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dinosaur"
|
||||
},
|
||||
{
|
||||
"Id": 985,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Disk"
|
||||
},
|
||||
{
|
||||
"Id": 986,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dodge"
|
||||
},
|
||||
{
|
||||
"Id": 987,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Door Knob"
|
||||
},
|
||||
{
|
||||
"Id": 988,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dragonfly"
|
||||
},
|
||||
{
|
||||
"Id": 989,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Drain"
|
||||
},
|
||||
{
|
||||
"Id": 990,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Drawbridge"
|
||||
},
|
||||
{
|
||||
"Id": 991,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Dress"
|
||||
},
|
||||
{
|
||||
"Id": 992,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Duck"
|
||||
},
|
||||
{
|
||||
"Id": 993,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ears"
|
||||
},
|
||||
{
|
||||
"Id": 994,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Edge"
|
||||
},
|
||||
{
|
||||
"Id": 995,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eel"
|
||||
},
|
||||
{
|
||||
"Id": 996,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eeyore"
|
||||
},
|
||||
{
|
||||
"Id": 997,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Eiffel Tower"
|
||||
},
|
||||
{
|
||||
"Id": 998,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Einstein"
|
||||
},
|
||||
{
|
||||
"Id": 999,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Elmo"
|
||||
},
|
||||
{
|
||||
"Id": 1000,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fall"
|
||||
},
|
||||
{
|
||||
"Id": 1001,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Family"
|
||||
},
|
||||
{
|
||||
"Id": 1002,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Faucet"
|
||||
},
|
||||
{
|
||||
"Id": 1003,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Feast"
|
||||
},
|
||||
{
|
||||
"Id": 1004,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Finger"
|
||||
},
|
||||
{
|
||||
"Id": 1005,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fire"
|
||||
},
|
||||
{
|
||||
"Id": 1006,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fire Hydrant"
|
||||
},
|
||||
{
|
||||
"Id": 1007,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fireplace"
|
||||
},
|
||||
{
|
||||
"Id": 1008,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fish"
|
||||
},
|
||||
{
|
||||
"Id": 1009,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fishing Pole"
|
||||
},
|
||||
{
|
||||
"Id": 1010,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Flag Pole"
|
||||
},
|
||||
{
|
||||
"Id": 1011,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Flood"
|
||||
},
|
||||
{
|
||||
"Id": 1012,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Flute"
|
||||
},
|
||||
{
|
||||
"Id": 1013,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Foot"
|
||||
},
|
||||
{
|
||||
"Id": 1014,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Frame"
|
||||
},
|
||||
{
|
||||
"Id": 1015,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Frisbee"
|
||||
},
|
||||
{
|
||||
"Id": 1016,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Fruit"
|
||||
},
|
||||
{
|
||||
"Id": 1017,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Full Moon"
|
||||
},
|
||||
{
|
||||
"Id": 1018,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Funnel"
|
||||
},
|
||||
{
|
||||
"Id": 1019,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Galaxy"
|
||||
},
|
||||
{
|
||||
"Id": 1020,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Game Boy"
|
||||
},
|
||||
{
|
||||
"Id": 1021,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Garbage"
|
||||
},
|
||||
{
|
||||
"Id": 1022,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Gate"
|
||||
},
|
||||
{
|
||||
"Id": 1023,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ghostbusters"
|
||||
},
|
||||
{
|
||||
"Id": 1024,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Girlfriend"
|
||||
},
|
||||
{
|
||||
"Id": 1025,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Glue"
|
||||
},
|
||||
{
|
||||
"Id": 1026,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Goal"
|
||||
},
|
||||
{
|
||||
"Id": 1027,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Goldfish"
|
||||
},
|
||||
{
|
||||
"Id": 1028,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Goofy"
|
||||
},
|
||||
{
|
||||
"Id": 1029,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Graduation"
|
||||
},
|
||||
{
|
||||
"Id": 1030,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Grandma"
|
||||
},
|
||||
{
|
||||
"Id": 1031,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Graph"
|
||||
},
|
||||
{
|
||||
"Id": 1032,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ground"
|
||||
},
|
||||
{
|
||||
"Id": 1033,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hair"
|
||||
},
|
||||
{
|
||||
"Id": 1034,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Haircut"
|
||||
},
|
||||
{
|
||||
"Id": 1035,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Halo"
|
||||
},
|
||||
{
|
||||
"Id": 1036,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Headband"
|
||||
},
|
||||
{
|
||||
"Id": 1037,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Highchair"
|
||||
},
|
||||
{
|
||||
"Id": 1038,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hockey"
|
||||
},
|
||||
{
|
||||
"Id": 1039,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Honey"
|
||||
},
|
||||
{
|
||||
"Id": 1040,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Horns"
|
||||
},
|
||||
{
|
||||
"Id": 1041,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Horse"
|
||||
},
|
||||
{
|
||||
"Id": 1042,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hour"
|
||||
},
|
||||
{
|
||||
"Id": 1043,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "House"
|
||||
},
|
||||
{
|
||||
"Id": 1044,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Houseboat"
|
||||
},
|
||||
{
|
||||
"Id": 1045,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Hug"
|
||||
},
|
||||
{
|
||||
"Id": 1046,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ice"
|
||||
},
|
||||
{
|
||||
"Id": 1047,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ice Cube"
|
||||
},
|
||||
{
|
||||
"Id": 1048,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ice Fishing"
|
||||
},
|
||||
{
|
||||
"Id": 1049,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Igloo"
|
||||
},
|
||||
{
|
||||
"Id": 1050,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Invitation"
|
||||
},
|
||||
{
|
||||
"Id": 1051,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ironing Board"
|
||||
},
|
||||
{
|
||||
"Id": 1052,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jelly Bean"
|
||||
},
|
||||
{
|
||||
"Id": 1053,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jumbotron"
|
||||
},
|
||||
{
|
||||
"Id": 1054,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Jungle"
|
||||
},
|
||||
{
|
||||
"Id": 1055,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Junk Mail"
|
||||
},
|
||||
{
|
||||
"Id": 1056,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lady Bug"
|
||||
},
|
||||
{
|
||||
"Id": 1057,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lake"
|
||||
},
|
||||
{
|
||||
"Id": 1058,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lamp Post"
|
||||
},
|
||||
{
|
||||
"Id": 1059,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Leg"
|
||||
},
|
||||
{
|
||||
"Id": 1060,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lego"
|
||||
},
|
||||
{
|
||||
"Id": 1061,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Leprechaun"
|
||||
},
|
||||
{
|
||||
"Id": 1062,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Letter Opener"
|
||||
},
|
||||
{
|
||||
"Id": 1063,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lettuce"
|
||||
},
|
||||
{
|
||||
"Id": 1064,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Librarian"
|
||||
},
|
||||
{
|
||||
"Id": 1065,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Library"
|
||||
},
|
||||
{
|
||||
"Id": 1066,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Light Switch"
|
||||
},
|
||||
{
|
||||
"Id": 1067,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lily Pad"
|
||||
},
|
||||
{
|
||||
"Id": 1068,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lunch"
|
||||
},
|
||||
{
|
||||
"Id": 1069,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lunchbox"
|
||||
},
|
||||
{
|
||||
"Id": 1070,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Macaroni"
|
||||
},
|
||||
{
|
||||
"Id": 1071,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Machine"
|
||||
},
|
||||
{
|
||||
"Id": 1072,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Magician"
|
||||
},
|
||||
{
|
||||
"Id": 1073,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Magnet"
|
||||
},
|
||||
{
|
||||
"Id": 1074,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mail"
|
||||
},
|
||||
{
|
||||
"Id": 1075,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Makeup"
|
||||
},
|
||||
{
|
||||
"Id": 1076,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Meat"
|
||||
},
|
||||
{
|
||||
"Id": 1077,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mess"
|
||||
},
|
||||
{
|
||||
"Id": 1078,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Meteor"
|
||||
},
|
||||
{
|
||||
"Id": 1079,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Milk"
|
||||
},
|
||||
{
|
||||
"Id": 1080,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mine Cart"
|
||||
},
|
||||
{
|
||||
"Id": 1081,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Minnie Mouse"
|
||||
},
|
||||
{
|
||||
"Id": 1082,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Minute"
|
||||
},
|
||||
{
|
||||
"Id": 1083,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Moat"
|
||||
},
|
||||
{
|
||||
"Id": 1084,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Moby Dick"
|
||||
},
|
||||
{
|
||||
"Id": 1085,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Monocle"
|
||||
},
|
||||
{
|
||||
"Id": 1086,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mouse"
|
||||
},
|
||||
{
|
||||
"Id": 1087,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mud"
|
||||
},
|
||||
{
|
||||
"Id": 1088,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Music"
|
||||
},
|
||||
{
|
||||
"Id": 1089,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Neck"
|
||||
},
|
||||
{
|
||||
"Id": 1090,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Net"
|
||||
},
|
||||
{
|
||||
"Id": 1091,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Night"
|
||||
},
|
||||
{
|
||||
"Id": 1092,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "North Pole"
|
||||
},
|
||||
{
|
||||
"Id": 1093,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ocean"
|
||||
},
|
||||
{
|
||||
"Id": 1094,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Olive Oil"
|
||||
},
|
||||
{
|
||||
"Id": 1095,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Onion"
|
||||
},
|
||||
{
|
||||
"Id": 1096,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Open"
|
||||
},
|
||||
{
|
||||
"Id": 1097,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Page"
|
||||
},
|
||||
{
|
||||
"Id": 1098,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Paintball"
|
||||
},
|
||||
{
|
||||
"Id": 1099,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Paintball Gun"
|
||||
},
|
||||
{
|
||||
"Id": 1100,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pancakes"
|
||||
},
|
||||
{
|
||||
"Id": 1101,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Paper"
|
||||
},
|
||||
{
|
||||
"Id": 1102,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Paper Clip"
|
||||
},
|
||||
{
|
||||
"Id": 1103,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Paper Plane"
|
||||
},
|
||||
{
|
||||
"Id": 1104,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Peak"
|
||||
},
|
||||
{
|
||||
"Id": 1105,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Peanut"
|
||||
},
|
||||
{
|
||||
"Id": 1106,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pen"
|
||||
},
|
||||
{
|
||||
"Id": 1107,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pencil"
|
||||
},
|
||||
{
|
||||
"Id": 1108,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "People"
|
||||
},
|
||||
{
|
||||
"Id": 1109,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Photograph"
|
||||
},
|
||||
{
|
||||
"Id": 1110,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Photographer"
|
||||
},
|
||||
{
|
||||
"Id": 1111,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pigeon"
|
||||
},
|
||||
{
|
||||
"Id": 1112,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Piglet"
|
||||
},
|
||||
{
|
||||
"Id": 1113,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pikachu"
|
||||
},
|
||||
{
|
||||
"Id": 1114,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pine Tree"
|
||||
},
|
||||
{
|
||||
"Id": 1115,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pineapple"
|
||||
},
|
||||
{
|
||||
"Id": 1116,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Plankton"
|
||||
},
|
||||
{
|
||||
"Id": 1117,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Plate"
|
||||
},
|
||||
{
|
||||
"Id": 1118,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pluto"
|
||||
},
|
||||
{
|
||||
"Id": 1119,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Police"
|
||||
},
|
||||
{
|
||||
"Id": 1120,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pony"
|
||||
},
|
||||
{
|
||||
"Id": 1121,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Poodle"
|
||||
},
|
||||
{
|
||||
"Id": 1122,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Portal"
|
||||
},
|
||||
{
|
||||
"Id": 1123,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pound"
|
||||
},
|
||||
{
|
||||
"Id": 1124,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Power Line"
|
||||
},
|
||||
{
|
||||
"Id": 1125,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "President"
|
||||
},
|
||||
{
|
||||
"Id": 1126,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Pretzel"
|
||||
},
|
||||
{
|
||||
"Id": 1127,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Printer"
|
||||
},
|
||||
{
|
||||
"Id": 1128,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Purse"
|
||||
},
|
||||
{
|
||||
"Id": 1129,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Push Up"
|
||||
},
|
||||
{
|
||||
"Id": 1130,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Quicksand"
|
||||
},
|
||||
{
|
||||
"Id": 1131,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Race"
|
||||
},
|
||||
{
|
||||
"Id": 1132,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Railroad"
|
||||
},
|
||||
{
|
||||
"Id": 1133,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rapunzel"
|
||||
},
|
||||
{
|
||||
"Id": 1134,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rice"
|
||||
},
|
||||
{
|
||||
"Id": 1135,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Ring"
|
||||
},
|
||||
{
|
||||
"Id": 1136,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "River"
|
||||
},
|
||||
{
|
||||
"Id": 1137,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rocking Chair"
|
||||
},
|
||||
{
|
||||
"Id": 1138,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rollerblade"
|
||||
},
|
||||
{
|
||||
"Id": 1139,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rollercoaster"
|
||||
},
|
||||
{
|
||||
"Id": 1140,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Rolling Pin"
|
||||
},
|
||||
{
|
||||
"Id": 1141,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Root Beer"
|
||||
},
|
||||
{
|
||||
"Id": 1142,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sailboat"
|
||||
},
|
||||
{
|
||||
"Id": 1143,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sand"
|
||||
},
|
||||
{
|
||||
"Id": 1144,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sand Castle"
|
||||
},
|
||||
{
|
||||
"Id": 1145,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sandbox"
|
||||
},
|
||||
{
|
||||
"Id": 1146,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Saw"
|
||||
},
|
||||
{
|
||||
"Id": 1147,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scooby-Doo"
|
||||
},
|
||||
{
|
||||
"Id": 1148,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Seahorse"
|
||||
},
|
||||
{
|
||||
"Id": 1149,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Seat Belt"
|
||||
},
|
||||
{
|
||||
"Id": 1150,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shakespeare"
|
||||
},
|
||||
{
|
||||
"Id": 1151,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sheep"
|
||||
},
|
||||
{
|
||||
"Id": 1152,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shoelaces"
|
||||
},
|
||||
{
|
||||
"Id": 1153,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shoulder"
|
||||
},
|
||||
{
|
||||
"Id": 1154,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shower"
|
||||
},
|
||||
{
|
||||
"Id": 1155,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Silhouette"
|
||||
},
|
||||
{
|
||||
"Id": 1156,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sit-Up"
|
||||
},
|
||||
{
|
||||
"Id": 1157,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Skunk"
|
||||
},
|
||||
{
|
||||
"Id": 1158,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Smartphone"
|
||||
},
|
||||
{
|
||||
"Id": 1159,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sock"
|
||||
},
|
||||
{
|
||||
"Id": 1160,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Solar Eclipse"
|
||||
},
|
||||
{
|
||||
"Id": 1161,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Solar System"
|
||||
},
|
||||
{
|
||||
"Id": 1162,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sombrero"
|
||||
},
|
||||
{
|
||||
"Id": 1163,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sonic the Hedgehog"
|
||||
},
|
||||
{
|
||||
"Id": 1164,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "South"
|
||||
},
|
||||
{
|
||||
"Id": 1165,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Space"
|
||||
},
|
||||
{
|
||||
"Id": 1166,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spin"
|
||||
},
|
||||
{
|
||||
"Id": 1167,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spongebob Squarepants"
|
||||
},
|
||||
{
|
||||
"Id": 1168,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spring"
|
||||
},
|
||||
{
|
||||
"Id": 1169,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Spy"
|
||||
},
|
||||
{
|
||||
"Id": 1170,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Square"
|
||||
},
|
||||
{
|
||||
"Id": 1171,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stable"
|
||||
},
|
||||
{
|
||||
"Id": 1172,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stage"
|
||||
},
|
||||
{
|
||||
"Id": 1173,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Star"
|
||||
},
|
||||
{
|
||||
"Id": 1174,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Steak"
|
||||
},
|
||||
{
|
||||
"Id": 1175,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stick"
|
||||
},
|
||||
{
|
||||
"Id": 1176,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stingray"
|
||||
},
|
||||
{
|
||||
"Id": 1177,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Stomach"
|
||||
},
|
||||
{
|
||||
"Id": 1178,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Storm"
|
||||
},
|
||||
{
|
||||
"Id": 1179,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Strong"
|
||||
},
|
||||
{
|
||||
"Id": 1180,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Suitcase"
|
||||
},
|
||||
{
|
||||
"Id": 1181,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Summer"
|
||||
},
|
||||
{
|
||||
"Id": 1182,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sunflower"
|
||||
},
|
||||
{
|
||||
"Id": 1183,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sunshine"
|
||||
},
|
||||
{
|
||||
"Id": 1184,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Superhero"
|
||||
},
|
||||
{
|
||||
"Id": 1185,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Surfboard"
|
||||
},
|
||||
{
|
||||
"Id": 1186,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sword"
|
||||
},
|
||||
{
|
||||
"Id": 1187,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tailor"
|
||||
},
|
||||
{
|
||||
"Id": 1188,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tall"
|
||||
},
|
||||
{
|
||||
"Id": 1189,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Teacher"
|
||||
},
|
||||
{
|
||||
"Id": 1190,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Teepee"
|
||||
},
|
||||
{
|
||||
"Id": 1191,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Text"
|
||||
},
|
||||
{
|
||||
"Id": 1192,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "The Beatles"
|
||||
},
|
||||
{
|
||||
"Id": 1193,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "The Grinch"
|
||||
},
|
||||
{
|
||||
"Id": 1194,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tigger"
|
||||
},
|
||||
{
|
||||
"Id": 1195,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Time Machine"
|
||||
},
|
||||
{
|
||||
"Id": 1196,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Toes"
|
||||
},
|
||||
{
|
||||
"Id": 1197,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tongue"
|
||||
},
|
||||
{
|
||||
"Id": 1198,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Towel"
|
||||
},
|
||||
{
|
||||
"Id": 1199,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tower"
|
||||
},
|
||||
{
|
||||
"Id": 1200,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Traffic Light"
|
||||
},
|
||||
{
|
||||
"Id": 1201,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Treasure"
|
||||
},
|
||||
{
|
||||
"Id": 1202,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tree Branch"
|
||||
},
|
||||
{
|
||||
"Id": 1203,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tree Stump"
|
||||
},
|
||||
{
|
||||
"Id": 1204,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Trophy"
|
||||
},
|
||||
{
|
||||
"Id": 1205,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tugboat"
|
||||
},
|
||||
{
|
||||
"Id": 1206,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Turkey"
|
||||
},
|
||||
{
|
||||
"Id": 1207,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tweety Bird"
|
||||
},
|
||||
{
|
||||
"Id": 1208,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Vase"
|
||||
},
|
||||
{
|
||||
"Id": 1209,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Vegetable"
|
||||
},
|
||||
{
|
||||
"Id": 1210,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Vest"
|
||||
},
|
||||
{
|
||||
"Id": 1211,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Video"
|
||||
},
|
||||
{
|
||||
"Id": 1212,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Virtual Reality"
|
||||
},
|
||||
{
|
||||
"Id": 1213,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Waiter"
|
||||
},
|
||||
{
|
||||
"Id": 1214,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Waldo"
|
||||
},
|
||||
{
|
||||
"Id": 1215,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Watch"
|
||||
},
|
||||
{
|
||||
"Id": 1216,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Water Bottle"
|
||||
},
|
||||
{
|
||||
"Id": 1217,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wave"
|
||||
},
|
||||
{
|
||||
"Id": 1218,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wax"
|
||||
},
|
||||
{
|
||||
"Id": 1219,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wedding Cake"
|
||||
},
|
||||
{
|
||||
"Id": 1220,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wheelbarrow"
|
||||
},
|
||||
{
|
||||
"Id": 1221,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wifi"
|
||||
},
|
||||
{
|
||||
"Id": 1222,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wind"
|
||||
},
|
||||
{
|
||||
"Id": 1223,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Window"
|
||||
},
|
||||
{
|
||||
"Id": 1224,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wing"
|
||||
},
|
||||
{
|
||||
"Id": 1225,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Winnie the Pooh"
|
||||
},
|
||||
{
|
||||
"Id": 1226,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Winter"
|
||||
},
|
||||
{
|
||||
"Id": 1227,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wonder Woman"
|
||||
},
|
||||
{
|
||||
"Id": 1228,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Woody Woodpecker"
|
||||
},
|
||||
{
|
||||
"Id": 1229,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wreath"
|
||||
},
|
||||
{
|
||||
"Id": 1230,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Writing"
|
||||
},
|
||||
{
|
||||
"Id": 1231,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "X-Ray"
|
||||
},
|
||||
{
|
||||
"Id": 1232,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Yard"
|
||||
},
|
||||
{
|
||||
"Id": 1233,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Bake"
|
||||
},
|
||||
{
|
||||
"Id": 1234,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Black Hole"
|
||||
},
|
||||
{
|
||||
"Id": 1235,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Brainstorm"
|
||||
},
|
||||
{
|
||||
"Id": 1236,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Buy"
|
||||
},
|
||||
{
|
||||
"Id": 1237,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Donald Duck"
|
||||
},
|
||||
{
|
||||
"Id": 1238,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "E-mail"
|
||||
},
|
||||
{
|
||||
"Id": 1239,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Electrician"
|
||||
},
|
||||
{
|
||||
"Id": 1240,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Explore"
|
||||
},
|
||||
{
|
||||
"Id": 1241,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Internet"
|
||||
},
|
||||
{
|
||||
"Id": 1242,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Lab"
|
||||
},
|
||||
{
|
||||
"Id": 1243,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Level"
|
||||
},
|
||||
{
|
||||
"Id": 1244,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Marriage"
|
||||
},
|
||||
{
|
||||
"Id": 1245,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mine"
|
||||
},
|
||||
{
|
||||
"Id": 1246,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Movie"
|
||||
},
|
||||
{
|
||||
"Id": 1247,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Programmer"
|
||||
},
|
||||
{
|
||||
"Id": 1248,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Scent"
|
||||
},
|
||||
{
|
||||
"Id": 1249,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Sell"
|
||||
},
|
||||
{
|
||||
"Id": 1250,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shaggy"
|
||||
},
|
||||
{
|
||||
"Id": 1251,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shelter"
|
||||
},
|
||||
{
|
||||
"Id": 1252,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Shrink"
|
||||
},
|
||||
{
|
||||
"Id": 1253,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Teleport"
|
||||
},
|
||||
{
|
||||
"Id": 1254,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "USB"
|
||||
},
|
||||
{
|
||||
"Id": 1255,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Virus"
|
||||
},
|
||||
{
|
||||
"Id": 1256,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Wedding"
|
||||
},
|
||||
{
|
||||
"Id": 1257,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Go Kart"
|
||||
},
|
||||
{
|
||||
"Id": 1404,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Theater"
|
||||
},
|
||||
{
|
||||
"Id": 1416,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Lucky Charms"
|
||||
},
|
||||
{
|
||||
"Id": 1419,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Acid Rain"
|
||||
},
|
||||
{
|
||||
"Id": 1462,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Dead Batteries"
|
||||
},
|
||||
{
|
||||
"Id": 1487,
|
||||
"Difficulty": 1,
|
||||
"EN_US": "Glass"
|
||||
},
|
||||
{
|
||||
"Id": 1629,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Tyrannosaurus Rex"
|
||||
},
|
||||
{
|
||||
"Id": 1679,
|
||||
"Difficulty": 0,
|
||||
"EN_US": "Mothman"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"FeaturedPlayer": {
|
||||
"Id": 1,
|
||||
"TitleOverride": "",
|
||||
"UrlOverride": null
|
||||
},
|
||||
"FeaturedRoomGroup": {
|
||||
"FeaturedRoomGroupId": 2,
|
||||
"Name": "Featured Rooms",
|
||||
"Rooms": [
|
||||
]
|
||||
},
|
||||
"CurrentAnnouncement": {
|
||||
"Message": "Server powered by RecFlare",
|
||||
"MoreInfoUrl": "https://recflare.net"
|
||||
},
|
||||
"InstagramImages": [
|
||||
{
|
||||
"ImageName": "test.jpg",
|
||||
"ImageUrl": "https://github.com/djdevin/recflare"
|
||||
}
|
||||
],
|
||||
"Videos": [
|
||||
{
|
||||
"BlobName": "something.mp4",
|
||||
"Title": "test",
|
||||
"Description": "test description",
|
||||
"ThumbnailBlobName": "something.jpg",
|
||||
"SourceUrl": "https://www.youtube.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,44 @@ export default defineConfig({
|
||||
bindings: {
|
||||
ENVIRONMENT: 'VITEST',
|
||||
},
|
||||
// The worker's RECFLARE_NOTIFICATIONS_HUB binding points at the `notify`
|
||||
// worker's DO (script_name: "notify"). That worker isn't part of this
|
||||
// isolated test, so provide a minimal stub service exposing the same
|
||||
// NotificationsHub RPC surface — enough for the runtime to start and for
|
||||
// notification sends to no-op.
|
||||
workers: [
|
||||
{
|
||||
name: 'notify',
|
||||
modules: true,
|
||||
compatibilityDate: '2026-06-16',
|
||||
compatibilityFlags: ['nodejs_compat'],
|
||||
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
|
||||
// notifyPlayer records every call so tests can assert the notifications the
|
||||
// worker pushed (type + payload). GET the DO for the most recent one,
|
||||
// GET /all for the whole list (friend-graph changes notify both players),
|
||||
// DELETE to reset it between assertions.
|
||||
script: `
|
||||
import { DurableObject } from 'cloudflare:workers'
|
||||
export class NotificationsHub extends DurableObject {
|
||||
sent = []
|
||||
async notifyPlayer(playerId, notificationType, data) {
|
||||
this.sent.push({ playerId, notificationType, data })
|
||||
return { delivered: 0, queued: true }
|
||||
}
|
||||
async broadcast() { return { delivered: 0 } }
|
||||
async fetch(request) {
|
||||
if (request.method === 'DELETE') {
|
||||
this.sent = []
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (new URL(request.url).pathname === '/all') return Response.json(this.sent)
|
||||
return Response.json(this.sent.at(-1) ?? null)
|
||||
}
|
||||
}
|
||||
export default { fetch() { return new Response('ok') } }
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
+18
-1
@@ -19,13 +19,30 @@
|
||||
}
|
||||
],
|
||||
// Image bucket shared with the `img` worker (which serves objects back by key).
|
||||
// Saved-image uploads are written here.
|
||||
// Saved-image uploads are written here. The `recflare-cdn` bucket (owned by the
|
||||
// `cdn` worker, written by `storage`) is bound read-only alongside it, to hash an
|
||||
// invention's uploaded data blob for its `BlobHash`.
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "IMAGES",
|
||||
"bucket_name": "recflare-img"
|
||||
},
|
||||
{
|
||||
"binding": "CDN_ASSETS",
|
||||
"bucket_name": "recflare-cdn"
|
||||
}
|
||||
],
|
||||
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
|
||||
// the `notify` worker). We only invoke its RPC methods; no migration here.
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "RECFLARE_NOTIFICATIONS_HUB",
|
||||
"class_name": "NotificationsHub",
|
||||
"script_name": "notify"
|
||||
}
|
||||
]
|
||||
},
|
||||
"logpush": false,
|
||||
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
|
||||
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
|
||||
|
||||
+148
-25
@@ -1,26 +1,150 @@
|
||||
# auth
|
||||
|
||||
Auth Worker served on the `auth` subdomain. A Hono app handling authentication.
|
||||
Binding-dependent behavior (database queries) is stubbed for now — no real
|
||||
KV/D1/DO bindings yet.
|
||||
Auth Worker served on the `auth` subdomain (`auth.recflare.net`) — a Hono app that
|
||||
authenticates players and issues the JWTs every other worker verifies.
|
||||
|
||||
## Routes
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------------------------ | ---------------------------------- |
|
||||
| GET | `/eac/challenge` | EAC challenge, served as text |
|
||||
| GET | `/cachedlogin/forplatformid/:platform/:id` | Cached logins (stubbed → `[]`) |
|
||||
| POST | `/connect/token` | OAuth token endpoint, issues a JWT |
|
||||
| GET | `/role/developer/:id` | Developer role lookup (TODO) |
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------------------------ | ------------------------------------------------------ |
|
||||
| GET | `/eac/challenge` | EAC handshake; a constant, JSON-quoted, as text |
|
||||
| GET | `/cachedlogin/forplatformid/:platform/:id` | Accounts linked to a platform id, for the login screen |
|
||||
| POST | `/cachedlogin/forplatformids` | Bulk cached-login lookup (friends resolution) |
|
||||
| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token |
|
||||
| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) |
|
||||
| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean |
|
||||
| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean |
|
||||
| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) |
|
||||
|
||||
## API documentation
|
||||
|
||||
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
|
||||
alongside each handler, with the schemas in `src/openapi.ts`.
|
||||
|
||||
**The spec is descriptive, not enforced.** Nothing validates requests against it. That
|
||||
is deliberate: this worker serves a protocol reverse-engineered from the Rec Room
|
||||
client, and the handlers are intentionally lenient — every field is read as
|
||||
`typeof body.x === 'string' ? body.x : ''`, and missing or malformed input generally
|
||||
falls through to a graceful path rather than a 400. Which parts of that tolerance the
|
||||
client actually depends on isn't fully known, so enforcing a schema would risk
|
||||
rejecting requests that work today. Read a "required" field as _the client always
|
||||
sends it_, not _the server rejects it if absent_.
|
||||
|
||||
A test asserts that every route the worker serves appears in the spec, so adding a
|
||||
route without documenting it fails rather than silently shipping an incomplete spec.
|
||||
|
||||
## Grants
|
||||
|
||||
`POST /connect/token` selects behavior from `grant_type`:
|
||||
|
||||
- **`create_account`** — mints an account with an auto-assigned random username and
|
||||
places the player in the Orientation room (RoomId 13), which the client enters
|
||||
without matchmaking. A posted `password` becomes the login credential.
|
||||
- **`cached_login`** — logs into an already-linked account using platform ownership as
|
||||
the credential; no password. The posted `account_id` must be linked to exactly the
|
||||
identity `platform_auth` proves.
|
||||
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
|
||||
30-day TTL; platform and platform id come from what was stored at issue time.
|
||||
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
|
||||
the account by `username` or numeric `account_id` and requires the matching password
|
||||
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
|
||||
all, which is what closes id/username-only takeover. When it also carries a verifying
|
||||
`platform_auth`, that identity is **linked** to the account (see below).
|
||||
|
||||
Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `role`
|
||||
claim, so developer/moderator powers refresh on every login and every refresh grant.
|
||||
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
|
||||
|
||||
### Verifiable platforms: Steam and Meta
|
||||
|
||||
Only an identity we can _prove_ is ever bound to an account, so any grant that
|
||||
authenticates _by platform identity_ (`cached_login`, and `create_account` when it
|
||||
asserts a platform) must be a platform we can verify. Two are:
|
||||
|
||||
- **Steam (`0`)** — `src/steam-ticket.ts` parses the `platform_auth` ticket and checks
|
||||
Steam's signature against Steam's system public key. Verified **offline**: no
|
||||
publisher Web API key, no network call. The SteamID64 the ticket carries replaces the
|
||||
client-supplied `platform_id`.
|
||||
- **Meta / Oculus (`1`)** — `src/meta-nonce.ts` posts the nonce in `platform_auth` to
|
||||
`graph.oculus.com/user_nonce_validate`, authenticated as the app with
|
||||
`META_APP_SECRET`. Meta's nonce proves nothing by itself; validation is what binds it
|
||||
to a user id, so here the posted `platform_id` is an _input_ to the check and a
|
||||
spoofed one fails. This means an outbound request on every Meta login, and no Meta
|
||||
login at all without the app secret — an unset `META_APP_SECRET` answers 500 rather
|
||||
than falling back to trusting the client.
|
||||
|
||||
Everything else is refused. Whichever platform, the identity that gets bound or linked
|
||||
is the verified one, never the raw `platform_id` field.
|
||||
|
||||
### One account, many platform identities
|
||||
|
||||
An account can be reached from several platform identities — a player's PC and their
|
||||
headset both open the same account, with no password after the first time. The links
|
||||
live in the `platform_account` table (`src/platform-db.ts`, migration 0007), one row per
|
||||
(platform, platform id, account).
|
||||
|
||||
That table is the **one source of truth** for both halves of a cached login: the picker
|
||||
(`/cachedlogin/forplatformid`) lists the accounts an identity links to, and the
|
||||
`cached_login` grant asks it whether the account it was handed is linked to the identity
|
||||
just proven. They used to be two separate checks over the account blob's single
|
||||
`platformId`, which could disagree — the client would be offered an account that then
|
||||
answered "no linked account" forever.
|
||||
|
||||
A second device is linked by **logging in with a password there**: the client posts its
|
||||
`platform_auth` alongside the password, and a proof that verifies becomes a link. Only a
|
||||
verified identity is ever linked, since a link is a password-free way into the account.
|
||||
A proof that doesn't verify never fails the login — it just leaves that device without a
|
||||
cached login.
|
||||
|
||||
The account blob keeps `platform`/`platformId` as the account's **primary** identity
|
||||
(the first one linked). It feeds the account DTO and a refreshed token's claims, and
|
||||
nothing authorizes off it. It is no longer indexed: migration 0008 drops the
|
||||
`account.platform_id` generated column that 0004 added, since leaving a queryable copy
|
||||
of one identity per account invites exactly the picker/grant disagreement above. Look
|
||||
identities up in `platform_account`.
|
||||
|
||||
## Signup caps
|
||||
|
||||
`create_account` is capped on two independent arms, per verified platform identity and
|
||||
per signup IP. The platform arm can't be spoofed or reset by changing networks; the IP
|
||||
arm is coarse and will produce false positives behind NAT, shared campus and mobile
|
||||
networks. Both default to 3.
|
||||
|
||||
The platform arm also caps **linking**, or it wouldn't be a cap: an identity at the
|
||||
limit could otherwise have accounts created for it with a password and link its way into
|
||||
all of them. Hitting it never fails a password login — the account just doesn't get a
|
||||
cached login on that device.
|
||||
|
||||
Override per environment via the root `.env` (`RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID`,
|
||||
`RECFLARE_MAX_ACCOUNTS_PER_IP`), injected at deploy time so tuning them never means
|
||||
editing a versioned file. Setting an arm to `0` disables it — worth reaching for on a
|
||||
small private server, or when a shared network is being locked out.
|
||||
|
||||
## Bindings
|
||||
|
||||
| Binding | Type | Notes |
|
||||
| -------------------- | ------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `DB` | D1 | Shared `recflare` database; this worker owns `account`, `refresh_tokens` and `platform_account` |
|
||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
|
||||
| `META_APP_SECRET` | Secrets Store | Meta app secret; only used to validate a login nonce |
|
||||
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
|
||||
|
||||
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
|
||||
table, so they stay independent of the `rooms` worker's migrations on the same
|
||||
database. Run them with `pnpm -F auth migrate`.
|
||||
|
||||
## Signing key
|
||||
|
||||
Tokens are signed HS256 with the `JWT_SECRET` binding (see `src/jwt.ts`), resolved
|
||||
at request time via `await c.env.JWT_SECRET.get()`. The key lives in a single shared
|
||||
**Cloudflare Secrets Store** that every worker binds (so `auth`-signed tokens verify
|
||||
in `rooms`, `api`, `match`, etc.). The store id is kept out of source in the root
|
||||
`.env` as `RECFLARE_SECRETS_STORE` and spliced into `wrangler.jsonc`'s `"local"`
|
||||
`store_id` placeholder at deploy time (see `packages/tools/bin/run-wrangler-deploy`).
|
||||
Tokens are signed HS256 with the `JWT_SECRET` binding (see `@repo/jwt`), resolved at
|
||||
request time via `await c.env.JWT_SECRET.get()`. The key lives in a single shared
|
||||
**Cloudflare Secrets Store** that every worker binds, so `auth`-signed tokens verify in
|
||||
`rooms`, `api`, `match`, etc. The store id is kept out of source in the root `.env` as
|
||||
`RECFLARE_SECRETS_STORE` and spliced into `wrangler.jsonc`'s `"local"` `store_id`
|
||||
placeholder at deploy time (see `packages/tools/bin/run-wrangler-deploy`).
|
||||
|
||||
If the secret resolves empty, the worker refuses to issue a token at all rather than
|
||||
sign one with an empty key — every worker validates against that same key, so an
|
||||
empty-key token would be forgeable by anyone.
|
||||
|
||||
One-time setup (needs Cloudflare auth):
|
||||
|
||||
@@ -30,20 +154,19 @@ wrangler secrets-store store create recflare --scopes workers
|
||||
|
||||
# Set the shared signing key (prompted for the value)
|
||||
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
|
||||
|
||||
# Set the Meta app secret. Required for the deploy to succeed even with no Meta app —
|
||||
# a binding to a missing secret is a deploy error. Any placeholder will do; Meta
|
||||
# sign-ins then answer 500 until it holds the real value.
|
||||
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
|
||||
```
|
||||
|
||||
For local `wrangler dev`, seed a local value (omit `--remote`) so `.get()` resolves:
|
||||
For local `wrangler dev`, seed local values (omit `--remote`) so `.get()` resolves:
|
||||
|
||||
```sh
|
||||
wrangler secrets-store secret create local --name JWT_SECRET --value <dev-key> --scopes workers
|
||||
wrangler secrets-store secret create local --name META_APP_SECRET --value <app-secret> --scopes workers
|
||||
```
|
||||
|
||||
Rotating the store value invalidates all existing tokens (clients re-authenticate).
|
||||
|
||||
## Notes / TODO
|
||||
|
||||
- `/eac/challenge` content is inlined in `src/auth.app.ts` (Workers have no
|
||||
filesystem) — replace `EAC_CHALLENGE` with the real challenge text.
|
||||
- `/cachedlogin/...` and the `RoomInstance` cleanup in `/connect/token` need a DB
|
||||
binding to be implemented.
|
||||
- `/role/developer/:id` is a stub (`// TODO: implement`).
|
||||
Rotating the signing key invalidates all existing tokens (clients re-authenticate).
|
||||
The Meta secret is read per request, so updating it takes effect without a redeploy.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Drop the platform identity from refresh_tokens. A refreshed access token now takes
|
||||
-- `platform`/`platform_id` from the account, which is where the bound identity lives —
|
||||
-- the copy stored at issue time was redundant, and went stale if the account's
|
||||
-- identity changed mid-session. Kept in sync with REFRESH_SCHEMA_DDL in
|
||||
-- src/refresh-db.ts.
|
||||
|
||||
ALTER TABLE refresh_tokens DROP COLUMN platform;
|
||||
ALTER TABLE refresh_tokens DROP COLUMN platform_id;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Let one account be linked to MORE THAN ONE platform identity, so a player with a
|
||||
-- PC and a headset gets a cached login on both. The account blob's single
|
||||
-- `platformId`/`platform` pair could only hold one, so logging in on the second
|
||||
-- device meant a password every time.
|
||||
--
|
||||
-- Links move into their own table, which becomes the one source of truth for both
|
||||
-- halves of a cached login (the picker and the `cached_login` grant). The blob fields
|
||||
-- stay as the account's *primary* identity — the first one linked — for the account
|
||||
-- DTO and the refresh grant's claims; nothing authorizes off them any more. Kept in
|
||||
-- sync with PLATFORM_SCHEMA_DDL in src/platform-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_account (
|
||||
account_id INTEGER NOT NULL,
|
||||
platform INTEGER NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
linked_at TEXT NOT NULL,
|
||||
PRIMARY KEY (platform, platform_id, account_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id);
|
||||
|
||||
-- Backfill every identity already bound to an account. `platform` is COALESCEd to 0
|
||||
-- because nothing ever defaulted that field: an account can carry a platformId with no
|
||||
-- platform recorded, and back when Steam was the only verifiable platform an unset one
|
||||
-- *was* Steam. Without the COALESCE those accounts would lose their cached login at
|
||||
-- deploy. Mirrored as PLATFORM_BACKFILL_SQL in src/platform-db.ts, which is what the
|
||||
-- tests run.
|
||||
INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||
SELECT
|
||||
account_id,
|
||||
COALESCE(json_extract(data, '$.platform'), 0),
|
||||
platform_id,
|
||||
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
|
||||
FROM account
|
||||
WHERE platform_id IS NOT NULL AND platform_id <> '';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Drop the `platform_id` generated column added by 0004. Nothing reads it any more:
|
||||
-- 0007 moved every account ↔ identity link into `platform_account`, which is now the
|
||||
-- one source of truth for the login picker and the `cached_login` grant. The column's
|
||||
-- last reader was 0007's own backfill, which has already run.
|
||||
--
|
||||
-- Leaving it would leave a SECOND, stale answer to "which account does this identity
|
||||
-- open?" — it only ever holds the account's primary identity, so an account reachable
|
||||
-- from a PC and a headset appears here under one of them. That is exactly the split
|
||||
-- that used to have the picker offer an account the grant then refused.
|
||||
--
|
||||
-- The underlying `platformId` in the JSON blob STAYS: it is the account's primary
|
||||
-- identity, and feeds the account DTO and a refreshed token's claims. This drops the
|
||||
-- generated column and its index only — a virtual column stores nothing, so no account
|
||||
-- data is rewritten or lost. The index has to go first; SQLite refuses to drop an
|
||||
-- indexed column. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts.
|
||||
--
|
||||
-- Safe to run before or after the deploy that ships it: no worker queries this column,
|
||||
-- so the currently-deployed code doesn't notice it go. (`PLATFORM_BACKFILL_SQL` in
|
||||
-- src/platform-db.ts still names it in 0007's text — that statement has run and won't
|
||||
-- run again; the exported copy selects the blob instead so tests keep working.)
|
||||
|
||||
DROP INDEX IF EXISTS idx_accounts_platform_id;
|
||||
ALTER TABLE account DROP COLUMN platform_id;
|
||||
@@ -19,8 +19,13 @@
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "0.16.20",
|
||||
|
||||
+835
-312
@@ -1,48 +1,86 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
countAccountsByPlatformId,
|
||||
countAccountsBySignupIp,
|
||||
createAccount,
|
||||
GAME_VERSION,
|
||||
getAccount,
|
||||
getAccountByUsername,
|
||||
getAccountsByPlatformId,
|
||||
getAccountsByIds,
|
||||
getPasswordHash,
|
||||
getRoomById,
|
||||
hashPassword,
|
||||
RoomInstanceType,
|
||||
setLastLoginTime,
|
||||
setLoginContext,
|
||||
setPasswordHash,
|
||||
setPresence,
|
||||
subRoomDataBlob,
|
||||
updateAccount,
|
||||
verifyPassword,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { intVar, logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import { hashPassword, verifyPassword } from './password'
|
||||
import { verifyMetaNonce } from './meta-nonce'
|
||||
import {
|
||||
CachedLogin,
|
||||
ChangePasswordRequest,
|
||||
ChangePasswordResponse,
|
||||
FakeCachedLogin,
|
||||
form,
|
||||
json,
|
||||
OAuthError,
|
||||
PlatformIdsRequest,
|
||||
PlatformType,
|
||||
roleLookup,
|
||||
TokenRequest,
|
||||
TokenResponse,
|
||||
} from './openapi'
|
||||
import {
|
||||
countAccountsForPlatformIdentity,
|
||||
getLinksForPlatformId,
|
||||
getLinksForPlatformIdentity,
|
||||
isPlatformIdentityLinked,
|
||||
linkPlatformIdentity,
|
||||
} from './platform-db'
|
||||
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
||||
import { verifySteamTicket } from './steam-ticket'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { Account } from '@repo/domain'
|
||||
import type { App } from './context'
|
||||
import type { PlatformLink } from './platform-db'
|
||||
|
||||
/** OAuth scopes granted by `/connect/token`. */
|
||||
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'
|
||||
|
||||
/** Platform-type enum names by value, used for the token's `platform` claim. */
|
||||
const PLATFORM_TYPES: Record<number, string> = {
|
||||
[-1]: 'All',
|
||||
0: 'Steam',
|
||||
1: 'Oculus',
|
||||
2: 'PlayStation',
|
||||
3: 'Xbox',
|
||||
4: 'RecNet',
|
||||
5: 'IOS',
|
||||
6: 'GooglePlay',
|
||||
7: 'Standalone',
|
||||
8: 'Pico',
|
||||
}
|
||||
/**
|
||||
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
|
||||
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
|
||||
* headset reports this same value. Two things follow, and both are enforced below:
|
||||
* - it is never verifiable (`verifyPlatformProof` refuses it outright), and
|
||||
* - it is therefore never LINKED to an account. A link is a password-free way in, so
|
||||
* one link on a shared id would open that account to every sideloaded build.
|
||||
* It exists only to get such a client onto the username/password login screen.
|
||||
*/
|
||||
const SIDELOAD_PLATFORM_ID = '1'
|
||||
|
||||
/**
|
||||
* The canned entry served for the one Oculus cached-login lookup below — the sideloaded
|
||||
* APK's way onto the password login screen. Not backed by a link, an account or a
|
||||
* platform proof, hence `requirePassword: true`.
|
||||
*/
|
||||
const FAKE_OCULUS_CACHED_LOGIN = {
|
||||
platform: PlatformType.Oculus,
|
||||
platformId: SIDELOAD_PLATFORM_ID,
|
||||
accountId: 1,
|
||||
lastLoginTime: '2026-07-19T17:13:29.225Z',
|
||||
requirePassword: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Signup caps, enforced on create_account only (never on login — an existing account
|
||||
@@ -89,12 +127,11 @@ async function placeNewPlayerInOrientation(
|
||||
accountId: number,
|
||||
deviceClass: number
|
||||
): Promise<void> {
|
||||
const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
|
||||
.bind(ORIENTATION_ROOM_ID)
|
||||
.first<{ data: string }>()
|
||||
if (!row) return
|
||||
// getRoomById hydrates the room's SubRooms from the subroom table (they no longer
|
||||
// live in the room blob), so the Orientation scene resolves the same way match does.
|
||||
const room = await getRoomById(env.DB, ORIENTATION_ROOM_ID)
|
||||
if (!room) return
|
||||
|
||||
const room = JSON.parse(row.data) as Record<string, unknown>
|
||||
const subRooms = room.SubRooms
|
||||
const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as
|
||||
Record<string, unknown> | undefined
|
||||
@@ -107,7 +144,7 @@ async function placeNewPlayerInOrientation(
|
||||
subRoomId: num(sub?.SubRoomId, 1),
|
||||
roomInstanceType: RoomInstanceType.Public,
|
||||
location: str(sub?.UnitySceneId),
|
||||
dataBlob: str(sub?.DataBlob),
|
||||
dataBlob: subRoomDataBlob(sub),
|
||||
eventId: 0,
|
||||
clubId: 0,
|
||||
roomCode: '',
|
||||
@@ -128,7 +165,7 @@ async function placeNewPlayerInOrientation(
|
||||
deviceClass,
|
||||
vrMovementMode: 1,
|
||||
platform: 0,
|
||||
appVersion: '20230302',
|
||||
appVersion: GAME_VERSION,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,51 +174,185 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/**
|
||||
* The elevated role names for an account's token `role` claim, derived from its
|
||||
* role flags. Base roles (gameClient) are added by generateToken — these are only
|
||||
* the operator-granted extras. Order is stable so tokens are deterministic.
|
||||
*/
|
||||
function accountRoles(account: Pick<Account, 'isDeveloper' | 'isModerator'> | null): string[] {
|
||||
if (!account) return []
|
||||
const roles: string[] = []
|
||||
if (account.isDeveloper) roles.push('developer')
|
||||
if (account.isModerator) roles.push('moderator')
|
||||
return roles
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform an account's `platformId` belongs to. Nothing defaults the `platform`
|
||||
* field (see defaultAccount), so an account can carry a platform identity with no
|
||||
* platform recorded — and Steam is the only platform whose identity we can prove, so
|
||||
* an unset one *is* Steam.
|
||||
* platform recorded — and until Meta verification landed Steam was the only identity
|
||||
* we could prove, so an unset one *is* Steam. Every account bound since records its
|
||||
* platform explicitly; this default only covers those older rows.
|
||||
*/
|
||||
function accountPlatform(account: Pick<Account, 'platform'>): number {
|
||||
return account.platform ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account is the one linked to a given platform identity — the single
|
||||
* check behind both the cached-login picker and the `cached_login` grant. It lives in
|
||||
* one place on purpose: if the picker offers an account the grant then rejects, the
|
||||
* client is handed an `account_id` it can never log into ("no linked account for this
|
||||
* platform identity" on every attempt).
|
||||
*
|
||||
* `platformId` must be the *proven* identity (the SteamID64 from a verified
|
||||
* platform_auth ticket), never the client-supplied `platform_id` field.
|
||||
*/
|
||||
export function isLinkedToPlatformIdentity(
|
||||
account: Pick<Account, 'platform' | 'platformId'>,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): boolean {
|
||||
if (!account.platformId || platformId === '') return false
|
||||
return account.platformId === platformId && accountPlatform(account) === platform
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a linked account into the client's CachedLogin DTO — the account-picker
|
||||
* entry on the login screen. The client posts the chosen `accountId` back as a
|
||||
* `grant_type=cached_login`. `requirePassword` is false because platform ownership
|
||||
* (the platform_auth ticket) is the credential for a cached login — no prompt.
|
||||
* (the verified `platform_auth`) is the credential for a cached login — no prompt.
|
||||
*
|
||||
* The platform and id come from the LINK, not from the account: an account linked to
|
||||
* both a Steam and a Meta identity appears in both pickers, and each has to report the
|
||||
* identity that picker was asked about — that's what the client posts back, and what
|
||||
* the grant then checks the link against.
|
||||
*/
|
||||
function toCachedLogin(account: Account) {
|
||||
function toCachedLogin(account: Account, link: PlatformLink) {
|
||||
return {
|
||||
platform: accountPlatform(account),
|
||||
platformId: account.platformId ?? '',
|
||||
platform: link.platform,
|
||||
platformId: link.platformId,
|
||||
accountId: account.accountId,
|
||||
lastLoginTime: account.lastLoginTime ?? account.createdAt,
|
||||
requirePassword: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a set of links into picker entries, dropping any whose account no longer
|
||||
* exists. One batched account read rather than one per link.
|
||||
*
|
||||
* Order follows the links (oldest first), so the picker is stable between launches.
|
||||
*/
|
||||
async function toCachedLogins(db: D1Database, links: PlatformLink[]) {
|
||||
if (links.length === 0) return []
|
||||
const accounts = await getAccountsByIds(db, [...new Set(links.map((l) => l.accountId))])
|
||||
const byId = new Map(accounts.map((a) => [a.accountId, a]))
|
||||
return links.flatMap((link) => {
|
||||
const account = byId.get(link.accountId)
|
||||
return account ? [toCachedLogin(account, link)] : []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Link the platform identity a password login proved to the account it logged into,
|
||||
* so the next launch on that device is a cached login. Called only with a VERIFIED
|
||||
* identity — a link is a password-free way into the account.
|
||||
*
|
||||
* Already linked is the common case (every subsequent login on that device) and costs
|
||||
* one read and nothing else.
|
||||
*
|
||||
* The per-identity cap applies here as well as at signup, or it wouldn't be a cap:
|
||||
* an identity could otherwise sit at the limit, have accounts created for it with a
|
||||
* password, and link its way into all of them. Reaching it does NOT fail the login —
|
||||
* the password was valid — it just leaves the account without a cached login, so the
|
||||
* player types their password each time rather than being locked out.
|
||||
*
|
||||
* The first identity linked also becomes the account's primary (the blob's
|
||||
* `platform`/`platformId`), which is what the account DTO and the refresh grant's
|
||||
* claims report. Later platforms link without disturbing it.
|
||||
*/
|
||||
async function linkLoginIdentity(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string,
|
||||
maxAccountsPerIdentity: number
|
||||
): Promise<void> {
|
||||
if (await isPlatformIdentityLinked(db, accountId, platform, platformId)) return
|
||||
|
||||
if (
|
||||
maxAccountsPerIdentity > 0 &&
|
||||
(await countAccountsForPlatformIdentity(db, platform, platformId)) >= maxAccountsPerIdentity
|
||||
) {
|
||||
logger.info('platform link refused: account limit reached for this platform identity', {
|
||||
accountId,
|
||||
platform,
|
||||
platformId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await linkPlatformIdentity(db, accountId, platform, platformId))) return
|
||||
logger.info('linked platform identity to account', { accountId, platform, platformId })
|
||||
|
||||
const account = await getAccount(db, accountId)
|
||||
if (account && !account.platformId) {
|
||||
await updateAccount(db, accountId, { platform, platformId })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a login's `platform_auth` proved, if anything. Failures are split because the
|
||||
* callers act on them differently: a grant that authenticates BY platform identity has
|
||||
* to refuse, while a password grant — which has already proven who it is — carries on
|
||||
* and just doesn't link.
|
||||
*
|
||||
* `unconfigured` is an operator problem (no META_APP_SECRET), not a bad credential,
|
||||
* and is the one case that warrants a 5xx.
|
||||
*/
|
||||
type PlatformProof =
|
||||
/** Nothing was checked — the login offered no proof, so there is nothing to report. */
|
||||
| { status: 'none' }
|
||||
| { status: 'verified'; platform: number; platformId: string }
|
||||
| { status: 'unsupported' }
|
||||
| { status: 'unconfigured' }
|
||||
| { status: 'rejected'; reason: string }
|
||||
|
||||
/**
|
||||
* Verify a login's `platform_auth` and return the identity it proves.
|
||||
*
|
||||
* The two verifiable platforms prove the id in opposite directions, which is why they
|
||||
* can't share a code path: Steam's ticket *carries* a SteamID64 we read out and trust,
|
||||
* so the posted `platform_id` is discarded. Meta's nonce carries nothing — it is
|
||||
* validated *against* the posted `platform_id`, so that field is an input, and a
|
||||
* spoofed one fails validation rather than being ignored. Either way the id that comes
|
||||
* back is proven, never the raw client-supplied field, and only a proven id is ever
|
||||
* written to an account or linked to one.
|
||||
*/
|
||||
async function verifyPlatformProof(
|
||||
env: App['Bindings'],
|
||||
platform: number,
|
||||
platformAuth: string,
|
||||
postedPlatformId: string
|
||||
): Promise<PlatformProof> {
|
||||
// A sideloaded APK reports the placeholder id (see SIDELOAD_PLATFORM_ID) because it
|
||||
// has no Meta SDK behind it. Refuse it here, before anything is asked of Meta, so no
|
||||
// caller downstream can treat it as an identity — above all `linkLoginIdentity` on the
|
||||
// password grant, which is the path such a client actually takes. Linking it would
|
||||
// hand every sideloaded headset a password-free login into that account, since they
|
||||
// all report this same id.
|
||||
//
|
||||
// Refusing costs a sideloaded player nothing: their password login still succeeds (a
|
||||
// password grant carries its own credential and only *links* on a verified proof), it
|
||||
// just never gets a cached login, so they type their password each launch. That is
|
||||
// the intended shape of the sideload flow.
|
||||
if (platform === PlatformType.Oculus && postedPlatformId === SIDELOAD_PLATFORM_ID) {
|
||||
return { status: 'rejected', reason: 'sideload placeholder platform id is never an identity' }
|
||||
}
|
||||
if (platform === PlatformType.Steam) {
|
||||
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
|
||||
if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' }
|
||||
return { status: 'verified', platform: PlatformType.Steam, platformId: verified.steamId }
|
||||
}
|
||||
if (platform === PlatformType.Oculus) {
|
||||
// `.get()` throws when the secret doesn't exist in the store at all (as opposed to
|
||||
// holding an empty/placeholder value) — the same misconfiguration from the player's
|
||||
// side, so it takes the same branch.
|
||||
const appSecret = await env.META_APP_SECRET.get().catch(() => '')
|
||||
if (appSecret === '') return { status: 'unconfigured' }
|
||||
const verified = await verifyMetaNonce(platformAuth, postedPlatformId, appSecret)
|
||||
if (!verified.ok) return { status: 'rejected', reason: verified.reason }
|
||||
return {
|
||||
status: 'verified',
|
||||
platform: PlatformType.Oculus,
|
||||
platformId: verified.identity.userId,
|
||||
}
|
||||
}
|
||||
return { status: 'unsupported' }
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -193,307 +364,659 @@ const app = new Hono<App>()
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
// The website (`www`) is a browser origin calling these endpoints directly, the way
|
||||
// rec.net's own site called the game's API — so the responses need CORS headers or
|
||||
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
|
||||
// endpoints authenticate with a bearer token in the `Authorization` header, never a
|
||||
// cookie: a hostile page can't read another origin's stored token, so there is no
|
||||
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
|
||||
.use('*', withDefaultCors())
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
// EAC challenge — a fresh GUID, JSON-quoted, served as plain text.
|
||||
.get('/eac/challenge', (c) => c.text(`"AA=="`))
|
||||
.get(
|
||||
'/eac/challenge',
|
||||
describeRoute({
|
||||
tags: ['EAC'],
|
||||
summary: 'Easy Anti-Cheat challenge',
|
||||
description:
|
||||
'Returns a constant JSON-quoted string (`"AA=="`) as `text/plain`. Anti-cheat is not implemented; this exists so the client\'s EAC handshake succeeds.',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The challenge, JSON-quoted, as text/plain',
|
||||
content: { 'text/plain': { schema: { type: 'string', example: '"AA=="' } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
(c) => c.text(`"AA=="`)
|
||||
)
|
||||
|
||||
// Cached logins for a platform id — the accounts linked to this platform-native
|
||||
// id, so the client can offer them on the login screen (and post one back as a
|
||||
// cached_login grant). No linked account → [], and the client falls back to a
|
||||
// fresh login / create_account.
|
||||
.get('/cachedlogin/forplatformid/:platform/:id', async (c) => {
|
||||
const { platform, id } = c.req.param()
|
||||
logger.info('cached login lookup', { platform, id })
|
||||
const platformInt = Number.parseInt(platform, 10)
|
||||
const accounts = await getAccountsByPlatformId(c.env.DB, id)
|
||||
// Offer only accounts the `cached_login` grant will actually accept — same check.
|
||||
return c.json(
|
||||
accounts
|
||||
.filter((a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id))
|
||||
.map(toCachedLogin)
|
||||
)
|
||||
})
|
||||
.get(
|
||||
'/cachedlogin/forplatformid/:platform/:id',
|
||||
describeRoute({
|
||||
tags: ['Cached login'],
|
||||
summary: 'Accounts linked to a platform id',
|
||||
description: [
|
||||
'Accounts the client may offer on its login screen for this platform identity —',
|
||||
'the links this identity has, so an entry here is always redeemable by a',
|
||||
'`cached_login` grant (both read the same table). An account linked to several',
|
||||
'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)',
|
||||
'and the client falls back to a fresh login or create_account.',
|
||||
'EXCEPT the exact identity `1/1` (Oculus, id `1`), which is stubbed for SIDELOADED',
|
||||
'APKs: with no Meta SDK they have no real identity to ask about and stall on an',
|
||||
'empty picker. It consults nothing and returns one canned, non-redeemable entry',
|
||||
'with `requirePassword: true`, sending the build to username/password login.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'platform',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'PlatformType integer. A non-numeric value matches the id on any platform.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Platform-native id — a SteamID64 for Steam, a user id for Meta.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(
|
||||
CachedLogin.or(FakeCachedLogin).array(),
|
||||
'Matching accounts; `[]` if none. The canned entry for `1/1`.'
|
||||
),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const { platform, id } = c.req.param()
|
||||
logger.info('cached login lookup', { platform, id })
|
||||
const platformInt = Number.parseInt(platform, 10)
|
||||
// SIDELOADED APKs ONLY. A sideloaded build has no Meta SDK behind it, so it can't
|
||||
// produce a real Meta identity or a nonce to prove one with — it asks about the
|
||||
// placeholder identity `1/1`, and an empty picker leaves it stuck on the platform
|
||||
// login screen with nothing to do. Hand back one canned entry to push it onto the
|
||||
// username/password login instead, which is the only flow such a build can finish.
|
||||
// `requirePassword` is true for exactly that reason: there's no platform proof here,
|
||||
// and the `cached_login` grant would (correctly) refuse this entry.
|
||||
//
|
||||
// Scoped to that ONE identity rather than to all of platform 1 — store builds do
|
||||
// real Meta logins, and shadowing the whole platform would hide genuine links from
|
||||
// their pickers.
|
||||
if (platformInt === PlatformType.Oculus && id === SIDELOAD_PLATFORM_ID) {
|
||||
return c.json([FAKE_OCULUS_CACHED_LOGIN])
|
||||
}
|
||||
// Listed straight from the link table, which is also what the `cached_login`
|
||||
// grant authorizes against — so the picker can't offer an account the grant
|
||||
// then refuses.
|
||||
const links = Number.isNaN(platformInt)
|
||||
? await getLinksForPlatformId(c.env.DB, id)
|
||||
: await getLinksForPlatformIdentity(c.env.DB, platformInt, id)
|
||||
return c.json(await toCachedLogins(c.env.DB, links))
|
||||
}
|
||||
)
|
||||
|
||||
// Bulk cached-login lookup by platform id (friends resolution). The client POSTs
|
||||
// repeated `id=` params on the auth host; resolve each to its linked accounts.
|
||||
.post('/cachedlogin/forplatformids', async (c) => {
|
||||
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||
const raw = body.id
|
||||
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
|
||||
const out: Array<ReturnType<typeof toCachedLogin>> = []
|
||||
for (const pid of ids) {
|
||||
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
|
||||
.post(
|
||||
'/cachedlogin/forplatformids',
|
||||
describeRoute({
|
||||
tags: ['Cached login'],
|
||||
summary: 'Bulk cached-login lookup (friends resolution)',
|
||||
description: [
|
||||
'Resolves many platform ids at once. Results are flattened across all ids, so the',
|
||||
'response cannot be mapped back to a specific input id — the client uses each',
|
||||
'entry’s own `platformId`. No platform accompanies these ids, so each matches on',
|
||||
'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
|
||||
].join(' '),
|
||||
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
|
||||
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||
const raw = body.id
|
||||
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
|
||||
const out: Array<ReturnType<typeof toCachedLogin>> = []
|
||||
for (const pid of ids) {
|
||||
// No platform accompanies these ids, so they match on any platform.
|
||||
out.push(...(await toCachedLogins(c.env.DB, await getLinksForPlatformId(c.env.DB, pid))))
|
||||
}
|
||||
return c.json(out)
|
||||
}
|
||||
return c.json(out)
|
||||
})
|
||||
)
|
||||
|
||||
// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
|
||||
.post('/connect/token', async (c) => {
|
||||
// Reads `grant_type`, `account_id`, `platform_id` and `platform` from the
|
||||
// form body.
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
|
||||
// `platform`/`platform_id` come from the body for a fresh login; a refresh
|
||||
// grant overrides them below with what was stored when the token was issued.
|
||||
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
|
||||
// `platform` is the PlatformType int → its enum name (e.g. 0 → "Steam").
|
||||
const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
|
||||
let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '')
|
||||
.post(
|
||||
'/connect/token',
|
||||
describeRoute({
|
||||
tags: ['Token'],
|
||||
summary: 'OAuth token endpoint — issues a JWT',
|
||||
description: [
|
||||
'Issues an access token (plus a single-use refresh token) for one of four grants,',
|
||||
'selected by `grant_type`. Every grant returns the same body on success.',
|
||||
'',
|
||||
'**`create_account`** — mints a new account with an auto-assigned random username',
|
||||
'(players do not pick one initially) and places it in the Orientation room. A posted',
|
||||
'`password` becomes the login credential. Subject to two independent signup caps,',
|
||||
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
|
||||
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
|
||||
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
|
||||
'must verify.',
|
||||
'',
|
||||
'**`cached_login`** — logs into an already-linked account using platform ownership as',
|
||||
'the credential; no password. Requires a verifying `platform_auth`, and the posted',
|
||||
'`account_id` must be LINKED to exactly the identity it proves. An account with no',
|
||||
'link for that identity cannot be cached-logged-into.',
|
||||
'',
|
||||
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
|
||||
'platform and platform id come from what was stored at issue time, not the body.',
|
||||
'',
|
||||
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
|
||||
'identifies the account by `username` or numeric `account_id` and requires the',
|
||||
'matching `password`. An account with no stored hash cannot be logged into at all,',
|
||||
'which is what closes id/username-only takeover. When it also posts a `platform_auth`',
|
||||
'that verifies, that identity is LINKED to the account — this is how a player who',
|
||||
'signed up on one platform gets a cached login on a second device. The login is',
|
||||
'never failed over the link: an unverifiable proof (or one over the per-identity',
|
||||
'cap) just leaves the account without a cached login there.',
|
||||
'',
|
||||
'**Platform identity.** An account can be reached from several platform identities;',
|
||||
'the links are the one thing both the picker and `cached_login` consult, and only a',
|
||||
'VERIFIED identity is ever linked. Two platforms can be verified. Steam (`0`) posts a',
|
||||
'Steam-signed `platform_auth` ticket, checked offline; the SteamID64 it carries',
|
||||
'replaces the client-supplied `platform_id`. Meta/Oculus (`1`) posts `platform_auth`',
|
||||
'as `{"Nonce":…,"AppId":…}`, which recflare sends to Meta together with the posted',
|
||||
'`platform_id` — validation is what binds the nonce to that user id, so a spoofed id',
|
||||
'fails. Meta logins therefore need the app secret (`META_APP_SECRET`) and answer 500',
|
||||
'when it is unset. The first identity linked also becomes the account’s primary',
|
||||
'(what the account DTO and a refreshed token report); later ones only link.',
|
||||
'',
|
||||
'The one platform id that is never verified and never linked is `1` on platform `1`',
|
||||
'— what a SIDELOADED Oculus APK reports, having no Meta SDK to ask. Every such',
|
||||
'build reports it, so it identifies nobody. A password login that carries it still',
|
||||
'succeeds; it simply links nothing, and the player types their password each launch.',
|
||||
'',
|
||||
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||
'powers refresh on every login and every refresh grant.',
|
||||
].join('\n'),
|
||||
requestBody: form(
|
||||
TokenRequest,
|
||||
'Union of all grants; see the description for per-grant requirements'
|
||||
),
|
||||
responses: {
|
||||
200: json(TokenResponse, 'Access token, refresh token and granted scopes'),
|
||||
400: json(
|
||||
OAuthError,
|
||||
[
|
||||
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
|
||||
'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
|
||||
].join(' ')
|
||||
),
|
||||
500: json(
|
||||
OAuthError,
|
||||
[
|
||||
'The server is missing a secret it cannot proceed without: JWT_SECRET (a token is',
|
||||
'refused rather than signed with an empty key) or, on a Meta login, META_APP_SECRET',
|
||||
'(no nonce can be validated without it).',
|
||||
].join(' ')
|
||||
),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
// Reads `grant_type`, `account_id`, `platform_id` and `platform` from the
|
||||
// form body.
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
|
||||
// `platform`/`platform_id` come from the body for a fresh login; a refresh
|
||||
// grant overrides them below with what was stored when the token was issued.
|
||||
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
|
||||
const platformInt =
|
||||
typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
|
||||
// The token's `platform` claim is the PlatformType int. A grant that asserts no
|
||||
// platform (a password login) falls back to Steam/0, the same default the account
|
||||
// itself carries — see `accountPlatform`.
|
||||
let platform = Number.isNaN(platformInt) ? PlatformType.Steam : platformInt
|
||||
|
||||
// The device this login came from. The client posts both on every grant; they're
|
||||
// unverified (client-picked) so they're recorded on the account, never trusted as
|
||||
// a credential. Stored on account creation AND refreshed on each successful login,
|
||||
// so the account's device tracks the player across devices — the raw material for
|
||||
// linking accounts that share a device later.
|
||||
const deviceId = typeof body.device_id === 'string' ? body.device_id : ''
|
||||
const deviceClassInt =
|
||||
typeof body.device_class === 'string' ? Number.parseInt(body.device_class, 10) : NaN
|
||||
const deviceClass = Number.isNaN(deviceClassInt) ? 0 : deviceClassInt
|
||||
// The device this login came from. The client posts both on every grant; they're
|
||||
// unverified (client-picked) so they're recorded on the account, never trusted as
|
||||
// a credential. Stored on account creation AND refreshed on each successful login,
|
||||
// so the account's device tracks the player across devices — the raw material for
|
||||
// linking accounts that share a device later.
|
||||
const deviceId = typeof body.device_id === 'string' ? body.device_id : ''
|
||||
const deviceClassInt =
|
||||
typeof body.device_class === 'string' ? Number.parseInt(body.device_class, 10) : NaN
|
||||
const deviceClass = Number.isNaN(deviceClassInt) ? 0 : deviceClassInt
|
||||
|
||||
// The client's real IP, per Cloudflare (the client can't spoof CF-Connecting-IP —
|
||||
// the edge sets it — unlike X-Forwarded-For, which is why we don't read that).
|
||||
// Recorded as the immutable `signupIp` at creation and as `lastLoginIp` on every
|
||||
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
|
||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||
// The client's real IP, per Cloudflare (the client can't spoof CF-Connecting-IP —
|
||||
// the edge sets it — unlike X-Forwarded-For, which is why we don't read that).
|
||||
// Recorded as the immutable `signupIp` at creation and as `lastLoginIp` on every
|
||||
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
|
||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||
|
||||
// A platform-authenticated login proves who you are with the platform itself,
|
||||
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
|
||||
// ticket. So those logins must be Steam:
|
||||
// - cached_login authenticates purely by platform identity → always Steam-only.
|
||||
// - create_account that asserts a platform is rejected unless it's Steam, since
|
||||
// we won't bind an identity we can't prove. (create_account with NO platform
|
||||
// is the password-account path — allowed, but it binds no platformId.)
|
||||
// The verified SteamID64 replaces the unauthenticated `platform_id` field and is
|
||||
// the ONLY value ever written to an account's `platformId`. Credential (password)
|
||||
// and refresh_token grants carry their own credential and aren't gated here.
|
||||
let verifiedSteamId: string | null = null
|
||||
const platformAsserted = !Number.isNaN(platformInt)
|
||||
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
|
||||
if (platformInt !== 0) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'unsupported platform; only Steam can be verified',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
// A platform-authenticated login proves who you are with the platform itself, and
|
||||
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
|
||||
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
|
||||
// verifyPlatformProof). Only a verified identity is ever bound or linked.
|
||||
//
|
||||
// Two grants are GATED on it — they have no other credential, so an unverifiable
|
||||
// platform is fatal:
|
||||
// - cached_login authenticates purely by platform identity.
|
||||
// - create_account that asserts a platform: we won't bind an identity we can't
|
||||
// prove. (create_account with NO platform is the password-account path —
|
||||
// allowed, but binds no platformId.)
|
||||
//
|
||||
// A password grant is NOT gated: the password already proved who it is. It posts
|
||||
// its platform proof too, and if that verifies we LINK the identity to the account
|
||||
// (see below), which is how a player who created an account on Steam gets a cached
|
||||
// login on their headset. If it doesn't verify, the login still succeeds — it just
|
||||
// links nothing, because a link is a password-free way into the account and must
|
||||
// never rest on an unproven id.
|
||||
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
|
||||
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
|
||||
if (!verified) {
|
||||
const platformAsserted = !Number.isNaN(platformInt)
|
||||
const gatedOnPlatform =
|
||||
grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)
|
||||
// The password grant only spends a verification when the client actually offered
|
||||
// one; the rest of the time there is nothing to link.
|
||||
const proof: PlatformProof =
|
||||
gatedOnPlatform || (platformAsserted && platformAuth !== '')
|
||||
? await verifyPlatformProof(c.env, platformInt, platformAuth, platformId)
|
||||
: { status: 'none' }
|
||||
|
||||
let verifiedPlatformId: string | null = null
|
||||
let verifiedPlatform: number | null = null
|
||||
if (proof.status === 'verified') {
|
||||
verifiedPlatform = proof.platform
|
||||
verifiedPlatformId = proof.platformId
|
||||
} else if (proof.status !== 'none') {
|
||||
// Log every failure, including the ones a password grant shrugs off: a player
|
||||
// who silently never gets a cached login on their headset has no other symptom,
|
||||
// and this line is where "Meta rejected the nonce" becomes visible.
|
||||
logger.info('platform_auth not verified', {
|
||||
platform: platformInt,
|
||||
platformId,
|
||||
grantType,
|
||||
status: proof.status,
|
||||
reason: proof.status === 'rejected' ? proof.reason : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (gatedOnPlatform && proof.status !== 'verified') {
|
||||
if (proof.status === 'unsupported') {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'unsupported platform; only Steam and Meta can be verified',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
if (proof.status === 'unconfigured') {
|
||||
// An operator misconfiguration, not the client's fault: without the app secret
|
||||
// every Meta player is locked out, so it answers 500 the way an unset
|
||||
// JWT_SECRET does below rather than blaming the credential. (We never fall
|
||||
// back to trusting the posted id — that would let anyone log into any
|
||||
// Meta-linked account by naming its user id.)
|
||||
logger.error('refusing a Meta login: META_APP_SECRET is empty')
|
||||
return c.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Meta platform verification is not configured',
|
||||
},
|
||||
500
|
||||
)
|
||||
}
|
||||
// The reason is for the operator; the client is told only that it was rejected.
|
||||
// A wrong app secret and a stale nonce look identical from the client side.
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: 'invalid or missing platform_auth ticket' },
|
||||
{ error: 'invalid_grant', error_description: 'invalid or missing platform_auth' },
|
||||
400
|
||||
)
|
||||
}
|
||||
verifiedSteamId = verified.steamId
|
||||
platformId = verified.steamId
|
||||
}
|
||||
|
||||
// Resolve the account this token is for:
|
||||
// - create_account: mint + persist a brand-new account (auto-assigned random
|
||||
// username — players don't pick one initially); the token's `sub` is its id.
|
||||
// A `password` may be posted to establish the account's login credential.
|
||||
// - refresh_token: redeem a stored (single-use) refresh token for its account +
|
||||
// platform, so an expiring session renews without re-login.
|
||||
// - otherwise: a credential login. The request identifies the account by
|
||||
// `username` (RecRoom's password grant posts the username, not the id) or a
|
||||
// numeric `account_id`, and MUST post the account's correct `password`. An
|
||||
// account with no password set can't be logged into (no credential to verify)
|
||||
// — closing the id/username-only takeover. New accounts establish a password
|
||||
// via create_account or /account/me/changepassword.
|
||||
let accountId: string
|
||||
if (grantType === 'create_account') {
|
||||
// Signup caps. Checked before minting anything, so a rejected signup leaves no
|
||||
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
|
||||
// identity is unknown (no verified platform id / no client IP) — an unattributable
|
||||
// signup can't be counted against anyone, and lumping them together would lock out
|
||||
// real players. The disabled check comes first so a disabled arm costs no D1 read.
|
||||
const maxPerPlatformId = intVar(
|
||||
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
|
||||
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
|
||||
// From here on `platformId` is the PROVEN identity wherever there is one — the
|
||||
// SteamID64 out of the ticket or the Meta user id the nonce validated against,
|
||||
// never the raw client-supplied field.
|
||||
if (verifiedPlatformId !== null) platformId = verifiedPlatformId
|
||||
|
||||
// Resolve the account this token is for:
|
||||
// - create_account: mint + persist a brand-new account (auto-assigned random
|
||||
// username — players don't pick one initially); the token's `sub` is its id.
|
||||
// A `password` may be posted to establish the account's login credential.
|
||||
// - refresh_token: redeem a stored (single-use) refresh token for its account +
|
||||
// platform, so an expiring session renews without re-login.
|
||||
// - otherwise: a credential login. The request identifies the account by
|
||||
// `username` (RecRoom's password grant posts the username, not the id) or a
|
||||
// numeric `account_id`, and MUST post the account's correct `password`. An
|
||||
// account with no password set can't be logged into (no credential to verify)
|
||||
// — closing the id/username-only takeover. New accounts establish a password
|
||||
// via create_account or /account/me/changepassword.
|
||||
let accountId: string
|
||||
if (grantType === 'create_account') {
|
||||
// Signup caps. Checked before minting anything, so a rejected signup leaves no
|
||||
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
|
||||
// identity is unknown (no verified platform id / no client IP) — an unattributable
|
||||
// signup can't be counted against anyone, and lumping them together would lock out
|
||||
// real players. The disabled check comes first so a disabled arm costs no D1 read.
|
||||
const maxPerPlatformId = intVar(
|
||||
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
|
||||
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
|
||||
)
|
||||
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
||||
if (
|
||||
maxPerPlatformId > 0 &&
|
||||
verifiedPlatformId !== null &&
|
||||
(await countAccountsForPlatformIdentity(
|
||||
c.env.DB,
|
||||
verifiedPlatform ?? 0,
|
||||
verifiedPlatformId
|
||||
)) >= maxPerPlatformId
|
||||
) {
|
||||
logger.info('signup rejected: platform account limit', {
|
||||
platformId: verifiedPlatformId,
|
||||
})
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'account limit reached for this platform account',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
if (
|
||||
maxPerIp > 0 &&
|
||||
clientIp !== '' &&
|
||||
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
|
||||
) {
|
||||
logger.info('signup rejected: per-IP account limit', { ip: clientIp })
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'too many accounts created from this network',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
|
||||
// a Meta-validated nonce). A password/anonymous create_account (no platform)
|
||||
// binds nothing. The account blob keeps this first identity as its PRIMARY one
|
||||
// (for the account DTO and the refresh grant's claims); the link written just
|
||||
// below is what a later cached login is actually authorized against.
|
||||
const account = await createAccount(c.env.DB, {
|
||||
platforms: platformInt || 0,
|
||||
platform: verifiedPlatform ?? undefined,
|
||||
platformId: verifiedPlatformId ?? undefined,
|
||||
lastLoginTime: new Date().toISOString(),
|
||||
deviceId: deviceId || undefined,
|
||||
deviceClass: deviceId ? deviceClass : undefined,
|
||||
signupIp: clientIp || undefined,
|
||||
lastLoginIp: clientIp || undefined,
|
||||
})
|
||||
accountId = String(account.accountId)
|
||||
if (verifiedPlatformId !== null) {
|
||||
await linkPlatformIdentity(
|
||||
c.env.DB,
|
||||
account.accountId,
|
||||
verifiedPlatform ?? 0,
|
||||
verifiedPlatformId
|
||||
)
|
||||
}
|
||||
// Establish the login password when one is posted (raw password never stored).
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (password !== '') {
|
||||
await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password))
|
||||
}
|
||||
// Place the new player in Orientation (they don't explicitly matchmake into it).
|
||||
await placeNewPlayerInOrientation(c.env, account.accountId, deviceClass)
|
||||
} else if (grantType === 'refresh_token') {
|
||||
const presented = typeof body.refresh_token === 'string' ? body.refresh_token : ''
|
||||
const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null
|
||||
if (!refreshed) {
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: 'refresh_token is invalid or expired' },
|
||||
400
|
||||
)
|
||||
}
|
||||
// `platform`/`platform_id` aren't stored with the token — they're taken from
|
||||
// the account below, so a refreshed token always reflects the identity the
|
||||
// account is bound to now.
|
||||
accountId = String(refreshed)
|
||||
} else if (grantType === 'cached_login') {
|
||||
// Platform-authenticated login into an already-linked account. The client posts
|
||||
// the `account_id` it got from /cachedlogin/forplatformid together with the
|
||||
// `platform_id` its platform_auth vouches for. Authorize ONLY when the link
|
||||
// table says that account is linked to exactly this platform identity — this is
|
||||
// the check that keeps anyone but that platform user out of the account
|
||||
// (platform ownership is the credential; no password needed). An account with no
|
||||
// link for the presented identity must use a password.
|
||||
//
|
||||
// The picker lists straight from the same table, so it can only offer accounts
|
||||
// this check accepts.
|
||||
//
|
||||
// NB: `platform_id` here is the verified identity set above — the SteamID64 from
|
||||
// the ticket, or the Meta user id the nonce validated against — never the raw
|
||||
// client-supplied field. See steam-ticket.ts and meta-nonce.ts.
|
||||
//
|
||||
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
|
||||
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
|
||||
const linked =
|
||||
account !== null &&
|
||||
(await isPlatformIdentityLinked(c.env.DB, account.accountId, platformInt, platformId))
|
||||
if (!account || !linked) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'no linked account for this platform identity',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
accountId = String(account.accountId)
|
||||
await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString())
|
||||
await setLoginContext(c.env.DB, account.accountId, { deviceId, deviceClass, ip: clientIp })
|
||||
} else {
|
||||
// Resolve the account from a posted numeric `account_id` or, as RecRoom's
|
||||
// password grant sends, a `username` (case-insensitive; trailing whitespace
|
||||
// is trimmed off the posted value).
|
||||
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
|
||||
const postedUsername = typeof body.username === 'string' ? body.username.trim() : ''
|
||||
let resolvedId: number | null = null
|
||||
if (/^\d+$/.test(postedId)) {
|
||||
resolvedId = Number(postedId)
|
||||
} else if (postedUsername !== '') {
|
||||
resolvedId = (await getAccountByUsername(c.env.DB, postedUsername))?.accountId ?? null
|
||||
}
|
||||
if (resolvedId === null) {
|
||||
return c.json(
|
||||
{ error: 'invalid_request', error_description: 'account_id or username is required' },
|
||||
400
|
||||
)
|
||||
}
|
||||
// The account's password MUST be presented and match. An account with no
|
||||
// stored hash has no credential to authenticate against, so login is refused
|
||||
// — this closes the id/username-only takeover.
|
||||
const storedHash = await getPasswordHash(c.env.DB, resolvedId)
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (!storedHash || !(await verifyPassword(password, storedHash))) {
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: 'invalid account_id or password' },
|
||||
400
|
||||
)
|
||||
}
|
||||
accountId = String(resolvedId)
|
||||
// The password proved the account; the platform proof (when the client sent one
|
||||
// and it verified) proves the device's platform identity. Linking the two is
|
||||
// what gives a player who signed up on Steam a cached login on their headset —
|
||||
// they type their password once there, and never again.
|
||||
if (verifiedPlatformId !== null) {
|
||||
await linkLoginIdentity(
|
||||
c.env.DB,
|
||||
resolvedId,
|
||||
verifiedPlatform ?? 0,
|
||||
verifiedPlatformId,
|
||||
intVar(c.env.MAX_ACCOUNTS_PER_PLATFORM_ID, DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID)
|
||||
)
|
||||
}
|
||||
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
|
||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||
}
|
||||
|
||||
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
|
||||
// binding) would still yield a well-formed token — but one signed with an empty
|
||||
// key, which every worker validates against, so anyone could forge it. Refuse to
|
||||
// issue a token at all rather than complete the login with a forgeable credential.
|
||||
const jwtSecret = await c.env.JWT_SECRET.get()
|
||||
if (jwtSecret === '') {
|
||||
logger.error('refusing to issue token: JWT_SECRET is empty')
|
||||
return c.json(
|
||||
{ error: 'server_error', error_description: 'token signing is not configured' },
|
||||
500
|
||||
)
|
||||
}
|
||||
|
||||
// Stamp the account's elevated roles into the token's `role` claim so the client
|
||||
// authorizes developer/moderator powers from the token itself (not just the
|
||||
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
|
||||
// every login and every refresh_token grant.
|
||||
const roleAccount = await getAccount(c.env.DB, Number(accountId))
|
||||
// A refresh grant posts no platform of its own, so the identity comes off the
|
||||
// account — the same read, and the only place the bound identity is authoritative.
|
||||
if (grantType === 'refresh_token' && roleAccount) {
|
||||
platform = accountPlatform(roleAccount)
|
||||
platformId = roleAccount.platformId ?? ''
|
||||
}
|
||||
const accessToken = await generateToken(
|
||||
accountId,
|
||||
platformId,
|
||||
platform,
|
||||
jwtSecret,
|
||||
accountRoles(roleAccount)
|
||||
)
|
||||
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
||||
if (
|
||||
maxPerPlatformId > 0 &&
|
||||
verifiedSteamId !== null &&
|
||||
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
|
||||
) {
|
||||
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'account limit reached for this platform account',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
if (
|
||||
maxPerIp > 0 &&
|
||||
clientIp !== '' &&
|
||||
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
|
||||
) {
|
||||
logger.info('signup rejected: per-IP account limit', { ip: clientIp })
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'too many accounts created from this network',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
||||
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
||||
const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId))
|
||||
|
||||
// Bind the platform identity ONLY when a Steam ticket proved it. That bound
|
||||
// `platformId` (the SteamID64) is what a later cached login is checked against,
|
||||
// so only this Steam user can log back into the account. A password/anonymous
|
||||
// create_account (no platform) binds no platformId.
|
||||
const account = await createAccount(c.env.DB, {
|
||||
platforms: platformInt || 0,
|
||||
platform: verifiedSteamId !== null ? 0 : undefined,
|
||||
platformId: verifiedSteamId ?? undefined,
|
||||
lastLoginTime: new Date().toISOString(),
|
||||
deviceId: deviceId || undefined,
|
||||
deviceClass: deviceId ? deviceClass : undefined,
|
||||
signupIp: clientIp || undefined,
|
||||
lastLoginIp: clientIp || undefined,
|
||||
return c.json({
|
||||
access_token: accessToken,
|
||||
expires_in: TOKEN_TTL_SECONDS,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: refreshToken,
|
||||
scope: TOKEN_SCOPE,
|
||||
// @kludge Why is this necessary? Who knows.
|
||||
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
|
||||
})
|
||||
accountId = String(account.accountId)
|
||||
// Establish the login password when one is posted (raw password never stored).
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (password !== '') {
|
||||
await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password))
|
||||
}
|
||||
// Place the new player in Orientation (they don't explicitly matchmake into it).
|
||||
await placeNewPlayerInOrientation(c.env, account.accountId, deviceClass)
|
||||
} else if (grantType === 'refresh_token') {
|
||||
const presented = typeof body.refresh_token === 'string' ? body.refresh_token : ''
|
||||
const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null
|
||||
if (!refreshed) {
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: 'refresh_token is invalid or expired' },
|
||||
400
|
||||
)
|
||||
}
|
||||
accountId = String(refreshed.accountId)
|
||||
platform = refreshed.platform
|
||||
platformId = refreshed.platformId
|
||||
} else if (grantType === 'cached_login') {
|
||||
// Platform-authenticated login into an already-linked account. The client posts
|
||||
// the `account_id` it got from /cachedlogin/forplatformid together with the
|
||||
// `platform_id` its platform_auth ticket vouches for. Authorize ONLY when that
|
||||
// account is linked to exactly this platform identity — this is the check that
|
||||
// keeps anyone but platform user `platform_id` out of the account (platform
|
||||
// ownership is the credential; no password needed). An account with no stored
|
||||
// platform identity can't be cached-logged-into and must use a fresh login.
|
||||
//
|
||||
// NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket
|
||||
// above), never the client-supplied field. See steam-ticket.ts.
|
||||
//
|
||||
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
|
||||
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
|
||||
if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'no linked account for this platform identity',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
accountId = String(account.accountId)
|
||||
await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString())
|
||||
await setLoginContext(c.env.DB, account.accountId, { deviceId, deviceClass, ip: clientIp })
|
||||
} else {
|
||||
// Resolve the account from a posted numeric `account_id` or, as RecRoom's
|
||||
// password grant sends, a `username` (case-insensitive; trailing whitespace
|
||||
// is trimmed off the posted value).
|
||||
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
|
||||
const postedUsername = typeof body.username === 'string' ? body.username.trim() : ''
|
||||
let resolvedId: number | null = null
|
||||
if (/^\d+$/.test(postedId)) {
|
||||
resolvedId = Number(postedId)
|
||||
} else if (postedUsername !== '') {
|
||||
resolvedId = (await getAccountByUsername(c.env.DB, postedUsername))?.accountId ?? null
|
||||
}
|
||||
if (resolvedId === null) {
|
||||
return c.json(
|
||||
{ error: 'invalid_request', error_description: 'account_id or username is required' },
|
||||
400
|
||||
)
|
||||
}
|
||||
// The account's password MUST be presented and match. An account with no
|
||||
// stored hash has no credential to authenticate against, so login is refused
|
||||
// — this closes the id/username-only takeover.
|
||||
const storedHash = await getPasswordHash(c.env.DB, resolvedId)
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (!storedHash || !(await verifyPassword(password, storedHash))) {
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: 'invalid account_id or password' },
|
||||
400
|
||||
)
|
||||
}
|
||||
accountId = String(resolvedId)
|
||||
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
|
||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||
}
|
||||
|
||||
const accessToken = await generateToken(
|
||||
accountId,
|
||||
platformId,
|
||||
platform,
|
||||
await c.env.JWT_SECRET.get()
|
||||
)
|
||||
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
||||
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
||||
const refreshToken = await issueRefreshToken(c.env.DB, {
|
||||
accountId: Number(accountId),
|
||||
platform,
|
||||
platformId,
|
||||
})
|
||||
|
||||
return c.json({
|
||||
access_token: accessToken,
|
||||
expires_in: TOKEN_TTL_SECONDS,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: refreshToken,
|
||||
scope: TOKEN_SCOPE,
|
||||
// @kludge Why is this necessary? Who knows.
|
||||
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Change the caller's password. Auth-gated. Stores a PBKDF2 hash on the account
|
||||
// row (the raw password is never persisted). When the account already has a
|
||||
// password, `oldPassword` must match; the first time it's set, `oldPassword` is
|
||||
// empty (as the client sends).
|
||||
.post('/account/me/changepassword', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.post(
|
||||
'/account/me/changepassword',
|
||||
describeRoute({
|
||||
tags: ['Account'],
|
||||
summary: "Change the caller's password",
|
||||
description: [
|
||||
'Stores a PBKDF2 hash on the account row; the raw password is never persisted.',
|
||||
'When the account already has a password, `oldPassword` must match. The first time',
|
||||
'a password is set, `oldPassword` is empty — which is what the client sends.',
|
||||
].join(' '),
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: form(ChangePasswordRequest, 'New password, plus the old one when one is set'),
|
||||
responses: {
|
||||
200: json(ChangePasswordResponse, 'Password changed'),
|
||||
400: json(ChangePasswordResponse, '`newPassword` was empty, or `oldPassword` was wrong'),
|
||||
401: { description: 'Missing or invalid bearer token (empty body)' },
|
||||
404: { description: 'The account no longer exists (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const oldPassword = typeof body.oldPassword === 'string' ? body.oldPassword : ''
|
||||
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
|
||||
if (newPassword === '') {
|
||||
return c.json({ success: false, error: 'You must enter a new password.' }, 400)
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const oldPassword = typeof body.oldPassword === 'string' ? body.oldPassword : ''
|
||||
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
|
||||
if (newPassword === '') {
|
||||
return c.json({ success: false, error: 'You must enter a new password.' }, 400)
|
||||
}
|
||||
|
||||
const currentHash = await getPasswordHash(c.env.DB, id)
|
||||
if (currentHash && !(await verifyPassword(oldPassword, currentHash))) {
|
||||
return c.json({ success: false, error: 'Your old password is incorrect.' }, 400)
|
||||
}
|
||||
|
||||
const ok = await setPasswordHash(c.env.DB, id, await hashPassword(newPassword))
|
||||
if (!ok) return c.body(null, 404)
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
const currentHash = await getPasswordHash(c.env.DB, id)
|
||||
if (currentHash && !(await verifyPassword(oldPassword, currentHash))) {
|
||||
return c.json({ success: false, error: 'Your old password is incorrect.' }, 400)
|
||||
}
|
||||
|
||||
const ok = await setPasswordHash(c.env.DB, id, await hashPassword(newPassword))
|
||||
if (!ok) return c.body(null, 404)
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// Developer role lookup. No developer role granted by default.
|
||||
.get('/role/developer/:id', (c) => {
|
||||
// Developer role lookup. Returns a bare JSON boolean (the client reads the body as
|
||||
// a bool), and 404s for an unknown player — mirroring the reference API. The role
|
||||
// is off by default and only an operator grants it (via `runx admin grant-developer`,
|
||||
// which sets the account's isDeveloper flag); it also rides in the token's `role`
|
||||
// claim (see accountRoles).
|
||||
.get('/role/developer/:id', describeRoute(roleLookup('developer')), async (c) => {
|
||||
const { id } = c.req.param()
|
||||
logger.info('developer role lookup', { id })
|
||||
return c.json({ success: false })
|
||||
const accountId = Number.parseInt(id, 10)
|
||||
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
|
||||
if (!account) return c.body(null, 404)
|
||||
return c.json(account.isDeveloper === true)
|
||||
})
|
||||
|
||||
// Moderator role lookup, mirroring developer (bare boolean, 404 for unknown player).
|
||||
// Operator-granted only (via `runx admin grant-moderator`); the flag also rides in
|
||||
// the token's `role` claim.
|
||||
.get('/role/moderator/:id', describeRoute(roleLookup('moderator')), async (c) => {
|
||||
const { id } = c.req.param()
|
||||
logger.info('moderator role lookup', { id })
|
||||
const accountId = Number.parseInt(id, 10)
|
||||
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
|
||||
if (!account) return c.body(null, 404)
|
||||
return c.json(account.isModerator === true)
|
||||
})
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
app.get(
|
||||
'/openapi.json',
|
||||
describeRoute({ hide: true }),
|
||||
withCleanSpec(
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: 'recflare auth',
|
||||
version: '1.0.0',
|
||||
description: [
|
||||
'Authentication and token issuance for recflare, a private-server reimplementation',
|
||||
'of the Rec Room backend.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://auth.recflare.net', description: 'Production' }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'An `access_token` from `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -12,6 +12,13 @@ export type Env = SharedHonoEnv & {
|
||||
// signed here verify in all of them. Provisioned via `wrangler secrets-store`;
|
||||
// the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
|
||||
JWT_SECRET: SecretsStoreSecret
|
||||
// The Meta (Oculus) app secret, from the app's page in the Meta developer dashboard.
|
||||
// Bound from the same Secrets Store as JWT_SECRET; resolve it with `.get()`. Used
|
||||
// only to authenticate US to Meta's graph API when validating a login nonce (see
|
||||
// meta-nonce.ts) — it never leaves the worker. Unlike Steam, whose ticket verifies
|
||||
// offline, Meta logins are impossible without it, so an empty value fails those
|
||||
// logins with a 500 rather than silently trusting the client's platform_id.
|
||||
META_APP_SECRET: SecretsStoreSecret
|
||||
// Signup caps, both optional (see auth.app.ts for what each arm counts and why).
|
||||
// Unset falls back to the DEFAULT_MAX_ACCOUNTS_* constants there; 0 disables that arm.
|
||||
// Typed `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Verification of a Meta (Oculus) `platform_auth` nonce, against Meta's graph API.
|
||||
*
|
||||
* Steam's ticket is signed by Steam, so we verify it offline with no network and no
|
||||
* credential (see steam-ticket.ts). Meta's user proof is the opposite: an opaque
|
||||
* nonce that means nothing on its own. The only way to know it is genuine is to ask
|
||||
* Meta — which is why this path makes an outbound request on every Meta login and
|
||||
* cannot work at all without the app secret.
|
||||
*
|
||||
* A Meta login posts
|
||||
*
|
||||
* platform_auth = {"Nonce":"<64 chars>","AppId":"1232175103309633","Source":"logged in user"}
|
||||
* platform_id = <the Meta user id>
|
||||
*
|
||||
* and validation is what BINDS those two together: `user_nonce_validate` answers
|
||||
* "was this nonce issued to this user, for this app?". So the posted `platform_id` is
|
||||
* an *input* here rather than something read out of a ticket, and a spoofed one fails
|
||||
* — a nonce Meta issued to user A does not validate as user B. The id is therefore
|
||||
* proven exactly as much as a Steam ticket's SteamID64 is, and is safe to bind to an
|
||||
* account. (It's an app-scoped id: it identifies the player within this app only.)
|
||||
*
|
||||
* The `AppId` comes from the payload rather than config because it must be the app the
|
||||
* nonce was issued for — a different one simply fails, since the access token below
|
||||
* pairs it with our secret. `Source` is informational and ignored.
|
||||
*
|
||||
* Shape and retry policy follow the reference Go server's utils/oculus.go.
|
||||
*/
|
||||
|
||||
/** Meta's nonce-validation endpoint. Takes a form body, answers `{"is_valid":true}`. */
|
||||
const NONCE_VALIDATE_URL = 'https://graph.oculus.com/user_nonce_validate'
|
||||
|
||||
/**
|
||||
* Graph error codes worth retrying — 1 (unknown) and 2 (service temporarily
|
||||
* unavailable) are Meta-side hiccups, not a verdict on the nonce. Anything else is a
|
||||
* real answer and retrying it just delays a login that is going to fail anyway.
|
||||
*/
|
||||
const TRANSIENT_ERROR_CODES = new Set([1, 2])
|
||||
|
||||
/**
|
||||
* Attempts per verification. A login is latency-sensitive and a nonce is single-use
|
||||
* with a short life, so this is deliberately small: two quick retries (250ms, 1s of
|
||||
* backoff) ride out a blip, and a longer outage fails the login rather than hanging
|
||||
* the client on a headset loading screen.
|
||||
*/
|
||||
const MAX_ATTEMPTS = 3
|
||||
|
||||
/** The trustworthy identity proven by a validated nonce. */
|
||||
export interface VerifiedMetaIdentity {
|
||||
/** The Meta user id the nonce was issued to — app-scoped, numeric. */
|
||||
userId: string
|
||||
/** The Meta app the nonce was issued for. */
|
||||
appId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The outcome of a verification. Failures carry a `reason` for the server log: the
|
||||
* client is told only that its platform_auth was rejected (it can't act on more), but
|
||||
* an operator debugging a headset that won't log in needs to know whether Meta said
|
||||
* "bad nonce", "bad access token" (the wrong app secret) or nothing at all.
|
||||
*/
|
||||
export type MetaVerification =
|
||||
{ ok: true; identity: VerifiedMetaIdentity } | { ok: false; reason: string }
|
||||
|
||||
/** The `{Nonce, AppId}` a Meta `platform_auth` payload carries. */
|
||||
export interface MetaPlatformAuth {
|
||||
nonce: string
|
||||
appId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Meta `platform_auth` payload, or null when it isn't one. The `AppId` must be
|
||||
* numeric — it is interpolated into the access token below, and this is what keeps a
|
||||
* client-supplied string out of that credential.
|
||||
*/
|
||||
export function parseMetaPlatformAuth(platformAuth: string): MetaPlatformAuth | null {
|
||||
let parsed: { Nonce?: unknown; AppId?: unknown }
|
||||
try {
|
||||
parsed = JSON.parse(platformAuth) as { Nonce?: unknown; AppId?: unknown }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const { Nonce: nonce, AppId: appId } = parsed
|
||||
if (typeof nonce !== 'string' || nonce === '') return null
|
||||
if (typeof appId !== 'string' || !/^\d+$/.test(appId)) return null
|
||||
return { nonce, appId }
|
||||
}
|
||||
|
||||
/** The graph response we care about; everything else in the body is ignored. */
|
||||
interface NonceValidateResponse {
|
||||
is_valid?: boolean
|
||||
error?: { message?: string; code?: number; type?: string; is_transient?: boolean }
|
||||
}
|
||||
|
||||
/** One validation round-trip. `retryable` says whether another attempt could differ. */
|
||||
async function validateOnce(
|
||||
form: URLSearchParams,
|
||||
fetcher: typeof fetch
|
||||
): Promise<{ ok: boolean; retryable: boolean; reason: string }> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetcher(NONCE_VALIDATE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: form.toString(),
|
||||
})
|
||||
} catch (err) {
|
||||
return { ok: false, retryable: true, reason: `request failed: ${String(err)}` }
|
||||
}
|
||||
|
||||
let body: NonceValidateResponse
|
||||
try {
|
||||
body = (await res.json()) as NonceValidateResponse
|
||||
} catch {
|
||||
// A non-JSON body is Meta's edge (a 5xx error page, a rate-limit page), not a
|
||||
// verdict — treat it the way a dropped connection is treated.
|
||||
return { ok: false, retryable: true, reason: `HTTP ${res.status} with a non-JSON body` }
|
||||
}
|
||||
|
||||
if (body.error) {
|
||||
const { code, message, is_transient } = body.error
|
||||
return {
|
||||
ok: false,
|
||||
retryable: is_transient === true || (code !== undefined && TRANSIENT_ERROR_CODES.has(code)),
|
||||
reason: `graph error ${code ?? '?'}: ${message ?? 'no message'}`,
|
||||
}
|
||||
}
|
||||
if (body.is_valid !== true) return { ok: false, retryable: false, reason: 'nonce rejected' }
|
||||
return { ok: true, retryable: false, reason: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Meta `platform_auth` payload against the `userId` it is claimed for, and
|
||||
* return the identity it proves. Only ever succeeds for a nonce Meta itself confirms
|
||||
* was issued to that user for that app.
|
||||
*
|
||||
* `appSecret` is the app's secret from the Meta developer dashboard; without it no
|
||||
* Meta login can be verified, so callers must treat an unset secret as a server
|
||||
* misconfiguration rather than a bad credential. `fetcher` is injectable so tests can
|
||||
* run the retry and response handling without reaching the network.
|
||||
*/
|
||||
export async function verifyMetaNonce(
|
||||
platformAuth: string,
|
||||
userId: string,
|
||||
appSecret: string,
|
||||
fetcher?: typeof fetch
|
||||
): Promise<MetaVerification> {
|
||||
if (appSecret === '') return { ok: false, reason: 'no app secret configured' }
|
||||
// The user id is what the nonce is checked against, so an absent or non-numeric one
|
||||
// can't be verified — reject before spending a round-trip on it.
|
||||
if (!/^\d+$/.test(userId)) return { ok: false, reason: 'missing or non-numeric platform_id' }
|
||||
const auth = parseMetaPlatformAuth(platformAuth)
|
||||
if (!auth) return { ok: false, reason: 'malformed platform_auth payload' }
|
||||
|
||||
// `OC|<app id>|<app secret>` is Meta's app access token — it authenticates the
|
||||
// *app*, which is why the secret never leaves the server.
|
||||
const form = new URLSearchParams({
|
||||
nonce: auth.nonce,
|
||||
user_id: userId,
|
||||
access_token: `OC|${auth.appId}|${appSecret}`,
|
||||
})
|
||||
|
||||
// Resolved per call, not at module load, so a test's stubbed global is honoured.
|
||||
const doFetch = fetcher ?? globalThis.fetch
|
||||
let last = { ok: false, retryable: false, reason: 'not attempted' }
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
last = await validateOnce(form, doFetch)
|
||||
if (last.ok) return { ok: true, identity: { userId, appId: auth.appId } }
|
||||
if (!last.retryable || attempt === MAX_ATTEMPTS) break
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt * attempt * 250))
|
||||
}
|
||||
return { ok: false, reason: last.reason }
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the auth worker.
|
||||
*
|
||||
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||
* generate the spec and are never wired into `hono-openapi`'s `validator()`.
|
||||
*
|
||||
* That is deliberate, not an oversight. This worker serves a reverse-engineered
|
||||
* protocol: the Rec Room client is the only real consumer, and the handlers are
|
||||
* intentionally lenient — every field is read as
|
||||
* `typeof body.x === 'string' ? body.x : ''` and missing/malformed input falls
|
||||
* through to a graceful path rather than a 400. Which parts of that tolerance the
|
||||
* client actually depends on is not fully known, so enforcing a schema would risk
|
||||
* rejecting requests that work today, for a client that is hard to debug against.
|
||||
*
|
||||
* So: these schemas record what the client is *observed* to send and what we send
|
||||
* back. If you want to enforce one, do it per-route and land a test with it.
|
||||
*/
|
||||
|
||||
/** Emit a zod schema as an `application/json` response body. */
|
||||
export function json(schema: z.ZodType, description: string) {
|
||||
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a zod schema as an `application/x-www-form-urlencoded` request body.
|
||||
*
|
||||
* Unlike `responses`, `describeRoute`'s `requestBody` takes a plain OpenAPI schema
|
||||
* and won't accept a `resolver()`, so convert here. zod's `$schema` key is dropped
|
||||
* (not meaningful in an OpenAPI schema position), as is `additionalProperties: false`
|
||||
* — these handlers read the fields they know and ignore the rest, so claiming a
|
||||
* closed object would misreport the server as stricter than it is.
|
||||
*/
|
||||
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||
return {
|
||||
description,
|
||||
content: {
|
||||
// zod's JSONSchema type is far wider than OpenAPI's SchemaObject (it carries
|
||||
// `~standard` and every draft keyword), so the two never match structurally
|
||||
// even though the emitted value is valid OpenAPI 3.1. Cast at the boundary.
|
||||
'application/x-www-form-urlencoded': { schema: jsonSchema as OpenAPIV3_1.SchemaObject },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PlatformType, the client's platform enum. Declaration order is wire order, and is
|
||||
* the single source for the schema and description below. The `platform` form field
|
||||
* is posted as the integer; the token's `platform` claim carries the name.
|
||||
*/
|
||||
export const PlatformType = {
|
||||
All: -1,
|
||||
Steam: 0,
|
||||
Oculus: 1,
|
||||
PlayStation: 2,
|
||||
Xbox: 3,
|
||||
RecNet: 4,
|
||||
IOS: 5,
|
||||
GooglePlay: 6,
|
||||
Standalone: 7,
|
||||
Pico: 8,
|
||||
} as const
|
||||
|
||||
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
|
||||
|
||||
/**
|
||||
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified —
|
||||
* see the platform-auth notes on `POST /connect/token`.
|
||||
*/
|
||||
export const PlatformTypeSchema = z
|
||||
.union([
|
||||
z.literal(-1),
|
||||
z
|
||||
.int()
|
||||
.min(0)
|
||||
.max(Math.max(...Object.values(PlatformType))),
|
||||
])
|
||||
.describe(
|
||||
Object.entries(PlatformType)
|
||||
.map(([name, value]) => `${value} ${name}`)
|
||||
.join(', ')
|
||||
)
|
||||
|
||||
/**
|
||||
* One entry on the client's login screen, from `toCachedLogin` — an account ↔ platform
|
||||
* identity LINK, not an account. An account linked to two platforms yields one entry in
|
||||
* each of their pickers, each reporting the identity that picker was asked about.
|
||||
*/
|
||||
export const CachedLogin = z.object({
|
||||
platform: PlatformTypeSchema,
|
||||
platformId: z
|
||||
.string()
|
||||
.describe('The linked platform-native id — a SteamID64 for Steam, a user id for Meta'),
|
||||
accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'),
|
||||
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
|
||||
requirePassword: z
|
||||
.literal(false)
|
||||
.describe('Always false — platform ownership is the credential for a cached login'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The stubbed Oculus cached login served to sideloaded APKs. Same shape as `CachedLogin`,
|
||||
* but `requirePassword` is true — with no Meta SDK there is nothing to prove platform
|
||||
* ownership with, so the client falls through to username/password.
|
||||
*/
|
||||
export const FakeCachedLogin = CachedLogin.extend({
|
||||
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
|
||||
})
|
||||
|
||||
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
|
||||
export const OAuthError = z.object({
|
||||
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
|
||||
error_description: z.string(),
|
||||
})
|
||||
|
||||
/** Successful `POST /connect/token` body. */
|
||||
export const TokenResponse = z.object({
|
||||
access_token: z.string().describe('Signed JWT; `sub` is the account id'),
|
||||
expires_in: z.int().describe('Access-token lifetime in seconds (TOKEN_TTL_SECONDS)'),
|
||||
token_type: z.literal('Bearer'),
|
||||
refresh_token: z
|
||||
.string()
|
||||
.describe('Single-use; redeem via grant_type=refresh_token, which rotates it'),
|
||||
scope: z.string().describe('Space-separated granted scopes'),
|
||||
key: z.string().describe('@kludge Constant the client appears to require. Purpose unknown.'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /connect/token` form body — the union of every grant's fields, since
|
||||
* OpenAPI cannot express "these fields iff grant_type=X" without splitting the
|
||||
* endpoint. Per-grant requirements are spelled out in the route description.
|
||||
*/
|
||||
export const TokenRequest = z.object({
|
||||
grant_type: z
|
||||
.enum(['create_account', 'cached_login', 'refresh_token', 'password'])
|
||||
.describe('Anything unrecognised (including absent) is treated as a password grant'),
|
||||
account_id: z.string().optional().describe('Numeric account id, as a string'),
|
||||
username: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Password grant alternative to account_id; case-insensitive, trimmed'),
|
||||
password: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Required on a password grant. On create_account, sets the initial password'),
|
||||
platform: z.string().optional().describe('PlatformType as an integer string'),
|
||||
platform_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'On Steam, unverified and ignored in favour of the id the ticket carries. On Meta it is ' +
|
||||
'the id the nonce is validated against, so it must be the real (numeric) user id'
|
||||
),
|
||||
platform_auth: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Platform proof, required for cached_login and platform create_account, and used to ' +
|
||||
'link the identity on a password grant. Steam: `{"Ticket":"<hex>","AppId":…}`. ' +
|
||||
'Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
|
||||
),
|
||||
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
|
||||
device_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Client-chosen, unverified. Recorded on the account, never trusted'),
|
||||
device_class: z.string().optional().describe('Integer string; defaults to 0'),
|
||||
})
|
||||
|
||||
/** `POST /account/me/changepassword` form body. */
|
||||
export const ChangePasswordRequest = z.object({
|
||||
newPassword: z.string().describe('Required; empty is rejected'),
|
||||
oldPassword: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Must match when the account already has a password; empty when first setting it'),
|
||||
})
|
||||
|
||||
/** `POST /account/me/changepassword` response body. */
|
||||
export const ChangePasswordResponse = z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Spec for the `/role/:role/:id` lookups, which are identical apart from the role.
|
||||
* Both return a BARE JSON boolean rather than an object — the client reads the whole
|
||||
* body as a bool — and 404 an unknown player, mirroring the reference API.
|
||||
*/
|
||||
export function roleLookup(role: 'developer' | 'moderator') {
|
||||
return {
|
||||
tags: ['Roles'],
|
||||
summary: `Whether a player has the ${role} role`,
|
||||
description:
|
||||
`Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
|
||||
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
|
||||
`in the access token's \`role\` claim, so the client rarely needs this route.`,
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path' as const,
|
||||
required: true,
|
||||
description: 'Account id. A non-numeric value is treated as unknown (404).',
|
||||
schema: { type: 'string' as const },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(z.boolean(), `\`true\` if the player has the ${role} role`),
|
||||
404: { description: 'No such player (empty body)' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Bulk cached-login lookup form body: repeated `id=` fields. */
|
||||
export const PlatformIdsRequest = z.object({
|
||||
id: z.union([z.string(), z.array(z.string())]).describe('Repeated `id=` form fields'),
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Platform identity links on the shared `recflare` D1 database (owned by the `auth`
|
||||
* worker, migration 0007). One row per (platform, platform id, account): the Steam
|
||||
* user 76561…211 is linked to account 42, the Meta user 27061… is linked to account
|
||||
* 42 as well, and both let that player into that account without a password.
|
||||
*
|
||||
* This table replaced the single `platformId`/`platform` pair on the account blob as
|
||||
* the thing logins are decided from, because that pair could only hold ONE identity —
|
||||
* a player with a PC and a headset had to pick which device got a cached login. The
|
||||
* blob fields are kept as the account's *primary* identity (the first one linked) for
|
||||
* the account DTO and the refresh grant's claims; nothing authorizes off them.
|
||||
*
|
||||
* It is deliberately the ONE source of truth for both halves of a cached login: the
|
||||
* picker (`/cachedlogin/forplatformid`) lists the accounts this table links to an
|
||||
* identity, and the `cached_login` grant asks this table whether the account it was
|
||||
* handed is linked to the identity that was proven. When those two disagreed the
|
||||
* client was offered an account it could never log into — see the regression test.
|
||||
*
|
||||
* A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a
|
||||
* Meta-validated nonce). It is what turns "this platform user" into "may enter this
|
||||
* account with no password", so an unproven `platform_id` must never reach it.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
|
||||
export const PLATFORM_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS platform_account (
|
||||
account_id INTEGER NOT NULL,
|
||||
platform INTEGER NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
linked_at TEXT NOT NULL,
|
||||
PRIMARY KEY (platform, platform_id, account_id)
|
||||
)`,
|
||||
// The picker's lookup: "which accounts does this identity open?". Covered by the
|
||||
// primary key's leading columns, so no separate index is needed for it.
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id)`,
|
||||
// Lookup by bare platform id, across platforms — the bulk (friends) route, which
|
||||
// resolves ids it has no platform for.
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* The one-time backfill 0007 ran after creating the table: every identity already bound
|
||||
* to an account became a link, so nobody lost their cached login at deploy. It has run;
|
||||
* this exists so a test can still exercise it, which is the only coverage that legacy
|
||||
* blob-bound accounts get a link at all.
|
||||
*
|
||||
* `platform` is COALESCEd to 0 because nothing ever defaulted that field — an account
|
||||
* can carry a platformId with no platform recorded, and back when Steam was the only
|
||||
* verifiable platform an unset one *was* Steam.
|
||||
*
|
||||
* NOT byte-identical to the migration any more, deliberately. 0007 selected the
|
||||
* `account.platform_id` generated column; 0008 drops it, so that text is unrunnable
|
||||
* against the head schema the tests build. This selects the blob directly instead —
|
||||
* the same values, since the dropped column was DEFINED as
|
||||
* `json_extract(data, '$.platformId')`. 0007 is left exactly as it ran on prod.
|
||||
*/
|
||||
export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||
SELECT
|
||||
account_id,
|
||||
COALESCE(json_extract(data, '$.platform'), 0),
|
||||
json_extract(data, '$.platformId'),
|
||||
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
|
||||
FROM account
|
||||
WHERE json_extract(data, '$.platformId') IS NOT NULL
|
||||
AND json_extract(data, '$.platformId') <> ''`
|
||||
|
||||
/** One account ↔ platform identity link. */
|
||||
export interface PlatformLink {
|
||||
accountId: number
|
||||
platform: number
|
||||
platformId: string
|
||||
/** ISO-8601 time the link was made. */
|
||||
linkedAt: string
|
||||
}
|
||||
|
||||
interface LinkRow {
|
||||
accountId: number
|
||||
platform: number
|
||||
platformId: string
|
||||
linkedAt: string
|
||||
}
|
||||
|
||||
const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS platformId,
|
||||
linked_at AS linkedAt FROM platform_account`
|
||||
|
||||
/**
|
||||
* Link a verified platform identity to an account. Idempotent — re-logging in on the
|
||||
* same platform doesn't churn the row, and `linkedAt` keeps the time of the FIRST
|
||||
* link. Returns true when this created a new link.
|
||||
*
|
||||
* Callers must pass an identity the platform itself proved. Nothing in here can tell
|
||||
* a verified id from a spoofed one.
|
||||
*/
|
||||
export async function linkPlatformIdentity(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<boolean> {
|
||||
if (platformId === '') return false
|
||||
const res = await db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
)
|
||||
.bind(accountId, platform, platformId, new Date().toISOString())
|
||||
.run()
|
||||
return res.meta.changes > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts a platform identity opens — what the login-screen picker lists.
|
||||
* Ordered oldest link first so the list is stable between launches (D1 row order
|
||||
* isn't). Empty id yields nothing rather than matching every link.
|
||||
*/
|
||||
export async function getLinksForPlatformIdentity(
|
||||
db: D1Database,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<PlatformLink[]> {
|
||||
if (platformId === '') return []
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
|
||||
)
|
||||
.bind(platform, platformId)
|
||||
.all<LinkRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Links for a bare platform id, whatever platform it belongs to. For the bulk
|
||||
* (friends-resolution) lookup, which posts ids with no platform alongside them, and
|
||||
* for the single-id route when the client sends a non-numeric platform.
|
||||
*/
|
||||
export async function getLinksForPlatformId(
|
||||
db: D1Database,
|
||||
platformId: string
|
||||
): Promise<PlatformLink[]> {
|
||||
if (platformId === '') return []
|
||||
const { results } = await db
|
||||
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
|
||||
.bind(platformId)
|
||||
.all<LinkRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/** Every platform identity linked to an account (a player's PC and headset, say). */
|
||||
export async function getLinksForAccount(
|
||||
db: D1Database,
|
||||
accountId: number
|
||||
): Promise<PlatformLink[]> {
|
||||
const { results } = await db
|
||||
.prepare(`${SELECT_LINK} WHERE account_id = ?1 ORDER BY linked_at, platform`)
|
||||
.bind(accountId)
|
||||
.all<LinkRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account is linked to this platform identity — the single check the
|
||||
* `cached_login` grant authorizes on. An account with no link for the presented
|
||||
* identity cannot be cached-logged-into and must use a password.
|
||||
*/
|
||||
export async function isPlatformIdentityLinked(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<boolean> {
|
||||
if (platformId === '') return false
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT 1 AS ok FROM platform_account
|
||||
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
|
||||
)
|
||||
.bind(accountId, platform, platformId)
|
||||
.first<{ ok: number }>()
|
||||
return row !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* How many accounts one platform identity already opens — the count both signup caps
|
||||
* and link caps are enforced against, so an identity can't accumulate accounts by
|
||||
* creating them under the cap and then linking more in.
|
||||
*/
|
||||
export async function countAccountsForPlatformIdentity(
|
||||
db: D1Database,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<number> {
|
||||
if (platformId === '') return 0
|
||||
const row = await db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM platform_account WHERE platform = ?1 AND platform_id = ?2`)
|
||||
.bind(platform, platformId)
|
||||
.first<{ n: number }>()
|
||||
return row?.n ?? 0
|
||||
}
|
||||
+17
-30
@@ -1,21 +1,22 @@
|
||||
/**
|
||||
* Refresh-token storage on the shared `recflare` D1 database (owned by the `auth`
|
||||
* worker, migration 0003). Only a SHA-256 hash of each token is stored — never the
|
||||
* raw value — alongside the account + platform needed to re-mint an access token,
|
||||
* and an absolute expiry. Tokens are single-use: redeeming one deletes it, so a
|
||||
* fresh token is issued each refresh (rotation) and a replayed token stops working.
|
||||
* raw value — alongside the account it logs in and an absolute expiry. Tokens are
|
||||
* single-use: redeeming one deletes it, so a fresh token is issued each refresh
|
||||
* (rotation) and a replayed token stops working.
|
||||
*
|
||||
* The platform identity is NOT kept here (dropped in 0006); a refreshed token takes
|
||||
* it from the account, which is where the bound identity actually lives.
|
||||
*/
|
||||
|
||||
/** Refresh tokens live this long (s) before the client must log in again. */
|
||||
export const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days
|
||||
|
||||
/** Schema DDL (mirror of migrations/0003_refresh_tokens.sql). */
|
||||
/** Schema DDL (mirror of migrations/0003_refresh_tokens.sql + 0006). */
|
||||
export const REFRESH_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)`,
|
||||
@@ -23,13 +24,6 @@ export const REFRESH_SCHEMA_DDL: string[] = [
|
||||
`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at)`,
|
||||
]
|
||||
|
||||
/** The login context needed to re-mint an access token from a refresh token. */
|
||||
export interface RefreshContext {
|
||||
accountId: number
|
||||
platform: string
|
||||
platformId: string
|
||||
}
|
||||
|
||||
/** SHA-256 hex of the token. Tokens are high-entropy random, so no salt is needed. */
|
||||
async function hashToken(token: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))
|
||||
@@ -37,47 +31,40 @@ async function hashToken(token: string): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint and persist a new refresh token for the given login, returning the raw
|
||||
* Mint and persist a new refresh token for the given account, returning the raw
|
||||
* token — the only moment it exists in plaintext (only its hash is stored).
|
||||
*/
|
||||
export async function issueRefreshToken(db: D1Database, ctx: RefreshContext): Promise<string> {
|
||||
export async function issueRefreshToken(db: D1Database, accountId: number): Promise<string> {
|
||||
const token = `${crypto.randomUUID()}`
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO refresh_tokens (token_hash, account_id, platform, platform_id, created_at, expires_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`
|
||||
)
|
||||
.bind(
|
||||
await hashToken(token),
|
||||
ctx.accountId,
|
||||
ctx.platform,
|
||||
ctx.platformId,
|
||||
now,
|
||||
now + REFRESH_TTL_SECONDS
|
||||
`INSERT INTO refresh_tokens (token_hash, account_id, created_at, expires_at)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
)
|
||||
.bind(await hashToken(token), accountId, now, now + REFRESH_TTL_SECONDS)
|
||||
.run()
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem a refresh token: if it exists and hasn't expired, delete it (single-use
|
||||
* rotation) and return its login context; otherwise return null. The delete is
|
||||
* rotation) and return the account it logs in; otherwise return null. The delete is
|
||||
* atomic (`DELETE ... RETURNING`), so a token can't be redeemed twice — a
|
||||
* concurrent second attempt finds no row. An expired token is deleted and rejected.
|
||||
*/
|
||||
export async function consumeRefreshToken(
|
||||
db: D1Database,
|
||||
token: string
|
||||
): Promise<RefreshContext | null> {
|
||||
): Promise<number | null> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const row = await db
|
||||
.prepare(
|
||||
`DELETE FROM refresh_tokens WHERE token_hash = ?1
|
||||
RETURNING account_id AS accountId, platform, platform_id AS platformId, expires_at AS expiresAt`
|
||||
RETURNING account_id AS accountId, expires_at AS expiresAt`
|
||||
)
|
||||
.bind(await hashToken(token))
|
||||
.first<{ accountId: number; platform: string; platformId: string; expiresAt: number }>()
|
||||
.first<{ accountId: number; expiresAt: number }>()
|
||||
if (!row || row.expiresAt < now) return null
|
||||
return { accountId: row.accountId, platform: row.platform, platformId: row.platformId }
|
||||
return row.accountId
|
||||
}
|
||||
|
||||
@@ -4,10 +4,22 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import '../../auth.app'
|
||||
|
||||
import { getAccountsByDeviceId, PRESENCE_SCHEMA_DDL, SCHEMA_DDL } from '@repo/domain'
|
||||
import {
|
||||
getAccountsByDeviceId,
|
||||
hashPassword,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { isLinkedToPlatformIdentity } from '../../auth.app'
|
||||
import { hashPassword } from '../../password'
|
||||
import {
|
||||
getLinksForAccount,
|
||||
linkPlatformIdentity,
|
||||
PLATFORM_BACKFILL_SQL,
|
||||
PLATFORM_SCHEMA_DDL,
|
||||
} from '../../platform-db'
|
||||
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
@@ -25,14 +37,28 @@ const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
|
||||
// accounts the login tests authenticate as (42, 77).
|
||||
const LOGIN_PASSWORD = 'correct-horse'
|
||||
|
||||
// Meta (Oculus) logins verify their nonce by calling graph.oculus.com authenticated
|
||||
// as the app, so the tests seed an app secret and stub that call — see metaLogin.
|
||||
const META_APP_SECRET = 'test-meta-app-secret'
|
||||
const META_APP_ID = '1232175103309633'
|
||||
const META_USER_ID = '27061366730207360'
|
||||
const META_NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
|
||||
/** Set in beforeAll; needed to overwrite the secret in the not-configured test. */
|
||||
let metaSecretId: string
|
||||
|
||||
// Apply the accounts schema so create_account can persist (mirrors the migration),
|
||||
// and seed the Orientation room (owned by the rooms worker) so signup can place
|
||||
// the new player there.
|
||||
beforeAll(async () => {
|
||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||
// The Meta app secret, likewise — a Meta login is refused outright without one.
|
||||
metaSecretId = await adminSecretsStore(env.META_APP_SECRET).create(META_APP_SECRET)
|
||||
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Platform identity links — one account can hold several (a PC and a headset), and
|
||||
// this table is what both the picker and the cached_login grant read.
|
||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
|
||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
@@ -43,22 +69,17 @@ beforeAll(async () => {
|
||||
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
|
||||
.run()
|
||||
}
|
||||
await env.DB.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS room (
|
||||
data TEXT NOT NULL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
RoomId: 13,
|
||||
Name: 'Orientation',
|
||||
IsDorm: false,
|
||||
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
|
||||
})
|
||||
)
|
||||
.run()
|
||||
// The rooms worker's schema (room + interaction) — reading a room aggregates its
|
||||
// cheer/favorite Stats from `interaction`, so both tables have to be here.
|
||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Subrooms live in their own table; seed the Orientation room and split its subroom into it.
|
||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: 13,
|
||||
Name: 'Orientation',
|
||||
IsDorm: false,
|
||||
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
|
||||
})
|
||||
})
|
||||
|
||||
/** Decode a JWT payload (no verification) for asserting claims. */
|
||||
@@ -97,6 +118,51 @@ async function postToken(
|
||||
return { status: res.status, json: (await res.json()) as Record<string, unknown> }
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a Meta grant to /connect/token with graph.oculus.com stubbed to answer
|
||||
* `is_valid`. The worker runs in this isolate, so replacing the global fetch is what
|
||||
* stands in for Meta — `verifyMetaNonce` resolves `globalThis.fetch` per call for
|
||||
* exactly this reason. Returns the graph requests the worker made alongside the
|
||||
* response, so a test can assert WHICH user id the nonce was validated against.
|
||||
*/
|
||||
async function metaLogin(
|
||||
body: string,
|
||||
isValid: boolean
|
||||
): Promise<{ status: number; json: Record<string, unknown>; graphCalls: URLSearchParams[] }> {
|
||||
const graphCalls: URLSearchParams[] = []
|
||||
const realFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (url: string, init?: { body?: string }) => {
|
||||
if (url.startsWith('https://graph.oculus.com/')) {
|
||||
graphCalls.push(new URLSearchParams(init?.body ?? ''))
|
||||
return Response.json({ is_valid: isValid })
|
||||
}
|
||||
return realFetch(url, init)
|
||||
}) as unknown as typeof fetch
|
||||
try {
|
||||
return { ...(await postToken(body)), graphCalls }
|
||||
} finally {
|
||||
globalThis.fetch = realFetch
|
||||
}
|
||||
}
|
||||
|
||||
/** GET a JSON route on the worker and parse the body as `T`. */
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const res = await exports.default.fetch(`${ORIGIN}${path}`)
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
/** The picker entries a platform identity yields, as the client sees them. */
|
||||
function cachedLogins(platform: number, id: string) {
|
||||
return getJson<Array<Record<string, unknown> & { accountId: number; platform: number }>>(
|
||||
`/cachedlogin/forplatformid/${platform}/${id}`
|
||||
)
|
||||
}
|
||||
|
||||
/** The `platform_auth` payload a Meta client posts, as observed from a live login. */
|
||||
function metaPlatformAuth(): string {
|
||||
return JSON.stringify({ Nonce: META_NONCE, AppId: META_APP_ID, Source: 'logged in user' })
|
||||
}
|
||||
|
||||
/** POST a form-urlencoded body to changepassword with an optional bearer token. */
|
||||
function changePassword(body: string, token?: string): Promise<Response> {
|
||||
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
|
||||
@@ -118,16 +184,42 @@ describe('auth worker routes', () => {
|
||||
expect(await res.text()).toBe('"AA=="')
|
||||
})
|
||||
|
||||
test('GET /cachedlogin/forplatformid/:platform/:id returns [] (no cached login)', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/abc123`)
|
||||
test.each([
|
||||
['0 (Steam)', 0],
|
||||
['1 (Meta)', 1],
|
||||
])(
|
||||
'GET /cachedlogin/forplatformid/%s/:id returns [] for an unknown id',
|
||||
async (_label, platform) => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/cachedlogin/forplatformid/${platform}/abc123`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
// The one stubbed identity: `1/1` consults nothing and always answers the canned
|
||||
// entry, which is how a sideloaded APK (no Meta SDK, so no real identity) gets off
|
||||
// the platform login screen and onto username/password.
|
||||
test('GET /cachedlogin/forplatformid/1/1 returns the canned Oculus entry', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/1`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
expect(await res.json()).toEqual([
|
||||
{
|
||||
platform: 1,
|
||||
platformId: '1',
|
||||
accountId: 1,
|
||||
lastLoginTime: '2026-07-19T17:13:29.225Z',
|
||||
requirePassword: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
// Only Steam (platform 0) can be verified (via its signed platform_auth ticket),
|
||||
// so every OTHER platform is rejected on the platform-authenticated grants — we
|
||||
// won't bind or authorize an identity we can't prove.
|
||||
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
|
||||
// Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth
|
||||
// ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected
|
||||
// on the platform-authenticated grants: we won't bind or authorize an identity we
|
||||
// can't prove.
|
||||
test.each([2, 3, 4, 5, 6, 7, 8])(
|
||||
'create_account rejects unverifiable platform %i',
|
||||
async (platform) => {
|
||||
const res = await postToken(
|
||||
@@ -135,11 +227,11 @@ describe('auth worker routes', () => {
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error).toBe('invalid_grant')
|
||||
expect(res.json.error_description).toContain('only Steam')
|
||||
expect(res.json.error_description).toContain('only Steam and Meta')
|
||||
}
|
||||
)
|
||||
|
||||
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
|
||||
test.each([2, 3, 4, 5, 6, 7, 8])(
|
||||
'cached_login rejects unverifiable platform %i',
|
||||
async (platform) => {
|
||||
const res = await postToken(
|
||||
@@ -147,7 +239,7 @@ describe('auth worker routes', () => {
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error).toBe('invalid_grant')
|
||||
expect(res.json.error_description).toContain('only Steam')
|
||||
expect(res.json.error_description).toContain('only Steam and Meta')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -171,6 +263,109 @@ describe('auth worker routes', () => {
|
||||
expect(res.json.error_description).toContain('platform_auth')
|
||||
})
|
||||
|
||||
test('Meta create_account requires a platform_auth nonce', async () => {
|
||||
// platform=1 with no nonce must not bind the spoofable platform_id field.
|
||||
const res = await postToken(`grant_type=create_account&platform=1&platform_id=${META_USER_ID}`)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error).toBe('invalid_grant')
|
||||
expect(res.json.error_description).toContain('platform_auth')
|
||||
})
|
||||
|
||||
test('Meta create_account binds the id Meta validated the nonce against', async () => {
|
||||
const res = await metaLogin(
|
||||
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}&device_id=meta-device`,
|
||||
true
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// The nonce was validated against the posted user id, authenticated as the app.
|
||||
expect(res.graphCalls).toHaveLength(1)
|
||||
expect(res.graphCalls[0].get('nonce')).toBe(META_NONCE)
|
||||
expect(res.graphCalls[0].get('user_id')).toBe(META_USER_ID)
|
||||
expect(res.graphCalls[0].get('access_token')).toBe(`OC|${META_APP_ID}|${META_APP_SECRET}`)
|
||||
|
||||
// The account is bound to platform 1 with that id — which is what makes the
|
||||
// cached-login picker offer it, and the cached_login grant accept it.
|
||||
const payload = decodePayload(res.json.access_token as string)
|
||||
const accountId = Number(payload.sub)
|
||||
const linked = await cachedLogins(1, META_USER_ID)
|
||||
expect(linked).toContainEqual(
|
||||
expect.objectContaining({ accountId, platform: 1, platformId: META_USER_ID })
|
||||
)
|
||||
// Platform ownership is the credential, so the client is not asked for a password.
|
||||
expect(linked.every((a) => a.requirePassword === false)).toBe(true)
|
||||
})
|
||||
|
||||
test('Meta create_account is rejected when Meta does not vouch for the nonce', async () => {
|
||||
const res = await metaLogin(
|
||||
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
false
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error).toBe('invalid_grant')
|
||||
expect(res.json.error_description).toContain('platform_auth')
|
||||
})
|
||||
|
||||
test('Meta cached_login logs into the linked account with no password', async () => {
|
||||
const userId = '27061366730209999'
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 5150,
|
||||
username: 'MetaPlayer',
|
||||
platform: 1,
|
||||
platformId: userId,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await linkPlatformIdentity(env.DB, 5150, 1, userId)
|
||||
const res = await metaLogin(
|
||||
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${userId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.graphCalls[0].get('user_id')).toBe(userId)
|
||||
const payload = decodePayload(res.json.access_token as string)
|
||||
expect(payload.sub).toBe('5150')
|
||||
})
|
||||
|
||||
test('a Meta user id cannot log into an account it is not linked to', async () => {
|
||||
// The Meta account seeded above, claimed by a different (but genuinely proven)
|
||||
// Meta user. Even with a nonce Meta vouches for, the identity has to be one the
|
||||
// account is actually linked to.
|
||||
const res = await metaLogin(
|
||||
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${META_USER_ID}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toContain('no linked account')
|
||||
})
|
||||
|
||||
test('a Meta login is refused (500) when META_APP_SECRET is unset', async () => {
|
||||
// An operator misconfiguration, not a bad credential: without the secret no nonce
|
||||
// can be validated, and the alternative — trusting the posted platform_id — would
|
||||
// let anyone log into any Meta-linked account by naming its user id.
|
||||
const admin = adminSecretsStore(env.META_APP_SECRET)
|
||||
await admin.update('', metaSecretId)
|
||||
try {
|
||||
const res = await metaLogin(
|
||||
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(res.status).toBe(500)
|
||||
expect(res.json.error).toBe('server_error')
|
||||
// Nothing was asked of Meta, and nothing was trusted.
|
||||
expect(res.graphCalls).toHaveLength(0)
|
||||
} finally {
|
||||
await admin.update(META_APP_SECRET, metaSecretId)
|
||||
}
|
||||
})
|
||||
|
||||
test('cachedlogin/forplatformid returns the DTO for a bound (Steam) account', async () => {
|
||||
// Seed a Steam-linked account directly (a real create_account needs a live
|
||||
// ticket); assert the picker projects the CachedLogin DTO the client expects.
|
||||
@@ -186,6 +381,7 @@ describe('auth worker routes', () => {
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await linkPlatformIdentity(env.DB, 31380, 0, steamId)
|
||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([
|
||||
@@ -199,32 +395,69 @@ describe('auth worker routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('a Steam-linked account with no stored `platform` field still cached-logs in', async () => {
|
||||
// Regression: nothing defaults an account's `platform` (see defaultAccount), so a
|
||||
// Steam-linked account can carry a platformId with no platform. The picker offered
|
||||
// such an account (it treats a missing platform as Steam) while the cached_login
|
||||
// grant rejected it — "no linked account for this platform identity" forever.
|
||||
// Both now run the same check.
|
||||
test('one account, a Steam and a Meta identity: both pickers offer it', async () => {
|
||||
// The point of the link table. The same account is reachable from the PC and from
|
||||
// the headset, and each picker reports the identity IT was asked about — that's
|
||||
// what the client posts back on the cached_login grant.
|
||||
const steamId = '76561197962463777'
|
||||
const metaId = '27061366730207777'
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 6200,
|
||||
username: 'CrossPlatform',
|
||||
platform: 0,
|
||||
platformId: steamId,
|
||||
lastLoginTime: '2026-08-01T10:00:00.000Z',
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await linkPlatformIdentity(env.DB, 6200, 0, steamId)
|
||||
await linkPlatformIdentity(env.DB, 6200, 1, metaId)
|
||||
|
||||
const onSteam = await cachedLogins(0, steamId)
|
||||
const onMeta = await cachedLogins(1, metaId)
|
||||
|
||||
expect(onSteam).toEqual([
|
||||
expect.objectContaining({ accountId: 6200, platform: 0, platformId: steamId }),
|
||||
])
|
||||
expect(onMeta).toEqual([
|
||||
expect.objectContaining({ accountId: 6200, platform: 1, platformId: metaId }),
|
||||
])
|
||||
|
||||
// And the grant accepts both, without a password.
|
||||
const viaMeta = await metaLogin(
|
||||
`grant_type=cached_login&account_id=6200&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(viaMeta.status).toBe(200)
|
||||
expect(decodePayload(viaMeta.json.access_token as string).sub).toBe('6200')
|
||||
})
|
||||
|
||||
test('the picker and the cached_login grant read the same table', async () => {
|
||||
// Regression: the picker used to derive links from the account blob (treating a
|
||||
// missing `platform` as Steam) while the grant ran its own check, so the client
|
||||
// could be handed an account_id that answered "no linked account" forever. Both
|
||||
// now read platform_account, which is why an account with a stale blob identity
|
||||
// is NOT offered — and, since it isn't offered, never rejected either.
|
||||
const steamId = '76561197962463211'
|
||||
const account = { platformId: steamId } // no `platform` field
|
||||
|
||||
// The grant now accepts it — this is what was returning invalid_grant.
|
||||
expect(isLinkedToPlatformIdentity(account, 0, steamId)).toBe(true)
|
||||
|
||||
// The identity is still the credential: another SteamID, an account with no
|
||||
// platform identity, and an account bound to a different platform are all refused.
|
||||
expect(isLinkedToPlatformIdentity(account, 0, '76561197962463299')).toBe(false)
|
||||
expect(isLinkedToPlatformIdentity({}, 0, steamId)).toBe(false)
|
||||
expect(isLinkedToPlatformIdentity({ ...account, platform: 3 }, 0, steamId)).toBe(false)
|
||||
|
||||
// And the picker offers exactly the accounts the grant accepts.
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId }))
|
||||
.run()
|
||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
|
||||
const offered = (await res.json()) as Array<{ accountId: number; platform: number }>
|
||||
expect(offered.map((a) => a.accountId)).toContain(8)
|
||||
expect(offered.find((a) => a.accountId === 8)?.platform).toBe(0)
|
||||
|
||||
// No link row yet: not offered.
|
||||
const before = await cachedLogins(0, steamId)
|
||||
expect(before.map((a) => a.accountId)).not.toContain(8)
|
||||
|
||||
// The 0007 backfill is what gives accounts like this one — bound before the link
|
||||
// table existed, and carrying no `platform` field at all — their link.
|
||||
await env.DB.prepare(PLATFORM_BACKFILL_SQL).run()
|
||||
|
||||
const after = await cachedLogins(0, steamId)
|
||||
expect(after.map((a) => a.accountId)).toContain(8)
|
||||
// COALESCEd to Steam, which is what an unset platform meant.
|
||||
expect(after.find((a) => a.accountId === 8)?.platform).toBe(0)
|
||||
})
|
||||
|
||||
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
|
||||
@@ -257,9 +490,28 @@ describe('auth worker routes', () => {
|
||||
expect(payload.iss).toBe('https://auth.recflare.net')
|
||||
expect(payload.aud).toBe('https://auth.recflare.net')
|
||||
expect(payload.role).toContain('gameClient')
|
||||
// A plain account carries only the base role — no elevated roles.
|
||||
expect(payload.role).not.toContain('developer')
|
||||
expect(payload.role).not.toContain('moderator')
|
||||
expect(payload.scope).toContain('rn.api')
|
||||
})
|
||||
|
||||
test('POST /connect/token stamps developer/moderator roles into the token', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 91,
|
||||
username: 'StaffPlayer',
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
isDeveloper: true,
|
||||
isModerator: true,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
const payload = await tokenFor(`account_id=91&password=${LOGIN_PASSWORD}`)
|
||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
|
||||
})
|
||||
|
||||
test('POST /connect/token 400s when no account_id is posted (never defaults to 1)', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' })
|
||||
expect(res.status).toBe(400)
|
||||
@@ -478,9 +730,17 @@ describe('auth worker routes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /connect/token maps the platform int to its enum name', async () => {
|
||||
const payload = await tokenFor(`account_id=42&platform=0&password=${LOGIN_PASSWORD}`)
|
||||
expect(payload.platform).toBe('Steam')
|
||||
test('POST /connect/token carries the platform int on the token', async () => {
|
||||
const payload = await tokenFor(`account_id=42&platform=5&password=${LOGIN_PASSWORD}`)
|
||||
expect(payload.platform).toBe(5)
|
||||
// `rn.plat` is the same int, not a pinned 0.
|
||||
expect(payload['rn.plat']).toBe(5)
|
||||
})
|
||||
|
||||
test('POST /connect/token defaults the platform claim when none is posted', async () => {
|
||||
const payload = await tokenFor(`account_id=42&password=${LOGIN_PASSWORD}`)
|
||||
expect(payload.platform).toBe(0)
|
||||
expect(payload['rn.plat']).toBe(0)
|
||||
})
|
||||
|
||||
test('POST /connect/token returns a refresh_token that redeems for a new token', async () => {
|
||||
@@ -496,15 +756,195 @@ describe('auth worker routes', () => {
|
||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
|
||||
)
|
||||
expect(refreshed.status).toBe(200)
|
||||
// A fresh access token for the same account, carrying the stored platform.
|
||||
const payload = decodePayload(refreshed.json.access_token as string)
|
||||
expect(payload.sub).toBe('42')
|
||||
expect(payload.platform).toBe('Steam')
|
||||
expect(payload.platform_id).toBe('steam-123')
|
||||
// The platform identity comes off the account, not the refresh token. Account 42
|
||||
// has none bound (the posted `platform_id` above was never Steam-verified, so it
|
||||
// was never written), so the refreshed token carries no identity either.
|
||||
expect(payload.platform).toBe(0)
|
||||
expect(payload.platform_id).toBe('')
|
||||
// The refresh token is rotated (single-use), so a new one is returned.
|
||||
expect(refreshed.json.refresh_token).not.toBe(refreshToken)
|
||||
})
|
||||
|
||||
test('a refreshed token carries the identity bound to the account', async () => {
|
||||
// A Steam-bound account: only a verified ticket writes `platformId`, so seed it
|
||||
// directly rather than posting an (unverified) platform_id on the login.
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 43,
|
||||
username: 'Player43',
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
platform: 0,
|
||||
platformId: 'steam-123',
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
const login = await postToken(`account_id=43&password=${LOGIN_PASSWORD}`)
|
||||
expect(login.status).toBe(200)
|
||||
const refreshed = await postToken(
|
||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(login.json.refresh_token as string)}`
|
||||
)
|
||||
expect(refreshed.status).toBe(200)
|
||||
const payload = decodePayload(refreshed.json.access_token as string)
|
||||
expect(payload.sub).toBe('43')
|
||||
expect(payload.platform).toBe(0)
|
||||
expect(payload.platform_id).toBe('steam-123')
|
||||
})
|
||||
|
||||
// A password login is how a player who already has an account signs in on a NEW
|
||||
// device. The client posts its platform proof alongside the password, and linking
|
||||
// the two is what turns the next launch on that device into a cached login.
|
||||
describe('password grant links the platform identity it proves', () => {
|
||||
/** Seed an account with LOGIN_PASSWORD set and no platform identity at all. */
|
||||
async function seedPasswordAccount(id: number, username: string) {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: id,
|
||||
username,
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
})
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
test('a verified Meta login on an existing account links it, and cached login follows', async () => {
|
||||
// Exactly the client's flow: an account made elsewhere, signed into on a headset
|
||||
// with username + password, with the Meta nonce riding along.
|
||||
await seedPasswordAccount(7100, 'djdevin')
|
||||
const metaId = '27061366730201234'
|
||||
const login = await metaLogin(
|
||||
`grant_type=password&username=djdevin&password=${LOGIN_PASSWORD}` +
|
||||
`&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(login.status).toBe(200)
|
||||
expect(decodePayload(login.json.access_token as string).sub).toBe('7100')
|
||||
// The nonce was validated against the id being linked — an unproven id is never
|
||||
// linked, since a link is a password-free way into the account.
|
||||
expect(login.graphCalls[0].get('user_id')).toBe(metaId)
|
||||
|
||||
// The headset now gets a cached login: offered by the picker…
|
||||
const offered = await cachedLogins(1, metaId)
|
||||
expect(offered.map((a) => a.accountId)).toContain(7100)
|
||||
|
||||
// …and accepted by the grant, with no password.
|
||||
const cached = await metaLogin(
|
||||
`grant_type=cached_login&account_id=7100&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(cached.status).toBe(200)
|
||||
})
|
||||
|
||||
test('the first identity linked becomes the account primary; later ones just link', async () => {
|
||||
await seedPasswordAccount(7101, 'multiplatform')
|
||||
const metaId = '27061366730205678'
|
||||
await metaLogin(
|
||||
`grant_type=password&username=multiplatform&password=${LOGIN_PASSWORD}` +
|
||||
`&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
// The blob's primary identity was empty, so the first link fills it in — this is
|
||||
// what the account DTO and the refresh grant's claims report.
|
||||
const account = (await env.DB.prepare(
|
||||
'SELECT data FROM account WHERE account_id = 7101'
|
||||
).first<{ data: string }>())!
|
||||
expect(JSON.parse(account.data)).toMatchObject({ platform: 1, platformId: metaId })
|
||||
|
||||
// A second identity on another platform links without disturbing the primary.
|
||||
await linkPlatformIdentity(env.DB, 7101, 0, '76561197962465678')
|
||||
const links = await getLinksForAccount(env.DB, 7101)
|
||||
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
|
||||
[1, metaId],
|
||||
[0, '76561197962465678'],
|
||||
])
|
||||
})
|
||||
|
||||
test('an unverified platform_auth logs in but links nothing', async () => {
|
||||
// The password already proved who this is, so the login stands — but a link is a
|
||||
// password-free way in, and this identity was never proven, so none is written.
|
||||
await seedPasswordAccount(7102, 'unproven')
|
||||
const metaId = '27061366730209876'
|
||||
const login = await metaLogin(
|
||||
`grant_type=password&username=unproven&password=${LOGIN_PASSWORD}` +
|
||||
`&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
false // Meta rejects the nonce
|
||||
)
|
||||
expect(login.status).toBe(200)
|
||||
expect(await getLinksForAccount(env.DB, 7102)).toEqual([])
|
||||
})
|
||||
|
||||
test('a login with no platform_auth links nothing and asks Meta nothing', async () => {
|
||||
await seedPasswordAccount(7103, 'noproof')
|
||||
const login = await metaLogin(
|
||||
`grant_type=password&username=noproof&password=${LOGIN_PASSWORD}` +
|
||||
`&platform=1&platform_id=27061366730204321`,
|
||||
true
|
||||
)
|
||||
expect(login.status).toBe(200)
|
||||
expect(login.graphCalls).toHaveLength(0)
|
||||
expect(await getLinksForAccount(env.DB, 7103)).toEqual([])
|
||||
})
|
||||
|
||||
test('a sideloaded APK (platform id 1) logs in but is never linked', async () => {
|
||||
// The sideload placeholder identifies nobody — every sideloaded headset reports
|
||||
// `1`, so a link on it would be a password-free way into this account from any of
|
||||
// them. The password login still stands; Meta is never even asked, since there is
|
||||
// nothing there to validate.
|
||||
await seedPasswordAccount(7105, 'sideloader')
|
||||
const login = await metaLogin(
|
||||
`grant_type=password&username=sideloader&password=${LOGIN_PASSWORD}` +
|
||||
`&platform=1&platform_id=1` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true // even with Meta answering yes to everything
|
||||
)
|
||||
expect(login.status).toBe(200)
|
||||
expect(login.graphCalls).toHaveLength(0)
|
||||
expect(await getLinksForAccount(env.DB, 7105)).toEqual([])
|
||||
// And so the picker never offers this account off the placeholder — only the
|
||||
// canned stub entry is there.
|
||||
expect((await cachedLogins(1, '1')).map((a) => a.accountId)).toEqual([1])
|
||||
})
|
||||
|
||||
test('linking obeys the per-identity account cap, without failing the login', async () => {
|
||||
// Otherwise the signup cap would be trivially bypassable: create accounts with a
|
||||
// password, then link the capped identity into all of them.
|
||||
const metaId = '27061366730203333'
|
||||
for (let i = 0; i < 3; i++) await linkPlatformIdentity(env.DB, 8000 + i, 1, metaId)
|
||||
|
||||
await seedPasswordAccount(8100, 'overcap')
|
||||
const login = await metaLogin(
|
||||
`grant_type=password&username=overcap&password=${LOGIN_PASSWORD}` +
|
||||
`&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
// The password was valid, so the player is logged in — they just don't get a
|
||||
// cached login on this account.
|
||||
expect(login.status).toBe(200)
|
||||
expect(await getLinksForAccount(env.DB, 8100)).toEqual([])
|
||||
})
|
||||
|
||||
test('re-logging in on the same device does not duplicate the link', async () => {
|
||||
await seedPasswordAccount(7104, 'repeatlogin')
|
||||
const metaId = '27061366730207654'
|
||||
const body =
|
||||
`grant_type=password&username=repeatlogin&password=${LOGIN_PASSWORD}` +
|
||||
`&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`
|
||||
await metaLogin(body, true)
|
||||
await metaLogin(body, true)
|
||||
expect(await getLinksForAccount(env.DB, 7104)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => {
|
||||
const login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`)
|
||||
const refreshToken = login.json.refresh_token as string
|
||||
@@ -569,14 +1009,142 @@ describe('auth worker routes', () => {
|
||||
expect(await rotate.json()).toEqual({ success: true })
|
||||
})
|
||||
|
||||
test('GET /role/developer/:id does not grant developer', async () => {
|
||||
test('GET /role/developer/:id returns a bare false for an un-flagged account', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: false })
|
||||
expect(await res.json()).toBe(false)
|
||||
})
|
||||
|
||||
test('GET /role/developer/:id returns a bare true when the account is flagged', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
|
||||
.run()
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/developer/4242`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(true)
|
||||
})
|
||||
|
||||
test('GET /role/developer/:id 404s for an unknown player', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/developer/99999`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /role/moderator/:id reflects the isModerator flag as a bare boolean', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4343, username: 'ModPlayer', isModerator: true }))
|
||||
.run()
|
||||
const granted = await exports.default.fetch(`${ORIGIN}/role/moderator/4343`)
|
||||
expect(await granted.json()).toBe(true)
|
||||
// An account without the flag (42) is not a moderator.
|
||||
const plain = await exports.default.fetch(`${ORIGIN}/role/moderator/42`)
|
||||
expect(await plain.json()).toBe(false)
|
||||
// Unknown player → 404.
|
||||
expect((await exports.default.fetch(`${ORIGIN}/role/moderator/99999`)).status).toBe(404)
|
||||
})
|
||||
|
||||
test('unknown path returns 404', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
const spec = (await res.json()) as {
|
||||
openapi: string
|
||||
paths: Record<string, Record<string, { summary?: string }>>
|
||||
}
|
||||
expect(spec.openapi).toMatch(/^3\.1/)
|
||||
|
||||
// The spec route hides itself.
|
||||
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'GET /cachedlogin/forplatformid/{platform}/{id}',
|
||||
'GET /eac/challenge',
|
||||
'GET /role/developer/{id}',
|
||||
'GET /role/moderator/{id}',
|
||||
'POST /account/me/changepassword',
|
||||
'POST /cachedlogin/forplatformids',
|
||||
'POST /connect/token',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
// documentation.
|
||||
for (const ops of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// The website is a browser origin calling these endpoints directly — the same ones the
|
||||
// game calls — instead of proxying them through `www`. That only works if the responses
|
||||
// carry CORS headers: without them the browser discards a perfectly good token response
|
||||
// and sign-in fails with nothing in any server log to explain it.
|
||||
describe('CORS', () => {
|
||||
test('answers the preflight the browser sends before a token grant', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
new Request(`${ORIGIN}/connect/token`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin: 'https://www.example.com',
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'content-type',
|
||||
},
|
||||
}),
|
||||
env
|
||||
)
|
||||
expect(res.status).toBe(204)
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
|
||||
'content-type'
|
||||
)
|
||||
})
|
||||
|
||||
// The header has to be on the REAL response too, not just the preflight — and on a
|
||||
// refusal as much as a success, or a rejected sign-in reaches the page as an opaque
|
||||
// network error rather than "that password is incorrect".
|
||||
test('allows the origin on the response itself, refusals included', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
new Request(`${ORIGIN}/connect/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'https://www.example.com',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({ grant_type: 'password', username: 'nobody' }).toString(),
|
||||
}),
|
||||
env
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||
})
|
||||
|
||||
// The bearer header is what the SPA authenticates with, so it must be allowed by name
|
||||
// — a preflight that omits it makes every signed-in call fail.
|
||||
test('allows the Authorization header the SPA signs its calls with', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
new Request(`${ORIGIN}/account/me/changepassword`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin: 'https://www.example.com',
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'authorization',
|
||||
},
|
||||
}),
|
||||
env
|
||||
)
|
||||
expect(res.status).toBe(204)
|
||||
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
|
||||
'authorization'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { parseMetaPlatformAuth, verifyMetaNonce } from '../../meta-nonce'
|
||||
|
||||
// The payload shape a real Meta login posts, captured from a live client. `Source`
|
||||
// is informational and ignored; the AppId is Rec Room's Meta app.
|
||||
const NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
|
||||
const APP_ID = '1232175103309633'
|
||||
const USER_ID = '27061366730207360'
|
||||
const PLATFORM_AUTH = JSON.stringify({ Nonce: NONCE, AppId: APP_ID, Source: 'logged in user' })
|
||||
const APP_SECRET = 'test-app-secret'
|
||||
|
||||
/**
|
||||
* A fetch stub answering with `bodies` (one body, or one per attempt), recording every
|
||||
* request it was handed. Typed to what `verifyMetaNonce` actually passes — a string URL
|
||||
* and a string body — rather than the whole of `fetch`, then cast at the boundary.
|
||||
*/
|
||||
function stubFetch(bodies: unknown, status = 200) {
|
||||
const queue = Array.isArray(bodies) ? [...(bodies as unknown[])] : [bodies]
|
||||
const calls: Array<{ url: string; form: URLSearchParams }> = []
|
||||
const fetcher = (async (url: string, init?: { body?: string }) => {
|
||||
calls.push({ url, form: new URLSearchParams(init?.body ?? '') })
|
||||
const body = queue.length > 1 ? queue.shift() : queue[0]
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
return { fetcher, calls }
|
||||
}
|
||||
|
||||
describe('meta-nonce', () => {
|
||||
test('parses the platform_auth payload the client posts', () => {
|
||||
expect(parseMetaPlatformAuth(PLATFORM_AUTH)).toEqual({ nonce: NONCE, appId: APP_ID })
|
||||
})
|
||||
|
||||
test.each([
|
||||
['not json', 'nonsense'],
|
||||
['no nonce', JSON.stringify({ AppId: APP_ID })],
|
||||
['empty nonce', JSON.stringify({ Nonce: '', AppId: APP_ID })],
|
||||
['no app id', JSON.stringify({ Nonce: NONCE })],
|
||||
// The app id is interpolated into the graph access token, so a non-numeric one
|
||||
// is refused rather than sent.
|
||||
['non-numeric app id', JSON.stringify({ Nonce: NONCE, AppId: 'OC|evil' })],
|
||||
])('rejects a malformed payload (%s)', (_label, payload) => {
|
||||
expect(parseMetaPlatformAuth(payload)).toBeNull()
|
||||
})
|
||||
|
||||
test('validates the nonce against the posted user id and returns the identity', async () => {
|
||||
const { fetcher, calls } = stubFetch({ is_valid: true })
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result).toEqual({ ok: true, identity: { userId: USER_ID, appId: APP_ID } })
|
||||
|
||||
// The request Meta actually sees: the nonce is bound to THIS user id, and the
|
||||
// app authenticates itself with `OC|<app id>|<secret>`.
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].url).toBe('https://graph.oculus.com/user_nonce_validate')
|
||||
expect(calls[0].form.get('nonce')).toBe(NONCE)
|
||||
expect(calls[0].form.get('user_id')).toBe(USER_ID)
|
||||
expect(calls[0].form.get('access_token')).toBe(`OC|${APP_ID}|${APP_SECRET}`)
|
||||
})
|
||||
|
||||
test('rejects a nonce Meta does not vouch for', async () => {
|
||||
const { fetcher } = stubFetch({ is_valid: false })
|
||||
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)).toEqual({
|
||||
ok: false,
|
||||
reason: 'nonce rejected',
|
||||
})
|
||||
})
|
||||
|
||||
// The whole point of validating against the posted id: a nonce genuinely issued to
|
||||
// one user does not authenticate another. Meta answers is_valid:false for the
|
||||
// mismatch, so nobody can log in by naming someone else's Meta user id.
|
||||
test('a nonce presented for the wrong user id fails', async () => {
|
||||
const { fetcher, calls } = stubFetch({ is_valid: false })
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, '99999999999999999', APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(calls[0].form.get('user_id')).toBe('99999999999999999')
|
||||
})
|
||||
|
||||
test.each([
|
||||
['missing', ''],
|
||||
['non-numeric', 'not-an-id'],
|
||||
])('refuses a %s user id without calling Meta', async (_label, userId) => {
|
||||
const { fetcher, calls } = stubFetch({ is_valid: true })
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, userId, APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('refuses to attempt verification with no app secret', async () => {
|
||||
const { fetcher, calls } = stubFetch({ is_valid: true })
|
||||
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, '', fetcher)).toEqual({
|
||||
ok: false,
|
||||
reason: 'no app secret configured',
|
||||
})
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('surfaces a graph error with its code, for the server log', async () => {
|
||||
const { fetcher } = stubFetch({
|
||||
error: { code: 100, message: 'Invalid OAuth access token', type: 'OAuthException' },
|
||||
})
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'graph error 100: Invalid OAuth access token',
|
||||
})
|
||||
})
|
||||
|
||||
test('a non-retryable graph error is not retried', async () => {
|
||||
const { fetcher, calls } = stubFetch({ error: { code: 100, message: 'bad token' } })
|
||||
await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('retries a transient graph error and succeeds on a later attempt', async () => {
|
||||
const { fetcher, calls } = stubFetch([
|
||||
{ error: { code: 2, message: 'service temporarily unavailable' } },
|
||||
{ is_valid: true },
|
||||
])
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(true)
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('gives up after three attempts when Meta stays unavailable', async () => {
|
||||
const { fetcher, calls } = stubFetch({ error: { code: 1, message: 'unknown error' } })
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(calls).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('treats a network failure as transient', async () => {
|
||||
let attempts = 0
|
||||
const fetcher = (async () => {
|
||||
attempts++
|
||||
throw new Error('connection reset')
|
||||
}) as unknown as typeof fetch
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
test('treats a non-JSON body (an edge error page) as transient', async () => {
|
||||
let attempts = 0
|
||||
const fetcher = (async () => {
|
||||
attempts++
|
||||
return new Response('<html>502</html>', { status: 502 })
|
||||
}) as unknown as typeof fetch
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result).toEqual({ ok: false, reason: 'HTTP 502 with a non-JSON body' })
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -22,11 +22,23 @@
|
||||
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
|
||||
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
|
||||
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
|
||||
//
|
||||
// META_APP_SECRET is the Meta (Oculus) app secret, bound only by this worker: Meta
|
||||
// logins are verified by asking Meta to validate the login nonce, which requires
|
||||
// authenticating as the app (see src/meta-nonce.ts). Both secrets must EXIST in the
|
||||
// store or the deploy fails — an operator with no Meta app still has to create
|
||||
// META_APP_SECRET (any placeholder will do); Meta logins then fail with a 500 until
|
||||
// it holds the real value, and nothing else is affected. See DEPLOYING.md.
|
||||
"secrets_store_secrets": [
|
||||
{
|
||||
"binding": "JWT_SECRET",
|
||||
"store_id": "local",
|
||||
"secret_name": "JWT_SECRET"
|
||||
},
|
||||
{
|
||||
"binding": "META_APP_SECRET",
|
||||
"store_id": "local",
|
||||
"secret_name": "META_APP_SECRET"
|
||||
}
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
|
||||
+29
-1
@@ -1,6 +1,34 @@
|
||||
# cdn
|
||||
|
||||
A Cloudflare Workers application using Hono
|
||||
CDN Worker served on the `cdn` subdomain (`cdn.recflare.net`) — a Hono app that streams
|
||||
the binary blobs the client downloads while playing out of the shared `recflare-cdn` R2
|
||||
bucket, plus the one bundled config file the loading screen reads.
|
||||
|
||||
Objects are keyed by prefix — `sigs/` (anti-cheat signatures), `room/` (saved room
|
||||
scenes, and room images by their bare `ImageName`), `invention/` (invention data) — and
|
||||
served as `application/octet-stream`; the worker never interprets what it hands back.
|
||||
Reads are unauthenticated: a caller needs the exact key, which only comes from an
|
||||
authenticated call to another worker.
|
||||
|
||||
This worker only reads. Uploads go through `storage`, which writes the same bucket, and
|
||||
images are served by `img`.
|
||||
|
||||
## API documentation
|
||||
|
||||
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
|
||||
alongside each handler, with the schemas in `src/openapi.ts`. It's also aggregated into
|
||||
the docs page www serves at `/docs`.
|
||||
|
||||
**The spec is descriptive, not enforced** — same rationale as the `img`/`auth` workers: a
|
||||
reverse-engineered protocol, lenient handlers, no runtime validation. A test asserts
|
||||
every route appears in the spec, so adding one without documenting it fails.
|
||||
|
||||
## Conditional and range requests
|
||||
|
||||
Every asset route honours `If-None-Match` (→ 304) and a single `Range` (→ 206). The range
|
||||
support is not an optimization: large-file downloaders fetch in chunks, and answering 200
|
||||
where a 206 is expected corrupts the reassembled file — which surfaces as an anti-cheat
|
||||
"Signatures don't match" failure rather than a download error.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -17,8 +17,13 @@
|
||||
"dependencies": {
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "0.16.20",
|
||||
|
||||
+161
-18
@@ -1,17 +1,26 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import loadingScreenTipData from '../static/loading-screen-tip-data.json'
|
||||
import {
|
||||
assetResponses,
|
||||
CONDITIONAL_HEADERS,
|
||||
json,
|
||||
keyParam,
|
||||
LoadingScreenTip,
|
||||
ServiceStatus,
|
||||
} from './openapi'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App, Env } from './context'
|
||||
|
||||
/**
|
||||
* CDN routes. The `cdn` prefix maps to this worker's subdomain, so method routes
|
||||
* are served bare. File-backed routes (`sigs`, `upload`) have no storage binding
|
||||
* yet and are stubbed.
|
||||
* are served bare. Everything but the liveness probe and the bundled tip data is
|
||||
* streamed out of the shared `recflare-cdn` R2 bucket, keyed by prefix.
|
||||
*/
|
||||
|
||||
/** Parse a single-range `Range: bytes=start-end` header into an R2 range. */
|
||||
@@ -60,16 +69,16 @@ async function serveAsset(c: Context<App>, key: string) {
|
||||
|
||||
// Range honored → 206 Partial Content with Content-Range.
|
||||
if (object.range && c.req.header('range')) {
|
||||
const r = object.range
|
||||
let offset: number
|
||||
let length: number
|
||||
if ('suffix' in r) {
|
||||
length = r.suffix
|
||||
offset = object.size - length
|
||||
} else {
|
||||
offset = r.offset ?? 0
|
||||
length = r.length ?? object.size - offset
|
||||
}
|
||||
// R2 hands back the RESOLVED range, and the object it returns carries all three
|
||||
// keys with the inapplicable ones set to undefined — so `'suffix' in r` is true
|
||||
// even for an offset/length range and cannot discriminate between the two forms.
|
||||
// (It read as a suffix range every time, making offset/length NaN and the
|
||||
// Content-Range header garbage.) Read the values, not the keys. A `bytes=-N`
|
||||
// request already comes back resolved to a concrete offset/length; the suffix
|
||||
// fallback below is only there in case that ever stops being true.
|
||||
const r = object.range as { offset?: number; length?: number; suffix?: number }
|
||||
const length = r.length ?? r.suffix ?? object.size - (r.offset ?? 0)
|
||||
const offset = r.offset ?? object.size - length
|
||||
headers.set('content-length', String(length))
|
||||
headers.set('content-range', `bytes ${offset}-${offset + length - 1}/${object.size}`)
|
||||
return new Response(object.body, { status: 206, headers })
|
||||
@@ -92,25 +101,159 @@ const app = new Hono<App>()
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
.get('/', (c) => c.json({ service: 'cdn', status: 'ok' }))
|
||||
.get(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Service'],
|
||||
summary: 'Service liveness',
|
||||
description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.',
|
||||
responses: { 200: json(ServiceStatus, 'Always `{ service: "cdn", status: "ok" }`') },
|
||||
}),
|
||||
(c) => c.json({ service: 'cdn', status: 'ok' })
|
||||
)
|
||||
|
||||
// Loading-screen tips, bundled here as static JSON.
|
||||
.get('/config/LoadingScreenTipData', (c) => c.json(loadingScreenTipData))
|
||||
.get(
|
||||
'/config/LoadingScreenTipData',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Loading-screen tips',
|
||||
description: [
|
||||
'The tips the client cycles through on a loading screen. A bundled static file',
|
||||
'(`static/loading-screen-tip-data.json`), captured from the real service and served',
|
||||
'verbatim — nothing here is editable at runtime, and every client gets the same list',
|
||||
'regardless of platform or room. The per-tip `Context`/`Visibility`/`PlatformMask`',
|
||||
'fields are the client’s own filters, applied client-side.',
|
||||
].join(' '),
|
||||
responses: { 200: json(LoadingScreenTip.array(), 'The bundled tips') },
|
||||
}),
|
||||
(c) => c.json(loadingScreenTipData)
|
||||
)
|
||||
|
||||
// Signature blobs by name. Streamed from R2 under the `sigs/` key prefix;
|
||||
// 404 when missing.
|
||||
.get('/sigs/:sigName', (c) => serveAsset(c, `sigs/${c.req.param('sigName')}`))
|
||||
.get(
|
||||
'/sigs/:sigName',
|
||||
describeRoute({
|
||||
tags: ['Assets'],
|
||||
summary: 'Serve a signature blob',
|
||||
description: [
|
||||
'Streams the object stored under `sigs/<sigName>`. These are the anti-cheat signature',
|
||||
'blobs the client fetches at startup; nothing here inspects or validates them.',
|
||||
].join(' '),
|
||||
parameters: [keyParam('sigName', 'The blob name.', false), ...CONDITIONAL_HEADERS],
|
||||
responses: assetResponses('The signature blob'),
|
||||
}),
|
||||
(c) => serveAsset(c, `sigs/${c.req.param('sigName')}`)
|
||||
)
|
||||
|
||||
// Room build data by name. The client fetches this for a SubRoom's DataBlob to
|
||||
// load the room. Streamed from R2 under `room/`. The name may contain slashes
|
||||
// (uploads are foldered by date, e.g. `2026-02-03/<uuid>`), so match the rest of
|
||||
// the path.
|
||||
.get('/room/:dataBlob{.+}', (c) => serveAsset(c, `room/${c.req.param('dataBlob')}`))
|
||||
.get(
|
||||
'/room/:dataBlob{.+}',
|
||||
describeRoute({
|
||||
tags: ['Assets'],
|
||||
summary: 'Serve room build data',
|
||||
description: [
|
||||
'Streams the object stored under `room/<dataBlob>` — the saved scene the client',
|
||||
'downloads to load a room. The name comes from a subroom’s `DataBlob` (see the `rooms`',
|
||||
'worker) and is date-foldered by the upload, e.g. `2026-02-03/<uuid>`, so it contains',
|
||||
'slashes.',
|
||||
'',
|
||||
'A room’s IMAGE also lives under this prefix, stored by its bare `ImageName` — the',
|
||||
'same route serves both.',
|
||||
].join('\n'),
|
||||
parameters: [keyParam('dataBlob', 'The blob name.', true), ...CONDITIONAL_HEADERS],
|
||||
responses: assetResponses('The room data'),
|
||||
}),
|
||||
(c) => serveAsset(c, `room/${c.req.param('dataBlob')}`)
|
||||
)
|
||||
|
||||
// Invention data by name. The client fetches this for an invention's
|
||||
// `CurrentVersion.BlobName` to spawn it. Streamed from R2 under `invention/`.
|
||||
// Like room blobs the name is date-foldered, and it carries the `.inv` extension
|
||||
// the upload stored it under, so the rest of the path is matched as-is.
|
||||
.get('/invention/:dataBlob{.+}', (c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`))
|
||||
.get(
|
||||
'/invention/:dataBlob{.+}',
|
||||
describeRoute({
|
||||
tags: ['Assets'],
|
||||
summary: 'Serve invention data',
|
||||
description: [
|
||||
'Streams the object stored under `invention/<dataBlob>` — the data the client',
|
||||
'downloads to spawn an invention. The name comes from an invention’s',
|
||||
'`CurrentVersion.BlobName` (see the `api` worker); like room blobs it is date-foldered,',
|
||||
'and it keeps the `.inv` extension the upload stored it under.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
keyParam('dataBlob', 'The blob name, including `.inv`.', true),
|
||||
...CONDITIONAL_HEADERS,
|
||||
],
|
||||
responses: assetResponses('The invention data'),
|
||||
}),
|
||||
(c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`)
|
||||
)
|
||||
|
||||
// Generic client data by name. Anything the client uploads as FileType 2 lands
|
||||
// under `data/` (a Holotar recording is the one seen in the wild) and the client
|
||||
// fetches it back from this prefix. Date-foldered like the room and invention
|
||||
// blobs, so the rest of the path is matched as-is.
|
||||
.get(
|
||||
'/data/:id{.+}',
|
||||
describeRoute({
|
||||
tags: ['Assets'],
|
||||
summary: 'Serve a client data blob',
|
||||
description: [
|
||||
'Streams the object stored under `data/<id>` — whatever the client uploaded as',
|
||||
'`UploadFileType` 2 (see the `storage` worker), a Holotar recording being the case',
|
||||
'observed. Like room and invention blobs the name is date-foldered by the upload,',
|
||||
'e.g. `2026-02-03/<uuid>`, so it contains slashes. The worker does not interpret the',
|
||||
'bytes — the prefix exists because the client expects to read these back from `/data/`.',
|
||||
].join(' '),
|
||||
parameters: [keyParam('id', 'The blob name.', true), ...CONDITIONAL_HEADERS],
|
||||
responses: assetResponses('The data blob'),
|
||||
}),
|
||||
(c) => serveAsset(c, `data/${c.req.param('id')}`)
|
||||
)
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
app.get(
|
||||
'/openapi.json',
|
||||
describeRoute({ hide: true }),
|
||||
withCleanSpec(
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: 'recflare cdn',
|
||||
version: '1.0.0',
|
||||
description: [
|
||||
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
|
||||
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
|
||||
'signatures, saved room scenes, invention data and generic client uploads — out of',
|
||||
'the shared `recflare-cdn` R2 bucket, plus the one bundled config file the loading',
|
||||
'screen reads.',
|
||||
'',
|
||||
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
|
||||
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
|
||||
'are unauthenticated — a caller needs the exact key, which only comes from an',
|
||||
'authenticated call to another worker.',
|
||||
'',
|
||||
'This worker only READS. Uploads go through the `storage` worker, which writes the',
|
||||
'same bucket, and images are served by `img` rather than from here.',
|
||||
'',
|
||||
'Every asset route supports conditional GETs (`If-None-Match` → 304) and single',
|
||||
'byte ranges (`Range` → 206). The ranges matter: large-file downloaders fetch in',
|
||||
'chunks, and answering 200 where a 206 is expected corrupts the reassembled file —',
|
||||
'which surfaces as an anti-cheat “Signatures don’t match” failure, not a download',
|
||||
'error.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://cdn.recflare.net', description: 'Production' }],
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the cdn worker.
|
||||
*
|
||||
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
|
||||
* rationale as the auth/accounts/img workers: a reverse-engineered protocol, lenient
|
||||
* handlers, no runtime validation.
|
||||
*
|
||||
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
|
||||
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
|
||||
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
|
||||
* schema inline, which renders correctly in any tool.
|
||||
*
|
||||
* Most of this worker's surface is opaque BYTES, not JSON, so those responses are
|
||||
* described with a binary content type rather than a zod schema (the same way the `img`
|
||||
* worker describes image bytes).
|
||||
*/
|
||||
|
||||
/** Emit a zod schema as an `application/json` response body. */
|
||||
export function json(schema: z.ZodType, description: string) {
|
||||
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||
}
|
||||
|
||||
/**
|
||||
* A binary asset response. Everything streamed out of the bucket is served as
|
||||
* `application/octet-stream` regardless of what it actually is — the client downloads
|
||||
* these blobs, it never sniffs their type.
|
||||
*/
|
||||
export function assetBytes(description: string): OpenAPIV3_1.ResponseObject {
|
||||
return {
|
||||
description,
|
||||
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
|
||||
}
|
||||
}
|
||||
|
||||
/** The shared responses of every asset route: the byte-serving ones plus its failures. */
|
||||
export function assetResponses(description: string): OpenAPIV3_1.ResponsesObject {
|
||||
return {
|
||||
200: assetBytes(description),
|
||||
206: assetBytes('A byte range, when the request carried a `Range` header'),
|
||||
304: { description: '`If-None-Match` matched the stored etag (no body)' },
|
||||
400: { description: 'The key contains `..` (no body)' },
|
||||
404: { description: 'No such object in the bucket' },
|
||||
}
|
||||
}
|
||||
|
||||
/** The `Range` / `If-None-Match` headers every asset route honours. */
|
||||
export const CONDITIONAL_HEADERS: OpenAPIV3_1.ParameterObject[] = [
|
||||
{
|
||||
name: 'Range',
|
||||
in: 'header',
|
||||
required: false,
|
||||
description:
|
||||
'A single byte range (`bytes=start-end`, `bytes=start-`, `bytes=-suffix`). Honoured with a 206; a malformed or multi-range value is ignored and the whole object served.',
|
||||
schema: { type: 'string', example: 'bytes=0-1023' },
|
||||
},
|
||||
{
|
||||
name: 'If-None-Match',
|
||||
in: 'header',
|
||||
required: false,
|
||||
description: 'The etag of a previously fetched copy; a match answers 304 with no body.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
]
|
||||
|
||||
/** A path parameter naming an object in the bucket. */
|
||||
export function keyParam(
|
||||
name: string,
|
||||
description: string,
|
||||
slashes: boolean
|
||||
): OpenAPIV3_1.ParameterObject {
|
||||
return {
|
||||
name,
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: slashes ? `${description} May contain slashes.` : description,
|
||||
schema: { type: 'string' },
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Response schemas ------------------------------------------------------
|
||||
|
||||
/** `GET /` — the liveness probe body. */
|
||||
export const ServiceStatus = z.object({
|
||||
service: z.literal('cdn'),
|
||||
status: z.literal('ok'),
|
||||
})
|
||||
|
||||
/**
|
||||
* One loading-screen tip. `Context`/`InputType`/`Visibility` are client-side enums that
|
||||
* decide where a tip may appear, and `PlatformMask` is a bit field of the platforms it
|
||||
* shows on — every tip in the bundled set is left at whatever the 2019 capture had.
|
||||
*/
|
||||
export const LoadingScreenTip = z.object({
|
||||
Name: z.string().describe('A GUID (no dashes) — the tip’s id, not a display name'),
|
||||
Title: z.string(),
|
||||
Message: z.string(),
|
||||
RoomNames: z
|
||||
.array(z.string())
|
||||
.describe('Rooms to restrict the tip to; empty everywhere in the bundled set'),
|
||||
Context: z.int(),
|
||||
InputType: z.int(),
|
||||
Visibility: z.int(),
|
||||
AllowCycling: z.boolean(),
|
||||
RestrictToNewUsers: z.boolean(),
|
||||
ImageName: z.string().describe('An image key the client resolves against the img worker'),
|
||||
PlatformMask: z.int().describe('Bit field of the platforms the tip shows on'),
|
||||
CreatedAt: z.string(),
|
||||
})
|
||||
@@ -52,6 +52,27 @@ describe('cdn endpoints', () => {
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([12, 13, 14]))
|
||||
})
|
||||
|
||||
// The other two Range forms. Both resolve to a concrete offset/length inside R2, so
|
||||
// they exercise the same Content-Range math as the closed range above — which read
|
||||
// every range as a suffix range and emitted `bytes NaN-NaN/6` until it was fixed.
|
||||
test('GET /sigs/:sigName honors open-ended and suffix Range requests', async () => {
|
||||
await env.CDN_ASSETS.put('sigs/ranged2', new Uint8Array([10, 11, 12, 13, 14, 15]))
|
||||
const fetchRange = (range: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/sigs/ranged2`, { headers: { Range: range } })
|
||||
|
||||
// `bytes=4-` — from an offset to the end.
|
||||
const open = await fetchRange('bytes=4-')
|
||||
expect(open.status).toBe(206)
|
||||
expect(open.headers.get('content-range')).toBe('bytes 4-5/6')
|
||||
expect(new Uint8Array(await open.arrayBuffer())).toEqual(new Uint8Array([14, 15]))
|
||||
|
||||
// `bytes=-2` — the last N bytes.
|
||||
const suffix = await fetchRange('bytes=-2')
|
||||
expect(suffix.status).toBe(206)
|
||||
expect(suffix.headers.get('content-range')).toBe('bytes 4-5/6')
|
||||
expect(new Uint8Array(await suffix.arrayBuffer())).toEqual(new Uint8Array([14, 15]))
|
||||
})
|
||||
|
||||
test('GET /room/:dataBlob streams the room blob from R2', async () => {
|
||||
await env.CDN_ASSETS.put('room/94tp5zjtwz0gppp8xlv1j9l5b.room', new Uint8Array([9, 8, 7]))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/room/94tp5zjtwz0gppp8xlv1j9l5b.room`)
|
||||
@@ -79,4 +100,58 @@ describe('cdn endpoints', () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/invention/missing.inv`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /data/:id streams the data blob from R2', async () => {
|
||||
// Date-foldered — the name the storage worker generates for a FileType 2 upload.
|
||||
const name = '2026-08-05/3b9c1f0a-5d2e-4c1b-9a77-2e6f0b4d8c31'
|
||||
await env.CDN_ASSETS.put(`data/${name}`, new Uint8Array([4, 5, 6]))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/data/${name}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('application/octet-stream')
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([4, 5, 6]))
|
||||
})
|
||||
|
||||
test('GET /data/:id 404s when the blob is absent', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/data/missing`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
const spec = (await res.json()) as {
|
||||
openapi: string
|
||||
paths: Record<string, Record<string, { summary?: string }>>
|
||||
}
|
||||
expect(spec.openapi).toMatch(/^3\.1/)
|
||||
|
||||
// The spec route hides itself.
|
||||
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'GET /',
|
||||
'GET /config/LoadingScreenTipData',
|
||||
'GET /data/{id}',
|
||||
'GET /invention/{dataBlob}',
|
||||
'GET /room/{dataBlob}',
|
||||
'GET /sigs/{sigName}',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
// documentation.
|
||||
for (const ops of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||
}
|
||||
|
||||
// Schemas must inline: a `$ref` here is a dangling reference (see openapi.ts).
|
||||
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,142 @@
|
||||
[
|
||||
{
|
||||
"Name": "bf5d8d41a7184e779399ff45a9e8785a",
|
||||
"Title": "Welcome!",
|
||||
"Message": "Thanks for trying RecFlare alpha!",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2026-07-11T18:27:25Z"
|
||||
}
|
||||
]
|
||||
{
|
||||
"Name": "bf5d8d41a7184e779399ff45a9e8785a",
|
||||
"Title": "Rec Room Tokens",
|
||||
"Message": "Redeem your Rec Room Tokens for all kinds of fun rewards! You can shop at the Rec Center Merch Booth or the Store section of your Watch Menu.",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "gay",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-02-28T18:27:25Z"
|
||||
},
|
||||
{
|
||||
"Name": "3801fdd940334b85a7460ffdda73f9de",
|
||||
"Title": "Become a Star!",
|
||||
"Message": "Use #RecRoom on your Instagram and Twitter posts for a chance to make it onto our Community Board!",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "gay",
|
||||
"PlatformMask": 167,
|
||||
"CreatedAt": "2019-02-28T18:15:33Z"
|
||||
},
|
||||
{
|
||||
"Name": "d517b46a3f734db39d77f2a15b105495",
|
||||
"Title": "Find Your Style",
|
||||
"Message": "Personalize your outfit and appearance in your Dorm Room.",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-03-02T00:12:59Z"
|
||||
},
|
||||
{
|
||||
"Name": "bc8569de432a4cc4b0e69d1b619921cb",
|
||||
"Title": "Room Cheers",
|
||||
"Message": "Cheer and Favorite any room in the This Room section of your Watch Menu.",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-02-28T18:08:56Z"
|
||||
},
|
||||
{
|
||||
"Name": "4a9bec133a26457280eb30887e9442d9",
|
||||
"Title": "Daily Challenges",
|
||||
"Message": "Check out the Challenges section in your watch for fun ways to earn in-game rewards.",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-02-28T18:22:44Z"
|
||||
},
|
||||
{
|
||||
"Name": "511adb340964437cbbe4ad382a658b52",
|
||||
"Title": "Welcome to Rec Room",
|
||||
"Message": "Rec Room is a virtual social club where you create and play games with friends. It's a fun and welcoming environment for people from all walks of life!",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-02-28T18:28:32Z"
|
||||
},
|
||||
{
|
||||
"Name": "0ec7868f309843158bfb7fd94fd2a80a",
|
||||
"Title": "Join the Rec Room Community!",
|
||||
"Message": "See recroom.com/community for links to Rec Room's YouTube, Instagram, Discord, and MORE!",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 167,
|
||||
"CreatedAt": "2019-02-28T18:24:33Z"
|
||||
},
|
||||
{
|
||||
"Name": "f0c49e1d5ce146638f1cb53f5d4f0bb2",
|
||||
"Title": "Play on the go!",
|
||||
"Message": "Download Rec Room on your iOS device and play anywhere!",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 131,
|
||||
"CreatedAt": "2019-12-21T01:24:23Z"
|
||||
},
|
||||
{
|
||||
"Name": "ae71ae948ce041bba799ee8d6ee9a0ba",
|
||||
"Title": "Laser Tag Merch",
|
||||
"Message": "You earn tickets for every game of Laser Tag. Redeem them for awesome Laser Tag gear!",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-02-28T18:07:50Z"
|
||||
},
|
||||
{
|
||||
"Name": "169b34a66ed046ba84cb2dbf43562a26",
|
||||
"Title": "We're all on Rec.Net!",
|
||||
"Message": "Log into your Rec.Net profile to stay in touch with your friends any time!",
|
||||
"RoomNames": [],
|
||||
"Context": 0,
|
||||
"InputType": 0,
|
||||
"Visibility": 1,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 239,
|
||||
"CreatedAt": "2019-02-28T18:21:25Z"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Chat messages, owned by the `chat` worker. One row per message posted to a thread;
|
||||
-- columns rather than a JSON blob (mirroring the reference model), since every field is a
|
||||
-- scalar the server reads: threads are listed newest-first by (chat_thread_id,
|
||||
-- chat_message_id), and `time_sent` is carried for display only.
|
||||
--
|
||||
-- `chat_message_id` is server-assigned (AUTOINCREMENT) so ids are unique across every
|
||||
-- thread, matching the client's expectation of a global message id. `contents` is the
|
||||
-- client's envelope — `{"Type":0,"Version":1,"Data":"..."}` — stored verbatim as an
|
||||
-- opaque string and served back untouched, so new message types need no schema change.
|
||||
-- `moderation_state` is the ChatModerationState enum (0 = none). Kept in sync with
|
||||
-- SCHEMA_DDL in src/message-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message (
|
||||
chat_message_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_thread_id INTEGER NOT NULL,
|
||||
sender_player_id INTEGER NOT NULL,
|
||||
time_sent TEXT NOT NULL,
|
||||
contents TEXT NOT NULL,
|
||||
moderation_state INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_thread ON message (chat_thread_id, chat_message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_sender ON message (sender_player_id);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Chat threads and their membership, owned by the `chat` worker.
|
||||
--
|
||||
-- A thread is a conversation — a DM pair, a group chat, or a system thread. Membership
|
||||
-- in `thread_member` is the authorization gate: a player may read or post to a thread
|
||||
-- only if they hold a row here, and the same rows render the `playerIds` array the
|
||||
-- client shows. There is deliberately no FK to accounts, here or on
|
||||
-- `message.sender_player_id`: that table belongs to the `auth` worker, and a thread
|
||||
-- outlives the accounts in it.
|
||||
--
|
||||
-- `latest_message_id` is denormalized onto the thread so the thread list renders from
|
||||
-- one indexed row per thread instead of a per-thread MAX() over `message`; it also
|
||||
-- orders that list (message ids being monotonic, newest thread = highest id). Kept in
|
||||
-- sync on every insert — see touchThread in src/thread-db.ts.
|
||||
--
|
||||
-- The per-viewer fields live on the membership row, not the thread: two players in one
|
||||
-- DM have independent read positions, snoozes, and favorites. Kept in sync with
|
||||
-- THREAD_SCHEMA_DDL in src/thread-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_thread (
|
||||
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
-- Null for DMs and unnamed groups; the client falls back to rendering the members.
|
||||
chat_thread_name TEXT,
|
||||
latest_message_id INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_thread_latest ON message_thread (latest_message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS thread_member (
|
||||
chat_thread_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
last_read_message_id INTEGER,
|
||||
snoozed_until TEXT,
|
||||
is_favorited INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (chat_thread_id, player_id)
|
||||
);
|
||||
-- The thread-list query is "every thread this player is in", so player_id leads.
|
||||
CREATE INDEX IF NOT EXISTS idx_thread_member_player ON thread_member (player_id);
|
||||
@@ -12,12 +12,19 @@
|
||||
"deploy": "run-wrangler-deploy",
|
||||
"dev": "run-wrangler-dev",
|
||||
"fix:workers-types": "run-wrangler-types",
|
||||
"migrate": "run-wrangler-migrate",
|
||||
"test": "run-vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "0.16.20",
|
||||
|
||||
+818
-4
@@ -1,9 +1,313 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import { getThreadMessages } from './message-db'
|
||||
import {
|
||||
AUTHED,
|
||||
ChatMessageDto,
|
||||
ChatResult,
|
||||
ChatThreadDto,
|
||||
ChatThreadWithMessagesDto,
|
||||
CreateThreadRequest,
|
||||
CreateThreadResponse,
|
||||
FavoriteThreadRequest,
|
||||
form,
|
||||
json,
|
||||
messageCountParam,
|
||||
NOT_A_MEMBER_RESPONSE,
|
||||
RenameThreadRequest,
|
||||
SendMessageRequest,
|
||||
SendMessageResponse,
|
||||
ServiceStatus,
|
||||
SnoozeThreadRequest,
|
||||
THREAD_ID_PARAM,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
WithMembersRequest,
|
||||
} from './openapi'
|
||||
import {
|
||||
addThreadMember,
|
||||
getOrCreateThreadWithMembers,
|
||||
getThreadForPlayer,
|
||||
getThreadMemberIds,
|
||||
getThreadsForPlayer,
|
||||
isThreadMember,
|
||||
leftChatContents,
|
||||
markThreadRead,
|
||||
postMessage,
|
||||
removeThreadMember,
|
||||
setThreadFavorited,
|
||||
setThreadName,
|
||||
setThreadSnoozed,
|
||||
SYSTEM_SENDER_ID,
|
||||
} from './thread-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
import type { ChatMessage } from './message-db'
|
||||
|
||||
/**
|
||||
* Resolve the account id from a Bearer token. Returns `null` when the header is
|
||||
* missing, the token is invalid, or the `sub` claim isn't an integer.
|
||||
*/
|
||||
async function authedId(c: Context<App>): Promise<number | null> {
|
||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/**
|
||||
* How many items a `MessageCount` query param asks for. The client sends 16; anything
|
||||
* missing, unparseable, or out of range falls back to the default rather than 400ing,
|
||||
* and the cap keeps a hand-written request from pulling a whole thread history.
|
||||
*/
|
||||
const DEFAULT_MESSAGE_COUNT = 16
|
||||
const MAX_MESSAGE_COUNT = 100
|
||||
|
||||
/** What the client asks for when opening a thread (`messageCount=50`). */
|
||||
const DEFAULT_THREAD_MESSAGE_COUNT = 50
|
||||
|
||||
function messageCount(c: Context<App>, fallback = DEFAULT_MESSAGE_COUNT): number {
|
||||
// The GET routes spell it `MessageCount` in the query; the POST forms spell it
|
||||
// `messageCount` in the body. Accept either, wherever it turns up.
|
||||
const raw = Number.parseInt(c.req.query('MessageCount') ?? c.req.query('messageCount') ?? '', 10)
|
||||
if (Number.isNaN(raw) || raw <= 0) return fallback
|
||||
return Math.min(raw, MAX_MESSAGE_COUNT)
|
||||
}
|
||||
|
||||
/** The page size a POST form asks for, which may also arrive in the body. */
|
||||
async function formMessageCount(c: Context<App>, fallback: number): Promise<number> {
|
||||
const raw = Number.parseInt((await formField(c, 'messageCount')) ?? '', 10)
|
||||
if (Number.isNaN(raw) || raw <= 0) return messageCount(c, fallback)
|
||||
return Math.min(raw, MAX_MESSAGE_COUNT)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a chat action reports back to the client alongside its payload — the reference's
|
||||
* ChatResult. Only success and "bad arguments" are reachable here.
|
||||
*/
|
||||
const CHAT_SUCCESS = 0
|
||||
const CHAT_INVALID_ARGUMENTS = 1
|
||||
const CHAT_MEMBERSHIP_NOT_FOUND = 3
|
||||
const CHAT_PLAYER_ALREADY_ON_THREAD = 4
|
||||
|
||||
/** The hub is a single global Durable Object instance, as every worker addresses it. */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/**
|
||||
* Push ChatMessageReceived to everyone in the thread once a message lands, so the
|
||||
* conversation updates live instead of on the next poll.
|
||||
*
|
||||
* The sender is notified too, deliberately: the client doesn't fold the HTTP response
|
||||
* into its local thread cache, so without a self-targeted push its own outgoing message
|
||||
* doesn't appear until the thread is refetched.
|
||||
*
|
||||
* Best-effort — a hub failure is logged and swallowed, since the message has already
|
||||
* committed and the client will still see it on the next fetch.
|
||||
*/
|
||||
async function pushChatMessage(c: Context<App>, message: ChatMessage): Promise<void> {
|
||||
try {
|
||||
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
|
||||
const members = await getThreadMemberIds(c.env.DB, message.chatThreadId)
|
||||
await Promise.all(
|
||||
members.map((playerId) =>
|
||||
hub.notifyPlayer(playerId, NotificationType.ChatMessageReceived, { ...message })
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push ChatMessageReceived notification', {
|
||||
chatThreadId: message.chatThreadId,
|
||||
chatMessageId: message.chatMessageId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a thread that already exists — every message after the one that
|
||||
* opened the conversation. `/thread/18` is what the client posts; `/thread/18/message` is
|
||||
* the same call under the reference's other spelling, so both routes land here.
|
||||
*
|
||||
* Answers `{chatResult, chatThread}` — the whole thread with its messages, not just the
|
||||
* message that was sent, so the client re-renders the conversation from one response.
|
||||
* Blank or missing contents stores nothing and reports invalid-arguments, still with the
|
||||
* thread attached, rather than an error status.
|
||||
*/
|
||||
async function sendToThread(c: Context<App>) {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id') ?? '', 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
|
||||
|
||||
// Stored exactly as sent: the envelope carries its own Type/Version and may hold
|
||||
// fields we know nothing about (the client sends Version 2 with a `<=>` prefix in
|
||||
// Data, and a `Blocks` array alongside it), so nothing here parses or rewrites it.
|
||||
const contents = (await formField(c, 'messageContents'))?.trim()
|
||||
const posted =
|
||||
contents === undefined || contents === ''
|
||||
? null
|
||||
: await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents })
|
||||
if (posted !== null) {
|
||||
await pushChatMessage(c, posted)
|
||||
// Sending is reading: the reference answers with `lastReadMessageId` already at the
|
||||
// message just posted, so the sender's own thread doesn't come back unread.
|
||||
await markThreadRead(c.env.DB, chatThreadId, id, posted.chatMessageId)
|
||||
}
|
||||
|
||||
const thread = await threadWithMessages(c, chatThreadId, id, DEFAULT_THREAD_MESSAGE_COUNT)
|
||||
return c.json({
|
||||
chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS,
|
||||
chatThread: thread,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the caller's read pointer on a thread, to `chatMessageId` or (undefined) to the
|
||||
* thread's latest message. Answers the bare ChatResult integer the reference sends.
|
||||
*/
|
||||
async function markRead(c: Context<App>, chatMessageId?: number) {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
// Every route reaching here constrains `:id` to digits, so the parse can't fail.
|
||||
const chatThreadId = Number.parseInt(c.req.param('id') ?? '', 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
|
||||
|
||||
await markThreadRead(c.env.DB, chatThreadId, id, chatMessageId)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
}
|
||||
|
||||
/**
|
||||
* A thread rendered for opening a conversation: the thread's own fields plus a page of
|
||||
* its messages, newest first. `latestMessage` gives way to the full page — the client is
|
||||
* sent one or the other, never both — and `messages` is always present, empty for a
|
||||
* thread with nothing in it yet.
|
||||
*
|
||||
* Null when the caller isn't a member (or the thread doesn't exist); membership is the
|
||||
* gate, so the two cases are indistinguishable from outside.
|
||||
*/
|
||||
async function threadWithMessages(
|
||||
c: Context<App>,
|
||||
chatThreadId: number,
|
||||
playerId: number,
|
||||
limit: number
|
||||
) {
|
||||
const thread = await getThreadForPlayer(c.env.DB, chatThreadId, playerId)
|
||||
if (thread === null) return null
|
||||
|
||||
const messages = await getThreadMessages(c.env.DB, chatThreadId, { limit })
|
||||
const { latestMessage: _latest, ...rest } = thread
|
||||
return { ...rest, messages }
|
||||
}
|
||||
|
||||
/** Ceiling on a new thread's roster, counting the caller. */
|
||||
const MAX_THREAD_MEMBERS = 50
|
||||
|
||||
/** Longest a thread name may be; anything beyond is truncated, not rejected. */
|
||||
const MAX_THREAD_NAME_LENGTH = 128
|
||||
|
||||
/**
|
||||
* What `snooze=True` stores in `snoozedUntil`. The client sends a boolean but reads back
|
||||
* an instant, so "snoozed" is expressed as a time far enough out to mean indefinitely.
|
||||
*/
|
||||
const SNOOZED_INDEFINITELY = '9999-12-31T23:59:59Z'
|
||||
|
||||
/**
|
||||
* The repeated `ids` fields naming a new thread's members (`ids=2&ids=155`). The client
|
||||
* sends them as a urlencoded body, but they're read from the query string too, since
|
||||
* the same call is easy to hand-write that way. Values that aren't integers are dropped.
|
||||
*/
|
||||
async function memberIds(c: Context<App>): Promise<number[]> {
|
||||
const raw = [...(c.req.queries('ids') ?? [])]
|
||||
const form = await c.req.formData().catch(() => null)
|
||||
if (form !== null) raw.push(...form.getAll('ids').map(String))
|
||||
return raw.map((value) => Number.parseInt(value, 10)).filter((id) => Number.isInteger(id))
|
||||
}
|
||||
|
||||
/** A form boolean as the client spells it (`True`/`False`), tolerant of the variants. */
|
||||
async function formBool(c: Context<App>, name: string): Promise<boolean> {
|
||||
const value = (await formField(c, name))?.trim().toLowerCase()
|
||||
return value === 'true' || value === '1' || value === 'yes'
|
||||
}
|
||||
|
||||
/** A single form field, or the query param of the same name. Hono caches the body, so
|
||||
* this is safe to call alongside `memberIds`. */
|
||||
async function formField(c: Context<App>, name: string): Promise<string | undefined> {
|
||||
const form = await c.req.formData().catch(() => null)
|
||||
const value = form?.get(name)
|
||||
return typeof value === 'string' ? value : c.req.query(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* A concise `describeRoute` spec for one of the thread-scoped actions that answers the
|
||||
* bare ChatResult integer rather than an HTTP status — rename, leave, snooze, favorite,
|
||||
* add-member and the read-pointer moves. They share the auth gate, the `:id` path param,
|
||||
* and the "3 when the caller isn't on the thread" behaviour.
|
||||
*/
|
||||
function chatResultRoute(
|
||||
summary: string,
|
||||
description: string,
|
||||
extra: {
|
||||
requestBody?: ReturnType<typeof form>
|
||||
parameters?: unknown[]
|
||||
successDescription?: string
|
||||
/** Set for the read-pointer routes, which 404 a non-member instead of answering 3. */
|
||||
notFound?: boolean
|
||||
} = {}
|
||||
) {
|
||||
return describeRoute({
|
||||
tags: ['Chat'],
|
||||
summary,
|
||||
description,
|
||||
security: AUTHED,
|
||||
parameters: [THREAD_ID_PARAM, ...((extra.parameters ?? []) as never[])],
|
||||
...(extra.requestBody === undefined ? {} : { requestBody: extra.requestBody }),
|
||||
responses: {
|
||||
200: json(
|
||||
ChatResult,
|
||||
extra.successDescription ??
|
||||
'The ChatResult (0 on success, 3 when the caller isn’t on the thread)'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
...(extra.notFound === true ? { 404: NOT_A_MEMBER_RESPONSE } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The `describeRoute` spec shared by the two spellings of "send to an existing thread".
|
||||
* `/thread/{id}` is what the client posts; `/thread/{id}/message` is the same call under
|
||||
* the reference's other spelling, and both land in `sendToThread`.
|
||||
*/
|
||||
function sendToThreadRoute(spelling: string) {
|
||||
return describeRoute({
|
||||
tags: ['Messages'],
|
||||
summary: `Send a message to an existing thread (${spelling})`,
|
||||
description: [
|
||||
'Every message after the one that opened the conversation. Answers',
|
||||
'`{ chatResult, chatThread }` — the WHOLE thread with its messages, not just the message',
|
||||
'that was sent, so the client re-renders the conversation from one response. Blank or',
|
||||
'missing `messageContents` stores nothing and reports invalid-arguments (1), still with',
|
||||
'the thread attached, rather than an error status. Sending is reading: the sender’s own',
|
||||
'`lastReadMessageId` comes back already at the message just posted. Pushes',
|
||||
'ChatMessageReceived to every member, the sender included — the client doesn’t fold the',
|
||||
'HTTP response into its local cache, so without a self-targeted push its own outgoing',
|
||||
'message doesn’t appear until the thread is refetched. Note the hub frame’s `Id` is a',
|
||||
'STRING: the client dispatches on it and silently drops a numeric one.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [THREAD_ID_PARAM],
|
||||
requestBody: form(SendMessageRequest, 'The message envelope'),
|
||||
responses: {
|
||||
200: json(SendMessageResponse, 'The ChatResult plus the whole thread with its messages'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: NOT_A_MEMBER_RESPONSE,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
@@ -19,9 +323,519 @@ const app = new Hono<App>()
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
.get('/', (c) => c.json({ service: 'chat', status: 'ok' }))
|
||||
.get(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Service'],
|
||||
summary: 'Service liveness',
|
||||
description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.',
|
||||
responses: { 200: json(ServiceStatus, 'Always `{ service: "chat", status: "ok" }`') },
|
||||
}),
|
||||
(c) => c.json({ service: 'chat', status: 'ok' })
|
||||
)
|
||||
|
||||
// Chat threads. No DB binding yet — returns `[]`.
|
||||
.get('/thread', (c) => c.json([]))
|
||||
// The player's own thread list, newest conversation first — each thread carrying its
|
||||
// latest message and the caller's own read/snooze/favorite state. `MessageCount` is
|
||||
// the page size (of threads, despite the name). Membership scopes the query, so a
|
||||
// player only ever sees their own threads.
|
||||
.get(
|
||||
'/thread',
|
||||
describeRoute({
|
||||
tags: ['Threads'],
|
||||
summary: 'The caller’s thread list',
|
||||
description: [
|
||||
'Every thread the caller is a member of, newest conversation first — each carrying its',
|
||||
'`latestMessage` and the caller’s own read/snooze/favorite state. `MessageCount` is the',
|
||||
'page size (of THREADS, despite the name). Membership scopes the query, so a player',
|
||||
'only ever sees their own threads.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [messageCountParam(DEFAULT_MESSAGE_COUNT)],
|
||||
responses: {
|
||||
200: json(ChatThreadDto.array(), 'The caller’s threads, newest first (empty when none)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
return c.json(await getThreadsForPlayer(c.env.DB, id, { limit: messageCount(c) }))
|
||||
}
|
||||
)
|
||||
|
||||
// Send to a set of players (`ids=155&ids=2&messageContents=…`) — the client's
|
||||
// create-thread-and-post-first-message call, in one. Resolves to the thread those
|
||||
// players already share rather than opening a second one.
|
||||
//
|
||||
// `messageContents` is the same envelope a message carries
|
||||
// (`{"Type":0,"Version":1,"Data":"…"}`) and is stored verbatim, unparsed. The client
|
||||
// also sends it blank, right after /thread/withmembers: that opens the thread without
|
||||
// posting an empty message, and reports invalid-arguments the way the reference does.
|
||||
.post(
|
||||
'/thread',
|
||||
describeRoute({
|
||||
tags: ['Threads'],
|
||||
summary: 'Open a thread with a set of players and post the first message',
|
||||
description: [
|
||||
'The client’s create-thread-and-post-first-message call, in one. Resolves to the thread',
|
||||
'those players already share rather than opening a second one. `messageContents` is the',
|
||||
'same envelope a message carries and is stored verbatim, unparsed; the client also sends',
|
||||
'it blank right after `/thread/withmembers`, which opens the thread without posting and',
|
||||
'reports invalid-arguments. Answers a `{ chatThread, chatResult }` wrapper, not a bare',
|
||||
'thread. Pushes ChatMessageReceived to every member (including the sender).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateThreadRequest, 'The member ids and the first message'),
|
||||
responses: {
|
||||
200: json(CreateThreadResponse, 'The thread plus the result of the first message'),
|
||||
400: {
|
||||
description: [
|
||||
'Fewer than 2 members (naming only yourself) or more than 50, counting the caller',
|
||||
'(empty body)',
|
||||
].join(' '),
|
||||
},
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const members = [...new Set([id, ...(await memberIds(c))])]
|
||||
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
|
||||
|
||||
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
|
||||
|
||||
const contents = (await formField(c, 'messageContents'))?.trim()
|
||||
const posted =
|
||||
contents === undefined || contents === ''
|
||||
? null
|
||||
: await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents })
|
||||
if (posted !== null) {
|
||||
await pushChatMessage(c, posted)
|
||||
await markThreadRead(c.env.DB, chatThreadId, id, posted.chatMessageId)
|
||||
}
|
||||
|
||||
const thread = await getThreadForPlayer(c.env.DB, chatThreadId, id)
|
||||
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
|
||||
// The reference answers a wrapper here, not a bare thread.
|
||||
return c.json({
|
||||
chatThread: thread,
|
||||
chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// "Open the chat with these people" — the client's GetChatBetweenPlayers. Fetch or
|
||||
// create: the thread whose membership is exactly `ids` plus the caller, opened only
|
||||
// if they don't already share one. Returning a fresh empty thread each call would
|
||||
// bury the real conversation and hand the client a thread with no messages.
|
||||
//
|
||||
// Answers the thread with a `messages` array (what `messageCount` sizes) rather than
|
||||
// the list's single `latestMessage`, so the client can open straight into the
|
||||
// conversation. The array is always present, empty for a brand-new thread.
|
||||
.post(
|
||||
'/thread/withmembers',
|
||||
describeRoute({
|
||||
tags: ['Threads'],
|
||||
summary: 'Fetch or open the thread with exactly these members',
|
||||
description: [
|
||||
'The client’s GetChatBetweenPlayers. Fetch-or-create: the thread whose membership is',
|
||||
'exactly `ids` plus the caller, opened only if they don’t already share one (returning a',
|
||||
'fresh empty thread each call would bury the real conversation). Answers the thread with',
|
||||
'a `messages` array — what `messageCount` sizes — rather than the list’s single',
|
||||
'`latestMessage`, so the client can open straight into the conversation. The array is',
|
||||
'always present, empty for a brand-new thread.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(WithMembersRequest, 'The member ids and the page size'),
|
||||
responses: {
|
||||
200: json(ChatThreadWithMessagesDto, 'The thread with a page of its messages'),
|
||||
400: {
|
||||
description: [
|
||||
'Fewer than 2 members (naming only yourself) or more than 50, counting the caller',
|
||||
'(empty body)',
|
||||
].join(' '),
|
||||
},
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const members = [...new Set([id, ...(await memberIds(c))])]
|
||||
// A thread needs someone else in it; naming only yourself is a bad request
|
||||
// rather than a lonely thread.
|
||||
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
|
||||
|
||||
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
|
||||
const limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
|
||||
const thread = await threadWithMessages(c, chatThreadId, id, limit)
|
||||
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
|
||||
return c.json(thread)
|
||||
}
|
||||
)
|
||||
|
||||
// A page of one thread's messages, newest first — a bare array, not a thread object.
|
||||
// The client reads a conversation through either spelling: `/thread/2?messageCount=50`
|
||||
// and `/thread/2/message?MessageCount=16` answer the same thing, so they share a
|
||||
// handler; only the default page size differs, matching what each caller sends.
|
||||
//
|
||||
// 404 rather than 403 for a thread the caller isn't in: whether a thread exists is
|
||||
// itself private, so a non-member gets the same answer as for a thread that's gone.
|
||||
// An empty thread is still a 200 with `[]` — a conversation just opened with someone
|
||||
// has no messages yet and still has to open.
|
||||
// One thread with its recent messages — what the client opens a conversation with
|
||||
// (`/thread/13?messageCount=50`). An OBJECT, the same shape /thread/withmembers
|
||||
// answers: the client parses this one as a thread and rejects a bare array
|
||||
// ("expected '{', actual '['"). Only /thread/:id/message below serves an array.
|
||||
//
|
||||
// 404s only for a thread the caller isn't in, not for one that's simply empty: a
|
||||
// thread just opened with someone has no messages yet and still has to open.
|
||||
.get(
|
||||
'/thread/:id{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Threads'],
|
||||
summary: 'One thread with its recent messages',
|
||||
description: [
|
||||
'What the client opens a conversation with (`/thread/13?messageCount=50`). An OBJECT —',
|
||||
'the same shape `/thread/withmembers` answers: the client parses this one as a thread',
|
||||
"and rejects a bare array (\"expected '{', actual '['\"). Only `/thread/{id}/message`",
|
||||
'serves an array. 404s only for a thread the caller isn’t in, not for one that’s simply',
|
||||
'empty — a thread just opened with someone has no messages yet and still has to open.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [THREAD_ID_PARAM, messageCountParam(DEFAULT_THREAD_MESSAGE_COUNT)],
|
||||
responses: {
|
||||
200: json(ChatThreadWithMessagesDto, 'The thread with a page of its messages'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: NOT_A_MEMBER_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
const limit = messageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
|
||||
const thread = await threadWithMessages(c, chatThreadId, id, limit)
|
||||
return thread === null ? c.notFound() : c.json(thread)
|
||||
}
|
||||
)
|
||||
|
||||
// Send a message to a thread that already exists — every message after the one that
|
||||
// opened the conversation. `/thread/18` is what the client posts; `/thread/18/message`
|
||||
// is the same call under the reference's other spelling.
|
||||
//
|
||||
// Answers the SendMessageResponse wrapper (`{chatMessage, chatResult}`), not a bare
|
||||
// message. Blank or missing contents is invalid-arguments with no message attached,
|
||||
// rather than an error status.
|
||||
.post('/thread/:id{[0-9]+}', sendToThreadRoute('`/thread/{id}`'), (c) => sendToThread(c))
|
||||
.post('/thread/:id{[0-9]+}/message', sendToThreadRoute('`/thread/{id}/message`'), (c) =>
|
||||
sendToThread(c)
|
||||
)
|
||||
|
||||
// Rename a thread (`name=my chat`). Any member may rename — there's no owner — and an
|
||||
// empty name clears it back to unnamed, which renders as the member list. Answers a
|
||||
// bare ChatResult: 3 when the caller isn't on the thread, 0 on success.
|
||||
.on(
|
||||
['POST', 'PUT'],
|
||||
'/thread/:id{[0-9]+}/rename',
|
||||
chatResultRoute(
|
||||
'Rename a thread',
|
||||
[
|
||||
'Any member may rename — there is no owner — and an empty name clears it back to unnamed,',
|
||||
'which renders as the member list. The name is truncated to 128 characters rather than',
|
||||
'rejected. Answers a bare ChatResult: 3 when the caller isn’t on the thread, 0 on success.',
|
||||
].join(' '),
|
||||
{ requestBody: form(RenameThreadRequest, 'The new name') }
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
}
|
||||
|
||||
const name = ((await formField(c, 'name')) ?? '').trim().slice(0, MAX_THREAD_NAME_LENGTH)
|
||||
await setThreadName(c.env.DB, chatThreadId, name)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
}
|
||||
)
|
||||
|
||||
// Leave a thread. The thread and its history survive — only the caller's membership
|
||||
// goes, so they stop seeing it and the remaining members keep the conversation.
|
||||
//
|
||||
// A "Player <@U…> left" notice is posted first, so the others see why the roster
|
||||
// changed; the leaver is still a member at that moment and gets the push too, which
|
||||
// is what tells their client the thread is gone.
|
||||
.on(
|
||||
['POST', 'DELETE'],
|
||||
'/thread/:id{[0-9]+}/leave',
|
||||
chatResultRoute(
|
||||
'Leave a thread',
|
||||
[
|
||||
'The thread and its history survive — only the caller’s membership goes, so they stop',
|
||||
'seeing it and the remaining members keep the conversation. A "Player <@U…> left" system',
|
||||
'notice is posted first so the others see why the roster changed; the leaver is still a',
|
||||
'member at that moment and gets the push too, which is what tells their client the thread',
|
||||
'is gone.',
|
||||
].join(' ')
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
}
|
||||
|
||||
const notice = await postMessage(c.env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
contents: leftChatContents(id),
|
||||
})
|
||||
await pushChatMessage(c, notice)
|
||||
|
||||
await removeThreadMember(c.env.DB, chatThreadId, id)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
}
|
||||
)
|
||||
|
||||
// Snooze or unsnooze a thread (`snooze=True`), for the caller alone — snoozing is a
|
||||
// per-member setting, so it never affects what anyone else sees.
|
||||
//
|
||||
// The client sends a boolean while the field it reads back is `snoozedUntil`, a time.
|
||||
// `True` is therefore stored as a far-future instant meaning "muted indefinitely", and
|
||||
// `False` clears it. If the real server instead snoozes for a fixed window, this is
|
||||
// the one line to change.
|
||||
.on(
|
||||
['POST', 'PUT'],
|
||||
'/thread/:id{[0-9]+}/snooze',
|
||||
chatResultRoute(
|
||||
'Snooze or unsnooze a thread',
|
||||
[
|
||||
'Per-member, for the caller alone — it never affects what anyone else sees. The client',
|
||||
'sends a boolean while the field it reads back (`snoozedUntil`) is a time, so `True` is',
|
||||
'stored as a far-future instant (9999-12-31T23:59:59Z) meaning "muted indefinitely" and',
|
||||
'`False` clears it.',
|
||||
].join(' '),
|
||||
{ requestBody: form(SnoozeThreadRequest, 'The snooze flag') }
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
}
|
||||
|
||||
const on = await formBool(c, 'snooze')
|
||||
await setThreadSnoozed(c.env.DB, chatThreadId, id, on ? SNOOZED_INDEFINITELY : null)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
}
|
||||
)
|
||||
|
||||
// Favorite or unfavorite a thread (`favorite=True`), for the caller alone — like
|
||||
// snoozing, it's a per-member flag that pins the thread in their own inbox.
|
||||
.on(
|
||||
['PUT', 'POST'],
|
||||
'/thread/:id{[0-9]+}/favorite',
|
||||
chatResultRoute(
|
||||
'Favorite or unfavorite a thread',
|
||||
[
|
||||
'Like snoozing, a per-member flag that pins the thread in the caller’s own inbox and',
|
||||
'leaves everyone else’s untouched.',
|
||||
].join(' '),
|
||||
{ requestBody: form(FavoriteThreadRequest, 'The favorite flag') }
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
}
|
||||
|
||||
await setThreadFavorited(c.env.DB, chatThreadId, id, await formBool(c, 'favorite'))
|
||||
return c.json(CHAT_SUCCESS)
|
||||
}
|
||||
)
|
||||
|
||||
// Add a player to a thread (`/thread/20/member/2`). Gated on the caller already being
|
||||
// in it — you can only pull someone into a conversation you're part of.
|
||||
//
|
||||
// Answers a bare ChatResult rather than an HTTP status, as the reference does: 3 when
|
||||
// the caller isn't a member (which doubles as "no such thread", keeping a thread's
|
||||
// existence private), 4 when the target is already on it, 0 on success. Idempotent —
|
||||
// re-adding an existing member changes nothing.
|
||||
.post(
|
||||
'/thread/:id{[0-9]+}/member/:playerId{[0-9]+}',
|
||||
chatResultRoute(
|
||||
'Add a player to a thread',
|
||||
[
|
||||
'Gated on the caller already being in it — you can only pull someone into a conversation',
|
||||
'you’re part of. Answers a bare ChatResult rather than an HTTP status, as the reference',
|
||||
'does: 3 when the caller isn’t a member (which doubles as "no such thread", keeping a',
|
||||
'thread’s existence private), 4 when the target is already on it, 0 on success.',
|
||||
'Idempotent — re-adding an existing member changes nothing.',
|
||||
].join(' '),
|
||||
{
|
||||
parameters: [
|
||||
{
|
||||
name: 'playerId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The account id to add (digits only)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
successDescription: '0 success · 3 caller not a member · 4 target already on the thread',
|
||||
}
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
}
|
||||
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
if (await isThreadMember(c.env.DB, chatThreadId, playerId)) {
|
||||
return c.json(CHAT_PLAYER_ALREADY_ON_THREAD)
|
||||
}
|
||||
|
||||
await addThreadMember(c.env.DB, chatThreadId, playerId)
|
||||
return c.json(CHAT_SUCCESS)
|
||||
}
|
||||
)
|
||||
|
||||
// Move the caller's read pointer — `/thread/15/read` for the whole thread, or
|
||||
// `/thread/15/message/:messageId/read` for a specific message, which the client uses
|
||||
// when the view sits on a message rather than the bottom. Both verbs, as the client
|
||||
// sends either. Answers the bare ChatResult integer the reference does.
|
||||
//
|
||||
// The pointer only moves forward, and never past the thread's real latest message: an
|
||||
// id the client made up (or one it read from a synthetic message) can't strand the
|
||||
// thread as permanently read.
|
||||
.on(
|
||||
['PUT', 'POST'],
|
||||
'/thread/:id{[0-9]+}/read',
|
||||
chatResultRoute(
|
||||
'Mark a whole thread read',
|
||||
[
|
||||
'Moves the caller’s read pointer to the thread’s latest message. The pointer only moves',
|
||||
'forward and never past the thread’s real latest message, so an id the client made up',
|
||||
'can’t strand the thread as permanently read. 404s for a thread the caller isn’t on.',
|
||||
].join(' '),
|
||||
{ successDescription: 'Always 0 (success)', notFound: true }
|
||||
),
|
||||
(c) => markRead(c)
|
||||
)
|
||||
.on(
|
||||
['PUT', 'POST'],
|
||||
'/thread/:id{[0-9]+}/message/:messageId{[0-9]+}/read',
|
||||
chatResultRoute(
|
||||
'Mark read up to a specific message',
|
||||
[
|
||||
'What the client sends when the view sits on a message rather than the bottom. Same',
|
||||
'forward-only, clamped pointer as the whole-thread form. 404s for a thread the caller',
|
||||
'isn’t on.',
|
||||
].join(' '),
|
||||
{
|
||||
parameters: [
|
||||
{
|
||||
name: 'messageId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The message to read up to (digits only)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
successDescription: 'Always 0 (success)',
|
||||
notFound: true,
|
||||
}
|
||||
),
|
||||
(c) => markRead(c, Number.parseInt(c.req.param('messageId'), 10))
|
||||
)
|
||||
|
||||
// A page of one thread's messages, newest first — a bare array, unlike /thread/:id.
|
||||
// `MessageCount` is the page size. 404 rather than 403 for a thread the caller isn't
|
||||
// in: whether a thread exists is itself private, so a non-member gets the same answer
|
||||
// as for a thread that's gone.
|
||||
.get(
|
||||
'/thread/:id{[0-9]+}/message',
|
||||
describeRoute({
|
||||
tags: ['Messages'],
|
||||
summary: 'A page of one thread’s messages',
|
||||
description: [
|
||||
'Newest first — a bare ARRAY, unlike `/thread/{id}`, which serves the thread object.',
|
||||
'`MessageCount` is the page size. 404 rather than 403 for a thread the caller isn’t in:',
|
||||
'whether a thread exists is itself private, so a non-member gets the same answer as for a',
|
||||
'thread that’s gone. An empty thread is still a 200 with `[]`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [THREAD_ID_PARAM, messageCountParam(DEFAULT_MESSAGE_COUNT)],
|
||||
responses: {
|
||||
200: json(ChatMessageDto.array(), 'The page of messages, newest first (empty when none)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: NOT_A_MEMBER_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
|
||||
|
||||
return c.json(await getThreadMessages(c.env.DB, chatThreadId, { limit: messageCount(c) }))
|
||||
}
|
||||
)
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
app.get(
|
||||
'/openapi.json',
|
||||
describeRoute({ hide: true }),
|
||||
withCleanSpec(
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: 'recflare chat',
|
||||
version: '1.0.0',
|
||||
description: [
|
||||
'Chat threads and messages for recflare, a private-server reimplementation of the Rec',
|
||||
'Room backend. A thread is a conversation — a DM pair, a named group, or a system',
|
||||
'thread — and membership is both the authorization gate and the `playerIds` the client',
|
||||
'renders. Threads, membership and messages are D1-backed; every message also fans out',
|
||||
'over the `notify` hub Durable Object as a ChatMessageReceived frame, so a conversation',
|
||||
'updates live instead of on the next poll. (The hub frame carries a STRING `Id` — the',
|
||||
'client dispatches on it and silently drops a numeric one.)',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://chat.recflare.net', description: 'Production' }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import type { HonoApp } from '@repo/hono-helpers'
|
||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||
// Type-only import (erased at build) of the DO class owned by the `notify` worker,
|
||||
// so this worker can push websocket notifications through its RPC surface.
|
||||
import type { NotificationsHub } from '../../notify/src/notifications-hub'
|
||||
|
||||
export type Env = SharedHonoEnv & {
|
||||
// add additional Bindings here
|
||||
// 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
|
||||
// signed by `auth` verify here.
|
||||
JWT_SECRET: SecretsStoreSecret
|
||||
/** Shared `recflare` D1 — this worker owns the `message` and thread tables. */
|
||||
DB: D1Database
|
||||
/** The `notify` worker's NotificationsHub DO — pushes ChatMessageReceived to members. */
|
||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Chat messages on the shared `recflare` D1 database — the individual messages posted
|
||||
* to a chat thread (a DM pair or a group). Stored as columns rather than a JSON blob
|
||||
* (mirroring the reference model): every field is a scalar the server itself reads, and the
|
||||
* one client-shaped part — `contents` — is already an opaque string.
|
||||
*
|
||||
* `contents` is the client's envelope, e.g.
|
||||
* `{"Type":0,"Version":1,"Data":"This is jordanparki7 from your Oculus friends."}`,
|
||||
* where `Type` selects how the client renders `Data` (plain text, an invite, an image
|
||||
* …) and `Version` versions that encoding. It is stored verbatim and served back
|
||||
* untouched, so new message types need no schema change here.
|
||||
*
|
||||
* `chatMessageId` is server-assigned and unique across all threads (AUTOINCREMENT), the
|
||||
* way the client expects to be able to reference a message by id alone.
|
||||
*
|
||||
* The `chat` worker owns this schema/migration (migrations/0001_message.sql, applied
|
||||
* under its own `migrations_table` so it doesn't clash with the other workers'
|
||||
* migrations that share the database). `SCHEMA_DDL` mirrors that migration so tests can
|
||||
* build the table directly.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0001_message.sql). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS message (
|
||||
chat_message_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_thread_id INTEGER NOT NULL,
|
||||
sender_player_id INTEGER NOT NULL,
|
||||
time_sent TEXT NOT NULL,
|
||||
contents TEXT NOT NULL,
|
||||
moderation_state INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
// Thread listing is always (thread, id) — newest-first pages walk this index.
|
||||
`CREATE INDEX IF NOT EXISTS idx_message_thread ON message (chat_thread_id, chat_message_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_message_sender ON message (sender_player_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* Whether a message has been touched by moderation. `None` is the overwhelmingly common
|
||||
* case and the column default; the others let a message be withheld from the thread
|
||||
* without deleting the row.
|
||||
*/
|
||||
export enum ChatModerationState {
|
||||
None = 0,
|
||||
Flagged = 1,
|
||||
Hidden = 2,
|
||||
}
|
||||
|
||||
/** A chat message, in the shape the client sends and receives it. */
|
||||
export interface ChatMessage {
|
||||
chatMessageId: number
|
||||
chatThreadId: number
|
||||
senderPlayerId: number
|
||||
/** ISO-8601 UTC instant, as .NET serializes `DateTime` (e.g. `2022-05-22T12:47:03.6536656`). */
|
||||
timeSent: string
|
||||
/** The raw message envelope, e.g. `{"Type":0,"Version":1,"Data":"hello"}`. */
|
||||
contents: string
|
||||
moderationState: ChatModerationState
|
||||
}
|
||||
|
||||
/** A new message, before the server assigns its id and (by default) its timestamp. */
|
||||
export interface NewChatMessage {
|
||||
chatThreadId: number
|
||||
senderPlayerId: number
|
||||
contents: string
|
||||
/** Defaults to now. Pass only when replaying a message with its original timestamp. */
|
||||
timeSent?: string
|
||||
moderationState?: ChatModerationState
|
||||
}
|
||||
|
||||
/** The stored row, before it's mapped back to the client's camelCase shape. */
|
||||
interface MessageRow {
|
||||
chat_message_id: number
|
||||
chat_thread_id: number
|
||||
sender_player_id: number
|
||||
time_sent: string
|
||||
contents: string
|
||||
moderation_state: number
|
||||
}
|
||||
|
||||
function toMessage(row: MessageRow): ChatMessage {
|
||||
return {
|
||||
chatMessageId: row.chat_message_id,
|
||||
chatThreadId: row.chat_thread_id,
|
||||
senderPlayerId: row.sender_player_id,
|
||||
timeSent: row.time_sent,
|
||||
contents: row.contents,
|
||||
moderationState: row.moderation_state,
|
||||
}
|
||||
}
|
||||
|
||||
/** Post a message to a thread, returning it with its server-assigned id. */
|
||||
export async function insertMessage(db: D1Database, message: NewChatMessage): Promise<ChatMessage> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO message (chat_thread_id, sender_player_id, time_sent, contents, moderation_state)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
message.chatThreadId,
|
||||
message.senderPlayerId,
|
||||
message.timeSent ?? new Date().toISOString(),
|
||||
message.contents,
|
||||
message.moderationState ?? ChatModerationState.None
|
||||
)
|
||||
.first<MessageRow>()
|
||||
// RETURNING on an INSERT that ran always yields the row; a null here means the
|
||||
// insert itself failed, which D1 would already have thrown for.
|
||||
if (row === null) throw new Error('failed to insert chat message')
|
||||
return toMessage(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of a thread's messages, newest first. `before` pages backwards through the
|
||||
* history: pass the `chatMessageId` of the oldest message you already have.
|
||||
*/
|
||||
export async function getThreadMessages(
|
||||
db: D1Database,
|
||||
chatThreadId: number,
|
||||
{ limit = 50, before }: { limit?: number; before?: number } = {}
|
||||
): Promise<ChatMessage[]> {
|
||||
const { results } = before
|
||||
? await db
|
||||
.prepare(
|
||||
`SELECT * FROM message WHERE chat_thread_id = ?1 AND chat_message_id < ?2
|
||||
ORDER BY chat_message_id DESC LIMIT ?3`
|
||||
)
|
||||
.bind(chatThreadId, before, limit)
|
||||
.all<MessageRow>()
|
||||
: await db
|
||||
.prepare(
|
||||
`SELECT * FROM message WHERE chat_thread_id = ?1
|
||||
ORDER BY chat_message_id DESC LIMIT ?2`
|
||||
)
|
||||
.bind(chatThreadId, limit)
|
||||
.all<MessageRow>()
|
||||
return results.map(toMessage)
|
||||
}
|
||||
|
||||
/** A single message by id, or null if there's no such message. */
|
||||
export async function getMessage(
|
||||
db: D1Database,
|
||||
chatMessageId: number
|
||||
): Promise<ChatMessage | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT * FROM message WHERE chat_message_id = ?1')
|
||||
.bind(chatMessageId)
|
||||
.first<MessageRow>()
|
||||
return row === null ? null : toMessage(row)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the chat worker.
|
||||
*
|
||||
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
|
||||
* rationale as the auth/accounts/econ/match workers: a reverse-engineered protocol,
|
||||
* lenient handlers, no runtime validation.
|
||||
*
|
||||
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
|
||||
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
|
||||
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
|
||||
* schema inline, which renders correctly in any tool.
|
||||
*/
|
||||
|
||||
/** Emit a zod schema as an `application/json` response body. */
|
||||
export function json(schema: z.ZodType, description: string) {
|
||||
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||
}
|
||||
|
||||
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
|
||||
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||
return jsonSchema as OpenAPIV3_1.SchemaObject
|
||||
}
|
||||
|
||||
/** A form-urlencoded / multipart request body (the client posts both). */
|
||||
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
const s = toOpenApiSchema(schema)
|
||||
return {
|
||||
description,
|
||||
content: {
|
||||
'application/x-www-form-urlencoded': { schema: s },
|
||||
'multipart/form-data': { schema: s },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** An `application/json` request body. */
|
||||
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
|
||||
}
|
||||
|
||||
/** The empty-body 401 the auth-gated routes return. */
|
||||
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
|
||||
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
export const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
/**
|
||||
* The 404 a thread-scoped route answers when the caller isn't a member. Deliberately
|
||||
* indistinguishable from "no such thread" — whether a thread exists is itself private.
|
||||
*/
|
||||
export const NOT_A_MEMBER_RESPONSE = {
|
||||
description: 'Not a member of the thread (or no such thread) — the two are indistinguishable',
|
||||
}
|
||||
|
||||
// ---- Response schemas ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A chat message as stored and served (see message-db.ts). `contents` is the client's own
|
||||
* envelope (`{"Type":0,"Version":1,"Data":"hello"}`) — stored verbatim and served back
|
||||
* untouched, so new message types need no schema change. A `senderPlayerId` of -5 is the
|
||||
* system pseudo-player the "started a chat" / "left" notices are posted as.
|
||||
*/
|
||||
export const ChatMessageDto = z.object({
|
||||
chatMessageId: z.int().describe('Server-assigned, unique across all threads'),
|
||||
chatThreadId: z.int(),
|
||||
senderPlayerId: z.int().describe('-5 is the system sender (join/leave notices)'),
|
||||
timeSent: z.string().describe('ISO-8601 UTC instant, as .NET serializes DateTime'),
|
||||
contents: z.string().describe('The raw client envelope, e.g. {"Type":0,"Version":1,"Data":"hi"}'),
|
||||
moderationState: z.int().describe('0 None, 1 Flagged, 2 Hidden'),
|
||||
})
|
||||
|
||||
/** The per-viewer fields every rendered thread carries, plus the thread's own. */
|
||||
const threadBase = {
|
||||
chatThreadId: z.int(),
|
||||
playerIds: z.array(z.int()).describe('The thread’s members, ordered by id'),
|
||||
lastReadMessageId: z
|
||||
.int()
|
||||
.describe('0 when never read — never null (the client deserializes a non-nullable int)'),
|
||||
chatThreadName: z
|
||||
.string()
|
||||
.describe('Empty for DMs and unnamed groups — never null (the client dereferences it)'),
|
||||
chatThreadType: z.int().describe('Always 0 — the only type the reference serves'),
|
||||
snoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
|
||||
isFavorited: z.boolean(),
|
||||
}
|
||||
|
||||
/**
|
||||
* A thread as it appears in the thread LIST: the thread, its members, the caller's own
|
||||
* read/snooze/favorite state, and its single most recent message.
|
||||
*/
|
||||
export const ChatThreadDto = z.object({
|
||||
latestMessage: ChatMessageDto.nullable().describe('Null only for a thread with no messages yet'),
|
||||
...threadBase,
|
||||
})
|
||||
|
||||
/**
|
||||
* A thread as it appears when a conversation is OPENED: the same fields, but with a page
|
||||
* of `messages` (newest first) in place of `latestMessage`. The client is sent one or the
|
||||
* other, never both; `messages` is always present, empty for a brand-new thread.
|
||||
*/
|
||||
export const ChatThreadWithMessagesDto = z.object({
|
||||
...threadBase,
|
||||
messages: z.array(ChatMessageDto).describe('Newest first; empty for a thread with nothing in it'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The bare ChatResult integer several actions answer with (HTTP 200 either way): 0
|
||||
* success, 1 invalid arguments, 3 membership not found (which doubles as "no such
|
||||
* thread"), 4 player already on the thread.
|
||||
*/
|
||||
export const ChatResult = z
|
||||
.int()
|
||||
.describe('0 success · 1 invalid arguments · 3 membership not found · 4 already on thread')
|
||||
|
||||
/**
|
||||
* `POST /thread` — the reference's wrapper: the created (or resolved) thread plus the
|
||||
* result of the first message. Blank `messageContents` opens the thread without posting
|
||||
* and reports invalid-arguments (1), still with the thread attached.
|
||||
*/
|
||||
export const CreateThreadResponse = z.object({
|
||||
chatThread: ChatThreadDto,
|
||||
chatResult: ChatResult,
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /thread/:id` and `/thread/:id/message` — the whole thread with its messages, not
|
||||
* just the message that was sent, so the client re-renders the conversation from one
|
||||
* response.
|
||||
*/
|
||||
export const SendMessageResponse = z.object({
|
||||
chatResult: ChatResult,
|
||||
chatThread: ChatThreadWithMessagesDto.nullable(),
|
||||
})
|
||||
|
||||
/** `GET /` — the liveness probe. */
|
||||
export const ServiceStatus = z.object({
|
||||
service: z.literal('chat'),
|
||||
status: z.literal('ok'),
|
||||
})
|
||||
|
||||
// ---- Request schemas -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `POST /thread` form body. `ids` is repeated (`ids=2&ids=155`) and names the OTHER
|
||||
* members; the caller is always added. Values that aren't integers are dropped. The
|
||||
* fields are also read from the query string, since the same call is easy to hand-write
|
||||
* that way.
|
||||
*/
|
||||
export const CreateThreadRequest = z.object({
|
||||
ids: z.array(z.int()).describe('Repeated: ids=2&ids=155. The caller is added automatically'),
|
||||
messageContents: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
[
|
||||
'The client envelope, stored verbatim and unparsed. Blank/absent opens the thread',
|
||||
'without posting a message and reports chatResult 1',
|
||||
].join(' ')
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /thread/withmembers` form body — the client's GetChatBetweenPlayers. Same
|
||||
* repeated `ids`, plus the page size for the returned `messages`.
|
||||
*/
|
||||
export const WithMembersRequest = z.object({
|
||||
ids: z.array(z.int()).describe('Repeated: ids=2&ids=155. The caller is added automatically'),
|
||||
messageCount: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe('Page size for `messages`; defaults to 50, capped at 100'),
|
||||
})
|
||||
|
||||
/** `POST /thread/:id` (and `/thread/:id/message`) form body. */
|
||||
export const SendMessageRequest = z.object({
|
||||
messageContents: z
|
||||
.string()
|
||||
.describe(
|
||||
[
|
||||
'The client envelope (Type/Version/Data), stored verbatim. Blank or missing stores',
|
||||
'nothing and reports chatResult 1, still with the thread attached',
|
||||
].join(' ')
|
||||
),
|
||||
messageCount: z.int().optional().describe('Page size for the returned thread’s `messages`'),
|
||||
})
|
||||
|
||||
/** `POST|PUT /thread/:id/rename` form body. Any member may rename; there is no owner. */
|
||||
export const RenameThreadRequest = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.describe('Truncated to 128 chars, not rejected. Empty clears it back to unnamed'),
|
||||
})
|
||||
|
||||
/** `POST|PUT /thread/:id/snooze` form body. */
|
||||
export const SnoozeThreadRequest = z.object({
|
||||
snooze: z
|
||||
.string()
|
||||
.describe('`True`/`False` as the client spells it (`1`/`yes` also count as true)'),
|
||||
})
|
||||
|
||||
/** `PUT|POST /thread/:id/favorite` form body. */
|
||||
export const FavoriteThreadRequest = z.object({
|
||||
favorite: z
|
||||
.string()
|
||||
.describe('`True`/`False` as the client spells it (`1`/`yes` also count as true)'),
|
||||
})
|
||||
|
||||
// ---- Shared parameters -----------------------------------------------------
|
||||
|
||||
/** The numeric `:id` path segment naming a thread (constrained to digits by the route). */
|
||||
export const THREAD_ID_PARAM = {
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Chat thread id (digits only — a non-numeric path matches no route)',
|
||||
schema: { type: 'string' },
|
||||
} as const
|
||||
|
||||
/** The `MessageCount` / `messageCount` query param the GET routes accept. */
|
||||
export function messageCountParam(fallback: number) {
|
||||
return {
|
||||
name: 'MessageCount',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: `Page size; defaults to ${fallback}, capped at 100. \`messageCount\` is accepted too. Anything unparseable or out of range falls back rather than 400ing`,
|
||||
schema: { type: 'integer' },
|
||||
} as const
|
||||
}
|
||||
@@ -1,10 +1,75 @@
|
||||
import { SELF } from 'cloudflare:test'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import '../../chat.app'
|
||||
|
||||
import { NotificationType } from '../../../../notify/src/notification-types'
|
||||
import {
|
||||
ChatModerationState,
|
||||
getMessage,
|
||||
getThreadMessages,
|
||||
insertMessage,
|
||||
SCHEMA_DDL,
|
||||
} from '../../message-db'
|
||||
import {
|
||||
createThread,
|
||||
findThreadWithMembers,
|
||||
getThreadForPlayer,
|
||||
getThreadsForPlayer,
|
||||
isThreadMember,
|
||||
leftChatContents,
|
||||
markThreadRead,
|
||||
postMessage,
|
||||
removeThreadMember,
|
||||
setThreadFavorited,
|
||||
startedChatContents,
|
||||
SYSTEM_SENDER_ID,
|
||||
THREAD_SCHEMA_DDL,
|
||||
} from '../../thread-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
import type { ChatMessage } from '../../message-db'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
|
||||
const ORIGIN = 'https://example.com'
|
||||
|
||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded
|
||||
// into the JWT_SECRET store.
|
||||
const TEST_SECRET = 'test-signing-key'
|
||||
|
||||
function b64url(input: ArrayBuffer | string): string {
|
||||
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
async function bearer(sub: number): Promise<Record<string, string>> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||
JSON.stringify({ sub: String(sub), exp: now + 3600 })
|
||||
)}`
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(TEST_SECRET),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
)
|
||||
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
|
||||
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
|
||||
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of THREAD_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
describe('chat endpoints', () => {
|
||||
it('GET / reports service status', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/`)
|
||||
@@ -12,9 +77,1255 @@ describe('chat endpoints', () => {
|
||||
expect(await res.json()).toEqual({ service: 'chat', status: 'ok' })
|
||||
})
|
||||
|
||||
it('GET /thread returns an empty array', async () => {
|
||||
it('GET /thread 401s without a token', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('GET /thread serves the caller their own threads', async () => {
|
||||
const player = 881001
|
||||
const thread = await createThread(env.DB, [player, 881002])
|
||||
const latest = await postMessage(env.DB, {
|
||||
chatThreadId: thread,
|
||||
senderPlayerId: 881002,
|
||||
timeSent: '2022-02-21T18:08:56.0362822',
|
||||
contents: '{"Type":0,"Version":1,"Data":"hi"}',
|
||||
})
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread?MessageCount=16&Mode=0`, {
|
||||
headers: await bearer(player),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
expect(await res.json()).toEqual([
|
||||
{
|
||||
latestMessage: latest,
|
||||
chatThreadId: thread,
|
||||
playerIds: [player, 881002],
|
||||
lastReadMessageId: 0,
|
||||
chatThreadName: '',
|
||||
chatThreadType: 0,
|
||||
snoozedUntil: null,
|
||||
isFavorited: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('GET /thread/:id/message serves the thread newest first', async () => {
|
||||
const player = 881003
|
||||
const thread = await createThread(env.DB, [player, 881004])
|
||||
const older = await postMessage(env.DB, {
|
||||
chatThreadId: thread,
|
||||
senderPlayerId: 881004,
|
||||
timeSent: '2022-02-19T22:13:56.7224503',
|
||||
contents: '{"Type":0,"Version":1,"Data":"on discord?"}',
|
||||
})
|
||||
const newer = await postMessage(env.DB, {
|
||||
chatThreadId: thread,
|
||||
senderPlayerId: 881004,
|
||||
timeSent: '2022-02-21T18:08:56.0362822',
|
||||
contents: '{"Type":0,"Version":1,"Data":"hi"}',
|
||||
})
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message?MessageCount=16&Mode=0`, {
|
||||
headers: await bearer(player),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([newer, older])
|
||||
})
|
||||
|
||||
it('GET /thread/:id/message honours MessageCount', async () => {
|
||||
const player = 881005
|
||||
const thread = await createThread(env.DB, [player])
|
||||
for (const data of ['one', 'two', 'three']) {
|
||||
await postMessage(env.DB, {
|
||||
chatThreadId: thread,
|
||||
senderPlayerId: player,
|
||||
contents: JSON.stringify({ Type: 0, Version: 1, Data: data }),
|
||||
})
|
||||
}
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message?MessageCount=2`, {
|
||||
headers: await bearer(player),
|
||||
})
|
||||
expect(await res.json()).toHaveLength(2)
|
||||
})
|
||||
|
||||
// A thread's existence is itself private, so a non-member gets the same 404 as for a
|
||||
// thread that never existed — not a 403 that confirms it's there.
|
||||
it('GET /thread/:id/message hides threads the caller is not in', async () => {
|
||||
const thread = await createThread(env.DB, [881006, 881007])
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message`, {
|
||||
headers: await bearer(881008),
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
|
||||
const missing = await SELF.fetch(`${ORIGIN}/thread/999999/message`, {
|
||||
headers: await bearer(881008),
|
||||
})
|
||||
expect(missing.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('message storage', () => {
|
||||
// A real message as the client sends it, kept verbatim (including the JSON-in-a-string
|
||||
// `contents` envelope) so the round-trip is tested against the actual payload shape.
|
||||
const CONTENTS =
|
||||
'{"Type":0,"Version":1,"Data":"This is jordanparki7 from your Oculus friends. We\'re friends in Rec Room now!"}'
|
||||
|
||||
it('round-trips a message, assigning an id', async () => {
|
||||
const stored = await insertMessage(env.DB, {
|
||||
chatThreadId: 116181128,
|
||||
senderPlayerId: 10441985,
|
||||
timeSent: '2022-05-22T12:47:03.6536656',
|
||||
contents: CONTENTS,
|
||||
})
|
||||
expect(stored.chatMessageId).toBeGreaterThan(0)
|
||||
expect(stored).toEqual({
|
||||
chatMessageId: stored.chatMessageId,
|
||||
chatThreadId: 116181128,
|
||||
senderPlayerId: 10441985,
|
||||
timeSent: '2022-05-22T12:47:03.6536656',
|
||||
contents: CONTENTS,
|
||||
moderationState: ChatModerationState.None,
|
||||
})
|
||||
expect(await getMessage(env.DB, stored.chatMessageId)).toEqual(stored)
|
||||
})
|
||||
|
||||
it('defaults timeSent to now', async () => {
|
||||
const stored = await insertMessage(env.DB, {
|
||||
chatThreadId: 999,
|
||||
senderPlayerId: 42,
|
||||
contents: CONTENTS,
|
||||
})
|
||||
expect(Date.parse(stored.timeSent)).toBeGreaterThan(Date.now() - 60_000)
|
||||
})
|
||||
|
||||
it('lists a thread newest first and pages backwards', async () => {
|
||||
const thread = 116181129
|
||||
const first = await insertMessage(env.DB, {
|
||||
chatThreadId: thread,
|
||||
senderPlayerId: 1,
|
||||
contents: CONTENTS,
|
||||
})
|
||||
const second = await insertMessage(env.DB, {
|
||||
chatThreadId: thread,
|
||||
senderPlayerId: 2,
|
||||
contents: CONTENTS,
|
||||
})
|
||||
|
||||
const page = await getThreadMessages(env.DB, thread)
|
||||
expect(page.map((m) => m.chatMessageId)).toEqual([second.chatMessageId, first.chatMessageId])
|
||||
|
||||
const older = await getThreadMessages(env.DB, thread, { before: second.chatMessageId })
|
||||
expect(older.map((m) => m.chatMessageId)).toEqual([first.chatMessageId])
|
||||
|
||||
// Messages from other threads never leak into a thread's listing.
|
||||
expect(await getThreadMessages(env.DB, 404)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('thread storage', () => {
|
||||
// The viewing player from the captured thread-list response.
|
||||
const VIEWER = 10441985
|
||||
|
||||
function contents(data: string): string {
|
||||
return JSON.stringify({ Type: 0, Version: 1, Data: data })
|
||||
}
|
||||
|
||||
it('renders the thread list in the shape the client expects', async () => {
|
||||
const dm = await createThread(env.DB, [9489959, VIEWER])
|
||||
const latest = await postMessage(env.DB, {
|
||||
chatThreadId: dm,
|
||||
senderPlayerId: VIEWER,
|
||||
timeSent: '2022-05-22T12:47:03.6536656',
|
||||
contents: contents(
|
||||
"This is jordanparki7 from your Oculus friends. We're friends in Rec Room now!"
|
||||
),
|
||||
})
|
||||
await markThreadRead(env.DB, dm, VIEWER, latest.chatMessageId)
|
||||
|
||||
const [thread] = await getThreadsForPlayer(env.DB, VIEWER)
|
||||
expect(thread).toEqual({
|
||||
latestMessage: latest,
|
||||
chatThreadId: dm,
|
||||
playerIds: [9489959, VIEWER],
|
||||
lastReadMessageId: latest.chatMessageId,
|
||||
chatThreadName: '',
|
||||
chatThreadType: 0,
|
||||
snoozedUntil: null,
|
||||
isFavorited: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a named group thread with all its members', async () => {
|
||||
const members = [VIEWER, 10452682, 12534039, 12535328, 12631702]
|
||||
const group = await createThread(env.DB, members, 'Group Chat =]')
|
||||
await postMessage(env.DB, {
|
||||
chatThreadId: group,
|
||||
senderPlayerId: VIEWER,
|
||||
contents: contents('sussy baka'),
|
||||
})
|
||||
|
||||
const thread = await getThreadForPlayer(env.DB, group, VIEWER)
|
||||
expect(thread?.chatThreadName).toBe('Group Chat =]')
|
||||
expect(thread?.playerIds).toEqual(members.sort((a, b) => a - b))
|
||||
})
|
||||
|
||||
// System notices ("Player <@U…> started a chat") and player messages both carry
|
||||
// markup the server must not touch — the mention token, and HTML entities the
|
||||
// client escaped itself. Stored and served back byte-for-byte.
|
||||
it('stores message contents verbatim, markup and all', async () => {
|
||||
const thread = await createThread(env.DB, [VIEWER, 29565301])
|
||||
const notice = await postMessage(env.DB, {
|
||||
chatThreadId: thread,
|
||||
senderPlayerId: 29565301,
|
||||
contents: contents('Player <@U29565301> started a chat'),
|
||||
})
|
||||
const escaped = await postMessage(env.DB, {
|
||||
chatThreadId: thread,
|
||||
senderPlayerId: 29563053,
|
||||
contents: contents('Ly2 bae <<<333'),
|
||||
})
|
||||
|
||||
expect(await getMessage(env.DB, notice.chatMessageId)).toEqual(notice)
|
||||
expect((await getMessage(env.DB, escaped.chatMessageId))?.contents).toBe(escaped.contents)
|
||||
})
|
||||
|
||||
it('orders threads newest first and honours the page size', async () => {
|
||||
const viewer = 777001
|
||||
const older = await createThread(env.DB, [viewer, 1])
|
||||
const newer = await createThread(env.DB, [viewer, 2])
|
||||
await postMessage(env.DB, {
|
||||
chatThreadId: older,
|
||||
senderPlayerId: viewer,
|
||||
contents: contents('first'),
|
||||
})
|
||||
await postMessage(env.DB, {
|
||||
chatThreadId: newer,
|
||||
senderPlayerId: viewer,
|
||||
contents: contents('second'),
|
||||
})
|
||||
|
||||
const threads = await getThreadsForPlayer(env.DB, viewer)
|
||||
expect(threads.map((t) => t.chatThreadId)).toEqual([newer, older])
|
||||
expect(await getThreadsForPlayer(env.DB, viewer, { limit: 1 })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('gates reads on membership', async () => {
|
||||
const thread = await createThread(env.DB, [777002, 777003])
|
||||
expect(await isThreadMember(env.DB, thread, 777002)).toBe(true)
|
||||
expect(await isThreadMember(env.DB, thread, 777004)).toBe(false)
|
||||
// A non-member sees neither the thread nor its place in their own list.
|
||||
expect(await getThreadForPlayer(env.DB, thread, 777004)).toBeNull()
|
||||
expect(await getThreadsForPlayer(env.DB, 777004)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps read state, favorites, and snoozes per viewer', async () => {
|
||||
const a = 777005
|
||||
const b = 777006
|
||||
const thread = await createThread(env.DB, [a, b])
|
||||
const first = await postMessage(env.DB, {
|
||||
chatThreadId: thread,
|
||||
senderPlayerId: a,
|
||||
contents: contents('one'),
|
||||
})
|
||||
const second = await postMessage(env.DB, {
|
||||
chatThreadId: thread,
|
||||
senderPlayerId: b,
|
||||
contents: contents('two'),
|
||||
})
|
||||
|
||||
await markThreadRead(env.DB, thread, a, second.chatMessageId)
|
||||
await setThreadFavorited(env.DB, thread, a, true)
|
||||
await markThreadRead(env.DB, thread, b, first.chatMessageId)
|
||||
|
||||
const forA = await getThreadForPlayer(env.DB, thread, a)
|
||||
const forB = await getThreadForPlayer(env.DB, thread, b)
|
||||
expect(forA?.lastReadMessageId).toBe(second.chatMessageId)
|
||||
expect(forA?.isFavorited).toBe(true)
|
||||
expect(forB?.lastReadMessageId).toBe(first.chatMessageId)
|
||||
expect(forB?.isFavorited).toBe(false)
|
||||
|
||||
// A late ack from a second client can't walk the thread back to unread.
|
||||
await markThreadRead(env.DB, thread, a, first.chatMessageId)
|
||||
expect((await getThreadForPlayer(env.DB, thread, a))?.lastReadMessageId).toBe(
|
||||
second.chatMessageId
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves an empty thread with no latest message', async () => {
|
||||
const thread = await createThread(env.DB, [777007])
|
||||
expect(await getThreadForPlayer(env.DB, thread, 777007)).toMatchObject({
|
||||
latestMessage: null,
|
||||
lastReadMessageId: 0,
|
||||
playerIds: [777007],
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a removed member from the roster but keeps the thread', async () => {
|
||||
const thread = await createThread(env.DB, [777008, 777009])
|
||||
await postMessage(env.DB, {
|
||||
chatThreadId: thread,
|
||||
senderPlayerId: 777008,
|
||||
contents: contents('mellon'),
|
||||
})
|
||||
await removeThreadMember(env.DB, thread, 777009)
|
||||
|
||||
expect(await getThreadForPlayer(env.DB, thread, 777009)).toBeNull()
|
||||
expect((await getThreadForPlayer(env.DB, thread, 777008))?.playerIds).toEqual([777008])
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /thread/withmembers', () => {
|
||||
async function withMembers(caller: number, body: string) {
|
||||
return SELF.fetch(`${ORIGIN}/thread/withmembers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(caller)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
it('opens a thread with the named players plus the caller', async () => {
|
||||
const caller = 882001
|
||||
const res = await withMembers(caller, 'ids=2&ids=155&messageCount=50')
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const thread = (await res.json()) as {
|
||||
chatThreadId: number
|
||||
playerIds: number[]
|
||||
messages: unknown[]
|
||||
}
|
||||
// A page of messages, not the list's single latestMessage. A brand-new thread
|
||||
// isn't empty: it opens with the system "started a chat" notice.
|
||||
expect(thread.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
contents: startedChatContents(caller),
|
||||
}),
|
||||
])
|
||||
expect(thread).toMatchObject({
|
||||
playerIds: [2, 155, caller],
|
||||
lastReadMessageId: 0,
|
||||
chatThreadName: '',
|
||||
snoozedUntil: null,
|
||||
isFavorited: false,
|
||||
})
|
||||
expect(thread).not.toHaveProperty('latestMessage')
|
||||
|
||||
// The thread is real: it shows up in the caller's list, and its members can read it.
|
||||
expect((await getThreadsForPlayer(env.DB, caller)).map((t) => t.chatThreadId)).toContain(
|
||||
thread.chatThreadId
|
||||
)
|
||||
expect(await isThreadMember(env.DB, thread.chatThreadId, 155)).toBe(true)
|
||||
})
|
||||
|
||||
// Two nulls GetChatBetweenPlayers can't survive: lastReadMessageId deserializes into
|
||||
// a non-nullable int ("expected 'Number Token', actual 'null'"), and chatThreadName
|
||||
// is dereferenced unchecked (NullReferenceException). Unread is 0, unnamed is ''.
|
||||
it('never serializes lastReadMessageId or chatThreadName as null', async () => {
|
||||
const res = await withMembers(882008, 'ids=153')
|
||||
const body = await res.text()
|
||||
expect(body).not.toContain('"lastReadMessageId":null')
|
||||
expect(body).not.toContain('"chatThreadName":null')
|
||||
|
||||
const thread = JSON.parse(body) as { lastReadMessageId: number; chatThreadName: string }
|
||||
expect(thread.lastReadMessageId).toBe(0)
|
||||
expect(thread.chatThreadName).toBe('')
|
||||
})
|
||||
|
||||
it('collapses duplicate ids and the caller naming themselves', async () => {
|
||||
const caller = 882002
|
||||
const res = await withMembers(caller, `ids=${caller}&ids=882003&ids=882003`)
|
||||
expect(((await res.json()) as { playerIds: number[] }).playerIds).toEqual([caller, 882003])
|
||||
})
|
||||
|
||||
// Fetch-or-create: reopening a chat with the same people must land back in the
|
||||
// conversation that already has the history, not a fresh empty one.
|
||||
it('returns the existing thread rather than opening a second', async () => {
|
||||
const caller = 882004
|
||||
const first = (await (await withMembers(caller, 'ids=882005')).json()) as {
|
||||
chatThreadId: number
|
||||
}
|
||||
await postMessage(env.DB, {
|
||||
chatThreadId: first.chatThreadId,
|
||||
senderPlayerId: 882005,
|
||||
contents: '{"Type":0,"Version":1,"Data":"hi"}',
|
||||
})
|
||||
|
||||
const second = (await (await withMembers(caller, 'ids=882005')).json()) as {
|
||||
chatThreadId: number
|
||||
messages: unknown[]
|
||||
}
|
||||
expect(second.chatThreadId).toBe(first.chatThreadId)
|
||||
// The opening notice plus the real message — reopening adds neither a thread nor
|
||||
// a second notice.
|
||||
expect(second.messages).toHaveLength(2)
|
||||
})
|
||||
|
||||
// Membership is matched as a whole set, so a DM isn't mistaken for a group that
|
||||
// happens to contain the same two people.
|
||||
it('does not confuse a subset or superset for the same thread', async () => {
|
||||
const caller = 882009
|
||||
const pair = (await (await withMembers(caller, 'ids=882010')).json()) as {
|
||||
chatThreadId: number
|
||||
}
|
||||
const trio = (await (await withMembers(caller, 'ids=882010&ids=882011')).json()) as {
|
||||
chatThreadId: number
|
||||
}
|
||||
expect(trio.chatThreadId).not.toBe(pair.chatThreadId)
|
||||
})
|
||||
|
||||
it('honours messageCount when paging the thread', async () => {
|
||||
const caller = 882012
|
||||
const opened = (await (await withMembers(caller, 'ids=882013')).json()) as {
|
||||
chatThreadId: number
|
||||
}
|
||||
for (const data of ['one', 'two', 'three']) {
|
||||
await postMessage(env.DB, {
|
||||
chatThreadId: opened.chatThreadId,
|
||||
senderPlayerId: caller,
|
||||
contents: JSON.stringify({ Type: 0, Version: 1, Data: data }),
|
||||
})
|
||||
}
|
||||
|
||||
const paged = (await (await withMembers(caller, 'ids=882013&messageCount=2')).json()) as {
|
||||
messages: unknown[]
|
||||
}
|
||||
expect(paged.messages).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('rejects a thread with nobody else in it', async () => {
|
||||
const caller = 882006
|
||||
expect((await withMembers(caller, '')).status).toBe(400)
|
||||
expect((await withMembers(caller, `ids=${caller}`)).status).toBe(400)
|
||||
expect((await withMembers(caller, 'ids=notanumber')).status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects an oversized roster', async () => {
|
||||
const ids = Array.from({ length: 60 }, (_, i) => `ids=${883000 + i}`).join('&')
|
||||
expect((await withMembers(882007, ids)).status).toBe(400)
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/withmembers`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'ids=2',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /thread', () => {
|
||||
async function createViaPost(caller: number, body: string) {
|
||||
return SELF.fetch(`${ORIGIN}/thread`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(caller)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
// The call the client actually makes after /thread/withmembers: members, blank
|
||||
// contents. A blank field must not post an empty message, and reports
|
||||
// invalid-arguments rather than success.
|
||||
it('opens a thread with no message when messageContents is blank', async () => {
|
||||
const caller = 884001
|
||||
const res = await createViaPost(caller, 'ids=155&ids=2&messageContents=')
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as {
|
||||
chatThread: { chatThreadId: number; latestMessage: { senderPlayerId: number } | null }
|
||||
chatResult: number
|
||||
}
|
||||
expect(body.chatResult).toBe(1)
|
||||
expect(body.chatThread).toMatchObject({ playerIds: [2, 155, caller] })
|
||||
|
||||
// Nothing of the caller's was posted — but the thread still isn't empty: it opens
|
||||
// with the system notice, which is what the client needs to render it at all.
|
||||
const messages = await getThreadMessages(env.DB, body.chatThread.chatThreadId)
|
||||
expect(messages).toEqual([
|
||||
expect.objectContaining({
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
contents: startedChatContents(caller),
|
||||
}),
|
||||
])
|
||||
expect(body.chatThread.latestMessage?.senderPlayerId).toBe(SYSTEM_SENDER_ID)
|
||||
})
|
||||
|
||||
it('posts the first message when messageContents is given', async () => {
|
||||
const caller = 884002
|
||||
const contents = '{"Type":0,"Version":1,"Data":"hi"}'
|
||||
const res = await createViaPost(
|
||||
caller,
|
||||
`ids=884003&messageContents=${encodeURIComponent(contents)}`
|
||||
)
|
||||
|
||||
const body = (await res.json()) as {
|
||||
chatThread: {
|
||||
chatThreadId: number
|
||||
latestMessage: { contents: string; senderPlayerId: number } | null
|
||||
}
|
||||
chatResult: number
|
||||
}
|
||||
expect(body.chatResult).toBe(0)
|
||||
// Stored verbatim, attributed to the caller, and already the thread's latest.
|
||||
expect(body.chatThread.latestMessage).toMatchObject({ contents, senderPlayerId: caller })
|
||||
// The opening notice, then the caller's message.
|
||||
expect(await getThreadMessages(env.DB, body.chatThread.chatThreadId)).toHaveLength(2)
|
||||
})
|
||||
|
||||
// Sending to people you already have a thread with appends to it, rather than
|
||||
// stranding the message in a second conversation.
|
||||
it('appends to the existing thread with the same members', async () => {
|
||||
const caller = 884005
|
||||
const first = (await (
|
||||
await createViaPost(caller, 'ids=884006&messageContents=%7B%22Data%22%3A%22one%22%7D')
|
||||
).json()) as { chatThread: { chatThreadId: number } }
|
||||
const second = (await (
|
||||
await createViaPost(caller, 'ids=884006&messageContents=%7B%22Data%22%3A%22two%22%7D')
|
||||
).json()) as { chatThread: { chatThreadId: number } }
|
||||
|
||||
expect(second.chatThread.chatThreadId).toBe(first.chatThread.chatThreadId)
|
||||
// The opening notice, then both messages.
|
||||
expect(await getThreadMessages(env.DB, first.chatThread.chatThreadId)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('rejects a thread with nobody else in it', async () => {
|
||||
expect((await createViaPost(884004, 'messageContents=')).status).toBe(400)
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'ids=2&messageContents=',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /thread/:id', () => {
|
||||
async function openThread(caller: number, chatThreadId: number, query = '?messageCount=50') {
|
||||
return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}${query}`, {
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
}
|
||||
|
||||
it('opens a thread with its recent messages, newest first', async () => {
|
||||
const caller = 885001
|
||||
const chatThreadId = await createThread(env.DB, [caller, 885002])
|
||||
const older = await postMessage(env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: 885002,
|
||||
timeSent: '2022-02-19T22:13:56.7224503',
|
||||
contents: '{"Type":0,"Version":1,"Data":"on discord?"}',
|
||||
})
|
||||
const newer = await postMessage(env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: 885002,
|
||||
timeSent: '2022-02-21T18:08:56.0362822',
|
||||
contents: '{"Type":0,"Version":1,"Data":"hi"}',
|
||||
})
|
||||
|
||||
const res = await openThread(caller, chatThreadId)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// An object, not a bare array: the client parses this one as a thread and rejects
|
||||
// an array outright ("expected '{', actual '['").
|
||||
const body = await res.text()
|
||||
expect(body.startsWith('{')).toBe(true)
|
||||
|
||||
const thread = JSON.parse(body) as { messages: unknown[] }
|
||||
expect(thread).toMatchObject({
|
||||
chatThreadId,
|
||||
playerIds: [caller, 885002],
|
||||
lastReadMessageId: 0,
|
||||
chatThreadName: '',
|
||||
snoozedUntil: null,
|
||||
isFavorited: false,
|
||||
})
|
||||
expect(thread.messages).toEqual([newer, older])
|
||||
expect(thread).not.toHaveProperty('latestMessage')
|
||||
})
|
||||
|
||||
// The sibling route serves the same messages as a bare array — the two shapes are
|
||||
// deliberately different, and the client depends on which is which.
|
||||
it('carries the same messages /thread/:id/message serves as an array', async () => {
|
||||
const caller = 885010
|
||||
const chatThreadId = await createThread(env.DB, [caller, 885011])
|
||||
await postMessage(env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: 885011,
|
||||
contents: '{"Type":0,"Version":1,"Data":"hi"}',
|
||||
})
|
||||
|
||||
const thread = (await (await openThread(caller, chatThreadId)).json()) as {
|
||||
messages: unknown[]
|
||||
}
|
||||
const messages = await (
|
||||
await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/message?MessageCount=50`, {
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
).json()
|
||||
expect(Array.isArray(messages)).toBe(true)
|
||||
expect(thread.messages).toEqual(messages)
|
||||
})
|
||||
|
||||
it('honours messageCount', async () => {
|
||||
const caller = 885003
|
||||
const chatThreadId = await createThread(env.DB, [caller, 885004])
|
||||
for (const data of ['one', 'two', 'three']) {
|
||||
await postMessage(env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: caller,
|
||||
contents: JSON.stringify({ Type: 0, Version: 1, Data: data }),
|
||||
})
|
||||
}
|
||||
|
||||
const res = await openThread(caller, chatThreadId, '?messageCount=2')
|
||||
expect(((await res.json()) as { messages: unknown[] }).messages).toHaveLength(2)
|
||||
})
|
||||
|
||||
// A thread opened moments ago has nothing in it and still has to open — an empty
|
||||
// messages array, not a 404.
|
||||
it('opens an empty thread with an empty messages array', async () => {
|
||||
const caller = 885005
|
||||
const chatThreadId = await createThread(env.DB, [caller, 885006])
|
||||
|
||||
const res = await openThread(caller, chatThreadId)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const { messages } = (await res.json()) as { messages: unknown[] }
|
||||
// Built directly by createThread with no starter, so genuinely empty.
|
||||
expect(messages).toEqual([])
|
||||
})
|
||||
|
||||
it('hides threads the caller is not in', async () => {
|
||||
const chatThreadId = await createThread(env.DB, [885007, 885008])
|
||||
expect((await openThread(885009, chatThreadId)).status).toBe(404)
|
||||
expect((await openThread(885009, 999999)).status).toBe(404)
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/1?messageCount=50`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChatMessageReceived push', () => {
|
||||
/** The stubbed hub (see vitest.config.ts) records what it was sent. */
|
||||
interface SentNotification {
|
||||
playerId: number
|
||||
notificationType: NotificationType
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
|
||||
getByName(name: string): { takeSent(): Promise<SentNotification[]> }
|
||||
}
|
||||
|
||||
async function send(caller: number, body: string) {
|
||||
return SELF.fetch(`${ORIGIN}/thread`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(caller)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await hub.getByName('global').takeSent()
|
||||
})
|
||||
|
||||
// Every member is notified, the sender included: the client doesn't fold the HTTP
|
||||
// response into its thread cache, so without a self-push its own message doesn't
|
||||
// show until the next refetch.
|
||||
it('notifies every member of the thread, sender included', async () => {
|
||||
const caller = 886001
|
||||
const contents = '{"Type":0,"Version":1,"Data":"hi"}'
|
||||
const res = await send(
|
||||
caller,
|
||||
`ids=886002&ids=886003&messageContents=${encodeURIComponent(contents)}`
|
||||
)
|
||||
const { chatThread } = (await res.json()) as {
|
||||
chatThread: { chatThreadId: number; latestMessage: { chatMessageId: number } }
|
||||
}
|
||||
|
||||
const sent = await hub.getByName('global').takeSent()
|
||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
|
||||
expect(
|
||||
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
|
||||
).toBe(true)
|
||||
expect(sent[0]!.data).toEqual({
|
||||
chatMessageId: chatThread.latestMessage.chatMessageId,
|
||||
chatThreadId: chatThread.chatThreadId,
|
||||
senderPlayerId: caller,
|
||||
timeSent: expect.any(String),
|
||||
contents,
|
||||
moderationState: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes nothing when there is no message to push', async () => {
|
||||
await send(886004, 'ids=886005&messageContents=')
|
||||
expect(await hub.getByName('global').takeSent()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// A membership row whose thread row is gone must never be resolved to: it would hand
|
||||
// back an id nothing can render ("thread N vanished after creation"), and because the
|
||||
// oldest match wins it would keep winning on every later call.
|
||||
describe('orphaned membership rows', () => {
|
||||
it('ignores members of a thread whose message_thread row is gone', async () => {
|
||||
const caller = 887001
|
||||
const other = 887002
|
||||
const orphaned = await createThread(env.DB, [caller, other])
|
||||
await env.DB.prepare('DELETE FROM message_thread WHERE chat_thread_id = ?1')
|
||||
.bind(orphaned)
|
||||
.run()
|
||||
|
||||
expect(await findThreadWithMembers(env.DB, [caller, other])).toBeNull()
|
||||
|
||||
// Opening the chat recovers: a usable thread comes back, and it isn't the orphan.
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/withmembers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(caller)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `ids=${other}&messageCount=50`,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const thread = (await res.json()) as { chatThreadId: number; playerIds: number[] }
|
||||
expect(thread.chatThreadId).not.toBe(orphaned)
|
||||
expect(thread.playerIds).toEqual([caller, other])
|
||||
|
||||
// And it stays stable — the orphan never wins a later lookup.
|
||||
expect(await findThreadWithMembers(env.DB, [caller, other])).toBe(thread.chatThreadId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('marking a thread read', () => {
|
||||
async function read(caller: number, path: string, method = 'POST') {
|
||||
return SELF.fetch(`${ORIGIN}${path}`, { method, headers: await bearer(caller) })
|
||||
}
|
||||
|
||||
it('moves the pointer to a specific message, on both verbs', async () => {
|
||||
const caller = 888001
|
||||
const chatThreadId = await createThread(env.DB, [caller, 888002])
|
||||
const first = await postMessage(env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: 888002,
|
||||
contents: '{"Type":0,"Version":1,"Data":"one"}',
|
||||
})
|
||||
const second = await postMessage(env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: 888002,
|
||||
contents: '{"Type":0,"Version":1,"Data":"two"}',
|
||||
})
|
||||
|
||||
const res = await read(caller, `/thread/${chatThreadId}/message/${first.chatMessageId}/read`)
|
||||
expect(res.status).toBe(200)
|
||||
// The bare ChatResult integer, not an envelope.
|
||||
expect(await res.json()).toBe(0)
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
|
||||
first.chatMessageId
|
||||
)
|
||||
|
||||
await read(caller, `/thread/${chatThreadId}/message/${second.chatMessageId}/read`, 'PUT')
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
|
||||
second.chatMessageId
|
||||
)
|
||||
})
|
||||
|
||||
it('marks the whole thread read without a message id', async () => {
|
||||
const caller = 888003
|
||||
const chatThreadId = await createThread(env.DB, [caller, 888004])
|
||||
const latest = await postMessage(env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: 888004,
|
||||
contents: '{"Type":0,"Version":1,"Data":"hi"}',
|
||||
})
|
||||
|
||||
expect((await read(caller, `/thread/${chatThreadId}/read`)).status).toBe(200)
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
|
||||
latest.chatMessageId
|
||||
)
|
||||
})
|
||||
|
||||
// The client acks whatever id it was shown — including the synthetic message's
|
||||
// 9007199254740976. Clamping keeps that from stranding the thread as read forever.
|
||||
it('clamps an id beyond the thread to the real latest message', async () => {
|
||||
const caller = 888005
|
||||
const chatThreadId = await createThread(env.DB, [caller, 888006])
|
||||
const real = await postMessage(env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: 888006,
|
||||
contents: '{"Type":0,"Version":1,"Data":"hi"}',
|
||||
})
|
||||
|
||||
await read(caller, `/thread/${chatThreadId}/message/9007199254740976/read`)
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
|
||||
real.chatMessageId
|
||||
)
|
||||
|
||||
// A later real message is still unread, rather than swallowed by the bogus ack.
|
||||
const next = await postMessage(env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: 888006,
|
||||
contents: '{"Type":0,"Version":1,"Data":"later"}',
|
||||
})
|
||||
expect(
|
||||
(await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId
|
||||
).toBeLessThan(next.chatMessageId)
|
||||
})
|
||||
|
||||
it('never moves the pointer backwards', async () => {
|
||||
const caller = 888007
|
||||
const chatThreadId = await createThread(env.DB, [caller, 888008])
|
||||
const first = await postMessage(env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: 888008,
|
||||
contents: '{"Type":0,"Version":1,"Data":"one"}',
|
||||
})
|
||||
const second = await postMessage(env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: 888008,
|
||||
contents: '{"Type":0,"Version":1,"Data":"two"}',
|
||||
})
|
||||
|
||||
await read(caller, `/thread/${chatThreadId}/message/${second.chatMessageId}/read`)
|
||||
await read(caller, `/thread/${chatThreadId}/message/${first.chatMessageId}/read`)
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
|
||||
second.chatMessageId
|
||||
)
|
||||
})
|
||||
|
||||
it('is gated on membership and auth', async () => {
|
||||
const chatThreadId = await createThread(env.DB, [888009, 888010])
|
||||
expect((await read(888011, `/thread/${chatThreadId}/read`)).status).toBe(404)
|
||||
|
||||
const anon = await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/read`, { method: 'POST' })
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /thread/:id', () => {
|
||||
// The exact body the client sends: a Version 2 envelope whose Data carries a `<=>`
|
||||
// prefix. Nothing in the worker parses it, so it must survive byte-for-byte.
|
||||
const CONTENTS = '{"Type":0,"Version":2,"Data":"<=>hey"}'
|
||||
|
||||
async function send(caller: number, path: string, contents = CONTENTS) {
|
||||
return SELF.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(caller)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `messageContents=${encodeURIComponent(contents)}`,
|
||||
})
|
||||
}
|
||||
|
||||
it('appends to an existing thread and answers the send wrapper', async () => {
|
||||
const caller = 889001
|
||||
const chatThreadId = await createThread(env.DB, [caller, 889002], null, caller)
|
||||
|
||||
const res = await send(caller, `/thread/${chatThreadId}`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as {
|
||||
chatResult: number
|
||||
chatThread: {
|
||||
chatThreadId: number
|
||||
playerIds: number[]
|
||||
lastReadMessageId: number
|
||||
chatThreadType: number
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
}
|
||||
expect(body.chatResult).toBe(0)
|
||||
|
||||
// The whole thread comes back, newest message first, with the opening notice
|
||||
// beneath it — the shape the client re-renders the conversation from.
|
||||
expect(body.chatThread).toMatchObject({ chatThreadId, playerIds: [caller, 889002] })
|
||||
expect(body.chatThread.messages).toHaveLength(2)
|
||||
expect(body.chatThread.messages[0]).toMatchObject({
|
||||
chatThreadId,
|
||||
senderPlayerId: caller,
|
||||
contents: CONTENTS,
|
||||
moderationState: 0,
|
||||
})
|
||||
expect(body.chatThread.messages[1]).toMatchObject({ senderPlayerId: SYSTEM_SENDER_ID })
|
||||
|
||||
// Sending marks the thread read for the sender, so it doesn't come back unread.
|
||||
expect(body.chatThread.lastReadMessageId).toBe(body.chatThread.messages[0]!.chatMessageId)
|
||||
|
||||
// And it's stored, not just echoed.
|
||||
expect(await getThreadMessages(env.DB, chatThreadId)).toEqual(body.chatThread.messages)
|
||||
})
|
||||
|
||||
it('accepts the /thread/:id/message spelling too', async () => {
|
||||
const caller = 889003
|
||||
const chatThreadId = await createThread(env.DB, [caller, 889004], null, caller)
|
||||
|
||||
const res = await send(caller, `/thread/${chatThreadId}/message`)
|
||||
expect(((await res.json()) as { chatResult: number }).chatResult).toBe(0)
|
||||
expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('pushes ChatMessageReceived to every member', async () => {
|
||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
|
||||
getByName(name: string): {
|
||||
takeSent(): Promise<
|
||||
Array<{ playerId: number; notificationType: NotificationType }>
|
||||
>
|
||||
}
|
||||
}
|
||||
const caller = 889005
|
||||
const chatThreadId = await createThread(env.DB, [caller, 889006], null, caller)
|
||||
await hub.getByName('global').takeSent()
|
||||
|
||||
await send(caller, `/thread/${chatThreadId}`)
|
||||
const sent = await hub.getByName('global').takeSent()
|
||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 889006])
|
||||
expect(
|
||||
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('reports invalid arguments for blank contents without storing anything', async () => {
|
||||
const caller = 889007
|
||||
const chatThreadId = await createThread(env.DB, [caller, 889008], null, caller)
|
||||
|
||||
const res = await send(caller, `/thread/${chatThreadId}`, ' ')
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as { chatResult: number; chatThread: { messages: unknown[] } }
|
||||
expect(body.chatResult).toBe(1)
|
||||
// The thread still comes back — only the opening notice is in it.
|
||||
expect(body.chatThread.messages).toHaveLength(1)
|
||||
expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('is gated on membership and auth', async () => {
|
||||
const chatThreadId = await createThread(env.DB, [889009, 889010], null, 889009)
|
||||
expect((await send(889011, `/thread/${chatThreadId}`)).status).toBe(404)
|
||||
|
||||
const anon = await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'messageContents=hi',
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /thread/:id/member/:playerId', () => {
|
||||
async function addMember(caller: number, chatThreadId: number, playerId: number) {
|
||||
return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/member/${playerId}`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(caller),
|
||||
})
|
||||
}
|
||||
|
||||
it('adds a player to a thread the caller is in', async () => {
|
||||
const caller = 890001
|
||||
const chatThreadId = await createThread(env.DB, [caller, 890002], null, caller)
|
||||
|
||||
const res = await addMember(caller, chatThreadId, 890003)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(0)
|
||||
|
||||
expect(await isThreadMember(env.DB, chatThreadId, 890003)).toBe(true)
|
||||
// The new member sees the thread, its history included.
|
||||
const thread = await getThreadForPlayer(env.DB, chatThreadId, 890003)
|
||||
expect(thread?.playerIds).toEqual([caller, 890002, 890003])
|
||||
})
|
||||
|
||||
it('reports the player is already on the thread', async () => {
|
||||
const caller = 890004
|
||||
const chatThreadId = await createThread(env.DB, [caller, 890005], null, caller)
|
||||
expect(await (await addMember(caller, chatThreadId, 890005)).json()).toBe(4)
|
||||
})
|
||||
|
||||
// A non-member gets the same answer as for a thread that doesn't exist, so the
|
||||
// endpoint can't be used to probe for threads.
|
||||
it('refuses a caller who is not on the thread', async () => {
|
||||
const chatThreadId = await createThread(env.DB, [890006, 890007], null, 890006)
|
||||
expect(await (await addMember(890008, chatThreadId, 890009)).json()).toBe(3)
|
||||
expect(await isThreadMember(env.DB, chatThreadId, 890009)).toBe(false)
|
||||
|
||||
expect(await (await addMember(890008, 999999, 890009)).json()).toBe(3)
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/1/member/2`, { method: 'POST' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renaming and leaving a thread', () => {
|
||||
async function post(caller: number, path: string, body?: string, method = 'POST') {
|
||||
return SELF.fetch(`${ORIGIN}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(await bearer(caller)),
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/x-www-form-urlencoded' }),
|
||||
},
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
it('renames a thread for everyone on it', async () => {
|
||||
const caller = 891001
|
||||
const chatThreadId = await createThread(env.DB, [caller, 891002], null, caller)
|
||||
|
||||
const res = await post(caller, `/thread/${chatThreadId}/rename`, 'name=my%20chat')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(0)
|
||||
|
||||
// Both members see the new name.
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toBe('my chat')
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, 891002))?.chatThreadName).toBe('my chat')
|
||||
})
|
||||
|
||||
it('clears the name back to unnamed, never null', async () => {
|
||||
const caller = 891003
|
||||
const chatThreadId = await createThread(env.DB, [caller, 891004], 'Group Chat =]', caller)
|
||||
|
||||
await post(caller, `/thread/${chatThreadId}/rename`, 'name=')
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toBe('')
|
||||
})
|
||||
|
||||
it('truncates an overlong name rather than rejecting it', async () => {
|
||||
const caller = 891005
|
||||
const chatThreadId = await createThread(env.DB, [caller, 891006], null, caller)
|
||||
|
||||
await post(caller, `/thread/${chatThreadId}/rename`, `name=${'x'.repeat(200)}`)
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toHaveLength(
|
||||
128
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses to rename a thread the caller is not on', async () => {
|
||||
const chatThreadId = await createThread(env.DB, [891007, 891008], null, 891007)
|
||||
expect(await (await post(891009, `/thread/${chatThreadId}/rename`, 'name=nope')).json()).toBe(3)
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, 891007))?.chatThreadName).toBe('')
|
||||
})
|
||||
|
||||
it('leaves a thread, posting the notice and keeping the history', async () => {
|
||||
const caller = 891010
|
||||
const stayer = 891011
|
||||
const chatThreadId = await createThread(env.DB, [caller, stayer], null, caller)
|
||||
await postMessage(env.DB, {
|
||||
chatThreadId,
|
||||
senderPlayerId: caller,
|
||||
contents: '{"Type":0,"Version":1,"Data":"bye"}',
|
||||
})
|
||||
|
||||
const res = await post(caller, `/thread/${chatThreadId}/leave`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(0)
|
||||
|
||||
// Gone for the leaver, intact for everyone else.
|
||||
expect(await isThreadMember(env.DB, chatThreadId, caller)).toBe(false)
|
||||
expect(await getThreadForPlayer(env.DB, chatThreadId, caller)).toBeNull()
|
||||
|
||||
const remaining = await getThreadForPlayer(env.DB, chatThreadId, stayer)
|
||||
expect(remaining?.playerIds).toEqual([stayer])
|
||||
expect(remaining?.latestMessage).toMatchObject({
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
contents: leftChatContents(caller),
|
||||
})
|
||||
// Opening notice, the message, and the leave notice.
|
||||
expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('accepts DELETE for leave as well as POST', async () => {
|
||||
const caller = 891012
|
||||
const chatThreadId = await createThread(env.DB, [caller, 891013], null, caller)
|
||||
|
||||
expect(
|
||||
await (await post(caller, `/thread/${chatThreadId}/leave`, undefined, 'DELETE')).json()
|
||||
).toBe(0)
|
||||
expect(await isThreadMember(env.DB, chatThreadId, caller)).toBe(false)
|
||||
})
|
||||
|
||||
it('reports membership-not-found when leaving a thread you are not on', async () => {
|
||||
const chatThreadId = await createThread(env.DB, [891014, 891015], null, 891014)
|
||||
expect(await (await post(891016, `/thread/${chatThreadId}/leave`)).json()).toBe(3)
|
||||
// Nothing was posted to a thread the caller has no business touching.
|
||||
expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
const rename = await SELF.fetch(`${ORIGIN}/thread/1/rename`, { method: 'POST' })
|
||||
expect(rename.status).toBe(401)
|
||||
const leave = await SELF.fetch(`${ORIGIN}/thread/1/leave`, { method: 'POST' })
|
||||
expect(leave.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /thread/:id/snooze', () => {
|
||||
async function snooze(caller: number, chatThreadId: number, body: string) {
|
||||
return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/snooze`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(caller)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
it('snoozes and unsnoozes for the caller alone', async () => {
|
||||
const caller = 892001
|
||||
const other = 892002
|
||||
const chatThreadId = await createThread(env.DB, [caller, other], null, caller)
|
||||
|
||||
const res = await snooze(caller, chatThreadId, 'snooze=True')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(0)
|
||||
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.snoozedUntil).toBe(
|
||||
'9999-12-31T23:59:59Z'
|
||||
)
|
||||
// Snoozing is per-member: the other player is untouched.
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, other))?.snoozedUntil).toBeNull()
|
||||
|
||||
await snooze(caller, chatThreadId, 'snooze=False')
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.snoozedUntil).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses a thread the caller is not on', async () => {
|
||||
const chatThreadId = await createThread(env.DB, [892003, 892004], null, 892003)
|
||||
expect(await (await snooze(892005, chatThreadId, 'snooze=True')).json()).toBe(3)
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/1/snooze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'snooze=True',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /thread/:id/favorite', () => {
|
||||
async function favorite(caller: number, chatThreadId: number, body: string, method = 'PUT') {
|
||||
return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/favorite`, {
|
||||
method,
|
||||
headers: {
|
||||
...(await bearer(caller)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
it('favorites and unfavorites for the caller alone', async () => {
|
||||
const caller = 893001
|
||||
const other = 893002
|
||||
const chatThreadId = await createThread(env.DB, [caller, other], null, caller)
|
||||
|
||||
const res = await favorite(caller, chatThreadId, 'favorite=True')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(0)
|
||||
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true)
|
||||
// Per-member, like snoozing: the other player's inbox is untouched.
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, other))?.isFavorited).toBe(false)
|
||||
|
||||
await favorite(caller, chatThreadId, 'favorite=False')
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts POST as well as PUT', async () => {
|
||||
const caller = 893003
|
||||
const chatThreadId = await createThread(env.DB, [caller, 893004], null, caller)
|
||||
|
||||
expect(await (await favorite(caller, chatThreadId, 'favorite=True', 'POST')).json()).toBe(0)
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a thread the caller is not on', async () => {
|
||||
const chatThreadId = await createThread(env.DB, [893005, 893006], null, 893005)
|
||||
expect(await (await favorite(893007, chatThreadId, 'favorite=True')).json()).toBe(3)
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/thread/1/favorite`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'favorite=True',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openapi', () => {
|
||||
it('GET /openapi.json documents every route', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
const spec = (await res.json()) as {
|
||||
openapi: string
|
||||
paths: Record<string, Record<string, { summary?: string }>>
|
||||
}
|
||||
expect(spec.openapi).toMatch(/^3\.1/)
|
||||
|
||||
// The spec route hides itself.
|
||||
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||
|
||||
// Every schema inlines — a `$ref` here means a schema picked up a `.meta({ id })`
|
||||
// and emitted a reference the framework didn't hoist into components.schemas.
|
||||
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
|
||||
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
|
||||
// `.on([...], …)` routes contribute every method they were registered for.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'DELETE /thread/{id}/leave',
|
||||
'GET /',
|
||||
'GET /thread',
|
||||
'GET /thread/{id}',
|
||||
'GET /thread/{id}/message',
|
||||
'POST /thread',
|
||||
'POST /thread/withmembers',
|
||||
'POST /thread/{id}',
|
||||
'POST /thread/{id}/favorite',
|
||||
'POST /thread/{id}/leave',
|
||||
'POST /thread/{id}/member/{playerId}',
|
||||
'POST /thread/{id}/message',
|
||||
'POST /thread/{id}/message/{messageId}/read',
|
||||
'POST /thread/{id}/read',
|
||||
'POST /thread/{id}/rename',
|
||||
'POST /thread/{id}/snooze',
|
||||
'PUT /thread/{id}/favorite',
|
||||
'PUT /thread/{id}/message/{messageId}/read',
|
||||
'PUT /thread/{id}/read',
|
||||
'PUT /thread/{id}/rename',
|
||||
'PUT /thread/{id}/snooze',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
// documentation.
|
||||
for (const ops of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* Chat threads and their membership on the shared `recflare` D1 database. A thread is a
|
||||
* conversation — a DM pair, a named group chat, or a system thread; the messages in it
|
||||
* live in `message` (see message-db.ts).
|
||||
*
|
||||
* Membership (`thread_member`) does double duty: it is the authorization gate — a
|
||||
* player may read or post to a thread only if they hold a row — and it is what renders
|
||||
* the `playerIds` array the client shows. Nothing here has a foreign key to accounts,
|
||||
* here or on a message's sender: that table belongs to the `auth` worker, and a thread
|
||||
* outlives the accounts in it.
|
||||
*
|
||||
* The thread denormalizes `latest_message_id` so the thread list renders from one
|
||||
* indexed row per thread rather than a per-thread MAX() over `message`, and so it can
|
||||
* be ordered by recency without a join — message ids are monotonic, so the highest id
|
||||
* is the newest thread. `postMessage` keeps it in sync.
|
||||
*
|
||||
* The per-viewer fields — `lastReadMessageId`, `snoozedUntil`, `isFavorited` — live on
|
||||
* the membership row, not the thread: two players in one DM have independent read
|
||||
* positions, snoozes, and favorites.
|
||||
*
|
||||
* The `chat` worker owns this schema/migration (migrations/0002_thread.sql).
|
||||
* `THREAD_SCHEMA_DDL` mirrors it so tests can build the tables directly.
|
||||
*/
|
||||
|
||||
import { insertMessage } from './message-db'
|
||||
|
||||
import type { ChatMessage, NewChatMessage } from './message-db'
|
||||
|
||||
/** Schema DDL (mirror of migrations/0002_thread.sql). */
|
||||
export const THREAD_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS message_thread (
|
||||
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_thread_name TEXT,
|
||||
latest_message_id INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_message_thread_latest ON message_thread (latest_message_id)`,
|
||||
`CREATE TABLE IF NOT EXISTS thread_member (
|
||||
chat_thread_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
last_read_message_id INTEGER,
|
||||
snoozed_until TEXT,
|
||||
is_favorited INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (chat_thread_id, player_id)
|
||||
)`,
|
||||
// The thread-list query is "every thread this player is in", so player_id leads.
|
||||
`CREATE INDEX IF NOT EXISTS idx_thread_member_player ON thread_member (player_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* A thread as the client receives it: the thread, its members, its most recent message,
|
||||
* and the viewing player's own read/snooze/favorite state. This is the element shape of
|
||||
* the thread-list response.
|
||||
*/
|
||||
export interface ChatThread {
|
||||
/** Null only for a thread with no messages yet. */
|
||||
latestMessage: ChatMessage | null
|
||||
chatThreadId: number
|
||||
playerIds: number[]
|
||||
/**
|
||||
* 0 when the player has never read the thread — never null. The client deserializes
|
||||
* this into a non-nullable int and fails the whole response on a null ("expected
|
||||
* 'Number Token', actual 'null'"), unlike `latestMessage`, which it accepts as null.
|
||||
*/
|
||||
lastReadMessageId: number
|
||||
/**
|
||||
* Empty for DMs and unnamed groups — never null. The client dereferences this name
|
||||
* without a null check (a null NullReferenceExceptions its way out of
|
||||
* GetChatBetweenPlayers) and falls back to naming the members when it's blank.
|
||||
*/
|
||||
chatThreadName: string
|
||||
/**
|
||||
* Which kind of conversation this is. Every thread the reference serves here comes back
|
||||
* as 0, and nothing in the worker distinguishes DMs from groups, so it's a constant —
|
||||
* but the field itself has to be present: the client deserializes it as a non-nullable
|
||||
* int and drops the whole response when it's missing.
|
||||
*/
|
||||
chatThreadType: number
|
||||
snoozedUntil: string | null
|
||||
isFavorited: boolean
|
||||
}
|
||||
|
||||
/** The only thread type the reference ever serves. See `ChatThread.chatThreadType`. */
|
||||
const CHAT_THREAD_TYPE_DEFAULT = 0
|
||||
|
||||
/** The joined row backing a rendered thread, before it's shaped for the client. */
|
||||
interface ThreadRow {
|
||||
chat_thread_id: number
|
||||
chat_thread_name: string | null
|
||||
player_ids: string | null
|
||||
last_read_message_id: number | null
|
||||
snoozed_until: string | null
|
||||
is_favorited: number
|
||||
msg_chat_message_id: number | null
|
||||
msg_chat_thread_id: number | null
|
||||
msg_sender_player_id: number | null
|
||||
msg_time_sent: string | null
|
||||
msg_contents: string | null
|
||||
msg_moderation_state: number | null
|
||||
}
|
||||
|
||||
function toThread(row: ThreadRow): ChatThread {
|
||||
return {
|
||||
latestMessage:
|
||||
row.msg_chat_message_id === null
|
||||
? null
|
||||
: {
|
||||
chatMessageId: row.msg_chat_message_id,
|
||||
chatThreadId: row.msg_chat_thread_id!,
|
||||
senderPlayerId: row.msg_sender_player_id!,
|
||||
timeSent: row.msg_time_sent!,
|
||||
contents: row.msg_contents!,
|
||||
moderationState: row.msg_moderation_state!,
|
||||
},
|
||||
chatThreadId: row.chat_thread_id,
|
||||
// group_concat of the membership rows, already ordered by player id.
|
||||
playerIds: row.player_ids === null ? [] : row.player_ids.split(',').map(Number),
|
||||
// Null in the column means "never read"; the client insists on a number.
|
||||
lastReadMessageId: row.last_read_message_id ?? 0,
|
||||
// Null in the column means "unnamed"; the client dereferences it unchecked.
|
||||
chatThreadName: row.chat_thread_name ?? '',
|
||||
chatThreadType: CHAT_THREAD_TYPE_DEFAULT,
|
||||
snoozedUntil: row.snoozed_until,
|
||||
isFavorited: row.is_favorited !== 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The thread list as it renders for one player, newest conversation first — the
|
||||
* `?MessageCount=N` page of the thread endpoint.
|
||||
*
|
||||
* Reads only threads the player is a member of, so the membership join is the
|
||||
* authorization check as well as the query. The inner ordered subquery around
|
||||
* group_concat is what makes `playerIds` come back sorted rather than in row order.
|
||||
*/
|
||||
export async function getThreadsForPlayer(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
{ limit = 50 }: { limit?: number } = {}
|
||||
): Promise<ChatThread[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT
|
||||
t.chat_thread_id,
|
||||
t.chat_thread_name,
|
||||
(SELECT group_concat(player_id) FROM
|
||||
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
|
||||
ORDER BY player_id)) AS player_ids,
|
||||
me.last_read_message_id,
|
||||
me.snoozed_until,
|
||||
me.is_favorited,
|
||||
msg.chat_message_id AS msg_chat_message_id,
|
||||
msg.chat_thread_id AS msg_chat_thread_id,
|
||||
msg.sender_player_id AS msg_sender_player_id,
|
||||
msg.time_sent AS msg_time_sent,
|
||||
msg.contents AS msg_contents,
|
||||
msg.moderation_state AS msg_moderation_state
|
||||
FROM thread_member me
|
||||
JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id
|
||||
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id
|
||||
WHERE me.player_id = ?1
|
||||
ORDER BY t.latest_message_id DESC
|
||||
LIMIT ?2`
|
||||
)
|
||||
.bind(playerId, limit)
|
||||
.all<ThreadRow>()
|
||||
return results.map(toThread)
|
||||
}
|
||||
|
||||
/** One thread as it renders for one player, or null if they aren't a member of it. */
|
||||
export async function getThreadForPlayer(
|
||||
db: D1Database,
|
||||
chatThreadId: number,
|
||||
playerId: number
|
||||
): Promise<ChatThread | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT
|
||||
t.chat_thread_id,
|
||||
t.chat_thread_name,
|
||||
(SELECT group_concat(player_id) FROM
|
||||
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
|
||||
ORDER BY player_id)) AS player_ids,
|
||||
me.last_read_message_id,
|
||||
me.snoozed_until,
|
||||
me.is_favorited,
|
||||
msg.chat_message_id AS msg_chat_message_id,
|
||||
msg.chat_thread_id AS msg_chat_thread_id,
|
||||
msg.sender_player_id AS msg_sender_player_id,
|
||||
msg.time_sent AS msg_time_sent,
|
||||
msg.contents AS msg_contents,
|
||||
msg.moderation_state AS msg_moderation_state
|
||||
FROM thread_member me
|
||||
JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id
|
||||
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id
|
||||
WHERE me.chat_thread_id = ?1 AND me.player_id = ?2`
|
||||
)
|
||||
.bind(chatThreadId, playerId)
|
||||
.first<ThreadRow>()
|
||||
return row === null ? null : toThread(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a player may read or post to a thread. Every thread-scoped route gates on
|
||||
* this before touching messages.
|
||||
*/
|
||||
export async function isThreadMember(
|
||||
db: D1Database,
|
||||
chatThreadId: number,
|
||||
playerId: number
|
||||
): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare('SELECT 1 AS ok FROM thread_member WHERE chat_thread_id = ?1 AND player_id = ?2')
|
||||
.bind(chatThreadId, playerId)
|
||||
.first<{ ok: number }>()
|
||||
return row !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* The pseudo-player system messages are sent as. Not a real account — the client renders
|
||||
* a message from this sender as a notice rather than as someone speaking, which is why
|
||||
* `message.sender_player_id` carries no foreign key and permits negative ids.
|
||||
*/
|
||||
export const SYSTEM_SENDER_ID = -5
|
||||
|
||||
/**
|
||||
* The notice a thread opens with: `Player <@U10441985> started a chat`. The `<@U…>` token
|
||||
* is a mention the client resolves to a display name, so the id goes in raw.
|
||||
*/
|
||||
export function startedChatContents(playerId: number): string {
|
||||
return JSON.stringify({
|
||||
Type: 0,
|
||||
Version: 1,
|
||||
Data: `Player <@U${playerId}> started a chat`,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The notice left behind when someone walks out of a group: `Player <@U14922080> left`.
|
||||
* Same `<@U…>` mention token the opening notice uses.
|
||||
*/
|
||||
export function leftChatContents(playerId: number): string {
|
||||
return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> left` })
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a thread. An empty name clears it back to unnamed, which renders as the member
|
||||
* list rather than a blank title.
|
||||
*/
|
||||
export async function setThreadName(
|
||||
db: D1Database,
|
||||
chatThreadId: number,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare('UPDATE message_thread SET chat_thread_name = ?2 WHERE chat_thread_id = ?1')
|
||||
.bind(chatThreadId, name === '' ? null : name)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a thread between a set of players, returning its new id. `name` is null for DMs
|
||||
* and unnamed groups. Duplicate player ids collapse, so a caller need not dedupe.
|
||||
*
|
||||
* Pass `startedBy` to open the thread the way the real server does — with a system
|
||||
* "started a chat" notice as its first message. A thread with no messages at all is one
|
||||
* the client won't display, so every thread born from a request gets one; the parameter
|
||||
* is optional only so tests can build a bare thread directly.
|
||||
*
|
||||
* Every call opens a *distinct* thread, even for a member set that already has one —
|
||||
* threads are not keyed by their membership, and the same pair may hold several.
|
||||
*/
|
||||
export async function createThread(
|
||||
db: D1Database,
|
||||
playerIds: number[],
|
||||
name: string | null = null,
|
||||
startedBy?: number
|
||||
): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO message_thread (chat_thread_name, created_at) VALUES (?1, ?2)
|
||||
RETURNING chat_thread_id`
|
||||
)
|
||||
.bind(name, new Date().toISOString())
|
||||
.first<{ chat_thread_id: number }>()
|
||||
if (row === null) throw new Error('failed to create chat thread')
|
||||
|
||||
const members = [...new Set(playerIds)]
|
||||
if (members.length > 0) {
|
||||
await db.batch(
|
||||
members.map((playerId) =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO thread_member (chat_thread_id, player_id)
|
||||
VALUES (?1, ?2)`
|
||||
)
|
||||
.bind(row.chat_thread_id, playerId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (startedBy !== undefined) {
|
||||
await postMessage(db, {
|
||||
chatThreadId: row.chat_thread_id,
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
contents: startedChatContents(startedBy),
|
||||
})
|
||||
}
|
||||
return row.chat_thread_id
|
||||
}
|
||||
|
||||
/**
|
||||
* The existing thread whose membership is *exactly* this set of players, or null. The
|
||||
* oldest match wins, so a set that somehow accumulated duplicates keeps resolving to the
|
||||
* conversation with the history in it.
|
||||
*
|
||||
* This is what makes "open a chat with these people" reuse the conversation you already
|
||||
* have with them rather than starting an empty one each time. Matching is on the whole
|
||||
* set: a DM and a group that happens to contain those two people are different threads.
|
||||
*
|
||||
* Only threads that still have a `message_thread` row can match. Membership rows whose
|
||||
* thread is gone are ignored rather than resolved to: matching one would hand back an id
|
||||
* that nothing else in the worker can render, and — since the oldest match wins — it
|
||||
* would keep winning on every subsequent call.
|
||||
*/
|
||||
export async function findThreadWithMembers(
|
||||
db: D1Database,
|
||||
playerIds: number[]
|
||||
): Promise<number | null> {
|
||||
const members = [...new Set(playerIds)]
|
||||
if (members.length === 0) return null
|
||||
|
||||
// ?1 is the member count; ?2… are the ids themselves.
|
||||
const placeholders = members.map((_, i) => `?${i + 2}`).join(', ')
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT m.chat_thread_id FROM thread_member m
|
||||
JOIN message_thread t ON t.chat_thread_id = m.chat_thread_id
|
||||
GROUP BY m.chat_thread_id
|
||||
HAVING COUNT(*) = ?1
|
||||
AND COUNT(CASE WHEN m.player_id IN (${placeholders}) THEN 1 END) = ?1
|
||||
ORDER BY m.chat_thread_id
|
||||
LIMIT 1`
|
||||
)
|
||||
.bind(members.length, ...members)
|
||||
.first<{ chat_thread_id: number }>()
|
||||
return row?.chat_thread_id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The thread with exactly these members, opening one if it doesn't exist yet. Two
|
||||
* simultaneous first-messages to the same set can still race into two threads; the
|
||||
* oldest-match rule in `findThreadWithMembers` means both parties converge on one of
|
||||
* them afterwards.
|
||||
*/
|
||||
export async function getOrCreateThreadWithMembers(
|
||||
db: D1Database,
|
||||
playerIds: number[],
|
||||
startedBy: number
|
||||
): Promise<number> {
|
||||
return (
|
||||
(await findThreadWithMembers(db, playerIds)) ??
|
||||
(await createThread(db, playerIds, null, startedBy))
|
||||
)
|
||||
}
|
||||
|
||||
/** Everyone in a thread, ordered by id — the fan-out list for a push notification. */
|
||||
export async function getThreadMemberIds(db: D1Database, chatThreadId: number): Promise<number[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT player_id FROM thread_member WHERE chat_thread_id = ?1 ORDER BY player_id')
|
||||
.bind(chatThreadId)
|
||||
.all<{ player_id: number }>()
|
||||
return results.map((r) => r.player_id)
|
||||
}
|
||||
|
||||
/** Add a player to an existing thread. A no-op if they're already in it. */
|
||||
export async function addThreadMember(
|
||||
db: D1Database,
|
||||
chatThreadId: number,
|
||||
playerId: number
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare('INSERT OR IGNORE INTO thread_member (chat_thread_id, player_id) VALUES (?1, ?2)')
|
||||
.bind(chatThreadId, playerId)
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Remove a player from a thread. The thread and its messages outlive the membership. */
|
||||
export async function removeThreadMember(
|
||||
db: D1Database,
|
||||
chatThreadId: number,
|
||||
playerId: number
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare('DELETE FROM thread_member WHERE chat_thread_id = ?1 AND player_id = ?2')
|
||||
.bind(chatThreadId, playerId)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a message and advance the thread's denormalized `latest_message_id` — the only
|
||||
* way messages should be written, so the thread list never goes stale. Two statements
|
||||
* rather than a batch, because the update needs the id the insert assigns.
|
||||
*/
|
||||
export async function postMessage(db: D1Database, message: NewChatMessage): Promise<ChatMessage> {
|
||||
const stored = await insertMessage(db, message)
|
||||
await db
|
||||
.prepare('UPDATE message_thread SET latest_message_id = ?2 WHERE chat_thread_id = ?1')
|
||||
.bind(stored.chatThreadId, stored.chatMessageId)
|
||||
.run()
|
||||
return stored
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a player's read position, to a specific message or (with no id) to the whole
|
||||
* thread. Only ever moves forward: an out-of-order ack from a second client can't walk
|
||||
* the thread back to unread.
|
||||
*
|
||||
* The id is also clamped to the thread's real latest message, so a client acking an id
|
||||
* that was never stored can't strand the pointer beyond every future message and leave
|
||||
* the thread permanently "read".
|
||||
*/
|
||||
export async function markThreadRead(
|
||||
db: D1Database,
|
||||
chatThreadId: number,
|
||||
playerId: number,
|
||||
chatMessageId?: number
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE thread_member
|
||||
SET last_read_message_id = MAX(
|
||||
COALESCE(last_read_message_id, 0),
|
||||
MIN(
|
||||
COALESCE(?3, (SELECT latest_message_id FROM message_thread WHERE chat_thread_id = ?1), 0),
|
||||
COALESCE((SELECT latest_message_id FROM message_thread WHERE chat_thread_id = ?1), 0)
|
||||
)
|
||||
)
|
||||
WHERE chat_thread_id = ?1 AND player_id = ?2`
|
||||
)
|
||||
.bind(chatThreadId, playerId, chatMessageId ?? null)
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Favorite or unfavorite a thread, for one player only. */
|
||||
export async function setThreadFavorited(
|
||||
db: D1Database,
|
||||
chatThreadId: number,
|
||||
playerId: number,
|
||||
isFavorited: boolean
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'UPDATE thread_member SET is_favorited = ?3 WHERE chat_thread_id = ?1 AND player_id = ?2'
|
||||
)
|
||||
.bind(chatThreadId, playerId, isFavorited ? 1 : 0)
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Snooze a thread's notifications until an instant, or clear the snooze with null. */
|
||||
export async function setThreadSnoozed(
|
||||
db: D1Database,
|
||||
chatThreadId: number,
|
||||
playerId: number,
|
||||
snoozedUntil: string | null
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'UPDATE thread_member SET snoozed_until = ?3 WHERE chat_thread_id = ?1 AND player_id = ?2'
|
||||
)
|
||||
.bind(chatThreadId, playerId, snoozedUntil)
|
||||
.run()
|
||||
}
|
||||
@@ -9,6 +9,40 @@ export default defineConfig({
|
||||
bindings: {
|
||||
ENVIRONMENT: 'VITEST',
|
||||
},
|
||||
// The worker's RECFLARE_NOTIFICATIONS_HUB binding points at the `notify`
|
||||
// worker's DO (script_name: "notify"). That worker isn't part of this
|
||||
// isolated test, so provide a minimal stub exposing the same NotificationsHub
|
||||
// RPC surface. This one also records what it was sent and hands it back via
|
||||
// `takeSent`, so tests can assert on the ChatMessageReceived fan-out.
|
||||
workers: [
|
||||
{
|
||||
name: 'notify',
|
||||
modules: true,
|
||||
compatibilityDate: '2026-06-16',
|
||||
compatibilityFlags: ['nodejs_compat'],
|
||||
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
|
||||
script: `
|
||||
import { DurableObject } from 'cloudflare:workers'
|
||||
export class NotificationsHub extends DurableObject {
|
||||
constructor(ctx, env) {
|
||||
super(ctx, env)
|
||||
this.sent = []
|
||||
}
|
||||
async notifyPlayer(playerId, notificationType, data) {
|
||||
this.sent.push({ playerId, notificationType, data })
|
||||
return { delivered: 1, queued: false }
|
||||
}
|
||||
async broadcast() { return { delivered: 0 } }
|
||||
async takeSent() {
|
||||
const sent = this.sent
|
||||
this.sent = []
|
||||
return sent
|
||||
}
|
||||
}
|
||||
export default { fetch() { return new Response('ok') } }
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -4,6 +4,41 @@
|
||||
"main": "src/chat.app.ts",
|
||||
"compatibility_date": "2026-06-16",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
// Shared `recflare` DB (created manually with `wrangler d1 create recflare`; the
|
||||
// "local" placeholder is spliced out at deploy time). The `chat` worker owns the
|
||||
// `message` table (schema/migration here); its own migrations_table keeps history
|
||||
// separate from the other workers' migrations on the shared database.
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "recflare",
|
||||
"database_id": "local",
|
||||
"migrations_dir": "migrations",
|
||||
"migrations_table": "d1_migrations_chat"
|
||||
}
|
||||
],
|
||||
"logpush": false,
|
||||
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
|
||||
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
|
||||
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
|
||||
"secrets_store_secrets": [
|
||||
{
|
||||
"binding": "JWT_SECRET",
|
||||
"store_id": "local",
|
||||
"secret_name": "JWT_SECRET"
|
||||
}
|
||||
],
|
||||
// The `notify` worker's NotificationsHub DO — used to push ChatMessageReceived to
|
||||
// every member of a thread when a message lands.
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "RECFLARE_NOTIFICATIONS_HUB",
|
||||
"class_name": "NotificationsHub",
|
||||
"script_name": "notify"
|
||||
}
|
||||
]
|
||||
},
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"logs": {
|
||||
|
||||
@@ -16,10 +16,16 @@
|
||||
"test": "run-vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "0.16.20",
|
||||
|
||||
+208
-7
@@ -14,6 +14,10 @@
|
||||
* can build the tables directly.
|
||||
*/
|
||||
|
||||
import { getSavedImagesByNames, placeholderSavedImage } from '@repo/domain'
|
||||
|
||||
import type { SavedImage } from '@repo/domain'
|
||||
|
||||
/** Schema DDL (mirror of migrations/0001_club.sql, sans seed rows). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS club (
|
||||
@@ -125,8 +129,17 @@ export interface Club {
|
||||
interface StoredClub extends Club {
|
||||
CreatedAt: string
|
||||
CustomTags?: string[]
|
||||
/**
|
||||
* The club's gallery image names, in order (the client PUTs to
|
||||
* `/additionalimage/{index}`). Packed, never sparse: removing one shifts the rest
|
||||
* up, so the list is always the images the club actually has.
|
||||
*/
|
||||
AdditionalImages?: string[]
|
||||
}
|
||||
|
||||
/** How many gallery images a club has room for (slots 0..2). */
|
||||
export const MAX_ADDITIONAL_IMAGES = 3
|
||||
|
||||
interface ClubRow {
|
||||
data: string
|
||||
}
|
||||
@@ -307,7 +320,12 @@ function clubPermission(
|
||||
|
||||
/** The club-details payload the client reads from create/details. */
|
||||
export interface ClubDetails {
|
||||
AdditionalImages: unknown[]
|
||||
/**
|
||||
* The club's gallery images as whole image records — the same `SavedImage` shape
|
||||
* every other image on the site is served as. The client deserializes these into
|
||||
* objects, so a bare array of names fails its parser ("expected '{'").
|
||||
*/
|
||||
AdditionalImages: SavedImage[]
|
||||
Club: Club
|
||||
ClubId: number
|
||||
CoownerPermissions: ClubPermission
|
||||
@@ -319,8 +337,9 @@ export interface ClubDetails {
|
||||
|
||||
/**
|
||||
* Build the club-details view for a caller. `MyMembershipType` is the caller's own
|
||||
* membership (0 = none, e.g. a signed-out viewer). Additional images have no storage
|
||||
* yet, so they're empty; custom tags come from the club (set via `modifydetails`).
|
||||
* membership (0 = none, e.g. a signed-out viewer). Additional images (set via
|
||||
* `/additionalimage/{index}`) and custom tags (set via `modifydetails`) both come off
|
||||
* the club's blob.
|
||||
*/
|
||||
export async function getClubDetails(
|
||||
db: D1Database,
|
||||
@@ -328,7 +347,7 @@ export async function getClubDetails(
|
||||
accountId: number | null
|
||||
): Promise<ClubDetails> {
|
||||
return {
|
||||
AdditionalImages: [],
|
||||
AdditionalImages: await getClubGallery(db, club.ClubId),
|
||||
Club: club,
|
||||
ClubId: club.ClubId,
|
||||
CoownerPermissions: clubPermission(club.ClubId, ClubMembershipType.Coowner, {
|
||||
@@ -519,6 +538,17 @@ export async function setHomeClub(
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the player's home club (the field is removed from their account row, not set
|
||||
* to 0 — `getHomeClub` reads a missing field as "no home club"). Idempotent.
|
||||
*/
|
||||
export async function clearHomeClub(db: D1Database, accountId: number): Promise<void> {
|
||||
await db
|
||||
.prepare("UPDATE account SET data = json_remove(data, '$.homeClubId') WHERE account_id = ?1")
|
||||
.bind(accountId)
|
||||
.run()
|
||||
}
|
||||
|
||||
/** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */
|
||||
export interface ClubMember {
|
||||
ClubMemberId: number
|
||||
@@ -643,6 +673,64 @@ function dedupeTags(tags: string[]): string[] {
|
||||
return out
|
||||
}
|
||||
|
||||
/** A club's gallery image names, in order (stored on the blob; `[]` when it has none). */
|
||||
export async function getClubAdditionalImages(db: D1Database, clubId: number): Promise<string[]> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||
.bind(clubId)
|
||||
.first<ClubRow>()
|
||||
return row === null ? [] : ((JSON.parse(row.data) as StoredClub).AdditionalImages ?? [])
|
||||
}
|
||||
|
||||
/**
|
||||
* A club's gallery as the client reads it: the image record behind each name, in
|
||||
* order. A name whose metadata row is missing falls back to a placeholder record so
|
||||
* the picture still renders.
|
||||
*/
|
||||
export async function getClubGallery(db: D1Database, clubId: number): Promise<SavedImage[]> {
|
||||
const names = await getClubAdditionalImages(db, clubId)
|
||||
if (names.length === 0) return []
|
||||
const records = await getSavedImagesByNames(db, names)
|
||||
return names.map((name) => records.get(name) ?? placeholderSavedImage(name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or remove, with an empty `imageName`) one of a club's gallery images. The list
|
||||
* stays packed: removing an image shifts the ones after it up, and setting an index
|
||||
* past the end appends rather than leaving a gap. Returns null when the club doesn't
|
||||
* exist; the caller validates the index is in range.
|
||||
*/
|
||||
export async function setClubAdditionalImage(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
index: number,
|
||||
imageName: string
|
||||
): Promise<Club | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||
.bind(clubId)
|
||||
.first<ClubRow>()
|
||||
if (row === null) return null
|
||||
const stored = JSON.parse(row.data) as StoredClub
|
||||
|
||||
const images = [...(stored.AdditionalImages ?? [])]
|
||||
if (imageName === '') {
|
||||
// Removing past the end is a no-op, not an error: the image is already gone.
|
||||
if (index < images.length) images.splice(index, 1)
|
||||
} else if (index < images.length) {
|
||||
images[index] = imageName
|
||||
} else if (images.length < MAX_ADDITIONAL_IMAGES) {
|
||||
images.push(imageName)
|
||||
}
|
||||
|
||||
const updated: StoredClub = { ...stored, AdditionalImages: images }
|
||||
await db
|
||||
.prepare('UPDATE club SET data = ?1 WHERE club_id = ?2')
|
||||
.bind(JSON.stringify(updated), clubId)
|
||||
.run()
|
||||
return toDto(updated)
|
||||
}
|
||||
|
||||
/** A club's custom tags (stored on the blob; empty when it has none). */
|
||||
export async function getClubCustomTags(db: D1Database, clubId: number): Promise<string[]> {
|
||||
const row = await db
|
||||
@@ -659,6 +747,30 @@ export async function getClub(db: D1Database, clubId: number): Promise<Club | nu
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a club and everything hanging off it — its memberships and announcements —
|
||||
* and clear it from the home club of anyone who'd set it. Returns false when there
|
||||
* was no such club. Batched so a half-deleted club can't be left behind.
|
||||
*/
|
||||
export async function deleteClub(db: D1Database, clubId: number): Promise<boolean> {
|
||||
if ((await getClub(db, clubId)) === null) return false
|
||||
await db.batch([
|
||||
db.prepare('DELETE FROM club_member WHERE club_id = ?1').bind(clubId),
|
||||
db.prepare('DELETE FROM club_announcement WHERE club_id = ?1').bind(clubId),
|
||||
// The account table belongs to the auth worker; a dangling homeClubId already
|
||||
// reads as "no home club" (getHomeClub), but leaving it would point at whatever
|
||||
// club later reuses the id.
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE account SET data = json_remove(data, '$.homeClubId')
|
||||
WHERE json_extract(data, '$.homeClubId') = ?1`
|
||||
)
|
||||
.bind(clubId),
|
||||
db.prepare('DELETE FROM club WHERE club_id = ?1').bind(clubId),
|
||||
])
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription clubs (`ClubType` 1) are a creator's paid-subscriber club, not a
|
||||
* club you browse or list among your own — they're excluded from the "my clubs"
|
||||
@@ -666,6 +778,23 @@ export async function getClub(db: D1Database, clubId: number): Promise<Club | nu
|
||||
*/
|
||||
const SUBSCRIPTION_CLUB_TYPE = 1
|
||||
|
||||
/**
|
||||
* How many clubs an account has made, for the per-account club cap. Subscription
|
||||
* clubs don't count — they're provisioned for a creator's subscribers rather than
|
||||
* made by hand, so they shouldn't eat a slot.
|
||||
*/
|
||||
export async function countClubsByCreator(db: D1Database, accountId: number): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM club
|
||||
WHERE creator_account_id = ?1
|
||||
AND json_extract(data, '$.ClubType') != ?2`
|
||||
)
|
||||
.bind(accountId, SUBSCRIPTION_CLUB_TYPE)
|
||||
.first<{ n: number }>()
|
||||
return row?.n ?? 0
|
||||
}
|
||||
|
||||
/** All clubs created by an account (GetMyCreatedClubs), oldest first. */
|
||||
export async function getClubsByCreator(db: D1Database, accountId: number): Promise<Club[]> {
|
||||
const { results } = await db
|
||||
@@ -740,19 +869,70 @@ export async function joinClub(
|
||||
return { ...club, MemberCount: count }
|
||||
}
|
||||
|
||||
/**
|
||||
* How a request to join resolved. `joined` is an Open club (no approval needed),
|
||||
* `requested` an AskToJoin club (now PendingRequested), `alreadyPending` a repeat
|
||||
* request, `alreadyMember` someone who's already in. `inviteOnly` and `banned` are
|
||||
* refusals — the caller can't get in this way.
|
||||
*/
|
||||
export type JoinRequestResult =
|
||||
'joined' | 'requested' | 'alreadyPending' | 'alreadyMember' | 'inviteOnly' | 'banned'
|
||||
|
||||
/**
|
||||
* Ask to join a club. Unlike `joinClub` this honours the club's Joinability strictly:
|
||||
* an InviteOnly club can only be entered through an invite, so a request is refused
|
||||
* rather than parked as pending. Returns the outcome plus the club with its refreshed
|
||||
* MemberCount, or null when the club doesn't exist.
|
||||
*/
|
||||
export async function requestToJoinClub(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
accountId: number
|
||||
): Promise<{ result: JoinRequestResult; club: Club } | null> {
|
||||
const club = await getClub(db, clubId)
|
||||
if (!club) return null
|
||||
|
||||
const current = await getMembership(db, clubId, accountId)
|
||||
// A ban can't be shed by asking again, and existing members/requests stay as-is.
|
||||
if (current === ClubMembershipType.Banned) return { result: 'banned', club }
|
||||
if (current >= MEMBER_THRESHOLD) return { result: 'alreadyMember', club }
|
||||
if (current === ClubMembershipType.PendingRequested) return { result: 'alreadyPending', club }
|
||||
|
||||
if (club.Joinability === ClubJoinability.InviteOnly) return { result: 'inviteOnly', club }
|
||||
|
||||
const open = club.Joinability === ClubJoinability.Open
|
||||
await setMembership(
|
||||
db,
|
||||
clubId,
|
||||
accountId,
|
||||
open ? ClubMembershipType.Member : ClubMembershipType.PendingRequested
|
||||
)
|
||||
|
||||
const count = await syncMemberCount(db, clubId)
|
||||
return { result: open ? 'joined' : 'requested', club: { ...club, MemberCount: count } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove `accountId`'s membership of a club (idempotent). A ban is preserved — you
|
||||
* can't clear it by leaving — but any member/pending row is dropped. Returns the
|
||||
* club with its refreshed MemberCount, or null when the club doesn't exist. The
|
||||
* club itself is left in place even when the last member leaves.
|
||||
* outcome plus the club with its refreshed MemberCount, or null when the club doesn't
|
||||
* exist. The club itself is left in place even when the last member leaves.
|
||||
*
|
||||
* The creator can't leave: a club with no owner has no one who can administer it, and
|
||||
* there's no ownership transfer, so they have to delete the club instead. `creator`
|
||||
* reports that refusal, with the club unchanged.
|
||||
*/
|
||||
export async function leaveClub(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
accountId: number
|
||||
): Promise<Club | null> {
|
||||
): Promise<{ result: 'left' | 'creator'; club: Club } | null> {
|
||||
const club = await getClub(db, clubId)
|
||||
if (!club) return null
|
||||
|
||||
const current = await getMembership(db, clubId, accountId)
|
||||
if (current === ClubMembershipType.Creator) return { result: 'creator', club }
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'DELETE FROM club_member WHERE club_id = ?1 AND account_id = ?2 AND membership_type <> ?3'
|
||||
@@ -760,5 +940,26 @@ export async function leaveClub(
|
||||
.bind(clubId, accountId, ClubMembershipType.Banned)
|
||||
.run()
|
||||
const count = await syncMemberCount(db, clubId)
|
||||
return { result: 'left', club: { ...club, MemberCount: count } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an account's membership tier in a club — the invite / role-assignment write
|
||||
* behind `PUT /club/:id/members/invite`. Upserts the `club_member` row to
|
||||
* `membershipType` (adding the account when it wasn't a member, and overriding a prior
|
||||
* tier or ban), then refreshes the club's MemberCount. Returns the club with its fresh
|
||||
* count, or null when the club is gone. The caller is responsible for checking that the
|
||||
* tier is one it may grant and that the target isn't the club's Creator.
|
||||
*/
|
||||
export async function setMemberType(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
accountId: number,
|
||||
membershipType: ClubMembershipType
|
||||
): Promise<Club | null> {
|
||||
const club = await getClub(db, clubId)
|
||||
if (!club) return null
|
||||
await setMembership(db, clubId, accountId, membershipType)
|
||||
const count = await syncMemberCount(db, clubId)
|
||||
return { ...club, MemberCount: count }
|
||||
}
|
||||
|
||||
+1330
-330
@@ -1,15 +1,24 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import {
|
||||
glyphLength,
|
||||
MAX_CLUB_DESCRIPTION_LENGTH,
|
||||
MAX_CLUB_NAME_LENGTH,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
clearHomeClub,
|
||||
ClubJoinability,
|
||||
ClubMembershipType,
|
||||
ClubVisibility,
|
||||
countClubsByCreator,
|
||||
createClub,
|
||||
createClubAnnouncement,
|
||||
deleteClub,
|
||||
getClub,
|
||||
getClubAnnouncements,
|
||||
getClubDetails,
|
||||
@@ -20,14 +29,67 @@ import {
|
||||
getMembership,
|
||||
joinClub,
|
||||
leaveClub,
|
||||
MAX_ADDITIONAL_IMAGES,
|
||||
requestToJoinClub,
|
||||
searchClubs,
|
||||
setClubAdditionalImage,
|
||||
setHomeClub,
|
||||
setMemberType,
|
||||
updateClub,
|
||||
} from './clubs-db'
|
||||
import {
|
||||
AnnouncementIdEnvelope,
|
||||
AnnouncementRequest,
|
||||
AUTHED,
|
||||
CategoryTags,
|
||||
ChatDisabledResponse,
|
||||
ClubAnnouncementsEnvelope,
|
||||
ClubDetailsDto,
|
||||
ClubDetailsEnvelope,
|
||||
ClubDto,
|
||||
ClubEnvelope,
|
||||
ClubhouseRequest,
|
||||
ClubMembersEnvelope,
|
||||
ClubSearchResponse,
|
||||
CreateClubRequest,
|
||||
EmptyObject,
|
||||
ErrorEnvelope,
|
||||
form,
|
||||
HomeClubRequest,
|
||||
ImageNameRequest,
|
||||
InviteMemberRequest,
|
||||
json,
|
||||
JsonArray,
|
||||
MinLevelRequest,
|
||||
ModifyClubRequest,
|
||||
NullEnvelope,
|
||||
SubscriberCountResponse,
|
||||
SubscriptionDetailsResponse,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from './openapi'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
* Clubs Worker. Hosts the club endpoints the game client calls on the `clubs` host:
|
||||
* club creation and editing, membership (join / ask-to-join / leave / ban tiers),
|
||||
* search, announcements, the club gallery, a club's clubhouse room, and each player's
|
||||
* home club. Everything is D1-backed (the shared `recflare` database); the
|
||||
* `/subscription/*` routes are stubs, since there are no subscription clubs yet.
|
||||
*
|
||||
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
|
||||
*/
|
||||
|
||||
/** The `clubId` path parameter, shared by every per-club route. */
|
||||
const CLUB_ID_PARAM = {
|
||||
name: 'clubId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The club’s id (digits only — a non-numeric id doesn’t match the route)',
|
||||
schema: { type: 'string' },
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Resolve the account id from a Bearer token. Returns `null` when the header is
|
||||
* missing, the token is invalid, or the `sub` claim isn't an integer.
|
||||
@@ -36,11 +98,28 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/** Longest a club name may be (the reference's MaxNameLength). */
|
||||
const MAX_CLUB_NAME_LENGTH = 16
|
||||
/**
|
||||
* How many clubs one account may create, when the `MAX_CLUBS_PER_ACCOUNT` var is
|
||||
* unset. Counts the clubs the account created (subscription clubs excluded — those
|
||||
* aren't made by hand). Setting the var to 0 lifts the cap entirely. Existing clubs
|
||||
* are never touched: lowering the cap just stops new ones.
|
||||
*/
|
||||
const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
|
||||
|
||||
|
||||
/**
|
||||
* The tiers `members/invite` may grant — the real member roles only. Creator (100) is
|
||||
* excluded so an invite can't mint a second owner, and the pending/none/banned states
|
||||
* aren't something you "invite" someone to.
|
||||
*/
|
||||
const INVITABLE_TIERS: ReadonlySet<number> = new Set([
|
||||
ClubMembershipType.Member,
|
||||
ClubMembershipType.Moderator,
|
||||
ClubMembershipType.Coowner,
|
||||
])
|
||||
|
||||
/** The punctuation a club name may use, on top of letters and digits. */
|
||||
const ALLOWED_NAME_PUNCTUATION = new Set([...` .,'!?-_&()#@:+`])
|
||||
const ALLOWED_NAME_PUNCTUATION = new Set(` .,'!?-_&()#@:+`)
|
||||
|
||||
/**
|
||||
* Club names are letters (any Latin script), digits, and basic punctuation — the
|
||||
@@ -128,439 +207,1360 @@ const app = new Hono<App>()
|
||||
// account. Auth-gated. 404 when they have no home club, the club is gone, or it has
|
||||
// no clubhouse room: the client expects a 404 for "no home club" and errors on an
|
||||
// empty object. Returns the bare club (not the envelope), as the reference does.
|
||||
.get('/club/home/me', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const club = await getHomeClub(c.env.DB, id)
|
||||
return club === null ? c.notFound() : c.json(club)
|
||||
})
|
||||
.get(
|
||||
'/club/home/me',
|
||||
describeRoute({
|
||||
tags: ['Home club'],
|
||||
summary: 'The player’s home club',
|
||||
description: [
|
||||
'The club whose clubhouse the player spawns into (a field on their account row).',
|
||||
'404 when they have no home club, the club is gone, or it has no clubhouse room —',
|
||||
'the client expects a 404 for “no home club” and errors on an empty object. Returns',
|
||||
'the bare club, not the envelope, as the reference does.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(ClubDto, 'The player’s home club'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: { description: 'No home club, or it has no clubhouse room' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const club = await getHomeClub(c.env.DB, id)
|
||||
return club === null ? c.notFound() : c.json(club)
|
||||
}
|
||||
)
|
||||
|
||||
// Set the player's home club (`clubId` form field). They must be a member of it —
|
||||
// you can't make a club you don't belong to your home. Answers the envelope.
|
||||
.put('/club/home/me', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.put(
|
||||
'/club/home/me',
|
||||
describeRoute({
|
||||
tags: ['Home club'],
|
||||
summary: 'Set the player’s home club',
|
||||
description: [
|
||||
'Points the player’s home club at the posted `clubId`. They must already be a member',
|
||||
'of it — you can’t make a club you don’t belong to your home. Answers the envelope',
|
||||
'carrying the bare club.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(HomeClubRequest, 'The club to make home'),
|
||||
responses: {
|
||||
200: json(ClubEnvelope, 'The envelope carrying the new home club'),
|
||||
400: json(ErrorEnvelope, 'Missing, non-numeric or zero clubId'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'The caller isn’t a member of that club'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'clubid')
|
||||
const clubId = Number.parseInt(
|
||||
typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '',
|
||||
10
|
||||
)
|
||||
if (Number.isNaN(clubId) || clubId === 0) return clubError(c, 'Invalid clubId.')
|
||||
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Member) {
|
||||
return c.json(
|
||||
{ error: 'You are not a member of that club.', success: false, value: null },
|
||||
403
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'clubid')
|
||||
const clubId = Number.parseInt(
|
||||
typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '',
|
||||
10
|
||||
)
|
||||
}
|
||||
if (Number.isNaN(clubId) || clubId === 0) return clubError(c, 'Invalid clubId.')
|
||||
|
||||
await setHomeClub(c.env.DB, id, clubId)
|
||||
return c.json({ error: '', success: true, value: club })
|
||||
})
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Member) {
|
||||
return c.json(
|
||||
{ error: 'You are not a member of that club.', success: false, value: null },
|
||||
403
|
||||
)
|
||||
}
|
||||
|
||||
await setHomeClub(c.env.DB, id, clubId)
|
||||
return c.json({ error: '', success: true, value: club })
|
||||
}
|
||||
)
|
||||
|
||||
// Clear the player's home club — they spawn into the default hub again instead of a
|
||||
// clubhouse. No body, idempotent (clearing when there's none set is a no-op, not a
|
||||
// 404), and it doesn't touch their membership of the club. The envelope's value is
|
||||
// null because there's no home club left to describe; GET goes back to 404ing.
|
||||
.delete(
|
||||
'/club/home/me',
|
||||
describeRoute({
|
||||
tags: ['Home club'],
|
||||
summary: 'Clear the player’s home club',
|
||||
description: [
|
||||
'The player spawns into the default hub again instead of a clubhouse. No body,',
|
||||
'idempotent (clearing when none is set is a no-op, not a 404), and it doesn’t touch',
|
||||
'their membership of the club. The envelope’s `value` is null because there’s no home',
|
||||
'club left to describe; GET goes back to 404ing.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(NullEnvelope, 'Cleared (value null)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
await clearHomeClub(c.env.DB, id)
|
||||
return c.json({ error: '', success: true, value: null })
|
||||
}
|
||||
)
|
||||
|
||||
// A real Rec Room client endpoint with no backing implementation yet. The
|
||||
// client calls it on the clubs host at /subscription/mine/member (no /club
|
||||
// prefix) and sends no auth header, so it isn't gated. Returns an empty
|
||||
// array = no club subscription memberships (the client chokes on null).
|
||||
.get('/subscription/mine/member', (c) => c.json([]))
|
||||
.get(
|
||||
'/subscription/mine/member',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'The caller’s club-subscription memberships',
|
||||
description: [
|
||||
'A real client endpoint with no backing implementation yet. The client calls it on',
|
||||
'the clubs host at `/subscription/mine/member` (no `/club` prefix) and sends no auth',
|
||||
'header, so it isn’t gated. Always `[]` — no subscription memberships (the client',
|
||||
'chokes on null).',
|
||||
].join(' '),
|
||||
responses: { 200: json(JsonArray, 'Always empty for now') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Subscription details for an account (numeric id) — simulated: no club, no subs.
|
||||
.get('/subscription/details/:accountId{[0-9]+}', (c) =>
|
||||
c.json({
|
||||
accountId: Number.parseInt(c.req.param('accountId'), 10),
|
||||
clubId: 0,
|
||||
subscriberCount: 0,
|
||||
})
|
||||
.get(
|
||||
'/subscription/details/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'Subscription details for an account',
|
||||
description: 'Simulated — no subscription club, no subscribers.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'accountId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Account id (digits only)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(SubscriptionDetailsResponse, 'Zeroed subscription details') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
accountId: Number.parseInt(c.req.param('accountId'), 10),
|
||||
clubId: 0,
|
||||
subscriberCount: 0,
|
||||
})
|
||||
)
|
||||
|
||||
// Details for a named subscription (e.g. `rrplus`). The client deserializes this
|
||||
// into an object, so it must return `{}` (not `[]`).
|
||||
.get('/subscription/details/:subscription', (c) => c.json({}))
|
||||
.get(
|
||||
'/subscription/details/:subscription',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'Details for a named subscription',
|
||||
description: [
|
||||
'A named subscription (e.g. `rrplus`). The client deserializes this into an object,',
|
||||
'so it must return `{}` — not `[]`.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'subscription',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The subscription name, e.g. `rrplus`',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(EmptyObject, 'Always an empty object') },
|
||||
}),
|
||||
(c) => c.json({})
|
||||
)
|
||||
|
||||
// Subscriber count for an account. No club subscriptions yet → 0.
|
||||
.get('/subscription/subscriberCount/:accountId{[0-9]+}', (c) => c.json(0))
|
||||
.get(
|
||||
'/subscription/subscriberCount/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'Subscriber count for an account',
|
||||
description: 'No club subscriptions yet, so this is always 0. A bare JSON integer.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'accountId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Account id (digits only)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(SubscriberCountResponse, 'Always 0') },
|
||||
}),
|
||||
(c) => c.json(0)
|
||||
)
|
||||
|
||||
// The player's clubs that have unread announcements (MyClubsWithUnread-
|
||||
// Announcements). Nothing tracks what a player has read yet → nothing is unread.
|
||||
.get('/announcements/v2/mine/unread', (c) => c.json([]))
|
||||
.get(
|
||||
'/announcements/v2/mine/unread',
|
||||
describeRoute({
|
||||
tags: ['Announcements'],
|
||||
summary: 'The player’s clubs with unread announcements',
|
||||
description: [
|
||||
'MyClubsWithUnreadAnnouncements. Nothing tracks what a player has read yet, so',
|
||||
'nothing is unread → always `[]`.',
|
||||
].join(' '),
|
||||
responses: { 200: json(JsonArray, 'Always empty for now') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// A club's announcements — its noticeboard, newest first. Public. Answers the
|
||||
// envelope, with `LastAnnouncementId` the newest one (null when there are none)
|
||||
// and `LastReadAnnouncementId` 0: nothing tracks read state yet.
|
||||
.get('/announcements/club/:clubId{[0-9]+}', async (c) => {
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const announcements = await getClubAnnouncements(c.env.DB, clubId)
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: {
|
||||
Announcements: announcements,
|
||||
ClubId: clubId,
|
||||
LastAnnouncementId: announcements[0]?.AnnouncementId ?? null,
|
||||
LastReadAnnouncementId: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
.get(
|
||||
'/announcements/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Announcements'],
|
||||
summary: 'A club’s announcements',
|
||||
description: [
|
||||
'The club’s noticeboard, newest first. Public. Answers the envelope, with',
|
||||
'`LastAnnouncementId` the newest one (null when there are none) and',
|
||||
'`LastReadAnnouncementId` 0 — nothing tracks read state yet. An unknown club simply',
|
||||
'has no announcements.',
|
||||
].join(' '),
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: { 200: json(ClubAnnouncementsEnvelope, 'The club’s noticeboard') },
|
||||
}),
|
||||
async (c) => {
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const announcements = await getClubAnnouncements(c.env.DB, clubId)
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: {
|
||||
Announcements: announcements,
|
||||
ClubId: clubId,
|
||||
LastAnnouncementId: announcements[0]?.AnnouncementId ?? null,
|
||||
LastReadAnnouncementId: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Post an announcement to a club. Co-owner or above only. The envelope's value is
|
||||
// the new announcement's id.
|
||||
.post('/announcements/club/:clubId{[0-9]+}', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.post(
|
||||
'/announcements/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Announcements'],
|
||||
summary: 'Post an announcement to a club',
|
||||
description: 'Co-owner or above only. The envelope’s `value` is the new announcement’s id.',
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
requestBody: form(AnnouncementRequest, 'The announcement fields'),
|
||||
responses: {
|
||||
200: json(AnnouncementIdEnvelope, 'The new announcement’s id'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Below co-owner'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Coowner) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Coowner) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const field = (name: string): string | undefined => {
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
|
||||
const v = key === undefined ? undefined : body[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
const announcementId = await createClubAnnouncement(c.env.DB, clubId, id, {
|
||||
title: field('title'),
|
||||
body: field('body'),
|
||||
imageName: field('imageName'),
|
||||
meta: field('meta'),
|
||||
})
|
||||
return c.json({ error: '', success: true, value: announcementId })
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const field = (name: string): string | undefined => {
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
|
||||
const v = key === undefined ? undefined : body[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
const announcementId = await createClubAnnouncement(c.env.DB, clubId, id, {
|
||||
title: field('title'),
|
||||
body: field('body'),
|
||||
imageName: field('imageName'),
|
||||
meta: field('meta'),
|
||||
})
|
||||
return c.json({ error: '', success: true, value: announcementId })
|
||||
})
|
||||
)
|
||||
|
||||
// The clubs the player is a member of (GetMyMembershipClubs). Reads the caller's
|
||||
// memberships from `club_member`. A caller with no valid token has no clubs, so
|
||||
// this answers an empty list rather than 401ing — the client shows the "my clubs"
|
||||
// shelf either way, and an error there breaks the screen.
|
||||
.get('/club/mine/member', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.json([])
|
||||
return c.json(await getClubsByMember(c.env.DB, id))
|
||||
})
|
||||
.get(
|
||||
'/club/mine/member',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'The clubs the player is a member of',
|
||||
description: [
|
||||
'GetMyMembershipClubs — the caller’s memberships from `club_member`, oldest club',
|
||||
'first (pending/denied/banned rows excluded). A caller with no valid token has no',
|
||||
'clubs, so this answers `[]` rather than 401ing: the client shows the “my clubs”',
|
||||
'shelf either way, and an error there breaks the screen.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: { 200: json(ClubDto.array(), 'The caller’s clubs (empty when signed out)') },
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.json([])
|
||||
return c.json(await getClubsByMember(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// The clubs the player created (GetMyCreatedClubs). Empty list when signed out,
|
||||
// like mine/member.
|
||||
.get('/club/mine/created', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.json([])
|
||||
return c.json(await getClubsByCreator(c.env.DB, id))
|
||||
})
|
||||
.get(
|
||||
'/club/mine/created',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'The clubs the player created',
|
||||
description: 'GetMyCreatedClubs, oldest first. Empty list when signed out, like mine/member.',
|
||||
security: AUTHED,
|
||||
responses: { 200: json(ClubDto.array(), 'The clubs the caller created') },
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.json([])
|
||||
return c.json(await getClubsByCreator(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Club search / browse. Public, non-subscription clubs; `category` filters to that
|
||||
// category, `query` matches the name or description, `sort` picks the order (1 =
|
||||
// newest, 2 = by name, default = most members first), and `count` caps the page
|
||||
// (out of range → 30). Public. Answers `{ Clubs, ContinuationToken, TotalClubs }`.
|
||||
.get('/club/search', async (c) => {
|
||||
const count = Number.parseInt(c.req.query('count') ?? '', 10)
|
||||
return c.json(
|
||||
await searchClubs(
|
||||
c.env.DB,
|
||||
c.req.query('category') ?? '',
|
||||
c.req.query('query') ?? '',
|
||||
c.req.query('sort'),
|
||||
Number.isNaN(count) || count <= 0 || count > 100 ? 30 : count
|
||||
.get(
|
||||
'/club/search',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Club search / browse',
|
||||
description: [
|
||||
'Public, non-subscription clubs. Public (no auth). `TotalClubs` is the full match',
|
||||
'count, not the page size.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'category',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Filter to one category (exact, case-insensitive)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'query',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Substring of the club name or description',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'sort',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: '1 = newest first, 2 = by name, anything else = most members first',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'count',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Page size; out of range (or absent) falls back to 30',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(ClubSearchResponse, 'The matching page of clubs') },
|
||||
}),
|
||||
async (c) => {
|
||||
const count = Number.parseInt(c.req.query('count') ?? '', 10)
|
||||
return c.json(
|
||||
await searchClubs(
|
||||
c.env.DB,
|
||||
c.req.query('category') ?? '',
|
||||
c.req.query('query') ?? '',
|
||||
c.req.query('sort'),
|
||||
Number.isNaN(count) || count <= 0 || count > 100 ? 30 : count
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// The set of club category tags a club can be filed under — a fixed list.
|
||||
.get('/club/categoryTags', (c) =>
|
||||
c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment'])
|
||||
.get(
|
||||
'/club/categoryTags',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Club category tags',
|
||||
description: 'The fixed set of categories a club can be filed under.',
|
||||
responses: { 200: json(CategoryTags, 'The category list') },
|
||||
}),
|
||||
(c) => c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment'])
|
||||
)
|
||||
|
||||
// Create a club. The client posts a form to `/club/create` with lowercase fields
|
||||
// (`name`, `description`, `category`). Auth-gated. Answers the `{ error, success,
|
||||
// value }` envelope carrying the new club's details — not a bare club.
|
||||
.post('/club/create', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.post(
|
||||
'/club/create',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Create a club',
|
||||
description: [
|
||||
'The client posts a form with lowercase fields (`name`, `description`, `category`);',
|
||||
'either casing is accepted. Enums arrive by name (`visibility=Public`,',
|
||||
'`joinability=Open`). `ClubType` is never taken from the client — a player-created',
|
||||
'club is always a regular one, since letting the client pick would let it mint a',
|
||||
'subscription club (type 1), which is excluded from every listing. The caller becomes',
|
||||
'the club’s Creator. Answers the `{ error, success, value }` envelope carrying the new',
|
||||
'club’s full details — not a bare club.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateClubRequest, 'The new club’s fields'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The new club’s details'),
|
||||
400: json(
|
||||
ErrorEnvelope,
|
||||
'Missing/invalid/too-long name, or the per-account club limit is reached'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
// The client sends lowercase field names; accept either casing.
|
||||
const field = (name: string): string | undefined => {
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
|
||||
const v = key === undefined ? undefined : body[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
const int = (v: string | undefined): number | undefined => {
|
||||
const n = v === undefined ? Number.NaN : Number.parseInt(v, 10)
|
||||
return Number.isNaN(n) ? undefined : n
|
||||
}
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
// The client sends lowercase field names; accept either casing.
|
||||
const field = (name: string): string | undefined => {
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
|
||||
const v = key === undefined ? undefined : body[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
const int = (v: string | undefined): number | undefined => {
|
||||
const n = v === undefined ? Number.NaN : Number.parseInt(v, 10)
|
||||
return Number.isNaN(n) ? undefined : n
|
||||
}
|
||||
|
||||
const name = field('name')?.trim() ?? ''
|
||||
const description = field('description') ?? ''
|
||||
if (name === '') return clubError(c, 'You must enter a name for your club.')
|
||||
if (!isValidClubName(name)) {
|
||||
return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.')
|
||||
}
|
||||
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
|
||||
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
|
||||
}
|
||||
|
||||
const club = await createClub(c.env.DB, id, {
|
||||
name,
|
||||
description,
|
||||
// An unset category files the club under Social, as the reference does.
|
||||
category: field('category')?.trim() || 'Social',
|
||||
visibility: parseVisibility(field('visibility')),
|
||||
joinability: parseJoinability(field('joinability')),
|
||||
allowJuniors: parseFormBool(field('allowJuniors')),
|
||||
mainImageName: field('mainImageName'),
|
||||
// ClubType is deliberately not taken from the client: a player-created club
|
||||
// is always a regular one. Letting the client pick would let it mint a
|
||||
// subscription club (type 1), which is excluded from every club listing.
|
||||
minLevel: int(field('minLevel')),
|
||||
})
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, club, id),
|
||||
})
|
||||
})
|
||||
|
||||
// Edit a club's details. The client PUTs a form of the fields it's changing —
|
||||
// enums by name (`visibility=Public`, `joinability=Open`, `allowJuniors=True`) —
|
||||
// and absent fields keep their stored value. `customTags` may repeat; when present
|
||||
// it replaces the club's tag set wholesale. Co-owner or above only. Answers the
|
||||
// same `{ error, success, value }` envelope create does.
|
||||
.put('/club/:clubId{[0-9]+}/modifydetails', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
|
||||
// Editing details is a co-owner power — plain members and moderators can't.
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Coowner) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
}
|
||||
|
||||
// `all: true` so a repeated `customTags` field arrives as a list.
|
||||
const body = (await c.req.parseBody({ all: true }).catch(() => ({}))) as Record<string, unknown>
|
||||
const field = (name: string): string | undefined => {
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
|
||||
const v = key === undefined ? undefined : body[key]
|
||||
const first = Array.isArray(v) ? v[0] : v
|
||||
return typeof first === 'string' ? first : undefined
|
||||
}
|
||||
const list = (name: string): string[] | undefined => {
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
|
||||
if (key === undefined) return undefined
|
||||
const v = body[key]
|
||||
const values = Array.isArray(v) ? v : [v]
|
||||
return values.filter((t): t is string => typeof t === 'string')
|
||||
}
|
||||
const int = (v: string | undefined): number | undefined => {
|
||||
const n = v === undefined ? Number.NaN : Number.parseInt(v, 10)
|
||||
return Number.isNaN(n) ? undefined : n
|
||||
}
|
||||
|
||||
// An empty name/description means "unchanged", not "clear it" — the reference
|
||||
// only applies these when non-empty.
|
||||
const name = field('name')?.trim() || undefined
|
||||
if (name !== undefined) {
|
||||
const name = field('name')?.trim() ?? ''
|
||||
const description = field('description') ?? ''
|
||||
if (name === '') return clubError(c, 'You must enter a name for your club.')
|
||||
if (!isValidClubName(name)) {
|
||||
return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.')
|
||||
}
|
||||
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
|
||||
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
|
||||
}
|
||||
// Counted in code points like the name above, so an emoji-heavy description is
|
||||
// measured the way a player sees it rather than by UTF-16 units.
|
||||
if (glyphLength(description) > MAX_CLUB_DESCRIPTION_LENGTH) {
|
||||
return clubError(
|
||||
c,
|
||||
`Club descriptions can be at most ${MAX_CLUB_DESCRIPTION_LENGTH} characters.`
|
||||
)
|
||||
}
|
||||
// The per-account cap, checked after the cheap validations so a rejected name
|
||||
// costs no extra D1 read.
|
||||
const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT)
|
||||
if (maxClubs > 0 && (await countClubsByCreator(c.env.DB, id)) >= maxClubs) {
|
||||
logger.info('club create rejected: per-account club limit', { accountId: id })
|
||||
return clubError(c, `You can only have ${maxClubs} clubs.`)
|
||||
}
|
||||
|
||||
const club = await createClub(c.env.DB, id, {
|
||||
name,
|
||||
description,
|
||||
// An unset category files the club under Social, as the reference does.
|
||||
category: field('category')?.trim() || 'Social',
|
||||
visibility: parseVisibility(field('visibility')),
|
||||
joinability: parseJoinability(field('joinability')),
|
||||
allowJuniors: parseFormBool(field('allowJuniors')),
|
||||
mainImageName: field('mainImageName'),
|
||||
// ClubType is deliberately not taken from the client: a player-created club
|
||||
// is always a regular one. Letting the client pick would let it mint a
|
||||
// subscription club (type 1), which is excluded from every club listing.
|
||||
minLevel: int(field('minLevel')),
|
||||
})
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, club, id),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const updated = await updateClub(c.env.DB, clubId, {
|
||||
name,
|
||||
description: field('description') || undefined,
|
||||
category: field('category')?.trim() || undefined,
|
||||
visibility: parseVisibility(field('visibility')),
|
||||
joinability: parseJoinability(field('joinability')),
|
||||
allowJuniors: parseFormBool(field('allowJuniors')),
|
||||
mainImageName: field('mainImageName') || undefined,
|
||||
minLevel: int(field('minLevel')),
|
||||
customTags: list('customTags'),
|
||||
})
|
||||
if (updated === null) return c.notFound()
|
||||
// Edit a club's details. The client PUTs a form of the fields it's changing —
|
||||
// enums by name (`visibility=Public`, `joinability=Open`, `allowJuniors=True`) —
|
||||
// and absent fields keep their stored value. `customTags` may repeat; when present
|
||||
// it replaces the club's tag set wholesale. Co-owner or above only. Answers the
|
||||
// same `{ error, success, value }` envelope create does.
|
||||
//
|
||||
// `/modify` is the same endpoint under the shorter name the client also PUTs to
|
||||
// (`name=…&description=…&category=…`); one handler, so the two can't drift.
|
||||
.on(
|
||||
'PUT',
|
||||
['/club/:clubId{[0-9]+}/modifydetails', '/club/:clubId{[0-9]+}/modify'],
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Edit a club’s details',
|
||||
description: [
|
||||
'The client PUTs a form of just the fields it’s changing — enums by name',
|
||||
'(`visibility=Public`, `joinability=Open`, `allowJuniors=True`) — and absent fields',
|
||||
'keep their stored value (an empty `name`/`description` means “unchanged”, not',
|
||||
'“clear it”). `customTags` may repeat; when present it replaces the club’s tag set',
|
||||
'wholesale. Co-owner or above only. `/modify` is the same endpoint under the shorter',
|
||||
'name the client also PUTs to — one handler, so the two can’t drift. Answers the same',
|
||||
'details envelope create does, since the client re-renders the club screen from it.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
requestBody: form(ModifyClubRequest, 'The fields to change'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The updated club’s details'),
|
||||
400: json(ErrorEnvelope, 'Invalid or too-long name'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Below co-owner'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, updated, id),
|
||||
})
|
||||
})
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
|
||||
// Editing details is a co-owner power — plain members and moderators can't.
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Coowner) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
}
|
||||
|
||||
// `all: true` so a repeated `customTags` field arrives as a list.
|
||||
const body = (await c.req.parseBody({ all: true }).catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const field = (name: string): string | undefined => {
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
|
||||
const v = key === undefined ? undefined : body[key]
|
||||
const first = Array.isArray(v) ? v[0] : v
|
||||
return typeof first === 'string' ? first : undefined
|
||||
}
|
||||
const list = (name: string): string[] | undefined => {
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
|
||||
if (key === undefined) return undefined
|
||||
const v = body[key]
|
||||
const values = Array.isArray(v) ? v : [v]
|
||||
return values.filter((t): t is string => typeof t === 'string')
|
||||
}
|
||||
const int = (v: string | undefined): number | undefined => {
|
||||
const n = v === undefined ? Number.NaN : Number.parseInt(v, 10)
|
||||
return Number.isNaN(n) ? undefined : n
|
||||
}
|
||||
|
||||
// An empty name/description means "unchanged", not "clear it" — the reference
|
||||
// only applies these when non-empty.
|
||||
const name = field('name')?.trim() || undefined
|
||||
if (name !== undefined) {
|
||||
if (!isValidClubName(name)) {
|
||||
return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.')
|
||||
}
|
||||
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
|
||||
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Same absent-means-unchanged rule as the name, so a club with no description
|
||||
// isn't forced to grow one just to be edited.
|
||||
const description = field('description') || undefined
|
||||
if (description !== undefined && glyphLength(description) > MAX_CLUB_DESCRIPTION_LENGTH) {
|
||||
return clubError(
|
||||
c,
|
||||
`Club descriptions can be at most ${MAX_CLUB_DESCRIPTION_LENGTH} characters.`
|
||||
)
|
||||
}
|
||||
|
||||
const updated = await updateClub(c.env.DB, clubId, {
|
||||
name,
|
||||
description,
|
||||
category: field('category')?.trim() || undefined,
|
||||
visibility: parseVisibility(field('visibility')),
|
||||
joinability: parseJoinability(field('joinability')),
|
||||
allowJuniors: parseFormBool(field('allowJuniors')),
|
||||
mainImageName: field('mainImageName') || undefined,
|
||||
minLevel: int(field('minLevel')),
|
||||
customTags: list('customTags'),
|
||||
})
|
||||
if (updated === null) return c.notFound()
|
||||
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, updated, id),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// A club's full details — the club plus its tags, the per-tier permissions, and the
|
||||
// caller's own membership. Public (a signed-out viewer just gets MyMembershipType
|
||||
// 0). Unlike create/modifydetails this one is *not* enveloped: the reference writes
|
||||
// the details object straight out.
|
||||
.get('/club/:clubId{[0-9]+}/details', async (c) => {
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
const id = await authedId(c)
|
||||
return c.json(await getClubDetails(c.env.DB, club, id))
|
||||
})
|
||||
.get(
|
||||
'/club/:clubId{[0-9]+}/details',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'A club’s full details',
|
||||
description: [
|
||||
'The club plus its custom tags, the per-tier permissions, its gallery, and the',
|
||||
'caller’s own membership. Public — a signed-out viewer just gets `MyMembershipType` 0.',
|
||||
'Unlike create/modifydetails this one is NOT enveloped: the details object is written',
|
||||
'straight out, as the reference does.',
|
||||
].join(' '),
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDetailsDto, 'The club’s details (not enveloped)'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
const id = await authedId(c)
|
||||
return c.json(await getClubDetails(c.env.DB, club, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Whether a club has turned its club chat off. Nothing can disable club chat yet
|
||||
// (no setting, no storage), so chat is always on → `false`. A bare JSON boolean,
|
||||
// like the other `is…`/`has…` gates the client polls; not in the reference, so if
|
||||
// the client chokes on this it likely wants the `{ error, success, value }`
|
||||
// envelope the other club endpoints use.
|
||||
.get('/club/:clubId{[0-9]+}/hasDisabledClubChat', (c) => c.json(false))
|
||||
.get(
|
||||
'/club/:clubId{[0-9]+}/hasDisabledClubChat',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Whether the club has turned club chat off',
|
||||
description: [
|
||||
'Nothing can disable club chat yet (no setting, no storage), so chat is always on →',
|
||||
'`false`. A bare JSON boolean, like the other `is…`/`has…` gates the client polls; not',
|
||||
'in the reference, so if the client chokes on this it likely wants the',
|
||||
'`{ error, success, value }` envelope the other club endpoints use.',
|
||||
].join(' '),
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: { 200: json(ChatDisabledResponse, 'Always false') },
|
||||
}),
|
||||
(c) => c.json(false)
|
||||
)
|
||||
|
||||
// A club's members. `membershipType` filters to exactly that tier (an exact match,
|
||||
// not a threshold — `30` lists co-owners only, not the creator above them), and
|
||||
// `sortBy` picks the order (1 = account id, 2 = oldest first, default = highest
|
||||
// tier first). Public, and an unknown club is an empty list. Answers the envelope.
|
||||
.get('/club/:clubId{[0-9]+}/members', async (c) => {
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const raw = c.req.query('membershipType')
|
||||
const membershipType = raw === undefined ? Number.NaN : Number.parseInt(raw, 10)
|
||||
.get(
|
||||
'/club/:clubId{[0-9]+}/members',
|
||||
describeRoute({
|
||||
tags: ['Membership'],
|
||||
summary: 'A club’s members',
|
||||
description:
|
||||
'Public, and an unknown club is an empty list rather than a 404. Answers the envelope.',
|
||||
parameters: [
|
||||
CLUB_ID_PARAM,
|
||||
{
|
||||
name: 'membershipType',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: [
|
||||
'Filter to exactly that tier — an exact match, not a threshold, so `30` lists',
|
||||
'co-owners only, not the creator above them',
|
||||
].join(' '),
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'sortBy',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: '1 = account id, 2 = oldest first, anything else = highest tier first',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(ClubMembersEnvelope, 'The club’s membership rows') },
|
||||
}),
|
||||
async (c) => {
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const raw = c.req.query('membershipType')
|
||||
const membershipType = raw === undefined ? Number.NaN : Number.parseInt(raw, 10)
|
||||
|
||||
const members = await getClubMembers(
|
||||
c.env.DB,
|
||||
clubId,
|
||||
Number.isNaN(membershipType) ? undefined : membershipType,
|
||||
c.req.query('sortBy')
|
||||
)
|
||||
return c.json({ error: '', success: true, value: members })
|
||||
})
|
||||
const members = await getClubMembers(
|
||||
c.env.DB,
|
||||
clubId,
|
||||
Number.isNaN(membershipType) ? undefined : membershipType,
|
||||
c.req.query('sortBy')
|
||||
)
|
||||
return c.json({ error: '', success: true, value: members })
|
||||
}
|
||||
)
|
||||
|
||||
// Set the minimum player level required to join the club. The reference has no such
|
||||
// route (it only takes `minLevel` on modifydetails), but the client PUTs it here.
|
||||
// Same rules as the other club edits: co-owner or above, and the details envelope.
|
||||
.put('/club/:clubId{[0-9]+}/minlevel', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
.put(
|
||||
'/club/:clubId{[0-9]+}/minlevel',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Set the club’s minimum join level',
|
||||
description: [
|
||||
'The reference has no such route (it only takes `minLevel` on modifydetails), but the',
|
||||
'client PUTs it here. Same rules as the other club edits: co-owner or above, and the',
|
||||
'details envelope back.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
requestBody: form(MinLevelRequest, 'The new minimum level'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The updated club’s details'),
|
||||
400: json(ErrorEnvelope, 'Missing, non-numeric or negative minLevel'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Below co-owner'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Coowner) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Coowner) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'minlevel')
|
||||
const minLevel = Number.parseInt(
|
||||
typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '',
|
||||
10
|
||||
)
|
||||
if (Number.isNaN(minLevel) || minLevel < 0) return clubError(c, 'Invalid minLevel.')
|
||||
|
||||
const updated = await updateClub(c.env.DB, clubId, { minLevel })
|
||||
if (updated === null) return c.notFound()
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, updated, id),
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'minlevel')
|
||||
const minLevel = Number.parseInt(
|
||||
typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '',
|
||||
10
|
||||
)
|
||||
if (Number.isNaN(minLevel) || minLevel < 0) return clubError(c, 'Invalid minLevel.')
|
||||
|
||||
const updated = await updateClub(c.env.DB, clubId, { minLevel })
|
||||
if (updated === null) return c.notFound()
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, updated, id),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Set (or clear) the club's clubhouse room — the room players spawn into when the
|
||||
// club is their home. `roomId` sets it; omitting it clears the clubhouse. Co-owner
|
||||
// or above only. Answers the envelope with a null value, as the reference does.
|
||||
.put('/club/:clubId{[0-9]+}/clubhouse', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
// or above only. Answers the details envelope (the reference returns a null value
|
||||
// here, but the client re-renders from the response and leaves the old clubhouse
|
||||
// on screen unless it gets the updated club back).
|
||||
//
|
||||
// DELETE is the same thing with the clearing spelled out — it ignores any body and
|
||||
// always unsets the room, so "remove the clubhouse" doesn't depend on the client
|
||||
// remembering to send an empty PUT.
|
||||
.on(
|
||||
['PUT', 'DELETE'],
|
||||
'/club/:clubId{[0-9]+}/clubhouse',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Set or clear the club’s clubhouse room',
|
||||
description: [
|
||||
'The clubhouse is the room players spawn into when the club is their home. PUT with',
|
||||
'`roomId` sets it; omitting `roomId` clears it. DELETE is the same thing with the',
|
||||
'clearing spelled out — it ignores any body and always unsets the room, so “remove the',
|
||||
'clubhouse” doesn’t depend on the client remembering to send an empty PUT. Co-owner or',
|
||||
'above only. Answers the full details envelope: the reference returns a null value',
|
||||
'here, but the client re-renders from the response and leaves the old clubhouse on',
|
||||
'screen unless it gets the updated club back.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
requestBody: form(ClubhouseRequest, 'The clubhouse room (PUT only; DELETE ignores the body)'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The updated club’s details'),
|
||||
400: json(ErrorEnvelope, 'Non-numeric roomId'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Below co-owner'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Coowner) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Coowner) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
}
|
||||
|
||||
let roomId: number | null = null
|
||||
if (c.req.method !== 'DELETE') {
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'roomid')
|
||||
const raw = typeof body[key ?? ''] === 'string' ? String(body[key ?? '']).trim() : ''
|
||||
if (raw !== '' && Number.isNaN(Number.parseInt(raw, 10))) {
|
||||
return clubError(c, 'Invalid roomId.')
|
||||
}
|
||||
roomId = raw === '' ? null : Number.parseInt(raw, 10)
|
||||
}
|
||||
|
||||
const updated = await updateClub(c.env.DB, clubId, { clubhouseRoomId: roomId })
|
||||
if (updated === null) return c.notFound()
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, updated, id),
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'roomid')
|
||||
const raw = typeof body[key ?? ''] === 'string' ? String(body[key ?? '']).trim() : ''
|
||||
if (raw !== '' && Number.isNaN(Number.parseInt(raw, 10))) {
|
||||
return clubError(c, 'Invalid roomId.')
|
||||
}
|
||||
|
||||
await updateClub(c.env.DB, clubId, {
|
||||
clubhouseRoomId: raw === '' ? null : Number.parseInt(raw, 10),
|
||||
})
|
||||
return c.json({ error: '', success: true, value: null })
|
||||
})
|
||||
)
|
||||
|
||||
// The club's main image. PUT sets it from an uploaded image's `imageName` (the
|
||||
// name the `storage` worker handed back); co-owner or above only. GET reads it —
|
||||
// the reference has no GET here (it 404s), but the client asks for it, so this
|
||||
// answers the same details envelope rather than erroring; the image name is on
|
||||
// `value.Club.MainImageName`.
|
||||
.get('/club/:clubId{[0-9]+}/mainimage', async (c) => {
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
const id = await authedId(c)
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, club, id),
|
||||
})
|
||||
})
|
||||
.put('/club/:clubId{[0-9]+}/mainimage', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Coowner) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
.get(
|
||||
'/club/:clubId{[0-9]+}/mainimage',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Read the club’s main image',
|
||||
description: [
|
||||
'The reference has no GET here (it 404s), but the client asks for it, so this answers',
|
||||
'the same details envelope rather than erroring; the image name is on',
|
||||
'`value.Club.MainImageName`. Public.',
|
||||
].join(' '),
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The club’s details, carrying MainImageName'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
const id = await authedId(c)
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, club, id),
|
||||
})
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/club/:clubId{[0-9]+}/mainimage',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Set the club’s main image',
|
||||
description: [
|
||||
'Sets the main image from an uploaded image’s `imageName` (the name the `storage`',
|
||||
'worker handed back). Co-owner or above only. Answers the details envelope.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
requestBody: form(ImageNameRequest, 'The uploaded image’s name'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The updated club’s details'),
|
||||
400: json(ErrorEnvelope, 'Missing imageName'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Below co-owner'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename')
|
||||
const imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : ''
|
||||
if (imageName === '') return clubError(c, 'imageName is required.')
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
|
||||
const updated = await updateClub(c.env.DB, clubId, { mainImageName: imageName })
|
||||
if (updated === null) return c.notFound()
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, updated, id),
|
||||
})
|
||||
})
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Coowner) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename')
|
||||
const imageName =
|
||||
typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : ''
|
||||
if (imageName === '') return clubError(c, 'imageName is required.')
|
||||
|
||||
const updated = await updateClub(c.env.DB, clubId, { mainImageName: imageName })
|
||||
if (updated === null) return c.notFound()
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, updated, id),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// One of the club's gallery images, by position (`/additionalimage/{index}`, 0-based
|
||||
// — the client PUTs the first image to 0, the second to 1). Takes the same
|
||||
// `imageName` the `storage` worker handed back. Co-owner or above, like the main
|
||||
// image. The list is packed: a PUT past the end appends rather than leaving a gap,
|
||||
// and the images come back in order on `value.AdditionalImages`.
|
||||
//
|
||||
// DELETE removes that position's image and shifts the rest up, so there's never a
|
||||
// blank slot in the gallery. It ignores any body, so it can't accidentally set an
|
||||
// image instead, and deleting a position that holds nothing is a no-op.
|
||||
.on(
|
||||
['PUT', 'DELETE'],
|
||||
'/club/:clubId{[0-9]+}/additionalimage/:index{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Set or remove one of the club’s gallery images',
|
||||
description: [
|
||||
'One gallery image by position (0-based — the client PUTs the first image to 0, the',
|
||||
'second to 1), taking the same `imageName` the `storage` worker handed back. Co-owner',
|
||||
'or above, like the main image. The list is PACKED, never sparse: a PUT past the end',
|
||||
'appends rather than leaving a gap, and DELETE removes that position and shifts the',
|
||||
'rest up, so there’s never a blank slot. DELETE ignores any body (so it can’t',
|
||||
'accidentally set an image instead) and deleting an empty position is a no-op. The',
|
||||
'images come back on `value.AdditionalImages` as whole image records, in order — a',
|
||||
'bare array of names fails the client’s parser.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
CLUB_ID_PARAM,
|
||||
{
|
||||
name: 'index',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The 0-based gallery slot; a club has 3 slots (0–2)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
requestBody: form(ImageNameRequest, 'The uploaded image’s name (PUT only)'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The updated club’s details'),
|
||||
400: json(ErrorEnvelope, 'The index is past the club’s gallery slots'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Below co-owner'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Coowner) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
}
|
||||
|
||||
const index = Number.parseInt(c.req.param('index'), 10)
|
||||
if (index >= MAX_ADDITIONAL_IMAGES) {
|
||||
return clubError(c, `A club has ${MAX_ADDITIONAL_IMAGES} additional image slots (0-based).`)
|
||||
}
|
||||
|
||||
let imageName = ''
|
||||
if (c.req.method !== 'DELETE') {
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename')
|
||||
imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : ''
|
||||
}
|
||||
|
||||
const updated = await setClubAdditionalImage(c.env.DB, clubId, index, imageName)
|
||||
if (updated === null) return c.notFound()
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, updated, id),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// A single club by id. 404 when the club isn't in the DB. Public.
|
||||
.get('/club/:clubId{[0-9]+}', async (c) => {
|
||||
const club = await getClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10))
|
||||
return club ? c.json(club) : c.notFound()
|
||||
})
|
||||
.get(
|
||||
'/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'A single club by id',
|
||||
description: 'The bare club (not the details view, not enveloped). Public.',
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDto, 'The club'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const club = await getClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10))
|
||||
return club ? c.json(club) : c.notFound()
|
||||
}
|
||||
)
|
||||
|
||||
// Delete a club, along with its memberships and announcements. The creator only —
|
||||
// not co-owners, who can edit a club but can't destroy one — which is also the way
|
||||
// out for a creator, since they aren't allowed to leave (see /members/leave).
|
||||
// Answers the envelope with a null value; the club is gone, so there are no details
|
||||
// left to return.
|
||||
.delete(
|
||||
'/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'Delete a club',
|
||||
description: [
|
||||
'Deletes the club along with its memberships and announcements, and clears it from the',
|
||||
'home club of anyone who’d set it. The creator only — not co-owners, who can edit a',
|
||||
'club but can’t destroy one — which is also the way out for a creator, since they',
|
||||
'aren’t allowed to leave. The envelope’s `value` is null: the club is gone, so there',
|
||||
'are no details left to return.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(NullEnvelope, 'Deleted (value null)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Not the club’s creator'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Creator) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
}
|
||||
|
||||
await deleteClub(c.env.DB, clubId)
|
||||
return c.json({ error: '', success: true, value: null })
|
||||
}
|
||||
)
|
||||
|
||||
// Ask to join a club. No body — the club id and the Bearer token are the whole
|
||||
// request. What it does depends on the club's Joinability: an Open club takes the
|
||||
// caller straight in as a Member, an AskToJoin club records a PendingRequested row
|
||||
// for a co-owner to approve, and an InviteOnly club refuses (you can only get in
|
||||
// through an invite). Repeats are idempotent; a banned account stays out. Answers
|
||||
// the details envelope so the client can read its new `MyMembershipType`.
|
||||
.put(
|
||||
'/club/:clubId{[0-9]+}/members/requesttojoin',
|
||||
describeRoute({
|
||||
tags: ['Membership'],
|
||||
summary: 'Ask to join a club',
|
||||
description: [
|
||||
'No body — the club id and the Bearer token are the whole request. What it does',
|
||||
'depends on the club’s Joinability: an Open club takes the caller straight in as a',
|
||||
'Member, an AskToJoin club records a PendingRequested row for a co-owner to approve,',
|
||||
'and an InviteOnly club refuses (you can only get in through an invite). Repeats are',
|
||||
'idempotent; a banned account stays out. Answers the details envelope so the client',
|
||||
'can read its new `MyMembershipType`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The club’s details, with the caller’s new membership'),
|
||||
400: json(ErrorEnvelope, 'The club is invite only'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'The caller is banned from the club'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const outcome = await requestToJoinClub(c.env.DB, clubId, id)
|
||||
if (outcome === null) return c.notFound()
|
||||
|
||||
if (outcome.result === 'inviteOnly') {
|
||||
return clubError(c, 'This club is invite only.')
|
||||
}
|
||||
if (outcome.result === 'banned') {
|
||||
return c.json({ error: 'You are banned from this club.', success: false, value: null }, 403)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, outcome.club, id),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Invite an account into the club at a given tier — the co-owner's "add member" /
|
||||
// role-assignment write. `accountId` is who to add and `membershipType` the tier they
|
||||
// get (10 Member, 20 Moderator, 30 Co-owner); the client sends both as form fields.
|
||||
// Co-owner or above only. The membership is upserted, so this also promotes/demotes an
|
||||
// existing member and overrides a ban — but it can't mint another Creator (100) and it
|
||||
// can't touch the club's own Creator. Answers the details envelope, like the other
|
||||
// membership writes.
|
||||
.put(
|
||||
'/club/:clubId{[0-9]+}/members/invite',
|
||||
describeRoute({
|
||||
tags: ['Membership'],
|
||||
summary: 'Invite an account into the club',
|
||||
description: [
|
||||
'Adds `accountId` to the club at `membershipType` (10 Member, 20 Moderator, 30',
|
||||
'Co-owner) — the co-owner’s “add member” / role-assignment write; both arrive as form',
|
||||
'fields, and an absent `membershipType` defaults to Member. Co-owner or above only. The',
|
||||
'membership is upserted, so this also promotes/demotes an existing member and overrides',
|
||||
'a ban; it can’t mint another Creator (100) or change the club’s own Creator. Answers the',
|
||||
'details envelope, like the other membership writes.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
requestBody: form(InviteMemberRequest, 'The account to add and the tier to grant'),
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The club’s details after the invite'),
|
||||
400: json(
|
||||
ErrorEnvelope,
|
||||
'Missing/invalid accountId, a tier outside Member/Moderator/Co-owner, or targeting the creator'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'Below co-owner'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) return c.notFound()
|
||||
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Coowner) {
|
||||
return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403)
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const field = (name: string): string | undefined => {
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
|
||||
const v = key === undefined ? undefined : body[key]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
const accountId = Number.parseInt(field('accountId') ?? '', 10)
|
||||
if (Number.isNaN(accountId) || accountId <= 0) return clubError(c, 'Invalid accountId.')
|
||||
|
||||
// An absent tier means "add as a plain Member"; anything present must be one of the
|
||||
// grantable roles (in particular not Creator), so an invite can't mint a second owner.
|
||||
const rawType = field('membershipType')
|
||||
const membershipType =
|
||||
rawType === undefined || rawType.trim() === ''
|
||||
? ClubMembershipType.Member
|
||||
: Number.parseInt(rawType, 10)
|
||||
if (!INVITABLE_TIERS.has(membershipType)) return clubError(c, 'Invalid membershipType.')
|
||||
|
||||
// The Creator is fixed — you can't demote them or promote someone over them.
|
||||
if (accountId === club.CreatorAccountId) {
|
||||
return clubError(c, 'You can’t change the club’s creator.')
|
||||
}
|
||||
|
||||
const updated = await setMemberType(c.env.DB, clubId, accountId, membershipType)
|
||||
if (updated === null) return c.notFound()
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, updated, id),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Leave a club. No body, like requesttojoin — the club id and the Bearer token are
|
||||
// the whole request. Idempotent (leaving a club you're not in is a no-op), and it
|
||||
// also withdraws a pending request; a ban is preserved, since you can't clear one
|
||||
// by leaving. The creator is refused — they'd leave the club ownerless, so they
|
||||
// have to delete it instead. Answers the details envelope so the client sees
|
||||
// `MyMembershipType` drop to 0 (or stay at -1 for a banned account).
|
||||
.post(
|
||||
'/club/:clubId{[0-9]+}/members/leave',
|
||||
describeRoute({
|
||||
tags: ['Membership'],
|
||||
summary: 'Leave a club',
|
||||
description: [
|
||||
'No body, like requesttojoin. Idempotent (leaving a club you’re not in is a no-op),',
|
||||
'and it also withdraws a pending request; a ban is preserved, since you can’t clear',
|
||||
'one by leaving. The creator is refused — they’d leave the club ownerless, so they',
|
||||
'have to delete it instead. Answers the details envelope so the client sees',
|
||||
'`MyMembershipType` drop to 0 (or stay at -1 for a banned account).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDetailsEnvelope, 'The club’s details, with the caller’s membership gone'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'The creator can’t leave — delete the club instead'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const outcome = await leaveClub(c.env.DB, clubId, id)
|
||||
if (outcome === null) return c.notFound()
|
||||
if (outcome.result === 'creator') {
|
||||
return c.json(
|
||||
{
|
||||
error: 'You created this club — delete it instead of leaving.',
|
||||
success: false,
|
||||
value: null,
|
||||
},
|
||||
403
|
||||
)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
error: '',
|
||||
success: true,
|
||||
value: await getClubDetails(c.env.DB, outcome.club, id),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Join / leave a club (auth-gated, idempotent). Both return the club with its
|
||||
// refreshed MemberCount; 404 when the club doesn't exist.
|
||||
.post('/club/:clubId{[0-9]+}/join', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const club = await joinClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
|
||||
return club ? c.json(club) : c.notFound()
|
||||
})
|
||||
.post('/club/:clubId{[0-9]+}/leave', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const club = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
|
||||
return club ? c.json(club) : c.notFound()
|
||||
})
|
||||
.post(
|
||||
'/club/:clubId{[0-9]+}/join',
|
||||
describeRoute({
|
||||
tags: ['Membership'],
|
||||
summary: 'Join a club',
|
||||
description: [
|
||||
'Auth-gated and idempotent. On an Open club the caller becomes a Member immediately;',
|
||||
'on an InviteOnly/AskToJoin club the join is recorded as PendingRequested, and a ban',
|
||||
'can’t be shed by re-joining. Returns the bare club with its refreshed MemberCount',
|
||||
'(not the details envelope — see members/requesttojoin for that).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDto, 'The club, with its refreshed MemberCount'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const club = await joinClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
|
||||
return club ? c.json(club) : c.notFound()
|
||||
}
|
||||
)
|
||||
// Leaving is refused for the creator here too (see /members/leave), so the two
|
||||
// routes can't disagree about who's still in the club.
|
||||
.post(
|
||||
'/club/:clubId{[0-9]+}/leave',
|
||||
describeRoute({
|
||||
tags: ['Membership'],
|
||||
summary: 'Leave a club (bare-club form)',
|
||||
description: [
|
||||
'The counterpart to `/join`: returns the bare club with its refreshed MemberCount',
|
||||
'rather than the details envelope. Leaving is refused for the creator here too (see',
|
||||
'`/members/leave`), so the two routes can’t disagree about who’s still in the club.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [CLUB_ID_PARAM],
|
||||
responses: {
|
||||
200: json(ClubDto, 'The club, with its refreshed MemberCount'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorEnvelope, 'The creator can’t leave — delete the club instead'),
|
||||
404: { description: 'No such club' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const outcome = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
|
||||
if (outcome === null) return c.notFound()
|
||||
if (outcome.result === 'creator') {
|
||||
return c.json(
|
||||
{
|
||||
error: 'You created this club — delete it instead of leaving.',
|
||||
success: false,
|
||||
value: null,
|
||||
},
|
||||
403
|
||||
)
|
||||
}
|
||||
return c.json(outcome.club)
|
||||
}
|
||||
)
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
app.get(
|
||||
'/openapi.json',
|
||||
describeRoute({ hide: true }),
|
||||
withCleanSpec(
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: 'recflare clubs',
|
||||
version: '1.0.0',
|
||||
description: [
|
||||
'Club endpoints for recflare, a private-server reimplementation of the Rec Room',
|
||||
'backend. The client calls these on the `clubs` host: club creation and editing,',
|
||||
'membership (join / ask-to-join / leave, with the ban and pending tiers),',
|
||||
'search, announcements, the club gallery and clubhouse room, and each player’s home',
|
||||
'club. Everything is D1-backed on the shared `recflare` database; the',
|
||||
'`/subscription/*` routes are stubs, since there are no subscription clubs yet.',
|
||||
'',
|
||||
'Most writes answer the `{ error, success, value }` envelope with HTTP 200, and the',
|
||||
'ones the client re-renders a club screen from carry the club’s FULL details as',
|
||||
'`value` rather than null.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://clubs.recflare.net', description: 'Production' }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -8,6 +8,12 @@ export type Env = SharedHonoEnv & {
|
||||
JWT_SECRET: SecretsStoreSecret
|
||||
// Shared `recflare` D1 database holding the club / club_member tables. See clubs-db.ts.
|
||||
DB: D1Database
|
||||
// How many clubs one account may create (optional). Unset falls back to
|
||||
// DEFAULT_MAX_CLUBS_PER_ACCOUNT in clubs.app.ts; 0 lifts the cap. Typed
|
||||
// `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
|
||||
// number while the same var set from the dashboard or `--var` arrives as a string —
|
||||
// read it through `intVar`, never as a bare number.
|
||||
MAX_CLUBS_PER_ACCOUNT?: string | number
|
||||
// add additional Bindings here
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the clubs worker.
|
||||
*
|
||||
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
|
||||
* rationale as the auth/accounts/econ/match workers: a reverse-engineered protocol,
|
||||
* lenient handlers, no runtime validation.
|
||||
*
|
||||
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
|
||||
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
|
||||
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
|
||||
* schema inline, which renders correctly in any tool.
|
||||
*/
|
||||
|
||||
/** Emit a zod schema as an `application/json` response body. */
|
||||
export function json(schema: z.ZodType, description: string) {
|
||||
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||
}
|
||||
|
||||
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
|
||||
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||
return jsonSchema as OpenAPIV3_1.SchemaObject
|
||||
}
|
||||
|
||||
/** A form-urlencoded / multipart request body (the client posts both). */
|
||||
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
const s = toOpenApiSchema(schema)
|
||||
return {
|
||||
description,
|
||||
content: {
|
||||
'application/x-www-form-urlencoded': { schema: s },
|
||||
'multipart/form-data': { schema: s },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** An `application/json` request body. */
|
||||
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
|
||||
}
|
||||
|
||||
/** The empty-body 401 the auth-gated routes return. */
|
||||
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
|
||||
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
export const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
/** An opaque JSON array — the empty-list stubs (`[]`) the client still expects. */
|
||||
export const JsonArray = z.array(z.unknown())
|
||||
|
||||
/** An empty JSON object — the stub shape the client deserializes into an object. */
|
||||
export const EmptyObject = z.object({})
|
||||
|
||||
// ---- Core entities ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The client-facing club DTO (mirror of the Go `Club` JSON tags). The stored blob also
|
||||
* carries `CreatedAt`, `CustomTags` and `AdditionalImages`, none of which are on this
|
||||
* object — the tags and gallery are served on the details view instead.
|
||||
*/
|
||||
export const ClubDto = z.object({
|
||||
ClubId: z.int(),
|
||||
Name: z.string().describe('At most 40 characters; letters, digits and basic punctuation'),
|
||||
Description: z.string(),
|
||||
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
|
||||
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
|
||||
Joinability: z.int().describe('ClubJoinability: 0 = Open, 1 = InviteOnly, 2 = AskToJoin'),
|
||||
AllowJuniors: z.boolean(),
|
||||
MainImageName: z.string().describe('An image name from the storage worker; DefaultImgPurple'),
|
||||
ClubType: z.int().describe('0 = a regular club; 1 = a subscription club (never listed)'),
|
||||
ClubhouseRoomId: z.int().nullable().describe('The room a home-club member spawns into'),
|
||||
CreatorAccountId: z.int(),
|
||||
IsRRO: z.boolean(),
|
||||
MinLevel: z.int(),
|
||||
State: z.int(),
|
||||
MemberCount: z.int().describe('Derived from the club_member rows at/above Member (10)'),
|
||||
})
|
||||
|
||||
/**
|
||||
* An image record as every image on the site is served (`SavedImage`). A club's gallery
|
||||
* serves these whole — see AdditionalImages on the details view.
|
||||
*/
|
||||
export const SavedImageDto = z.object({
|
||||
Id: z.int(),
|
||||
Type: z.int().describe('SavedImageType: 1 = share camera, 3 = room, 4 = profile, …'),
|
||||
Accessibility: z.int(),
|
||||
AccessibilityLocked: z.boolean(),
|
||||
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
|
||||
Description: z.string().nullable(),
|
||||
PlayerId: z.int(),
|
||||
TaggedPlayerIds: z.array(z.int()),
|
||||
RoomId: z.int().nullable(),
|
||||
PlayerEventId: z.int().nullable(),
|
||||
CreatedAt: z.string(),
|
||||
CheerCount: z.int(),
|
||||
CommentCount: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* What a membership tier may do in a club. These are the defaults every club gets
|
||||
* (co-owners everything, moderators approve/ban, members none); nothing edits them yet,
|
||||
* so they're derived per club rather than stored.
|
||||
*/
|
||||
export const ClubPermissionDto = z.object({
|
||||
ClubId: z.int(),
|
||||
Type: z.int().describe('The ClubMembershipType tier these permissions describe'),
|
||||
ApproveMember: z.boolean(),
|
||||
BanUnban: z.boolean(),
|
||||
CreateEvent: z.boolean(),
|
||||
EditDetails: z.boolean(),
|
||||
EditPermissionSettings: z.boolean(),
|
||||
PostAnnouncement: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The club-details payload the client renders a club screen from: the club, its tags,
|
||||
* the per-tier permissions, its gallery, and the caller's own membership.
|
||||
*/
|
||||
export const ClubDetailsDto = z.object({
|
||||
AdditionalImages: z
|
||||
.array(SavedImageDto)
|
||||
.describe(
|
||||
[
|
||||
'The club’s gallery as WHOLE image records, not image names — the client',
|
||||
'deserializes each entry into an object, so a bare array of names fails its parser',
|
||||
'("expected \'{\'"). The list is packed and in order: removing an image shifts the',
|
||||
'rest up, never leaving a blank slot.',
|
||||
].join(' ')
|
||||
),
|
||||
Club: ClubDto,
|
||||
ClubId: z.int(),
|
||||
CoownerPermissions: ClubPermissionDto,
|
||||
CustomTags: z.array(z.string()).describe('Set wholesale by modifydetails’ repeated customTags'),
|
||||
MemberPermissions: ClubPermissionDto,
|
||||
ModeratorPermissions: ClubPermissionDto,
|
||||
MyMembershipType: z
|
||||
.int()
|
||||
.describe(
|
||||
[
|
||||
'The caller’s own ClubMembershipType: -1 banned, 0 none (also a signed-out viewer),',
|
||||
'1 pending request, 2 pending invite, 3 denied, 10 member, 20 moderator, 30 co-owner,',
|
||||
'100 creator',
|
||||
].join(' ')
|
||||
),
|
||||
})
|
||||
|
||||
/** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */
|
||||
export const ClubMemberDto = z.object({
|
||||
ClubMemberId: z.int(),
|
||||
ClubId: z.int(),
|
||||
AccountId: z.int(),
|
||||
MembershipType: z.int().describe('See MyMembershipType for the tiers'),
|
||||
CreatedAt: z.string().nullable().describe('When the membership row was first written'),
|
||||
})
|
||||
|
||||
/** One entry on a club's noticeboard (mirror of the Go `ClubAnnouncement`). */
|
||||
export const ClubAnnouncementDto = z.object({
|
||||
AnnouncementId: z.int(),
|
||||
ClubId: z.int(),
|
||||
AccountId: z.int().describe('Who posted it'),
|
||||
Title: z.string(),
|
||||
Body: z.string(),
|
||||
ImageName: z.string(),
|
||||
Meta: z.string(),
|
||||
CreatedAt: z.string().nullable(),
|
||||
})
|
||||
|
||||
// ---- Envelopes -------------------------------------------------------------
|
||||
//
|
||||
// Most club writes answer the `{ error, success, value }` envelope with HTTP 200 (or
|
||||
// 400/403 carrying the same shape with `success: false`). The envelope's `value` is the
|
||||
// entity the client re-renders from, so routes that change a club return the FULL
|
||||
// details view rather than a null value — `PUT /club/:id/clubhouse` left the old
|
||||
// clubhouse on screen until it answered the details envelope.
|
||||
|
||||
/** The success envelope carrying a club's full details. */
|
||||
export const ClubDetailsEnvelope = z.object({
|
||||
error: z.string(),
|
||||
success: z.boolean(),
|
||||
value: ClubDetailsDto,
|
||||
})
|
||||
|
||||
/** The success envelope carrying a bare club (`PUT /club/home/me`). */
|
||||
export const ClubEnvelope = z.object({
|
||||
error: z.string(),
|
||||
success: z.boolean(),
|
||||
value: ClubDto,
|
||||
})
|
||||
|
||||
/**
|
||||
* The envelope with nothing left to describe — clearing the home club, deleting a club.
|
||||
* Only used where the entity is genuinely gone; anything the client re-renders from
|
||||
* returns the details envelope instead.
|
||||
*/
|
||||
export const NullEnvelope = z.object({
|
||||
error: z.string(),
|
||||
success: z.boolean(),
|
||||
value: z.null(),
|
||||
})
|
||||
|
||||
/** A rejected action: the same envelope, carrying the message the client shows. */
|
||||
export const ErrorEnvelope = z.object({
|
||||
error: z.string().describe('The message shown to the player'),
|
||||
success: z.boolean().describe('Always false'),
|
||||
value: z.null(),
|
||||
})
|
||||
|
||||
/** The envelope carrying a club's members (`GET /club/:clubId/members`). */
|
||||
export const ClubMembersEnvelope = z.object({
|
||||
error: z.string(),
|
||||
success: z.boolean(),
|
||||
value: z.array(ClubMemberDto),
|
||||
})
|
||||
|
||||
/** The envelope carrying a club's noticeboard (`GET /announcements/club/:clubId`). */
|
||||
export const ClubAnnouncementsEnvelope = z.object({
|
||||
error: z.string(),
|
||||
success: z.boolean(),
|
||||
value: z.object({
|
||||
Announcements: z.array(ClubAnnouncementDto).describe('Newest first'),
|
||||
ClubId: z.int(),
|
||||
LastAnnouncementId: z.int().nullable().describe('The newest one; null when there are none'),
|
||||
LastReadAnnouncementId: z.int().describe('Always 0 — nothing tracks read state yet'),
|
||||
}),
|
||||
})
|
||||
|
||||
/** The envelope carrying a new announcement's id (`POST /announcements/club/:clubId`). */
|
||||
export const AnnouncementIdEnvelope = z.object({
|
||||
error: z.string(),
|
||||
success: z.boolean(),
|
||||
value: z.int().describe('The new announcement’s id'),
|
||||
})
|
||||
|
||||
// ---- Other response shapes -------------------------------------------------
|
||||
|
||||
/** `GET /club/search` — a page of clubs plus the full match count. */
|
||||
export const ClubSearchResponse = z.object({
|
||||
Clubs: z.array(ClubDto),
|
||||
ContinuationToken: z.null().describe('Always null — the whole page is served at once'),
|
||||
TotalClubs: z.int().describe('How many clubs matched, not the page size'),
|
||||
})
|
||||
|
||||
/** `GET /subscription/details/:accountId` — simulated: no club, no subscribers. */
|
||||
export const SubscriptionDetailsResponse = z.object({
|
||||
accountId: z.int(),
|
||||
clubId: z.int().describe('Always 0 — no subscription clubs yet'),
|
||||
subscriberCount: z.int().describe('Always 0'),
|
||||
})
|
||||
|
||||
/** The set of category tags a club can be filed under — a fixed list. */
|
||||
export const CategoryTags = z.array(z.string())
|
||||
|
||||
/** `GET /subscription/subscriberCount/:accountId` — a bare JSON integer. */
|
||||
export const SubscriberCountResponse = z
|
||||
.int()
|
||||
.describe('Always 0 — there are no club subscriptions yet')
|
||||
|
||||
/**
|
||||
* `GET /club/:clubId/hasDisabledClubChat` — a bare JSON boolean, like the other
|
||||
* `is…`/`has…` gates the client polls. Nothing can turn club chat off yet, so it's
|
||||
* always false; not in the reference, so if the client chokes on this it likely wants
|
||||
* the `{ error, success, value }` envelope the other club endpoints use.
|
||||
*/
|
||||
export const ChatDisabledResponse = z.boolean()
|
||||
|
||||
// ---- Request schemas -------------------------------------------------------
|
||||
//
|
||||
// Every write takes a form body (urlencoded or multipart — the client posts both) with
|
||||
// lowercase field names; the handlers match field names case-insensitively.
|
||||
|
||||
/** `POST /club/create` form body. */
|
||||
export const CreateClubRequest = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.describe('Required; at most 40 characters, letters/digits/basic punctuation only'),
|
||||
description: z.string().optional().describe('At most 512 characters'),
|
||||
category: z.string().optional().describe('Defaults to Social when unset'),
|
||||
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
||||
joinability: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('By name (`Open`/`InviteOnly`/`AskToJoin`) or number'),
|
||||
allowJuniors: z.string().optional().describe('`True`/`false`/`1`/`yes`'),
|
||||
mainImageName: z.string().optional(),
|
||||
minLevel: z.string().optional(),
|
||||
})
|
||||
|
||||
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
|
||||
export const ModifyClubRequest = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('At most 40 characters. Empty means unchanged, not "clear it"'),
|
||||
description: z.string().optional().describe('At most 512 characters. Empty means unchanged'),
|
||||
category: z.string().optional(),
|
||||
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
||||
joinability: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('By name (`Open`/`InviteOnly`/`AskToJoin`) or number'),
|
||||
allowJuniors: z.string().optional().describe('`True`/`false`/`1`/`yes`'),
|
||||
mainImageName: z.string().optional(),
|
||||
minLevel: z.string().optional(),
|
||||
customTags: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('May repeat; when present it replaces the club’s tag set wholesale'),
|
||||
})
|
||||
|
||||
/** `PUT /club/home/me` form body. */
|
||||
export const HomeClubRequest = z.object({
|
||||
clubId: z.string().describe('The club to make home; the caller must be a member of it'),
|
||||
})
|
||||
|
||||
/** `PUT /club/:clubId/minlevel` form body. */
|
||||
export const MinLevelRequest = z.object({
|
||||
minLevel: z.string().describe('The minimum player level to join; negative/NaN is 400'),
|
||||
})
|
||||
|
||||
/** `PUT /club/:clubId/clubhouse` form body. */
|
||||
export const ClubhouseRequest = z.object({
|
||||
roomId: z.string().optional().describe('The clubhouse room; omitting it clears the clubhouse'),
|
||||
})
|
||||
|
||||
/** `PUT /club/:clubId/mainimage` and `/additionalimage/:index` form body. */
|
||||
export const ImageNameRequest = z.object({
|
||||
imageName: z.string().describe('The image name the `storage` worker handed back'),
|
||||
})
|
||||
|
||||
/** `PUT /club/:clubId/members/invite` form body. */
|
||||
export const InviteMemberRequest = z.object({
|
||||
accountId: z.string().describe('The account to add to the club; a positive integer'),
|
||||
membershipType: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The tier to grant — 10 Member, 20 Moderator, 30 Co-owner; defaults to Member'),
|
||||
})
|
||||
|
||||
/** `POST /announcements/club/:clubId` form body. */
|
||||
export const AnnouncementRequest = z.object({
|
||||
title: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
imageName: z.string().optional(),
|
||||
meta: z.string().optional(),
|
||||
})
|
||||
@@ -28,6 +28,16 @@ beforeAll(async () => {
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
// Image metadata (owned by the img worker, written by api on upload) — a club's
|
||||
// gallery serves the whole image record behind each stored image name.
|
||||
await env.DB.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS image (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL,
|
||||
image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
|
||||
const insertAccount = env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
await env.DB.batch(
|
||||
[42, 9100, 9101].map((accountId) =>
|
||||
@@ -231,9 +241,16 @@ describe('clubs endpoints', () => {
|
||||
expect(emoji.status).toBe(400)
|
||||
expect(await emoji.json()).toMatchObject({ success: false, value: null })
|
||||
|
||||
// Names cap at 16 characters.
|
||||
expect((await create({ name: 'a'.repeat(17) })).status).toBe(400)
|
||||
expect((await create({ name: 'a'.repeat(16) })).status).toBe(200)
|
||||
// Names cap at 40 characters.
|
||||
expect((await create({ name: 'a'.repeat(41) })).status).toBe(400)
|
||||
expect((await create({ name: 'a'.repeat(40) })).status).toBe(200)
|
||||
|
||||
// Descriptions cap at 512. Counted in code points, so an emoji-heavy one isn't
|
||||
// refused at half the length a player can see (the description has no charset rule
|
||||
// — only the name does).
|
||||
expect((await create({ name: 'DescTooLong', description: 'd'.repeat(513) })).status).toBe(400)
|
||||
expect((await create({ name: 'DescAtLimit', description: 'd'.repeat(512) })).status).toBe(200)
|
||||
expect((await create({ name: 'DescEmoji', description: '🎉'.repeat(512) })).status).toBe(200)
|
||||
|
||||
// Basic punctuation is allowed.
|
||||
expect((await create({ name: "Bob's Club (2)" })).status).toBe(200)
|
||||
@@ -325,6 +342,25 @@ describe('clubs endpoints', () => {
|
||||
body: 'name=Ghost',
|
||||
})
|
||||
expect(missing.status).toBe(404)
|
||||
|
||||
// /modify is the same endpoint under the client's shorter name.
|
||||
const short = async (sub: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/club/${clubId}/modify`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'name=my%20club&description=rock%20out&category=Casual',
|
||||
})
|
||||
const renamed = (await (await short('6000')).json()) as Details
|
||||
expect(renamed.value.Club).toMatchObject({
|
||||
Name: 'my club',
|
||||
Description: 'rock out',
|
||||
Category: 'Casual',
|
||||
})
|
||||
// ...and it's gated the same way.
|
||||
expect((await short('6001')).status).toBe(403)
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}/club/${clubId}/modify`, { method: 'PUT' })).status
|
||||
).toBe(401)
|
||||
})
|
||||
|
||||
test('GET/PUT /club/:id/mainimage reads and sets the club image, co-owner only', async () => {
|
||||
@@ -473,26 +509,255 @@ describe('clubs endpoints', () => {
|
||||
// Give it a clubhouse → the home club now resolves.
|
||||
const clubhouse = await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=77', '9100')
|
||||
expect(clubhouse.status).toBe(200)
|
||||
expect(await clubhouse.json()).toEqual({ error: '', success: true, value: null })
|
||||
// The details envelope carries the new room back — the client re-renders from it.
|
||||
const setRoom = (await clubhouse.json()) as {
|
||||
error: string
|
||||
success: boolean
|
||||
value: { Club: { ClubhouseRoomId: number | null } }
|
||||
}
|
||||
expect(setRoom).toMatchObject({ error: '', success: true })
|
||||
expect(setRoom.value.Club.ClubhouseRoomId).toBe(77)
|
||||
|
||||
const home = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
|
||||
headers: await bearer('9100'),
|
||||
})
|
||||
expect((await home.json()) as Club).toMatchObject({ ClubId: clubId, ClubhouseRoomId: 77 })
|
||||
|
||||
// Clearing the clubhouse takes the home club away again.
|
||||
await form(`/club/${clubId}/clubhouse`, 'PUT', '', '9100')
|
||||
// Clearing the clubhouse takes the home club away again — and reports the cleared
|
||||
// room, so the client doesn't keep showing the old one.
|
||||
const clear = (await (await form(`/club/${clubId}/clubhouse`, 'PUT', '', '9100')).json()) as {
|
||||
value: { Club: { ClubhouseRoomId: number | null } }
|
||||
}
|
||||
expect(clear.value.Club.ClubhouseRoomId).toBeNull()
|
||||
const cleared = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
|
||||
headers: await bearer('9100'),
|
||||
})
|
||||
expect(cleared.status).toBe(404)
|
||||
|
||||
// Only co-owners may set the clubhouse; signed out is a 401 on both.
|
||||
// DELETE clears it too, ignoring any body it's sent.
|
||||
await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=88', '9100')
|
||||
const deleted = (await (
|
||||
await form(`/club/${clubId}/clubhouse`, 'DELETE', 'roomId=99', '9100')
|
||||
).json()) as { success: boolean; value: { Club: { ClubhouseRoomId: number | null } } }
|
||||
expect(deleted.success).toBe(true)
|
||||
expect(deleted.value.Club.ClubhouseRoomId).toBeNull()
|
||||
|
||||
// DELETE /club/home/me drops the home club without touching the membership, and
|
||||
// is idempotent when there's none set.
|
||||
await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=77', '9100')
|
||||
await form('/club/home/me', 'PUT', `clubId=${clubId}`, '9100')
|
||||
const dropped = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
|
||||
method: 'DELETE',
|
||||
headers: await bearer('9100'),
|
||||
})
|
||||
expect(dropped.status).toBe(200)
|
||||
expect(await dropped.json()).toEqual({ error: '', success: true, value: null })
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}/club/home/me`, { headers: await bearer('9100') }))
|
||||
.status
|
||||
).toBe(404)
|
||||
// Still a member of the club they'd made their home.
|
||||
const mine = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('9100') })
|
||||
).json()) as Club[]
|
||||
expect(mine.map((c) => c.ClubId)).toContain(clubId)
|
||||
// Clearing again, and clearing when nothing was set, both succeed.
|
||||
for (const sub of ['9100', '9101']) {
|
||||
const again = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
|
||||
method: 'DELETE',
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
expect(again.status).toBe(200)
|
||||
}
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}/club/home/me`, { method: 'DELETE' })).status
|
||||
).toBe(401)
|
||||
|
||||
// Only co-owners may set or clear the clubhouse; signed out is a 401 on both.
|
||||
expect((await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=1', '9101')).status).toBe(403)
|
||||
expect((await form(`/club/${clubId}/clubhouse`, 'DELETE', '', '9101')).status).toBe(403)
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}/club/${clubId}/clubhouse`, { method: 'DELETE' }))
|
||||
.status
|
||||
).toBe(401)
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/club/home/me`, { method: 'PUT' })
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
|
||||
test('PUT /club/:id/additionalimage/:index fills the club’s gallery slots', async () => {
|
||||
type Image = { Id: number; ImageName: string; PlayerId: number }
|
||||
type Details = { error: string; success: boolean; value: { AdditionalImages: Image[] } }
|
||||
// The gallery is served as whole image records, joined from the image table the
|
||||
// `api` worker writes on upload. Seed the rows those names point at.
|
||||
const first = 'sharecamera/2026-07-21/e37fc41f-005e-4216-8f1e-a37dca953981.jpg'
|
||||
const insertImage = env.DB.prepare('INSERT OR IGNORE INTO image (data) VALUES (?1)')
|
||||
await env.DB.batch(
|
||||
[first, 'b.jpg', 'c.jpg'].map((ImageName, i) =>
|
||||
insertImage.bind(
|
||||
JSON.stringify({
|
||||
Id: 500 + i,
|
||||
Type: 1,
|
||||
Accessibility: 1,
|
||||
AccessibilityLocked: false,
|
||||
ImageName,
|
||||
Description: null,
|
||||
PlayerId: 7100,
|
||||
TaggedPlayerIds: [],
|
||||
RoomId: null,
|
||||
PlayerEventId: null,
|
||||
CreatedAt: '2026-07-21T00:00:00Z',
|
||||
CheerCount: 0,
|
||||
CommentCount: 0,
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('7100')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'name=Gallery',
|
||||
})
|
||||
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
|
||||
const setImage = async (index: number, imageName: string, sub = '7100') =>
|
||||
exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/${index}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ imageName }).toString(),
|
||||
})
|
||||
|
||||
const names = (images: Image[]) => images.map((i) => i.ImageName)
|
||||
|
||||
// A fresh club has no gallery images.
|
||||
const fresh = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`)
|
||||
).json()) as { AdditionalImages: Image[] }
|
||||
expect(fresh.AdditionalImages).toEqual([])
|
||||
|
||||
// The client's exact request: the storage worker's image name into slot 0. It
|
||||
// comes back as the whole image record, not a bare name.
|
||||
const set = (await (await setImage(0, first)).json()) as Details
|
||||
expect(set).toMatchObject({ error: '', success: true })
|
||||
expect(set.value.AdditionalImages).toEqual([
|
||||
expect.objectContaining({ Id: 500, ImageName: first, PlayerId: 7100 }),
|
||||
])
|
||||
|
||||
// The list stays packed: a PUT past the end appends rather than leaving a gap.
|
||||
const third = (await (await setImage(2, 'c.jpg')).json()) as Details
|
||||
expect(names(third.value.AdditionalImages)).toEqual([first, 'c.jpg'])
|
||||
const second = (await (await setImage(2, 'b.jpg')).json()) as Details
|
||||
expect(names(second.value.AdditionalImages)).toEqual([first, 'c.jpg', 'b.jpg'])
|
||||
|
||||
// Re-PUTting a position replaces just that image. A name with no image row still
|
||||
// renders, as a placeholder record.
|
||||
const replaced = (await (await setImage(0, 'a2.jpg')).json()) as Details
|
||||
expect(names(replaced.value.AdditionalImages)).toEqual(['a2.jpg', 'c.jpg', 'b.jpg'])
|
||||
expect(replaced.value.AdditionalImages[0]).toMatchObject({ Id: 0, ImageName: 'a2.jpg' })
|
||||
|
||||
// An empty name removes that image and shifts the rest up — no blank left behind.
|
||||
const cleared = (await (await setImage(1, '')).json()) as Details
|
||||
expect(names(cleared.value.AdditionalImages)).toEqual(['a2.jpg', 'b.jpg'])
|
||||
|
||||
// They're on the club's details payload, for everyone reading the club.
|
||||
const details = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`)
|
||||
).json()) as { AdditionalImages: Image[] }
|
||||
expect(names(details.AdditionalImages)).toEqual(['a2.jpg', 'b.jpg'])
|
||||
|
||||
// DELETE removes that position's image and shifts the rest up, ignoring any body.
|
||||
// Deleting a position that holds nothing is a no-op.
|
||||
const deleteImage = async (index: number, sub = '7100', body?: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/${index}`, {
|
||||
method: 'DELETE',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
const dropped = (await (await deleteImage(0, '7100', 'imageName=sneaky.jpg')).json()) as Details
|
||||
expect(dropped).toMatchObject({ error: '', success: true })
|
||||
expect(names(dropped.value.AdditionalImages)).toEqual(['b.jpg'])
|
||||
// Position 1 holds nothing now, so deleting it changes nothing.
|
||||
expect(
|
||||
names(((await (await deleteImage(1)).json()) as Details).value.AdditionalImages)
|
||||
).toEqual(['b.jpg'])
|
||||
// A PUT past the end appends, so the club is back to two images.
|
||||
const refilled = (await (await setImage(1, 'a3.jpg')).json()) as Details
|
||||
expect(names(refilled.value.AdditionalImages)).toEqual(['b.jpg', 'a3.jpg'])
|
||||
|
||||
// Same gate as the PUT.
|
||||
expect((await deleteImage(0, '7101')).status).toBe(403)
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/0`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
expect((await deleteImage(3)).status).toBe(400)
|
||||
|
||||
// There are only three slots, and only co-owners may set them.
|
||||
expect((await setImage(3, 'd.jpg')).status).toBe(400)
|
||||
expect((await setImage(0, 'hijack.jpg', '7101')).status).toBe(403)
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/0`, {
|
||||
method: 'PUT',
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/club/99999/additionalimage/0`, {
|
||||
method: 'PUT',
|
||||
headers: await bearer('7100'),
|
||||
})
|
||||
).status
|
||||
).toBe(404)
|
||||
})
|
||||
|
||||
test('POST /club/create enforces the per-account club cap', async () => {
|
||||
const create = async (name: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/club/create`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('7200')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ name }).toString(),
|
||||
})
|
||||
|
||||
// The cap an operator actually runs is the `MAX_CLUBS_PER_ACCOUNT` var; the
|
||||
// constant in the worker is only the fallback.
|
||||
const original = env.MAX_CLUBS_PER_ACCOUNT
|
||||
try {
|
||||
env.MAX_CLUBS_PER_ACCOUNT = 2
|
||||
expect((await create('CapOne')).status).toBe(200)
|
||||
expect((await create('CapTwo')).status).toBe(200)
|
||||
|
||||
const rejected = await create('CapThree')
|
||||
expect(rejected.status).toBe(400)
|
||||
const body = (await rejected.json()) as { error: string; success: boolean }
|
||||
expect(body.success).toBe(false)
|
||||
expect(body.error).toMatch(/only have 2 clubs/i)
|
||||
|
||||
// A subscription club doesn't count against the cap — it isn't made by hand.
|
||||
await env.DB.prepare('INSERT INTO club (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
ClubId: 9500,
|
||||
Name: 'Subs7200',
|
||||
ClubType: 1,
|
||||
CreatorAccountId: 7200,
|
||||
CreatedAt: '2026-07-01T00:00:00Z',
|
||||
})
|
||||
)
|
||||
.run()
|
||||
env.MAX_CLUBS_PER_ACCOUNT = 3
|
||||
expect((await create('CapThreeReal')).status).toBe(200)
|
||||
|
||||
// 0 lifts the cap entirely.
|
||||
env.MAX_CLUBS_PER_ACCOUNT = 0
|
||||
expect((await create('Uncapped')).status).toBe(200)
|
||||
} finally {
|
||||
env.MAX_CLUBS_PER_ACCOUNT = original
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /club/search filters by category/query and sorts', async () => {
|
||||
type Result = {
|
||||
Clubs: Array<{ ClubId: number; Name: string; Category: string }>
|
||||
@@ -875,4 +1140,313 @@ describe('clubs endpoints', () => {
|
||||
).json()) as Club[]
|
||||
expect(member811.map((c) => c.ClubId)).not.toContain(club.ClubId)
|
||||
})
|
||||
|
||||
test('requesttojoin follows the club joinability', async () => {
|
||||
const create = async (sub: string, fields: Record<string, string>) =>
|
||||
(
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/club/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
).json()) as { value: { Club: { ClubId: number } } }
|
||||
).value.Club.ClubId
|
||||
const request = async (clubId: number, sub: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/club/${clubId}/members/requesttojoin`, {
|
||||
method: 'PUT',
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
type Details = { error: string; success: boolean; value: { MyMembershipType: number } | null }
|
||||
|
||||
const open = await create('820', { name: 'Open Doors', joinability: 'Open' })
|
||||
const ask = await create('821', { name: 'Ask First', joinability: 'AskToJoin' })
|
||||
const invite = await create('822', { name: 'Invite Only', joinability: 'InviteOnly' })
|
||||
|
||||
// Open → straight in as a Member (10).
|
||||
const joined = (await (await request(open, '830')).json()) as Details
|
||||
expect(joined).toMatchObject({ success: true })
|
||||
expect(joined.value?.MyMembershipType).toBe(10)
|
||||
|
||||
// AskToJoin → PendingRequested (1), and a repeat request leaves it there.
|
||||
const asked = (await (await request(ask, '830')).json()) as Details
|
||||
expect(asked.value?.MyMembershipType).toBe(1)
|
||||
expect(
|
||||
(((await (await request(ask, '830')).json()) as Details).value ?? {}).MyMembershipType
|
||||
).toBe(1)
|
||||
|
||||
// InviteOnly → refused, with no membership row created.
|
||||
const refused = await request(invite, '830')
|
||||
expect(refused.status).toBe(400)
|
||||
expect(((await refused.json()) as Details).success).toBe(false)
|
||||
|
||||
// No token, and an unknown club.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/club/${open}/members/requesttojoin`, {
|
||||
method: 'PUT',
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
expect((await request(99999, '830')).status).toBe(404)
|
||||
})
|
||||
|
||||
test('PUT /club/:id/members/invite adds and promotes members, co-owner only', async () => {
|
||||
type Member = { AccountId: number; MembershipType: number }
|
||||
type Details = {
|
||||
error: string
|
||||
success: boolean
|
||||
value: { Club: { MemberCount: number } } | null
|
||||
}
|
||||
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('870')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'name=Invitational',
|
||||
})
|
||||
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
|
||||
|
||||
const invite = async (fields: Record<string, string>, sub = '870'): Promise<Response> =>
|
||||
exports.default.fetch(`${ORIGIN}/club/${clubId}/members/invite`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
const tiers = async (): Promise<Map<number, number>> => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/club/${clubId}/members`)
|
||||
const body = (await res.json()) as { value: Member[] }
|
||||
return new Map(body.value.map((m) => [m.AccountId, m.MembershipType]))
|
||||
}
|
||||
|
||||
// The client's exact request: add account 872 as a Member (10).
|
||||
const added = await invite({ accountId: '872', membershipType: '10' })
|
||||
expect(added.status).toBe(200)
|
||||
const addedBody = (await added.json()) as Details
|
||||
expect(addedBody).toMatchObject({ error: '', success: true })
|
||||
expect(addedBody.value?.Club.MemberCount).toBe(2) // creator + 872
|
||||
expect((await tiers()).get(872)).toBe(10)
|
||||
|
||||
// Inviting an existing member at a higher tier promotes them in place.
|
||||
expect((await invite({ accountId: '872', membershipType: '20' })).status).toBe(200)
|
||||
expect((await tiers()).get(872)).toBe(20)
|
||||
|
||||
// membershipType defaults to Member when omitted.
|
||||
await invite({ accountId: '873' })
|
||||
expect((await tiers()).get(873)).toBe(10)
|
||||
|
||||
// Can't mint another creator, can't touch the creator, needs a valid accountId — and
|
||||
// a rejected invite writes nothing.
|
||||
expect((await invite({ accountId: '874', membershipType: '100' })).status).toBe(400)
|
||||
expect((await invite({ accountId: '870', membershipType: '30' })).status).toBe(400)
|
||||
expect((await invite({ accountId: 'abc' })).status).toBe(400)
|
||||
expect((await tiers()).has(874)).toBe(false)
|
||||
|
||||
// A non-co-owner can't invite (872 is a moderator now, still below co-owner); signed
|
||||
// out is a 401; an unknown club 404s.
|
||||
expect((await invite({ accountId: '875' }, '872')).status).toBe(403)
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/club/${clubId}/members/invite`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'accountId=875',
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
const missing = await exports.default.fetch(`${ORIGIN}/club/99999/members/invite`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('870')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'accountId=875',
|
||||
})
|
||||
expect(missing.status).toBe(404)
|
||||
})
|
||||
|
||||
test('members/leave drops a membership and withdraws a pending request', async () => {
|
||||
const create = async (sub: string, fields: Record<string, string>) =>
|
||||
(
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/club/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
).json()) as { value: { Club: { ClubId: number } } }
|
||||
).value.Club.ClubId
|
||||
const call = async (clubId: number, sub: string, action: 'requesttojoin' | 'leave') =>
|
||||
exports.default.fetch(`${ORIGIN}/club/${clubId}/members/${action}`, {
|
||||
method: action === 'leave' ? 'POST' : 'PUT',
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
type Details = {
|
||||
success: boolean
|
||||
value: { Club: { MemberCount: number }; MyMembershipType: number } | null
|
||||
}
|
||||
|
||||
const open = await create('840', { name: 'Revolving', joinability: 'Open' })
|
||||
const ask = await create('841', { name: 'Waitlist', joinability: 'AskToJoin' })
|
||||
|
||||
// A member leaves → membership 0, and the count drops back to the creator alone.
|
||||
await call(open, '850', 'requesttojoin')
|
||||
const left = (await (await call(open, '850', 'leave')).json()) as Details
|
||||
expect(left.success).toBe(true)
|
||||
expect(left.value?.MyMembershipType).toBe(0)
|
||||
expect(left.value?.Club.MemberCount).toBe(1)
|
||||
|
||||
// Leaving again is a no-op, not an error.
|
||||
expect(
|
||||
(((await (await call(open, '850', 'leave')).json()) as Details).value ?? {}).MyMembershipType
|
||||
).toBe(0)
|
||||
|
||||
// Leaving withdraws a pending request too.
|
||||
expect(
|
||||
(((await (await call(ask, '850', 'requesttojoin')).json()) as Details).value ?? {})
|
||||
.MyMembershipType
|
||||
).toBe(1)
|
||||
expect(
|
||||
(((await (await call(ask, '850', 'leave')).json()) as Details).value ?? {}).MyMembershipType
|
||||
).toBe(0)
|
||||
|
||||
// The creator can't leave their own club — they'd leave it ownerless.
|
||||
for (const path of [`/club/${open}/members/leave`, `/club/${open}/leave`]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('840'),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
}
|
||||
// ...and they're still the creator afterwards.
|
||||
const stillIn = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/club/${open}/details`, {
|
||||
headers: await bearer('840'),
|
||||
})
|
||||
).json()) as { MyMembershipType: number }
|
||||
expect(stillIn.MyMembershipType).toBe(100)
|
||||
|
||||
// No token, and an unknown club.
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}/club/${open}/members/leave`, { method: 'POST' }))
|
||||
.status
|
||||
).toBe(401)
|
||||
expect((await call(99999, '850', 'leave')).status).toBe(404)
|
||||
})
|
||||
|
||||
test('DELETE /club/:id is the creator’s only, and takes the memberships with it', async () => {
|
||||
const create = async (sub: string, fields: Record<string, string>) =>
|
||||
(
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/club/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
).json()) as { value: { Club: { ClubId: number } } }
|
||||
).value.Club.ClubId
|
||||
const del = async (clubId: number, sub?: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/club/${clubId}`, {
|
||||
method: 'DELETE',
|
||||
...(sub === undefined ? {} : { headers: await bearer(sub) }),
|
||||
})
|
||||
|
||||
const clubId = await create('860', { name: 'Doomed', joinability: 'Open' })
|
||||
await exports.default.fetch(`${ORIGIN}/club/${clubId}/members/requesttojoin`, {
|
||||
method: 'PUT',
|
||||
headers: await bearer('861'),
|
||||
})
|
||||
|
||||
// Signed out, a plain member, and an unknown club.
|
||||
expect((await del(clubId)).status).toBe(401)
|
||||
expect((await del(clubId, '861')).status).toBe(403)
|
||||
expect((await del(99999, '860')).status).toBe(404)
|
||||
|
||||
// The creator can. The club, and its members, are gone.
|
||||
const res = await del(clubId, '860')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ error: '', success: true, value: null })
|
||||
expect((await exports.default.fetch(`${ORIGIN}/club/${clubId}`)).status).toBe(404)
|
||||
expect(
|
||||
await (await exports.default.fetch(`${ORIGIN}/club/${clubId}/members`)).json()
|
||||
).toMatchObject({ value: [] })
|
||||
const member861 = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('861') })
|
||||
).json()) as Array<{ ClubId: number }>
|
||||
expect(member861.map((c) => c.ClubId)).not.toContain(clubId)
|
||||
|
||||
// Deleting twice 404s rather than reporting success.
|
||||
expect((await del(clubId, '860')).status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
const spec = (await res.json()) as {
|
||||
openapi: string
|
||||
paths: Record<string, Record<string, { summary?: string }>>
|
||||
}
|
||||
expect(spec.openapi).toMatch(/^3\.1/)
|
||||
|
||||
// The spec route hides itself.
|
||||
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||
|
||||
// Every schema inlines: a `.meta({ id })` on any of them would emit a $ref this
|
||||
// setup doesn't always hoist into components.schemas, leaving it dangling.
|
||||
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
|
||||
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec. Hono's `:param` syntax (regex constraints and all) becomes
|
||||
// OpenAPI's `{param}`; the `.on([...])` clubhouse and additionalimage routes
|
||||
// contribute both their methods, and modifydetails/modify both their paths.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'DELETE /club/home/me',
|
||||
'DELETE /club/{clubId}',
|
||||
'DELETE /club/{clubId}/additionalimage/{index}',
|
||||
'DELETE /club/{clubId}/clubhouse',
|
||||
'GET /announcements/club/{clubId}',
|
||||
'GET /announcements/v2/mine/unread',
|
||||
'GET /club/categoryTags',
|
||||
'GET /club/home/me',
|
||||
'GET /club/mine/created',
|
||||
'GET /club/mine/member',
|
||||
'GET /club/search',
|
||||
'GET /club/{clubId}',
|
||||
'GET /club/{clubId}/details',
|
||||
'GET /club/{clubId}/hasDisabledClubChat',
|
||||
'GET /club/{clubId}/mainimage',
|
||||
'GET /club/{clubId}/members',
|
||||
'GET /subscription/details/{accountId}',
|
||||
'GET /subscription/details/{subscription}',
|
||||
'GET /subscription/mine/member',
|
||||
'GET /subscription/subscriberCount/{accountId}',
|
||||
'POST /announcements/club/{clubId}',
|
||||
'POST /club/create',
|
||||
'POST /club/{clubId}/join',
|
||||
'POST /club/{clubId}/leave',
|
||||
'POST /club/{clubId}/members/leave',
|
||||
'PUT /club/home/me',
|
||||
'PUT /club/{clubId}/additionalimage/{index}',
|
||||
'PUT /club/{clubId}/clubhouse',
|
||||
'PUT /club/{clubId}/mainimage',
|
||||
'PUT /club/{clubId}/members/invite',
|
||||
'PUT /club/{clubId}/members/requesttojoin',
|
||||
'PUT /club/{clubId}/minlevel',
|
||||
'PUT /club/{clubId}/modify',
|
||||
'PUT /club/{clubId}/modifydetails',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
// documentation.
|
||||
for (const ops of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
},
|
||||
// The per-account cap (MAX_CLUBS_PER_ACCOUNT) is deliberately NOT set here. It's
|
||||
// injected at deploy time from the gitignored .env (RECFLARE_MAX_CLUBS_PER_ACCOUNT, see
|
||||
// .env.example), so tuning it never means editing a versioned file. Unset — the
|
||||
// default — falls back to DEFAULT_MAX_CLUBS_PER_ACCOUNT in src/clubs.app.ts.
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
A Cloudflare Workers application using Hono
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /purchase/v1/hasspentmoney` — whether the player has ever spent money;
|
||||
`false`.
|
||||
- `GET /api/catalog/v1/all` — the purchasable SKU catalog (token packs, special
|
||||
offers), served from the bundled `static/catalog-v1-all.json`. The client's
|
||||
`?onlyAvailableSkus=true` is accepted and ignored: the bundled catalog already
|
||||
contains only available SKUs.
|
||||
- `GET /purchasecampaign/allcurrent/v2` — current purchase campaigns
|
||||
(limited-time offers/promos); `[]` (none active).
|
||||
|
||||
## Development
|
||||
|
||||
### Run in dev mode
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import catalog from '../static/catalog-v1-all.json'
|
||||
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
@@ -29,4 +31,18 @@ const app = new Hono<App>()
|
||||
// it as an error, so we return `false` (no purchases).
|
||||
.get('/purchase/v1/hasspentmoney', (c) => c.json(false))
|
||||
|
||||
// The purchasable SKU catalog (token packs, special offers), served from the
|
||||
// bundled static JSON. The client passes `?onlyAvailableSkus=true`; the bundled
|
||||
// catalog is already only the available SKUs, so the param doesn't change the
|
||||
// response.
|
||||
.get('/api/catalog/v1/all', (c) => c.json(catalog))
|
||||
|
||||
// Current purchase campaigns (limited-time offers/promos). None exist, and
|
||||
// an empty list is the client's "no active campaigns" state.
|
||||
.get('/purchasecampaign/allcurrent/v2', (c) => c.json([]))
|
||||
|
||||
// Token-bundle purchase reminders (the "buy more tokens" nudge). None to show,
|
||||
// and an empty list is the client's "no reminders" state.
|
||||
.get('/reminder/currentTokenBundles/v2', (c) => c.json([]))
|
||||
|
||||
export default app
|
||||
|
||||
@@ -17,4 +17,25 @@ describe('commerce endpoints', () => {
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(false)
|
||||
})
|
||||
|
||||
it('GET /api/catalog/v1/all serves the SKU catalog', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/api/catalog/v1/all?onlyAvailableSkus=true`)
|
||||
expect(res.status).toBe(200)
|
||||
const skus = (await res.json()) as Array<{ skuId: number }>
|
||||
expect(Array.isArray(skus)).toBe(true)
|
||||
expect(skus.length).toBeGreaterThan(0)
|
||||
expect(skus[0]).toHaveProperty('skuId')
|
||||
})
|
||||
|
||||
it('GET /purchasecampaign/allcurrent/v2 returns []', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/purchasecampaign/allcurrent/v2`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
it('GET /reminder/currentTokenBundles/v2 returns []', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/reminder/currentTokenBundles/v2`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
[
|
||||
{
|
||||
"skuId": 178,
|
||||
"name": "500 Tokens",
|
||||
"description": "",
|
||||
"imageName": "0j3qprcooizhw1fw37vbcxj5b.png",
|
||||
"price": 99,
|
||||
"oculusSkuId": "TK0007",
|
||||
"appleProductId": "iTK007",
|
||||
"googlePlaySkuId": "tk0007",
|
||||
"picoSkuId": "TK007",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
14248
|
||||
],
|
||||
"message": "500 Tokens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 184,
|
||||
"name": "Special Offer 9",
|
||||
"description": "",
|
||||
"imageName": "6lp54v2yy3te4xd4ilbl9gq1f.png",
|
||||
"price": 99,
|
||||
"oculusSkuId": "CO1009",
|
||||
"xboxProductId": "43474e39-5143-3035-c04c-5a47484c5b00",
|
||||
"xboxStoreId": "9NGCCQ5LZGHL",
|
||||
"appleProductId": "CO0009",
|
||||
"googlePlaySkuId": "co0009",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 182,
|
||||
"name": "Techwear Samurai Bundle",
|
||||
"description": "Techwear Samurai Bundle",
|
||||
"imageName": "dcsac8vyi7dewkwwh9uobi422.png",
|
||||
"price": 99,
|
||||
"oculusSkuId": "TK0007",
|
||||
"appleProductId": "iTK007",
|
||||
"googlePlaySkuId": "tk0007",
|
||||
"picoSkuId": "TK007",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Techwear Samurai Bundle"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 183,
|
||||
"name": "Maker AI Timed Access One Day",
|
||||
"description": "Grants one day of Maker AI access, timer starts after purchase completion",
|
||||
"imageName": "0b8qpn5ghc8wtwqnnb7fpmcwz.png",
|
||||
"price": 199,
|
||||
"oculusSkuId": "MD0001",
|
||||
"xboxProductId": "4a464e39-5835-3053-c046-475a53528300",
|
||||
"xboxStoreId": "9NFJ5XSFGZSR",
|
||||
"appleProductId": "MD0001",
|
||||
"googlePlaySkuId": "md0001",
|
||||
"picoSkuId": "MD0001",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": true,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "One day of Maker AI access granted!"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 185,
|
||||
"name": "Special Offer 10",
|
||||
"description": "",
|
||||
"imageName": "d8sqdpzxny5sgoebk3wfl976c.png",
|
||||
"price": 199,
|
||||
"oculusSkuId": "CO0010",
|
||||
"xboxProductId": "48544e39-4647-304e-c033-325642524d00",
|
||||
"xboxStoreId": "9NTHGFN32VBR",
|
||||
"appleProductId": "CO0010",
|
||||
"googlePlaySkuId": "co0010",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 10"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 179,
|
||||
"name": "1,000 Tokens",
|
||||
"description": "",
|
||||
"imageName": "535sxig13awyltiiu1tpc28em.png",
|
||||
"price": 199,
|
||||
"oculusSkuId": "TK0008",
|
||||
"appleProductId": "iTK008",
|
||||
"googlePlaySkuId": "tk0008",
|
||||
"picoSkuId": "TK008",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
14249
|
||||
],
|
||||
"message": "1,000 Tokens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 23,
|
||||
"name": "Special Offer 1",
|
||||
"description": "",
|
||||
"imageName": "33q0sd57vofyqyd8559zud6s5.png",
|
||||
"price": 299,
|
||||
"oculusSkuId": "CO0001",
|
||||
"xboxProductId": "57434e39-5137-3051-c04e-3631384e3500",
|
||||
"xboxStoreId": "9NCW7QQN618N",
|
||||
"appleProductId": "CO0001",
|
||||
"googlePlaySkuId": "co0001",
|
||||
"picoSkuId": "CO001",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 177,
|
||||
"name": "1,500 Tokens",
|
||||
"description": "",
|
||||
"imageName": "bsvmr0bvc8koxe19a731sy2jt.png",
|
||||
"price": 299,
|
||||
"oculusSkuId": "TK0005",
|
||||
"xboxProductId": "56334e39-5348-3050-c035-533647373700",
|
||||
"xboxStoreId": "9N3VHSP5S6G7",
|
||||
"appleProductId": "iTK005",
|
||||
"googlePlaySkuId": "tk0005",
|
||||
"picoSkuId": "TK005",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
4463
|
||||
],
|
||||
"message": "1,500 Tokens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 186,
|
||||
"name": "Special Offer 11",
|
||||
"description": "",
|
||||
"imageName": "5msy6rgvumgv64hi25bpe9zef.png",
|
||||
"price": 399,
|
||||
"oculusSkuId": "CO0011",
|
||||
"xboxProductId": "504c4e39-5051-304a-c058-44525054a000",
|
||||
"xboxStoreId": "9NLPQPJXDRPT",
|
||||
"appleProductId": "CO0011",
|
||||
"googlePlaySkuId": "co0011",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 11"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 180,
|
||||
"name": "2,000 Tokens",
|
||||
"description": "",
|
||||
"imageName": "89brexfrkyxx7a1fqtb7vimtp.png",
|
||||
"price": 399,
|
||||
"oculusSkuId": "TK0009",
|
||||
"appleProductId": "iTK009",
|
||||
"googlePlaySkuId": "tk0009",
|
||||
"picoSkuId": "TK009",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
14250
|
||||
],
|
||||
"message": "2,000 Tokens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 181,
|
||||
"name": "Techwear Samurai Bundle",
|
||||
"description": "Techwear Samurai Bundle",
|
||||
"imageName": "dmrxioogofer7d7djcvvt6l6o.png",
|
||||
"price": 499,
|
||||
"oculusSkuId": "TK0001",
|
||||
"psnProductLabel": "TK00010000000000",
|
||||
"psnEntitlementLabel": "TK0001",
|
||||
"xboxProductId": "4e444e39-5651-3046-c044-33484b374700",
|
||||
"xboxStoreId": "9NDNQVFD3HK7",
|
||||
"appleProductId": "iTK001",
|
||||
"googlePlaySkuId": "tk0001",
|
||||
"picoSkuId": "TK001",
|
||||
"nintendoSkuId": "TK0001",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Techwear Samurai Bundle"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 2,
|
||||
"name": "2,500 Tokens",
|
||||
"description": "",
|
||||
"imageName": "c8nb2o470fwyyiccr4fuiv4k9.png",
|
||||
"price": 499,
|
||||
"oculusSkuId": "TK0001",
|
||||
"psnProductLabel": "TK00010000000000",
|
||||
"psnEntitlementLabel": "TK0001",
|
||||
"xboxProductId": "4e444e39-5651-3046-c044-33484b374700",
|
||||
"xboxStoreId": "9NDNQVFD3HK7",
|
||||
"appleProductId": "iTK001",
|
||||
"googlePlaySkuId": "tk0001",
|
||||
"picoSkuId": "TK001",
|
||||
"nintendoSkuId": "TK0001",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": true,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
2632
|
||||
],
|
||||
"message": "2,500 Tokens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 24,
|
||||
"name": "Special Offer 2",
|
||||
"description": "",
|
||||
"imageName": "0snwnkn7l94klxot4hkha93ot.png",
|
||||
"price": 499,
|
||||
"oculusSkuId": "CO0002",
|
||||
"xboxProductId": "32574e39-3250-304d-c048-445242363500",
|
||||
"xboxStoreId": "9NW2P2MHDRB6",
|
||||
"appleProductId": "CO0002",
|
||||
"googlePlaySkuId": "co0002",
|
||||
"picoSkuId": "CO002",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 10,
|
||||
"name": "Starter Pack",
|
||||
"description": "5,500 Tokens\r\nPizza, Pop, & a Potion\r\nExclusive White Hoodie",
|
||||
"imageName": "7jeywaubvdew56wlq2bazbeof.png",
|
||||
"price": 499,
|
||||
"oculusSkuId": "SP0001",
|
||||
"psnProductLabel": "SP00010000000000",
|
||||
"psnEntitlementLabel": "SP0001",
|
||||
"xboxProductId": "57395039-5332-304b-c032-4239434b2400",
|
||||
"xboxStoreId": "9P9W2SK2B9CK",
|
||||
"appleProductId": "iSP001",
|
||||
"googlePlaySkuId": "sp0001",
|
||||
"isSingleUse": true,
|
||||
"shouldAppearInTokenStore": true,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
2636,
|
||||
2011,
|
||||
2025,
|
||||
2010,
|
||||
2548
|
||||
],
|
||||
"message": "Starter Pack"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 187,
|
||||
"name": "Special Offer 12",
|
||||
"description": "",
|
||||
"imageName": "a5owgnqsbvpw0wm6im1rdtsp3.png",
|
||||
"price": 599,
|
||||
"oculusSkuId": "CO0012",
|
||||
"xboxProductId": "4d474e39-3046-3047-c031-544742311700",
|
||||
"xboxStoreId": "9NGMF0G1TGB1",
|
||||
"appleProductId": "CO0012",
|
||||
"googlePlaySkuId": "co0012",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 188,
|
||||
"name": "Special Offer 13",
|
||||
"description": "",
|
||||
"imageName": "dn4gs1ie40kfhoruy7jmkfey4.png",
|
||||
"price": 699,
|
||||
"oculusSkuId": "CO0013",
|
||||
"xboxProductId": "364e4e39-4334-3044-c04a-385257352600",
|
||||
"xboxStoreId": "9NN64CDJ8RW5",
|
||||
"appleProductId": "CO0013",
|
||||
"googlePlaySkuId": "co0013",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 13"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 189,
|
||||
"name": "Special Offer 14",
|
||||
"description": "",
|
||||
"imageName": "3fbwxtc1flb1ngd71l2vudklx.png",
|
||||
"price": 799,
|
||||
"oculusSkuId": "CO0014",
|
||||
"xboxProductId": "43314e39-3235-3039-c050-573158562100",
|
||||
"xboxStoreId": "9N1C529PW1XV",
|
||||
"appleProductId": "CO0014",
|
||||
"googlePlaySkuId": "co0014",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 14"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 11,
|
||||
"name": "Rec Room Plus Membership",
|
||||
"description": "Rec Room Plus Membership",
|
||||
"imageName": "304tyaxvb6d8s3dcef8s3y4pb.png",
|
||||
"price": 799,
|
||||
"oculusSkuId": "PM0001",
|
||||
"psnProductLabel": "PM00010000000000",
|
||||
"psnEntitlementLabel": "PM0001",
|
||||
"xboxProductId": "54465039-534b-3044-c04a-564e32517600",
|
||||
"xboxStoreId": "9PFTKSDJVN2Q",
|
||||
"appleProductId": "iCC001",
|
||||
"googlePlaySkuId": "pm0001",
|
||||
"nintendoSkuId": "a26f976e026d86e1",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": true,
|
||||
"dataSchemaVersion": 2,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
14258
|
||||
],
|
||||
"message": "Rec Room Plus Membership",
|
||||
"subscriptionPurchase": {
|
||||
"type": 0,
|
||||
"level": 0,
|
||||
"period": 0,
|
||||
"isAutoRenewing": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 190,
|
||||
"name": "Special Offer 15",
|
||||
"description": "",
|
||||
"imageName": "du9cs8uv5buib1ajgwbdcibtp.png",
|
||||
"price": 899,
|
||||
"oculusSkuId": "CO0015",
|
||||
"xboxProductId": "36534e39-5435-3047-c031-585a57435d00",
|
||||
"xboxStoreId": "9NS65TG1XZWC",
|
||||
"appleProductId": "CO0015",
|
||||
"googlePlaySkuId": "co0015",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 15"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 25,
|
||||
"name": "Special Offer 3",
|
||||
"description": "",
|
||||
"imageName": "7yr1khevvss841xrwahj6hfn9.png",
|
||||
"price": 999,
|
||||
"oculusSkuId": "CO0003",
|
||||
"xboxProductId": "36344e39-4747-3051-c044-4d3634350000",
|
||||
"xboxStoreId": "9N46GGQDM645",
|
||||
"appleProductId": "CO0003",
|
||||
"googlePlaySkuId": "co0003",
|
||||
"picoSkuId": "CO003",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 5,
|
||||
"name": "5,500 Tokens",
|
||||
"description": "",
|
||||
"imageName": "0k5tpvs9p5tp50dfn4tq1j72l.png",
|
||||
"price": 999,
|
||||
"oculusSkuId": "TK0002",
|
||||
"psnProductLabel": "TK00020000000000",
|
||||
"psnEntitlementLabel": "TK0002",
|
||||
"xboxProductId": "44544d39-5153-3030-c044-4a5734576200",
|
||||
"xboxStoreId": "9MTDSQ0DJW4W",
|
||||
"appleProductId": "iTK002",
|
||||
"googlePlaySkuId": "tk0002",
|
||||
"picoSkuId": "TK002",
|
||||
"nintendoSkuId": "TK0002",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": true,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
2633
|
||||
],
|
||||
"message": "5,500 Tokens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 26,
|
||||
"name": "Special Offer 4",
|
||||
"description": "",
|
||||
"imageName": "ev9ynntwhgonosztxuk6r75vq.png",
|
||||
"price": 1499,
|
||||
"oculusSkuId": "CO0004",
|
||||
"xboxProductId": "52465039-4342-3056-c04e-465847538200",
|
||||
"xboxStoreId": "9PFRBCVNFXGS",
|
||||
"appleProductId": "CO0004",
|
||||
"googlePlaySkuId": "co0004",
|
||||
"picoSkuId": "CO004",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 27,
|
||||
"name": "Special Offer 5",
|
||||
"description": "",
|
||||
"imageName": "3exoho63d3xtnx6z8eiudm3yl.png",
|
||||
"price": 1999,
|
||||
"oculusSkuId": "CO0005",
|
||||
"xboxProductId": "5a514e39-3544-3043-c035-5434324a2700",
|
||||
"xboxStoreId": "9NQZD5C5T42J",
|
||||
"appleProductId": "CO0005",
|
||||
"googlePlaySkuId": "co0005",
|
||||
"picoSkuId": "CO005",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 8,
|
||||
"name": "12,000 Tokens",
|
||||
"description": "",
|
||||
"imageName": "2cxy6mghew6po1qyl2hjjp1dz.png",
|
||||
"price": 1999,
|
||||
"oculusSkuId": "TK0003",
|
||||
"psnProductLabel": "TK00030000000000",
|
||||
"psnEntitlementLabel": "TK0003",
|
||||
"xboxProductId": "38434e39-5447-3032-c054-503747433400",
|
||||
"xboxStoreId": "9NC8GT2TP7GC",
|
||||
"appleProductId": "iTK003",
|
||||
"googlePlaySkuId": "tk0003",
|
||||
"picoSkuId": "TK003",
|
||||
"nintendoSkuId": "TK0003",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": true,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
2634
|
||||
],
|
||||
"message": "12,000 Tokens"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 191,
|
||||
"name": "Special Offer 16",
|
||||
"description": "",
|
||||
"imageName": "de614ysh9h08jgt6rm7xtl8g1.png",
|
||||
"price": 2499,
|
||||
"oculusSkuId": "CO0016",
|
||||
"xboxProductId": "47314e39-3557-3043-c052-345233573000",
|
||||
"xboxStoreId": "9N1GW5CR4R3W",
|
||||
"appleProductId": "CO0016",
|
||||
"googlePlaySkuId": "co0016",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 192,
|
||||
"name": "Special Offer 17",
|
||||
"description": "",
|
||||
"imageName": "e2pwk2sqieip5vcbd5bmf5qek.png",
|
||||
"price": 2999,
|
||||
"oculusSkuId": "CO0017",
|
||||
"xboxProductId": "4e465039-464e-3047-c04d-374351334300",
|
||||
"xboxStoreId": "9PFNNFGM7CQ3",
|
||||
"appleProductId": "CO0017",
|
||||
"googlePlaySkuId": "co0017",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 17"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 9,
|
||||
"name": "35,000 Tokens",
|
||||
"description": "",
|
||||
"imageName": "8okk17qjqs25tavj04fzqzski.png",
|
||||
"price": 4999,
|
||||
"oculusSkuId": "TK0004",
|
||||
"psnProductLabel": "TK00040000000000",
|
||||
"psnEntitlementLabel": "TK0004",
|
||||
"xboxProductId": "36524e39-314d-3034-c039-314b5230f800",
|
||||
"xboxStoreId": "9NR6M1491KR0",
|
||||
"appleProductId": "iTK004",
|
||||
"googlePlaySkuId": "tk0004",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": true,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [
|
||||
2635
|
||||
],
|
||||
"message": "35,000 Tokens!"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 28,
|
||||
"name": "Special Offer 6",
|
||||
"description": "",
|
||||
"imageName": "9xtn0r4ol3cszw6n3nmdtjqpc.png",
|
||||
"price": 4999,
|
||||
"oculusSkuId": "CO0006",
|
||||
"xboxProductId": "4a395039-4437-3054-c042-4d3847432c00",
|
||||
"xboxStoreId": "9P9J7DTBM8GC",
|
||||
"appleProductId": "CO0006",
|
||||
"googlePlaySkuId": "co0006",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 29,
|
||||
"name": "Special Offer 7",
|
||||
"description": "",
|
||||
"imageName": "brrnvugan0vtdz5ha58xsydsk.png",
|
||||
"price": 6999,
|
||||
"oculusSkuId": "CO0007",
|
||||
"xboxProductId": "42534e39-4230-3048-c047-46504d373700",
|
||||
"xboxStoreId": "9NSB0BHGFPM7",
|
||||
"appleProductId": "CO0007",
|
||||
"googlePlaySkuId": "co0007",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 193,
|
||||
"name": "Special Offer 18",
|
||||
"description": "",
|
||||
"imageName": "ct4fpwoznnerrdjwuii3vmlxk.png",
|
||||
"price": 7499,
|
||||
"oculusSkuId": "CO0018",
|
||||
"xboxProductId": "5a485039-4e56-304e-c039-5a3735324e00",
|
||||
"xboxStoreId": "9PHZVNN9Z752",
|
||||
"appleProductId": "CO0018",
|
||||
"googlePlaySkuId": "co0018",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "Special Offer 18"
|
||||
}
|
||||
},
|
||||
{
|
||||
"skuId": 30,
|
||||
"name": "99.99 Special Offer",
|
||||
"description": "",
|
||||
"imageName": "5gv9upz5ndauzgwgn8x4lohg6.png",
|
||||
"price": 9999,
|
||||
"oculusSkuId": "CO0008",
|
||||
"xboxProductId": "4b4a4e39-4d51-304e-c054-5a444d569d00",
|
||||
"xboxStoreId": "9NJKQMNTZDMV",
|
||||
"appleProductId": "CO0008",
|
||||
"googlePlaySkuId": "co0008",
|
||||
"isSingleUse": false,
|
||||
"shouldAppearInTokenStore": false,
|
||||
"dataSchemaVersion": 1,
|
||||
"data": {
|
||||
"giftDropIds": [],
|
||||
"message": "$99.99 Special Offer"
|
||||
}
|
||||
}
|
||||
]
|
||||
+109
-43
@@ -1,50 +1,116 @@
|
||||
# econ
|
||||
|
||||
Economy Worker served on the `econ` subdomain. Hosts the avatar/economy
|
||||
endpoints the game client calls on the `econ` service (distinct from the main
|
||||
`api` worker). DB-backed data is stubbed for now — no bindings yet.
|
||||
Economy Worker served on the `econ` subdomain (`econ.recflare.net`). Hosts the
|
||||
avatar/economy endpoints the game client calls on the `econ` service (distinct from the
|
||||
main `api` worker, which also serves many of them — the client may call either host).
|
||||
|
||||
## Endpoints
|
||||
Balances, inventory, consumables, saved outfits, avatars and gift boxes are D1-backed;
|
||||
storefront catalogs are static assets (`static/storefronts/sf{N}.json`) served via the
|
||||
ASSETS binding. Several routes are still empty-list stubs.
|
||||
|
||||
- `GET /api/avatar/v1/defaultunlocked` — default-unlocked avatar items, served
|
||||
from the bundled `static/default-avatar-items.json` catalog.
|
||||
- `GET /api/avatar/v1/defaultbaseavataritems` — default base avatar items. Reads
|
||||
the same source file as `defaultunlocked`, so it returns the identical
|
||||
catalog.
|
||||
- `GET /api/avatar/v4/items` — `[Authorize]`. The player's avatar items: owned
|
||||
items concatenated with the default catalog. No DB binding yet, so owned is
|
||||
empty and this returns just the catalog.
|
||||
- `GET /api/avatar/v2` — `[Authorize]`. The player's avatar. No DB binding yet,
|
||||
so it returns the default `{ OutfitSelections, FaceFeatures, SkinColor,
|
||||
HairColor }` seeded for a new player.
|
||||
- `GET /econ/customAvatarItems/v1/owned` — the player's owned custom avatar
|
||||
items. No auth; returns `{ items: [] }` with no DB binding.
|
||||
The client requests this when custom-item creation is allowed, so a missing
|
||||
route here shows up as "Failed to download unlocked avatar items".
|
||||
- `GET /api/objectives/v1/myprogress` — objectives progress. No auth (serves a
|
||||
static JSON file verbatim); returns the bundled
|
||||
`static/my-progress.json` default for all players until a DB binding exists.
|
||||
- `GET /api/avatar/v3/saved` — `[Authorize]`. Saved outfits; `[]` without a DB.
|
||||
- `GET /api/avatar/v2/gifts` — `[Authorize]`. Pending gifts; `[]` without a DB.
|
||||
- `GET /api/equipment/v2/getUnlocked` — unlocked equipment; `[]` (no auth).
|
||||
- `POST /api/settings/v2/set` — `[Authorize]`. Persist settings; 200 ack only.
|
||||
- `GET /api/consumables/v2/getUnlocked` — `[Authorize]`. `[]` without a DB.
|
||||
- `GET /api/storefronts/v4/balance/2` — `[Authorize]`. Token balance; `[]`.
|
||||
- `GET /api/storefronts/v3/giftdropstore/3` — gift-drop storefront, served from
|
||||
the bundled `static/storefronts-v3-giftdropstore-3.json`.
|
||||
- `GET /api/challenge/v2/getCurrent` — current weekly challenge, served from the
|
||||
bundled `static/weekly-challenge.json`.
|
||||
- `GET /api/gamerewards/v1/pending` — pending rewards; `[]`.
|
||||
- `GET /api/roomkeys/v1/mine` — the player's room keys; `[]`.
|
||||
- `POST /api/CampusCard/v1/UpdateAndGetSubscription` — subscription lookup;
|
||||
`{ subscription: null, platformAccountSubscribedPlayerId: null }`.
|
||||
- Stubbed: `GET /api/roomconsumables/v1/roomConsumable/room/:id`
|
||||
and `GET /api/roomcurrencies/v1/currencies` both return `[]`.
|
||||
## Routes
|
||||
|
||||
These economy routes are also served by the `api` worker; they're
|
||||
duplicated here because the client calls them on the `econ` host.
|
||||
`✓` = auth-gated (validates the Bearer JWT from the `auth` worker; empty-body 401 when
|
||||
missing/invalid).
|
||||
|
||||
## TODO before production
|
||||
| Method | Path | Auth | Description |
|
||||
| -------- | ---------------------------------------------------- | ---- | --------------------------------------- |
|
||||
| GET | `/api/avatar/v1/defaultunlocked` | | Default-unlocked avatar items (static) |
|
||||
| GET | `/api/avatar/v1/defaultbaseavataritems` | | Default base avatar items (stub `[]`) |
|
||||
| GET | `/api/avatar/v4/items` | ✓ | Owned items + the default catalog |
|
||||
| GET | `/econ/customAvatarItems/v1/owned` | ✓ | Owned custom avatar items (stub) |
|
||||
| GET | `/api/objectives/v1/myprogress` | | Objectives progress (static) |
|
||||
| GET/POST | `/api/objectives/v1/cleargroup` | | Clear an objectives group (no-op `[]`) |
|
||||
| GET | `/api/avatar/v2` | ✓ | The player's own avatar |
|
||||
| POST | `/api/avatar/v2/set` | ✓ | Save the player's avatar |
|
||||
| GET | `/api/checklist/v1/current` | ✓ | NUX checklist (stub `[]`) |
|
||||
| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | Item wishlist (stub `[]`) |
|
||||
| GET | `/api/avatar/v3/saved` | ✓ | Saved outfits |
|
||||
| POST | `/api/avatar/v3/saved/set` | ✓ | Save an outfit into a slot |
|
||||
| GET | `/api/avatar/v2/gifts` | ✓ | Pending (unopened) gift boxes |
|
||||
| POST | `/api/avatar/v2/gifts/consume` | | Open a gift box → success envelope |
|
||||
| GET | `/api/avatar/v2/:id` | | Another player's avatar (render subset) |
|
||||
| GET | `/api/equipment/v2/getUnlocked` | | Unlocked equipment (stub `[]`) |
|
||||
| GET | `/api/roomconsumables/v1/roomConsumable/room/:id` | | Room consumables (stub `[]`) |
|
||||
| GET | `/api/roomconsumables/v1/roomConsumable/room/:id/me` | | Caller's room consumables (stub `[]`) |
|
||||
| GET | `/api/roomcurrencies/v1/currencies` | | Room currencies (stub `[]`) |
|
||||
| GET | `/api/roomcurrencies/v1/getAllBalances` | | Room balances (stub `[]`) |
|
||||
| POST | `/api/settings/v2/set` | ✓ | Persist settings (accept-and-ack) |
|
||||
| GET | `/api/consumables/v2/getUnlocked` | ✓ | Unlocked consumables |
|
||||
| POST | `/api/consumables/v1/consume` | ✓ | Consume an owned consumable |
|
||||
| GET | `/api/storefronts/v4/balance/:currencyType` | ✓ | Currency balance |
|
||||
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
|
||||
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
|
||||
| GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) |
|
||||
| GET | `/api/challenge/v2/getCurrent` | | Current weekly challenge (static) |
|
||||
| POST | `/api/challenge/v2/updateProgress` | | Report challenge progress (stub) |
|
||||
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
|
||||
| POST | `/api/gamerewards/v1/request` | | Request a game reward (stub `[]`) |
|
||||
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
|
||||
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
|
||||
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) |
|
||||
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
||||
|
||||
- Wire a DB binding and prepend each player's owned `AvatarItems` to
|
||||
`/api/avatar/v4/items`.
|
||||
The app runs with `strict: false`, so trailing-slash variants match (the client posts
|
||||
`/gifts/consume/` with a trailing slash).
|
||||
|
||||
## API documentation
|
||||
|
||||
`GET /openapi.json` serves a spec generated from `describeRoute` blocks alongside each
|
||||
handler, with the schemas in `src/openapi.ts`. **Descriptive, not enforced** — same
|
||||
rationale as the `auth`/`accounts`/`match` workers. A test asserts every route appears
|
||||
in the spec, so adding one without documenting it fails.
|
||||
|
||||
## Purchases (`buyItem`)
|
||||
|
||||
The core flow. The client posts the storefront/item ids, the currency, and the
|
||||
`RequestedPrice` it rendered; the handler:
|
||||
|
||||
1. looks the item up in `static/storefronts/sf{StorefrontType}.json`;
|
||||
2. rejects a stale price (`409`) — this stops a stale or tampered client buying at a
|
||||
price the catalog no longer offers;
|
||||
3. debits the buyer **atomically** (`400` on insufficient balance);
|
||||
4. grants the drop — an avatar item into the `inventory` table (own-once), a consumable
|
||||
into the `consumable` table (each buy stacks a new instance); currency/xp drops
|
||||
aren't granted yet;
|
||||
5. returns a **gift box** and pushes a `StorefrontBalanceUpdate` over the socket.
|
||||
|
||||
Two things are easy to get wrong:
|
||||
|
||||
- **`Balance` in the response is the _change_ applied** (the negated price), not the
|
||||
resulting total. The client reads its new total from `GET /balance/:type`.
|
||||
- **Ownership is persisted at purchase**, not when the box is opened. Opening a box
|
||||
(`/gifts/consume`) just deletes it — the item was already granted. So the grant never
|
||||
waits on the cosmetic "open it" moment.
|
||||
|
||||
A `Gift` block routes the item (and box) to another player, but the caller always pays.
|
||||
A self-buy or anonymous gift is attributed to the "Coach" system account (id 1).
|
||||
|
||||
## Consume envelopes
|
||||
|
||||
Both consume routes (`/gifts/consume`, `/consumables/consume`) always answer HTTP 200
|
||||
with `{ error: "", success: true, value: null }` — even for a missing or already-gone
|
||||
target. A captured real consume returns this envelope, not an empty body: the client
|
||||
parses it to finish the action, so a bare 200 reads as a failure and the item never
|
||||
finishes unlocking. Deletes are scoped to the caller, so an unauthenticated or
|
||||
mismatched call is a harmless no-op (opening _another_ player's box is a 403).
|
||||
|
||||
## Bindings
|
||||
|
||||
| Binding | Type | Notes |
|
||||
| ---------------------------- | -------------- | -------------------------------------------------------- |
|
||||
| `DB` | D1 | Shared `recflare` database — balances, inventory, etc. |
|
||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
|
||||
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
|
||||
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
|
||||
| `STARTING_TOKENS` | var | Optional; new-player token grant (default in balance-db) |
|
||||
|
||||
Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no code change.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- Gifting to another player grants the item and box but does not notify the recipient.
|
||||
- `buyItem` grants avatar-item and consumable drops; currency/xp drops aren't granted.
|
||||
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
|
||||
- Several routes (room keys, wishlist, equipment, room consumables/currencies, game
|
||||
rewards) are empty-list stubs pending their own stores.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Received gift boxes, owned by the `econ` worker. One row per box: a box is created
|
||||
-- when a player buys a storefront item (`/api/storefronts/v2/buyItem`) and deleted when
|
||||
-- the client opens it (`/api/avatar/v2/gifts/consume`, on the `api` worker). Opening is
|
||||
-- cosmetic — the item is granted into the `inventory` table at purchase time, so a box
|
||||
-- carries only its rendered content (`data`) for the gift list. Kept in sync with
|
||||
-- RECEIVED_GIFT_SCHEMA_DDL in @repo/domain's gifts-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS received_gift (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_received_gift_account ON received_gift (account_id);
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Owned avatar items, owned by the `econ` worker. One row per (account, item): the
|
||||
-- items a player has bought from a storefront. Granted at purchase time by
|
||||
-- `/api/storefronts/v2/buyItem` and read back by `/api/avatar/v4/items`, where they are
|
||||
-- concatenated with the default catalog. The item is keyed by its `AvatarItemDesc` (the
|
||||
-- gift-drop's item guid string) so re-buying the same item is a no-op rather than a
|
||||
-- duplicate row; `data` is the rendered avatar-item DTO. Kept in sync with
|
||||
-- INVENTORY_SCHEMA_DDL in src/inventory-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory (
|
||||
account_id INTEGER NOT NULL,
|
||||
avatar_item_desc TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, avatar_item_desc)
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Owned consumables, owned by the `econ` worker. Unlike avatar items (own-once, one
|
||||
-- row per (account, item)), consumables stack: each purchase inserts a fresh instance
|
||||
-- row carrying its own id, count and created_at. Granted at purchase time by
|
||||
-- `/api/storefronts/v2/buyItem` (when the gift-drop carries a `ConsumableItemDesc`) and
|
||||
-- read back by `/api/consumables/v2/getUnlocked`, which groups a player's rows by
|
||||
-- `consumable_item_desc` into the client's unlocked-consumable DTO (its `Ids`/`CreatedAts`
|
||||
-- are these per-instance columns; `Count` their sum). Kept in sync with
|
||||
-- CONSUMABLE_SCHEMA_DDL in src/consumables-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consumable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
consumable_item_desc TEXT NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consumable_account ON consumable (account_id);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Owned equipment, owned by the `econ` worker. Like avatar items (own-once, one row
|
||||
-- per (account, item)) rather than consumables (which stack): equipment is a boolean
|
||||
-- unlock keyed by its `EquipmentModificationGuid` (the gift-drop's equipment guid), so
|
||||
-- re-buying the same skin is a no-op rather than a duplicate row. Granted at purchase
|
||||
-- time by `/api/storefronts/v2/buyItem` (when the gift-drop carries an
|
||||
-- `EquipmentModificationGuid`) and read back by `/api/equipment/v2/getUnlocked`; `data`
|
||||
-- is the rendered unlocked-equipment DTO. Kept in sync with EQUIPMENT_SCHEMA_DDL in
|
||||
-- src/equipment-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS equipment (
|
||||
account_id INTEGER NOT NULL,
|
||||
equipment_modification_guid TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, equipment_modification_guid)
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Rewrite the stored unlocked-equipment DTOs onto the shape the client actually reads.
|
||||
-- Rows written before this used `EquipmentModificationGuid`/`EquipmentPrefabName` (the
|
||||
-- gift-drop's prefixed names, carried straight over at grant time) and had no
|
||||
-- `Favorited`/`PlatformMask`. The live endpoint serves the unprefixed
|
||||
-- `ModificationGuid`/`PrefabName` plus both of those, and the entries the client PUTs
|
||||
-- back to `/api/equipment/v1/update` use the unprefixed names too — so an un-rewritten
|
||||
-- row renders with a blank prefab and can never be favourited (the update matches on a
|
||||
-- guid the row's `data` no longer spells the same way).
|
||||
--
|
||||
-- The `data` column is the DTO verbatim, so the fix is a JSON rewrite in place; the row
|
||||
-- key (`equipment_modification_guid`) is unchanged. Guarded on the old key being
|
||||
-- present, which also makes it a no-op on re-run.
|
||||
|
||||
UPDATE equipment
|
||||
SET data = json_object(
|
||||
'ModificationGuid', json_extract(data, '$.EquipmentModificationGuid'),
|
||||
'PrefabName', json_extract(data, '$.EquipmentPrefabName'),
|
||||
'FriendlyName', json_extract(data, '$.FriendlyName'),
|
||||
'Tooltip', json_extract(data, '$.Tooltip'),
|
||||
'Rarity', json_extract(data, '$.Rarity'),
|
||||
'PlatformMask', -1,
|
||||
'Favorited', json('false')
|
||||
)
|
||||
WHERE json_extract(data, '$.EquipmentModificationGuid') IS NOT NULL;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Owned inventions, owned by the `econ` worker. One row per (account, invention): the
|
||||
-- inventions a player has bought from the invention store. Written at purchase time by
|
||||
-- `/api/storefronts/v2/buyInvention`, which also uses it to reject a re-buy. Ownership
|
||||
-- is boolean (you own an invention or you don't), so the pair is the primary key and a
|
||||
-- second purchase is a no-op rather than a duplicate row.
|
||||
--
|
||||
-- The invention itself lives in the `invention` table, whose schema/migrations the `api`
|
||||
-- worker owns (apps/api/migrations/0002_invention.sql) on this same `recflare` database;
|
||||
-- only the id is stored here. Creators are NOT listed here — an invention's creator owns
|
||||
-- it by virtue of `CreatorPlayerId`, and never buys their own. Kept in sync with
|
||||
-- INVENTORY_INVENTION_SCHEMA_DDL in src/inventory-invention-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_invention (
|
||||
account_id INTEGER NOT NULL,
|
||||
invention_id INTEGER NOT NULL,
|
||||
acquired_at TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, invention_id)
|
||||
);
|
||||
@@ -16,10 +16,16 @@
|
||||
"test": "run-vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "0.16.20",
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Owned consumables on the shared `recflare` D1 database — the consumable items a
|
||||
* player has bought from a storefront (e.g. a "Supreme Pizza"). One row per granted
|
||||
* instance: unlike avatar items (own-once, keyed by their desc), consumables stack, so
|
||||
* each purchase inserts a fresh row carrying its own id, count and created_at.
|
||||
*
|
||||
* Granted at purchase time (`POST /api/storefronts/v2/buyItem`, when the gift-drop
|
||||
* carries a `ConsumableItemDesc`) and read back by `GET /api/consumables/v2/getUnlocked`,
|
||||
* which groups a player's rows by `consumable_item_desc` into the client's unlocked-
|
||||
* consumable DTO — its `Ids`/`CreatedAts` are these per-instance columns and `Count`
|
||||
* their sum.
|
||||
*
|
||||
* This worker (`econ`) owns the table and its migration — see apps/econ/migrations/
|
||||
* 0005_consumable.sql.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations 0005_consumable.sql) — also builds the table in tests. */
|
||||
export const CONSUMABLE_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS consumable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
consumable_item_desc TEXT NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_consumable_account ON consumable (account_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* An unlocked consumable as `/api/consumables/v2/getUnlocked` serves it: one entry per
|
||||
* distinct `ConsumableItemDesc`, aggregating every instance the player owns. `Ids` and
|
||||
* `CreatedAts` line up per instance; `Count`/`InitialCount` are the summed quantity (no
|
||||
* consumption is tracked yet, so they stay equal). The activation fields are inert
|
||||
* defaults until timed consumables exist.
|
||||
*/
|
||||
export interface UnlockedConsumable {
|
||||
Ids: number[]
|
||||
CreatedAts: string[]
|
||||
ConsumableItemDesc: string
|
||||
Count: number
|
||||
InitialCount: number
|
||||
IsActive: boolean
|
||||
ActiveDurationMinutes: number
|
||||
IsTransferable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant `count` of a consumable to a player as a new owned instance (they stack).
|
||||
* Returns the new row's id — the consumable mapping id the client keys on.
|
||||
*/
|
||||
export async function grantConsumable(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
consumableItemDesc: string,
|
||||
count: number
|
||||
): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO consumable (account_id, consumable_item_desc, count, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4) RETURNING id`
|
||||
)
|
||||
.bind(accountId, consumableItemDesc, count, new Date().toISOString())
|
||||
.first<{ id: number }>()
|
||||
return row?.id ?? 0
|
||||
}
|
||||
|
||||
/** A player's total owned count of a consumable, summed across its stacked instances. */
|
||||
export async function countConsumable(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
consumableItemDesc: string
|
||||
): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT COALESCE(SUM(count), 0) AS total FROM consumable WHERE account_id = ?1 AND consumable_item_desc = ?2'
|
||||
)
|
||||
.bind(accountId, consumableItemDesc)
|
||||
.first<{ total: number }>()
|
||||
return row?.total ?? 0
|
||||
}
|
||||
|
||||
/** The outcome of consuming an instance — its identity plus the resulting count. */
|
||||
export interface ConsumeResult {
|
||||
id: number
|
||||
consumableItemDesc: string
|
||||
createdAt: string
|
||||
/** The instance's count before this consumption. */
|
||||
previousCount: number
|
||||
/** The count left after consuming (0 when the row was deleted). */
|
||||
remaining: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume `deltaCount` from one owned consumable instance, by row `id` and scoped to
|
||||
* its owner (so a player can only consume their own). Reduces that instance's `count`;
|
||||
* once it would reach zero (or below) the row is deleted entirely. Returns the
|
||||
* instance's details plus the resulting count, or null when the row didn't exist /
|
||||
* isn't the caller's.
|
||||
*/
|
||||
export async function consumeConsumable(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
id: number,
|
||||
deltaCount: number
|
||||
): Promise<ConsumeResult | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT consumable_item_desc, count, created_at FROM consumable WHERE id = ?1 AND account_id = ?2'
|
||||
)
|
||||
.bind(id, accountId)
|
||||
.first<{ consumable_item_desc: string; count: number; created_at: string }>()
|
||||
if (row === null) return null
|
||||
|
||||
const remaining = row.count - deltaCount
|
||||
if (remaining > 0) {
|
||||
await db.prepare('UPDATE consumable SET count = ?2 WHERE id = ?1').bind(id, remaining).run()
|
||||
} else {
|
||||
await db
|
||||
.prepare('DELETE FROM consumable WHERE id = ?1 AND account_id = ?2')
|
||||
.bind(id, accountId)
|
||||
.run()
|
||||
}
|
||||
return {
|
||||
id,
|
||||
consumableItemDesc: row.consumable_item_desc,
|
||||
createdAt: row.created_at,
|
||||
previousCount: row.count,
|
||||
remaining: Math.max(remaining, 0),
|
||||
}
|
||||
}
|
||||
|
||||
interface ConsumableRow {
|
||||
id: number
|
||||
consumable_item_desc: string
|
||||
count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Every consumable a player owns, grouped by item into the unlocked-consumable DTO.
|
||||
* Rows are read oldest-first so each group's `Ids`/`CreatedAts` are in purchase order.
|
||||
*/
|
||||
export async function getConsumables(
|
||||
db: D1Database,
|
||||
accountId: number
|
||||
): Promise<UnlockedConsumable[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT id, consumable_item_desc, count, created_at
|
||||
FROM consumable WHERE account_id = ?1 ORDER BY id`
|
||||
)
|
||||
.bind(accountId)
|
||||
.all<ConsumableRow>()
|
||||
|
||||
const byDesc = new Map<string, UnlockedConsumable>()
|
||||
for (const r of results) {
|
||||
const existing = byDesc.get(r.consumable_item_desc)
|
||||
if (existing === undefined) {
|
||||
byDesc.set(r.consumable_item_desc, {
|
||||
Ids: [r.id],
|
||||
CreatedAts: [r.created_at],
|
||||
ConsumableItemDesc: r.consumable_item_desc,
|
||||
Count: r.count,
|
||||
InitialCount: r.count,
|
||||
IsActive: false,
|
||||
ActiveDurationMinutes: 0,
|
||||
IsTransferable: false,
|
||||
})
|
||||
} else {
|
||||
existing.Ids.push(r.id)
|
||||
existing.CreatedAts.push(r.created_at)
|
||||
existing.Count += r.count
|
||||
existing.InitialCount += r.count
|
||||
}
|
||||
}
|
||||
return [...byDesc.values()]
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { HonoApp } from '@repo/hono-helpers'
|
||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||
// Type-only import (erased at build) of the DO class owned by the `notify` worker,
|
||||
// so this worker can push websocket notifications through its RPC surface.
|
||||
import type { NotificationsHub } from '../../notify/src/notifications-hub'
|
||||
|
||||
export type Env = SharedHonoEnv & {
|
||||
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
|
||||
@@ -10,6 +13,8 @@ export type Env = SharedHonoEnv & {
|
||||
DB: D1Database
|
||||
/** Static storefront catalogs (`static/storefronts/sf*.json`), fetched by path. */
|
||||
ASSETS: Fetcher
|
||||
/** The `notify` worker's NotificationsHub DO — push websocket notifications to a player. */
|
||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||
/**
|
||||
* The RecCenterTokens a new player is granted (see balance-db.ts). Optional — unset
|
||||
* falls back to DEFAULT_STARTING_TOKENS, and 0 means players start broke.
|
||||
|
||||
+1338
-129
@@ -1,28 +1,95 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { intVar, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import {
|
||||
consumeGift,
|
||||
createGift,
|
||||
getGift,
|
||||
getPendingGifts,
|
||||
grantInvention,
|
||||
ownsInvention,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
// Invention storage (owned by the `api` worker, on this same `recflare` database).
|
||||
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
|
||||
// their own, and buyInvention has to read the very rows `api` writes.
|
||||
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
||||
// as a value — the enum has no runtime dependencies.
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import adCarouselItems from '../static/ad-carousel-items.json'
|
||||
import defaultAvatarItems from '../static/default-avatar-items.json'
|
||||
import defaultAvatar from '../static/default-avatar.json'
|
||||
import myProgress from '../static/my-progress.json'
|
||||
import weeklyChallenge from '../static/weekly-challenge.json'
|
||||
import { getAvatar, setAvatar } from './avatar-db'
|
||||
import { ALL_PLATFORMS, DEFAULT_STARTING_TOKENS, getBalance, isSpendable } from './balance-db'
|
||||
import {
|
||||
ALL_PLATFORMS,
|
||||
creditCurrency,
|
||||
CurrencyType,
|
||||
DEFAULT_STARTING_TOKENS,
|
||||
ensureStartingBalances,
|
||||
getBalance,
|
||||
isSpendable,
|
||||
spendCurrency,
|
||||
} from './balance-db'
|
||||
import {
|
||||
consumeConsumable,
|
||||
countConsumable,
|
||||
getConsumables,
|
||||
grantConsumable,
|
||||
} from './consumables-db'
|
||||
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
|
||||
import { getInventory, grantItem } from './inventory-db'
|
||||
import {
|
||||
AUTHED,
|
||||
AvatarV2Dto,
|
||||
BalanceEntry,
|
||||
BuyInventionResponse,
|
||||
BuyItemRequest,
|
||||
BuyItemResponse,
|
||||
ChallengeProgressRequest,
|
||||
ChallengeProgressResponse,
|
||||
ConsumeConsumableRequest,
|
||||
ConsumeEnvelope,
|
||||
ConsumeGiftRequest,
|
||||
CustomAvatarItemsResponse,
|
||||
EquipmentUpdateRequest,
|
||||
ErrorResponse,
|
||||
form,
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
JsonObject,
|
||||
OpaqueJsonBody,
|
||||
SaveOutfitRequest,
|
||||
SaveOutfitV4Response,
|
||||
SubscriptionResponse,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from './openapi'
|
||||
import { getOutfits, setOutfit } from './outfit-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { GiftContent, StoredGift } from '@repo/domain'
|
||||
import type { Avatar } from './avatar-db'
|
||||
import type { ConsumeResult } from './consumables-db'
|
||||
import type { App } from './context'
|
||||
import type { Equipment } from './equipment-db'
|
||||
import type { AvatarItem } from './inventory-db'
|
||||
import type { Outfit } from './outfit-db'
|
||||
|
||||
/**
|
||||
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
|
||||
* the `econ` service (these are separate from the main `api` worker). DB-backed
|
||||
* data is stubbed for now — no bindings yet.
|
||||
* the `econ` service (these are separate from the main `api` worker). Balances,
|
||||
* inventory (avatar items, equipment, bought inventions), consumables, saved outfits,
|
||||
* avatars and gift boxes are D1-backed;
|
||||
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
|
||||
* binding. Some routes are still empty-list stubs (room keys, wishlist, …).
|
||||
*
|
||||
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
|
||||
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -38,6 +105,140 @@ function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared parse/validate/store for the save-outfit routes (v3 and v4). Persists the
|
||||
* posted outfit into its `Slot` verbatim and returns the stored `Outfit`; on the
|
||||
* unauth or bad-body path it returns the Response to send directly (401, or 400 for a
|
||||
* non-object body or missing/non-integer `Slot`). Callers format the success body — v3
|
||||
* echoes the whole outfit, v4 answers a lean `{ Success, Slot }` ack.
|
||||
*/
|
||||
async function persistPostedOutfit(c: Context<App>): Promise<Outfit | Response> {
|
||||
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.body(null, 400)
|
||||
}
|
||||
if (!Number.isInteger(body.Slot)) return c.body(null, 400)
|
||||
const outfit = body as Outfit
|
||||
await setOutfit(c.env.DB, id, outfit)
|
||||
return outfit
|
||||
}
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/**
|
||||
* Push a ConsumableMappingRemoved notification to a player after they consume a
|
||||
* consumable, mirroring the reference's
|
||||
* `HubSendToPlayer(accountID, NotifFrame(ConsumableMappingRemoved, {...}))` — the
|
||||
* client uses it to update/remove the item from inventory. Best-effort: a hub failure
|
||||
* is logged and swallowed, since the consume has already committed.
|
||||
*/
|
||||
async function pushConsumableRemoved(
|
||||
c: Context<App>,
|
||||
accountId: number,
|
||||
consumed: ConsumeResult
|
||||
): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
accountId,
|
||||
NotificationType.ConsumableMappingRemoved,
|
||||
{
|
||||
Id: consumed.id,
|
||||
ConsumableItemDesc: consumed.consumableItemDesc,
|
||||
CreatedAt: consumed.createdAt,
|
||||
Count: consumed.remaining,
|
||||
InitialCount: consumed.previousCount,
|
||||
IsActive: false,
|
||||
ActiveDurationMinutes: 0,
|
||||
IsTransferable: false,
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push ConsumableMappingRemoved notification', {
|
||||
accountId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a ConsumableMappingAdded notification to a player after they open a gift box
|
||||
* that carried a consumable, mirroring the reference's
|
||||
* `HubSendToPlayer(accountID, NotifFrame(ConsumableMappingAdded, {...}))` — the client
|
||||
* uses it to show the newly-unlocked consumable. The mapping id and pre-existing count
|
||||
* were stamped onto the box at purchase (see toGiftContent). Best-effort like the
|
||||
* removed push.
|
||||
*/
|
||||
async function pushConsumableAdded(
|
||||
c: Context<App>,
|
||||
accountId: number,
|
||||
gift: StoredGift
|
||||
): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
accountId,
|
||||
NotificationType.ConsumableMappingAdded,
|
||||
{
|
||||
Id: gift.ConsumableMappingId ?? 0,
|
||||
ConsumableItemDesc: gift.ConsumableItemDesc,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
Count: gift.ConsumableCount,
|
||||
InitialCount: gift.ConsumablePreExistingCount ?? 0,
|
||||
IsActive: false,
|
||||
ActiveDurationMinutes: 0,
|
||||
IsTransferable: false,
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push ConsumableMappingAdded notification', {
|
||||
accountId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a StorefrontBalanceUpdate to a player after their balance changes, mirroring the
|
||||
* reference's
|
||||
* `HubSendToPlayer(accountID, NotifFrame(StorefrontBalanceUpdate, {Balance, CurrencyType, BalanceType}))`.
|
||||
* The client applies it to the shown balance so a purchase reflects immediately, without
|
||||
* waiting for a `GET /balance` re-fetch.
|
||||
*
|
||||
* `Balance` is the CHANGE — negative for a debit, positive for a payout — not the
|
||||
* resulting total. The client ADDS what it receives to the balance it is already showing,
|
||||
* so sending the total made a 10,000-token player who earned 250 read 20,250: their own
|
||||
* balance plus the new total. That also makes this frame non-idempotent, so push exactly
|
||||
* once per change and never re-send it as a "refresh".
|
||||
*
|
||||
* `BalanceType` is -2 (account-wide, all platforms). Best-effort: a hub failure is logged
|
||||
* and swallowed, since the balance change has already committed.
|
||||
*/
|
||||
async function pushBalanceUpdate(
|
||||
c: Context<App>,
|
||||
accountId: number,
|
||||
currencyType: number,
|
||||
change: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
accountId,
|
||||
NotificationType.StorefrontBalanceUpdate,
|
||||
{
|
||||
Balance: change,
|
||||
CurrencyType: currencyType,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push StorefrontBalanceUpdate notification', {
|
||||
accountId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored avatar into the public render subset returned by
|
||||
* `GET /api/avatar/v2/:id` — the fields needed to draw another player's avatar
|
||||
@@ -53,7 +254,143 @@ function toAvatarV2Dto(avatar: Avatar) {
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
/**
|
||||
* The subset of a storefront catalog (`static/storefronts/sf{N}.json`) that `buyItem`
|
||||
* reads: each store item carries the `GiftDrop` describing what you get and a list of
|
||||
* `Prices` per currency. The catalogs hold more fields (SubscriberPrices, IsFeatured,
|
||||
* …) that the purchase path doesn't need.
|
||||
*/
|
||||
interface StoreGiftDrop {
|
||||
FriendlyName: string
|
||||
Tooltip: string
|
||||
ConsumableItemDesc: string
|
||||
AvatarItemDesc: string
|
||||
AvatarItemType: number | null
|
||||
EquipmentPrefabName: string
|
||||
EquipmentModificationGuid: string
|
||||
Rarity: number
|
||||
Context: number
|
||||
Currency: number
|
||||
CurrencyType: number
|
||||
}
|
||||
interface StorePrice {
|
||||
CurrencyType: number
|
||||
Price: number
|
||||
}
|
||||
interface StoreItem {
|
||||
GiftDrop: StoreGiftDrop
|
||||
Prices: StorePrice[]
|
||||
PurchasableItemId: number
|
||||
}
|
||||
interface Storefront {
|
||||
StoreItems: StoreItem[]
|
||||
}
|
||||
|
||||
/** The `Gift` block of a buyItem body — present when buying an item for another player. */
|
||||
interface GiftRequest {
|
||||
ToPlayerId?: number
|
||||
Anonymous?: boolean
|
||||
Message?: string
|
||||
GiftContext?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a store item by (storefront type, purchasable item id), reading the catalog
|
||||
* from the ASSETS binding (`sf{type}.json`). Returns null when there is no such
|
||||
* storefront or no item with that id in it.
|
||||
*/
|
||||
async function findStoreItem(
|
||||
c: Context<App>,
|
||||
storefrontType: number,
|
||||
purchasableItemId: number
|
||||
): Promise<StoreItem | null> {
|
||||
const res = await c.env.ASSETS.fetch(new URL(`/sf${storefrontType}.json`, c.req.url))
|
||||
if (!res.ok) return null
|
||||
const storefront = (await res.json()) as Storefront
|
||||
return storefront.StoreItems.find((it) => it.PurchasableItemId === purchasableItemId) ?? null
|
||||
}
|
||||
|
||||
/** Build the owned avatar-item DTO granted into the buyer's inventory from a gift-drop. */
|
||||
function toAvatarItem(giftDrop: StoreGiftDrop): AvatarItem {
|
||||
return {
|
||||
AvatarItemType: giftDrop.AvatarItemType,
|
||||
AvatarItemDesc: giftDrop.AvatarItemDesc,
|
||||
PlatformMask: -1,
|
||||
FriendlyName: giftDrop.FriendlyName,
|
||||
Tooltip: giftDrop.Tooltip,
|
||||
Rarity: giftDrop.Rarity,
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the owned equipment DTO granted into the buyer's inventory from a gift-drop. */
|
||||
function toEquipment(giftDrop: StoreGiftDrop): Equipment {
|
||||
return {
|
||||
ModificationGuid: giftDrop.EquipmentModificationGuid,
|
||||
PrefabName: giftDrop.EquipmentPrefabName,
|
||||
FriendlyName: giftDrop.FriendlyName,
|
||||
Tooltip: giftDrop.Tooltip,
|
||||
Rarity: giftDrop.Rarity,
|
||||
PlatformMask: -1,
|
||||
Favorited: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Quantity of a consumable granted per purchase — our storefront catalogs don't specify one. */
|
||||
const CONSUMABLE_GRANT_COUNT = 1
|
||||
|
||||
/** The "Coach" system account — the sender a self-buy or anonymous gift is attributed to. */
|
||||
const COACH_ACCOUNT_ID = 1
|
||||
|
||||
/** Build the stored gift-box content (the client's rendered "gift box") from a gift-drop. */
|
||||
function toGiftContent(
|
||||
giftDrop: StoreGiftDrop,
|
||||
message: string,
|
||||
consumableCount: number,
|
||||
consumableMappingId = 0,
|
||||
consumablePreExistingCount = 0
|
||||
): GiftContent {
|
||||
return {
|
||||
ConsumableItemDesc: giftDrop.ConsumableItemDesc,
|
||||
ConsumableCount: consumableCount,
|
||||
ConsumableMappingId: consumableMappingId,
|
||||
ConsumablePreExistingCount: consumablePreExistingCount,
|
||||
AvatarItemDesc: giftDrop.AvatarItemDesc,
|
||||
AvatarItemType: giftDrop.AvatarItemType,
|
||||
CurrencyType: giftDrop.CurrencyType,
|
||||
Currency: giftDrop.Currency,
|
||||
Xp: 0,
|
||||
PackageType: 0,
|
||||
Message: message,
|
||||
EquipmentPrefabName: giftDrop.EquipmentPrefabName,
|
||||
EquipmentModificationGuid: giftDrop.EquipmentModificationGuid,
|
||||
GiftRarity: giftDrop.Rarity,
|
||||
Platform: -1,
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A concise `describeRoute` spec for a route that serves an opaque JSON array — either
|
||||
* a static catalog served verbatim or an empty-list stub. `auth` adds the bearer
|
||||
* requirement + a 401 response.
|
||||
*/
|
||||
function listRoute(summary: string, description: string, auth = false) {
|
||||
return describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary,
|
||||
description,
|
||||
...(auth ? { security: AUTHED } : {}),
|
||||
responses: {
|
||||
200: json(JsonArray, description),
|
||||
...(auth ? { 401: UNAUTHORIZED_RESPONSE } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
const app = new Hono<App>({ strict: false })
|
||||
.use(
|
||||
'*',
|
||||
// middleware
|
||||
@@ -68,79 +405,199 @@ const app = new Hono<App>()
|
||||
.notFound(withNotFound())
|
||||
|
||||
// Default-unlocked avatar items, served from the bundled static JSON.
|
||||
.get('/api/avatar/v1/defaultunlocked', (c) => c.json(defaultAvatarItems))
|
||||
.get(
|
||||
'/api/avatar/v1/defaultunlocked',
|
||||
listRoute('Default-unlocked avatar items', 'The bundled default avatar-item catalog'),
|
||||
(c) => c.json(defaultAvatarItems)
|
||||
)
|
||||
|
||||
// Default base avatar items — empty stub for now. No auth.
|
||||
.get('/api/avatar/v1/defaultbaseavataritems', (c) => c.json([]))
|
||||
.get(
|
||||
'/api/avatar/v1/defaultbaseavataritems',
|
||||
listRoute('Default base avatar items', 'Empty stub for now'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// The player's avatar items — owned items concatenated with the default
|
||||
// catalog. No DB binding yet, so owned is empty and this is just the catalog.
|
||||
.get('/api/avatar/v4/items', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// TODO: prepend the player's owned AvatarItems once a DB binding exists.
|
||||
return c.json(defaultAvatarItems)
|
||||
})
|
||||
// The player's avatar items — the items they've bought (from `buyItem`, stored in
|
||||
// the inventory table) prepended to the default catalog. A player who has bought
|
||||
// nothing gets just the catalog.
|
||||
.get(
|
||||
'/api/avatar/v4/items',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'The player’s avatar items',
|
||||
description: [
|
||||
'The items the player has bought (from buyItem, in the inventory table) prepended',
|
||||
'to the default catalog. A player who has bought nothing gets just the catalog.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(JsonArray, 'Owned items followed by the default catalog'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const owned = await getInventory(c.env.DB, id)
|
||||
return c.json([...owned, ...defaultAvatarItems])
|
||||
}
|
||||
)
|
||||
|
||||
// The player's owned custom avatar items. [Authorize]; paginated. Empty stub for
|
||||
// now (no DB binding). The client downloads these when custom-item creation is
|
||||
// allowed; a 404 here surfaces as "Failed to download unlocked avatar items".
|
||||
.get('/econ/customAvatarItems/v1/owned', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({ Results: [], TotalResults: 0 })
|
||||
})
|
||||
.get(
|
||||
'/econ/customAvatarItems/v1/owned',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Owned custom avatar items',
|
||||
description: [
|
||||
'Paginated owned custom items. Empty stub for now. The client requests this when',
|
||||
'custom-item creation is allowed; a 404 shows as “Failed to download unlocked',
|
||||
'avatar items”.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(CustomAvatarItemsResponse, 'Paginated results (empty for now)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({ Results: [], TotalResults: 0 })
|
||||
}
|
||||
)
|
||||
|
||||
// The player's objectives progress. Serves a static JSON file verbatim with
|
||||
// no auth — same default for everyone until there's a DB binding to track
|
||||
// per-player progress.
|
||||
.get('/api/objectives/v1/myprogress', (c) => c.json(myProgress))
|
||||
.get(
|
||||
'/api/objectives/v1/myprogress',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Objectives progress',
|
||||
description:
|
||||
'Serves the bundled static progress verbatim (no per-player store yet). No auth.',
|
||||
responses: { 200: json(JsonObject, 'The bundled objectives-progress default') },
|
||||
}),
|
||||
(c) => c.json(myProgress)
|
||||
)
|
||||
|
||||
// Clears a group of objectives. No per-player progress to clear yet, so this
|
||||
// is a no-op that returns an empty array (a 404 here breaks the client). Accepts
|
||||
// GET or POST since the client may use either.
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/objectives/v1/cleargroup',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Clear an objectives group (no-op)',
|
||||
description: 'No per-player progress to clear yet → []. Accepts GET or POST.',
|
||||
responses: { 200: json(JsonArray, 'Always empty for now') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// The player's avatar, stored as a JSON blob on their account row. Falls back
|
||||
// to the default outfit when they haven't saved one — the client's parser NREs
|
||||
// on an empty OutfitSelections (real RecNet never returns one).
|
||||
.get('/api/avatar/v2', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json((await getAvatar(c.env.DB, id)) ?? defaultAvatar)
|
||||
})
|
||||
.get(
|
||||
'/api/avatar/v2',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'The player’s own avatar',
|
||||
description: [
|
||||
'The avatar JSON blob stored on the account row, or the default outfit when none is',
|
||||
'saved (the client NREs on an empty OutfitSelections).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(JsonObject, 'The stored avatar blob (or the default)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json((await getAvatar(c.env.DB, id)) ?? defaultAvatar)
|
||||
}
|
||||
)
|
||||
|
||||
// Save the player's avatar. [Authorize]. Stores the posted JSON payload verbatim
|
||||
// on the account row and echoes it back. 400 on a non-object body; 404 when the
|
||||
// caller has no account row to attach it to.
|
||||
.post('/api/avatar/v2/set', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const avatar = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (avatar === null || typeof avatar !== 'object' || Array.isArray(avatar)) {
|
||||
return c.body(null, 400)
|
||||
.post(
|
||||
'/api/avatar/v2/set',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Save the player’s avatar',
|
||||
description: 'Stores the posted JSON blob verbatim on the account row and echoes it back.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(OpaqueJsonBody, 'The avatar blob'),
|
||||
responses: {
|
||||
200: json(JsonObject, 'The saved avatar (echoed back)'),
|
||||
400: { description: 'Body was not a JSON object (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: { description: 'No account row to attach it to (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const avatar = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (avatar === null || typeof avatar !== 'object' || Array.isArray(avatar)) {
|
||||
return c.body(null, 400)
|
||||
}
|
||||
if (!(await setAvatar(c.env.DB, id, avatar))) return c.body(null, 404)
|
||||
return c.json(avatar)
|
||||
}
|
||||
if (!(await setAvatar(c.env.DB, id, avatar))) return c.body(null, 404)
|
||||
return c.json(avatar)
|
||||
})
|
||||
)
|
||||
|
||||
// NUX checklist — the client fetches this on the econ host during load. []
|
||||
// with no DB. A 404 here can abort the load orchestration before matchmake.
|
||||
.get('/api/checklist/v1/current', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
})
|
||||
.get(
|
||||
'/api/checklist/v1/current',
|
||||
listRoute('NUX checklist', 'The new-user checklist; [] for now. A 404 can abort load.', true),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
// The player's item wishlist. [Authorize]; empty without a DB binding.
|
||||
.get('/api/itemWishlists/v1/wishlist/me', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
})
|
||||
.get(
|
||||
'/api/itemWishlists/v1/wishlist/me',
|
||||
listRoute('The player’s item wishlist', 'Empty for now', true),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
// The player's saved outfits. [Authorize]. Served back as the client posted them
|
||||
// (see /saved/set); a player who has saved none gets [].
|
||||
.get('/api/avatar/v3/saved', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getOutfits(c.env.DB, id))
|
||||
})
|
||||
.get(
|
||||
'/api/avatar/v3/saved',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'The player’s saved outfits',
|
||||
description: 'Served back as the client posted them (see /saved/set); [] when none.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(JsonArray, 'Saved outfits (empty when none)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getOutfits(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Save an outfit into one of the player's slots. [Authorize]. The posted `Slot` is
|
||||
// the slot to write, and re-saving a slot overwrites it — that's the avatar screen's
|
||||
@@ -150,63 +607,315 @@ const app = new Hono<App>()
|
||||
//
|
||||
// A missing/non-integer `Slot` is a 400 rather than a default slot — guessing would
|
||||
// silently overwrite an outfit the player didn't mean to touch.
|
||||
.post('/api/avatar/v3/saved/set', 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.body(null, 400)
|
||||
//
|
||||
// v3 and v4 share this handler: newer clients POST to /v4/saved/set with the same
|
||||
// payload shape (Slot, PreviewImageName, OutfitSelections(V2), FaceFeatures, Skin/HairColor,
|
||||
// CustomAvatarItems) and expect the same slot-overwrite semantics, so they store into the
|
||||
// same outfit table and read back through /api/avatar/v3/saved.
|
||||
.post(
|
||||
'/api/avatar/v3/saved/set',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Save an outfit into a slot',
|
||||
description: [
|
||||
'Writes the posted outfit into the given `Slot` (overwriting it) and echoes it back.',
|
||||
'The payload is stored verbatim — its inner fields are JSON-in-a-string from the',
|
||||
'client’s own serializer. A missing/non-integer `Slot` is a 400 (guessing would',
|
||||
'silently overwrite another outfit).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'),
|
||||
responses: {
|
||||
200: json(JsonObject, 'The saved outfit (echoed back)'),
|
||||
400: { description: 'Non-object body or missing/non-integer Slot (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const result = await persistPostedOutfit(c)
|
||||
if (result instanceof Response) return result
|
||||
return c.json(result)
|
||||
}
|
||||
if (!Number.isInteger(body.Slot)) return c.body(null, 400)
|
||||
const outfit = body as Outfit
|
||||
await setOutfit(c.env.DB, id, outfit)
|
||||
return c.json(outfit)
|
||||
})
|
||||
)
|
||||
|
||||
// Pending avatar gifts for the player. [Authorize]; empty without a DB binding.
|
||||
.get('/api/avatar/v2/gifts', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// TODO: query pending ReceivedGifts once a DB binding exists.
|
||||
return c.json([])
|
||||
})
|
||||
// v4 of the save-outfit route. Same payload, table and slot-overwrite semantics as v3
|
||||
// (see above) — newer clients moved to /v4/saved/set. The one difference is the response:
|
||||
// v4 answers a lean `{ Success, Slot }` acknowledgement rather than echoing the whole
|
||||
// outfit back. The outfit is read back through /api/avatar/v3/saved either way.
|
||||
.post(
|
||||
'/api/avatar/v4/saved/set',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Save an outfit into a slot (v4)',
|
||||
description: [
|
||||
'Writes the posted outfit into the given `Slot` (overwriting it), same as',
|
||||
'`POST /api/avatar/v3/saved/set`, but answers a lean `{ Success, Slot }` ack instead',
|
||||
'of echoing the outfit. A missing/non-integer `Slot` is a 400.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'),
|
||||
responses: {
|
||||
200: json(SaveOutfitV4Response, 'Save acknowledgement'),
|
||||
400: { description: 'Non-object body or missing/non-integer Slot (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const result = await persistPostedOutfit(c)
|
||||
if (result instanceof Response) return result
|
||||
return c.json({ Success: true, Slot: result.Slot })
|
||||
}
|
||||
)
|
||||
|
||||
// Pending avatar gifts for the player — the unopened gift boxes from their purchases
|
||||
// (and, once gifting lands, from other players). [Authorize]. The client opens each
|
||||
// box and consumes it via the consume route below; the item itself was already
|
||||
// granted at purchase, so an unopened box is cosmetic.
|
||||
.get(
|
||||
'/api/avatar/v2/gifts',
|
||||
describeRoute({
|
||||
tags: ['Gifts'],
|
||||
summary: 'Pending gift boxes',
|
||||
description: [
|
||||
'The player’s unopened gift boxes from their purchases (and, later, from other',
|
||||
'players). The item was already granted at purchase, so an unopened box is cosmetic.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(JsonArray, 'Unopened gift boxes (empty when none)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getPendingGifts(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Open (consume) a gift box. [Authorize]. The client posts this on the econ host after
|
||||
// the box animation, form-encoded as `Id=<giftId>&UnlockedLevel=<n>`. Opening just
|
||||
// deletes the box — the item was granted into the inventory at purchase, so there's
|
||||
// nothing to grant here — an avatar-item drop was granted into the inventory table and a
|
||||
// consumable drop into the consumable table, both at purchase. (`UnlockedLevel`, a
|
||||
// consumable-level hint, is unused.)
|
||||
//
|
||||
// Always answers 200 with the `{ error, success, value }` envelope — even with no token,
|
||||
// a zero id, or a box that is already gone. A captured real consume returns this envelope,
|
||||
// not an empty body: the client parses it to finish opening the box, so a bare 200 reads
|
||||
// as a failure and the consumable never finishes unlocking. The delete is scoped to the
|
||||
// caller's account, so an unauthenticated or mismatched call is simply a no-op. Mirrors
|
||||
// the same route on the `api` worker (the client may call either host).
|
||||
.post(
|
||||
'/api/avatar/v2/gifts/consume',
|
||||
describeRoute({
|
||||
tags: ['Gifts'],
|
||||
summary: 'Open (consume) a gift box',
|
||||
description: [
|
||||
'Deletes the box (the item was already granted at purchase). Always answers the',
|
||||
'`{ error, success, value }` envelope with HTTP 200 — even with no token, a zero id,',
|
||||
'or a box already gone — because the client parses it to finish opening the box. The',
|
||||
'delete is scoped to the caller; opening someone else’s box is 403. Also served by',
|
||||
'the `api` worker.',
|
||||
].join(' '),
|
||||
requestBody: form(ConsumeGiftRequest, 'The gift-box id'),
|
||||
responses: {
|
||||
200: json(ConsumeEnvelope, 'Success envelope'),
|
||||
403: { description: 'The box belongs to another player (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0
|
||||
if (id !== null && giftId !== 0) {
|
||||
// Scoped delete: only the box's owner deletes it. A returned box means it was
|
||||
// theirs and is now consumed.
|
||||
const gift = await consumeGift(c.env.DB, id, giftId)
|
||||
if (gift !== null) {
|
||||
// If the box carried a consumable, tell the client it now has it (so it shows
|
||||
// up in inventory without a refetch). Avatar-item boxes carry no ConsumableItemDesc.
|
||||
if (gift.ConsumableItemDesc !== '') await pushConsumableAdded(c, id, gift)
|
||||
} else {
|
||||
// Nothing was consumed: either the box is already gone (a harmless no-op —
|
||||
// re-opening your own consumed box still succeeds) or it belongs to another
|
||||
// player, which is forbidden.
|
||||
const other = await getGift(c.env.DB, giftId)
|
||||
if (other !== null && other.accountId !== id) return c.body(null, 403)
|
||||
}
|
||||
}
|
||||
return c.json({ error: '', success: true, value: null })
|
||||
}
|
||||
)
|
||||
|
||||
// A player's avatar by account id, projected to the public render subset (used
|
||||
// to draw other players' avatars). No auth — like the accounts `/account/:id`
|
||||
// lookup. Falls back to the default outfit when the player hasn't saved one.
|
||||
// Registered after the static `/api/avatar/v2/*` routes so `:id` can't shadow them.
|
||||
.get('/api/avatar/v2/:id', async (c) => {
|
||||
const accountId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (Number.isNaN(accountId)) return c.body(null, 400)
|
||||
return c.json(toAvatarV2Dto((await getAvatar(c.env.DB, accountId)) ?? defaultAvatar))
|
||||
})
|
||||
.get(
|
||||
'/api/avatar/v2/:id',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Another player’s avatar (render subset)',
|
||||
description: [
|
||||
'The public render subset used to draw another player’s avatar. No auth. Falls back',
|
||||
'to the default outfit when the player hasn’t saved one.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Account id; non-numeric is 400',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(AvatarV2Dto, 'The render subset'),
|
||||
400: { description: 'Non-numeric id (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (Number.isNaN(accountId)) return c.body(null, 400)
|
||||
return c.json(toAvatarV2Dto((await getAvatar(c.env.DB, accountId)) ?? defaultAvatar))
|
||||
}
|
||||
)
|
||||
|
||||
// Unlocked equipment. Returns "[]" with no auth.
|
||||
.get('/api/equipment/v2/getUnlocked', (c) => c.json([]))
|
||||
// Unlocked equipment. [Authorize]. The equipment skins the player has bought (from
|
||||
// `buyItem`, stored in the `equipment` table). A player who has bought none gets an
|
||||
// empty list.
|
||||
.get(
|
||||
'/api/equipment/v2/getUnlocked',
|
||||
listRoute('Unlocked equipment', 'The equipment skins the player has bought', true),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getEquipment(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Favourite/un-favourite owned equipment. [Authorize]. The client PUTs the entries
|
||||
// it wants changed (one request can carry several) and reads nothing back. Only
|
||||
// `Favorited` is written — the rest of each entry is the client echoing what it was
|
||||
// served, and a guid the caller doesn't own matches no row and is dropped.
|
||||
.put(
|
||||
'/api/equipment/v1/update',
|
||||
describeRoute({
|
||||
tags: ['Equipment'],
|
||||
summary: 'Update owned equipment',
|
||||
description: [
|
||||
'Applies the posted `Favorited` flags to the caller’s owned equipment, matched by',
|
||||
'`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller',
|
||||
'doesn’t own is silently skipped. Empty body on success.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'),
|
||||
responses: {
|
||||
200: { description: 'Applied (empty body)' },
|
||||
400: { description: 'Body isn’t a JSON array (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = (await c.req.json().catch(() => null)) as unknown
|
||||
if (!Array.isArray(body)) return c.body(null, 400)
|
||||
const updates = body
|
||||
.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null)
|
||||
.filter((e) => typeof e.ModificationGuid === 'string' && e.ModificationGuid !== '')
|
||||
.map((e) => ({
|
||||
ModificationGuid: e.ModificationGuid as string,
|
||||
Favorited: e.Favorited === true,
|
||||
}))
|
||||
await setEquipmentFavorited(c.env.DB, id, updates)
|
||||
return c.body(null, 200)
|
||||
}
|
||||
)
|
||||
|
||||
// Room consumables/currencies for a given room. Stubbed as empty lists so the
|
||||
// client doesn't 404.
|
||||
.get('/api/roomconsumables/v1/roomConsumable/room/:roomId', (c) => c.json([]))
|
||||
.get('/api/roomconsumables/v1/roomConsumable/room/:roomId/me', (c) => c.json([]))
|
||||
.get('/api/roomcurrencies/v1/currencies', (c) => c.json([]))
|
||||
.get('/api/roomcurrencies/v1/getAllBalances', (c) => c.json([]))
|
||||
.get(
|
||||
'/api/roomconsumables/v1/roomConsumable/room/:roomId',
|
||||
listRoute('Room consumables', 'Empty stub so the client doesn’t 404'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/api/roomconsumables/v1/roomConsumable/room/:roomId/me',
|
||||
listRoute('The caller’s room consumables', 'Empty stub'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get('/api/roomcurrencies/v1/currencies', listRoute('Room currencies', 'Empty stub'), (c) =>
|
||||
c.json([])
|
||||
)
|
||||
.get('/api/roomcurrencies/v1/getAllBalances', listRoute('Room balances', 'Empty stub'), (c) =>
|
||||
c.json([])
|
||||
)
|
||||
|
||||
// Persist player settings. [Authorize]; would replace the player's settings.
|
||||
// No DB binding yet, so accept-and-ack.
|
||||
.post('/api/settings/v2/set', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// TODO: replace stored settings for `id` once a DB binding exists.
|
||||
return c.body(null, 200)
|
||||
})
|
||||
// Unlocked consumables. [Authorize]. The consumables the player has bought (from
|
||||
// `buyItem`, stored in the `consumable` table), grouped by item into the client's
|
||||
// unlocked-consumable DTO. A player who has bought none gets an empty list.
|
||||
.get(
|
||||
'/api/consumables/v2/getUnlocked',
|
||||
describeRoute({
|
||||
tags: ['Consumables'],
|
||||
summary: 'Unlocked consumables',
|
||||
description: [
|
||||
'The consumables the player has bought (from buyItem, in the consumable table),',
|
||||
'grouped by item into the unlocked-consumable DTO (Ids/CreatedAts per instance,',
|
||||
'Count their sum). [] when they’ve bought none.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(JsonArray, 'Grouped unlocked consumables (empty when none)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getConsumables(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Unlocked consumables. [Authorize]; empty without a DB binding.
|
||||
.get('/api/consumables/v2/getUnlocked', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// TODO: query ConsumableItems once a DB binding exists.
|
||||
return c.json([])
|
||||
})
|
||||
// Consume a quantity of an owned consumable instance. [Authorize]. Body is JSON
|
||||
// `{ Id, DeltaCount }` where `Id` is the consumable row id. Reduces that instance's
|
||||
// count by DeltaCount, deleting the row once it hits zero. Scoped to the caller so
|
||||
// they can only consume their own. Envelope mirrors the gift-consume ack.
|
||||
.post(
|
||||
'/api/consumables/v1/consume',
|
||||
describeRoute({
|
||||
tags: ['Consumables'],
|
||||
summary: 'Consume a quantity of an owned consumable',
|
||||
description: [
|
||||
'Reduces the given consumable instance’s count by `DeltaCount` (default 1), deleting',
|
||||
'the row at zero. Scoped to the caller. Pushes a ConsumableMappingRemoved socket',
|
||||
'notification. Envelope mirrors the gift-consume ack.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(ConsumeConsumableRequest, 'The consumable id and delta'),
|
||||
responses: {
|
||||
200: json(ConsumeEnvelope, 'Success envelope'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = await c.req
|
||||
.json<{ Id?: unknown; DeltaCount?: unknown }>()
|
||||
.catch(() => ({}) as { Id?: unknown; DeltaCount?: unknown })
|
||||
const consumableId = typeof body.Id === 'number' ? body.Id : Number.NaN
|
||||
const delta = typeof body.DeltaCount === 'number' ? body.DeltaCount : 1
|
||||
if (!Number.isNaN(consumableId) && delta > 0) {
|
||||
const consumed = await consumeConsumable(c.env.DB, id, consumableId, delta)
|
||||
// Notify the player so their client removes/updates the item in inventory.
|
||||
if (consumed !== null) await pushConsumableRemoved(c, id, consumed)
|
||||
}
|
||||
return c.json({ error: '', success: true, value: null })
|
||||
}
|
||||
)
|
||||
|
||||
// Currency balance. [Authorize]. The trailing int is a CurrencyType — the client
|
||||
// fetches `/balance/2` (RecCenterTokens) on load. Backed by the `balance` table; a
|
||||
@@ -215,46 +924,546 @@ const app = new Hono<App>()
|
||||
// An unknown or non-account-scoped currency (a room currency, ProgressionEvent,
|
||||
// Invalid) returns a 0 balance rather than 404: the client treats a failed balance
|
||||
// fetch as a load error, and "you have none of that" is the honest answer anyway.
|
||||
.get('/api/storefronts/v4/balance/:currencyType', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const currencyType = Number.parseInt(c.req.param('currencyType'), 10)
|
||||
if (Number.isNaN(currencyType)) return c.body(null, 400)
|
||||
const amount = isSpendable(currencyType)
|
||||
? await getBalance(
|
||||
c.env.DB,
|
||||
id,
|
||||
currencyType,
|
||||
intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
)
|
||||
: 0
|
||||
return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }])
|
||||
})
|
||||
.get(
|
||||
'/api/storefronts/v4/balance/:currencyType',
|
||||
describeRoute({
|
||||
tags: ['Storefront'],
|
||||
summary: 'Currency balance',
|
||||
description: [
|
||||
'The player’s balance in a CurrencyType (the client fetches `/balance/2`,',
|
||||
'RecCenterTokens, on load). A first read seeds their starting balance. An unknown or',
|
||||
'non-account currency returns a 0 balance rather than 404.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'currencyType',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'CurrencyType integer; non-numeric is 400',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(BalanceEntry.array(), 'A single-entry balance array'),
|
||||
400: { description: 'Non-numeric currencyType (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const currencyType = Number.parseInt(c.req.param('currencyType'), 10)
|
||||
if (Number.isNaN(currencyType)) return c.body(null, 400)
|
||||
const amount = isSpendable(currencyType)
|
||||
? await getBalance(
|
||||
c.env.DB,
|
||||
id,
|
||||
currencyType,
|
||||
intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
)
|
||||
: 0
|
||||
return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }])
|
||||
}
|
||||
)
|
||||
|
||||
// Gift-drop storefront. Serves `static/storefronts/sf{id}.json` for the requested
|
||||
// storefront id via the ASSETS binding; 404s when no such catalog exists.
|
||||
.get('/api/storefronts/v3/giftdropstore/:id', async (c) => {
|
||||
const id = c.req.param('id')
|
||||
const res = await c.env.ASSETS.fetch(new URL(`/sf${id}.json`, c.req.url))
|
||||
if (!res.ok) return c.notFound()
|
||||
return c.json(await res.json())
|
||||
})
|
||||
.get(
|
||||
'/api/storefronts/v3/giftdropstore/:id',
|
||||
describeRoute({
|
||||
tags: ['Storefront'],
|
||||
summary: 'Gift-drop storefront catalog',
|
||||
description: 'Serves the `sf{id}.json` catalog via the ASSETS binding. 404 when none exists.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Storefront id (selects sf{id}.json)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(JsonObject, 'The storefront catalog'),
|
||||
404: { description: 'No such storefront catalog' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = c.req.param('id')
|
||||
const res = await c.env.ASSETS.fetch(new URL(`/sf${id}.json`, c.req.url))
|
||||
if (!res.ok) return c.notFound()
|
||||
return c.json(await res.json())
|
||||
}
|
||||
)
|
||||
|
||||
// Buy a storefront item. [Authorize]. The client posts the storefront/item ids, the
|
||||
// currency and the price it sees; we look the item up in that storefront's catalog,
|
||||
// confirm the price the client sent still matches, debit the buyer atomically, grant
|
||||
// the item into the recipient's inventory, and hand back a gift box.
|
||||
//
|
||||
// The buyer is always the caller; a `Gift` block routes the item (and box) to another
|
||||
// player, but the caller pays. Ownership is persisted at purchase — the gift box is
|
||||
// only the cosmetic "open it" moment, so the grant does not wait for the box to be
|
||||
// opened (see /api/avatar/v2/gifts/consume on the `api` worker, which just deletes it).
|
||||
//
|
||||
// `RequestedPrice` is the price the client rendered; rejecting a mismatch stops a stale
|
||||
// client (or a tampered request) from buying at a price the catalog no longer offers.
|
||||
.post(
|
||||
'/api/storefronts/v2/buyItem',
|
||||
describeRoute({
|
||||
tags: ['Storefront'],
|
||||
summary: 'Buy a storefront item',
|
||||
description: [
|
||||
'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice`',
|
||||
'still matches, debits the buyer atomically, grants the item (into the inventory or',
|
||||
'consumable table), and returns a gift box. A `Gift` block routes the item to another',
|
||||
'player, but the caller always pays. `Balance` in the response is the CHANGE (negated',
|
||||
'price), not the new total. Pushes a StorefrontBalanceUpdate socket frame carrying the',
|
||||
'same change, which the client ADDS to the balance it is showing.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'),
|
||||
responses: {
|
||||
200: json(BuyItemResponse, 'The purchase result (gift box + balance change)'),
|
||||
400: json(ErrorResponse, 'Invalid body, unavailable currency, or insufficient balance'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: json(ErrorResponse, 'No such item'),
|
||||
409: json(ErrorResponse, 'The price has changed since the client rendered it'),
|
||||
},
|
||||
}),
|
||||
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 storefrontType = body.StorefrontType
|
||||
const purchasableItemId = body.PurchasableItemId
|
||||
const currencyType = body.CurrencyType
|
||||
const requestedPrice = body.RequestedPrice
|
||||
if (
|
||||
!Number.isInteger(storefrontType) ||
|
||||
!Number.isInteger(purchasableItemId) ||
|
||||
!Number.isInteger(currencyType) ||
|
||||
!Number.isInteger(requestedPrice)
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'StorefrontType, PurchasableItemId, CurrencyType and RequestedPrice are required',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const item = await findStoreItem(c, storefrontType as number, purchasableItemId as number)
|
||||
if (item === null) return c.json({ error: 'Item not found' }, 404)
|
||||
|
||||
const price = item.Prices.find((p) => p.CurrencyType === currencyType)
|
||||
if (price === undefined) {
|
||||
return c.json({ error: 'Currency type not available for this item' }, 400)
|
||||
}
|
||||
if (price.Price !== requestedPrice) {
|
||||
return c.json({ error: 'Price has changed' }, 409)
|
||||
}
|
||||
// The item's currency must be an account balance we can debit (RecCenterTokens et al),
|
||||
// not a room-scoped or non-spendable currency.
|
||||
if (!isSpendable(currencyType as number)) {
|
||||
return c.json({ error: 'Currency type is not spendable' }, 400)
|
||||
}
|
||||
|
||||
const gift = (
|
||||
typeof body.Gift === 'object' && body.Gift !== null ? body.Gift : null
|
||||
) as GiftRequest | null
|
||||
const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id
|
||||
// A named (non-anonymous) gift shows the sender; a self-purchase or an anonymous gift
|
||||
// is attributed to the "Coach" system account (id 1), never a null/0 sender.
|
||||
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
|
||||
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
|
||||
|
||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
// Debit the buyer atomically; a false return means they couldn't afford it and
|
||||
// nothing changed, so no item is granted.
|
||||
const paid = await spendCurrency(
|
||||
c.env.DB,
|
||||
id,
|
||||
currencyType as number,
|
||||
price.Price,
|
||||
startingTokens
|
||||
)
|
||||
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
|
||||
|
||||
// Grant the item to the recipient. A gift-drop carries an avatar item, a consumable,
|
||||
// an equipment skin, or none of these (currency/xp drops aren't granted yet); grant
|
||||
// whichever it actually has.
|
||||
if (typeof item.GiftDrop.AvatarItemDesc === 'string' && item.GiftDrop.AvatarItemDesc !== '') {
|
||||
await grantItem(c.env.DB, receiverId, toAvatarItem(item.GiftDrop))
|
||||
}
|
||||
if (
|
||||
typeof item.GiftDrop.EquipmentModificationGuid === 'string' &&
|
||||
item.GiftDrop.EquipmentModificationGuid !== ''
|
||||
) {
|
||||
await grantEquipment(c.env.DB, receiverId, toEquipment(item.GiftDrop))
|
||||
}
|
||||
const isConsumable =
|
||||
typeof item.GiftDrop.ConsumableItemDesc === 'string' &&
|
||||
item.GiftDrop.ConsumableItemDesc !== ''
|
||||
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0
|
||||
// Capture the granted consumable's row id and the player's pre-existing count so
|
||||
// the gift box can carry them — gift-consume fires ConsumableMappingAdded from these.
|
||||
let consumableMappingId = 0
|
||||
let consumablePreExisting = 0
|
||||
if (isConsumable) {
|
||||
consumablePreExisting = await countConsumable(
|
||||
c.env.DB,
|
||||
receiverId,
|
||||
item.GiftDrop.ConsumableItemDesc
|
||||
)
|
||||
consumableMappingId = await grantConsumable(
|
||||
c.env.DB,
|
||||
receiverId,
|
||||
item.GiftDrop.ConsumableItemDesc,
|
||||
consumableCount
|
||||
)
|
||||
}
|
||||
const { id: giftId } = await createGift(
|
||||
c.env.DB,
|
||||
receiverId,
|
||||
toGiftContent(
|
||||
item.GiftDrop,
|
||||
message,
|
||||
consumableCount,
|
||||
consumableMappingId,
|
||||
consumablePreExisting
|
||||
)
|
||||
)
|
||||
|
||||
// Push the debit over the socket so the buyer's client updates the shown total
|
||||
// immediately — the buyer (`id`) is who was charged, in the currency they spent. The
|
||||
// frame carries the CHANGE, so a purchase is negative. Best-effort; the HTTP response
|
||||
// carries the same change either way.
|
||||
await pushBalanceUpdate(c, id, currencyType as number, -price.Price)
|
||||
|
||||
// The response mirrors a captured real buyItem: `Balance` is the change applied (the
|
||||
// negated price), not the resulting balance (the client reads its new total from
|
||||
// `GET /balance/:type`); `BalanceType` is -2 (account-wide, all platforms). The Data
|
||||
// entry is the gift-drop the client received — it carries no FriendlyName or
|
||||
// consumable count (the count is a getUnlocked concept; each box is one instance).
|
||||
return c.json({
|
||||
BalanceUpdates: [
|
||||
{
|
||||
UpdateResponse: 0,
|
||||
Data: [
|
||||
{
|
||||
Id: giftId,
|
||||
FromPlayerId: fromPlayerId,
|
||||
ConsumableItemDesc: item.GiftDrop.ConsumableItemDesc,
|
||||
AvatarItemDesc: item.GiftDrop.AvatarItemDesc,
|
||||
AvatarItemType: item.GiftDrop.AvatarItemType ?? 0,
|
||||
EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName,
|
||||
EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid,
|
||||
CurrencyType: item.GiftDrop.CurrencyType,
|
||||
Currency: item.GiftDrop.Currency,
|
||||
Xp: 0,
|
||||
Level: 0,
|
||||
Platform: -1,
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
GiftContext: Number.isInteger(gift?.GiftContext)
|
||||
? (gift?.GiftContext as number)
|
||||
: item.GiftDrop.Context,
|
||||
GiftRarity: item.GiftDrop.Rarity,
|
||||
Message: message,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
Balance: -price.Price,
|
||||
CurrencyType: currencyType,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends
|
||||
// `?inventionId=…&requestedPrice=…` with no body, so that's what we answer.
|
||||
//
|
||||
// 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.
|
||||
.get(
|
||||
'/api/storefronts/v2/buyInvention',
|
||||
describeRoute({
|
||||
tags: ['Storefront'],
|
||||
summary: 'Buy an invention',
|
||||
description: [
|
||||
'Looks the invention up by id, confirms the client’s `requestedPrice` still matches',
|
||||
'its stored `Price`, debits the buyer and pays the creator that price in',
|
||||
'RecCenterTokens (a free invention moves nothing), records ownership in',
|
||||
'`inventory_invention`, and returns the invention alongside the buyer’s resulting',
|
||||
'balance. When tokens moved, both players get a StorefrontBalanceUpdate push carrying',
|
||||
'their CHANGE (the buyer’s negative, the creator’s positive), which the client adds to',
|
||||
'the balance it is showing — unlike this response body, which replaces it.',
|
||||
'A GET because that is how the client sends it.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'inventionId',
|
||||
in: 'query',
|
||||
required: true,
|
||||
description: 'Invention id; missing or non-numeric is 400',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
{
|
||||
name: 'requestedPrice',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'The price the client rendered; a mismatch is 409. Defaults to 0',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(BuyInventionResponse, 'The purchase result (invention + balance)'),
|
||||
400: json(
|
||||
ErrorResponse,
|
||||
'Missing/non-numeric 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 inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
// Absent/non-numeric requestedPrice reads as 0, which only matches a free invention —
|
||||
// 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 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)
|
||||
await creditCurrency(
|
||||
c.env.DB,
|
||||
invention.CreatorPlayerId,
|
||||
CurrencyType.RecCenterTokens,
|
||||
price,
|
||||
startingTokens
|
||||
)
|
||||
// The creator is a different, probably-online player: push the payout so a sale
|
||||
// lands on their shown balance without a re-fetch. Positive, because the frame
|
||||
// carries the change. Best-effort, as everywhere.
|
||||
await pushBalanceUpdate(c, invention.CreatorPlayerId, CurrencyType.RecCenterTokens, price)
|
||||
}
|
||||
|
||||
// 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 socket frame below is the other way round: the HTTP
|
||||
// body REPLACES the shown balance, the push ADDS to it.
|
||||
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
|
||||
// A free invention moved nothing, so there is no change to push for it.
|
||||
if (price > 0) {
|
||||
await pushBalanceUpdate(c, id, CurrencyType.RecCenterTokens, -price)
|
||||
}
|
||||
return c.json({
|
||||
BalanceUpdateResponse: {
|
||||
Balance: balance,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
BalanceUpdates: [{ UpdateResponse: 0, Data: invention }],
|
||||
},
|
||||
// The same `{ Status, Invention, InventionVersion }` envelope the invention
|
||||
// save/read endpoints serve — the client re-renders the invention from it.
|
||||
InventionResponse: toSaveResult(invention),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Storefront ad-carousel items. Served from the bundled static JSON — one
|
||||
// placeholder banner with no purchasable items until real promo data exists.
|
||||
.get(
|
||||
'/api/storefronts/v1/adcarouselitems',
|
||||
listRoute('Storefront ad-carousel items', 'The bundled carousel (one placeholder banner)'),
|
||||
(c) => c.json(adCarouselItems)
|
||||
)
|
||||
|
||||
// Current weekly challenge. Served from the bundled static JSON until
|
||||
// per-rotation challenge data is wired up.
|
||||
.get('/api/challenge/v2/getCurrent', (c) => c.json(weeklyChallenge))
|
||||
|
||||
// Pending game rewards. Returns "[]".
|
||||
.get('/api/gamerewards/v1/pending', (c) => c.json([]))
|
||||
|
||||
// The player's room keys. Returns "[]".
|
||||
.get('/api/roomkeys/v1/mine', (c) => c.json([]))
|
||||
// Room keys for a given room (client calls this on the econ host). [] with no DB.
|
||||
.get('/api/roomkeys/v1/room', (c) => c.json([]))
|
||||
|
||||
// Subscription lookup. Returns both fields null with no auth.
|
||||
.post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) =>
|
||||
c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||||
.get(
|
||||
'/api/challenge/v2/getCurrent',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Current weekly challenge',
|
||||
description: 'Served from the bundled static challenge until per-rotation data is wired up.',
|
||||
responses: { 200: json(JsonObject, 'The current weekly challenge') },
|
||||
}),
|
||||
(c) => c.json(weeklyChallenge)
|
||||
)
|
||||
|
||||
// Report progress on a weekly challenge. The client evaluates the challenge's rule
|
||||
// tree locally and posts ChallengeMapId/ChallengeId, that tree in `Config`, and
|
||||
// whether it now considers the challenge `Complete`. Stubbed: with no challenge-
|
||||
// progress DB yet we persist nothing and never mark a challenge complete (so the
|
||||
// gift flow isn't triggered). Echo the identifying fields back with Complete=false
|
||||
// so the client gets a well-formed, non-null body to deserialize.
|
||||
.post(
|
||||
'/api/challenge/v2/updateProgress',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Report weekly-challenge progress',
|
||||
description: [
|
||||
'Stubbed: with no challenge-progress store we persist nothing and never mark a',
|
||||
'challenge complete. Echoes the identifying fields back with `Complete: false` so the',
|
||||
'client gets a well-formed body.',
|
||||
].join(' '),
|
||||
requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'),
|
||||
responses: { 200: json(ChallengeProgressResponse, 'Echoed fields, Complete false') },
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req
|
||||
.json<{
|
||||
ChallengeMapId?: string | number
|
||||
ChallengeId?: string | number
|
||||
Config?: string
|
||||
}>()
|
||||
.catch(() => ({}) as Record<string, never>)
|
||||
return c.json({
|
||||
ChallengeMapId: Number(body.ChallengeMapId) || 0,
|
||||
ChallengeId: Number(body.ChallengeId) || 0,
|
||||
Config: typeof body.Config === 'string' ? body.Config : '',
|
||||
Complete: false,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Pending game rewards. Returns "[]".
|
||||
.get('/api/gamerewards/v1/pending', listRoute('Pending game rewards', 'Empty for now'), (c) =>
|
||||
c.json([])
|
||||
)
|
||||
|
||||
// Request a game reward (client posts `rewardType`/`Message`, e.g.
|
||||
// FirstActivityOfDay). Stubbed: with no reward DB yet we grant nothing and return an
|
||||
// empty list of rewards — matching the `pending` shape so the client deserializes it.
|
||||
.post(
|
||||
'/api/gamerewards/v1/request',
|
||||
listRoute('Request a game reward', 'Stubbed — grants nothing, returns []'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// The player's room keys. Returns "[]".
|
||||
.get('/api/roomkeys/v1/mine', listRoute('The player’s room keys', 'Empty for now'), (c) =>
|
||||
c.json([])
|
||||
)
|
||||
// Room keys for a given room (client calls this on the econ host). [] with no DB.
|
||||
.get('/api/roomkeys/v1/room', listRoute('Room keys for a room', 'Empty for now'), (c) =>
|
||||
c.json([])
|
||||
)
|
||||
|
||||
// Subscription lookup. Returns both fields null with no auth.
|
||||
.post(
|
||||
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Subscription lookup',
|
||||
description: 'No subscriptions yet — both fields null. No auth.',
|
||||
responses: { 200: json(SubscriptionResponse, 'Both fields null') },
|
||||
}),
|
||||
(c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||||
)
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
app.get(
|
||||
'/openapi.json',
|
||||
describeRoute({ hide: true }),
|
||||
withCleanSpec(
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: 'recflare econ',
|
||||
version: '1.0.0',
|
||||
description: [
|
||||
'Avatar and economy endpoints for recflare, a private-server reimplementation of the',
|
||||
'Rec Room backend. The client calls these on the `econ` host; many are also served by',
|
||||
'the `api` worker. Storefront catalogs are static assets (`sf{N}.json`); balances,',
|
||||
'inventory, consumables, saved outfits and gift boxes are D1-backed.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://econ.recflare.net', description: 'Production' }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Owned equipment on the shared `recflare` D1 database — the equipment skins a player
|
||||
* has bought from a storefront (e.g. a "Bow Skin (Dryad Summer)"). One row per
|
||||
* (account, item): like avatar items, owning a piece of equipment is boolean, so the
|
||||
* skin is granted at purchase time (`POST /api/storefronts/v2/buyItem`, when the
|
||||
* gift-drop carries an `EquipmentModificationGuid`) and read back by
|
||||
* `GET /api/equipment/v2/getUnlocked`.
|
||||
*
|
||||
* The item is keyed by the gift-drop's equipment guid, so re-buying the same skin
|
||||
* upserts rather than piling up duplicate rows (these drops are flagged `Unique`).
|
||||
* `data` is the rendered unlocked-equipment DTO, stored opaquely and served back
|
||||
* untouched.
|
||||
*
|
||||
* This worker (`econ`) owns the table and its migration — see apps/econ/migrations/
|
||||
* 0006_equipment.sql. The gift box the purchase also creates lives in a separate table
|
||||
* (@repo/domain's received_gift); ownership does not depend on the box being opened.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations 0006_equipment.sql) — also builds the table in tests. */
|
||||
export const EQUIPMENT_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS equipment (
|
||||
account_id INTEGER NOT NULL,
|
||||
equipment_modification_guid TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, equipment_modification_guid)
|
||||
)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* A rendered piece of unlocked equipment, as `/api/equipment/v2/getUnlocked` serves it.
|
||||
* `ModificationGuid` is the item's guid string and the row's key; `PrefabName` names the
|
||||
* base equipment the modification applies to.
|
||||
*
|
||||
* The names are UNPREFIXED here, unlike the gift-drop/gift-box shapes that carry the
|
||||
* same two values as `EquipmentPrefabName`/`EquipmentModificationGuid`. That's not an
|
||||
* inconsistency to tidy up: a drop is a flat record holding avatar, consumable and
|
||||
* equipment fields side by side, so it needs the prefix to disambiguate, while this
|
||||
* record is all equipment. Confirmed against the live endpoint, and the entries the
|
||||
* client PUTs back to `/api/equipment/v1/update` use the same unprefixed names.
|
||||
*/
|
||||
export interface Equipment extends Record<string, unknown> {
|
||||
ModificationGuid: string
|
||||
PrefabName: string
|
||||
FriendlyName: string
|
||||
Tooltip: string
|
||||
Rarity: number
|
||||
/** Always -1 (all platforms) — we don't gate equipment per platform. */
|
||||
PlatformMask: number
|
||||
/** Player-set favourite flag, toggled by `PUT /api/equipment/v1/update`. */
|
||||
Favorited: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a piece of equipment into a player's inventory. Upserts on
|
||||
* (account_id, equipment_modification_guid): owning equipment is boolean, so re-buying
|
||||
* it refreshes the stored DTO rather than adding a second copy. The refresh carries the
|
||||
* player's `Favorited` flag over, so re-buying doesn't quietly un-favourite the skin
|
||||
* (a row written before the flag existed reads as not favourited).
|
||||
*/
|
||||
export async function grantEquipment(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
equipment: Equipment
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO equipment (account_id, equipment_modification_guid, data) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT (account_id, equipment_modification_guid) DO UPDATE SET
|
||||
data = json_set(?3, '$.Favorited',
|
||||
json(CASE WHEN json_extract(equipment.data, '$.Favorited') THEN 'true' ELSE 'false' END))`
|
||||
)
|
||||
.bind(accountId, equipment.ModificationGuid, JSON.stringify(equipment))
|
||||
.run()
|
||||
}
|
||||
|
||||
/** One entry of the `PUT /api/equipment/v1/update` body. */
|
||||
export interface EquipmentFavoriteUpdate {
|
||||
ModificationGuid: string
|
||||
Favorited: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the client's favourite toggles. Only the `Favorited` flag is writable — the
|
||||
* rest of the posted entry (PrefabName, Rarity, …) is the client echoing back what it
|
||||
* was served, and the reference server ignores it too.
|
||||
*
|
||||
* A guid the caller doesn't own matches no row and is silently dropped: equipment is
|
||||
* only ever granted by a purchase, so there is nothing to favourite until then (and an
|
||||
* insert here would let a client mint equipment for itself).
|
||||
*/
|
||||
export async function setEquipmentFavorited(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
updates: EquipmentFavoriteUpdate[]
|
||||
): Promise<void> {
|
||||
if (updates.length === 0) return
|
||||
const stmt = db.prepare(
|
||||
`UPDATE equipment SET data = json_set(data, '$.Favorited', json(?3))
|
||||
WHERE account_id = ?1 AND equipment_modification_guid = ?2`
|
||||
)
|
||||
await db.batch(
|
||||
updates.map((u) => stmt.bind(accountId, u.ModificationGuid, u.Favorited ? 'true' : 'false'))
|
||||
)
|
||||
}
|
||||
|
||||
/** Every piece of equipment a player owns, ordered by guid for a stable listing. */
|
||||
export async function getEquipment(db: D1Database, accountId: number): Promise<Equipment[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
'SELECT data FROM equipment WHERE account_id = ?1 ORDER BY equipment_modification_guid'
|
||||
)
|
||||
.bind(accountId)
|
||||
.all<{ data: string }>()
|
||||
return results.map((r) => JSON.parse(r.data) as Equipment)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Owned avatar items on the shared `recflare` D1 database — the items a player has
|
||||
* bought from a storefront. One row per (account, item): the item is granted at
|
||||
* purchase time (`POST /api/storefronts/v2/buyItem`) and read back by
|
||||
* `GET /api/avatar/v4/items`, which concatenates it with the default catalog.
|
||||
*
|
||||
* The item is keyed by its full `AvatarItemDesc` — the comma-delimited descriptor exactly
|
||||
* as sent, trailing `,,,` and all — so re-buying the same item upserts rather than piling
|
||||
* up duplicate rows. The descriptor is stored verbatim (not normalized): the client expects
|
||||
* the commas back and fails without them. `data` is the rendered avatar-item DTO, stored
|
||||
* opaquely and served back untouched; it matches the shape of the entries in
|
||||
* default-avatar-items.json.
|
||||
*
|
||||
* This worker (`econ`) owns the table and its migration — see apps/econ/migrations/
|
||||
* 0004_inventory.sql. The gift box the purchase also creates lives in a separate table
|
||||
* (@repo/domain's received_gift); ownership does not depend on the box being opened.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations 0004_inventory.sql) — also builds the table in tests. */
|
||||
export const INVENTORY_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS inventory (
|
||||
account_id INTEGER NOT NULL,
|
||||
avatar_item_desc TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, avatar_item_desc)
|
||||
)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* A rendered avatar item, as `/api/avatar/v4/items` serves it (same shape as the
|
||||
* entries in default-avatar-items.json). `AvatarItemDesc` is the item's guid string
|
||||
* and the row's key.
|
||||
*/
|
||||
export interface AvatarItem extends Record<string, unknown> {
|
||||
AvatarItemType: number | null
|
||||
AvatarItemDesc: string
|
||||
PlatformMask: number
|
||||
FriendlyName: string
|
||||
Tooltip: string
|
||||
Rarity: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant an item into a player's inventory. Upserts on (account_id, avatar_item_desc):
|
||||
* owning an item is boolean, so re-buying it refreshes the stored DTO rather than
|
||||
* adding a second copy. The descriptor is stored verbatim, commas included — the client
|
||||
* expects the full comma-delimited form back.
|
||||
*/
|
||||
export async function grantItem(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
item: AvatarItem
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO inventory (account_id, avatar_item_desc, data) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT (account_id, avatar_item_desc) DO UPDATE SET data = ?3`
|
||||
)
|
||||
.bind(accountId, item.AvatarItemDesc, JSON.stringify(item))
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Every avatar item a player owns, ordered by item guid for a stable listing. */
|
||||
export async function getInventory(db: D1Database, accountId: number): Promise<AvatarItem[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM inventory WHERE account_id = ?1 ORDER BY avatar_item_desc')
|
||||
.bind(accountId)
|
||||
.all<{ data: string }>()
|
||||
return results.map((r) => JSON.parse(r.data) as AvatarItem)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the econ worker.
|
||||
*
|
||||
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
|
||||
* rationale as the auth/accounts/match workers: a reverse-engineered protocol, lenient
|
||||
* handlers, no runtime validation.
|
||||
*
|
||||
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
|
||||
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
|
||||
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
|
||||
* schema inline, which renders correctly in any tool.
|
||||
*/
|
||||
|
||||
/** Emit a zod schema as an `application/json` response body. */
|
||||
export function json(schema: z.ZodType, description: string) {
|
||||
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||
}
|
||||
|
||||
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
|
||||
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||
return jsonSchema as OpenAPIV3_1.SchemaObject
|
||||
}
|
||||
|
||||
/** A form-urlencoded / multipart request body (the client posts both). */
|
||||
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
const s = toOpenApiSchema(schema)
|
||||
return {
|
||||
description,
|
||||
content: {
|
||||
'application/x-www-form-urlencoded': { schema: s },
|
||||
'multipart/form-data': { schema: s },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** An `application/json` request body. */
|
||||
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
|
||||
}
|
||||
|
||||
/** The empty-body 401 the auth-gated routes return. */
|
||||
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
|
||||
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
export const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
// ---- Loose shapes ----------------------------------------------------------
|
||||
// Several routes serve opaque static catalogs (avatar items, the weekly challenge) or
|
||||
// empty-list stubs. Modelling every catalog field adds noise without value, so these
|
||||
// use deliberately loose schemas.
|
||||
|
||||
/** An opaque JSON object (a catalog entry, an avatar blob, …). */
|
||||
export const JsonObject = z.record(z.string(), z.unknown())
|
||||
/** An opaque JSON array (a static catalog served verbatim). */
|
||||
export const JsonArray = z.array(z.unknown())
|
||||
|
||||
// ---- Response schemas ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The public avatar render subset (`GET /api/avatar/v2/:id`) — the fields needed to
|
||||
* draw another player's avatar. The stored blob also holds OutfitSelectionsV2 /
|
||||
* CustomAvatarItems, which this view omits.
|
||||
*/
|
||||
export const AvatarV2Dto = z.object({
|
||||
OutfitSelections: z.unknown(),
|
||||
FaceFeatures: z.unknown(),
|
||||
SkinColor: z.unknown(),
|
||||
HairColor: z.unknown(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The `{ error, success, value }` envelope both consume routes return. Always HTTP 200,
|
||||
* even for a missing/already-gone target — the client parses this to finish the action,
|
||||
* so a bare 200 reads as a failure.
|
||||
*/
|
||||
export const ConsumeEnvelope = z.object({
|
||||
error: z.string(),
|
||||
success: z.boolean(),
|
||||
value: z.null(),
|
||||
})
|
||||
|
||||
/** One currency balance entry (`GET /api/storefronts/v4/balance/:currencyType`). */
|
||||
export const BalanceEntry = z.object({
|
||||
CurrencyType: z.int(),
|
||||
Platform: z.int().describe('-2 = all platforms (account-wide)'),
|
||||
Balance: z.int(),
|
||||
})
|
||||
|
||||
/** `GET /econ/customAvatarItems/v1/owned` — paginated owned custom items. */
|
||||
export const CustomAvatarItemsResponse = z.object({
|
||||
Results: JsonArray,
|
||||
TotalResults: z.int(),
|
||||
})
|
||||
|
||||
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both fields null (no subs yet). */
|
||||
export const SubscriptionResponse = z.object({
|
||||
subscription: z.null(),
|
||||
platformAccountSubscribedPlayerId: z.null(),
|
||||
})
|
||||
|
||||
/** `POST /api/challenge/v2/updateProgress` — the identifying fields echoed back. */
|
||||
export const ChallengeProgressResponse = z.object({
|
||||
ChallengeMapId: z.int(),
|
||||
ChallengeId: z.int(),
|
||||
Config: z.string(),
|
||||
Complete: z.boolean().describe('Always false — no challenge-progress store yet'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/storefronts/v2/buyItem` — the purchase result. `Balance` is the CHANGE
|
||||
* applied (the negated price), not the resulting total; the client reads its new total
|
||||
* from `GET /balance/:type`. `BalanceType` -2 is account-wide. Each `Data` entry is the
|
||||
* gift-drop the recipient received.
|
||||
*/
|
||||
export const BuyItemResponse = z.object({
|
||||
BalanceUpdates: z.array(
|
||||
z.object({
|
||||
UpdateResponse: z.int(),
|
||||
Data: z.array(JsonObject).describe('The gift-drop(s) granted'),
|
||||
})
|
||||
),
|
||||
Balance: z.int().describe('The change applied (negated price), not the new total'),
|
||||
CurrencyType: z.int(),
|
||||
BalanceType: z.int().describe('-2 = account-wide'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/storefronts/v2/buyInvention` — the purchase result. 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({
|
||||
BalanceUpdateResponse: z.object({
|
||||
Balance: z.int().describe('The resulting balance — NOT the change, unlike buyItem'),
|
||||
BalanceType: z.int().describe('-2 = account-wide'),
|
||||
CurrencyType: z.int().describe('2 = RecCenterTokens'),
|
||||
BalanceUpdates: z.array(
|
||||
z.object({
|
||||
UpdateResponse: z.int(),
|
||||
Data: JsonObject.describe('The bought invention (`RRInvention`)'),
|
||||
})
|
||||
),
|
||||
}),
|
||||
InventionResponse: z
|
||||
.object({
|
||||
Status: z.int(),
|
||||
Invention: JsonObject,
|
||||
InventionVersion: JsonObject,
|
||||
})
|
||||
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
|
||||
})
|
||||
|
||||
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
|
||||
export const ErrorResponse = z.object({ error: z.string() })
|
||||
|
||||
// ---- Request schemas -------------------------------------------------------
|
||||
|
||||
/** `POST /api/storefronts/v2/buyItem` JSON body. */
|
||||
export const BuyItemRequest = z.object({
|
||||
StorefrontType: z.int().describe('Which storefront catalog (sf{N}.json)'),
|
||||
PurchasableItemId: z.int(),
|
||||
CurrencyType: z.int().describe('Must be a spendable account currency'),
|
||||
RequestedPrice: z.int().describe('The price the client rendered; a mismatch is 409'),
|
||||
Gift: z
|
||||
.object({
|
||||
ToPlayerId: z.int().optional(),
|
||||
Anonymous: z.boolean().optional(),
|
||||
Message: z.string().optional(),
|
||||
GiftContext: z.int().optional(),
|
||||
})
|
||||
.optional()
|
||||
.describe('Present when buying for another player; the caller still pays'),
|
||||
})
|
||||
|
||||
/** `POST /api/consumables/v1/consume` JSON body. */
|
||||
export const ConsumeConsumableRequest = z.object({
|
||||
Id: z.int().describe('The consumable row id to spend from'),
|
||||
DeltaCount: z.int().optional().describe('How many to spend; defaults to 1'),
|
||||
})
|
||||
|
||||
/** `POST /api/avatar/v2/gifts/consume` form body (posted with a trailing slash). */
|
||||
export const ConsumeGiftRequest = z.object({
|
||||
Id: z.string().describe('The gift-box id to open'),
|
||||
UnlockedLevel: z.string().optional().describe('Consumable-level hint; unused'),
|
||||
})
|
||||
|
||||
/** `POST /api/challenge/v2/updateProgress` JSON body. */
|
||||
export const ChallengeProgressRequest = z.object({
|
||||
ChallengeMapId: z.union([z.string(), z.int()]).optional(),
|
||||
ChallengeId: z.union([z.string(), z.int()]).optional(),
|
||||
Config: z.string().optional().describe('The client-evaluated rule tree'),
|
||||
})
|
||||
|
||||
/** `POST /api/avatar/v3/saved/set` JSON body — an outfit with a target `Slot`. */
|
||||
export const SaveOutfitRequest = z
|
||||
.object({ Slot: z.int().describe('Which slot to overwrite; a non-integer is 400') })
|
||||
.catchall(z.unknown())
|
||||
.describe('Plus opaque outfit fields (OutfitSelectionsV2, FaceFeatures, …) stored verbatim')
|
||||
|
||||
/**
|
||||
* `POST /api/avatar/v4/saved/set` response — a lean acknowledgement. Unlike v3 (which
|
||||
* echoes the whole outfit), v4 answers just the success flag and the slot it wrote.
|
||||
*/
|
||||
export const SaveOutfitV4Response = z.object({
|
||||
Success: z.boolean(),
|
||||
Slot: z.int().describe('The slot that was written'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /api/equipment/v1/update` JSON body — the client's favourite toggles. It echoes
|
||||
* back the whole entry it was served, but only `Favorited` is written; the rest is
|
||||
* ignored (as on the reference server).
|
||||
*/
|
||||
export const EquipmentUpdateRequest = z.array(
|
||||
z
|
||||
.object({
|
||||
ModificationGuid: z.string().describe('Identifies the owned equipment row'),
|
||||
Favorited: z.boolean(),
|
||||
})
|
||||
.catchall(z.unknown())
|
||||
.describe('Plus the echoed-back PrefabName / FriendlyName / Tooltip / Rarity, all ignored')
|
||||
)
|
||||
|
||||
/** An opaque JSON body stored verbatim (the avatar blob for `POST /api/avatar/v2/set`). */
|
||||
export const OpaqueJsonBody = JsonObject.describe('Stored verbatim and echoed back')
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user