mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
Compare commits
43 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 |
@@ -64,3 +64,16 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# 0 means players start broke. Applies only to players who haven't been granted yet —
|
# 0 means players start broke. Applies only to players who haven't been granted yet —
|
||||||
# raising it later does NOT top up existing players.
|
# raising it later does NOT top up existing players.
|
||||||
# RECFLARE_STARTING_TOKENS=10000
|
# 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
|
||||||
@@ -70,6 +70,11 @@ inconsistency here without checking the client first.
|
|||||||
- A club's `AdditionalImages` (`clubs`) is an array of whole `SavedImage` records, not
|
- 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
|
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.
|
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
|
- 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
|
`{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old
|
||||||
clubhouse on screen until it answered the full details envelope.
|
clubhouse on screen until it answered the full details envelope.
|
||||||
|
|||||||
+83
-6
@@ -43,10 +43,12 @@ services but would require small code changes.
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- node (modern)
|
**You must have all these requirements or RecFlare deployment will fail!**
|
||||||
- pnpm
|
|
||||||
- bun
|
- node 24 (https://nodejs.org)
|
||||||
- jq/awk/sed
|
- 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.
|
- 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
|
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
|
just install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You do not have to use `just` but you will have to run things manually with `pnpm`/`bun`.
|
||||||
|
|
||||||
**Configure your custom domain:**
|
**Configure your custom domain:**
|
||||||
|
|
||||||
Create a new .env file from the template:
|
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
|
(Optional) - per-app subdomain overrides come from
|
||||||
`RECFLARE_SUBDOMAINS` (a JSON object, e.g. `'{"playersettings":"settings"}'`). This would be used
|
`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:**
|
**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`
|
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
|
files carry `"local"` placeholders; the real IDs are spliced in at deploy time, so
|
||||||
nothing in version control needs editing. Authenticate wrangler first
|
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
|
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!
|
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
|
||||||
|
|
||||||
```bash
|
```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
|
> `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.
|
> 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
|
## Repository Structure
|
||||||
|
|
||||||
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
|
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
|
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
|
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
|
||||||
Cloudflare Workers. It implements the network services the Rec Room client talks
|
Cloudflare Workers. It implements the network services the Rec Room client talks
|
||||||
to — accounts, auth, rooms, matchmaking, economy, chat, notifications, and more —
|
to — accounts, auth, rooms, matchmaking, economy, chat, notifications, and more —
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
import { describeRoute, openAPIRouteHandler, validator } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
searchAccounts,
|
searchAccounts,
|
||||||
updateAccount,
|
updateAccount,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -69,12 +69,18 @@ function unauthorized(c: Context<App>) {
|
|||||||
const DEFAULT_USERNAME_CHANGES = 1
|
const DEFAULT_USERNAME_CHANGES = 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
|
* Username-change result envelope: `{ success, error, value }`. On success `value` is
|
||||||
* On success `value` is the updated account; on error `error` carries the message
|
* the updated account; on a refusal `error` carries the message and `value` is an empty
|
||||||
* and `value` is an empty string.
|
* 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 = '') {
|
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. */
|
/** Read a single string field from a form-urlencoded / multipart body. */
|
||||||
@@ -112,7 +118,9 @@ function toSelfAccountDto(account: Account) {
|
|||||||
return {
|
return {
|
||||||
...toAccountDto(account),
|
...toAccountDto(account),
|
||||||
email: account.email ?? null,
|
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,
|
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,6 +167,14 @@ const app = new Hono<App>()
|
|||||||
})(c, next)
|
})(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())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
@@ -409,18 +425,20 @@ const app = new Hono<App>()
|
|||||||
summary: 'Set display name',
|
summary: 'Set display name',
|
||||||
description: 'Persisted and broadcast via an AccountUpdate notification.',
|
description: 'Persisted and broadcast via an AccountUpdate notification.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(DisplayNameRequest, 'The new display name'),
|
|
||||||
responses: {
|
responses: {
|
||||||
200: json(SuccessResponse, 'Updated'),
|
200: json(SuccessResponse, 'Updated'),
|
||||||
400: { description: 'Empty display name (empty body)' },
|
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
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) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const displayName = (await formField(c, 'displayName')).trim()
|
const { displayName } = c.req.valid('form')
|
||||||
if (displayName === '') return c.body(null, 400)
|
|
||||||
const account = await updateAccount(c.env.DB, id, { displayName })
|
const account = await updateAccount(c.env.DB, id, { displayName })
|
||||||
await pushAccountUpdate(c, account)
|
await pushAccountUpdate(c, account)
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
@@ -436,23 +454,34 @@ const app = new Hono<App>()
|
|||||||
tags: ['Profile'],
|
tags: ['Profile'],
|
||||||
summary: 'Change username',
|
summary: 'Change username',
|
||||||
description: [
|
description: [
|
||||||
'Rejects a name taken by another account and requires a remaining change; on',
|
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
|
||||||
'success the name is persisted and the counter decremented. Always HTTP 200 —',
|
'account and requires a remaining change; on success the name is persisted and',
|
||||||
'failures carry a message in `error` (see the UsernameResult envelope).',
|
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
|
||||||
|
'(see the UsernameResult envelope).',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(UsernameRequest, 'The desired username'),
|
|
||||||
responses: {
|
responses: {
|
||||||
200: json(UsernameResult, 'Result envelope (success or a validation error)'),
|
200: json(UsernameResult, 'The updated account, in the result envelope'),
|
||||||
|
400: json(UsernameResult, 'Refused — `error` carries the reason, `value` is ""'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
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) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
|
|
||||||
const username = (await formField(c, 'username')).trim()
|
const { username } = c.req.valid('form')
|
||||||
if (username === '') return usernameResult(c, 'You must enter a username.')
|
|
||||||
|
|
||||||
// Duplicate check first (case-insensitive); keeping your own name is allowed.
|
// Duplicate check first (case-insensitive); keeping your own name is allowed.
|
||||||
const existing = await getAccountByUsername(c.env.DB, username)
|
const existing = await getAccountByUsername(c.env.DB, username)
|
||||||
@@ -484,18 +513,17 @@ const app = new Hono<App>()
|
|||||||
summary: 'Set email',
|
summary: 'Set email',
|
||||||
description: 'Persisted; surfaced only by `/account/me`. Not broadcast.',
|
description: 'Persisted; surfaced only by `/account/me`. Not broadcast.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(EmailRequest, 'The new email'),
|
|
||||||
responses: {
|
responses: {
|
||||||
200: json(SuccessResponse, 'Updated'),
|
200: json(SuccessResponse, 'Updated'),
|
||||||
400: { description: 'Email without an “@” (empty body)' },
|
400: { description: 'Not a syntactically valid address (empty body)' },
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
validator('form', EmailRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const email = (await formField(c, 'email')).trim()
|
const { email } = c.req.valid('form')
|
||||||
if (!email.includes('@')) return c.body(null, 400)
|
|
||||||
await updateAccount(c.env.DB, id, { email })
|
await updateAccount(c.env.DB, id, { email })
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
}
|
}
|
||||||
@@ -509,18 +537,17 @@ const app = new Hono<App>()
|
|||||||
summary: 'Set phone number',
|
summary: 'Set phone number',
|
||||||
description: 'Persisted on the account row. Not broadcast.',
|
description: 'Persisted on the account row. Not broadcast.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(PhoneRequest, 'The new phone number'),
|
|
||||||
responses: {
|
responses: {
|
||||||
200: json(SuccessResponse, 'Updated'),
|
200: json(SuccessResponse, 'Updated'),
|
||||||
400: { description: 'Empty phone (empty body)' },
|
400: { description: 'Empty phone (empty body)' },
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
validator('form', PhoneRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const phone = (await formField(c, 'phone')).trim()
|
const { phone } = c.req.valid('form')
|
||||||
if (phone === '') return c.body(null, 400)
|
|
||||||
await updateAccount(c.env.DB, id, { phone })
|
await updateAccount(c.env.DB, id, { phone })
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
}
|
}
|
||||||
@@ -595,18 +622,20 @@ const app = new Hono<App>()
|
|||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Profile'],
|
tags: ['Profile'],
|
||||||
summary: 'Set bio',
|
summary: 'Set bio',
|
||||||
description: 'Free text; empty is allowed. Persisted and broadcast.',
|
description: 'Free text up to 255 characters; empty is allowed. Persisted and broadcast.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(BioRequest, 'The new bio'),
|
|
||||||
responses: {
|
responses: {
|
||||||
200: json(SuccessResponse, 'Updated'),
|
200: json(SuccessResponse, 'Updated'),
|
||||||
|
400: { description: 'Bio over 255 characters (empty body)' },
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
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) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const bio = await formField(c, 'bio')
|
const { bio } = c.req.valid('form')
|
||||||
const account = await updateAccount(c.env.DB, id, { bio })
|
const account = await updateAccount(c.env.DB, id, { bio })
|
||||||
await pushAccountUpdate(c, account)
|
await pushAccountUpdate(c, account)
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
import { resolver } from 'hono-openapi'
|
import { resolver } from 'hono-openapi'
|
||||||
import { z } from 'zod'
|
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'
|
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenAPI schemas for the accounts worker.
|
* OpenAPI schemas for the accounts worker.
|
||||||
*
|
*
|
||||||
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
* Most of these are DESCRIPTIVE ONLY: they are passed to `describeRoute` to generate the
|
||||||
* generate the spec and are never wired into `hono-openapi`'s `validator()`.
|
* 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.
|
||||||
*
|
*
|
||||||
* As with the auth worker, this is deliberate. The Rec Room client is the only real
|
* The EXCEPTION is the profile mutations a player types into a box — displayName,
|
||||||
* consumer and the handlers are intentionally lenient — form fields are read as
|
* username, email, phone, bio. Those carry real rules (see `@repo/domain`), and each is
|
||||||
* `typeof value === 'string' ? value : ''` and missing/malformed input falls through
|
* wired into `hono-openapi`'s `validator()` per route, with tests, exactly as the older
|
||||||
* to a graceful path (or a synthesized default account) rather than a hard error.
|
* version of this note prescribed. Wiring one up means the schema both validates the
|
||||||
* These schemas record what the client is observed to send and what we send back; to
|
* request and generates the spec, so a limit can't be changed in one and not the other —
|
||||||
* enforce one, do it per-route and land a test with it.
|
* 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. */
|
/** Emit a zod schema as an `application/json` response body. */
|
||||||
@@ -122,21 +137,54 @@ export const CreateAccountRequest = z.object({
|
|||||||
platformId: z.string().optional().describe('Parsed for fidelity; currently unused'),
|
platformId: z.string().optional().describe('Parsed for fidelity; currently unused'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Single-string form bodies, one per profile mutation. */
|
/**
|
||||||
|
* 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({
|
export const DisplayNameRequest = z.object({
|
||||||
displayName: z.string().describe('Trimmed; empty is rejected (400)'),
|
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({
|
export const UsernameRequest = z.object({
|
||||||
username: z.string().describe('Trimmed; must be unique and changes must remain'),
|
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({
|
export const EmailRequest = z.object({
|
||||||
email: z.string().describe('Must contain "@"; otherwise 400'),
|
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({
|
export const PhoneRequest = z.object({
|
||||||
phone: z.string().describe('Trimmed; empty is rejected (400)'),
|
// 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({
|
export const IdentityFlagsRequest = z.object({
|
||||||
@@ -147,7 +195,10 @@ export const PronounsRequest = z.object({
|
|||||||
pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
|
pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const BioRequest = z.object({ bio: z.string().describe('Free text; empty is allowed') })
|
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({
|
export const ProfileImageRequest = z.object({
|
||||||
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
|
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
|
||||||
|
|||||||
@@ -220,8 +220,9 @@ describe('auth-gated endpoints', () => {
|
|||||||
...form({ username: 'Coach' }),
|
...form({ username: 'Coach' }),
|
||||||
headers: { ...(await bearer('893')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { ...(await bearer('893')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
})
|
})
|
||||||
// Business errors are HTTP 200 with the { success, error, value } envelope.
|
// A refusal is a 400 carrying the same { success, error, value } envelope. It used
|
||||||
expect(res.status).toBe(200)
|
// 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 }
|
const body = (await res.json()) as { success: boolean; error: string; value: string }
|
||||||
expect(body.success).toBe(false)
|
expect(body.success).toBe(false)
|
||||||
expect(body.error).toMatch(/already taken/i)
|
expect(body.error).toMatch(/already taken/i)
|
||||||
@@ -260,7 +261,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
...form({ username: 'coachy' }),
|
...form({ username: 'coachy' }),
|
||||||
headers,
|
headers,
|
||||||
})
|
})
|
||||||
expect(blocked.status).toBe(200)
|
expect(blocked.status).toBe(400)
|
||||||
const blockedBody = (await blocked.json()) as { success: boolean; error: string }
|
const blockedBody = (await blocked.json()) as { success: boolean; error: string }
|
||||||
expect(blockedBody.success).toBe(false)
|
expect(blockedBody.success).toBe(false)
|
||||||
expect(blockedBody.error).toMatch(/no username changes/i)
|
expect(blockedBody.error).toMatch(/no username changes/i)
|
||||||
@@ -459,4 +460,178 @@ describe('auth-gated endpoints', () => {
|
|||||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
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;
|
||||||
+14
-4
@@ -2,10 +2,11 @@ import { Hono } from 'hono'
|
|||||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
|
|
||||||
import { avatarRoutes } from './routes/avatar'
|
import { avatarRoutes } from './routes/avatar'
|
||||||
import { configRoutes } from './routes/config'
|
import { configRoutes } from './routes/config'
|
||||||
|
import { eventRoutes } from './routes/events'
|
||||||
import { gameplayRoutes } from './routes/gameplay'
|
import { gameplayRoutes } from './routes/gameplay'
|
||||||
import { imageRoutes } from './routes/images'
|
import { imageRoutes } from './routes/images'
|
||||||
import { inventoryRoutes } from './routes/inventory'
|
import { inventoryRoutes } from './routes/inventory'
|
||||||
@@ -39,6 +40,14 @@ const app = new Hono<App>({ strict: false })
|
|||||||
})(c, next)
|
})(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())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
@@ -48,6 +57,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
.route('/', progressionRoutes)
|
.route('/', progressionRoutes)
|
||||||
.route('/', avatarRoutes)
|
.route('/', avatarRoutes)
|
||||||
.route('/', gameplayRoutes)
|
.route('/', gameplayRoutes)
|
||||||
|
.route('/', eventRoutes)
|
||||||
.route('/', moderationRoutes)
|
.route('/', moderationRoutes)
|
||||||
.route('/', inventoryRoutes)
|
.route('/', inventoryRoutes)
|
||||||
.route('/', roomRoutes)
|
.route('/', roomRoutes)
|
||||||
@@ -68,9 +78,9 @@ app.get(
|
|||||||
'The catch-all Game API for recflare, a private-server reimplementation of the Rec',
|
'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',
|
'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,',
|
'worker yet. Today that is config, the friend graph, inventions, saved photos,',
|
||||||
'reputation and the assorted sinks the client hits while loading. Relationships,',
|
'player events, reputation and the assorted sinks the client hits while loading.',
|
||||||
'inventions and images are D1-backed; several endpoints are still stubs, noted per',
|
'Relationships, inventions, images and player events are D1-backed; several',
|
||||||
'route.',
|
'endpoints are still stubs, noted per route.',
|
||||||
'',
|
'',
|
||||||
'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,',
|
'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',
|
'equipment, consumables and objectives on `econ`) are already served there — the',
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export type Env = SharedHonoEnv & {
|
|||||||
// Image bucket (shared with the `img` worker, which serves objects back by
|
// Image bucket (shared with the `img` worker, which serves objects back by
|
||||||
// key). Uploaded saved images are written here.
|
// key). Uploaded saved images are written here.
|
||||||
IMAGES: R2Bucket
|
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
|
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
|
||||||
// push RelationshipChanged notifications when a player's relationship changes.
|
// push RelationshipChanged notifications when a player's relationship changes.
|
||||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+11
-1
@@ -1,4 +1,4 @@
|
|||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from './context'
|
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())
|
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. */
|
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
||||||
export function unauthorized(c: Context<App>) {
|
export function unauthorized(c: Context<App>) {
|
||||||
return c.body(null, 401)
|
return c.body(null, 401)
|
||||||
|
|||||||
@@ -299,8 +299,15 @@ export function toImagesPlayer(img: SavedImage): ImagesPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default number of recent images the slideshow feed returns. */
|
/** How many recent images the slideshow feed returns when the caller doesn't say. */
|
||||||
export const SLIDESHOW_LIMIT = 130
|
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. */
|
/** The slideshow projection of an image — creator username + room name joined in. */
|
||||||
export interface SlideshowImage {
|
export interface SlideshowImage {
|
||||||
|
|||||||
+145
-36
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Saved-invention storage on the shared `recflare` D1 database. Each invention is
|
* Saved-invention storage on the shared `recflare` D1 database. Each invention is
|
||||||
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId)
|
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId, the
|
||||||
* are SQLite generated (virtual) columns extracted from that JSON — the same
|
* visibility flags) are SQLite generated (virtual) columns extracted from that JSON —
|
||||||
* JSON-blob pattern the image/rooms/accounts tables use.
|
* the same JSON-blob pattern the image/rooms/accounts tables use.
|
||||||
*
|
*
|
||||||
* The `api` worker owns this schema/migration (migrations/0002_invention.sql,
|
* The `api` worker owns this schema/migration (migrations/0002_invention.sql,
|
||||||
* applied under its own `migrations_table`). The invention's data file itself is
|
* 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 stored/returned DTO mirrors Rec Room's `RRInvention` (PascalCase), including
|
||||||
* the nested `CurrentVersion` that carries the blob name and per-version costs —
|
* the nested `CurrentVersion` that carries the blob name and per-version costs —
|
||||||
* shaped after a real `GET /api/inventions/v1?inventionId=…` response.
|
* 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,
|
* 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
|
* 0008_invention_visibility.sql, sans any seed rows). `is_featured` backs the featured
|
||||||
* of a JSON `true` is 1, so the column is 1/0.
|
* 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[] = [
|
export const SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS invention (
|
`CREATE TABLE IF NOT EXISTS invention (
|
||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.InventionId')) VIRTUAL,
|
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.InventionId')) VIRTUAL,
|
||||||
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) 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 UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
|
||||||
@@ -130,6 +140,37 @@ function inventionBlobName(filename: string): string {
|
|||||||
return filename.toLowerCase().endsWith('.inv') ? filename : `${filename}.inv`
|
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.
|
* Fields the client supplies on save (camelCase); everything else is defaulted here.
|
||||||
* `inventionDataFilename` is the one the caller must supply — an invention with no
|
* `inventionDataFilename` is the one the caller must supply — an invention with no
|
||||||
@@ -163,6 +204,7 @@ export interface NewInvention {
|
|||||||
*/
|
*/
|
||||||
export async function createInvention(
|
export async function createInvention(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
|
bucket: R2Bucket,
|
||||||
input: NewInvention
|
input: NewInvention
|
||||||
): Promise<SavedInvention> {
|
): Promise<SavedInvention> {
|
||||||
// Sequential id: one past the current max (the table starts empty).
|
// Sequential id: one past the current max (the table starts empty).
|
||||||
@@ -171,6 +213,7 @@ export async function createInvention(
|
|||||||
.first<{ next: number }>()
|
.first<{ next: number }>()
|
||||||
const inventionId = row?.next ?? 1
|
const inventionId = row?.next ?? 1
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
const blobName = inventionBlobName(input.inventionDataFilename)
|
||||||
const invention: SavedInvention = {
|
const invention: SavedInvention = {
|
||||||
InventionId: inventionId,
|
InventionId: inventionId,
|
||||||
ReplicationId: crypto.randomUUID(),
|
ReplicationId: crypto.randomUUID(),
|
||||||
@@ -183,8 +226,8 @@ export async function createInvention(
|
|||||||
InventionId: inventionId,
|
InventionId: inventionId,
|
||||||
ReplicationId: crypto.randomUUID(),
|
ReplicationId: crypto.randomUUID(),
|
||||||
VersionNumber: 1,
|
VersionNumber: 1,
|
||||||
BlobName: inventionBlobName(input.inventionDataFilename),
|
BlobName: blobName,
|
||||||
BlobHash: null,
|
BlobHash: await inventionBlobHash(bucket, blobName),
|
||||||
InstantiationCost: input.instantiationCost ?? 0,
|
InstantiationCost: input.instantiationCost ?? 0,
|
||||||
LightsCost: input.lightsCost ?? 0,
|
LightsCost: input.lightsCost ?? 0,
|
||||||
ChipsCost: input.chipsCost ?? 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)
|
.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 search — the browse/search list the client shows when picking an
|
||||||
* invention to spawn. Only published, non-hidden inventions are visible here (a
|
* 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.
|
* ones via the indexed `is_featured` column.
|
||||||
*/
|
*/
|
||||||
async function publicInventions(db: D1Database, featuredOnly = false): Promise<SavedInvention[]> {
|
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
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT data FROM invention
|
`SELECT data FROM invention
|
||||||
WHERE json_extract(data, '$.IsPublished') = 1
|
WHERE is_published = 1
|
||||||
AND json_extract(data, '$.HideFromPlayer') = 0
|
AND hide_from_player = 0
|
||||||
${featuredOnly ? 'AND is_featured = 1' : ''}`
|
${featuredOnly ? 'AND is_featured = 1' : ''}`
|
||||||
)
|
)
|
||||||
.all<InventionRow>()
|
.all<InventionRow>()
|
||||||
return results.map((r) => JSON.parse(r.data) as SavedInvention)
|
return results.map((r) => JSON.parse(r.data) as SavedInvention)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Engagement score used to rank the top feed (downloads weigh most, then cheers). */
|
/** Length of the "today" window — a trailing day, not the calendar one. */
|
||||||
function topScore(invention: SavedInvention): number {
|
const TOP_TODAY_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||||
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
|
|
||||||
return (
|
/** 24 hours ago, as the ISO timestamp `acquired_at` is compared against. */
|
||||||
n(invention.NumDownloads) * 3 +
|
function startOfWindow(): string {
|
||||||
n(invention.CheerCount) * 2 +
|
return new Date(Date.now() - TOP_TODAY_WINDOW_MS).toISOString()
|
||||||
n(invention.NumPlayersHaveUsedInRoom)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "top today" feed — published inventions ranked by engagement. The real feed
|
* The "top today" feed — the inventions other players picked up in the last 24 hours,
|
||||||
* ranks by *today's* activity; we don't track per-day counters, so this ranks by
|
* most first.
|
||||||
* 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.
|
* 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(
|
export async function getTopInventions(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
skip: number,
|
skip: number,
|
||||||
take: number
|
take: number
|
||||||
): Promise<SavedInvention[]> {
|
): Promise<SavedInvention[]> {
|
||||||
const inventions = await publicInventions(db)
|
const counts = await getInventionAcquisitionCounts(db, startOfWindow())
|
||||||
return inventions
|
if (counts.length === 0) return []
|
||||||
.sort((a, b) => topScore(b) - topScore(a) || b.InventionId - a.InventionId)
|
|
||||||
.slice(skip, skip + take)
|
// 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.
|
* The featured feed — published inventions flagged `IsFeatured`, newest first.
|
||||||
* Selected on the indexed `is_featured` column rather than by parsing every public
|
* 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
|
* invention.
|
||||||
* than handing the client an empty shelf; once inventions are curated it serves them.
|
*
|
||||||
|
* 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(
|
export async function getFeaturedInventions(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -322,7 +414,6 @@ export async function getFeaturedInventions(
|
|||||||
take: number
|
take: number
|
||||||
): Promise<SavedInvention[]> {
|
): Promise<SavedInvention[]> {
|
||||||
const featured = await publicInventions(db, true)
|
const featured = await publicInventions(db, true)
|
||||||
if (featured.length === 0) return getTopInventions(db, skip, take)
|
|
||||||
return featured
|
return featured
|
||||||
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
||||||
.slice(skip, skip + take)
|
.slice(skip, skip + take)
|
||||||
@@ -546,8 +637,8 @@ export async function getInventionsByRoom(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT data FROM invention
|
`SELECT data FROM invention
|
||||||
WHERE json_extract(data, '$.CreationRoomId') = ?1
|
WHERE json_extract(data, '$.CreationRoomId') = ?1
|
||||||
AND json_extract(data, '$.IsPublished') = 1
|
AND is_published = 1
|
||||||
AND json_extract(data, '$.HideFromPlayer') = 0`
|
AND hide_from_player = 0`
|
||||||
)
|
)
|
||||||
.bind(roomId)
|
.bind(roomId)
|
||||||
.all<InventionRow>()
|
.all<InventionRow>()
|
||||||
@@ -568,20 +659,38 @@ export async function getInventionsByRoom(
|
|||||||
*/
|
*/
|
||||||
export async function getInventionVersion(
|
export async function getInventionVersion(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
|
bucket: R2Bucket,
|
||||||
inventionId: number,
|
inventionId: number,
|
||||||
versionNumber: number
|
versionNumber: number
|
||||||
): Promise<InventionVersion | null> {
|
): Promise<InventionVersion | null> {
|
||||||
const invention = await getInventionById(db, inventionId)
|
const invention = await getInventionById(db, inventionId)
|
||||||
if (invention === null) return null
|
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. */
|
/** Persist an edited invention record, bumping ModifiedAt. */
|
||||||
async function writeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
|
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
|
await db
|
||||||
.prepare('UPDATE invention SET data = ?1 WHERE id = ?2')
|
.prepare('UPDATE invention SET data = ?1 WHERE id = ?2')
|
||||||
.bind(JSON.stringify(updated), invention.InventionId)
|
.bind(JSON.stringify(invention), invention.InventionId)
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+157
-7
@@ -97,6 +97,16 @@ export const BareString = z.string()
|
|||||||
/** The `{ error }` body the 400 / 403 branches return. */
|
/** The `{ error }` body the 400 / 403 branches return. */
|
||||||
export const ErrorResponse = z.object({ error: z.string() })
|
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 ----------------------------------------------------------------
|
// ---- Config ----------------------------------------------------------------
|
||||||
|
|
||||||
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
|
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
|
||||||
@@ -160,9 +170,34 @@ export const RelationshipDto = z.object({
|
|||||||
Muted: 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. */
|
/** The `{ Success, Message }` ack the flag toggles answer with. */
|
||||||
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
|
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 -----------------------------------------------------------
|
// ---- Progression -----------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -206,7 +241,10 @@ export const InventionVersionDto = z.object({
|
|||||||
ReplicationId: z.string(),
|
ReplicationId: z.string(),
|
||||||
VersionNumber: z.int(),
|
VersionNumber: z.int(),
|
||||||
BlobName: z.string().describe('The `.inv` key in the storage worker‘s bucket'),
|
BlobName: z.string().describe('The `.inv` key in the storage worker‘s bucket'),
|
||||||
BlobHash: z.string().nullable(),
|
BlobHash: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.describe('Base64 SHA-256 of the blob; null when it was never uploaded'),
|
||||||
InstantiationCost: z.int(),
|
InstantiationCost: z.int(),
|
||||||
LightsCost: z.int(),
|
LightsCost: z.int(),
|
||||||
ChipsCost: z.int(),
|
ChipsCost: z.int(),
|
||||||
@@ -282,8 +320,14 @@ export const InventionPersonalDetails = z.object({
|
|||||||
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
|
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
|
||||||
export const SetTagsRequest = z.object({
|
export const SetTagsRequest = z.object({
|
||||||
InventionId: z.int(),
|
InventionId: z.int(),
|
||||||
AutoTags: z.array(z.string()).optional().describe('Client-derived tags (Type 2)'),
|
AutoTags: z
|
||||||
CustomTags: z.array(z.string()).optional().describe('Creator-submitted tags (Type 0)'),
|
.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. */
|
/** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */
|
||||||
@@ -303,8 +347,14 @@ export const SaveInventionRequest = z.object({
|
|||||||
inventionDataFilename: z
|
inventionDataFilename: z
|
||||||
.string()
|
.string()
|
||||||
.describe('The blob uploaded through the storage worker; the one required field'),
|
.describe('The blob uploaded through the storage worker; the one required field'),
|
||||||
name: z.string().optional().describe('Defaults to “Untitled”'),
|
name: z
|
||||||
description: z.string().optional(),
|
.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(),
|
imageName: z.string().optional(),
|
||||||
instantiationCost: z.int().optional(),
|
instantiationCost: z.int().optional(),
|
||||||
lightsCost: z.int().optional(),
|
lightsCost: z.int().optional(),
|
||||||
@@ -370,10 +420,68 @@ export const KeepsakeConfig = z.object({
|
|||||||
SocialXpBoostEnabled: z.boolean(),
|
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. */
|
/** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */
|
||||||
export const PlayerEventsAll = z.object({
|
export const PlayerEventsAll = z.object({
|
||||||
Created: JsonArray,
|
Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'),
|
||||||
Responses: JsonArray,
|
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. */
|
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
|
||||||
@@ -408,6 +516,48 @@ export const ModerationBlockDetails = z.object({
|
|||||||
TimeoutStartedAt: z.string().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. */
|
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
|
||||||
export const DeviceIdRequest = z.object({
|
export const DeviceIdRequest = z.object({
|
||||||
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
|
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
|
||||||
|
|||||||
@@ -155,6 +155,48 @@ export async function getRelationshipsForPlayer(
|
|||||||
return results.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)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist `type` for the pair, with `requesterId` recorded as the row's
|
* Persist `type` for the pair, with `requesterId` recorded as the row's
|
||||||
* requester. Inserts a new row or, if one already exists for the pair (either
|
* requester. Inserts a new row or, if one already exists for the pair (either
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+111
-35
@@ -1,17 +1,23 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
import { describeRoute } from 'hono-openapi'
|
||||||
|
|
||||||
|
import {
|
||||||
|
inventionDescriptionRejection,
|
||||||
|
inventionNameRejection,
|
||||||
|
inventionTagRejection,
|
||||||
|
} from '@repo/domain'
|
||||||
|
|
||||||
import { authedId, unauthorized } from '../http'
|
import { authedId, unauthorized } from '../http'
|
||||||
import {
|
import {
|
||||||
createInvention,
|
createInvention,
|
||||||
getFeaturedInventions,
|
getFeaturedInventions,
|
||||||
getInventionById,
|
getInventionById,
|
||||||
getInventionsByCreator,
|
|
||||||
getInventionsByIds,
|
getInventionsByIds,
|
||||||
getInventionsByRoom,
|
getInventionsByRoom,
|
||||||
getInventionTagFilters,
|
getInventionTagFilters,
|
||||||
getInventionTags,
|
getInventionTags,
|
||||||
getInventionVersion,
|
getInventionVersion,
|
||||||
|
getMyInventions,
|
||||||
getTopInventions,
|
getTopInventions,
|
||||||
parsePermissionLevel,
|
parsePermissionLevel,
|
||||||
publishInvention,
|
publishInvention,
|
||||||
@@ -330,18 +336,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
)
|
)
|
||||||
|
|
||||||
// A single version of an invention (`?inventionId=…&version=…`) — the bare
|
// A single version of an invention (`?inventionId=…&version=…`) — the bare
|
||||||
// RRInventionVersion, which carries the blob name the client downloads. Public.
|
// RRInventionVersion, which carries the blob name the client downloads and the
|
||||||
// Only the current version exists (nothing writes version history yet), so any
|
// SHA-256 of that blob. Public. Only the current version exists (nothing writes
|
||||||
// other version number 404s rather than naming a blob that isn't there.
|
// version history yet), so any other version number 404s rather than naming a
|
||||||
|
// blob that isn't there.
|
||||||
.get(
|
.get(
|
||||||
'/api/inventions/v1/version',
|
'/api/inventions/v1/version',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Inventions'],
|
tags: ['Inventions'],
|
||||||
summary: 'One version of an invention',
|
summary: 'One version of an invention',
|
||||||
description:
|
description:
|
||||||
'The bare `RRInventionVersion`, which carries the blob name the client downloads. ' +
|
'The bare `RRInventionVersion`, which carries the blob name the client downloads ' +
|
||||||
'Only the current version exists — nothing writes version history yet — so any ' +
|
'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
|
||||||
'other version number 404s rather than naming a blob that is not there.',
|
'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: [
|
parameters: [
|
||||||
intQuery('inventionId', 'Invention id; required'),
|
intQuery('inventionId', 'Invention id; required'),
|
||||||
intQuery('version', 'Version number; required'),
|
intQuery('version', 'Version number; required'),
|
||||||
@@ -358,7 +367,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
|
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
|
||||||
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
|
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
|
||||||
|
|
||||||
const version = await getInventionVersion(c.env.DB, inventionId, versionNumber)
|
const version = await getInventionVersion(
|
||||||
|
c.env.DB,
|
||||||
|
c.env.CDN_ASSETS,
|
||||||
|
inventionId,
|
||||||
|
versionNumber
|
||||||
|
)
|
||||||
return version === null ? c.notFound() : c.json(version)
|
return version === null ? c.notFound() : c.json(version)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -379,18 +393,20 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
'A GET that writes — that is what the client sends, with the fields to change as ' +
|
'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` ' +
|
'query params. Absent params keep their stored value. An empty `description` ' +
|
||||||
'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' +
|
'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' +
|
||||||
'invention. Publishing and pricing are separate endpoints.',
|
'invention. A supplied name/description must satisfy the same rules `v6/save` ' +
|
||||||
|
'enforces. Publishing and pricing are separate endpoints.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
parameters: [
|
parameters: [
|
||||||
intQuery('inventionId', 'Invention id; required'),
|
intQuery('inventionId', 'Invention id; required'),
|
||||||
stringQuery('name', 'New name; empty is ignored'),
|
stringQuery('name', '3–24 chars, letters/digits/spaces/dashes/colons; empty is ignored'),
|
||||||
stringQuery('description', 'New description; present-but-empty clears it'),
|
stringQuery('description', 'Max 512 chars; present-but-empty clears it'),
|
||||||
stringQuery('imageName', 'New thumbnail; empty is ignored'),
|
stringQuery('imageName', 'New thumbnail; empty is ignored'),
|
||||||
stringQuery('allowTrial', '`true`/`1` to allow trials'),
|
stringQuery('allowTrial', '`true`/`1` to allow trials'),
|
||||||
stringQuery('permission', 'A name like `useonly`, or the raw permission number'),
|
stringQuery('permission', 'A name like `useonly`, or the raw permission number'),
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
|
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
|
||||||
|
400: json(ErrorResponse, 'A supplied name or description breaks its rule'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||||
404: { description: 'No such invention' },
|
404: { description: 'No such invention' },
|
||||||
@@ -408,10 +424,23 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
const allowTrial = c.req.query('allowTrial')
|
const allowTrial = c.req.query('allowTrial')
|
||||||
const permission = c.req.query('permission')
|
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, {
|
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||||||
name: nonEmpty('name'),
|
name,
|
||||||
// Present-but-empty clears the description, so this checks presence.
|
// Present-but-empty clears the description, so this checks presence.
|
||||||
description: c.req.query('description'),
|
description,
|
||||||
imageName: nonEmpty('imageName'),
|
imageName: nonEmpty('imageName'),
|
||||||
allowTrial:
|
allowTrial:
|
||||||
allowTrial === undefined
|
allowTrial === undefined
|
||||||
@@ -515,13 +544,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
'`CustomTags` are the creator’s own (Type 0), `AutoTags` the ones the client ' +
|
'`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 ' +
|
'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' +
|
||||||
'only.\n\n' +
|
'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 ' +
|
'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' +
|
||||||
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
|
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
|
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(SetTagsResponse, 'The resulting tag names'),
|
200: json(SetTagsResponse, 'The resulting tag names'),
|
||||||
400: json(ErrorResponse, 'Unparseable body'),
|
400: json(ErrorResponse, 'Unparseable body, or a tag that breaks the rule'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||||
404: { description: 'No such invention' },
|
404: { description: 'No such invention' },
|
||||||
@@ -538,11 +569,29 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
const strings = (v: unknown): string[] =>
|
const strings = (v: unknown): string[] =>
|
||||||
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
|
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
|
||||||
|
|
||||||
|
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(
|
const tags = await setInventionTags(
|
||||||
c.env.DB,
|
c.env.DB,
|
||||||
gate.invention.InventionId,
|
gate.invention.InventionId,
|
||||||
strings(body.AutoTags),
|
autoTags,
|
||||||
strings(body.CustomTags)
|
customTags
|
||||||
)
|
)
|
||||||
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
|
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
|
||||||
}
|
}
|
||||||
@@ -573,17 +622,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// The "top today" invention feed — published inventions ranked by engagement
|
// The "top today" invention feed — the inventions most acquired in the last 24 hours,
|
||||||
// (lifetime, not per-day: we keep no daily counters). Paginated via skip/take
|
// counted from the purchase rows the `econ` worker writes. A real day window, so an
|
||||||
// (take defaults to 50, as the client asks for). Bare array.
|
// 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(
|
.get(
|
||||||
'/api/inventions/v1/toptoday',
|
'/api/inventions/v1/toptoday',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Inventions'],
|
tags: ['Inventions'],
|
||||||
summary: 'The “top today” feed',
|
summary: 'The “top today” feed',
|
||||||
description:
|
description:
|
||||||
'Published inventions ranked by engagement — lifetime, not per-day: we keep no ' +
|
'Published inventions ranked by how many players acquired them in the last 24 ' +
|
||||||
'daily counters, so “today” is a label, not a window.',
|
'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),
|
parameters: pageParams(50),
|
||||||
responses: { 200: json(InventionDto.array(), 'The top inventions') },
|
responses: { 200: json(InventionDto.array(), 'The top inventions') },
|
||||||
}),
|
}),
|
||||||
@@ -594,16 +647,17 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// The featured invention feed — curated (`IsFeatured`) inventions, falling back
|
// The featured invention feed — the curated (`IsFeatured`) inventions and nothing
|
||||||
// to the top feed while nothing is curated. Bare array, like toptoday.
|
// else, newest first. Empty until someone flags one. Bare array, like toptoday.
|
||||||
.get(
|
.get(
|
||||||
'/api/inventions/v1/featured',
|
'/api/inventions/v1/featured',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Inventions'],
|
tags: ['Inventions'],
|
||||||
summary: 'The featured feed',
|
summary: 'The featured feed',
|
||||||
description:
|
description:
|
||||||
'Curated (`IsFeatured`) inventions, falling back to the top feed while nothing is ' +
|
'Curated (`IsFeatured`) inventions, newest first — published and non-hidden only. ' +
|
||||||
'curated — so this is never empty just because no one has picked favourites.',
|
'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),
|
parameters: pageParams(50),
|
||||||
responses: { 200: json(InventionDto.array(), 'The featured inventions') },
|
responses: { 200: json(InventionDto.array(), 'The featured inventions') },
|
||||||
}),
|
}),
|
||||||
@@ -640,16 +694,20 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// The signed-in player's saved inventions ("my inventions"), newest first.
|
// The signed-in player's invention shelf ("my inventions"), newest first — the ones
|
||||||
// Auth-gated; returns a bare array (empty when the player has saved none).
|
// 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(
|
.get(
|
||||||
'/api/inventions/v2/mine',
|
'/api/inventions/v2/mine',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Inventions'],
|
tags: ['Inventions'],
|
||||||
summary: 'The caller’s own inventions',
|
summary: 'The caller’s own inventions',
|
||||||
description:
|
description:
|
||||||
'“My inventions”, newest first — including unpublished ones, which nobody else can ' +
|
'“My inventions”, newest first — the ones the caller created plus the ones they ' +
|
||||||
'see. Not paginated.',
|
'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,
|
security: AUTHED,
|
||||||
responses: {
|
responses: {
|
||||||
200: json(InventionDto.array(), 'The caller’s inventions'),
|
200: json(InventionDto.array(), 'The caller’s inventions'),
|
||||||
@@ -659,7 +717,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
return c.json(await getInventionsByCreator(c.env.DB, id))
|
return c.json(await getMyInventions(c.env.DB, id))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -678,14 +736,19 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
'Records an invention’s metadata. The data file itself is uploaded separately ' +
|
'Records an invention’s metadata. The data file itself is uploaded separately ' +
|
||||||
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
|
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
|
||||||
'one required field, since an invention with no data blob is unusable. An omitted ' +
|
'one required field, since an invention with no data blob is unusable. An omitted ' +
|
||||||
'name/description is defaulted rather than rejected.\n\n' +
|
'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 ' +
|
'A freshly saved invention is private: it shows up only in the creator’s own list ' +
|
||||||
'until they call `v3/publish`.',
|
'until they call `v3/publish`.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'),
|
requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
|
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
|
||||||
400: json(ErrorResponse, 'Unparseable body, or no inventionDataFilename'),
|
400: json(
|
||||||
|
ErrorResponse,
|
||||||
|
'Unparseable body, no inventionDataFilename, or an invalid name/description'
|
||||||
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -704,11 +767,24 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
return c.json({ error: 'inventionDataFilename is required' }, 400)
|
return c.json({ error: 'inventionDataFilename is required' }, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const invention = await createInvention(c.env.DB, {
|
// 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,
|
creatorPlayerId: id,
|
||||||
inventionDataFilename,
|
inventionDataFilename,
|
||||||
name: str(body.name),
|
name,
|
||||||
description: str(body.description),
|
description,
|
||||||
imageName: str(body.imageName),
|
imageName: str(body.imageName),
|
||||||
instantiationCost: num(body.instantiationCost),
|
instantiationCost: num(body.instantiationCost),
|
||||||
lightsCost: num(body.lightsCost),
|
lightsCost: num(body.lightsCost),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -6,19 +6,15 @@ import communityBoard from '../../static/community-board.json'
|
|||||||
import {
|
import {
|
||||||
BareString,
|
BareString,
|
||||||
idParam,
|
idParam,
|
||||||
intQuery,
|
|
||||||
IsPureResponse,
|
IsPureResponse,
|
||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
jsonBody,
|
jsonBody,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
KeepsakeConfig,
|
KeepsakeConfig,
|
||||||
PlayerEventsAll,
|
|
||||||
PlayerEventsPage,
|
|
||||||
SanitizeRequest,
|
SanitizeRequest,
|
||||||
stringParam,
|
stringParam,
|
||||||
SubscriptionResponse,
|
SubscriptionResponse,
|
||||||
TagFilters,
|
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
|
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
@@ -128,86 +124,8 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
|||||||
}),
|
}),
|
||||||
(c) => c.json(communityBoard)
|
(c) => c.json(communityBoard)
|
||||||
)
|
)
|
||||||
.get(
|
// Player events live in their own controller (routes/events.ts) — they're D1-backed
|
||||||
'/api/playerevents/v1/all',
|
// now, unlike the stubs around them here.
|
||||||
describeRoute({
|
|
||||||
tags: ['Gameplay'],
|
|
||||||
summary: 'The caller’s player events',
|
|
||||||
description:
|
|
||||||
'Events the player created and events they have RSVP’d to. No player-event ' +
|
|
||||||
'storage yet, so both lists are empty.',
|
|
||||||
responses: { 200: json(PlayerEventsAll, 'Two empty lists') },
|
|
||||||
}),
|
|
||||||
(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',
|
|
||||||
describeRoute({
|
|
||||||
tags: ['Gameplay'],
|
|
||||||
summary: 'Player-event filter chips',
|
|
||||||
description:
|
|
||||||
'The 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.',
|
|
||||||
responses: { 200: json(TagFilters, 'Empty chip lists') },
|
|
||||||
}),
|
|
||||||
(c) => c.json({ PinnedFilters: [], PopularFilters: [], 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. No
|
|
||||||
// player-event storage yet, so the feed is empty.
|
|
||||||
.get(
|
|
||||||
'/api/playerevents/v1/clubs',
|
|
||||||
describeRoute({
|
|
||||||
tags: ['Gameplay'],
|
|
||||||
summary: 'Player events across several clubs',
|
|
||||||
description:
|
|
||||||
'The events shelf for a set of clubs (`?id=1&id=2`). 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 player-event storage yet, ' +
|
|
||||||
'so the feed is empty.',
|
|
||||||
parameters: [intQuery('id', 'Repeatable club id')],
|
|
||||||
responses: { 200: json(JsonArray, 'An empty list') },
|
|
||||||
}),
|
|
||||||
(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]+}',
|
|
||||||
describeRoute({
|
|
||||||
tags: ['Gameplay'],
|
|
||||||
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. An empty `ContinuationToken` means no next ' +
|
|
||||||
'page.',
|
|
||||||
parameters: [idParam('clubId', 'Club id')],
|
|
||||||
responses: { 200: json(PlayerEventsPage, 'An empty page') },
|
|
||||||
}),
|
|
||||||
(c) => c.json({ ContinuationToken: '', Events: [] })
|
|
||||||
)
|
|
||||||
// Live player-event search (the "happening now" browse query). No player-event
|
|
||||||
// storage yet, so there's nothing live to return — a bare empty array.
|
|
||||||
.get(
|
|
||||||
'/api/playerevents/v1/searchlive',
|
|
||||||
describeRoute({
|
|
||||||
tags: ['Gameplay'],
|
|
||||||
summary: 'Search live player events',
|
|
||||||
description:
|
|
||||||
'The "happening now" search on the player-events browse screen. No player-event ' +
|
|
||||||
'storage yet, so there are no live events — returns an empty list.',
|
|
||||||
responses: { 200: json(JsonArray, 'An empty list') },
|
|
||||||
}),
|
|
||||||
(c) => c.json([])
|
|
||||||
)
|
|
||||||
.get(
|
.get(
|
||||||
'/api/announcement/v1/get',
|
'/api/announcement/v1/get',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
getSlideshowImages,
|
getSlideshowImages,
|
||||||
SavedImageType,
|
SavedImageType,
|
||||||
setImageCheer,
|
setImageCheer,
|
||||||
|
SLIDESHOW_LIMIT,
|
||||||
|
SLIDESHOW_MAX_LIMIT,
|
||||||
toImagesPlayer,
|
toImagesPlayer,
|
||||||
} from '../images-db'
|
} from '../images-db'
|
||||||
import {
|
import {
|
||||||
@@ -312,6 +314,10 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
// creator's username and room name. Public (no auth): it only surfaces already-public
|
// creator's username and room name. Public (no auth): it only surfaces already-public
|
||||||
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
|
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
|
||||||
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
|
// 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(
|
.get(
|
||||||
'/api/images/v1/slideshow',
|
'/api/images/v1/slideshow',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -323,10 +329,21 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
'Deliberately public — it surfaces only already-public images and backs the ' +
|
'Deliberately public — it surfaces only already-public images and backs the ' +
|
||||||
'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' +
|
'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' +
|
||||||
'client refreshes against.',
|
'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') },
|
responses: { 200: json(SlideshowResponse, 'The feed plus its cache hint') },
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const Images = await getSlideshowImages(c.env.DB)
|
// 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()
|
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
|
||||||
return c.json({ Images, ValidTill })
|
return c.json({ Images, ValidTill })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,62 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
import { describeRoute } from 'hono-openapi'
|
||||||
|
|
||||||
|
import { authedId, authedRoles, unauthorized } from '../http'
|
||||||
import {
|
import {
|
||||||
|
AUTHED,
|
||||||
BareBoolean,
|
BareBoolean,
|
||||||
|
CreateReportRequest,
|
||||||
|
CreateWarningRequest,
|
||||||
DeviceIdRequest,
|
DeviceIdRequest,
|
||||||
form,
|
form,
|
||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
ModerationBlockDetails,
|
ModerationBlockDetails,
|
||||||
|
SuccessErrorEnvelope,
|
||||||
|
UNAUTHORIZED_RESPONSE,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
|
import { createReport } from '../reports-db'
|
||||||
|
import { createWarning } from '../warnings-db'
|
||||||
|
|
||||||
|
import type { Context } from 'hono'
|
||||||
import type { App } from '../context'
|
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 ------------------------------------------------------
|
// ---- Player reporting ------------------------------------------------------
|
||||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
|
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
|
||||||
@@ -69,6 +114,119 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
(c) => c.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: '' })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// The client reporting its device id (form-encoded `oldDeviceId`, `newDeviceId`,
|
// 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
|
// `platform`), rotating from the id it thinks we hold to the current one. Carries no
|
||||||
// bearer token and fires before account creation, so there is no caller to attribute
|
// bearer token and fires before account creation, so there is no caller to attribute
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
import { describeRoute } from 'hono-openapi'
|
||||||
|
|
||||||
|
import { getAccountsByIds } from '@repo/domain'
|
||||||
import { logger } from '@repo/hono-helpers'
|
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 { authedId, unauthorized } from '../http'
|
||||||
import {
|
import {
|
||||||
AckResponse,
|
AckResponse,
|
||||||
AUTHED,
|
AUTHED,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
|
form,
|
||||||
intQuery,
|
intQuery,
|
||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
|
MutualFriendDto,
|
||||||
RelationshipDto,
|
RelationshipDto,
|
||||||
|
SendMessageRequest,
|
||||||
|
SuccessErrorEnvelope,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
import {
|
import {
|
||||||
acceptFriendRequest,
|
acceptFriendRequest,
|
||||||
addFriend,
|
addFriend,
|
||||||
|
getMutualFriendIds,
|
||||||
getRelationshipsForPlayer,
|
getRelationshipsForPlayer,
|
||||||
|
MUTUAL_FRIENDS_LIMIT,
|
||||||
removeFriend,
|
removeFriend,
|
||||||
sendFriendRequest,
|
sendFriendRequest,
|
||||||
setRelationshipFlag,
|
setRelationshipFlag,
|
||||||
@@ -34,9 +44,6 @@ import type {
|
|||||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||||
const HUB_INSTANCE = 'global'
|
const HUB_INSTANCE = 'global'
|
||||||
|
|
||||||
/** NotificationType.RelationshipChanged (see apps/notify/src/notification-types.ts). */
|
|
||||||
const RELATIONSHIP_CHANGED = 1
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push a `RelationshipChanged` notification carrying `rel` to one player. Hub failures are
|
* 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
|
* logged and swallowed — the DB write has already committed, so a hub hiccup must not fail
|
||||||
@@ -50,7 +57,7 @@ async function notifyRelationship(
|
|||||||
try {
|
try {
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
playerId,
|
playerId,
|
||||||
RELATIONSHIP_CHANGED,
|
NotificationType.RelationshipChanged,
|
||||||
{ ...rel }
|
{ ...rel }
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -199,6 +206,128 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// 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
|
// matched any method). Auth-gated. Returns the resulting relationship from the
|
||||||
|
|||||||
@@ -2,15 +2,31 @@ import { adminSecretsStore, env } from 'cloudflare:test'
|
|||||||
import { exports } from 'cloudflare:workers'
|
import { exports } from 'cloudflare:workers'
|
||||||
import { beforeAll, describe, expect, test } from 'vitest'
|
import { beforeAll, describe, expect, test } from 'vitest'
|
||||||
|
|
||||||
import { GAME_VERSION, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL } from '@repo/domain'
|
import {
|
||||||
|
GAME_VERSION,
|
||||||
|
grantInvention,
|
||||||
|
INVENTORY_INVENTION_SCHEMA_DDL,
|
||||||
|
ROOM_SCHEMA_DDL,
|
||||||
|
seedRoomWithSubRooms,
|
||||||
|
SUBROOM_SCHEMA_DDL,
|
||||||
|
} from '@repo/domain'
|
||||||
|
|
||||||
import '../../api.app'
|
import '../../api.app'
|
||||||
|
|
||||||
|
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 { 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 INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
||||||
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-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 { Env } from '../../context'
|
||||||
|
import type { PlayerEvent, PlayerEventResult } from '../../events-db'
|
||||||
import type { SavedImage } from '../../images-db'
|
import type { SavedImage } from '../../images-db'
|
||||||
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
|
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
|
||||||
|
|
||||||
@@ -44,14 +60,9 @@ const TEST_ROOMS = [
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||||
await env.DB.prepare(
|
// The rooms worker's schema (room + interaction) — reading a room aggregates its
|
||||||
`CREATE TABLE IF NOT EXISTS room (
|
// cheer/favorite Stats from `interaction`, so both tables have to be here.
|
||||||
data TEXT NOT NULL,
|
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
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()
|
|
||||||
// Subrooms live in their own table now; getRoomById hydrates from it, so create it and
|
// 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).
|
// 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 stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
@@ -81,6 +92,18 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
// Inventions table (owned by the api worker) — invention save/mine use it.
|
// 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()
|
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
|
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
|
||||||
@@ -94,10 +117,13 @@ function b64url(input: ArrayBuffer | string): string {
|
|||||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
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 now = Math.floor(Date.now() / 1000)
|
||||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
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(
|
const key = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
@@ -110,6 +136,12 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
|
|||||||
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
|
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', () => {
|
describe('public endpoints', () => {
|
||||||
test('GET /api/config/v1/amplitude', async () => {
|
test('GET /api/config/v1/amplitude', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
|
||||||
@@ -188,17 +220,6 @@ describe('public endpoints', () => {
|
|||||||
expect(reps.map((r) => r.AccountId)).toEqual([1, 2])
|
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`)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(await res.json()).toEqual({
|
|
||||||
PinnedFilters: [],
|
|
||||||
PopularFilters: [],
|
|
||||||
TrendingFilters: null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GET /api/activities/charades/v1/words/Charades returns the word bank', async () => {
|
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`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/activities/charades/v1/words/Charades`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -208,26 +229,6 @@ describe('public endpoints', () => {
|
|||||||
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
|
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
|
||||||
})
|
})
|
||||||
|
|
||||||
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: [] })
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GET /api/playerevents/v1/searchlive returns an empty list', async () => {
|
|
||||||
// No player-event storage yet → nothing live to return.
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/searchlive`)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(await res.json()).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
|
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
|
||||||
const res = await exports.default.fetch(
|
const res = await exports.default.fetch(
|
||||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
|
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
|
||||||
@@ -425,7 +426,7 @@ describe('public endpoints', () => {
|
|||||||
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
|
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(
|
expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe(
|
||||||
'2026-07-12/x.inv'
|
'2026-07-12/x.inv'
|
||||||
@@ -457,6 +458,49 @@ describe('public endpoints', () => {
|
|||||||
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
|
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 () => {
|
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -488,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 () => {
|
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
|
||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
@@ -567,6 +656,39 @@ describe('public endpoints', () => {
|
|||||||
})
|
})
|
||||||
expect(await replaced.json()).toEqual({ Result: 0, Tags: ['modern', 'bed'] })
|
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.
|
// Only the creator may retag; unknown inventions 404; no token → 401.
|
||||||
const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999')
|
const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999')
|
||||||
expect(notMine.status).toBe(403)
|
expect(notMine.status).toBe(403)
|
||||||
@@ -766,6 +888,12 @@ describe('public endpoints', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
|
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
|
||||||
|
// 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`, {
|
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
|
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
|
||||||
@@ -777,7 +905,8 @@ describe('public endpoints', () => {
|
|||||||
})
|
})
|
||||||
const { Invention } = (await save.json()) as InventionSaveResult
|
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(
|
const res = await exports.default.fetch(
|
||||||
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
|
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
|
||||||
)
|
)
|
||||||
@@ -786,6 +915,7 @@ describe('public endpoints', () => {
|
|||||||
InventionId: Invention.InventionId,
|
InventionId: Invention.InventionId,
|
||||||
VersionNumber: 1,
|
VersionNumber: 1,
|
||||||
BlobName: '2026-07-12/lamp.inv',
|
BlobName: '2026-07-12/lamp.inv',
|
||||||
|
BlobHash: await base64Sha256(data),
|
||||||
InstantiationCost: 42,
|
InstantiationCost: 42,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -808,6 +938,44 @@ describe('public endpoints', () => {
|
|||||||
expect(noId.status).toBe(400)
|
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 () => {
|
test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => {
|
||||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -849,6 +1017,16 @@ describe('public endpoints', () => {
|
|||||||
const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult
|
const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult
|
||||||
expect(cleared.Invention).toMatchObject({ Description: '', Name: 'Draft Lamp' })
|
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.
|
// allowTrial takes true/1.
|
||||||
const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult
|
const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult
|
||||||
expect(trial.Invention.AllowTrial).toBe(true)
|
expect(trial.Invention.AllowTrial).toBe(true)
|
||||||
@@ -960,12 +1138,15 @@ describe('public endpoints', () => {
|
|||||||
const ids = async (res: Response): Promise<number[]> =>
|
const ids = async (res: Response): Promise<number[]> =>
|
||||||
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
|
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
|
||||||
|
|
||||||
// Nothing is flagged IsFeatured yet → featured falls back to the top feed.
|
// Both feeds start EMPTY, for different reasons: nothing is flagged IsFeatured, and
|
||||||
const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
|
// the only inventions acquired so far in this file are an unpublished one and an id
|
||||||
const beforeFeatured = await ids(
|
// with no invention row — neither of which a public feed may show.
|
||||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`)
|
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 = (
|
const feedInvention = (
|
||||||
id: number,
|
id: number,
|
||||||
@@ -1003,19 +1184,42 @@ describe('public endpoints', () => {
|
|||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top: engagement-ranked, so the biggest download counts lead.
|
// Recent acquisitions, which is what "top today" now counts: 201 picked up by three
|
||||||
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
|
// players, 203 by one. 204/205 are acquired too — an unpublished and a hidden
|
||||||
expect(top.slice(0, 3)).toEqual([202, 203, 201])
|
// invention can still be owned — and must not surface in a public feed.
|
||||||
expect(top).not.toContain(204)
|
for (const accountId of [7001, 7002, 7003]) await grantInvention(env.DB, accountId, 201)
|
||||||
expect(top).not.toContain(205)
|
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`))
|
const featured = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))
|
||||||
expect(featured).toEqual([203, 202])
|
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`)
|
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
|
||||||
expect(await ids(page)).toEqual([203])
|
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 () => {
|
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
|
||||||
@@ -1047,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', () => {
|
describe('rooms', () => {
|
||||||
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
|
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
|
||||||
const verify = async (fields: Record<string, string>, sub?: string): Promise<boolean> => {
|
const verify = async (fields: Record<string, string>, sub?: string): Promise<boolean> => {
|
||||||
@@ -1154,6 +1531,29 @@ describe('images', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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 () => {
|
test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
|
||||||
// Seed an image to cheer.
|
// Seed an image to cheer.
|
||||||
// Its own player id: 700's photos are asserted on exactly in the player-list test.
|
// Its own player id: 700's photos are asserted on exactly in the player-list test.
|
||||||
@@ -1867,6 +2267,641 @@ describe('relationships', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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', () => {
|
describe('openapi', () => {
|
||||||
test('GET /openapi.json documents every route', async () => {
|
test('GET /openapi.json documents every route', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||||
@@ -1938,13 +2973,17 @@ describe('openapi', () => {
|
|||||||
'GET /api/playerReputation/v1/{id}',
|
'GET /api/playerReputation/v1/{id}',
|
||||||
'GET /api/playerReputation/v2/bulk',
|
'GET /api/playerReputation/v2/bulk',
|
||||||
'GET /api/playerevents/v1/all',
|
'GET /api/playerevents/v1/all',
|
||||||
|
'GET /api/playerevents/v1/bulk',
|
||||||
'GET /api/playerevents/v1/club/{clubId}',
|
'GET /api/playerevents/v1/club/{clubId}',
|
||||||
'GET /api/playerevents/v1/clubs',
|
'GET /api/playerevents/v1/clubs',
|
||||||
|
'GET /api/playerevents/v1/search',
|
||||||
'GET /api/playerevents/v1/searchlive',
|
'GET /api/playerevents/v1/searchlive',
|
||||||
'GET /api/playerevents/v1/tagfilters',
|
'GET /api/playerevents/v1/tagfilters',
|
||||||
|
'GET /api/playerevents/v1/{eventId}',
|
||||||
'GET /api/players/v1/progression/{id}',
|
'GET /api/players/v1/progression/{id}',
|
||||||
'GET /api/players/v2/progression/bulk',
|
'GET /api/players/v2/progression/bulk',
|
||||||
'GET /api/quickPlay/v1/getandclear',
|
'GET /api/quickPlay/v1/getandclear',
|
||||||
|
'GET /api/relationships/mutualfriends',
|
||||||
'GET /api/relationships/v1/favorite',
|
'GET /api/relationships/v1/favorite',
|
||||||
'GET /api/relationships/v1/ignore',
|
'GET /api/relationships/v1/ignore',
|
||||||
'GET /api/relationships/v1/mute',
|
'GET /api/relationships/v1/mute',
|
||||||
@@ -1964,6 +3003,7 @@ describe('openapi', () => {
|
|||||||
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
||||||
'POST /api/PlayerReporting/v1/deviceId',
|
'POST /api/PlayerReporting/v1/deviceId',
|
||||||
'POST /api/PlayerReporting/v1/hile',
|
'POST /api/PlayerReporting/v1/hile',
|
||||||
|
'POST /api/PlayerReporting/v3/create',
|
||||||
'POST /api/avatar/v2/gifts/generate',
|
'POST /api/avatar/v2/gifts/generate',
|
||||||
'POST /api/gamesight/event',
|
'POST /api/gamesight/event',
|
||||||
'POST /api/images/v1/cheer',
|
'POST /api/images/v1/cheer',
|
||||||
@@ -1971,10 +3011,15 @@ describe('openapi', () => {
|
|||||||
'POST /api/inventions/v1/settags',
|
'POST /api/inventions/v1/settags',
|
||||||
'POST /api/inventions/v1/updateprice',
|
'POST /api/inventions/v1/updateprice',
|
||||||
'POST /api/inventions/v6/save',
|
'POST /api/inventions/v6/save',
|
||||||
|
'POST /api/messages/v2/send',
|
||||||
'POST /api/playerReputation/v1/bulk',
|
'POST /api/playerReputation/v1/bulk',
|
||||||
'POST /api/playerReputation/v2/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/v1/progression/bulk',
|
||||||
'POST /api/players/v2/progression/bulk',
|
'POST /api/players/v2/progression/bulk',
|
||||||
|
'POST /api/playerwarnings',
|
||||||
'POST /api/relationships/v1/favorite',
|
'POST /api/relationships/v1/favorite',
|
||||||
'POST /api/relationships/v1/ignore',
|
'POST /api/relationships/v1/ignore',
|
||||||
'POST /api/relationships/v1/mute',
|
'POST /api/relationships/v1/mute',
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"CurrentAnnouncement": {
|
"CurrentAnnouncement": {
|
||||||
"Message": "Server powered by RecFlare",
|
"Message": "Server powered by RecFlare",
|
||||||
"MoreInfoUrl": "https://github.com/djdevin/recflare"
|
"MoreInfoUrl": "https://recflare.net"
|
||||||
},
|
},
|
||||||
"InstagramImages": [
|
"InstagramImages": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,11 +19,17 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
// Image bucket shared with the `img` worker (which serves objects back by key).
|
// 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": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
"binding": "IMAGES",
|
"binding": "IMAGES",
|
||||||
"bucket_name": "recflare-img"
|
"bucket_name": "recflare-img"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"binding": "CDN_ASSETS",
|
||||||
|
"bucket_name": "recflare-cdn"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
|
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
|
||||||
|
|||||||
+72
-20
@@ -42,35 +42,79 @@ route without documenting it fails rather than silently shipping an incomplete s
|
|||||||
without matchmaking. A posted `password` becomes the login credential.
|
without matchmaking. A posted `password` becomes the login credential.
|
||||||
- **`cached_login`** — logs into an already-linked account using platform ownership as
|
- **`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
|
the credential; no password. The posted `account_id` must be linked to exactly the
|
||||||
identity the Steam ticket proves.
|
identity `platform_auth` proves.
|
||||||
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
|
- **`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.
|
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
|
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
|
||||||
the account by `username` or numeric `account_id` and requires the matching password
|
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
|
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
|
||||||
all, which is what closes id/username-only takeover.
|
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`
|
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.
|
claim, so developer/moderator powers refresh on every login and every refresh grant.
|
||||||
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
|
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
|
||||||
|
|
||||||
### Steam is the only verifiable platform
|
### Verifiable platforms: Steam and Meta
|
||||||
|
|
||||||
`platform_auth` tickets are verified **offline** — `src/steam-ticket.ts` parses the
|
Only an identity we can _prove_ is ever bound to an account, so any grant that
|
||||||
ticket and checks Steam's signature against Steam's system public key. No publisher
|
authenticates _by platform identity_ (`cached_login`, and `create_account` when it
|
||||||
Web API key, no network call. Steam (platform `0`) is therefore the only platform
|
asserts a platform) must be a platform we can verify. Two are:
|
||||||
whose identity can be proven, so any grant that authenticates _by platform identity_
|
|
||||||
(`cached_login`, and `create_account` when it asserts a platform) must be Steam. The
|
- **Steam (`0`)** — `src/steam-ticket.ts` parses the `platform_auth` ticket and checks
|
||||||
verified SteamID64 replaces the client-supplied `platform_id` and is the only value
|
Steam's signature against Steam's system public key. Verified **offline**: no
|
||||||
ever written to an account's `platformId`.
|
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
|
## Signup caps
|
||||||
|
|
||||||
`create_account` is capped on two independent arms, per verified platform id and per
|
`create_account` is capped on two independent arms, per verified platform identity and
|
||||||
signup IP. The platform arm can't be spoofed or reset by changing networks; the IP arm
|
per signup IP. The platform arm can't be spoofed or reset by changing networks; the IP
|
||||||
is coarse and will produce false positives behind NAT, shared campus and mobile
|
arm is coarse and will produce false positives behind NAT, shared campus and mobile
|
||||||
networks. Both default to 3.
|
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`,
|
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
|
`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
|
editing a versioned file. Setting an arm to `0` disables it — worth reaching for on a
|
||||||
@@ -78,11 +122,12 @@ small private server, or when a shared network is being locked out.
|
|||||||
|
|
||||||
## Bindings
|
## Bindings
|
||||||
|
|
||||||
| Binding | Type | Notes |
|
| Binding | Type | Notes |
|
||||||
| -------------------- | ------------- | ------------------------------------------------------ |
|
| -------------------- | ------------- | ----------------------------------------------------------------------------------------------- |
|
||||||
| `DB` | D1 | Shared `recflare` database; this worker owns `account` |
|
| `DB` | D1 | Shared `recflare` database; this worker owns `account`, `refresh_tokens` and `platform_account` |
|
||||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
|
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
|
||||||
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
|
| `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`
|
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
|
table, so they stay independent of the `rooms` worker's migrations on the same
|
||||||
@@ -109,12 +154,19 @@ wrangler secrets-store store create recflare --scopes workers
|
|||||||
|
|
||||||
# Set the shared signing key (prompted for the value)
|
# Set the shared signing key (prompted for the value)
|
||||||
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
|
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
|
```sh
|
||||||
wrangler secrets-store secret create local --name JWT_SECRET --value <dev-key> --scopes workers
|
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).
|
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,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;
|
||||||
+385
-120
@@ -3,13 +3,12 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
|||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
countAccountsByPlatformId,
|
|
||||||
countAccountsBySignupIp,
|
countAccountsBySignupIp,
|
||||||
createAccount,
|
createAccount,
|
||||||
GAME_VERSION,
|
GAME_VERSION,
|
||||||
getAccount,
|
getAccount,
|
||||||
getAccountByUsername,
|
getAccountByUsername,
|
||||||
getAccountsByPlatformId,
|
getAccountsByIds,
|
||||||
getPasswordHash,
|
getPasswordHash,
|
||||||
getRoomById,
|
getRoomById,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
@@ -19,11 +18,13 @@ import {
|
|||||||
setPasswordHash,
|
setPasswordHash,
|
||||||
setPresence,
|
setPresence,
|
||||||
subRoomDataBlob,
|
subRoomDataBlob,
|
||||||
|
updateAccount,
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { intVar, logger, withCleanSpec, 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 { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
|
import { verifyMetaNonce } from './meta-nonce'
|
||||||
import {
|
import {
|
||||||
CachedLogin,
|
CachedLogin,
|
||||||
ChangePasswordRequest,
|
ChangePasswordRequest,
|
||||||
@@ -38,21 +39,44 @@ import {
|
|||||||
TokenRequest,
|
TokenRequest,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
} from './openapi'
|
} from './openapi'
|
||||||
|
import {
|
||||||
|
countAccountsForPlatformIdentity,
|
||||||
|
getLinksForPlatformId,
|
||||||
|
getLinksForPlatformIdentity,
|
||||||
|
isPlatformIdentityLinked,
|
||||||
|
linkPlatformIdentity,
|
||||||
|
} from './platform-db'
|
||||||
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
||||||
import { verifySteamTicket } from './steam-ticket'
|
import { verifySteamTicket } from './steam-ticket'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { Account } from '@repo/domain'
|
import type { Account } from '@repo/domain'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
import type { PlatformLink } from './platform-db'
|
||||||
|
|
||||||
/** OAuth scopes granted by `/connect/token`. */
|
/** OAuth scopes granted by `/connect/token`. */
|
||||||
const TOKEN_SCOPE =
|
const TOKEN_SCOPE =
|
||||||
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
|
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
|
||||||
|
|
||||||
/** The canned entry served for any Oculus cached-login lookup. See the route below. */
|
/**
|
||||||
|
* 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 = {
|
const FAKE_OCULUS_CACHED_LOGIN = {
|
||||||
platform: PlatformType.Oculus,
|
platform: PlatformType.Oculus,
|
||||||
platformId: '1',
|
platformId: SIDELOAD_PLATFORM_ID,
|
||||||
accountId: 1,
|
accountId: 1,
|
||||||
lastLoginTime: '2026-07-19T17:13:29.225Z',
|
lastLoginTime: '2026-07-19T17:13:29.225Z',
|
||||||
requirePassword: true,
|
requirePassword: true,
|
||||||
@@ -166,48 +190,169 @@ function accountRoles(account: Pick<Account, 'isDeveloper' | 'isModerator'> | nu
|
|||||||
/**
|
/**
|
||||||
* The platform an account's `platformId` belongs to. Nothing defaults the `platform`
|
* 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
|
* 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
|
* platform recorded — and until Meta verification landed Steam was the only identity
|
||||||
* an unset one *is* Steam.
|
* 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 {
|
function accountPlatform(account: Pick<Account, 'platform'>): number {
|
||||||
return account.platform ?? 0
|
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
|
* 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
|
* entry on the login screen. The client posts the chosen `accountId` back as a
|
||||||
* `grant_type=cached_login`. `requirePassword` is false because platform ownership
|
* `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 {
|
return {
|
||||||
platform: accountPlatform(account),
|
platform: link.platform,
|
||||||
platformId: account.platformId ?? '',
|
platformId: link.platformId,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
lastLoginTime: account.lastLoginTime ?? account.createdAt,
|
lastLoginTime: account.lastLoginTime ?? account.createdAt,
|
||||||
requirePassword: false,
|
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>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -219,6 +364,14 @@ const app = new Hono<App>()
|
|||||||
})(c, next)
|
})(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())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
@@ -250,33 +403,36 @@ const app = new Hono<App>()
|
|||||||
tags: ['Cached login'],
|
tags: ['Cached login'],
|
||||||
summary: 'Accounts linked to a platform id',
|
summary: 'Accounts linked to a platform id',
|
||||||
description: [
|
description: [
|
||||||
'Accounts the client may offer on its login screen for this platform identity.',
|
'Accounts the client may offer on its login screen for this platform identity —',
|
||||||
'Filtered to those a `cached_login` grant would actually accept, so an entry here',
|
'the links this identity has, so an entry here is always redeemable by a',
|
||||||
'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls',
|
'`cached_login` grant (both read the same table). An account linked to several',
|
||||||
'back to a fresh login or create_account.',
|
'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)',
|
||||||
'EXCEPT platform 1 (Oculus), which is stubbed: it ignores the id and returns one',
|
'and the client falls back to a fresh login or create_account.',
|
||||||
'canned, non-redeemable entry with `requirePassword: true`.',
|
'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(' '),
|
].join(' '),
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
name: 'platform',
|
name: 'platform',
|
||||||
in: 'path',
|
in: 'path',
|
||||||
required: true,
|
required: true,
|
||||||
description: 'PlatformType integer. A non-numeric value disables the link filter.',
|
description: 'PlatformType integer. A non-numeric value matches the id on any platform.',
|
||||||
schema: { type: 'string' },
|
schema: { type: 'string' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'id',
|
name: 'id',
|
||||||
in: 'path',
|
in: 'path',
|
||||||
required: true,
|
required: true,
|
||||||
description: 'Platform-native id — a SteamID64 for Steam.',
|
description: 'Platform-native id — a SteamID64 for Steam, a user id for Meta.',
|
||||||
schema: { type: 'string' },
|
schema: { type: 'string' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(
|
200: json(
|
||||||
CachedLogin.or(FakeCachedLogin).array(),
|
CachedLogin.or(FakeCachedLogin).array(),
|
||||||
'Matching accounts; `[]` if none. The canned entry for platform 1 (Oculus).'
|
'Matching accounts; `[]` if none. The canned entry for `1/1`.'
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -284,21 +440,27 @@ const app = new Hono<App>()
|
|||||||
const { platform, id } = c.req.param()
|
const { platform, id } = c.req.param()
|
||||||
logger.info('cached login lookup', { platform, id })
|
logger.info('cached login lookup', { platform, id })
|
||||||
const platformInt = Number.parseInt(platform, 10)
|
const platformInt = Number.parseInt(platform, 10)
|
||||||
// Oculus has no identity flow yet, so there is nothing in the DB to look up and
|
// SIDELOADED APKs ONLY. A sideloaded build has no Meta SDK behind it, so it can't
|
||||||
// the real path would always yield []. Hand back one canned entry instead, so the
|
// produce a real Meta identity or a nonce to prove one with — it asks about the
|
||||||
// Oculus client gets past its login screen. `requirePassword` is true — unlike a
|
// placeholder identity `1/1`, and an empty picker leaves it stuck on the platform
|
||||||
// genuine cached login there is no platform ticket behind this, so the client must
|
// login screen with nothing to do. Hand back one canned entry to push it onto the
|
||||||
// prompt. Delete this branch once Oculus platform auth lands.
|
// username/password login instead, which is the only flow such a build can finish.
|
||||||
if (platformInt === PlatformType.Oculus) return c.json([FAKE_OCULUS_CACHED_LOGIN])
|
// `requirePassword` is true for exactly that reason: there's no platform proof here,
|
||||||
const accounts = await getAccountsByPlatformId(c.env.DB, id)
|
// and the `cached_login` grant would (correctly) refuse this entry.
|
||||||
// Offer only accounts the `cached_login` grant will actually accept — same check.
|
//
|
||||||
return c.json(
|
// Scoped to that ONE identity rather than to all of platform 1 — store builds do
|
||||||
accounts
|
// real Meta logins, and shadowing the whole platform would hide genuine links from
|
||||||
.filter(
|
// their pickers.
|
||||||
(a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id)
|
if (platformInt === PlatformType.Oculus && id === SIDELOAD_PLATFORM_ID) {
|
||||||
)
|
return c.json([FAKE_OCULUS_CACHED_LOGIN])
|
||||||
.map(toCachedLogin)
|
}
|
||||||
)
|
// 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))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -312,8 +474,8 @@ const app = new Hono<App>()
|
|||||||
description: [
|
description: [
|
||||||
'Resolves many platform ids at once. Results are flattened across all ids, so the',
|
'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',
|
'response cannot be mapped back to a specific input id — the client uses each',
|
||||||
'entry’s own `platformId`. Unlike the single-id route, results are NOT filtered to',
|
'entry’s own `platformId`. No platform accompanies these ids, so each matches on',
|
||||||
'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
|
'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
|
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
|
||||||
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
|
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
|
||||||
@@ -324,7 +486,8 @@ const app = new Hono<App>()
|
|||||||
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
|
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
|
||||||
const out: Array<ReturnType<typeof toCachedLogin>> = []
|
const out: Array<ReturnType<typeof toCachedLogin>> = []
|
||||||
for (const pid of ids) {
|
for (const pid of ids) {
|
||||||
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
|
// 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)
|
||||||
}
|
}
|
||||||
@@ -345,12 +508,13 @@ const app = new Hono<App>()
|
|||||||
'`password` becomes the login credential. Subject to two independent signup caps,',
|
'`password` becomes the login credential. Subject to two independent signup caps,',
|
||||||
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
|
'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',
|
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
|
||||||
'`platform`, that platform must be Steam and `platform_auth` must verify.',
|
'`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',
|
'**`cached_login`** — logs into an already-linked account using platform ownership as',
|
||||||
'the credential; no password. Requires a Steam `platform_auth` ticket, and the posted',
|
'the credential; no password. Requires a verifying `platform_auth`, and the posted',
|
||||||
'`account_id` must be linked to exactly the identity that ticket proves. An account',
|
'`account_id` must be LINKED to exactly the identity it proves. An account with no',
|
||||||
'with no stored platform identity cannot be cached-logged-into.',
|
'link for that identity cannot be cached-logged-into.',
|
||||||
'',
|
'',
|
||||||
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
|
'**`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.',
|
'platform and platform id come from what was stored at issue time, not the body.',
|
||||||
@@ -358,13 +522,27 @@ const app = new Hono<App>()
|
|||||||
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
|
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
|
||||||
'identifies the account by `username` or numeric `account_id` and requires the',
|
'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,',
|
'matching `password`. An account with no stored hash cannot be logged into at all,',
|
||||||
'which is what closes id/username-only takeover.',
|
'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 verification.** Steam (platform `0`) is the only platform that can be',
|
'**Platform identity.** An account can be reached from several platform identities;',
|
||||||
'verified, via its signed `platform_auth` ticket, so any grant authenticating by',
|
'the links are the one thing both the picker and `cached_login` consult, and only a',
|
||||||
'platform identity must be Steam. The verified SteamID64 replaces the client-supplied',
|
'VERIFIED identity is ever linked. Two platforms can be verified. Steam (`0`) posts a',
|
||||||
'`platform_id` and is the only value ever written to an account. Password and refresh',
|
'Steam-signed `platform_auth` ticket, checked offline; the SteamID64 it carries',
|
||||||
'grants carry their own credential and are not gated this way.',
|
'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',
|
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||||
'powers refresh on every login and every refresh grant.',
|
'powers refresh on every login and every refresh grant.',
|
||||||
@@ -378,13 +556,17 @@ const app = new Hono<App>()
|
|||||||
400: json(
|
400: json(
|
||||||
OAuthError,
|
OAuthError,
|
||||||
[
|
[
|
||||||
'Unusable grant: bad credentials, an unverifiable or non-Steam platform, an',
|
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
|
||||||
'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
|
'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
|
||||||
].join(' ')
|
].join(' ')
|
||||||
),
|
),
|
||||||
500: json(
|
500: json(
|
||||||
OAuthError,
|
OAuthError,
|
||||||
'JWT_SECRET is unset — a token is refused rather than signed with an empty key'
|
[
|
||||||
|
'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(' ')
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -419,43 +601,91 @@ const app = new Hono<App>()
|
|||||||
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
|
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
|
||||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||||
|
|
||||||
// A platform-authenticated login proves who you are with the platform itself,
|
// A platform-authenticated login proves who you are with the platform itself, and
|
||||||
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
|
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
|
||||||
// ticket. So those logins must be Steam:
|
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
|
||||||
// - cached_login authenticates purely by platform identity → always Steam-only.
|
// verifyPlatformProof). Only a verified identity is ever bound or linked.
|
||||||
// - 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
|
// Two grants are GATED on it — they have no other credential, so an unverifiable
|
||||||
// is the password-account path — allowed, but it binds no platformId.)
|
// platform is fatal:
|
||||||
// The verified SteamID64 replaces the unauthenticated `platform_id` field and is
|
// - cached_login authenticates purely by platform identity.
|
||||||
// the ONLY value ever written to an account's `platformId`. Credential (password)
|
// - create_account that asserts a platform: we won't bind an identity we can't
|
||||||
// and refresh_token grants carry their own credential and aren't gated here.
|
// prove. (create_account with NO platform is the password-account path —
|
||||||
let verifiedSteamId: string | null = null
|
// 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 platformAsserted = !Number.isNaN(platformInt)
|
const platformAsserted = !Number.isNaN(platformInt)
|
||||||
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
|
const gatedOnPlatform =
|
||||||
if (platformInt !== PlatformType.Steam) {
|
grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)
|
||||||
return c.json(
|
// The password grant only spends a verification when the client actually offered
|
||||||
{
|
// one; the rest of the time there is nothing to link.
|
||||||
error: 'invalid_grant',
|
const proof: PlatformProof =
|
||||||
error_description: 'unsupported platform; only Steam can be verified',
|
gatedOnPlatform || (platformAsserted && platformAuth !== '')
|
||||||
},
|
? await verifyPlatformProof(c.env, platformInt, platformAuth, platformId)
|
||||||
400
|
: { status: 'none' }
|
||||||
)
|
|
||||||
}
|
let verifiedPlatformId: string | null = null
|
||||||
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
|
let verifiedPlatform: number | null = null
|
||||||
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
|
if (proof.status === 'verified') {
|
||||||
if (!verified) {
|
verifiedPlatform = proof.platform
|
||||||
return c.json(
|
verifiedPlatformId = proof.platformId
|
||||||
{
|
} else if (proof.status !== 'none') {
|
||||||
error: 'invalid_grant',
|
// Log every failure, including the ones a password grant shrugs off: a player
|
||||||
error_description: 'invalid or missing platform_auth ticket',
|
// 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.
|
||||||
400
|
logger.info('platform_auth not verified', {
|
||||||
)
|
platform: platformInt,
|
||||||
}
|
platformId,
|
||||||
verifiedSteamId = verified.steamId
|
grantType,
|
||||||
platformId = verified.steamId
|
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' },
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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:
|
// Resolve the account this token is for:
|
||||||
// - create_account: mint + persist a brand-new account (auto-assigned random
|
// - 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.
|
// username — players don't pick one initially); the token's `sub` is its id.
|
||||||
@@ -482,10 +712,16 @@ const app = new Hono<App>()
|
|||||||
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
||||||
if (
|
if (
|
||||||
maxPerPlatformId > 0 &&
|
maxPerPlatformId > 0 &&
|
||||||
verifiedSteamId !== null &&
|
verifiedPlatformId !== null &&
|
||||||
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
|
(await countAccountsForPlatformIdentity(
|
||||||
|
c.env.DB,
|
||||||
|
verifiedPlatform ?? 0,
|
||||||
|
verifiedPlatformId
|
||||||
|
)) >= maxPerPlatformId
|
||||||
) {
|
) {
|
||||||
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
|
logger.info('signup rejected: platform account limit', {
|
||||||
|
platformId: verifiedPlatformId,
|
||||||
|
})
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: 'invalid_grant',
|
error: 'invalid_grant',
|
||||||
@@ -509,14 +745,15 @@ const app = new Hono<App>()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind the platform identity ONLY when a Steam ticket proved it. That bound
|
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
|
||||||
// `platformId` (the SteamID64) is what a later cached login is checked against,
|
// a Meta-validated nonce). A password/anonymous create_account (no platform)
|
||||||
// so only this Steam user can log back into the account. A password/anonymous
|
// binds nothing. The account blob keeps this first identity as its PRIMARY one
|
||||||
// create_account (no platform) binds no platformId.
|
// (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, {
|
const account = await createAccount(c.env.DB, {
|
||||||
platforms: platformInt || 0,
|
platforms: platformInt || 0,
|
||||||
platform: verifiedSteamId !== null ? 0 : undefined,
|
platform: verifiedPlatform ?? undefined,
|
||||||
platformId: verifiedSteamId ?? undefined,
|
platformId: verifiedPlatformId ?? undefined,
|
||||||
lastLoginTime: new Date().toISOString(),
|
lastLoginTime: new Date().toISOString(),
|
||||||
deviceId: deviceId || undefined,
|
deviceId: deviceId || undefined,
|
||||||
deviceClass: deviceId ? deviceClass : undefined,
|
deviceClass: deviceId ? deviceClass : undefined,
|
||||||
@@ -524,6 +761,14 @@ const app = new Hono<App>()
|
|||||||
lastLoginIp: clientIp || undefined,
|
lastLoginIp: clientIp || undefined,
|
||||||
})
|
})
|
||||||
accountId = String(account.accountId)
|
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).
|
// Establish the login password when one is posted (raw password never stored).
|
||||||
const password = typeof body.password === 'string' ? body.password : ''
|
const password = typeof body.password === 'string' ? body.password : ''
|
||||||
if (password !== '') {
|
if (password !== '') {
|
||||||
@@ -547,18 +792,25 @@ const app = new Hono<App>()
|
|||||||
} else if (grantType === 'cached_login') {
|
} else if (grantType === 'cached_login') {
|
||||||
// Platform-authenticated login into an already-linked account. The client posts
|
// Platform-authenticated login into an already-linked account. The client posts
|
||||||
// the `account_id` it got from /cachedlogin/forplatformid together with the
|
// the `account_id` it got from /cachedlogin/forplatformid together with the
|
||||||
// `platform_id` its platform_auth ticket vouches for. Authorize ONLY when that
|
// `platform_id` its platform_auth vouches for. Authorize ONLY when the link
|
||||||
// account is linked to exactly this platform identity — this is the check that
|
// table says that account is linked to exactly this platform identity — this is
|
||||||
// keeps anyone but platform user `platform_id` out of the account (platform
|
// the check that keeps anyone but that platform user out of the account
|
||||||
// ownership is the credential; no password needed). An account with no stored
|
// (platform ownership is the credential; no password needed). An account with no
|
||||||
// platform identity can't be cached-logged-into and must use a fresh login.
|
// link for the presented identity must use a password.
|
||||||
//
|
//
|
||||||
// NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket
|
// The picker lists straight from the same table, so it can only offer accounts
|
||||||
// above), never the client-supplied field. See steam-ticket.ts.
|
// 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 postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
|
||||||
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
|
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
|
||||||
if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) {
|
const linked =
|
||||||
|
account !== null &&
|
||||||
|
(await isPlatformIdentityLinked(c.env.DB, account.accountId, platformInt, platformId))
|
||||||
|
if (!account || !linked) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: 'invalid_grant',
|
error: 'invalid_grant',
|
||||||
@@ -600,6 +852,19 @@ const app = new Hono<App>()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
accountId = String(resolvedId)
|
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 setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
|
||||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ export type Env = SharedHonoEnv & {
|
|||||||
// signed here verify in all of them. Provisioned via `wrangler secrets-store`;
|
// 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).
|
// the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
|
||||||
JWT_SECRET: SecretsStoreSecret
|
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).
|
// 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.
|
// 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
|
// 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 }
|
||||||
|
}
|
||||||
@@ -69,21 +69,33 @@ export const PlatformType = {
|
|||||||
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
|
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A PlatformType by value. Only Steam can actually be verified — see the
|
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified —
|
||||||
* platform-auth notes on `POST /connect/token`.
|
* see the platform-auth notes on `POST /connect/token`.
|
||||||
*/
|
*/
|
||||||
export const PlatformTypeSchema = z
|
export const PlatformTypeSchema = z
|
||||||
.union([z.literal(-1), z.int().min(0).max(Math.max(...Object.values(PlatformType)))])
|
.union([
|
||||||
|
z.literal(-1),
|
||||||
|
z
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(Math.max(...Object.values(PlatformType))),
|
||||||
|
])
|
||||||
.describe(
|
.describe(
|
||||||
Object.entries(PlatformType)
|
Object.entries(PlatformType)
|
||||||
.map(([name, value]) => `${value} ${name}`)
|
.map(([name, value]) => `${value} ${name}`)
|
||||||
.join(', ')
|
.join(', ')
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One entry on the client's login screen, from `toCachedLogin`. */
|
/**
|
||||||
|
* 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({
|
export const CachedLogin = z.object({
|
||||||
platform: PlatformTypeSchema,
|
platform: PlatformTypeSchema,
|
||||||
platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'),
|
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'),
|
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"),
|
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
|
||||||
requirePassword: z
|
requirePassword: z
|
||||||
@@ -92,8 +104,9 @@ export const CachedLogin = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The stubbed Oculus cached login. Same shape as `CachedLogin`, but `requirePassword`
|
* The stubbed Oculus cached login served to sideloaded APKs. Same shape as `CachedLogin`,
|
||||||
* is true — nothing proves platform ownership, so the client has to prompt.
|
* 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({
|
export const FakeCachedLogin = CachedLogin.extend({
|
||||||
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
|
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
|
||||||
@@ -139,11 +152,18 @@ export const TokenRequest = z.object({
|
|||||||
platform_id: z
|
platform_id: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Unverified; ignored in favour of the Steam-verified id where a ticket is required'),
|
.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
|
platform_auth: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Steam session ticket. Required for cached_login and platform create_account'),
|
.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'),
|
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
|
||||||
device_id: z
|
device_id: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -8,12 +8,18 @@ import {
|
|||||||
getAccountsByDeviceId,
|
getAccountsByDeviceId,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
PRESENCE_SCHEMA_DDL,
|
PRESENCE_SCHEMA_DDL,
|
||||||
|
ROOM_SCHEMA_DDL,
|
||||||
SCHEMA_DDL,
|
SCHEMA_DDL,
|
||||||
seedRoomWithSubRooms,
|
seedRoomWithSubRooms,
|
||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
import { isLinkedToPlatformIdentity } from '../../auth.app'
|
import {
|
||||||
|
getLinksForAccount,
|
||||||
|
linkPlatformIdentity,
|
||||||
|
PLATFORM_BACKFILL_SQL,
|
||||||
|
PLATFORM_SCHEMA_DDL,
|
||||||
|
} from '../../platform-db'
|
||||||
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
|
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
@@ -31,14 +37,28 @@ const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
|
|||||||
// accounts the login tests authenticate as (42, 77).
|
// accounts the login tests authenticate as (42, 77).
|
||||||
const LOGIN_PASSWORD = 'correct-horse'
|
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),
|
// 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
|
// and seed the Orientation room (owned by the rooms worker) so signup can place
|
||||||
// the new player there.
|
// the new player there.
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
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 SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of REFRESH_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.
|
// 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()
|
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
|
||||||
@@ -49,12 +69,9 @@ beforeAll(async () => {
|
|||||||
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
|
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
await env.DB.prepare(
|
// The rooms worker's schema (room + interaction) — reading a room aggregates its
|
||||||
`CREATE TABLE IF NOT EXISTS room (
|
// cheer/favorite Stats from `interaction`, so both tables have to be here.
|
||||||
data TEXT NOT NULL,
|
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
|
||||||
)`
|
|
||||||
).run()
|
|
||||||
// Subrooms live in their own table; seed the Orientation room and split its subroom into it.
|
// 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()
|
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
await seedRoomWithSubRooms(env.DB, {
|
await seedRoomWithSubRooms(env.DB, {
|
||||||
@@ -101,6 +118,51 @@ async function postToken(
|
|||||||
return { status: res.status, json: (await res.json()) as Record<string, unknown> }
|
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. */
|
/** POST a form-urlencoded body to changepassword with an optional bearer token. */
|
||||||
function changePassword(body: string, token?: string): Promise<Response> {
|
function changePassword(body: string, token?: string): Promise<Response> {
|
||||||
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
|
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
|
||||||
@@ -122,16 +184,25 @@ describe('auth worker routes', () => {
|
|||||||
expect(await res.text()).toBe('"AA=="')
|
expect(await res.text()).toBe('"AA=="')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Platform 0 (Steam), not 1 — platform 1 is Oculus, which is stubbed below.
|
test.each([
|
||||||
test('GET /cachedlogin/forplatformid/:platform/:id returns [] (no cached login)', async () => {
|
['0 (Steam)', 0],
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/abc123`)
|
['1 (Meta)', 1],
|
||||||
expect(res.status).toBe(200)
|
])(
|
||||||
expect(await res.json()).toEqual([])
|
'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([])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Oculus is stubbed: no DB lookup, one canned entry whatever the id.
|
// The one stubbed identity: `1/1` consults nothing and always answers the canned
|
||||||
test('GET /cachedlogin/forplatformid/1/:id returns the canned Oculus entry', async () => {
|
// entry, which is how a sideloaded APK (no Meta SDK, so no real identity) gets off
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/anything`)
|
// 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(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual([
|
expect(await res.json()).toEqual([
|
||||||
{
|
{
|
||||||
@@ -144,10 +215,11 @@ describe('auth worker routes', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
// Only Steam (platform 0) can be verified (via its signed platform_auth ticket),
|
// Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth
|
||||||
// so every OTHER platform is rejected on the platform-authenticated grants — we
|
// ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected
|
||||||
// won't bind or authorize an identity we can't prove.
|
// on the platform-authenticated grants: we won't bind or authorize an identity we
|
||||||
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
|
// can't prove.
|
||||||
|
test.each([2, 3, 4, 5, 6, 7, 8])(
|
||||||
'create_account rejects unverifiable platform %i',
|
'create_account rejects unverifiable platform %i',
|
||||||
async (platform) => {
|
async (platform) => {
|
||||||
const res = await postToken(
|
const res = await postToken(
|
||||||
@@ -155,11 +227,11 @@ describe('auth worker routes', () => {
|
|||||||
)
|
)
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
expect(res.json.error).toBe('invalid_grant')
|
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',
|
'cached_login rejects unverifiable platform %i',
|
||||||
async (platform) => {
|
async (platform) => {
|
||||||
const res = await postToken(
|
const res = await postToken(
|
||||||
@@ -167,7 +239,7 @@ describe('auth worker routes', () => {
|
|||||||
)
|
)
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
expect(res.json.error).toBe('invalid_grant')
|
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')
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -191,6 +263,109 @@ describe('auth worker routes', () => {
|
|||||||
expect(res.json.error_description).toContain('platform_auth')
|
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 () => {
|
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
|
// Seed a Steam-linked account directly (a real create_account needs a live
|
||||||
// ticket); assert the picker projects the CachedLogin DTO the client expects.
|
// ticket); assert the picker projects the CachedLogin DTO the client expects.
|
||||||
@@ -206,6 +381,7 @@ describe('auth worker routes', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
|
await linkPlatformIdentity(env.DB, 31380, 0, steamId)
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
|
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual([
|
expect(await res.json()).toEqual([
|
||||||
@@ -219,32 +395,69 @@ describe('auth worker routes', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a Steam-linked account with no stored `platform` field still cached-logs in', async () => {
|
test('one account, a Steam and a Meta identity: both pickers offer it', async () => {
|
||||||
// Regression: nothing defaults an account's `platform` (see defaultAccount), so a
|
// The point of the link table. The same account is reachable from the PC and from
|
||||||
// Steam-linked account can carry a platformId with no platform. The picker offered
|
// the headset, and each picker reports the identity IT was asked about — that's
|
||||||
// such an account (it treats a missing platform as Steam) while the cached_login
|
// what the client posts back on the cached_login grant.
|
||||||
// grant rejected it — "no linked account for this platform identity" forever.
|
const steamId = '76561197962463777'
|
||||||
// Both now run the same check.
|
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 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)')
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
.bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId }))
|
.bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId }))
|
||||||
.run()
|
.run()
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
|
|
||||||
const offered = (await res.json()) as Array<{ accountId: number; platform: number }>
|
// No link row yet: not offered.
|
||||||
expect(offered.map((a) => a.accountId)).toContain(8)
|
const before = await cachedLogins(0, steamId)
|
||||||
expect(offered.find((a) => a.accountId === 8)?.platform).toBe(0)
|
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 () => {
|
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
|
||||||
@@ -581,6 +794,157 @@ describe('auth worker routes', () => {
|
|||||||
expect(payload.platform_id).toBe('steam-123')
|
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 () => {
|
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 login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`)
|
||||||
const refreshToken = login.json.refresh_token as string
|
const refreshToken = login.json.refresh_token as string
|
||||||
@@ -720,3 +1084,67 @@ describe('auth worker routes', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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
|
// 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"
|
// 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.
|
// 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": [
|
"secrets_store_secrets": [
|
||||||
{
|
{
|
||||||
"binding": "JWT_SECRET",
|
"binding": "JWT_SECRET",
|
||||||
"store_id": "local",
|
"store_id": "local",
|
||||||
"secret_name": "JWT_SECRET"
|
"secret_name": "JWT_SECRET"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"binding": "META_APP_SECRET",
|
||||||
|
"store_id": "local",
|
||||||
|
"secret_name": "META_APP_SECRET"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"upload_source_maps": true,
|
"upload_source_maps": true,
|
||||||
|
|||||||
+26
-3
@@ -195,6 +195,28 @@ const app = new Hono<App>()
|
|||||||
(c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`)
|
(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
|
// The generated spec. Documentation only — no request is validated against it (see
|
||||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||||
app.get(
|
app.get(
|
||||||
@@ -209,10 +231,11 @@ app.get(
|
|||||||
description: [
|
description: [
|
||||||
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
|
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
|
||||||
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
|
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
|
||||||
'signatures, saved room scenes and invention data — out of the shared `recflare-cdn`',
|
'signatures, saved room scenes, invention data and generic client uploads — out of',
|
||||||
'R2 bucket, plus the one bundled config file the loading screen reads.',
|
'the shared `recflare-cdn` R2 bucket, plus the one bundled config file the loading',
|
||||||
|
'screen reads.',
|
||||||
'',
|
'',
|
||||||
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`) and served as',
|
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
|
||||||
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
|
'`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',
|
'are unauthenticated — a caller needs the exact key, which only comes from an',
|
||||||
'authenticated call to another worker.',
|
'authenticated call to another worker.',
|
||||||
|
|||||||
@@ -101,6 +101,21 @@ describe('cdn endpoints', () => {
|
|||||||
expect(res.status).toBe(404)
|
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 () => {
|
test('GET /openapi.json documents every route', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -124,6 +139,7 @@ describe('cdn endpoints', () => {
|
|||||||
expect([...documented].sort()).toEqual([
|
expect([...documented].sort()).toEqual([
|
||||||
'GET /',
|
'GET /',
|
||||||
'GET /config/LoadingScreenTipData',
|
'GET /config/LoadingScreenTipData',
|
||||||
|
'GET /data/{id}',
|
||||||
'GET /invention/{dataBlob}',
|
'GET /invention/{dataBlob}',
|
||||||
'GET /room/{dataBlob}',
|
'GET /room/{dataBlob}',
|
||||||
'GET /sigs/{sigName}',
|
'GET /sigs/{sigName}',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
|||||||
|
|
||||||
import '../../chat.app'
|
import '../../chat.app'
|
||||||
|
|
||||||
|
import { NotificationType } from '../../../../notify/src/notification-types'
|
||||||
import {
|
import {
|
||||||
ChatModerationState,
|
ChatModerationState,
|
||||||
getMessage,
|
getMessage,
|
||||||
@@ -721,7 +722,7 @@ describe('ChatMessageReceived push', () => {
|
|||||||
/** The stubbed hub (see vitest.config.ts) records what it was sent. */
|
/** The stubbed hub (see vitest.config.ts) records what it was sent. */
|
||||||
interface SentNotification {
|
interface SentNotification {
|
||||||
playerId: number
|
playerId: number
|
||||||
notificationType: number
|
notificationType: NotificationType
|
||||||
data: Record<string, unknown>
|
data: Record<string, unknown>
|
||||||
}
|
}
|
||||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
|
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
|
||||||
@@ -759,8 +760,9 @@ describe('ChatMessageReceived push', () => {
|
|||||||
|
|
||||||
const sent = await hub.getByName('global').takeSent()
|
const sent = await hub.getByName('global').takeSent()
|
||||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
|
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
|
||||||
// NotificationType.ChatMessageReceived
|
expect(
|
||||||
expect(sent.every((n) => n.notificationType === 90)).toBe(true)
|
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
|
||||||
|
).toBe(true)
|
||||||
expect(sent[0]!.data).toEqual({
|
expect(sent[0]!.data).toEqual({
|
||||||
chatMessageId: chatThread.latestMessage.chatMessageId,
|
chatMessageId: chatThread.latestMessage.chatMessageId,
|
||||||
chatThreadId: chatThread.chatThreadId,
|
chatThreadId: chatThread.chatThreadId,
|
||||||
@@ -982,7 +984,9 @@ describe('POST /thread/:id', () => {
|
|||||||
it('pushes ChatMessageReceived to every member', async () => {
|
it('pushes ChatMessageReceived to every member', async () => {
|
||||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
|
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
|
||||||
getByName(name: string): {
|
getByName(name: string): {
|
||||||
takeSent(): Promise<Array<{ playerId: number; notificationType: number }>>
|
takeSent(): Promise<
|
||||||
|
Array<{ playerId: number; notificationType: NotificationType }>
|
||||||
|
>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const caller = 889005
|
const caller = 889005
|
||||||
@@ -992,7 +996,9 @@ describe('POST /thread/:id', () => {
|
|||||||
await send(caller, `/thread/${chatThreadId}`)
|
await send(caller, `/thread/${chatThreadId}`)
|
||||||
const sent = await hub.getByName('global').takeSent()
|
const sent = await hub.getByName('global').takeSent()
|
||||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 889006])
|
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 889006])
|
||||||
expect(sent.every((n) => n.notificationType === 90)).toBe(true)
|
expect(
|
||||||
|
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
|
||||||
|
).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports invalid arguments for blank contents without storing anything', async () => {
|
it('reports invalid arguments for blank contents without storing anything', async () => {
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { Hono } from 'hono'
|
|||||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
|
import {
|
||||||
|
glyphLength,
|
||||||
|
MAX_CLUB_DESCRIPTION_LENGTH,
|
||||||
|
MAX_CLUB_NAME_LENGTH,
|
||||||
|
} from '@repo/domain'
|
||||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
@@ -101,8 +106,6 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
|||||||
*/
|
*/
|
||||||
const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
|
const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
|
||||||
|
|
||||||
/** Longest a club name may be (the reference's MaxNameLength). */
|
|
||||||
const MAX_CLUB_NAME_LENGTH = 16
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tiers `members/invite` may grant — the real member roles only. Creator (100) is
|
* The tiers `members/invite` may grant — the real member roles only. Creator (100) is
|
||||||
@@ -665,6 +668,14 @@ const app = new Hono<App>()
|
|||||||
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
|
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
|
||||||
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
|
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
|
// The per-account cap, checked after the cheap validations so a rejected name
|
||||||
// costs no extra D1 read.
|
// costs no extra D1 read.
|
||||||
const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT)
|
const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT)
|
||||||
@@ -778,9 +789,19 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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, {
|
const updated = await updateClub(c.env.DB, clubId, {
|
||||||
name,
|
name,
|
||||||
description: field('description') || undefined,
|
description,
|
||||||
category: field('category')?.trim() || undefined,
|
category: field('category')?.trim() || undefined,
|
||||||
visibility: parseVisibility(field('visibility')),
|
visibility: parseVisibility(field('visibility')),
|
||||||
joinability: parseJoinability(field('joinability')),
|
joinability: parseJoinability(field('joinability')),
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const EmptyObject = z.object({})
|
|||||||
*/
|
*/
|
||||||
export const ClubDto = z.object({
|
export const ClubDto = z.object({
|
||||||
ClubId: z.int(),
|
ClubId: z.int(),
|
||||||
Name: z.string().describe('At most 16 characters; letters, digits and basic punctuation'),
|
Name: z.string().describe('At most 40 characters; letters, digits and basic punctuation'),
|
||||||
Description: z.string(),
|
Description: z.string(),
|
||||||
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
|
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
|
||||||
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
|
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
|
||||||
@@ -277,8 +277,8 @@ export const ChatDisabledResponse = z.boolean()
|
|||||||
export const CreateClubRequest = z.object({
|
export const CreateClubRequest = z.object({
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
.describe('Required; at most 16 characters, letters/digits/basic punctuation only'),
|
.describe('Required; at most 40 characters, letters/digits/basic punctuation only'),
|
||||||
description: z.string().optional(),
|
description: z.string().optional().describe('At most 512 characters'),
|
||||||
category: z.string().optional().describe('Defaults to Social when unset'),
|
category: z.string().optional().describe('Defaults to Social when unset'),
|
||||||
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
||||||
joinability: z
|
joinability: z
|
||||||
@@ -292,8 +292,11 @@ export const CreateClubRequest = z.object({
|
|||||||
|
|
||||||
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
|
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
|
||||||
export const ModifyClubRequest = z.object({
|
export const ModifyClubRequest = z.object({
|
||||||
name: z.string().optional().describe('Empty means unchanged, not "clear it"'),
|
name: z
|
||||||
description: z.string().optional().describe('Empty means unchanged'),
|
.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(),
|
category: z.string().optional(),
|
||||||
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
||||||
joinability: z
|
joinability: z
|
||||||
|
|||||||
@@ -241,9 +241,16 @@ describe('clubs endpoints', () => {
|
|||||||
expect(emoji.status).toBe(400)
|
expect(emoji.status).toBe(400)
|
||||||
expect(await emoji.json()).toMatchObject({ success: false, value: null })
|
expect(await emoji.json()).toMatchObject({ success: false, value: null })
|
||||||
|
|
||||||
// Names cap at 16 characters.
|
// Names cap at 40 characters.
|
||||||
expect((await create({ name: 'a'.repeat(17) })).status).toBe(400)
|
expect((await create({ name: 'a'.repeat(41) })).status).toBe(400)
|
||||||
expect((await create({ name: 'a'.repeat(16) })).status).toBe(200)
|
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.
|
// Basic punctuation is allowed.
|
||||||
expect((await create({ name: "Bob's Club (2)" })).status).toBe(200)
|
expect((await create({ name: "Bob's Club (2)" })).status).toBe(200)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
+190
-14
@@ -2,10 +2,21 @@ import { Hono } from 'hono'
|
|||||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain'
|
import {
|
||||||
|
consumeGift,
|
||||||
|
createGift,
|
||||||
|
getGift,
|
||||||
|
getPendingGifts,
|
||||||
|
grantInvention,
|
||||||
|
ownsInvention,
|
||||||
|
} from '@repo/domain'
|
||||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
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
|
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
||||||
// as a value — the enum has no runtime dependencies.
|
// as a value — the enum has no runtime dependencies.
|
||||||
import { NotificationType } from '../../notify/src/notification-types'
|
import { NotificationType } from '../../notify/src/notification-types'
|
||||||
@@ -17,7 +28,10 @@ import weeklyChallenge from '../static/weekly-challenge.json'
|
|||||||
import { getAvatar, setAvatar } from './avatar-db'
|
import { getAvatar, setAvatar } from './avatar-db'
|
||||||
import {
|
import {
|
||||||
ALL_PLATFORMS,
|
ALL_PLATFORMS,
|
||||||
|
creditCurrency,
|
||||||
|
CurrencyType,
|
||||||
DEFAULT_STARTING_TOKENS,
|
DEFAULT_STARTING_TOKENS,
|
||||||
|
ensureStartingBalances,
|
||||||
getBalance,
|
getBalance,
|
||||||
isSpendable,
|
isSpendable,
|
||||||
spendCurrency,
|
spendCurrency,
|
||||||
@@ -34,6 +48,7 @@ import {
|
|||||||
AUTHED,
|
AUTHED,
|
||||||
AvatarV2Dto,
|
AvatarV2Dto,
|
||||||
BalanceEntry,
|
BalanceEntry,
|
||||||
|
BuyInventionResponse,
|
||||||
BuyItemRequest,
|
BuyItemRequest,
|
||||||
BuyItemResponse,
|
BuyItemResponse,
|
||||||
ChallengeProgressRequest,
|
ChallengeProgressRequest,
|
||||||
@@ -69,7 +84,8 @@ import type { Outfit } from './outfit-db'
|
|||||||
/**
|
/**
|
||||||
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
|
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
|
||||||
* the `econ` service (these are separate from the main `api` worker). Balances,
|
* the `econ` service (these are separate from the main `api` worker). Balances,
|
||||||
* inventory, consumables, saved outfits, avatars and gift boxes are D1-backed;
|
* 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
|
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
|
||||||
* binding. Some routes are still empty-list stubs (room keys, wishlist, …).
|
* binding. Some routes are still empty-list stubs (room keys, wishlist, …).
|
||||||
*
|
*
|
||||||
@@ -187,23 +203,30 @@ async function pushConsumableAdded(
|
|||||||
* Push a StorefrontBalanceUpdate to a player after their balance changes, mirroring the
|
* Push a StorefrontBalanceUpdate to a player after their balance changes, mirroring the
|
||||||
* reference's
|
* reference's
|
||||||
* `HubSendToPlayer(accountID, NotifFrame(StorefrontBalanceUpdate, {Balance, CurrencyType, BalanceType}))`.
|
* `HubSendToPlayer(accountID, NotifFrame(StorefrontBalanceUpdate, {Balance, CurrencyType, BalanceType}))`.
|
||||||
* The client applies it to the shown balance so a purchase debit reflects immediately,
|
* The client applies it to the shown balance so a purchase reflects immediately, without
|
||||||
* without waiting for a `GET /balance` re-fetch. `Balance` is the resulting total in that
|
* waiting for a `GET /balance` re-fetch.
|
||||||
* currency (not the delta), `BalanceType` is -2 (account-wide, all platforms). Best-effort:
|
*
|
||||||
* a hub failure is logged and swallowed, since the balance change has already committed.
|
* `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(
|
async function pushBalanceUpdate(
|
||||||
c: Context<App>,
|
c: Context<App>,
|
||||||
accountId: number,
|
accountId: number,
|
||||||
currencyType: number,
|
currencyType: number,
|
||||||
balance: number
|
change: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
accountId,
|
accountId,
|
||||||
NotificationType.StorefrontBalanceUpdate,
|
NotificationType.StorefrontBalanceUpdate,
|
||||||
{
|
{
|
||||||
Balance: balance,
|
Balance: change,
|
||||||
CurrencyType: currencyType,
|
CurrencyType: currencyType,
|
||||||
BalanceType: ALL_PLATFORMS,
|
BalanceType: ALL_PLATFORMS,
|
||||||
}
|
}
|
||||||
@@ -996,7 +1019,8 @@ const app = new Hono<App>({ strict: false })
|
|||||||
'still matches, debits the buyer atomically, grants the item (into the inventory or',
|
'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',
|
'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',
|
'player, but the caller always pays. `Balance` in the response is the CHANGE (negated',
|
||||||
'price), not the new total. Pushes a StorefrontBalanceUpdate socket notification.',
|
'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(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'),
|
requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'),
|
||||||
@@ -1117,11 +1141,11 @@ const app = new Hono<App>({ strict: false })
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Push the buyer's new (reduced) balance over the socket so their client updates the
|
// Push the debit over the socket so the buyer's client updates the shown total
|
||||||
// shown total immediately — the buyer (`id`) is who was debited, in the currency they
|
// immediately — the buyer (`id`) is who was charged, in the currency they spent. The
|
||||||
// spent. Best-effort; the HTTP response still carries the change either way.
|
// frame carries the CHANGE, so a purchase is negative. Best-effort; the HTTP response
|
||||||
const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
|
// carries the same change either way.
|
||||||
await pushBalanceUpdate(c, id, currencyType as number, newBalance)
|
await pushBalanceUpdate(c, id, currencyType as number, -price.Price)
|
||||||
|
|
||||||
// The response mirrors a captured real buyItem: `Balance` is the change applied (the
|
// 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
|
// negated price), not the resulting balance (the client reads its new total from
|
||||||
@@ -1164,6 +1188,158 @@ const app = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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
|
// Storefront ad-carousel items. Served from the bundled static JSON — one
|
||||||
// placeholder banner with no purchasable items until real promo data exists.
|
// placeholder banner with no purchasable items until real promo data exists.
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -130,7 +130,34 @@ export const BuyItemResponse = z.object({
|
|||||||
BalanceType: z.int().describe('-2 = account-wide'),
|
BalanceType: z.int().describe('-2 = account-wide'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** buyItem error body (`{ error }`), returned on 400/404/409. */
|
/**
|
||||||
|
* `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() })
|
export const ErrorResponse = z.object({ error: z.string() })
|
||||||
|
|
||||||
// ---- Request schemas -------------------------------------------------------
|
// ---- Request schemas -------------------------------------------------------
|
||||||
|
|||||||
@@ -4,8 +4,15 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
|||||||
|
|
||||||
import '../../econ.app'
|
import '../../econ.app'
|
||||||
|
|
||||||
import { RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain'
|
import {
|
||||||
|
getOwnedInventionIds,
|
||||||
|
INVENTORY_INVENTION_SCHEMA_DDL,
|
||||||
|
RECEIVED_GIFT_SCHEMA_DDL,
|
||||||
|
} from '@repo/domain'
|
||||||
|
|
||||||
|
// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL
|
||||||
|
// is built here too (see the same cross-worker import in econ.app.ts).
|
||||||
|
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
|
||||||
import { SCHEMA_DDL } from '../../avatar-db'
|
import { SCHEMA_DDL } from '../../avatar-db'
|
||||||
import {
|
import {
|
||||||
BALANCE_SCHEMA_DDL,
|
BALANCE_SCHEMA_DDL,
|
||||||
@@ -39,11 +46,76 @@ beforeAll(async () => {
|
|||||||
for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
|
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
|
||||||
.run()
|
.run()
|
||||||
|
for (const invention of SEEDED_INVENTIONS) {
|
||||||
|
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
|
||||||
|
.bind(JSON.stringify(invention))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inventions the buyInvention tests buy (or fail to buy). Only the fields that path
|
||||||
|
* reads are meaningful — id, creator, published flag and price — but the record is
|
||||||
|
* shaped like a real stored `RRInvention` so the response envelope is realistic.
|
||||||
|
*/
|
||||||
|
function invention(
|
||||||
|
inventionId: number,
|
||||||
|
overrides: { CreatorPlayerId?: number; IsPublished?: boolean; Price?: number } = {}
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
InventionId: inventionId,
|
||||||
|
ReplicationId: `replication-${inventionId}`,
|
||||||
|
CreatorPlayerId: 999,
|
||||||
|
Name: `Invention ${inventionId}`,
|
||||||
|
Description: 'A test invention',
|
||||||
|
ImageName: '',
|
||||||
|
CurrentVersionNumber: 1,
|
||||||
|
CurrentVersion: {
|
||||||
|
InventionId: inventionId,
|
||||||
|
ReplicationId: `version-${inventionId}`,
|
||||||
|
VersionNumber: 1,
|
||||||
|
BlobName: `invention-${inventionId}.inv`,
|
||||||
|
BlobHash: null,
|
||||||
|
InstantiationCost: 0,
|
||||||
|
LightsCost: 0,
|
||||||
|
ChipsCost: 0,
|
||||||
|
CloudVariablesCost: 0,
|
||||||
|
AICost: 0,
|
||||||
|
},
|
||||||
|
Accessibility: 0,
|
||||||
|
IsPublished: true,
|
||||||
|
IsFeatured: false,
|
||||||
|
ModifiedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
CreatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
FirstPublishedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
CreationRoomId: 0,
|
||||||
|
NumPlayersHaveUsedInRoom: 0,
|
||||||
|
NumDownloads: 0,
|
||||||
|
CheerCount: 0,
|
||||||
|
CreatorPermission: 100,
|
||||||
|
GeneralPermission: 20,
|
||||||
|
IsAGInvention: false,
|
||||||
|
IsCertifiedInvention: false,
|
||||||
|
Price: 0,
|
||||||
|
AllowTrial: true,
|
||||||
|
HideFromPlayer: false,
|
||||||
|
ReferencedInventions: [],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEEDED_INVENTIONS = [
|
||||||
|
invention(8), // free, published, someone else's — the sellable one
|
||||||
|
invention(9, { Price: 250 }), // priced: buying it pays creator 999 250 tokens
|
||||||
|
invention(10, { IsPublished: false }), // a draft, not on sale even at 0
|
||||||
|
invention(11, { CreatorPlayerId: 60 }), // account 60's own invention
|
||||||
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A real outfit as the client posts it to /api/avatar/v3/saved/set — kept verbatim
|
* A real outfit as the client posts it to /api/avatar/v3/saved/set — kept verbatim
|
||||||
* (including the JSON-in-a-string OutfitSelectionsV2/FaceFeatures fields) so the
|
* (including the JSON-in-a-string OutfitSelectionsV2/FaceFeatures fields) so the
|
||||||
@@ -629,6 +701,7 @@ describe('econ endpoints', () => {
|
|||||||
|
|
||||||
test('POST /api/storefronts/v2/buyItem debits, grants the item, and hands back a gift box', async () => {
|
test('POST /api/storefronts/v2/buyItem debits, grants the item, and hands back a gift box', async () => {
|
||||||
// Account 20: fresh, so its first balance touch grants the 10000 default.
|
// Account 20: fresh, so its first balance touch grants the 10000 default.
|
||||||
|
await drainFrames()
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...(await bearer('20')), 'Content-Type': 'application/json' },
|
headers: { ...(await bearer('20')), 'Content-Type': 'application/json' },
|
||||||
@@ -656,6 +729,16 @@ describe('econ endpoints', () => {
|
|||||||
expect(gift.AvatarItemDesc).not.toBe('')
|
expect(gift.AvatarItemDesc).not.toBe('')
|
||||||
expect(gift.Id).toBeGreaterThan(0)
|
expect(gift.Id).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// The socket frame carries the same change the response does — the client adds it to
|
||||||
|
// the balance it is showing, so the resulting total here would double-count the 9550.
|
||||||
|
expect(await drainFrames()).toEqual([
|
||||||
|
{
|
||||||
|
accountId: 20,
|
||||||
|
notificationType: STOREFRONT_BALANCE_UPDATE,
|
||||||
|
payload: { Balance: -450, CurrencyType: 2, BalanceType: -2 },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
// The balance endpoint reflects the debit (this is the resulting total, 10000 - 450).
|
// The balance endpoint reflects the debit (this is the resulting total, 10000 - 450).
|
||||||
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||||
headers: await bearer('20'),
|
headers: await bearer('20'),
|
||||||
@@ -920,6 +1003,153 @@ describe('econ endpoints', () => {
|
|||||||
expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true)
|
expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The StorefrontBalanceUpdate (and other) frames the worker has pushed since the last
|
||||||
|
* drain, read back off the stub hub in vitest.config.ts. Notification sends are
|
||||||
|
* best-effort — the worker logs and swallows a hub failure — so this is the only way a
|
||||||
|
* test sees what was actually pushed.
|
||||||
|
*/
|
||||||
|
const drainFrames = async (): Promise<
|
||||||
|
Array<{ accountId: number; notificationType: number; payload: Record<string, number> }>
|
||||||
|
> =>
|
||||||
|
(
|
||||||
|
env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as {
|
||||||
|
drainFrames(): Promise<
|
||||||
|
Array<{ accountId: number; notificationType: number; payload: Record<string, number> }>
|
||||||
|
>
|
||||||
|
}
|
||||||
|
).drainFrames()
|
||||||
|
|
||||||
|
/** `NotificationType.StorefrontBalanceUpdate` in the notify worker's enum. */
|
||||||
|
const STOREFRONT_BALANCE_UPDATE = 61
|
||||||
|
|
||||||
|
// buyInvention is a GET with query params — that is how the client sends it.
|
||||||
|
const buyInvention = async (sub: string, inventionId: number, requestedPrice = 0) =>
|
||||||
|
exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=${inventionId}&requestedPrice=${requestedPrice}`,
|
||||||
|
{ headers: await bearer(sub) }
|
||||||
|
)
|
||||||
|
|
||||||
|
test('GET /api/storefronts/v2/buyInvention 401s without a token', async () => {
|
||||||
|
const res = await exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=8&requestedPrice=0`
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /api/storefronts/v2/buyInvention records ownership of a free invention', async () => {
|
||||||
|
const res = await buyInvention('50', 8)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
BalanceUpdateResponse: {
|
||||||
|
Balance: number
|
||||||
|
BalanceType: number
|
||||||
|
CurrencyType: number
|
||||||
|
BalanceUpdates: Array<{ UpdateResponse: number; Data: { InventionId: number } }>
|
||||||
|
}
|
||||||
|
InventionResponse: {
|
||||||
|
Status: number
|
||||||
|
Invention: { InventionId: number; Name: string }
|
||||||
|
InventionVersion: { InventionId: number; VersionNumber: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nothing was debited, so `Balance` is the resulting total — the untouched starting
|
||||||
|
// grant — not a change, unlike buyItem's.
|
||||||
|
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS)
|
||||||
|
expect(body.BalanceUpdateResponse.CurrencyType).toBe(CurrencyType.RecCenterTokens)
|
||||||
|
expect(body.BalanceUpdateResponse.BalanceType).toBe(-2)
|
||||||
|
expect(body.BalanceUpdateResponse.BalanceUpdates[0].Data.InventionId).toBe(8)
|
||||||
|
expect(body.InventionResponse.Status).toBe(0)
|
||||||
|
expect(body.InventionResponse.Invention.Name).toBe('Invention 8')
|
||||||
|
expect(body.InventionResponse.InventionVersion.VersionNumber).toBe(1)
|
||||||
|
|
||||||
|
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
|
||||||
|
|
||||||
|
// Owning an invention is boolean: buying it again is a conflict, not a second row.
|
||||||
|
expect((await buyInvention('50', 8)).status).toBe(409)
|
||||||
|
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /api/storefronts/v2/buyInvention pays the creator the buyer’s tokens', async () => {
|
||||||
|
// Invention 9 costs 250 and was made by account 999. Buying it moves 250 tokens from
|
||||||
|
// the buyer to that creator — no house cut, so the two sides are equal and opposite.
|
||||||
|
await drainFrames()
|
||||||
|
const res = await buyInvention('51', 9, 250)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { BalanceUpdateResponse: { Balance: number } }
|
||||||
|
// `Balance` is the buyer's RESULTING total, so it already has the debit in it.
|
||||||
|
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS - 250)
|
||||||
|
expect(
|
||||||
|
await getBalance(env.DB, 51, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||||
|
).toBe(DEFAULT_STARTING_TOKENS - 250)
|
||||||
|
// The creator had never touched their balance: they keep their starting grant AND get
|
||||||
|
// paid, rather than the payout standing in for the grant.
|
||||||
|
expect(
|
||||||
|
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||||
|
).toBe(DEFAULT_STARTING_TOKENS + 250)
|
||||||
|
expect(await getOwnedInventionIds(env.DB, 51)).toEqual([9])
|
||||||
|
|
||||||
|
// Both sides get a socket frame carrying their CHANGE, not their new total: the client
|
||||||
|
// ADDS what it receives to the balance it is showing, so a total would have the creator
|
||||||
|
// reading their own balance plus the payout. Equal and opposite, like the ledger.
|
||||||
|
expect(await drainFrames()).toEqual([
|
||||||
|
{
|
||||||
|
accountId: 999,
|
||||||
|
notificationType: STOREFRONT_BALANCE_UPDATE,
|
||||||
|
payload: { Balance: 250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: 51,
|
||||||
|
notificationType: STOREFRONT_BALANCE_UPDATE,
|
||||||
|
payload: { Balance: -250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /api/storefronts/v2/buyInvention rejects a stale price and an unaffordable one', async () => {
|
||||||
|
// Sending 0 for the 250-token invention 9 is a stale (or tampered) price.
|
||||||
|
expect((await buyInvention('53', 9, 0)).status).toBe(409)
|
||||||
|
|
||||||
|
// Account 54 can't afford it: nothing is debited, nobody is paid, nothing is owned.
|
||||||
|
await spendCurrency(
|
||||||
|
env.DB,
|
||||||
|
54,
|
||||||
|
CurrencyType.RecCenterTokens,
|
||||||
|
DEFAULT_STARTING_TOKENS,
|
||||||
|
DEFAULT_STARTING_TOKENS
|
||||||
|
)
|
||||||
|
const creatorBefore = await getBalance(
|
||||||
|
env.DB,
|
||||||
|
999,
|
||||||
|
CurrencyType.RecCenterTokens,
|
||||||
|
DEFAULT_STARTING_TOKENS
|
||||||
|
)
|
||||||
|
expect((await buyInvention('54', 9, 250)).status).toBe(400)
|
||||||
|
expect(
|
||||||
|
await getBalance(env.DB, 54, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||||
|
).toBe(0)
|
||||||
|
expect(
|
||||||
|
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||||
|
).toBe(creatorBefore)
|
||||||
|
expect(await getOwnedInventionIds(env.DB, 53)).toEqual([])
|
||||||
|
expect(await getOwnedInventionIds(env.DB, 54)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /api/storefronts/v2/buyInvention rejects drafts, self-buys and unknown ids', async () => {
|
||||||
|
// Unpublished — a draft is not on sale, free or not.
|
||||||
|
expect((await buyInvention('52', 10)).status).toBe(403)
|
||||||
|
// Account 60 created invention 11; a creator already owns it.
|
||||||
|
expect((await buyInvention('60', 11)).status).toBe(400)
|
||||||
|
expect((await buyInvention('52', 9999)).status).toBe(404)
|
||||||
|
// Missing/non-numeric inventionId.
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyInvention`, {
|
||||||
|
headers: await bearer('52'),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(await getOwnedInventionIds(env.DB, 52)).toEqual([])
|
||||||
|
expect(await getOwnedInventionIds(env.DB, 60)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
|
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
|
||||||
// Buy an item for account 24, then consume the box the way the client does: on the
|
// Buy an item for account 24, then consume the box the way the client does: on the
|
||||||
// econ host, with a form body (`Id=..&UnlockedLevel=..`).
|
// econ host, with a form body (`Id=..&UnlockedLevel=..`).
|
||||||
@@ -1182,6 +1412,7 @@ describe('econ endpoints', () => {
|
|||||||
'GET /api/roomkeys/v1/mine',
|
'GET /api/roomkeys/v1/mine',
|
||||||
'GET /api/roomkeys/v1/room',
|
'GET /api/roomkeys/v1/room',
|
||||||
'GET /api/storefronts/v1/adcarouselitems',
|
'GET /api/storefronts/v1/adcarouselitems',
|
||||||
|
'GET /api/storefronts/v2/buyInvention',
|
||||||
'GET /api/storefronts/v3/giftdropstore/{id}',
|
'GET /api/storefronts/v3/giftdropstore/{id}',
|
||||||
'GET /api/storefronts/v4/balance/{currencyType}',
|
'GET /api/storefronts/v4/balance/{currencyType}',
|
||||||
'GET /econ/customAvatarItems/v1/owned',
|
'GET /econ/customAvatarItems/v1/owned',
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ export default defineConfig({
|
|||||||
// isolated test, so provide a minimal stub exposing the same NotificationsHub
|
// isolated test, so provide a minimal stub exposing the same NotificationsHub
|
||||||
// RPC surface — enough for the runtime to start and for notification sends to
|
// RPC surface — enough for the runtime to start and for notification sends to
|
||||||
// no-op.
|
// no-op.
|
||||||
|
//
|
||||||
|
// The stub RECORDS what it was sent (`drainFrames`) rather than discarding it.
|
||||||
|
// Pushes are best-effort and swallow their own errors, so a frame carrying the
|
||||||
|
// wrong payload is otherwise invisible here — which is exactly how
|
||||||
|
// StorefrontBalanceUpdate shipped with the resulting total in a field the
|
||||||
|
// client adds to what it is already showing.
|
||||||
workers: [
|
workers: [
|
||||||
{
|
{
|
||||||
name: 'notify',
|
name: 'notify',
|
||||||
@@ -24,8 +30,18 @@ export default defineConfig({
|
|||||||
script: `
|
script: `
|
||||||
import { DurableObject } from 'cloudflare:workers'
|
import { DurableObject } from 'cloudflare:workers'
|
||||||
export class NotificationsHub extends DurableObject {
|
export class NotificationsHub extends DurableObject {
|
||||||
async notifyPlayer() { return { delivered: 0, queued: true } }
|
frames = []
|
||||||
|
async notifyPlayer(accountId, notificationType, payload) {
|
||||||
|
this.frames.push({ accountId, notificationType, payload })
|
||||||
|
return { delivered: 0, queued: true }
|
||||||
|
}
|
||||||
async broadcast() { return { delivered: 0 } }
|
async broadcast() { return { delivered: 0 } }
|
||||||
|
/** Everything pushed since the last call, then forget it. */
|
||||||
|
async drainFrames() {
|
||||||
|
const drained = this.frames
|
||||||
|
this.frames = []
|
||||||
|
return drained
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export default { fetch() { return new Response('ok') } }
|
export default { fetch() { return new Response('ok') } }
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ export type Env = SharedHonoEnv & {
|
|||||||
DB: D1Database
|
DB: D1Database
|
||||||
/** R2 bucket holding the served image objects, keyed by filename. */
|
/** R2 bucket holding the served image objects, keyed by filename. */
|
||||||
IMAGES: R2Bucket
|
IMAGES: R2Bucket
|
||||||
|
/**
|
||||||
|
* Shared `recflare-cdn` bucket. Only its `image/` prefix is read here: images
|
||||||
|
* uploaded through the `storage` worker are stored extensionless under
|
||||||
|
* `image/<date>/<uuid>` and requested from this worker by the bare name.
|
||||||
|
*/
|
||||||
|
CDN_ASSETS: R2Bucket
|
||||||
/** Static assets (fallback images) served from `static/`. */
|
/** Static assets (fallback images) served from `static/`. */
|
||||||
ASSETS: Fetcher
|
ASSETS: Fetcher
|
||||||
/**
|
/**
|
||||||
|
|||||||
+33
-4
@@ -15,6 +15,9 @@ const SIGNATURE_KEY_ID = 'KEY:RSA:p1.rec.net'
|
|||||||
/** Static asset served (200) when the requested key is missing from R2. */
|
/** Static asset served (200) when the requested key is missing from R2. */
|
||||||
const FALLBACK_ASSET_PATH = '/DefaultProfileImage.jpg'
|
const FALLBACK_ASSET_PATH = '/DefaultProfileImage.jpg'
|
||||||
|
|
||||||
|
/** Prefix extensionless keys resolve under in the shared `recflare-cdn` bucket. */
|
||||||
|
const CDN_IMAGE_PREFIX = 'image/'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache-Control for served images. Uploaded images are immutable once written,
|
* Cache-Control for served images. Uploaded images are immutable once written,
|
||||||
* so cache for a year and mark `immutable` so browsers never revalidate. A new
|
* so cache for a year and mark `immutable` so browsers never revalidate. A new
|
||||||
@@ -101,6 +104,23 @@ function resizeImage(input: Uint8Array, transform: Transform): Uint8Array {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which bucket (and under which key) a requested path resolves in.
|
||||||
|
*
|
||||||
|
* Every object the `api` worker writes to `recflare-img` keeps a file extension
|
||||||
|
* (`.jpg` is forced when the upload has none), so an extensionless key can only be
|
||||||
|
* a `storage` upload: FileType 3 lands in the shared `recflare-cdn` bucket as
|
||||||
|
* `image/<date>/<uuid>` and the client references it by the bare `<date>/<uuid>`
|
||||||
|
* name it got back. That makes the extension a reliable discriminator —
|
||||||
|
* `/2028-06-01/<uuid>` here is `recflare-cdn`'s `image/2028-06-01/<uuid>`.
|
||||||
|
*/
|
||||||
|
function resolveObject(env: Env, key: string): { bucket: R2Bucket; objectKey: string } {
|
||||||
|
const filename = key.slice(key.lastIndexOf('/') + 1)
|
||||||
|
return filename.includes('.')
|
||||||
|
? { bucket: env.IMAGES, objectKey: key }
|
||||||
|
: { bucket: env.CDN_ASSETS, objectKey: CDN_IMAGE_PREFIX + key }
|
||||||
|
}
|
||||||
|
|
||||||
// Import the signing key once per isolate. The key material is constant for the
|
// Import the signing key once per isolate. The key material is constant for the
|
||||||
// lifetime of the Worker, so caching the promise is safe.
|
// lifetime of the Worker, so caching the promise is safe.
|
||||||
let signingKey: Promise<CryptoKey | null> | undefined
|
let signingKey: Promise<CryptoKey | null> | undefined
|
||||||
@@ -231,9 +251,11 @@ app.get(
|
|||||||
description: [
|
description: [
|
||||||
'Image hosting for recflare, a private-server reimplementation of the Rec Room',
|
'Image hosting for recflare, a private-server reimplementation of the Rec Room',
|
||||||
'backend. Serves every image the client renders — profile photos, room thumbnails,',
|
'backend. Serves every image the client renders — profile photos, room thumbnails,',
|
||||||
'club banners and the photo feed — from an R2 bucket, with bundled static assets',
|
'club banners and the photo feed — out of R2, with bundled static assets',
|
||||||
'(`static/`) taking precedence over the bucket and `DefaultProfileImage.jpg` served',
|
'(`static/`) taking precedence over the bucket and `DefaultProfileImage.jpg` served',
|
||||||
'as the fallback when a key is missing. Optional on-the-fly center-crop and resize',
|
'as the fallback when a key is missing. Keys with an extension come from the',
|
||||||
|
'`recflare-img` bucket; extensionless ones are `storage` uploads and come from the',
|
||||||
|
'shared `recflare-cdn` bucket under its `image/` prefix. Optional center-crop and resize',
|
||||||
'run through the Photon WASM codec; `?sig=p1` adds the RSA-SHA1 `Content-Signature`',
|
'run through the Photon WASM codec; `?sig=p1` adds the RSA-SHA1 `Content-Signature`',
|
||||||
'header the client verifies against `KEY:RSA:p1.rec.net`.',
|
'header the client verifies against `KEY:RSA:p1.rec.net`.',
|
||||||
'',
|
'',
|
||||||
@@ -266,6 +288,12 @@ app.get(
|
|||||||
'the same key; when neither exists the bundled `DefaultProfileImage.jpg` is served',
|
'the same key; when neither exists the bundled `DefaultProfileImage.jpg` is served',
|
||||||
'with a 200 rather than a 404, so the client never renders a broken image.',
|
'with a 200 rather than a 404, so the client never renders a broken image.',
|
||||||
'',
|
'',
|
||||||
|
'Which bucket the key resolves in depends on its extension. A key with one (always',
|
||||||
|
'the case for an `api` image upload) comes from `recflare-img`. A key WITHOUT one is',
|
||||||
|
'a `storage` upload and comes from the shared `recflare-cdn` bucket under its',
|
||||||
|
'`image/` prefix, so `/2028-06-01/<uuid>` here serves `image/2028-06-01/<uuid>`',
|
||||||
|
'there.',
|
||||||
|
'',
|
||||||
'Responses carry `Cache-Control: public, max-age=31536000, immutable` — an uploaded',
|
'Responses carry `Cache-Control: public, max-age=31536000, immutable` — an uploaded',
|
||||||
'image is never rewritten in place, a new image gets a new key.',
|
'image is never rewritten in place, a new image gets a new key.',
|
||||||
'',
|
'',
|
||||||
@@ -360,8 +388,9 @@ app.get(
|
|||||||
// resized response carries no etag, so the client can never send a matching
|
// resized response carries no etag, so the client can never send a matching
|
||||||
// one. Skip the precondition when a transform is requested.
|
// one. Skip the precondition when a transform is requested.
|
||||||
const ifNoneMatch = transform ? undefined : c.req.header('if-none-match')?.replace(/"/g, '')
|
const ifNoneMatch = transform ? undefined : c.req.header('if-none-match')?.replace(/"/g, '')
|
||||||
const object = await c.env.IMAGES.get(
|
const { bucket, objectKey } = resolveObject(c.env, key)
|
||||||
key,
|
const object = await bucket.get(
|
||||||
|
objectKey,
|
||||||
ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined
|
ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined
|
||||||
)
|
)
|
||||||
if (!object) {
|
if (!object) {
|
||||||
|
|||||||
@@ -34,10 +34,17 @@ const PUBLIC_SPKI_B64 =
|
|||||||
// bucket path rather than a static asset.
|
// bucket path rather than a static asset.
|
||||||
const R2_KEY = 'user-photo.jpg'
|
const R2_KEY = 'user-photo.jpg'
|
||||||
|
|
||||||
|
// An extensionless name, as returned by the `storage` worker for a FileType 3
|
||||||
|
// upload — served from `recflare-cdn` under `image/`, not `recflare-img`.
|
||||||
|
const CDN_NAME = '2028-06-01/12345-67890-12345'
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await env.IMAGES.put(R2_KEY, IMAGE_BYTES, {
|
await env.IMAGES.put(R2_KEY, IMAGE_BYTES, {
|
||||||
httpMetadata: { contentType: 'image/jpeg' },
|
httpMetadata: { contentType: 'image/jpeg' },
|
||||||
})
|
})
|
||||||
|
await env.CDN_ASSETS.put(`image/${CDN_NAME}`, IMAGE_BYTES, {
|
||||||
|
httpMetadata: { contentType: 'image/jpeg' },
|
||||||
|
})
|
||||||
// Seed R2 with a key that ALSO exists in `static/` to prove static wins.
|
// Seed R2 with a key that ALSO exists in `static/` to prove static wins.
|
||||||
await env.IMAGES.put('3DCharades.jpg', IMAGE_BYTES, {
|
await env.IMAGES.put('3DCharades.jpg', IMAGE_BYTES, {
|
||||||
httpMetadata: { contentType: 'image/jpeg' },
|
httpMetadata: { contentType: 'image/jpeg' },
|
||||||
@@ -58,6 +65,38 @@ describe('img endpoints', () => {
|
|||||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
|
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('serves an extensionless key from the cdn bucket under image/', async () => {
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/${CDN_NAME}`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.headers.get('content-type')).toBe('image/jpeg')
|
||||||
|
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not look for an extensionless key in the image bucket', async () => {
|
||||||
|
// Same bare name seeded into `recflare-img` instead: extensionless keys only
|
||||||
|
// ever resolve against `recflare-cdn`, so this falls through to the default.
|
||||||
|
await env.IMAGES.put('2028-06-02/only-in-img', IMAGE_BYTES)
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/2028-06-02/only-in-img`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = new Uint8Array(await res.arrayBuffer())
|
||||||
|
expect(body.length).toBeGreaterThan(IMAGE_BYTES.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resizes an extensionless cdn image', async () => {
|
||||||
|
// Exercises the transform path against the cdn bucket, not just the stream-through.
|
||||||
|
// Needs a decodable JPEG, so reuse a bundled static asset's bytes.
|
||||||
|
const real = await (await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)).arrayBuffer()
|
||||||
|
await env.CDN_ASSETS.put('image/2028-06-03/real-photo', real, {
|
||||||
|
httpMetadata: { contentType: 'image/jpeg' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/2028-06-03/real-photo?width=128`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.headers.get('content-type')).toBe('image/jpeg')
|
||||||
|
expect(res.headers.get('etag')).toBeNull()
|
||||||
|
expect(jpegSize(new Uint8Array(await res.arrayBuffer())).width).toBe(128)
|
||||||
|
})
|
||||||
|
|
||||||
it('serves a static asset in preference to an R2 object of the same key', async () => {
|
it('serves a static asset in preference to an R2 object of the same key', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)
|
const res = await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
@@ -17,10 +17,18 @@
|
|||||||
"run_worker_first": true
|
"run_worker_first": true
|
||||||
},
|
},
|
||||||
// Images are stored as objects in an R2 bucket and streamed back by key.
|
// Images are stored as objects in an R2 bucket and streamed back by key.
|
||||||
|
// `recflare-cdn` (owned by the `cdn` worker, written by `storage`) is bound
|
||||||
|
// alongside it: uploads posted to `storage` as FileType 3 land under its
|
||||||
|
// `image/` prefix with no extension, and the client asks THIS worker for them
|
||||||
|
// by the bare name — see the extensionless-key branch in src/img.app.ts.
|
||||||
"r2_buckets": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
"binding": "IMAGES",
|
"binding": "IMAGES",
|
||||||
"bucket_name": "recflare-img"
|
"bucket_name": "recflare-img"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"binding": "CDN_ASSETS",
|
||||||
|
"bucket_name": "recflare-cdn"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// Shared `recflare` D1 — the `img` worker owns the `images` metadata table
|
// Shared `recflare` D1 — the `img` worker owns the `images` metadata table
|
||||||
|
|||||||
+234
-32
@@ -21,12 +21,15 @@ import {
|
|||||||
getRoomByName,
|
getRoomByName,
|
||||||
getRoomInstance,
|
getRoomInstance,
|
||||||
getRoomInstancesByRoom,
|
getRoomInstancesByRoom,
|
||||||
|
getRoomInstanceSummariesByRoom,
|
||||||
isClubMember,
|
isClubMember,
|
||||||
|
isPlayerBannedFromRoom,
|
||||||
MessageType,
|
MessageType,
|
||||||
refreshInstanceFullness,
|
refreshInstanceFullness,
|
||||||
RoomInstanceType,
|
RoomInstanceType,
|
||||||
setPresence,
|
setPresence,
|
||||||
setRoomInstanceInProgress,
|
setRoomInstanceInProgress,
|
||||||
|
setRoomInstancePrivate,
|
||||||
subRoomDataBlob,
|
subRoomDataBlob,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
@@ -50,6 +53,7 @@ import {
|
|||||||
NotifyDisconnectRequest,
|
NotifyDisconnectRequest,
|
||||||
PlayerDto,
|
PlayerDto,
|
||||||
RoomInstanceDto,
|
RoomInstanceDto,
|
||||||
|
RoomInstanceSummaryDto,
|
||||||
StatusVisibilityRequest,
|
StatusVisibilityRequest,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
} from './openapi'
|
} from './openapi'
|
||||||
@@ -273,6 +277,14 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
|
|||||||
/** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */
|
/** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */
|
||||||
const NO_SUCH_ROOM = 20
|
const NO_SUCH_ROOM = 20
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MatchmakingErrorCode for "you are banned from this room". Unlike the opaque
|
||||||
|
* NoSuchRoom every other refusal answers, a banned player is told why: they already
|
||||||
|
* know the room exists, so there's nothing to hide, and the client can say so instead
|
||||||
|
* of showing a room that mysteriously fails to load.
|
||||||
|
*/
|
||||||
|
const BANNED_FROM_ROOM = 55
|
||||||
|
|
||||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||||
const HUB_INSTANCE = 'global'
|
const HUB_INSTANCE = 'global'
|
||||||
|
|
||||||
@@ -476,10 +488,20 @@ async function inviteParty(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outcome of resolving a room to join: the instance, or the `errorCode` to answer
|
||||||
|
* with. Kept as a pair rather than a bare null so callers can tell a room that isn't
|
||||||
|
* there (NoSuchRoom) from one the caller is banned from — those answer different codes.
|
||||||
|
*/
|
||||||
|
type ResolvedInstance =
|
||||||
|
| { instance: RoomInstance; errorCode: 0 }
|
||||||
|
| { instance: null; errorCode: number }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a room by `:room` path segment (numeric id or name) from D1, then find a
|
* Resolve a room by `:room` path segment (numeric id or name) from D1, then find a
|
||||||
* joinable instance of it (public matchmakes reuse one via the `room_instance`
|
* joinable instance of it (public matchmakes reuse one via the `room_instance`
|
||||||
* table) or create a new one. Returns null when the room isn't found.
|
* table) or create a new one. A null instance carries the error code to answer:
|
||||||
|
* NoSuchRoom when the room isn't in the DB, BannedFromRoom when the caller is banned.
|
||||||
*/
|
*/
|
||||||
async function resolveRoomInstance(
|
async function resolveRoomInstance(
|
||||||
c: Context<App>,
|
c: Context<App>,
|
||||||
@@ -487,14 +509,24 @@ async function resolveRoomInstance(
|
|||||||
isPrivate: boolean,
|
isPrivate: boolean,
|
||||||
ownerId: number,
|
ownerId: number,
|
||||||
subRoomId?: number
|
subRoomId?: number
|
||||||
): Promise<RoomInstance | null> {
|
): Promise<ResolvedInstance> {
|
||||||
const id = Number.parseInt(roomKey, 10)
|
const id = Number.parseInt(roomKey, 10)
|
||||||
const room = Number.isNaN(id)
|
const room = Number.isNaN(id)
|
||||||
? await getRoomByName(c.env.DB, roomKey)
|
? await getRoomByName(c.env.DB, roomKey)
|
||||||
: await getRoomById(c.env.DB, id)
|
: await getRoomById(c.env.DB, id)
|
||||||
if (!room) return null
|
if (!room) return { instance: null, errorCode: NO_SUCH_ROOM }
|
||||||
|
|
||||||
const f = instanceFieldsFromRoom(room, subRoomId)
|
const f = instanceFieldsFromRoom(room, subRoomId)
|
||||||
|
|
||||||
|
// A banned player never gets an instance. This is the whole enforcement of a room
|
||||||
|
// ban: the Photon room id only ever reaches a player through a matchmake, so
|
||||||
|
// refusing here means they have no coordinates to join or interact with. Handled
|
||||||
|
// before any instance is created or reused so a ban can't spawn one.
|
||||||
|
if (await isPlayerBannedFromRoom(c.env.DB, f.roomId, ownerId)) {
|
||||||
|
logger.info('matchmake refused: player banned from room', { roomId: f.roomId, ownerId })
|
||||||
|
return { instance: null, errorCode: BANNED_FROM_ROOM }
|
||||||
|
}
|
||||||
|
|
||||||
// Never place the player back into the instance they're already in: the client
|
// Never place the player back into the instance they're already in: the client
|
||||||
// keys the room transition off a changing `roomInstanceId`, so re-matchmaking into
|
// keys the room transition off a changing `roomInstanceId`, so re-matchmaking into
|
||||||
// your current instance (e.g. the only public instance of a room you're already in)
|
// your current instance (e.g. the only public instance of a room you're already in)
|
||||||
@@ -525,13 +557,16 @@ async function resolveRoomInstance(
|
|||||||
roomInstanceType: f.roomInstanceType,
|
roomInstanceType: f.roomInstanceType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return roomInstanceFromRoom(
|
return {
|
||||||
room,
|
instance: roomInstanceFromRoom(
|
||||||
isPrivate,
|
room,
|
||||||
instance.roomInstanceId,
|
isPrivate,
|
||||||
instance.photonRoomId,
|
instance.roomInstanceId,
|
||||||
f.subRoomId
|
instance.photonRoomId,
|
||||||
)
|
f.subRoomId
|
||||||
|
),
|
||||||
|
errorCode: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -859,7 +894,8 @@ const app = new Hono<App>()
|
|||||||
description: [
|
description: [
|
||||||
'Looks the club up, checks the caller is a member of it, and places them into an',
|
'Looks the club up, checks the caller is a member of it, and places them into an',
|
||||||
'instance of its clubhouse room. Returns errorCode 20 with a null instance when the',
|
'instance of its clubhouse room. Returns errorCode 20 with a null instance when the',
|
||||||
'club is unknown, has no clubhouse set, or the caller isn’t a member.',
|
'club is unknown, has no clubhouse set, or the caller isn’t a member — and errorCode',
|
||||||
|
'55 when they are banned from the clubhouse room.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
|
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
|
||||||
@@ -875,7 +911,7 @@ const app = new Hono<App>()
|
|||||||
responses: {
|
responses: {
|
||||||
200: json(
|
200: json(
|
||||||
MatchmakeResponse,
|
MatchmakeResponse,
|
||||||
'The clubhouse instance (or errorCode 20 with null when it can’t be entered)'
|
'The clubhouse instance (or a null instance with errorCode 20 / 55 when it can’t be entered)'
|
||||||
),
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
@@ -895,13 +931,13 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const joinMode = await readJoinMode(c)
|
const joinMode = await readJoinMode(c)
|
||||||
const instance = await resolveRoomInstance(
|
const { instance, errorCode } = await resolveRoomInstance(
|
||||||
c,
|
c,
|
||||||
String(club.clubhouseRoomId),
|
String(club.clubhouseRoomId),
|
||||||
joinMode === 2,
|
joinMode === 2,
|
||||||
id
|
id
|
||||||
)
|
)
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
if (!instance) return c.json({ errorCode, roomInstance: null })
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
return c.json({ errorCode: 0, roomInstance: instance })
|
return c.json({ errorCode: 0, roomInstance: instance })
|
||||||
}
|
}
|
||||||
@@ -925,7 +961,9 @@ const app = new Hono<App>()
|
|||||||
'from the target’s stored presence. FRIENDS ONLY: the caller must be a mutual friend',
|
'from the target’s stored presence. FRIENDS ONLY: the caller must be a mutual friend',
|
||||||
'of the target (otherwise anyone could read a player’s presence and warp to them).',
|
'of the target (otherwise anyone could read a player’s presence and warp to them).',
|
||||||
'Returns errorCode 20 with a null instance when the target isn’t a friend, is the',
|
'Returns errorCode 20 with a null instance when the target isn’t a friend, is the',
|
||||||
'caller themselves, or isn’t currently in a room.',
|
'caller themselves, or isn’t currently in a room, and errorCode 55 when the caller is',
|
||||||
|
'banned from the room the friend is in — this path hands out join coordinates without',
|
||||||
|
'going through the room resolver, so it carries its own ban check.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -940,7 +978,7 @@ const app = new Hono<App>()
|
|||||||
responses: {
|
responses: {
|
||||||
200: json(
|
200: json(
|
||||||
MatchmakeResponse,
|
MatchmakeResponse,
|
||||||
'The friend’s instance (or errorCode 20 with null when it can’t be joined)'
|
'The friend’s instance (or a null instance with errorCode 20 / 55 when it can’t be joined)'
|
||||||
),
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
@@ -961,6 +999,14 @@ const app = new Hono<App>()
|
|||||||
const instance = targetPresence?.roomInstance ?? null
|
const instance = targetPresence?.roomInstance ?? null
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
|
|
||||||
|
// This path hands out a Photon room id without going through
|
||||||
|
// resolveRoomInstance, so the room's bans have to be checked here too —
|
||||||
|
// otherwise following a friend in is a way around a ban.
|
||||||
|
if (await isPlayerBannedFromRoom(c.env.DB, instance.roomId, id)) {
|
||||||
|
logger.info('follow refused: player banned from room', { roomId: instance.roomId, id })
|
||||||
|
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
|
||||||
|
}
|
||||||
|
|
||||||
// Join that same instance (same id + Photon room) and store it as the caller's
|
// Join that same instance (same id + Photon room) and store it as the caller's
|
||||||
// presence, so the heartbeat replays it and their own friend fan-out fires.
|
// presence, so the heartbeat replays it and their own friend fan-out fires.
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
@@ -968,6 +1014,93 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Join one SPECIFIC live instance by id (`/matchmake/instance/{roomInstanceId}`) —
|
||||||
|
// the action behind the owner's instance listing (`GET /room/{roomId}/instances`),
|
||||||
|
// where they pick a session of their room and drop into it. Unlike every other
|
||||||
|
// matchmake this targets a fixed instance: nothing is reused, nothing is created,
|
||||||
|
// and a full or in-progress instance is still entered (moderating a full instance
|
||||||
|
// is the point). OWNER-ONLY, gated with the same creator-or-co-owner check as the
|
||||||
|
// listing — the Photon room id is the join coordinate, so an open version of this
|
||||||
|
// would let anyone warp into any private session by guessing an id. Registered
|
||||||
|
// before the `/matchmake/room/…` routes so `instance` isn't read as a room name.
|
||||||
|
.post(
|
||||||
|
'/matchmake/instance/:instanceId{[0-9]+}',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Navigation'],
|
||||||
|
summary: 'Join a specific instance (owner only)',
|
||||||
|
description: [
|
||||||
|
'Places the caller into one specific live instance of their own room, picked by id',
|
||||||
|
'from the owner’s instance listing. Gated to the room’s creator or a co-owner.',
|
||||||
|
'Unlike the other matchmakes this never reuses or creates an instance, and enters',
|
||||||
|
'even a full or in-progress one. Returns errorCode 20 with a null instance when the',
|
||||||
|
'instance or its room is gone, or the caller doesn’t manage that room; errorCode 55',
|
||||||
|
'when banned.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'instanceId',
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
description: 'Room instance id (digits only)',
|
||||||
|
schema: { type: 'string', pattern: '^[0-9]+$' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: json(
|
||||||
|
MatchmakeResponse,
|
||||||
|
'The instance (or a null instance with errorCode 20 / 55 when it can’t be joined)'
|
||||||
|
),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const instanceId = Number.parseInt(c.req.param('instanceId'), 10)
|
||||||
|
const stored = await getRoomInstance(c.env.DB, instanceId)
|
||||||
|
// One opaque refusal for "no such instance", "no such room" and "not yours":
|
||||||
|
// a distinct code for the last would confirm which instance ids are live.
|
||||||
|
if (!stored) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
|
const room = await getRoomById(c.env.DB, stored.roomId)
|
||||||
|
if (!room) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
|
if (!canManageRoom(room, id)) {
|
||||||
|
logger.info('instance matchmake refused: not the room’s owner', {
|
||||||
|
roomInstanceId: instanceId,
|
||||||
|
roomId: stored.roomId,
|
||||||
|
accountId: id,
|
||||||
|
})
|
||||||
|
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like the follow-a-friend path, this hands out a Photon room id without going
|
||||||
|
// through resolveRoomInstance, so the room's bans are checked here too. An owner
|
||||||
|
// can't ban themselves out of their own room in practice, but a co-owner can be
|
||||||
|
// banned, and a ban must beat every route that yields join coordinates.
|
||||||
|
if (await isPlayerBannedFromRoom(c.env.DB, stored.roomId, id)) {
|
||||||
|
logger.info('instance matchmake refused: player banned from room', {
|
||||||
|
roomId: stored.roomId,
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the wire instance from the room (fresh scene + published save) keyed to
|
||||||
|
// this instance's own id and Photon room, so the owner lands in exactly the
|
||||||
|
// session they picked rather than a new one alongside it.
|
||||||
|
const instance = roomInstanceFromRoom(
|
||||||
|
room,
|
||||||
|
stored.isPrivate,
|
||||||
|
stored.roomInstanceId,
|
||||||
|
stored.photonRoomId,
|
||||||
|
stored.subRoomId
|
||||||
|
)
|
||||||
|
await enterRoom(c, id, instance)
|
||||||
|
return c.json({ errorCode: 0, roomInstance: instance })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}`
|
// Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}`
|
||||||
// — the client uses this to enter a room's other scenes). The subroom decides the
|
// — the client uses this to enter a room's other scenes). The subroom decides the
|
||||||
// scene the client loads and which instances are joinable, so it must be carried
|
// scene the client loads and which instances are joinable, so it must be carried
|
||||||
@@ -994,7 +1127,10 @@ const app = new Hono<App>()
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
|
200: json(
|
||||||
|
MatchmakeResponse,
|
||||||
|
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)'
|
||||||
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1003,14 +1139,14 @@ const app = new Hono<App>()
|
|||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
||||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||||
const instance = await resolveRoomInstance(
|
const { instance, errorCode } = await resolveRoomInstance(
|
||||||
c,
|
c,
|
||||||
c.req.param('roomId'),
|
c.req.param('roomId'),
|
||||||
joinMode === 2,
|
joinMode === 2,
|
||||||
id,
|
id,
|
||||||
subRoomId
|
subRoomId
|
||||||
)
|
)
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
if (!instance) return c.json({ errorCode, roomInstance: null })
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
|
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
|
||||||
await inviteParty(c, id, additionalPlayerIds, instance)
|
await inviteParty(c, id, additionalPlayerIds, instance)
|
||||||
@@ -1033,7 +1169,10 @@ const app = new Hono<App>()
|
|||||||
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
|
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
|
||||||
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
|
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
|
200: json(
|
||||||
|
MatchmakeResponse,
|
||||||
|
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)'
|
||||||
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1041,8 +1180,13 @@ const app = new Hono<App>()
|
|||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
||||||
const instance = await resolveRoomInstance(c, c.req.param('roomId'), joinMode === 2, id)
|
const { instance, errorCode } = await resolveRoomInstance(
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
c,
|
||||||
|
c.req.param('roomId'),
|
||||||
|
joinMode === 2,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
if (!instance) return c.json({ errorCode, roomInstance: null })
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
|
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
|
||||||
await inviteParty(c, id, additionalPlayerIds, instance)
|
await inviteParty(c, id, additionalPlayerIds, instance)
|
||||||
@@ -1165,16 +1309,20 @@ const app = new Hono<App>()
|
|||||||
(c) => c.body(null, 200)
|
(c) => c.body(null, 200)
|
||||||
)
|
)
|
||||||
|
|
||||||
// The room owner flips the instance's in-progress flag once the session starts
|
// The instance's in-progress flag, flipped when a session starts (e.g. a game round
|
||||||
// (e.g. a game round begins). Body is a form post: `inProgress=True|False`.
|
// begins). Deliberately NOT owner-gated, unlike the other room-instance mutations:
|
||||||
|
// this is set by whoever in the room starts the game, not by the room's owner — a
|
||||||
|
// gate here would break game starts for everyone else. Body is a form post:
|
||||||
|
// `inProgress=True|False`.
|
||||||
.put(
|
.put(
|
||||||
'/roominstance/:id/inprogress',
|
'/roominstance/:id/inprogress',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Room instance'],
|
tags: ['Room instance'],
|
||||||
summary: 'Set instance in-progress flag',
|
summary: 'Set instance in-progress flag',
|
||||||
description: [
|
description: [
|
||||||
'The room owner flips the instance’s in-progress flag when a session starts (e.g. a',
|
'Flips the instance’s in-progress flag when a session starts (e.g. a round begins).',
|
||||||
'round begins). Body is `inProgress=True|False`.',
|
'Set by whoever in the room starts the game — any authenticated player, not just the',
|
||||||
|
'room’s owner. Body is `inProgress=True|False`.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(InProgressRequest, 'The inProgress flag'),
|
requestBody: form(InProgressRequest, 'The inProgress flag'),
|
||||||
@@ -1202,18 +1350,72 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Close a live instance to strangers (`/roominstance/{id}/markprivate`) — the owner
|
||||||
|
// makes the session they're running private, so public matchmaking stops feeding new
|
||||||
|
// players into it (getJoinableInstance only reuses non-private instances). Everyone
|
||||||
|
// already inside stays put; this shuts the door rather than clearing the room.
|
||||||
|
// OWNER-ONLY (same creator-or-co-owner gate as the instance listing): whether a
|
||||||
|
// session is open is the room owner's call, not a passer-by's. Generic empty ack.
|
||||||
|
.post(
|
||||||
|
'/roominstance/:id/markprivate',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Room instance'],
|
||||||
|
summary: 'Mark an instance private (owner only)',
|
||||||
|
description: [
|
||||||
|
'Marks a live instance private, so public matchmaking stops placing new players',
|
||||||
|
'into it. Players already inside are unaffected. Auth-gated and gated to the',
|
||||||
|
'instance’s room’s creator or a co-owner (403 otherwise). Empty ack.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
description: 'Room instance id',
|
||||||
|
schema: { type: 'string' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: EMPTY_OK,
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||||||
|
404: { description: 'Non-numeric id or no such instance (empty body)' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const instanceId = Number.parseInt(c.req.param('id'), 10)
|
||||||
|
if (Number.isNaN(instanceId)) return c.body(null, 404)
|
||||||
|
|
||||||
|
const stored = await getRoomInstance(c.env.DB, instanceId)
|
||||||
|
if (!stored) return c.body(null, 404)
|
||||||
|
const room = await getRoomById(c.env.DB, stored.roomId)
|
||||||
|
if (!room || !canManageRoom(room, id)) return c.body(null, 403)
|
||||||
|
|
||||||
|
await setRoomInstancePrivate(c.env.DB, instanceId, true)
|
||||||
|
return c.body(null, 200)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// The room's live instances — the owner's view of active sessions of their room.
|
// The room's live instances — the owner's view of active sessions of their room.
|
||||||
// Auth-gated (401) and owner/co-owner-only (403): the caller must be the room's
|
// Auth-gated (401) and owner/co-owner-only (403): the caller must be the room's
|
||||||
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns the
|
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns a
|
||||||
// bare RoomInstance DTO array (empty when the room has no live instances).
|
// summary per instance (empty when the room has no live instances) — id, subroom,
|
||||||
|
// fullness, creation time and who's currently in it — not the client's
|
||||||
|
// RoomInstance DTO: this is a management listing, so it answers "who's in there"
|
||||||
|
// and withholds the connection details of a session the owner isn't joining.
|
||||||
.get(
|
.get(
|
||||||
'/room/:roomId{[0-9]+}/instances',
|
'/room/:roomId{[0-9]+}/instances',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Room instance'],
|
tags: ['Room instance'],
|
||||||
summary: 'A room’s live instances',
|
summary: 'A room’s live instances',
|
||||||
description: [
|
description: [
|
||||||
'The owner’s view of active sessions of their room. Auth-gated and gated to the',
|
'The owner’s view of active sessions of their room — each instance with the',
|
||||||
'room’s creator or a co-owner (403 otherwise). Unknown room → 404.',
|
'players currently in it. Auth-gated and gated to the room’s creator or a',
|
||||||
|
'co-owner (403 otherwise). Unknown room → 404.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -1226,7 +1428,7 @@ const app = new Hono<App>()
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(RoomInstanceDto.array(), 'Live instances (empty when none)'),
|
200: json(RoomInstanceSummaryDto.array(), 'Live instances (empty when none)'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||||||
404: { description: 'No such room (empty body)' },
|
404: { description: 'No such room (empty body)' },
|
||||||
@@ -1243,7 +1445,7 @@ const app = new Hono<App>()
|
|||||||
// same owner-or-co-owner gate the rooms worker uses for room-admin actions.
|
// same owner-or-co-owner gate the rooms worker uses for room-admin actions.
|
||||||
if (!canManageRoom(room, id)) return c.body(null, 403)
|
if (!canManageRoom(room, id)) return c.body(null, 403)
|
||||||
|
|
||||||
return c.json(await getRoomInstancesByRoom(c.env.DB, roomId))
|
return c.json(await getRoomInstanceSummariesByRoom(c.env.DB, roomId))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,22 @@ export const RoomInstanceDto = z.object({
|
|||||||
EncryptVoiceChat: z.boolean(),
|
EncryptVoiceChat: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One live instance in the owner's management listing (`GET /room/:roomId/instances`).
|
||||||
|
* Not the client `RoomInstanceDto`: it carries who's in there and drops the connection
|
||||||
|
* details (photon ids, data blob, room code) of a session the owner isn't in.
|
||||||
|
*/
|
||||||
|
export const RoomInstanceSummaryDto = z.object({
|
||||||
|
roomInstanceId: z.int(),
|
||||||
|
roomId: z.int(),
|
||||||
|
subRoomId: z.int().describe('Which subroom (scene) of the room this instance is'),
|
||||||
|
isFull: z.boolean(),
|
||||||
|
createdAt: z.string().describe('ISO 8601 UTC, stamped when the instance was created'),
|
||||||
|
playerIds: z
|
||||||
|
.array(z.int())
|
||||||
|
.describe('Accounts currently in the instance (live presence); empty when nobody is'),
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A player's presence as the client reads it (`GET /player`, `POST /player/heartbeat`).
|
* A player's presence as the client reads it (`GET /player`, `POST /player/heartbeat`).
|
||||||
* `isOnline` means "has a live (unexpired) presence row", NOT "is in a room" — a player
|
* `isOnline` means "has a live (unexpired) presence row", NOT "is in a room" — a player
|
||||||
@@ -128,7 +144,9 @@ export const PlayerDto = z.object({
|
|||||||
* a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
|
* a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
|
||||||
*/
|
*/
|
||||||
export const MatchmakeResponse = z.object({
|
export const MatchmakeResponse = z.object({
|
||||||
errorCode: z.int().describe('0 = success; 20 = NoSuchRoom'),
|
errorCode: z
|
||||||
|
.int()
|
||||||
|
.describe('0 = success; 20 = NoSuchRoom; 55 = banned from the room (the one non-opaque code)'),
|
||||||
roomInstance: RoomInstanceDto.nullable(),
|
roomInstance: RoomInstanceDto.nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
getRoomInstance,
|
getRoomInstance,
|
||||||
PRESENCE_SCHEMA_DDL,
|
PRESENCE_SCHEMA_DDL,
|
||||||
ROOM_INSTANCE_SCHEMA_DDL,
|
ROOM_INSTANCE_SCHEMA_DDL,
|
||||||
|
ROOM_SCHEMA_DDL,
|
||||||
seedRoomWithSubRooms,
|
seedRoomWithSubRooms,
|
||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
@@ -83,15 +84,9 @@ const TEST_ROOMS = [
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||||
await env.DB.prepare(
|
// The rooms worker's schema (room + interaction) — reading a room aggregates its
|
||||||
`CREATE TABLE IF NOT EXISTS room (
|
// cheer/favorite Stats from `interaction`, so both tables have to be here.
|
||||||
data TEXT NOT NULL,
|
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
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,
|
|
||||||
is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL
|
|
||||||
)`
|
|
||||||
).run()
|
|
||||||
// Subrooms live in their own table now; seed each room and split its subrooms into it.
|
// Subrooms live in their own table now; seed each room and split its subrooms into it.
|
||||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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>)
|
for (const r of TEST_ROOMS) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
||||||
@@ -989,10 +984,32 @@ describe('auth-gated endpoints', () => {
|
|||||||
headers: await bearer('42'),
|
headers: await bearer('42'),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const instances = (await res.json()) as Array<{ roomId: number; roomInstanceId: number }>
|
const instances = (await res.json()) as Array<{
|
||||||
|
roomInstanceId: number
|
||||||
|
roomId: number
|
||||||
|
subRoomId: number
|
||||||
|
isFull: boolean
|
||||||
|
createdAt: string
|
||||||
|
playerIds: number[]
|
||||||
|
}>
|
||||||
expect(instances.length).toBeGreaterThanOrEqual(1)
|
expect(instances.length).toBeGreaterThanOrEqual(1)
|
||||||
expect(instances.every((i) => i.roomId === 3)).toBe(true)
|
expect(instances.every((i) => i.roomId === 3)).toBe(true)
|
||||||
|
|
||||||
|
// The summary projection: id/subroom/fullness/createdAt plus who's in there —
|
||||||
|
// and none of the client DTO's connection fields.
|
||||||
|
const instance = instances.find((i) => i.playerIds.includes(42))
|
||||||
|
expect(instance).toBeDefined()
|
||||||
|
expect(Object.keys(instance!).sort()).toEqual([
|
||||||
|
'createdAt',
|
||||||
|
'isFull',
|
||||||
|
'playerIds',
|
||||||
|
'roomId',
|
||||||
|
'roomInstanceId',
|
||||||
|
'subRoomId',
|
||||||
|
])
|
||||||
|
expect(instance!.isFull).toBe(false)
|
||||||
|
expect(Number.isNaN(Date.parse(instance!.createdAt))).toBe(false)
|
||||||
|
|
||||||
// The co-owner (account 43, Role 30) may view the instances too.
|
// The co-owner (account 43, Role 30) may view the instances too.
|
||||||
const coOwner = await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
|
const coOwner = await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
|
||||||
headers: await bearer('43'),
|
headers: await bearer('43'),
|
||||||
@@ -1001,6 +1018,141 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length)
|
expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('POST /matchmake/instance/:id joins that exact instance, owner-only', async () => {
|
||||||
|
// A player with no role on room 3 spins up an instance of it, which the room's
|
||||||
|
// owner should then be able to drop into by id.
|
||||||
|
const spawn = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('43'),
|
||||||
|
})
|
||||||
|
const spawned = (await spawn.json()) as {
|
||||||
|
roomInstance: { roomInstanceId: number; photonRoomId: string }
|
||||||
|
}
|
||||||
|
const instanceId = spawned.roomInstance.roomInstanceId
|
||||||
|
|
||||||
|
// No token → 401.
|
||||||
|
expect(
|
||||||
|
(await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, { method: 'POST' }))
|
||||||
|
.status
|
||||||
|
).toBe(401)
|
||||||
|
|
||||||
|
// Authed but not the room's owner or co-owner → the opaque NoSuchRoom refusal,
|
||||||
|
// so instance ids can't be probed for live private sessions.
|
||||||
|
const stranger = await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('999'),
|
||||||
|
})
|
||||||
|
expect(stranger.status).toBe(200)
|
||||||
|
expect(await stranger.json()).toEqual({ errorCode: 20, roomInstance: null })
|
||||||
|
|
||||||
|
// Unknown instance → same refusal.
|
||||||
|
const unknown = await exports.default.fetch(`${ORIGIN}/matchmake/instance/9999999`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
expect(await unknown.json()).toEqual({ errorCode: 20, roomInstance: null })
|
||||||
|
|
||||||
|
// Park the owner somewhere else first, so this is a real transition.
|
||||||
|
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// The owner lands in that exact instance — same id AND same Photon room as the
|
||||||
|
// player already in it, which is what makes it the same session.
|
||||||
|
const joined = await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
expect(joined.status).toBe(200)
|
||||||
|
const body = (await joined.json()) as {
|
||||||
|
errorCode: number
|
||||||
|
roomInstance: { roomInstanceId: number; photonRoomId: string; roomId: number }
|
||||||
|
}
|
||||||
|
expect(body.errorCode).toBe(0)
|
||||||
|
expect(body.roomInstance.roomInstanceId).toBe(instanceId)
|
||||||
|
expect(body.roomInstance.photonRoomId).toBe(spawned.roomInstance.photonRoomId)
|
||||||
|
expect(body.roomInstance.roomId).toBe(3)
|
||||||
|
|
||||||
|
// It's now the owner's presence, and the listing shows both of them in there.
|
||||||
|
const listed = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/room/3/instances`, { headers: await bearer('42') })
|
||||||
|
).json()) as Array<{ roomInstanceId: number; playerIds: number[] }>
|
||||||
|
const target = listed.find((i) => i.roomInstanceId === instanceId)
|
||||||
|
expect(target?.playerIds).toEqual([42, 43])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /roominstance/:id/markprivate closes the instance, owner-only', async () => {
|
||||||
|
// Room 77 subroom 34 — its own instance, so marking it private can't affect the
|
||||||
|
// instances the other tests matchmake into.
|
||||||
|
const spawn = await exports.default.fetch(`${ORIGIN}/matchmake/room/77/34`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
const { roomInstance } = (await spawn.json()) as { roomInstance: { roomInstanceId: number } }
|
||||||
|
const instanceId = roomInstance.roomInstanceId
|
||||||
|
|
||||||
|
// No token → 401.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await exports.default.fetch(`${ORIGIN}/roominstance/${instanceId}/markprivate`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(401)
|
||||||
|
|
||||||
|
// Unknown instance → 404.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await exports.default.fetch(`${ORIGIN}/roominstance/9999999/markprivate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(404)
|
||||||
|
|
||||||
|
// Room 77 has no creator and no roles, so nobody manages it → 403 even for 42.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await exports.default.fetch(`${ORIGIN}/roominstance/${instanceId}/markprivate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(403)
|
||||||
|
|
||||||
|
// Room 3 is account 42's, so its instances are theirs to close.
|
||||||
|
const owned = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('43'),
|
||||||
|
})
|
||||||
|
const ownedId = ((await owned.json()) as { roomInstance: { roomInstanceId: number } })
|
||||||
|
.roomInstance.roomInstanceId
|
||||||
|
const marked = await exports.default.fetch(`${ORIGIN}/roominstance/${ownedId}/markprivate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
expect(marked.status).toBe(200)
|
||||||
|
expect(await marked.text()).toBe('')
|
||||||
|
|
||||||
|
// Closed to strangers: a public matchmake into room 3 no longer reuses it, so a
|
||||||
|
// new player lands in a different instance.
|
||||||
|
const after = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('999'),
|
||||||
|
})
|
||||||
|
const afterId = ((await after.json()) as { roomInstance: { roomInstanceId: number } })
|
||||||
|
.roomInstance.roomInstanceId
|
||||||
|
expect(afterId).not.toBe(ownedId)
|
||||||
|
|
||||||
|
// The player already inside is untouched — this shuts the door, it doesn't clear
|
||||||
|
// the room.
|
||||||
|
const listed = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/room/3/instances`, { headers: await bearer('42') })
|
||||||
|
).json()) as Array<{ roomInstanceId: number; playerIds: number[] }>
|
||||||
|
expect(listed.find((i) => i.roomInstanceId === ownedId)?.playerIds).toContain(43)
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /invite pushes a game-invite MessageReceived to the target', async () => {
|
test('POST /invite pushes a game-invite MessageReceived to the target', async () => {
|
||||||
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
|
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
|
||||||
type Sent = {
|
type Sent = {
|
||||||
@@ -1246,6 +1398,70 @@ describe('auth-gated endpoints', () => {
|
|||||||
|
|
||||||
// No token → 401.
|
// No token → 401.
|
||||||
expect((await follow(9801)).status).toBe(401)
|
expect((await follow(9801)).status).toBe(401)
|
||||||
|
|
||||||
|
// A ban on the room blocks the follow too: this path hands out a Photon room id
|
||||||
|
// without going through resolveRoomInstance, so it carries its own ban check —
|
||||||
|
// otherwise following a friend in would be a way around a ban.
|
||||||
|
await env.DB.prepare(
|
||||||
|
`INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)
|
||||||
|
VALUES (2, 9800, 0, 1, '2026-01-01T00:00:00.000Z')`
|
||||||
|
).run()
|
||||||
|
try {
|
||||||
|
expect(await (await follow(9801, '9800')).json()).toEqual({
|
||||||
|
errorCode: 55,
|
||||||
|
roomInstance: null,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await env.DB.prepare('DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800')
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /matchmake/room/:roomId refuses a player banned from the room', async () => {
|
||||||
|
const matchmake = async (sub: string) =>
|
||||||
|
(await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer(sub),
|
||||||
|
})
|
||||||
|
).json()) as { errorCode: number; roomInstance: { roomInstanceId: number } | null }
|
||||||
|
|
||||||
|
// Not banned yet → a normal join.
|
||||||
|
expect((await matchmake('9700')).errorCode).toBe(0)
|
||||||
|
|
||||||
|
await env.DB.prepare(
|
||||||
|
`INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)
|
||||||
|
VALUES (2, 9701, 0, 1, '2026-01-01T00:00:00.000Z')`
|
||||||
|
).run()
|
||||||
|
|
||||||
|
// The ban is the whole enforcement: no instance means no Photon room id, so there
|
||||||
|
// is nothing for the banned player to join. errorCode 55 rather than the opaque
|
||||||
|
// NoSuchRoom every other refusal answers — a banned player already knows the room
|
||||||
|
// exists, so the client can say why. Applies to the subroom path as well.
|
||||||
|
expect(await matchmake('9701')).toEqual({ errorCode: 55, roomInstance: null })
|
||||||
|
const sub = await exports.default.fetch(`${ORIGIN}/matchmake/room/2/2`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('9701'),
|
||||||
|
})
|
||||||
|
expect(await sub.json()).toEqual({ errorCode: 55, roomInstance: null })
|
||||||
|
|
||||||
|
// Refused before any instance is created, and no presence was recorded for them.
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare('SELECT 1 AS hit FROM presence WHERE account_id = 9701').first()
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
// The ban is per-room — another room is unaffected.
|
||||||
|
const other = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/matchmake/room/77`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('9701'),
|
||||||
|
})
|
||||||
|
).json()) as { errorCode: number }
|
||||||
|
expect(other.errorCode).toBe(0)
|
||||||
|
|
||||||
|
// Lifting the ban lets them in again.
|
||||||
|
await env.DB.prepare('DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9701').run()
|
||||||
|
expect((await matchmake('9701')).errorCode).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /matchmake/room/:id invites AdditionalPlayerIds (party) into the instance', async () => {
|
test('POST /matchmake/room/:id invites AdditionalPlayerIds (party) into the instance', async () => {
|
||||||
@@ -1339,6 +1555,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'POST /invite',
|
'POST /invite',
|
||||||
'POST /matchmake/club/{clubId}',
|
'POST /matchmake/club/{clubId}',
|
||||||
'POST /matchmake/dorm',
|
'POST /matchmake/dorm',
|
||||||
|
'POST /matchmake/instance/{instanceId}',
|
||||||
'POST /matchmake/player/{playerId}',
|
'POST /matchmake/player/{playerId}',
|
||||||
'POST /matchmake/room/{roomId}',
|
'POST /matchmake/room/{roomId}',
|
||||||
'POST /matchmake/room/{roomId}/{subRoomId}',
|
'POST /matchmake/room/{roomId}/{subRoomId}',
|
||||||
@@ -1347,6 +1564,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'POST /player/login',
|
'POST /player/login',
|
||||||
'POST /player/logout',
|
'POST /player/logout',
|
||||||
'POST /player/notifydisconnect',
|
'POST /player/notifydisconnect',
|
||||||
|
'POST /roominstance/{id}/markprivate',
|
||||||
'POST /roominstance/{id}/reportjoinresult',
|
'POST /roominstance/{id}/reportjoinresult',
|
||||||
'PUT /player/gameserverregionpings',
|
'PUT /player/gameserverregionpings',
|
||||||
'PUT /player/photonregionpings',
|
'PUT /player/photonregionpings',
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/
|
|||||||
export type Env = SharedHonoEnv & {
|
export type Env = SharedHonoEnv & {
|
||||||
// HS256 JWT signing key (shared Secrets Store). Tokens signed by `auth` verify everywhere.
|
// HS256 JWT signing key (shared Secrets Store). Tokens signed by `auth` verify everywhere.
|
||||||
JWT_SECRET: SecretsStoreSecret
|
JWT_SECRET: SecretsStoreSecret
|
||||||
|
// Meta (Oculus) app secret, from the same store. Read only by `auth`, to validate a
|
||||||
|
// headset login's nonce with Meta (see apps/auth/src/meta-nonce.ts).
|
||||||
|
META_APP_SECRET: SecretsStoreSecret
|
||||||
// Shared `recflare` database (accounts, auth, api, clubs, match, rooms, …).
|
// Shared `recflare` database (accounts, auth, api, clubs, match, rooms, …).
|
||||||
DB: D1Database
|
DB: D1Database
|
||||||
// Image storage bucket (api, img).
|
// Image storage bucket (api, img).
|
||||||
|
|||||||
@@ -50,13 +50,20 @@
|
|||||||
"crons": ["*/5 * * * *"]
|
"crons": ["*/5 * * * *"]
|
||||||
},
|
},
|
||||||
"logpush": false,
|
"logpush": false,
|
||||||
// Shared Secrets Store holding the HS256 JWT signing key. "local" store_id replaced
|
// Shared Secrets Store holding the HS256 JWT signing key, plus the Meta app secret
|
||||||
// with RECFLARE_SECRETS_STORE at deploy.
|
// the mounted `auth` app needs to verify Oculus logins. "local" store_id replaced
|
||||||
|
// with RECFLARE_SECRETS_STORE at deploy. Both must exist in the store or the deploy
|
||||||
|
// fails — see DEPLOYING.md.
|
||||||
"secrets_store_secrets": [
|
"secrets_store_secrets": [
|
||||||
{
|
{
|
||||||
"binding": "JWT_SECRET",
|
"binding": "JWT_SECRET",
|
||||||
"store_id": "local",
|
"store_id": "local",
|
||||||
"secret_name": "JWT_SECRET"
|
"secret_name": "JWT_SECRET"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"binding": "META_APP_SECRET",
|
||||||
|
"store_id": "local",
|
||||||
|
"secret_name": "META_APP_SECRET"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"upload_source_maps": true,
|
"upload_source_maps": true,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export enum NotificationType {
|
|||||||
ModerationUpdateRequired = 21,
|
ModerationUpdateRequired = 21,
|
||||||
ModerationKick = 22,
|
ModerationKick = 22,
|
||||||
ModerationKickAttemptFailed = 23,
|
ModerationKickAttemptFailed = 23,
|
||||||
ModerationRoomBan = 24,
|
ModerationRoomBan = "ModerationRoomBan",
|
||||||
ServerMaintenance = 25,
|
ServerMaintenance = 25,
|
||||||
GiftPackageReceived = 30,
|
GiftPackageReceived = 30,
|
||||||
GiftPackageReceivedImmediate = 31,
|
GiftPackageReceivedImmediate = 31,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { logger, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||||
|
|
||||||
import { NotificationsHub, OWNER_HEADER } from './notifications-hub'
|
import { NotificationsHub, OWNER_HEADER } from './notifications-hub'
|
||||||
@@ -80,6 +80,14 @@ const app = new Hono<App>()
|
|||||||
})(c, next)
|
})(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())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
|
|||||||
@@ -581,3 +581,44 @@ describe('clearing pending notifications', () => {
|
|||||||
expect((await clear('all=true', await bearer('3', ['gameClient']))).status).toBe(403)
|
expect((await clear('all=true', await bearer('3', ['gameClient']))).status).toBe(403)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The website's admin controls (maintenance countdown, coach broadcast) are a browser
|
||||||
|
// calling `/internal/*` directly rather than through a `www` proxy, so these need CORS.
|
||||||
|
describe('CORS', () => {
|
||||||
|
// The catch: `/internal/*` is behind `requireAdmin`, and a browser preflight carries
|
||||||
|
// NO Authorization header — it can't, that's the header it's asking permission to
|
||||||
|
// send. So the CORS middleware has to answer it before the admin gate sees it,
|
||||||
|
// otherwise every admin action fails the preflight with a 401 and never gets sent.
|
||||||
|
test('answers the preflight on an admin endpoint without a token', async () => {
|
||||||
|
const res = await exports.default.fetch(
|
||||||
|
new Request(`${ORIGIN}/internal/broadcast`, {
|
||||||
|
method: 'OPTIONS',
|
||||||
|
headers: {
|
||||||
|
origin: 'https://www.example.com',
|
||||||
|
'access-control-request-method': 'POST',
|
||||||
|
'access-control-request-headers': 'authorization, content-type',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
env
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(204)
|
||||||
|
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||||
|
const allowed = res.headers.get('access-control-allow-headers')?.toLowerCase() ?? ''
|
||||||
|
expect(allowed).toContain('authorization')
|
||||||
|
expect(allowed).toContain('content-type')
|
||||||
|
})
|
||||||
|
|
||||||
|
// The gate itself is untouched: the preflight passing is not the request passing.
|
||||||
|
test('still rejects the actual call without an admin token', async () => {
|
||||||
|
const res = await exports.default.fetch(
|
||||||
|
new Request(`${ORIGIN}/internal/broadcast`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { origin: 'https://www.example.com', 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ notificationType: 25, data: {} }),
|
||||||
|
}),
|
||||||
|
env
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- Per-subroom permission overrides. `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions`
|
||||||
|
-- is how a room's creator changes what a role may do in one subroom (spawn inventions,
|
||||||
|
-- invite, use the delete-all button, …). The client addresses an entry by the
|
||||||
|
-- (`Permission`, `Role`) pair and re-PUTs that pair to change it, so the pair is the
|
||||||
|
-- primary key: sending it again overwrites the stored row rather than appending a second.
|
||||||
|
--
|
||||||
|
-- A row IS an override, so the client's `Override` flag is not a column. It's the checkbox
|
||||||
|
-- the client draws next to each permission — `Override: true` stores the value, and
|
||||||
|
-- `Override: false` means "fall back to the default", which deletes the row. Reads always
|
||||||
|
-- serve `Override: true`.
|
||||||
|
--
|
||||||
|
-- Read on one path only — `GET /photon_access_token`, where a stored entry overwrites the
|
||||||
|
-- matching default in the permission table the client applies when it spawns. That's why
|
||||||
|
-- this is its own table rather than a field on the subroom's `data` blob: that blob is
|
||||||
|
-- served to the client verbatim inside the room, and nothing client-facing reads these.
|
||||||
|
--
|
||||||
|
-- `value` is the client's string kept verbatim: usually `True`/`False`, but a permission
|
||||||
|
-- whose UI isn't a True/False picker carries something else, and we don't interpret it.
|
||||||
|
--
|
||||||
|
-- Generated from packages/domain/src/rooms-db.ts (SUBROOM_SCHEMA_DDL) — keep in sync.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS subroom_permission (
|
||||||
|
sub_room_id INTEGER NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
role INTEGER NOT NULL,
|
||||||
|
type INTEGER NOT NULL DEFAULT 0,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (sub_room_id, permission, role)
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Per-room player bans. `POST /rooms/{roomId}/bans` is how a room's owner (or a
|
||||||
|
-- staff account) bans a player from a room; one row per (room, player), so
|
||||||
|
-- re-banning someone already banned updates their row rather than appending a
|
||||||
|
-- second one.
|
||||||
|
--
|
||||||
|
-- `ban_mask` is the client's `banMask` form field, stored verbatim. Its meaning is
|
||||||
|
-- not known yet — the client sends 0 — so nothing interprets it; it's kept so the
|
||||||
|
-- value isn't lost once we work out what it selects.
|
||||||
|
--
|
||||||
|
-- Columnar rather than a JSON blob, and deliberately NOT part of the room's `data`
|
||||||
|
-- blob: that blob is served to the client verbatim as the room, and a room's ban
|
||||||
|
-- list is not something every reader of a room should receive.
|
||||||
|
--
|
||||||
|
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_ban (
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
banned_player_id INTEGER NOT NULL,
|
||||||
|
ban_mask INTEGER NOT NULL DEFAULT 0,
|
||||||
|
banned_by_account_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (room_id, banned_player_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id);
|
||||||
@@ -64,6 +64,12 @@ export const FORBIDDEN_RESPONSE = {
|
|||||||
description: 'A valid token, but not the room’s creator or a co-owner (empty body)',
|
description: 'A valid token, but not the room’s creator or a co-owner (empty body)',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The 403 the friends-only routes return (empty body). */
|
||||||
|
export const NOT_FRIENDS_RESPONSE = {
|
||||||
|
description:
|
||||||
|
'A valid token, but the caller is not that player (nor a friend of theirs) (empty body)',
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Parameters ------------------------------------------------------------
|
// ---- Parameters ------------------------------------------------------------
|
||||||
|
|
||||||
/** A digits-only id path parameter (the route patterns constrain these to `[0-9]+`). */
|
/** A digits-only id path parameter (the route patterns constrain these to `[0-9]+`). */
|
||||||
@@ -83,6 +89,12 @@ export const roomIdParam = idParam('roomId', 'Room id')
|
|||||||
/** The `:subRoomId` path parameter. */
|
/** The `:subRoomId` path parameter. */
|
||||||
export const subRoomIdParam = idParam('subRoomId', 'Subroom id (globally unique, not per-room)')
|
export const subRoomIdParam = idParam('subRoomId', 'Subroom id (globally unique, not per-room)')
|
||||||
|
|
||||||
|
/** The `:playerId` path parameter (an account id). */
|
||||||
|
export const playerIdParam = idParam('playerId', 'The account whose list to read')
|
||||||
|
|
||||||
|
/** The `:playerId` path parameter on the unban route. */
|
||||||
|
export const bannedPlayerIdParam = idParam('playerId', 'The banned account to unban')
|
||||||
|
|
||||||
/** An optional string query parameter. */
|
/** An optional string query parameter. */
|
||||||
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||||
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
|
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
|
||||||
@@ -123,7 +135,11 @@ export const RoomTagDto = z.object({
|
|||||||
Type: z.int().describe('0 = owner-set, 2 = auto'),
|
Type: z.int().describe('0 = owner-set, 2 = auto'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** A room's engagement counters. Nothing increments these yet, so they stay at 0. */
|
/**
|
||||||
|
* A room's engagement counters. `CheerCount`/`FavoriteCount` are aggregated from the
|
||||||
|
* per-player `interaction` rows on every read; nothing records visits yet, so
|
||||||
|
* `VisitorCount`/`VisitCount` stay at 0.
|
||||||
|
*/
|
||||||
export const RoomStatsDto = z.object({
|
export const RoomStatsDto = z.object({
|
||||||
CheerCount: z.int(),
|
CheerCount: z.int(),
|
||||||
FavoriteCount: z.int(),
|
FavoriteCount: z.int(),
|
||||||
@@ -428,6 +444,42 @@ export const RoleRequest = z.object({
|
|||||||
role: z.string().describe('The role tier: 10 Host, 20 Moderator, 30 CoOwner, 255 Creator'),
|
role: z.string().describe('The role tier: 10 Host, 20 Moderator, 30 CoOwner, 255 Creator'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** `POST /rooms/{roomId}/bans` — the player to ban from the room. */
|
||||||
|
export const BanRequest = z.object({
|
||||||
|
id: z.string().describe('Account id of the player to ban'),
|
||||||
|
banMask: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Stored verbatim; meaning unknown — the client sends `0`. Defaults to 0'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** A stored room ban — what `POST /rooms/{roomId}/bans` answers in `value`. */
|
||||||
|
export const RoomBanDto = z.object({
|
||||||
|
RoomId: z.int(),
|
||||||
|
BannedPlayerId: z.int(),
|
||||||
|
BanMask: z.int(),
|
||||||
|
BannedByAccountId: z.int().describe('Who issued the ban'),
|
||||||
|
CreatedAt: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry of `GET /rooms/{roomId}/bans` — the client's ban-list shape. camelCase and
|
||||||
|
* a different field set from the {@link RoomBanDto} the write answers: no room id (the
|
||||||
|
* path already says which room) and no ban mask.
|
||||||
|
*/
|
||||||
|
export const RoomBanEntryDto = z.object({
|
||||||
|
accountId: z.int().describe('The banned player'),
|
||||||
|
bannedByAccountId: z.int().describe('Who issued the ban'),
|
||||||
|
banStartTime: z.string().describe('ISO 8601 UTC, when the ban was issued'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The envelope the ban write answers — same shape as the room writes, `value` is the ban. */
|
||||||
|
export const RoomBanEnvelope = z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.string().describe('Empty on success'),
|
||||||
|
value: RoomBanDto.nullable().describe('Null on a rejection'),
|
||||||
|
})
|
||||||
|
|
||||||
/** `PUT /rooms/{roomId}/warning`. */
|
/** `PUT /rooms/{roomId}/warning`. */
|
||||||
export const WarningRequest = z.object({
|
export const WarningRequest = z.object({
|
||||||
warningMask: z.string().describe('Content-warning bit flags, as an integer'),
|
warningMask: z.string().describe('Content-warning bit flags, as an integer'),
|
||||||
@@ -453,7 +505,7 @@ export const RestrictionsRequest = z.object({
|
|||||||
supportsJuniors: z.string().optional().describe('`True` / `False`'),
|
supportsJuniors: z.string().optional().describe('`True` / `False`'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** `PUT /rooms/{roomId}/loadscreen` — appends one screen to the list. */
|
/** `PUT /rooms/{roomId}/loadscreen` — the posted screen replaces the whole list. */
|
||||||
export const LoadScreenRequest = z.object({
|
export const LoadScreenRequest = z.object({
|
||||||
imageName: z.string().describe('A key from the storage upload'),
|
imageName: z.string().describe('A key from the storage upload'),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
@@ -479,6 +531,35 @@ export const SubRoomAccessibilityRequest = z.object({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions` — the entries to change, keyed by
|
||||||
|
* (`Permission`, `Role`). Only the pairs sent are touched. `Override` is the client's
|
||||||
|
* checkbox: true stores the entry, false clears it back to the default.
|
||||||
|
*/
|
||||||
|
export const SubRoomPermissionsRequest = z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
Permission: z
|
||||||
|
.string()
|
||||||
|
.describe('e.g. `CAN_SAVE_INVENTIONS`, `CAN_INVITE`, `CAN_USE_DELETE_ALL_BUTTON`'),
|
||||||
|
Role: z.int().describe('The role tier the entry applies to (0 = everyone, 30 = co-owner)'),
|
||||||
|
Override: z
|
||||||
|
.boolean()
|
||||||
|
.describe(
|
||||||
|
'The override checkbox, and a JSON boolean unlike `Value`: true stores this entry, ' +
|
||||||
|
'false DELETES any stored one so the pair falls back to its default'
|
||||||
|
),
|
||||||
|
Type: z.int().describe('Always 0 in what the client sends; stored verbatim'),
|
||||||
|
Value: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'A STRING, not a boolean — usually `True` / `False`, but kept verbatim: not every ' +
|
||||||
|
'permission’s UI is a True/False picker. Ignored when `Override` is false'
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.describe('An array — the client sends one even when changing a single permission')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save` — promotes one save to live.
|
* `POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save` — promotes one save to live.
|
||||||
* Any id from the subroom's history works, so this is both publish and restore.
|
* Any id from the subroom's history works, so this is both publish and restore.
|
||||||
@@ -543,11 +624,13 @@ export const SubRoomSavesPage = z.object({
|
|||||||
|
|
||||||
/** One entry of the permission table the client applies when it spawns into a room. */
|
/** One entry of the permission table the client applies when it spawns into a room. */
|
||||||
export const RoomPermissionDto = z.object({
|
export const RoomPermissionDto = z.object({
|
||||||
Override: z.boolean(),
|
Override: z.boolean().describe('Always true on an entry that came from a subroom’s overrides'),
|
||||||
Permission: z.string().describe('e.g. `CAN_USE_MAKER_PEN`, `CAN_SAVE_INVENTIONS`'),
|
Permission: z.string().describe('e.g. `CAN_USE_MAKER_PEN`, `CAN_SAVE_INVENTIONS`'),
|
||||||
Role: z.int().describe('The role tier the permission applies to (0 = everyone)'),
|
Role: z.int().describe('The role tier the permission applies to (0 = everyone)'),
|
||||||
Type: z.int(),
|
Type: z.int(),
|
||||||
Value: z.string().describe('Always `True` — a permission is present or absent'),
|
Value: z
|
||||||
|
.string()
|
||||||
|
.describe('A STRING, not a boolean — `True` on the defaults, anything on an override'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -556,6 +639,11 @@ export const RoomPermissionDto = z.object({
|
|||||||
* `PhotonAccessToken` is deliberately empty: the reference server signs it with a
|
* `PhotonAccessToken` is deliberately empty: the reference server signs it with a
|
||||||
* secret/algorithm we don't have, and our Photon setup accepts an empty token. The
|
* secret/algorithm we don't have, and our Photon setup accepts an empty token. The
|
||||||
* global (Role 0) maker pen is granted only to the hardcoded dev accounts.
|
* global (Role 0) maker pen is granted only to the hardcoded dev accounts.
|
||||||
|
*
|
||||||
|
* `Permissions` is the default table with the overrides stored on the subroom the caller
|
||||||
|
* is standing in merged over it (see
|
||||||
|
* `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions`): an override replaces the
|
||||||
|
* default with the same (`Permission`, `Role`), and one naming a new pair is appended.
|
||||||
*/
|
*/
|
||||||
export const PhotonAccessTokenDto = z.object({
|
export const PhotonAccessTokenDto = z.object({
|
||||||
Permissions: z.array(RoomPermissionDto),
|
Permissions: z.array(RoomPermissionDto),
|
||||||
|
|||||||
+518
-48
@@ -4,6 +4,8 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Accessibility,
|
Accessibility,
|
||||||
|
areFriends,
|
||||||
|
banPlayerFromRoom,
|
||||||
canManageRoom,
|
canManageRoom,
|
||||||
cloneRoom,
|
cloneRoom,
|
||||||
cloneSubRoom,
|
cloneSubRoom,
|
||||||
@@ -20,14 +22,18 @@ import {
|
|||||||
getPresence,
|
getPresence,
|
||||||
getPublicRoomsByCreator,
|
getPublicRoomsByCreator,
|
||||||
getRecommendedRooms,
|
getRecommendedRooms,
|
||||||
|
getRoomBans,
|
||||||
getRoomById,
|
getRoomById,
|
||||||
getRoomByName,
|
getRoomByName,
|
||||||
getRoomsByCreator,
|
getRoomsByCreator,
|
||||||
getRoomsByIds,
|
getRoomsByIds,
|
||||||
getSimilarRooms,
|
getSimilarRooms,
|
||||||
|
getSubRoomPermissions,
|
||||||
getSubRoomSaves,
|
getSubRoomSaves,
|
||||||
getVisitedRooms,
|
getVisitedRooms,
|
||||||
|
MAX_ROOM_NAME_LENGTH,
|
||||||
modifySubRoom,
|
modifySubRoom,
|
||||||
|
nameRejection,
|
||||||
publishSubRoomSave,
|
publishSubRoomSave,
|
||||||
removeCheer,
|
removeCheer,
|
||||||
removeFavorite,
|
removeFavorite,
|
||||||
@@ -37,17 +43,24 @@ import {
|
|||||||
setRoomImage,
|
setRoomImage,
|
||||||
setRoomName,
|
setRoomName,
|
||||||
setRoomRole,
|
setRoomRole,
|
||||||
|
setSubRoomPermissions,
|
||||||
toggleCheer,
|
toggleCheer,
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
toggleRoomTag,
|
toggleRoomTag,
|
||||||
|
unbanPlayerFromRoom,
|
||||||
updateRoomFields,
|
updateRoomFields,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||||
|
// 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 {
|
import {
|
||||||
AccessibilityRequest,
|
AccessibilityRequest,
|
||||||
AUTHED,
|
AUTHED,
|
||||||
|
bannedPlayerIdParam,
|
||||||
|
BanRequest,
|
||||||
CloneRoomRequest,
|
CloneRoomRequest,
|
||||||
CloningRequest,
|
CloningRequest,
|
||||||
CreateSubRoomRequest,
|
CreateSubRoomRequest,
|
||||||
@@ -63,13 +76,17 @@ import {
|
|||||||
MissingLookupParam,
|
MissingLookupParam,
|
||||||
ModifySubRoomRequest,
|
ModifySubRoomRequest,
|
||||||
NameRequest,
|
NameRequest,
|
||||||
|
NOT_FRIENDS_RESPONSE,
|
||||||
PagedRooms,
|
PagedRooms,
|
||||||
pageParams,
|
pageParams,
|
||||||
PhotonAccessTokenDto,
|
PhotonAccessTokenDto,
|
||||||
PlayerDataDto,
|
PlayerDataDto,
|
||||||
|
playerIdParam,
|
||||||
PublishSaveRequest,
|
PublishSaveRequest,
|
||||||
RestrictionsRequest,
|
RestrictionsRequest,
|
||||||
RoleRequest,
|
RoleRequest,
|
||||||
|
RoomBanEnvelope,
|
||||||
|
RoomBanEntryDto,
|
||||||
RoomDto,
|
RoomDto,
|
||||||
RoomEnvelope,
|
RoomEnvelope,
|
||||||
roomIdParam,
|
roomIdParam,
|
||||||
@@ -81,6 +98,7 @@ import {
|
|||||||
stringQuery,
|
stringQuery,
|
||||||
SubRoomAccessibilityRequest,
|
SubRoomAccessibilityRequest,
|
||||||
subRoomIdParam,
|
subRoomIdParam,
|
||||||
|
SubRoomPermissionsRequest,
|
||||||
SubRoomSavesPage,
|
SubRoomSavesPage,
|
||||||
TagRequest,
|
TagRequest,
|
||||||
UNAUTHORIZED_EMPTY,
|
UNAUTHORIZED_EMPTY,
|
||||||
@@ -90,6 +108,7 @@ import {
|
|||||||
} from './openapi'
|
} from './openapi'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
|
import type { RoomBan, RoomPermission } from '@repo/domain'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,9 +150,14 @@ const DEFAULT_MAX_ROOMS_PER_ACCOUNT = 10
|
|||||||
* hardcoded moderator/dev accounts. */
|
* hardcoded moderator/dev accounts. */
|
||||||
const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])
|
const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])
|
||||||
|
|
||||||
/** The slice of the shared presence row we read — the caller's current room instance. */
|
/**
|
||||||
|
* The slice of the shared presence row we read — the caller's current room instance.
|
||||||
|
* `subRoomId` is what scopes the stored permission overrides: they belong to the subroom
|
||||||
|
* the player is standing in, not to the room.
|
||||||
|
*/
|
||||||
interface PresenceView {
|
interface PresenceView {
|
||||||
roomInstanceId?: number
|
roomInstanceId?: number
|
||||||
|
subRoomId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,16 +167,26 @@ interface PresenceView {
|
|||||||
* they aren't in one). `PhotonAccessToken` stays empty — the reference server
|
* they aren't in one). `PhotonAccessToken` stays empty — the reference server
|
||||||
* signs it via `ClientSecurity`, whose secret/algorithm we don't have; our
|
* signs it via `ClientSecurity`, whose secret/algorithm we don't have; our
|
||||||
* Photon setup accepts an empty token.
|
* Photon setup accepts an empty token.
|
||||||
|
*
|
||||||
|
* `overrides` are the permissions the room's creator saved on the subroom the caller is
|
||||||
|
* in (see `PUT …/subrooms/{subRoomId}/permissions`). They are matched against the
|
||||||
|
* defaults by (`Permission`, `Role`) — the same pair the client addresses an entry by —
|
||||||
|
* and win, so a subroom that revokes the Role 0 maker pen revokes it for a dev account
|
||||||
|
* standing in it as well.
|
||||||
*/
|
*/
|
||||||
function photonAccessToken(accountId: number, roomInstanceId: number | null) {
|
function photonAccessToken(
|
||||||
const perm = (Permission: string, Role: number, Override: boolean) => ({
|
accountId: number,
|
||||||
|
roomInstanceId: number | null,
|
||||||
|
overrides: RoomPermission[] = []
|
||||||
|
) {
|
||||||
|
const perm = (Permission: string, Role: number, Override: boolean): RoomPermission => ({
|
||||||
Override,
|
Override,
|
||||||
Permission,
|
Permission,
|
||||||
Role,
|
Role,
|
||||||
Type: 0,
|
Type: 0,
|
||||||
Value: 'True',
|
Value: 'True',
|
||||||
})
|
})
|
||||||
const permissions = [
|
const permissions: RoomPermission[] = [
|
||||||
perm('CAN_USE_ROOM_RESET_BUTTON', 0, true),
|
perm('CAN_USE_ROOM_RESET_BUTTON', 0, true),
|
||||||
perm('CAN_USE_DELETE_ALL_BUTTON', 0, true),
|
perm('CAN_USE_DELETE_ALL_BUTTON', 0, true),
|
||||||
perm('CAN_SAVE_INVENTIONS', 0, true),
|
perm('CAN_SAVE_INVENTIONS', 0, true),
|
||||||
@@ -165,9 +199,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) {
|
|||||||
perm('CAN_SPAWN_INVENTIONS', 30, true),
|
perm('CAN_SPAWN_INVENTIONS', 30, true),
|
||||||
perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true),
|
perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true),
|
||||||
]
|
]
|
||||||
|
|
||||||
if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) {
|
if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) {
|
||||||
permissions.unshift(perm('CAN_USE_MAKER_PEN', 0, true))
|
permissions.unshift(perm('CAN_USE_MAKER_PEN', 0, true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The subroom's stored table wins, applied LAST and over the dev grant too: a
|
||||||
|
// (Permission, Role) the table already carries is replaced in place — so the order
|
||||||
|
// doesn't shift under the client, and no pair is ever listed twice with two values —
|
||||||
|
// and one it doesn't (e.g. CAN_INVITE) is appended.
|
||||||
|
for (const override of overrides) {
|
||||||
|
const i = permissions.findIndex(
|
||||||
|
(p) => p.Permission === override.Permission && p.Role === override.Role
|
||||||
|
)
|
||||||
|
if (i === -1) permissions.push(override)
|
||||||
|
else permissions[i] = override
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
PhotonAccessToken: '',
|
PhotonAccessToken: '',
|
||||||
@@ -176,16 +223,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Photon access-token handler (served bare and under `/roomserver`). Auth-gated:
|
* Photon access-token handler. Auth-gated: resolves the caller, reads their current
|
||||||
* resolves the caller, reads their current room instance from the shared
|
* room instance from the shared `presence` table (see @repo/domain), and returns the
|
||||||
* `presence` table (see @repo/domain), and returns the permissions + token.
|
* permissions + token.
|
||||||
*/
|
*/
|
||||||
async function handlePhotonAccessToken(c: Context<App>) {
|
async function handlePhotonAccessToken(c: Context<App>) {
|
||||||
const accountId = await authedAccountId(c)
|
const accountId = await authedAccountId(c)
|
||||||
if (accountId === null) return unauthorized(c)
|
if (accountId === null) return unauthorized(c)
|
||||||
const presence = await getPresence<PresenceView>(c.env.DB, accountId)
|
const instance = (await getPresence<PresenceView>(c.env.DB, accountId))?.roomInstance
|
||||||
const roomInstanceId = presence?.roomInstance?.roomInstanceId ?? null
|
// The permission overrides are the ones saved on the subroom the caller is standing in.
|
||||||
return c.json(photonAccessToken(accountId, roomInstanceId))
|
// A player in no instance — sitting in the lobby, or an instance predating subroom
|
||||||
|
// tracking — gets the default table untouched.
|
||||||
|
const overrides =
|
||||||
|
typeof instance?.subRoomId === 'number'
|
||||||
|
? await getSubRoomPermissions(c.env.DB, instance.subRoomId)
|
||||||
|
: []
|
||||||
|
return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, overrides))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The Bearer token's account id (`sub`), or null when there's no valid token. */
|
/** The Bearer token's account id (`sub`), or null when there's no valid token. */
|
||||||
@@ -193,6 +246,22 @@ async function authedAccountId(c: Context<App>): Promise<number | null> {
|
|||||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator-granted elevated roles — the ones the auth worker stamps from an account's
|
||||||
|
* isDeveloper/isModerator flags (see the admin CLI). Same set the `notify` / `www`
|
||||||
|
* workers gate their admin surfaces on.
|
||||||
|
*/
|
||||||
|
const STAFF_ROLES: ReadonlySet<string> = new Set(['developer', 'moderator'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the caller's token carries a staff role. Used alongside the per-room owner
|
||||||
|
* check for actions staff may take in a room they don't own.
|
||||||
|
*/
|
||||||
|
async function isStaff(c: Context<App>): Promise<boolean> {
|
||||||
|
const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
|
return roles?.some((role) => STAFF_ROLES.has(role)) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
/** 401 for the auth-gated `*by/me` endpoints — no stub-account fallback. */
|
/** 401 for the auth-gated `*by/me` endpoints — no stub-account fallback. */
|
||||||
function unauthorized(c: Context<App>) {
|
function unauthorized(c: Context<App>) {
|
||||||
return c.json({ error: 'Unauthorized' }, 401)
|
return c.json({ error: 'Unauthorized' }, 401)
|
||||||
@@ -214,6 +283,59 @@ function parseAccessibility(value: unknown): number | undefined {
|
|||||||
return named ? (named[1] as number) : undefined
|
return named ? (named[1] as number) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse an integer from the number or numeric string a JSON body may carry. */
|
||||||
|
function parseInt10(value: unknown): number | undefined {
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : undefined
|
||||||
|
if (typeof value !== 'string') return undefined
|
||||||
|
const n = Number.parseInt(value.trim(), 10)
|
||||||
|
return Number.isNaN(n) ? undefined : n
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client's `Value`, kept as the STRING it sends. Usually `"True"`/`"False"` — the
|
||||||
|
* True/False picker beside the override checkbox — but a permission whose UI is something
|
||||||
|
* else carries a different value, so nothing here interprets it. A JSON boolean or number
|
||||||
|
* is rendered the way the client would have written it.
|
||||||
|
*/
|
||||||
|
function permissionValue(value: unknown): string {
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
if (typeof value === 'boolean') return value ? 'True' : 'False'
|
||||||
|
if (typeof value === 'number') return String(value)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the subroom-permissions PUT body: a JSON ARRAY of
|
||||||
|
* `{ Permission, Role, Override, Type, Value }` entries.
|
||||||
|
*
|
||||||
|
* `Override` is the client's checkbox, not data — see {@link setSubRoomPermissions}: true
|
||||||
|
* stores `Value` for that (`Permission`, `Role`), false clears any stored entry so the
|
||||||
|
* pair falls back to the default. It is carried through as sent.
|
||||||
|
*
|
||||||
|
* Entries without a permission name or a usable role are dropped rather than rejected —
|
||||||
|
* the client ignores the response either way, so half a table applied beats none.
|
||||||
|
*/
|
||||||
|
function parseRoomPermissions(body: unknown): RoomPermission[] {
|
||||||
|
if (!Array.isArray(body)) return []
|
||||||
|
const permissions: RoomPermission[] = []
|
||||||
|
for (const entry of body) {
|
||||||
|
if (typeof entry !== 'object' || entry === null) continue
|
||||||
|
const e = entry as Record<string, unknown>
|
||||||
|
const permission = typeof e.Permission === 'string' ? e.Permission.trim() : ''
|
||||||
|
const role = parseInt10(e.Role)
|
||||||
|
if (permission === '' || role === undefined) continue
|
||||||
|
permissions.push({
|
||||||
|
Permission: permission,
|
||||||
|
Role: role,
|
||||||
|
// Sent as a JSON boolean, unlike `Value` — accept the string form regardless.
|
||||||
|
Override: e.Override === true || String(e.Override).toLowerCase() === 'true',
|
||||||
|
Type: parseInt10(e.Type) ?? 0,
|
||||||
|
Value: permissionValue(e.Value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return permissions
|
||||||
|
}
|
||||||
|
|
||||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||||
const HUB_INSTANCE = 'global'
|
const HUB_INSTANCE = 'global'
|
||||||
|
|
||||||
@@ -256,6 +378,63 @@ async function pushRoomUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `reportCategory` on a moderation frame. -1 is "Moderator" — the category for an
|
||||||
|
* action a person took rather than one the system inferred, which is what a room ban
|
||||||
|
* is. The rest of the enum, for reference: 2 Harassment, 3 Cheating, 5 AFK, 6 Misc,
|
||||||
|
* 7 Underage, 10 VoteKick, 100–104 CoC_*, 200 InappropriateClothing.
|
||||||
|
*/
|
||||||
|
const REPORT_CATEGORY_MODERATOR = -1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eject a player from the room they're in — a `ModerationKick` push (id 22), the frame
|
||||||
|
* the client acts on to remove someone. Sent on a ban: the row keeps them out of future
|
||||||
|
* matchmakes, this gets them out of the instance they're in right now.
|
||||||
|
*
|
||||||
|
* The payload is the client's moderation shape, camelCase, in wire order:
|
||||||
|
* `reportCategory`, `duration`, `gameSessionId`, `isHostKick`, `message`,
|
||||||
|
* `playerIdReporter`, `isBan`, `isVoiceModAutoban`. `duration` is 0 (a room ban has no
|
||||||
|
* expiry — it's lifted by DELETE, not by time) and `gameSessionId` is 0 (nothing here
|
||||||
|
* tracks one).
|
||||||
|
*
|
||||||
|
* `isHostKick` says the room's HOST ejected the player, as opposed to the room
|
||||||
|
* majority vote-kicking them. There is no vote-kick path yet, so the only false case
|
||||||
|
* here is a staff moderator acting in a room they don't host. `playerIdReporter` is
|
||||||
|
* whoever caused it — the host today, and the player who started the vote once
|
||||||
|
* vote-kicks exist (those will carry `reportCategory` 10 and `isHostKick` false).
|
||||||
|
*
|
||||||
|
* Like {@link pushRoomUpdate}, hub failures are logged and swallowed: the ban row has
|
||||||
|
* already committed, so a hub hiccup must not fail the request.
|
||||||
|
*/
|
||||||
|
async function pushRoomBan(
|
||||||
|
c: Context<App>,
|
||||||
|
ban: RoomBan,
|
||||||
|
roomName: string,
|
||||||
|
isHostKick: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
|
ban.BannedPlayerId,
|
||||||
|
NotificationType.ModerationKick,
|
||||||
|
{
|
||||||
|
reportCategory: REPORT_CATEGORY_MODERATOR,
|
||||||
|
duration: 0,
|
||||||
|
gameSessionId: 0,
|
||||||
|
isHostKick,
|
||||||
|
message: `You have been banned from ${roomName}.`,
|
||||||
|
playerIdReporter: ban.BannedByAccountId,
|
||||||
|
isBan: true,
|
||||||
|
isVoiceModAutoban: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('failed to push ModerationKick notification', {
|
||||||
|
playerId: ban.BannedPlayerId,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Room-mutation result envelope: `{ Success, Value, ErrorId, Error }`, always
|
* Room-mutation result envelope: `{ Success, Value, ErrorId, Error }`, always
|
||||||
* HTTP 200 (the client reads `Success`). `ErrorId`/`Error` are null on success.
|
* HTTP 200 (the client reads `Success`). `ErrorId`/`Error` are null on success.
|
||||||
@@ -304,6 +483,12 @@ function roomEnvelope(c: Context<App>, value: unknown, error = '') {
|
|||||||
return c.json({ success: error === '', error, value })
|
return c.json({ success: error === '', error, value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same envelope for the ban write, whose `value` is the BAN rather than the room —
|
||||||
|
* a ban isn't part of the room the client renders, so there is no updated room to send.
|
||||||
|
*/
|
||||||
|
const banEnvelope = roomEnvelope
|
||||||
|
|
||||||
/** Rooms created/owned by the authed caller (shared by the createdby routes). */
|
/** Rooms created/owned by the authed caller (shared by the createdby routes). */
|
||||||
async function ownedRooms(c: Context<App>) {
|
async function ownedRooms(c: Context<App>) {
|
||||||
const accountId = await authedAccountId(c)
|
const accountId = await authedAccountId(c)
|
||||||
@@ -410,19 +595,27 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// "Hot" rooms feed — public, non-dorm rooms ordered by engagement, optionally
|
// "Hot" rooms feed — public, non-dorm rooms ordered by live player count (their
|
||||||
// filtered to a single `tag` (e.g. `rro`). Paginated via skip/take (take
|
// instances' presence), then stored engagement, optionally filtered to a single
|
||||||
// defaults to 100). Returns `{ Results, TotalResults }` like search.
|
// `tag` (e.g. `rro`). `tag=new` is a pseudo-tag no room carries: it serves the
|
||||||
|
// player-made (non-RRO) rooms newest-first. Paginated via skip/take (take defaults
|
||||||
|
// to 100). Returns `{ Results, TotalResults }` like search.
|
||||||
.get(
|
.get(
|
||||||
'/rooms/hot',
|
'/rooms/hot',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Discovery'],
|
tags: ['Discovery'],
|
||||||
summary: 'The “hot” rooms feed',
|
summary: 'The “hot” rooms feed',
|
||||||
description: [
|
description: [
|
||||||
'Public, non-dorm rooms ordered by engagement, optionally narrowed to a single `tag`',
|
'Public, non-dorm rooms ordered by how many players are in them right now — live',
|
||||||
'(the browse screen’s filter chips post one, e.g. `rro`).',
|
'presence summed across each room’s instances — falling back to stored engagement',
|
||||||
|
'for rooms nobody is in. Optionally narrowed to a single `tag` (the browse screen’s',
|
||||||
|
'filter chips post one, e.g. `rro`). The `new` chip is a pseudo-tag — no room carries',
|
||||||
|
'a `new` tag — and instead serves the player-made (non-RRO) rooms, newest first.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
parameters: [stringQuery('tag', 'Restrict to rooms carrying this tag'), ...pageParams(100)],
|
parameters: [
|
||||||
|
stringQuery('tag', 'Restrict to rooms carrying this tag (or `new`, a pseudo-tag)'),
|
||||||
|
...pageParams(100),
|
||||||
|
],
|
||||||
responses: { 200: json(PagedRooms, 'The feed page') },
|
responses: { 200: json(PagedRooms, 'The feed page') },
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -672,6 +865,45 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Another player's visited rooms — what the client shows on a friend's profile.
|
||||||
|
// Auth-gated (401), and FRIENDS-ONLY: a valid token for someone who isn't that
|
||||||
|
// player and isn't a mutual friend of theirs is a 403, since where a player has
|
||||||
|
// been is not public. Registered after `visitedby/me` so the literal path wins.
|
||||||
|
// Paginated via skip/take (take defaults to 100) and, like `visitedby/me`, a bare
|
||||||
|
// array — the client's room-source loaders expect a plain list, not a page.
|
||||||
|
.get(
|
||||||
|
'/rooms/visitedby/:playerId{[0-9]+}',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Rooms'],
|
||||||
|
summary: 'A friend’s visited rooms',
|
||||||
|
description: [
|
||||||
|
'The rooms another player has visited, as a bare array. Friends only: the caller must',
|
||||||
|
'be that player or a mutual friend of theirs (403 otherwise) — visit history is not',
|
||||||
|
'public.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [playerIdParam, ...pageParams(100)],
|
||||||
|
responses: {
|
||||||
|
200: json(RoomDto.array(), 'That player’s visited rooms'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
403: NOT_FRIENDS_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||||
|
// Your own history is always readable (the client sometimes sends the id
|
||||||
|
// rather than `me`); anyone else's needs a mutual friendship.
|
||||||
|
if (playerId !== accountId && !(await areFriends(c.env.DB, accountId, playerId))) {
|
||||||
|
return c.body(null, 403)
|
||||||
|
}
|
||||||
|
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 getVisitedRooms(c.env.DB, playerId, skip, take))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// The current player's interaction state with a room (cheered/favorited/last
|
// The current player's interaction state with a room (cheered/favorited/last
|
||||||
// visited), read from the `interaction` table. Auth-gated.
|
// visited), read from the `interaction` table. Auth-gated.
|
||||||
.get(
|
.get(
|
||||||
@@ -841,6 +1073,9 @@ const app = new Hono<App>()
|
|||||||
const name = typeof raw === 'string' ? raw.trim() : ''
|
const name = typeof raw === 'string' ? raw.trim() : ''
|
||||||
|
|
||||||
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your room.')
|
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your room.')
|
||||||
|
// Shape before availability, so a rejected name costs no D1 read.
|
||||||
|
const badName = nameRejection(name, 'room name', MAX_ROOM_NAME_LENGTH)
|
||||||
|
if (badName !== null) return roomEnvelope(c, null, badName)
|
||||||
if (await getRoomByName(c.env.DB, name)) {
|
if (await getRoomByName(c.env.DB, name)) {
|
||||||
return roomEnvelope(c, null, 'A room with that name already exists!')
|
return roomEnvelope(c, null, 'A room with that name already exists!')
|
||||||
}
|
}
|
||||||
@@ -965,6 +1200,12 @@ const app = new Hono<App>()
|
|||||||
Error: 'You must enter a name for your room!',
|
Error: 'You must enter a name for your room!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Same ErrorId as the empty case — the client keys off it to mark the field, and
|
||||||
|
// both are the name being unusable. The sentence is what tells them which.
|
||||||
|
const badName = nameRejection(name, 'room name', MAX_ROOM_NAME_LENGTH)
|
||||||
|
if (badName !== null) {
|
||||||
|
return roomResult(c, { Success: false, ErrorId: 'Rooms.InvalidName', Error: badName })
|
||||||
|
}
|
||||||
|
|
||||||
// Reject if a different room already uses this name (case-insensitive).
|
// Reject if a different room already uses this name (case-insensitive).
|
||||||
const existing = await getRoomByName(c.env.DB, name)
|
const existing = await getRoomByName(c.env.DB, name)
|
||||||
@@ -1202,6 +1443,179 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A room's ban list — the owner's view of who they've banned. Same gate as issuing a
|
||||||
|
// ban: a ban list says who a room's owner has had trouble with, so it isn't public.
|
||||||
|
// Answers a BARE array (not the room-write envelope), newest ban first.
|
||||||
|
.get(
|
||||||
|
'/rooms/:roomId{[0-9]+}/bans',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'A room’s ban list',
|
||||||
|
description: [
|
||||||
|
'Everyone banned from the room, most recently banned first. Auth-gated, then gated',
|
||||||
|
'exactly like issuing a ban: the room’s creator or a co-owner, or an account whose',
|
||||||
|
'token carries the `developer` / `moderator` role. A ban list says who a room’s',
|
||||||
|
'owner has had trouble with, so it is not public.',
|
||||||
|
'',
|
||||||
|
'A bare array, NOT the `{ success, error, value }` envelope the ban write answers,',
|
||||||
|
'and the entries are camelCase with a different field set: no room id (the path',
|
||||||
|
'already says which room) and no ban mask. An unknown room is an empty list rather',
|
||||||
|
'than an error — it reads the same as a room nobody is banned from.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
responses: {
|
||||||
|
200: json(RoomBanEntryDto.array(), 'The room’s bans, newest first'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
403: FORBIDDEN_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
// No room → nothing banned. Same answer as a room with an empty ban list, so
|
||||||
|
// this doesn't become a way to probe which room ids exist.
|
||||||
|
if (!room) return c.json([])
|
||||||
|
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
|
||||||
|
|
||||||
|
const bans = await getRoomBans(c.env.DB, roomId)
|
||||||
|
return c.json(
|
||||||
|
bans.map((ban) => ({
|
||||||
|
accountId: ban.BannedPlayerId,
|
||||||
|
bannedByAccountId: ban.BannedByAccountId,
|
||||||
|
banStartTime: ban.CreatedAt,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ban a player from a room (form body `id` + `banMask`). Auth-gated (401), then
|
||||||
|
// gated to the room's owner/co-owner OR a staff token (403). One row per
|
||||||
|
// (room, player) — re-banning rewrites it, so the call is idempotent.
|
||||||
|
.post(
|
||||||
|
'/rooms/:roomId{[0-9]+}/bans',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Ban a player from a room',
|
||||||
|
description: [
|
||||||
|
'Records a ban in the `room_ban` table — one row per (room, player), so re-banning',
|
||||||
|
'someone already banned rewrites their row rather than adding a second. The row is',
|
||||||
|
'what the `match` worker checks: a banned player’s matchmake into this room is',
|
||||||
|
'refused with errorCode 55 and never gets a Photon room id.',
|
||||||
|
'',
|
||||||
|
'Gated to the room’s creator or a co-owner, OR to any account whose token carries the',
|
||||||
|
'`developer` / `moderator` role — a valid token from anyone else is a 403. Banning',
|
||||||
|
'yourself, or banning someone who can manage the room, is refused: otherwise a',
|
||||||
|
'co-owner could ban the owner out of their own room.',
|
||||||
|
'',
|
||||||
|
'`banMask` is stored verbatim and nothing interprets it — the client sends `0` and',
|
||||||
|
'what it selects is not known yet. It defaults to 0 when absent.',
|
||||||
|
'',
|
||||||
|
'The BANNED player (not the caller) gets a `ModerationKick` push (id 22) — the frame',
|
||||||
|
'the client acts on to eject someone — so a ban takes effect immediately rather than',
|
||||||
|
'only at their next matchmake. `isBan` is true, `duration` 0 (a room ban has no',
|
||||||
|
'expiry; it is lifted by DELETE, not by time) and `reportCategory` -1 (Moderator).',
|
||||||
|
'',
|
||||||
|
'`isHostKick` means the room’s HOST ejected them rather than the room majority',
|
||||||
|
'vote-kicking them; with no vote-kick path yet the only false case is a staff',
|
||||||
|
'moderator acting in a room they do not host. `playerIdReporter` is whoever caused',
|
||||||
|
'it — the host today, the player who started the vote once vote-kicks exist. The hub',
|
||||||
|
'queues the frame if they are offline.',
|
||||||
|
'',
|
||||||
|
'Answers the same lowercase `{ success, error, value }` envelope the room writes use,',
|
||||||
|
'but `value` is the BAN, not the room — a ban is not part of the room the client',
|
||||||
|
'renders. This shape is unverified against the real service.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
requestBody: form(BanRequest, 'The player to ban'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomBanEnvelope, 'The stored ban, or a rejection with `success: false`'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
403: FORBIDDEN_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
if (!room) return banEnvelope(c, null, 'This room does not exist!')
|
||||||
|
|
||||||
|
// The room's own owners, or a staffer acting across rooms. Roles are only
|
||||||
|
// looked up when the cheaper room check fails. The room's own owner IS the
|
||||||
|
// host, which is what the kick frame's `isHostKick` reports.
|
||||||
|
const isHostKick = canManageRoom(room, accountId)
|
||||||
|
if (!isHostKick && !(await isStaff(c))) return c.body(null, 403)
|
||||||
|
|
||||||
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
|
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||||||
|
const bannedPlayerId = Number.parseInt(str(body.id), 10)
|
||||||
|
if (Number.isNaN(bannedPlayerId)) {
|
||||||
|
return banEnvelope(c, null, 'You must provide a valid player to ban!')
|
||||||
|
}
|
||||||
|
if (bannedPlayerId === accountId) return banEnvelope(c, null, 'You cannot ban yourself!')
|
||||||
|
// Without this a co-owner could ban the room's creator out of their own room.
|
||||||
|
if (canManageRoom(room, bannedPlayerId)) {
|
||||||
|
return banEnvelope(c, null, 'You cannot ban an owner of this room!')
|
||||||
|
}
|
||||||
|
// Absent or unparseable → 0, the value the client sends.
|
||||||
|
const banMask = Number.parseInt(str(body.banMask), 10) || 0
|
||||||
|
|
||||||
|
const ban = await banPlayerFromRoom(c.env.DB, roomId, bannedPlayerId, banMask, accountId)
|
||||||
|
// The banned player is told, not the caller — their client acts on the kick.
|
||||||
|
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
|
||||||
|
await pushRoomBan(c, ban, roomName, isHostKick)
|
||||||
|
return banEnvelope(c, ban)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lift a player's ban on a room. Same gate as issuing one: auth-gated (401), then the
|
||||||
|
// room's owner/co-owner OR a staff token (403).
|
||||||
|
.delete(
|
||||||
|
'/rooms/:roomId{[0-9]+}/bans/:playerId{[0-9]+}',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Unban a player from a room',
|
||||||
|
description: [
|
||||||
|
'Removes the player’s `room_ban` row, so they can matchmake into the room again.',
|
||||||
|
'Gated exactly like issuing a ban: the room’s creator or a co-owner, or an account',
|
||||||
|
'whose token carries the `developer` / `moderator` role.',
|
||||||
|
'',
|
||||||
|
'Unbanning someone who is not banned is a rejection (`success: false`), not a silent',
|
||||||
|
'success — the caller asked to undo something that was not there.',
|
||||||
|
'',
|
||||||
|
'Answers the same envelope as the ban write, with the REMOVED ban as `value`. No',
|
||||||
|
'notification is pushed: nothing tells a player their ban was lifted.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam, bannedPlayerIdParam],
|
||||||
|
responses: {
|
||||||
|
200: json(RoomBanEnvelope, 'The removed ban, or a rejection with `success: false`'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
403: FORBIDDEN_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
if (!room) return banEnvelope(c, null, 'This room does not exist!')
|
||||||
|
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
|
||||||
|
|
||||||
|
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||||
|
const removed = await unbanPlayerFromRoom(c.env.DB, roomId, playerId)
|
||||||
|
if (!removed) return banEnvelope(c, null, 'This player is not banned from this room!')
|
||||||
|
return banEnvelope(c, removed)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Set a room's content warning: the `WarningMask` bit flags plus an optional
|
// Set a room's content warning: the `WarningMask` bit flags plus an optional
|
||||||
// free-text `CustomWarning`. Auth-gated (401) and owner/co-owner-only (403). Body is
|
// free-text `CustomWarning`. Auth-gated (401) and owner/co-owner-only (403). Body is
|
||||||
// the `warningMask` form field (an integer) and an optional `customWarning` string
|
// the `warningMask` form field (an integer) and an optional `customWarning` string
|
||||||
@@ -1349,24 +1763,31 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add a load screen to a room (`LoadScreens[]` — the images shown while the room
|
// Set a room's load screen (`LoadScreens[]` — the image shown while the room loads).
|
||||||
// loads). Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName`
|
// Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName` form field
|
||||||
// form field plus optional `title`/`subtitle`. Appends one
|
// plus optional `title`/`subtitle`. REPLACES the list with the single posted
|
||||||
// `{ ImageName, Title, Subtitle }` to the existing list and returns the updated
|
// `{ ImageName, Title, Subtitle }` and returns the updated room in the
|
||||||
// room in the `{ success, error, value }` envelope.
|
// `{ success, error, value }` envelope.
|
||||||
|
//
|
||||||
|
// The field is an array because the client's parser wants one, but the client only
|
||||||
|
// ever renders (and only ever posts) a single screen — appending left the old screen
|
||||||
|
// in slot 0 and the new one unreachable behind it, so setting a load screen appeared
|
||||||
|
// to do nothing. Kept as an array so multi-screen support can land without a
|
||||||
|
// migration.
|
||||||
.put(
|
.put(
|
||||||
'/rooms/:roomId{[0-9]+}/loadscreen',
|
'/rooms/:roomId{[0-9]+}/loadscreen',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Room settings'],
|
tags: ['Room settings'],
|
||||||
summary: 'Add a load screen to a room',
|
summary: 'Set a room’s load screen',
|
||||||
description: [
|
description: [
|
||||||
'APPENDS one `{ ImageName, Title, Subtitle }` to the room’s `LoadScreens` — the images',
|
'REPLACES the room’s `LoadScreens` with the single posted `{ ImageName, Title,',
|
||||||
'shown while the room loads. There is no remove or replace counterpart. Owner or',
|
'Subtitle }` — the image shown while the room loads. The field is an array (the',
|
||||||
'co-owner only (403 otherwise).',
|
'client’s parser expects one) but the client only supports a single screen, so this',
|
||||||
|
'never appends. Owner or co-owner only (403 otherwise).',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
parameters: [roomIdParam],
|
parameters: [roomIdParam],
|
||||||
requestBody: form(LoadScreenRequest, 'The load screen to append'),
|
requestBody: form(LoadScreenRequest, 'The load screen to set'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
|
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
@@ -1390,8 +1811,8 @@ const app = new Hono<App>()
|
|||||||
const title = typeof body.title === 'string' ? body.title : ''
|
const title = typeof body.title === 'string' ? body.title : ''
|
||||||
const subtitle = typeof body.subtitle === 'string' ? body.subtitle : ''
|
const subtitle = typeof body.subtitle === 'string' ? body.subtitle : ''
|
||||||
|
|
||||||
const existing = Array.isArray(room.LoadScreens) ? (room.LoadScreens as unknown[]) : []
|
// The posted screen becomes the whole list — the client shows one load screen.
|
||||||
const loadScreens = [...existing, { ImageName: imageName, Title: title, Subtitle: subtitle }]
|
const loadScreens = [{ ImageName: imageName, Title: title, Subtitle: subtitle }]
|
||||||
const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens })
|
const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens })
|
||||||
await pushRoomUpdate(c, accountId, updated)
|
await pushRoomUpdate(c, accountId, updated)
|
||||||
return roomEnvelope(c, updated)
|
return roomEnvelope(c, updated)
|
||||||
@@ -1665,6 +2086,10 @@ const app = new Hono<App>()
|
|||||||
Error: 'You must enter a name for your room!',
|
Error: 'You must enter a name for your room!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const badName = nameRejection(name, 'subroom name', MAX_ROOM_NAME_LENGTH)
|
||||||
|
if (badName !== null) {
|
||||||
|
return roomResult(c, { Success: false, ErrorId: 'Rooms.InvalidName', Error: badName })
|
||||||
|
}
|
||||||
const maxPlayers =
|
const maxPlayers =
|
||||||
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
|
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
|
||||||
|
|
||||||
@@ -1820,6 +2245,67 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Set a subroom's permission overrides — what each role may do in that subroom. The
|
||||||
|
// body is a JSON ARRAY of the entries to change, keyed by (Permission, Role): `Override`
|
||||||
|
// is the client's checkbox, so true stores the entry for that pair and false clears it
|
||||||
|
// back to the default. The stored table then overwrites the matching defaults in
|
||||||
|
// `GET /photon_access_token`. Auth-gated (401) and creator-only (403), like the other
|
||||||
|
// subroom mutations. Answers an EMPTY 200 — the client fires this and re-reads nothing,
|
||||||
|
// so there is no envelope to match.
|
||||||
|
.put(
|
||||||
|
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/permissions',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Subrooms'],
|
||||||
|
summary: 'Set a subroom’s permissions',
|
||||||
|
description: [
|
||||||
|
'Stores the permission entries a room’s creator changed for one subroom — who may',
|
||||||
|
'save inventions, invite players, use the delete-all button, and so on. The body is a',
|
||||||
|
'JSON ARRAY; each entry is addressed by its (`Permission`, `Role`) pair, so re-sending',
|
||||||
|
'a pair overwrites the stored entry rather than adding a second, and pairs that were',
|
||||||
|
'never sent are left alone.',
|
||||||
|
'',
|
||||||
|
'`Override` is the checkbox the client draws beside each permission, not data:',
|
||||||
|
'`true` stores `Value` for that pair, and `false` means “fall back to the default”, so',
|
||||||
|
'it DELETES any stored entry. Nothing is stored with `Override: false`, and reads',
|
||||||
|
'always serve `true`. `Value` is a string — usually `True`/`False`, but it is kept',
|
||||||
|
'verbatim, since not every permission’s UI is a True/False picker.',
|
||||||
|
'',
|
||||||
|
'What this feeds is `GET /photon_access_token`: a stored entry replaces the default',
|
||||||
|
'with the same (`Permission`, `Role`) in the table the client applies when it spawns,',
|
||||||
|
'and one naming a pair the defaults don’t carry (e.g. `CAN_INVITE`) is added to it.',
|
||||||
|
'The overrides apply to the subroom the caller is standing in, resolved from presence.',
|
||||||
|
'',
|
||||||
|
'Creator-only — co-owners may build in a room but not decide what a role may do.',
|
||||||
|
'The response body is EMPTY: the client doesn’t read one.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam, subRoomIdParam],
|
||||||
|
requestBody: jsonBody(SubRoomPermissionsRequest, 'The permission entries to set'),
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Stored (empty body)' },
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
403: FORBIDDEN_RESPONSE,
|
||||||
|
404: { description: 'No such room or subroom' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||||
|
|
||||||
|
// Scoped through the room so a subroom id from another room can't be written.
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
if (!room || !findSubRoom(room, subRoomId)) return c.notFound()
|
||||||
|
if (room.CreatorAccountId !== accountId) return c.body(null, 403)
|
||||||
|
|
||||||
|
const permissions = parseRoomPermissions(await c.req.json().catch(() => null))
|
||||||
|
await setSubRoomPermissions(c.env.DB, subRoomId, permissions)
|
||||||
|
return c.body(null, 200)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Clone a subroom into a new subroom of the same room (fresh SubRoomId, same
|
// Clone a subroom into a new subroom of the same room (fresh SubRoomId, same
|
||||||
// scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and
|
// scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and
|
||||||
// returns the updated ROOM in the `{ success, error, value }` envelope — NOT the new
|
// returns the updated ROOM in the `{ success, error, value }` envelope — NOT the new
|
||||||
@@ -1912,6 +2398,8 @@ const app = new Hono<App>()
|
|||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||||
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your subroom!')
|
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your subroom!')
|
||||||
|
const badName = nameRejection(name, 'subroom name', MAX_ROOM_NAME_LENGTH)
|
||||||
|
if (badName !== null) return roomEnvelope(c, null, badName)
|
||||||
|
|
||||||
const result = await createSubRoom(c.env.DB, roomId, accountId, name)
|
const result = await createSubRoom(c.env.DB, roomId, accountId, name)
|
||||||
if (!result) return roomEnvelope(c, null, 'This room does not exist!')
|
if (!result) return roomEnvelope(c, null, 'This room does not exist!')
|
||||||
@@ -2041,8 +2529,7 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Photon access token + room permissions the client needs to spawn into a
|
// Photon access token + room permissions the client needs to spawn into a room.
|
||||||
// room. The client calls it on the rooms host both bare and under `/roomserver`.
|
|
||||||
.get(
|
.get(
|
||||||
'/photon_access_token',
|
'/photon_access_token',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -2065,23 +2552,6 @@ const app = new Hono<App>()
|
|||||||
}),
|
}),
|
||||||
handlePhotonAccessToken
|
handlePhotonAccessToken
|
||||||
)
|
)
|
||||||
.get(
|
|
||||||
'/roomserver/photon_access_token',
|
|
||||||
describeRoute({
|
|
||||||
tags: ['Session'],
|
|
||||||
summary: 'Photon token + room permissions (legacy path)',
|
|
||||||
description: [
|
|
||||||
'Identical to `GET /photon_access_token` — the client calls it both bare and under the',
|
|
||||||
'`/roomserver` prefix, so both forms are registered.',
|
|
||||||
].join(' '),
|
|
||||||
security: AUTHED,
|
|
||||||
responses: {
|
|
||||||
200: json(PhotonAccessTokenDto, 'The permissions and (empty) token'),
|
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
handlePhotonAccessToken
|
|
||||||
)
|
|
||||||
|
|
||||||
// The generated spec. Documentation only — no request is validated against it (see
|
// The generated spec. Documentation only — no request is validated against it (see
|
||||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
|
import { NotificationType } from '../../../../notify/src/notification-types'
|
||||||
import importRooms from '../../../static/ImportRooms.json'
|
import importRooms from '../../../static/ImportRooms.json'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
@@ -31,10 +32,12 @@ function b64url(input: ArrayBuffer | string): string {
|
|||||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
}
|
}
|
||||||
async function bearer(sub: string): Promise<Record<string, string>> {
|
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
|
||||||
|
// off, the token carries none — what a plain player's looks like to the role gates.
|
||||||
|
async function bearer(sub: string, roles?: string[]): Promise<Record<string, string>> {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
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(
|
const key = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
@@ -58,6 +61,25 @@ beforeAll(async () => {
|
|||||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
|
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
|
||||||
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
||||||
|
|
||||||
|
// Relationship table (owned by the api worker) — `visitedby/:playerId` reads it to
|
||||||
|
// check the caller is a friend of the player whose history they're asking for.
|
||||||
|
await env.DB.prepare(
|
||||||
|
`CREATE TABLE IF NOT EXISTS relationship (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
requester_id INTEGER NOT NULL,
|
||||||
|
target_id INTEGER NOT NULL,
|
||||||
|
relationship_type INTEGER NOT NULL DEFAULT 0
|
||||||
|
)`
|
||||||
|
).run()
|
||||||
|
const insertRel = env.DB.prepare(
|
||||||
|
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
|
||||||
|
)
|
||||||
|
await env.DB.batch([
|
||||||
|
insertRel.bind(791, 790, 3), // friends — the caller (791) is the requester
|
||||||
|
insertRel.bind(790, 792, 3), // friends — the caller (792) is the target
|
||||||
|
insertRel.bind(793, 790, 1), // request out, not accepted — 793 is NOT a friend
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('rooms endpoints', () => {
|
describe('rooms endpoints', () => {
|
||||||
@@ -260,6 +282,62 @@ describe('rooms endpoints', () => {
|
|||||||
expect(other).toEqual([])
|
expect(other).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('GET /rooms/visitedby/:playerId serves a friend’s visited rooms and 403s everyone else', async () => {
|
||||||
|
// Give 790 a visit history (cheering/favoriting stamps a last-visit).
|
||||||
|
const subject = await bearer('790')
|
||||||
|
await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/cheer`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: subject,
|
||||||
|
})
|
||||||
|
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: subject,
|
||||||
|
})
|
||||||
|
|
||||||
|
// A mutual friend reads it — a bare array, regardless of which side of the
|
||||||
|
// relationship row the caller sits on.
|
||||||
|
for (const friend of ['791', '792']) {
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, {
|
||||||
|
headers: await bearer(friend),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as Array<{ RoomId: number }>
|
||||||
|
expect(body.map((r) => r.RoomId).sort((a, b) => a - b)).toEqual([2, 12])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paginated via skip/take.
|
||||||
|
const page = (await (
|
||||||
|
await SELF.fetch(`${ORIGIN}/rooms/visitedby/790?skip=0&take=1`, {
|
||||||
|
headers: await bearer('791'),
|
||||||
|
})
|
||||||
|
).json()) as unknown[]
|
||||||
|
expect(page.length).toBe(1)
|
||||||
|
|
||||||
|
// Your own history is readable by id, not just via `me`.
|
||||||
|
const own = (await (
|
||||||
|
await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, { headers: subject })
|
||||||
|
).json()) as unknown[]
|
||||||
|
expect(own.length).toBe(2)
|
||||||
|
|
||||||
|
// A pending request is not a friendship, and a stranger is not either → 403.
|
||||||
|
for (const outsider of ['793', '794']) {
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, {
|
||||||
|
headers: await bearer(outsider),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No token at all → 401, never a fallback account.
|
||||||
|
const anon = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`)
|
||||||
|
expect(anon.status).toBe(401)
|
||||||
|
|
||||||
|
// `visitedby/me` still routes to the literal handler, not the id pattern.
|
||||||
|
const me = (await (
|
||||||
|
await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers: subject })
|
||||||
|
).json()) as unknown[]
|
||||||
|
expect(me.length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('GET /rooms/hot returns a paginated { Results, TotalResults } of public rooms', async () => {
|
it('GET /rooms/hot returns a paginated { Results, TotalResults } of public rooms', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/rooms/hot?skip=0&take=100`)
|
const res = await SELF.fetch(`${ORIGIN}/rooms/hot?skip=0&take=100`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -293,6 +371,49 @@ describe('rooms endpoints', () => {
|
|||||||
expect(body.TotalResults).toBeGreaterThan(body.Results.length)
|
expect(body.TotalResults).toBeGreaterThan(body.Results.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('GET /rooms/hot ranks rooms by the live presence in their instances', async () => {
|
||||||
|
const feed = async (): Promise<number[]> =>
|
||||||
|
(
|
||||||
|
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?skip=0&take=100`)).json()) as {
|
||||||
|
Results: Array<{ RoomId: number }>
|
||||||
|
}
|
||||||
|
).Results.map((r) => r.RoomId)
|
||||||
|
|
||||||
|
// Two rooms from the tail of the engagement-ordered feed, so any move to the
|
||||||
|
// front can only come from presence.
|
||||||
|
const before = await feed()
|
||||||
|
const busiest = before[before.length - 1]
|
||||||
|
const quieter = before[before.length - 2]
|
||||||
|
|
||||||
|
// Two players in two different instances of `busiest`, one in `quieter`, plus a
|
||||||
|
// lobby presence (no instance) that must not count for anyone.
|
||||||
|
const expiresAt = Math.floor(Date.now() / 1000) + 900
|
||||||
|
const seed = env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||||
|
await env.DB.batch(
|
||||||
|
[
|
||||||
|
{ accountId: 90001, roomInstance: { roomInstanceId: 1000901, roomId: busiest } },
|
||||||
|
{ accountId: 90002, roomInstance: { roomInstanceId: 1000902, roomId: busiest } },
|
||||||
|
{ accountId: 90003, roomInstance: { roomInstanceId: 1000903, roomId: quieter } },
|
||||||
|
{ accountId: 90004, roomInstance: null },
|
||||||
|
].map((p) => seed.bind(JSON.stringify({ ...p, expiresAt })))
|
||||||
|
)
|
||||||
|
|
||||||
|
expect((await feed()).slice(0, 2)).toEqual([busiest, quieter])
|
||||||
|
|
||||||
|
// Expired presence doesn't count — the feed falls back to engagement order.
|
||||||
|
await env.DB.prepare(
|
||||||
|
`UPDATE presence SET data = json_set(data, '$.expiresAt', ?1)
|
||||||
|
WHERE account_id IN (90001, 90002, 90003, 90004)`
|
||||||
|
)
|
||||||
|
.bind(Math.floor(Date.now() / 1000) - 1)
|
||||||
|
.run()
|
||||||
|
expect(await feed()).toEqual(before)
|
||||||
|
|
||||||
|
await env.DB.prepare(
|
||||||
|
'DELETE FROM presence WHERE account_id IN (90001, 90002, 90003, 90004)'
|
||||||
|
).run()
|
||||||
|
})
|
||||||
|
|
||||||
it('GET /rooms/hot aliases #recroomoriginal to the rro tag', async () => {
|
it('GET /rooms/hot aliases #recroomoriginal to the rro tag', async () => {
|
||||||
const aliased = (await (
|
const aliased = (await (
|
||||||
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=recroomoriginal`)
|
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=recroomoriginal`)
|
||||||
@@ -304,6 +425,66 @@ describe('rooms endpoints', () => {
|
|||||||
expect(aliased.TotalResults).toBeGreaterThan(0)
|
expect(aliased.TotalResults).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('GET /rooms/hot?tag=new serves player-made rooms newest-first (pseudo-tag)', async () => {
|
||||||
|
type Feed = { Results: Array<{ Name: string }>; TotalResults: number }
|
||||||
|
const feed = async (): Promise<Feed> =>
|
||||||
|
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?tag=new&skip=0&take=100`)).json()) as Feed
|
||||||
|
const names = async (): Promise<string[]> => (await feed()).Results.map((r) => r.Name)
|
||||||
|
|
||||||
|
// No room carries a `new` tag, and every seeded room is a Rec Room Original — so
|
||||||
|
// the feed is empty until a player makes something.
|
||||||
|
expect(await feed()).toEqual({ Results: [], TotalResults: 0 })
|
||||||
|
|
||||||
|
const seeded: number[] = []
|
||||||
|
const seed = async (room: Record<string, unknown>) => {
|
||||||
|
seeded.push(Number(room.RoomId))
|
||||||
|
await seedRoomWithSubRooms(env.DB, { Accessibility: 1, IsDorm: false, IsRRO: false, ...room })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two player-made public rooms and one that isn't public.
|
||||||
|
await seed({ RoomId: 9001, Name: 'OlderPlayerRoom', CreatedAt: '2026-07-01T00:00:00Z' })
|
||||||
|
await seed({ RoomId: 9002, Name: 'NewerPlayerRoom', CreatedAt: '2026-07-02T00:00:00Z' })
|
||||||
|
await seed({
|
||||||
|
RoomId: 9003,
|
||||||
|
Name: 'UnlistedPlayerRoom',
|
||||||
|
CreatedAt: '2026-07-03T00:00:00Z',
|
||||||
|
Accessibility: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Newest first, and the non-public room is excluded as it is everywhere else.
|
||||||
|
expect(await feed()).toMatchObject({
|
||||||
|
Results: [{ Name: 'NewerPlayerRoom' }, { Name: 'OlderPlayerRoom' }],
|
||||||
|
TotalResults: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
// An RRO stays out even when it's the newest room in the database — by the flag,
|
||||||
|
// or by the auto-derived `rro` tag alone.
|
||||||
|
await seed({
|
||||||
|
RoomId: 9004,
|
||||||
|
Name: 'BrandNewRRO',
|
||||||
|
CreatedAt: '2026-07-04T00:00:00Z',
|
||||||
|
IsRRO: true,
|
||||||
|
})
|
||||||
|
await seed({
|
||||||
|
RoomId: 9005,
|
||||||
|
Name: 'TaggedRRO',
|
||||||
|
CreatedAt: '2026-07-05T00:00:00Z',
|
||||||
|
Tags: [{ Tag: 'rro', Type: 2 }],
|
||||||
|
})
|
||||||
|
expect(await names()).toEqual(['NewerPlayerRoom', 'OlderPlayerRoom'])
|
||||||
|
|
||||||
|
// Paging comes off the same order.
|
||||||
|
const page = (await (
|
||||||
|
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=new&skip=1&take=1`)
|
||||||
|
).json()) as Feed
|
||||||
|
expect(page).toMatchObject({ Results: [{ Name: 'OlderPlayerRoom' }], TotalResults: 2 })
|
||||||
|
|
||||||
|
// Leave the shared feeds as they were for the tests that follow.
|
||||||
|
const ids = seeded.join(',')
|
||||||
|
await env.DB.prepare(`DELETE FROM room WHERE room_id IN (${ids})`).run()
|
||||||
|
await env.DB.prepare(`DELETE FROM subroom WHERE room_id IN (${ids})`).run()
|
||||||
|
})
|
||||||
|
|
||||||
it('GET /rooms/base returns a bare array of base/template rooms (incl. non-public)', async () => {
|
it('GET /rooms/base returns a bare array of base/template rooms (incl. non-public)', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/rooms/base`)
|
const res = await SELF.fetch(`${ORIGIN}/rooms/base`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -558,6 +739,21 @@ describe('rooms endpoints', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const postForm = async (
|
||||||
|
path: string,
|
||||||
|
fields: Record<string, string>,
|
||||||
|
sub?: string,
|
||||||
|
roles?: string[]
|
||||||
|
) =>
|
||||||
|
SELF.fetch(`${ORIGIN}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(sub ? await bearer(sub, roles) : {}),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(fields).toString(),
|
||||||
|
})
|
||||||
|
|
||||||
const putForm = async (path: string, fields: Record<string, string>, sub?: string) =>
|
const putForm = async (path: string, fields: Record<string, string>, sub?: string) =>
|
||||||
SELF.fetch(`${ORIGIN}${path}`, {
|
SELF.fetch(`${ORIGIN}${path}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -745,6 +941,200 @@ describe('rooms endpoints', () => {
|
|||||||
expect(roles).toContainEqual(expect.objectContaining({ AccountId: 2, Role: 30 }))
|
expect(roles).toContainEqual(expect.objectContaining({ AccountId: 2, Role: 30 }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('POST /rooms/:id/bans is gated to the room’s owners or staff, and persists', async () => {
|
||||||
|
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
|
||||||
|
const bansOf = async (roomId: number) =>
|
||||||
|
(
|
||||||
|
await env.DB.prepare(
|
||||||
|
'SELECT banned_player_id, ban_mask, banned_by_account_id FROM room_ban WHERE room_id = ?1'
|
||||||
|
)
|
||||||
|
.bind(roomId)
|
||||||
|
.all<{ banned_player_id: number; ban_mask: number; banned_by_account_id: number }>()
|
||||||
|
).results
|
||||||
|
|
||||||
|
// No token → 401 (auth gate).
|
||||||
|
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '205' })).status).toBe(401)
|
||||||
|
// A valid token, no role on the room and no staff role → 403.
|
||||||
|
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '205' }, '999')).status).toBe(403)
|
||||||
|
// Unknown room → failure envelope.
|
||||||
|
expect(
|
||||||
|
await envOf(await postForm('/rooms/99999/bans', { banMask: '0', id: '205' }, '1'))
|
||||||
|
).toMatchObject({ success: false, error: 'This room does not exist!' })
|
||||||
|
|
||||||
|
// The owner bans player 205 — the real client body.
|
||||||
|
const ok = await postForm('/rooms/2/bans', { banMask: '0', id: '205' }, '1')
|
||||||
|
expect(ok.status).toBe(200)
|
||||||
|
expect(await envOf(ok)).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
error: '',
|
||||||
|
value: { RoomId: 2, BannedPlayerId: 205, BanMask: 0, BannedByAccountId: 1 },
|
||||||
|
})
|
||||||
|
expect(await bansOf(2)).toEqual([
|
||||||
|
{ banned_player_id: 205, ban_mask: 0, banned_by_account_id: 1 },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Re-banning rewrites the one row rather than appending a second.
|
||||||
|
expect((await postForm('/rooms/2/bans', { banMask: '7', id: '205' }, '2')).status).toBe(200)
|
||||||
|
expect(await bansOf(2)).toEqual([
|
||||||
|
{ banned_player_id: 205, ban_mask: 7, banned_by_account_id: 2 },
|
||||||
|
])
|
||||||
|
|
||||||
|
// A staff token bans in a room they have no role on.
|
||||||
|
const byStaff = await postForm('/rooms/2/bans', { id: '206' }, '999', [
|
||||||
|
'gameClient',
|
||||||
|
'moderator',
|
||||||
|
])
|
||||||
|
expect(byStaff.status).toBe(200)
|
||||||
|
// banMask defaults to 0 when the field is absent.
|
||||||
|
expect(await envOf(byStaff)).toMatchObject({ value: { BannedPlayerId: 206, BanMask: 0 } })
|
||||||
|
|
||||||
|
// Refusals: no id, yourself, and an owner of the room (a co-owner must not be
|
||||||
|
// able to ban the creator out of their own room).
|
||||||
|
expect(await envOf(await postForm('/rooms/2/bans', { id: 'nope' }, '1'))).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
value: null,
|
||||||
|
})
|
||||||
|
expect(await envOf(await postForm('/rooms/2/bans', { id: '1' }, '1'))).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: 'You cannot ban yourself!',
|
||||||
|
})
|
||||||
|
expect(await envOf(await postForm('/rooms/2/bans', { id: '1' }, '2'))).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: 'You cannot ban an owner of this room!',
|
||||||
|
})
|
||||||
|
// Nothing was written by any of the refusals.
|
||||||
|
expect(await bansOf(2)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('POST /rooms/:id/bans kicks the banned player', async () => {
|
||||||
|
type Sent = { playerId: number; notificationType: string | number; data: unknown }
|
||||||
|
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||||
|
const sentSince = async (): Promise<Sent[]> =>
|
||||||
|
(await (await hub().fetch('http://do/all')).json()) as Sent[]
|
||||||
|
|
||||||
|
// The room's current name, read rather than hardcoded — earlier tests rename it.
|
||||||
|
const { Name } = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Name: string }
|
||||||
|
|
||||||
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
|
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '207' }, '1')).status).toBe(200)
|
||||||
|
|
||||||
|
// A ModerationKick (id 22) to the BANNED player, not the caller — it ejects them
|
||||||
|
// from the instance they're in now; the row keeps them out of future matchmakes.
|
||||||
|
// Asserted against the enum rather than a literal: the ids are notify's to change.
|
||||||
|
expect(await sentSince()).toEqual([
|
||||||
|
{
|
||||||
|
playerId: 207,
|
||||||
|
notificationType: NotificationType.ModerationKick,
|
||||||
|
// The client's moderation payload, camelCase, in wire order.
|
||||||
|
data: {
|
||||||
|
reportCategory: -1, // Moderator — a person acted, not the system
|
||||||
|
duration: 0, // a room ban has no expiry
|
||||||
|
gameSessionId: 0,
|
||||||
|
// The host ejected them (as opposed to a room vote-kick, which doesn't
|
||||||
|
// exist yet). Account 1 owns RecCenter, so it hosts it.
|
||||||
|
isHostKick: true,
|
||||||
|
message: `You have been banned from ${Name}.`,
|
||||||
|
playerIdReporter: 1,
|
||||||
|
isBan: true,
|
||||||
|
isVoiceModAutoban: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// A staff moderator doesn't host the room, so it isn't a host kick — and
|
||||||
|
// `playerIdReporter` is still whoever caused it.
|
||||||
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
|
expect(
|
||||||
|
(await postForm('/rooms/2/bans', { id: '208' }, '999', ['gameClient', 'moderator'])).status
|
||||||
|
).toBe(200)
|
||||||
|
expect((await sentSince())[0]).toMatchObject({
|
||||||
|
playerId: 208,
|
||||||
|
data: { isHostKick: false, playerIdReporter: 999 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET /rooms/:id/bans lists the room’s bans, under the same gate', async () => {
|
||||||
|
type Entry = { accountId: number; bannedByAccountId: number; banStartTime: string }
|
||||||
|
const list = async (path: string, sub?: string, roles?: string[]) =>
|
||||||
|
SELF.fetch(`${ORIGIN}${path}`, { headers: sub ? await bearer(sub, roles) : {} })
|
||||||
|
|
||||||
|
// Room 3 is owned by account 1 (it has no bans yet) — ban two players into it.
|
||||||
|
expect((await postForm('/rooms/3/bans', { id: '401' }, '1')).status).toBe(200)
|
||||||
|
expect((await postForm('/rooms/3/bans', { id: '402' }, '1')).status).toBe(200)
|
||||||
|
|
||||||
|
// No token → 401; a valid token with no room role and no staff role → 403.
|
||||||
|
expect((await list('/rooms/3/bans')).status).toBe(401)
|
||||||
|
expect((await list('/rooms/3/bans', '999')).status).toBe(403)
|
||||||
|
|
||||||
|
const res = await list('/rooms/3/bans', '1')
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
// A bare array in the client's camelCase shape — no room id, no ban mask.
|
||||||
|
const bans = (await res.json()) as Entry[]
|
||||||
|
expect(bans.map((b) => b.accountId).sort((a, b) => a - b)).toEqual([401, 402])
|
||||||
|
expect(bans[0]).toEqual({
|
||||||
|
accountId: expect.any(Number),
|
||||||
|
bannedByAccountId: 1,
|
||||||
|
banStartTime: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
|
||||||
|
})
|
||||||
|
|
||||||
|
// A staffer can read a list for a room they have no role on.
|
||||||
|
expect((await list('/rooms/3/bans', '999', ['gameClient', 'moderator'])).status).toBe(200)
|
||||||
|
|
||||||
|
// An unknown room reads the same as a room with nobody banned — no probing which
|
||||||
|
// room ids exist.
|
||||||
|
expect(await (await list('/rooms/99999/bans', '1')).json()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DELETE /rooms/:id/bans/:playerId lifts a ban, under the same gate', async () => {
|
||||||
|
const del = async (path: string, sub?: string, roles?: string[]) =>
|
||||||
|
SELF.fetch(`${ORIGIN}${path}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: sub ? await bearer(sub, roles) : {},
|
||||||
|
})
|
||||||
|
const isBanned = async (roomId: number, playerId: number) =>
|
||||||
|
(await env.DB.prepare(
|
||||||
|
'SELECT 1 AS hit FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2'
|
||||||
|
)
|
||||||
|
.bind(roomId, playerId)
|
||||||
|
.first()) !== null
|
||||||
|
|
||||||
|
// Two bans to lift: one removed by the owner, one by a staffer.
|
||||||
|
expect((await postForm('/rooms/2/bans', { id: '305' }, '1')).status).toBe(200)
|
||||||
|
expect((await postForm('/rooms/2/bans', { id: '306' }, '1')).status).toBe(200)
|
||||||
|
|
||||||
|
// No token → 401; a valid token with no room role and no staff role → 403.
|
||||||
|
expect((await del('/rooms/2/bans/305')).status).toBe(401)
|
||||||
|
expect((await del('/rooms/2/bans/305', '999')).status).toBe(403)
|
||||||
|
expect(await isBanned(2, 305)).toBe(true)
|
||||||
|
|
||||||
|
// Unknown room → failure envelope.
|
||||||
|
expect(await envOf(await del('/rooms/99999/bans/305', '1'))).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: 'This room does not exist!',
|
||||||
|
})
|
||||||
|
|
||||||
|
// The owner lifts it; the removed ban comes back as `value`.
|
||||||
|
const ok = await del('/rooms/2/bans/305', '1')
|
||||||
|
expect(ok.status).toBe(200)
|
||||||
|
expect(await envOf(ok)).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
error: '',
|
||||||
|
value: { RoomId: 2, BannedPlayerId: 305 },
|
||||||
|
})
|
||||||
|
expect(await isBanned(2, 305)).toBe(false)
|
||||||
|
|
||||||
|
// Unbanning someone who isn't banned is a rejection, not a silent success.
|
||||||
|
expect(await envOf(await del('/rooms/2/bans/305', '1'))).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: 'This player is not banned from this room!',
|
||||||
|
value: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// A staff token may lift a ban in a room they have no role on.
|
||||||
|
expect((await del('/rooms/2/bans/306', '999', ['gameClient', 'developer'])).status).toBe(200)
|
||||||
|
expect(await isBanned(2, 306)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('PUT /rooms/:id/warning is auth-gated, owner/co-owner-only, and persists', async () => {
|
it('PUT /rooms/:id/warning is auth-gated, owner/co-owner-only, and persists', async () => {
|
||||||
// No token → 401 (auth gate).
|
// No token → 401 (auth gate).
|
||||||
expect((await putForm('/rooms/2/warning', { warningMask: '2' })).status).toBe(401)
|
expect((await putForm('/rooms/2/warning', { warningMask: '2' })).status).toBe(401)
|
||||||
@@ -868,7 +1258,7 @@ describe('rooms endpoints', () => {
|
|||||||
expect(typeof room.SupportsMobile).toBe('boolean')
|
expect(typeof room.SupportsMobile).toBe('boolean')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('PUT /rooms/:id/loadscreen appends a load screen (auth-gated, owner/co-owner-only)', async () => {
|
it('PUT /rooms/:id/loadscreen replaces the load screen (auth-gated, owner/co-owner-only)', async () => {
|
||||||
const screensOf = async (): Promise<Array<Record<string, unknown>>> => {
|
const screensOf = async (): Promise<Array<Record<string, unknown>>> => {
|
||||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
|
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
|
||||||
LoadScreens?: Array<Record<string, unknown>>
|
LoadScreens?: Array<Record<string, unknown>>
|
||||||
@@ -888,10 +1278,8 @@ describe('rooms endpoints', () => {
|
|||||||
success: false,
|
success: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const before = (await screensOf()).length
|
// Owner sets one (imageName + title + subtitle) — the success envelope carries the
|
||||||
|
// updated room, and the posted screen is the ONLY entry.
|
||||||
// Owner adds one (imageName + title + subtitle) — appended, and the success
|
|
||||||
// envelope carries the updated room.
|
|
||||||
const added = await envOf(
|
const added = await envOf(
|
||||||
await putForm(
|
await putForm(
|
||||||
'/rooms/2/loadscreen',
|
'/rooms/2/loadscreen',
|
||||||
@@ -900,18 +1288,17 @@ describe('rooms endpoints', () => {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
expect(added).toMatchObject({ success: true })
|
expect(added).toMatchObject({ success: true })
|
||||||
expect(added.value?.LoadScreens as unknown[]).toContainEqual({
|
expect(added.value?.LoadScreens).toEqual([
|
||||||
ImageName: 'sharecamera/2026-07-15/abc.jpg',
|
{ ImageName: 'sharecamera/2026-07-15/abc.jpg', Title: 'asdf', Subtitle: 'sdf' },
|
||||||
Title: 'asdf',
|
])
|
||||||
Subtitle: 'sdf',
|
expect(await screensOf()).toHaveLength(1)
|
||||||
})
|
|
||||||
expect(await screensOf()).toHaveLength(before + 1)
|
|
||||||
|
|
||||||
// A second call appends rather than replacing; title/subtitle default to empty.
|
// A second call REPLACES rather than appending (the client renders one screen, so
|
||||||
|
// an appended one would sit unreachable behind the old); title/subtitle default to
|
||||||
|
// empty when omitted.
|
||||||
const co = await envOf(await putForm('/rooms/2/loadscreen', { imageName: 'second.jpg' }, '2'))
|
const co = await envOf(await putForm('/rooms/2/loadscreen', { imageName: 'second.jpg' }, '2'))
|
||||||
expect(co).toMatchObject({ success: true })
|
expect(co).toMatchObject({ success: true })
|
||||||
expect(await screensOf()).toHaveLength(before + 2)
|
expect(await screensOf()).toEqual([{ ImageName: 'second.jpg', Title: '', Subtitle: '' }])
|
||||||
expect(await screensOf()).toContainEqual({ ImageName: 'second.jpg', Title: '', Subtitle: '' })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('PUT /rooms/:id/accessibility sets the room-level Accessibility (auth-gated, owner/co-owner-only)', async () => {
|
it('PUT /rooms/:id/accessibility sets the room-level Accessibility (auth-gated, owner/co-owner-only)', async () => {
|
||||||
@@ -1433,13 +1820,10 @@ describe('rooms endpoints', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('GET /photon_access_token 401s without a token', async () => {
|
it('GET /photon_access_token 401s without a token', async () => {
|
||||||
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
|
expect((await SELF.fetch(`${ORIGIN}/photon_access_token`)).status).toBe(401)
|
||||||
const res = await SELF.fetch(`${ORIGIN}${path}`)
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('GET /photon_access_token (bare + /roomserver) returns permissions + presence instance', async () => {
|
it('GET /photon_access_token returns permissions + presence instance', async () => {
|
||||||
// Seed the caller's presence so RoomInstanceId reflects their current instance.
|
// Seed the caller's presence so RoomInstanceId reflects their current instance.
|
||||||
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||||
.bind(
|
.bind(
|
||||||
@@ -1450,22 +1834,19 @@ describe('rooms endpoints', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
const headers = await bearer('777')
|
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer('777') })
|
||||||
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
|
expect(res.status).toBe(200)
|
||||||
const res = await SELF.fetch(`${ORIGIN}${path}`, { headers })
|
const body = (await res.json()) as {
|
||||||
expect(res.status).toBe(200)
|
Permissions: Array<{ Permission: string; Role: number }>
|
||||||
const body = (await res.json()) as {
|
PhotonAccessToken: string
|
||||||
Permissions: Array<{ Permission: string; Role: number }>
|
RoomInstanceId: number | null
|
||||||
PhotonAccessToken: string
|
|
||||||
RoomInstanceId: number | null
|
|
||||||
}
|
|
||||||
expect(body.Permissions.length).toBe(11)
|
|
||||||
expect(body.RoomInstanceId).toBe(1000042)
|
|
||||||
// A non-dev account does NOT get the global (Role 0) maker pen.
|
|
||||||
expect(
|
|
||||||
body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)
|
|
||||||
).toBe(false)
|
|
||||||
}
|
}
|
||||||
|
expect(body.Permissions.length).toBe(11)
|
||||||
|
expect(body.RoomInstanceId).toBe(1000042)
|
||||||
|
// A non-dev account does NOT get the global (Role 0) maker pen.
|
||||||
|
expect(body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('GET /photon_access_token returns null RoomInstanceId when the caller has no presence', async () => {
|
it('GET /photon_access_token returns null RoomInstanceId when the caller has no presence', async () => {
|
||||||
@@ -1526,6 +1907,55 @@ describe('rooms endpoints', () => {
|
|||||||
expect(otherGet).toMatchObject({ Cheered: false, Favorited: false })
|
expect(otherGet).toMatchObject({ Cheered: false, Favorited: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('room Stats aggregate cheers/favorites from the interaction table', async () => {
|
||||||
|
type Stats = {
|
||||||
|
CheerCount: number
|
||||||
|
FavoriteCount: number
|
||||||
|
VisitorCount: number
|
||||||
|
VisitCount: number
|
||||||
|
}
|
||||||
|
// Room 15 (CrimsonCauldron) is untouched by the other interaction tests.
|
||||||
|
const searched = async (): Promise<Stats> => {
|
||||||
|
const body = (await (
|
||||||
|
await SELF.fetch(`${ORIGIN}/rooms/search?query=crimsoncauldron`)
|
||||||
|
).json()) as { Results: Array<{ Stats: Stats }> }
|
||||||
|
return body.Results[0]!.Stats
|
||||||
|
}
|
||||||
|
const direct = async (): Promise<Stats> =>
|
||||||
|
((await (await SELF.fetch(`${ORIGIN}/rooms/15`)).json()) as { Stats: Stats }).Stats
|
||||||
|
const interact = async (player: string, action: string, method: string) =>
|
||||||
|
SELF.fetch(`${ORIGIN}/rooms/15/interactionby/me/${action}`, {
|
||||||
|
method,
|
||||||
|
headers: await bearer(player),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Nobody has interacted with it yet.
|
||||||
|
expect(await searched()).toEqual({
|
||||||
|
CheerCount: 0,
|
||||||
|
FavoriteCount: 0,
|
||||||
|
VisitorCount: 0,
|
||||||
|
VisitCount: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Two players cheer it; one of them also favorites it.
|
||||||
|
await interact('561', 'cheer', 'PUT')
|
||||||
|
await interact('562', 'cheer', 'PUT')
|
||||||
|
await interact('561', 'favorite', 'PUT')
|
||||||
|
|
||||||
|
// Both the search results and the room itself report the aggregate.
|
||||||
|
expect(await searched()).toMatchObject({ CheerCount: 2, FavoriteCount: 1 })
|
||||||
|
expect(await direct()).toMatchObject({ CheerCount: 2, FavoriteCount: 1 })
|
||||||
|
|
||||||
|
// Clearing a cheer decrements it. Nothing records visits, so those stay 0.
|
||||||
|
await interact('562', 'cheer', 'DELETE')
|
||||||
|
expect(await direct()).toEqual({
|
||||||
|
CheerCount: 1,
|
||||||
|
FavoriteCount: 1,
|
||||||
|
VisitorCount: 0,
|
||||||
|
VisitCount: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('DELETE /rooms/:id/interactionby/me/cheer clears the cheer (auth-gated, idempotent)', async () => {
|
it('DELETE /rooms/:id/interactionby/me/cheer clears the cheer (auth-gated, idempotent)', async () => {
|
||||||
type Interaction = { Cheered: boolean; Favorited: boolean }
|
type Interaction = { Cheered: boolean; Favorited: boolean }
|
||||||
const headers = await bearer('557')
|
const headers = await bearer('557')
|
||||||
@@ -1596,7 +2026,7 @@ describe('rooms endpoints', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('PUT /rooms/:id/subrooms/:sid/modify is auth-gated, owner-only, and persists subroom settings', async () => {
|
it('PUT /rooms/:id/subrooms/:sid/modify is auth-gated, owner-only, and persists subroom settings', async () => {
|
||||||
const fields = { name: 'My Cool Subroom', accessibility: '1', maxPlayers: '20' }
|
const fields = { name: 'MyCoolSubroom', accessibility: '1', maxPlayers: '20' }
|
||||||
// No token → 401 (auth gate).
|
// No token → 401 (auth gate).
|
||||||
expect((await putForm('/rooms/2/subrooms/2/modify', fields)).status).toBe(401)
|
expect((await putForm('/rooms/2/subrooms/2/modify', fields)).status).toBe(401)
|
||||||
// Not the owner (room 2 is owned by account 1) → NotOwner.
|
// Not the owner (room 2 is owned by account 1) → NotOwner.
|
||||||
@@ -1626,7 +2056,7 @@ describe('rooms endpoints', () => {
|
|||||||
Accessibility: number
|
Accessibility: number
|
||||||
MaxPlayers: number
|
MaxPlayers: number
|
||||||
}
|
}
|
||||||
expect(sub).toMatchObject({ Name: 'My Cool Subroom', Accessibility: 1, MaxPlayers: 20 })
|
expect(sub).toMatchObject({ Name: 'MyCoolSubroom', Accessibility: 1, MaxPlayers: 20 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('PUT /rooms/:id/subrooms/:sid/accessibility takes the enum name the client sends', async () => {
|
it('PUT /rooms/:id/subrooms/:sid/accessibility takes the enum name the client sends', async () => {
|
||||||
@@ -1676,6 +2106,254 @@ describe('rooms endpoints', () => {
|
|||||||
expect(await accessibilityOf()).toBe(1)
|
expect(await accessibilityOf()).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The permission table a room's creator saves on a subroom, and how it reaches the
|
||||||
|
// client: `PUT …/permissions` stores entries keyed by (Permission, Role), and
|
||||||
|
// `GET /photon_access_token` merges them over its defaults for whoever is standing in
|
||||||
|
// that subroom. Room 2 / subroom 2 is owned by account 1; account 743 is the visitor
|
||||||
|
// whose presence points at it.
|
||||||
|
describe('subroom permissions', () => {
|
||||||
|
type Permission = { Permission: string; Role: number; Override: boolean; Value: string }
|
||||||
|
|
||||||
|
const putPermissions = async (path: string, body: unknown, sub?: string) =>
|
||||||
|
SELF.fetch(`${ORIGIN}${path}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...(sub ? await bearer(sub) : {}), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Put a player in an instance of the given subroom, then read the permission table
|
||||||
|
// the client would apply when it spawns there.
|
||||||
|
const permissionsIn = async (accountId: number, subRoomId: number): Promise<Permission[]> => {
|
||||||
|
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({
|
||||||
|
accountId,
|
||||||
|
roomInstance: { roomInstanceId: 1000900 + subRoomId, roomId: 2, subRoomId },
|
||||||
|
expiresAt: Math.floor(Date.now() / 1000) + 900,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, {
|
||||||
|
headers: await bearer(String(accountId)),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
return ((await res.json()) as { Permissions: Permission[] }).Permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = (list: Permission[], permission: string, role: number) =>
|
||||||
|
list.find((p) => p.Permission === permission && p.Role === role)
|
||||||
|
|
||||||
|
it('is auth-gated and creator-only', async () => {
|
||||||
|
const body = [
|
||||||
|
{ Permission: 'CAN_SAVE_INVENTIONS', Role: 30, Override: false, Type: 0, Value: 'True' },
|
||||||
|
]
|
||||||
|
// No token → 401.
|
||||||
|
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body)).status).toBe(401)
|
||||||
|
// A valid token that isn't the room's creator → 403.
|
||||||
|
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '999')).status).toBe(
|
||||||
|
403
|
||||||
|
)
|
||||||
|
// Not even a co-owner: account 2 holds Role 30 on the seeded rooms. Co-owners may
|
||||||
|
// build in a room but don't decide what a role may do.
|
||||||
|
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '2')).status).toBe(403)
|
||||||
|
// Unknown room / unknown subroom → 404.
|
||||||
|
expect((await putPermissions('/rooms/99999/subrooms/2/permissions', body, '1')).status).toBe(
|
||||||
|
404
|
||||||
|
)
|
||||||
|
// A subroom id belonging to another room doesn't resolve either.
|
||||||
|
expect((await putPermissions('/rooms/2/subrooms/9999/permissions', body, '1')).status).toBe(
|
||||||
|
404
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers an empty 200 — the client reads no body', async () => {
|
||||||
|
const res = await putPermissions(
|
||||||
|
'/rooms/2/subrooms/2/permissions',
|
||||||
|
[{ Permission: 'CAN_SPAWN_INVENTIONS', Role: 30, Override: true, Type: 0, Value: 'True' }],
|
||||||
|
'1'
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.text()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a checked Override replaces the matching default in place', async () => {
|
||||||
|
const before = await permissionsIn(743, 2)
|
||||||
|
expect(before.length).toBe(11)
|
||||||
|
const at = before.findIndex((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 30)
|
||||||
|
// The default for this pair is an un-overridden grant.
|
||||||
|
expect(before[at]).toMatchObject({ Override: false, Value: 'True' })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await putPermissions(
|
||||||
|
'/rooms/2/subrooms/2/permissions',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
Permission: 'CAN_USE_MAKER_PEN',
|
||||||
|
Role: 30,
|
||||||
|
Override: true,
|
||||||
|
Type: 0,
|
||||||
|
Value: 'False',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'1'
|
||||||
|
)
|
||||||
|
).status
|
||||||
|
).toBe(200)
|
||||||
|
|
||||||
|
const after = await permissionsIn(743, 2)
|
||||||
|
// Replaced, not appended — and at the same index, so the table doesn't reshuffle.
|
||||||
|
expect(after.length).toBe(11)
|
||||||
|
expect(after[at]).toMatchObject({
|
||||||
|
Permission: 'CAN_USE_MAKER_PEN',
|
||||||
|
Role: 30,
|
||||||
|
Override: true,
|
||||||
|
Value: 'False',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-sending the same (Permission, Role) updates that entry rather than adding one.
|
||||||
|
await putPermissions(
|
||||||
|
'/rooms/2/subrooms/2/permissions',
|
||||||
|
[{ Permission: 'CAN_USE_MAKER_PEN', Role: 30, Override: true, Type: 0, Value: 'True' }],
|
||||||
|
'1'
|
||||||
|
)
|
||||||
|
const changed = await permissionsIn(743, 2)
|
||||||
|
expect(changed.length).toBe(11)
|
||||||
|
expect(changed[at]).toMatchObject({ Override: true, Value: 'True' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an unchecked Override erases the entry, back to the default', async () => {
|
||||||
|
const stored = async () =>
|
||||||
|
(await env.DB.prepare(
|
||||||
|
`SELECT COUNT(*) AS n FROM subroom_permission
|
||||||
|
WHERE sub_room_id = 2 AND permission = 'CAN_USE_MAKER_PEN' AND role = 30`
|
||||||
|
).first<{ n: number }>())!.n
|
||||||
|
|
||||||
|
// The previous test left this pair overridden.
|
||||||
|
expect(await stored()).toBe(1)
|
||||||
|
|
||||||
|
// `Override: false` means "fall back to the default" — the `Value` riding along is
|
||||||
|
// not stored, it's whatever the picker happened to show.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await putPermissions(
|
||||||
|
'/rooms/2/subrooms/2/permissions',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
Permission: 'CAN_USE_MAKER_PEN',
|
||||||
|
Role: 30,
|
||||||
|
Override: false,
|
||||||
|
Type: 0,
|
||||||
|
Value: 'True',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'1'
|
||||||
|
)
|
||||||
|
).status
|
||||||
|
).toBe(200)
|
||||||
|
|
||||||
|
// The row is gone, and the token serves the default for the pair again.
|
||||||
|
expect(await stored()).toBe(0)
|
||||||
|
const table = await permissionsIn(743, 2)
|
||||||
|
expect(table.length).toBe(11)
|
||||||
|
expect(entry(table, 'CAN_USE_MAKER_PEN', 30)).toMatchObject({
|
||||||
|
Override: false,
|
||||||
|
Value: 'True',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clearing a pair that was never overridden is a no-op, not an insert.
|
||||||
|
await putPermissions(
|
||||||
|
'/rooms/2/subrooms/2/permissions',
|
||||||
|
[{ Permission: 'CAN_INVITE', Role: 0, Override: false, Type: 0, Value: 'True' }],
|
||||||
|
'1'
|
||||||
|
)
|
||||||
|
expect((await permissionsIn(743, 2)).length).toBe(11)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends a permission the defaults do not carry, and scopes it to its subroom', async () => {
|
||||||
|
// CAN_INVITE is in none of the defaults, so it lands as a new entry.
|
||||||
|
await putPermissions(
|
||||||
|
'/rooms/2/subrooms/2/permissions',
|
||||||
|
[{ Permission: 'CAN_INVITE', Role: 30, Override: true, Type: 0, Value: 'False' }],
|
||||||
|
'1'
|
||||||
|
)
|
||||||
|
const inSubRoom2 = await permissionsIn(744, 2)
|
||||||
|
expect(inSubRoom2.length).toBe(12)
|
||||||
|
expect(entry(inSubRoom2, 'CAN_INVITE', 30)).toMatchObject({
|
||||||
|
Override: true,
|
||||||
|
Value: 'False',
|
||||||
|
})
|
||||||
|
|
||||||
|
// A different subroom is untouched — the table is per-subroom, not per-room.
|
||||||
|
expect((await permissionsIn(744, 3)).length).toBe(11)
|
||||||
|
// And so is a player in no instance at all.
|
||||||
|
await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(744).run()
|
||||||
|
const lobby = await SELF.fetch(`${ORIGIN}/photon_access_token`, {
|
||||||
|
headers: await bearer('744'),
|
||||||
|
})
|
||||||
|
expect(((await lobby.json()) as { Permissions: Permission[] }).Permissions.length).toBe(11)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a Value that isn’t True/False verbatim', async () => {
|
||||||
|
// Not every permission's UI is the True/False picker, so nothing interprets the
|
||||||
|
// string — it goes to the client exactly as the creator set it.
|
||||||
|
await putPermissions(
|
||||||
|
'/rooms/2/subrooms/2/permissions',
|
||||||
|
[{ Permission: 'MAX_SPAWNED_INVENTIONS', Role: 0, Override: true, Type: 0, Value: '25' }],
|
||||||
|
'1'
|
||||||
|
)
|
||||||
|
expect(entry(await permissionsIn(747, 2), 'MAX_SPAWNED_INVENTIONS', 0)).toMatchObject({
|
||||||
|
Override: true,
|
||||||
|
Value: '25',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies over the dev accounts’ global maker pen, without listing a pair twice', async () => {
|
||||||
|
await putPermissions(
|
||||||
|
'/rooms/2/subrooms/2/permissions',
|
||||||
|
[
|
||||||
|
{ Permission: 'CAN_USE_MAKER_PEN', Role: 0, Override: true, Type: 0, Value: 'False' },
|
||||||
|
// The third sample body — a Role 0 grant the defaults already carry.
|
||||||
|
{
|
||||||
|
Permission: 'CAN_USE_DELETE_ALL_BUTTON',
|
||||||
|
Role: 0,
|
||||||
|
Override: true,
|
||||||
|
Type: 0,
|
||||||
|
Value: 'True',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'1'
|
||||||
|
)
|
||||||
|
// Account 3 is one of the hardcoded dev accounts, so it gets the global (Role 0)
|
||||||
|
// maker pen prepended — which this subroom then revokes. The merge runs last and
|
||||||
|
// replaces it in place, so the pair appears exactly ONCE: a table listing it twice
|
||||||
|
// with two values would leave which one applies up to the client.
|
||||||
|
const devTable = await permissionsIn(3, 2)
|
||||||
|
expect(devTable.filter((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toEqual([
|
||||||
|
{ Override: true, Permission: 'CAN_USE_MAKER_PEN', Role: 0, Type: 0, Value: 'False' },
|
||||||
|
])
|
||||||
|
expect(entry(devTable, 'CAN_USE_DELETE_ALL_BUTTON', 0)).toMatchObject({ Value: 'True' })
|
||||||
|
|
||||||
|
// A normal player in the same subroom sees the same revocation.
|
||||||
|
expect(entry(await permissionsIn(745, 2), 'CAN_USE_MAKER_PEN', 0)).toMatchObject({
|
||||||
|
Value: 'False',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a cloned subroom inherits the source’s permission table', async () => {
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('1'),
|
||||||
|
})
|
||||||
|
const room = (await res.json()) as { value: { SubRooms: Array<{ SubRoomId: number }> } }
|
||||||
|
const cloneId = Math.max(...room.value.SubRooms.map((s) => s.SubRoomId))
|
||||||
|
|
||||||
|
const inClone = await permissionsIn(746, cloneId)
|
||||||
|
expect(entry(inClone, 'CAN_INVITE', 30)).toMatchObject({ Value: 'False' })
|
||||||
|
expect(entry(inClone, 'CAN_USE_MAKER_PEN', 0)).toMatchObject({ Value: 'False' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => {
|
it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => {
|
||||||
const clone = async (roomId: number, subRoomId: number, sub?: string) =>
|
const clone = async (roomId: number, subRoomId: number, sub?: string) =>
|
||||||
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, {
|
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, {
|
||||||
@@ -1821,10 +2499,10 @@ describe('rooms endpoints', () => {
|
|||||||
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms`, {
|
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...(await bearer('1')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { ...(await bearer('1')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: new URLSearchParams({ name: 'to-delete' }).toString(),
|
body: new URLSearchParams({ name: 'ToDelete' }).toString(),
|
||||||
})
|
})
|
||||||
).json()) as { value: { SubRooms: SubRoom[] } }
|
).json()) as { value: { SubRooms: SubRoom[] } }
|
||||||
const newId = created.value.SubRooms.find((s) => s.Name === 'to-delete')!.SubRoomId
|
const newId = created.value.SubRooms.find((s) => s.Name === 'ToDelete')!.SubRoomId
|
||||||
|
|
||||||
// No token → 401. Not the owner → success:false. Unknown subroom → success:false.
|
// No token → 401. Not the owner → success:false. Unknown subroom → success:false.
|
||||||
expect((await del(2, newId)).status).toBe(401)
|
expect((await del(2, newId)).status).toBe(401)
|
||||||
@@ -1922,6 +2600,7 @@ describe('rooms endpoints', () => {
|
|||||||
)
|
)
|
||||||
expect([...documented].sort()).toEqual([
|
expect([...documented].sort()).toEqual([
|
||||||
'DELETE /rooms/{roomId}',
|
'DELETE /rooms/{roomId}',
|
||||||
|
'DELETE /rooms/{roomId}/bans/{playerId}',
|
||||||
'DELETE /rooms/{roomId}/interactionby/me/cheer',
|
'DELETE /rooms/{roomId}/interactionby/me/cheer',
|
||||||
'DELETE /rooms/{roomId}/interactionby/me/favorite',
|
'DELETE /rooms/{roomId}/interactionby/me/favorite',
|
||||||
'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
|
'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
|
||||||
@@ -1939,13 +2618,15 @@ describe('rooms endpoints', () => {
|
|||||||
'GET /rooms/recommendations',
|
'GET /rooms/recommendations',
|
||||||
'GET /rooms/search',
|
'GET /rooms/search',
|
||||||
'GET /rooms/visitedby/me',
|
'GET /rooms/visitedby/me',
|
||||||
|
'GET /rooms/visitedby/{playerId}',
|
||||||
'GET /rooms/{roomId}',
|
'GET /rooms/{roomId}',
|
||||||
|
'GET /rooms/{roomId}/bans',
|
||||||
'GET /rooms/{roomId}/interactionby/me',
|
'GET /rooms/{roomId}/interactionby/me',
|
||||||
'GET /rooms/{roomId}/playerdata/me',
|
'GET /rooms/{roomId}/playerdata/me',
|
||||||
'GET /rooms/{roomId}/similar',
|
'GET /rooms/{roomId}/similar',
|
||||||
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
|
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
|
||||||
'GET /roomserver/photon_access_token',
|
|
||||||
'GET /roomserver/rooms/createdby/me',
|
'GET /roomserver/rooms/createdby/me',
|
||||||
|
'POST /rooms/{roomId}/bans',
|
||||||
'POST /rooms/{roomId}/clone',
|
'POST /rooms/{roomId}/clone',
|
||||||
'POST /rooms/{roomId}/subrooms',
|
'POST /rooms/{roomId}/subrooms',
|
||||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
|
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
|
||||||
@@ -1963,6 +2644,7 @@ describe('rooms endpoints', () => {
|
|||||||
'PUT /rooms/{roomId}/roles/{accountId}',
|
'PUT /rooms/{roomId}/roles/{accountId}',
|
||||||
'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility',
|
'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility',
|
||||||
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
|
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
|
||||||
|
'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions',
|
||||||
'PUT /rooms/{roomId}/tags',
|
'PUT /rooms/{roomId}/tags',
|
||||||
'PUT /rooms/{roomId}/warning',
|
'PUT /rooms/{roomId}/warning',
|
||||||
])
|
])
|
||||||
@@ -1974,3 +2656,81 @@ describe('rooms endpoints', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Room and subroom names are held to the same rule as usernames — letters and digits,
|
||||||
|
// at most 32 (see `nameRejection` in @repo/domain). All four routes that take a
|
||||||
|
// player-supplied name enforce it, and each keeps its OWN refusal shape: the create
|
||||||
|
// paths answer the lowercase `{ success, error, value }` envelope, the two settings
|
||||||
|
// routes answer `{ Success, ErrorId, Error }` with the same `Rooms.InvalidName` id they
|
||||||
|
// already used for an empty name. The client keys off those, so the rule had to fit the
|
||||||
|
// existing shapes rather than introduce a fifth one.
|
||||||
|
//
|
||||||
|
// Names the SERVER generates are exempt on purpose — a dorm is `@<username>'s Dorm`,
|
||||||
|
// which this rule would reject. That's why the check lives in the handlers.
|
||||||
|
describe('room name validation', () => {
|
||||||
|
const bad = ['My Room', 'under_score', 'punct!', 'a'.repeat(33)]
|
||||||
|
|
||||||
|
const post = async (path: string, fields: Record<string, string>, sub: string) =>
|
||||||
|
SELF.fetch(`${ORIGIN}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(fields).toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const put = async (path: string, fields: Record<string, string>, sub: string) =>
|
||||||
|
SELF.fetch(`${ORIGIN}${path}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(fields).toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a bad name when a player clones a room into existence', async () => {
|
||||||
|
for (const name of bad) {
|
||||||
|
const res = await post('/rooms/2/clone', { name }, '1')
|
||||||
|
const body = (await res.json()) as { success: boolean; error: string; value: unknown }
|
||||||
|
expect(body.success, name).toBe(false)
|
||||||
|
expect(body.error).toMatch(/letters and numbers|at most 32 characters/)
|
||||||
|
expect(body.value).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a bad name on rename, with the id the client already handles', async () => {
|
||||||
|
for (const name of bad) {
|
||||||
|
const res = await put('/rooms/2/name', { name }, '1')
|
||||||
|
const body = (await res.json()) as { Success: boolean; ErrorId: string; Error: string }
|
||||||
|
expect(body.Success, name).toBe(false)
|
||||||
|
expect(body.ErrorId).toBe('Rooms.InvalidName')
|
||||||
|
expect(body.Error).toMatch(/letters and numbers|at most 32 characters/)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unchanged: the refusals above never reached the write.
|
||||||
|
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Name: string }
|
||||||
|
expect(room.Name).not.toMatch(/[^A-Za-z0-9]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a bad name when creating or modifying a subroom', async () => {
|
||||||
|
for (const name of bad) {
|
||||||
|
const created = await post('/rooms/2/subrooms', { name }, '1')
|
||||||
|
const env1 = (await created.json()) as { success: boolean; error: string }
|
||||||
|
expect(env1.success, name).toBe(false)
|
||||||
|
expect(env1.error).toMatch(/letters and numbers|at most 32 characters/)
|
||||||
|
|
||||||
|
const modified = await put(
|
||||||
|
'/rooms/2/subrooms/2/modify',
|
||||||
|
{ name, accessibility: '1', maxPlayers: '20' },
|
||||||
|
'1'
|
||||||
|
)
|
||||||
|
const res2 = (await modified.json()) as { Success: boolean; ErrorId: string }
|
||||||
|
expect(res2.Success, name).toBe(false)
|
||||||
|
expect(res2.ErrorId).toBe('Rooms.InvalidName')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a 32-character alphanumeric name', async () => {
|
||||||
|
const name = 'a'.repeat(32)
|
||||||
|
const res = await post('/rooms/2/subrooms', { name }, '1')
|
||||||
|
const body = (await res.json()) as { success: boolean; value: { SubRooms: Array<{ Name: string }> } }
|
||||||
|
expect(body.success).toBe(true)
|
||||||
|
expect(body.value.SubRooms.some((s) => s.Name === name)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -21,11 +21,25 @@ export default defineConfig({
|
|||||||
compatibilityDate: '2026-06-16',
|
compatibilityDate: '2026-06-16',
|
||||||
compatibilityFlags: ['nodejs_compat'],
|
compatibilityFlags: ['nodejs_compat'],
|
||||||
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
|
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
|
||||||
|
// notifyPlayer records every call so tests can assert the notifications the
|
||||||
|
// worker pushed (type + payload). GET /all for the whole list, DELETE to
|
||||||
|
// reset it between assertions.
|
||||||
script: `
|
script: `
|
||||||
import { DurableObject } from 'cloudflare:workers'
|
import { DurableObject } from 'cloudflare:workers'
|
||||||
export class NotificationsHub extends DurableObject {
|
export class NotificationsHub extends DurableObject {
|
||||||
async notifyPlayer() { return { delivered: 0, queued: true } }
|
sent = []
|
||||||
|
async notifyPlayer(playerId, notificationType, data) {
|
||||||
|
this.sent.push({ playerId, notificationType, data })
|
||||||
|
return { delivered: 0, queued: true }
|
||||||
|
}
|
||||||
async broadcast() { return { delivered: 0 } }
|
async broadcast() { return { delivered: 0 } }
|
||||||
|
async fetch(request) {
|
||||||
|
if (request.method === 'DELETE') {
|
||||||
|
this.sent = []
|
||||||
|
return new Response(null, { status: 204 })
|
||||||
|
}
|
||||||
|
return Response.json(this.sent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export default { fetch() { return new Response('ok') } }
|
export default { fetch() { return new Response('ok') } }
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import type { App } from './context'
|
|||||||
*/
|
*/
|
||||||
const UPLOAD_SUBFOLDER: Record<number, string> = {
|
const UPLOAD_SUBFOLDER: Record<number, string> = {
|
||||||
1: 'room',
|
1: 'room',
|
||||||
2: 'holotar',
|
2: 'data',
|
||||||
3: 'image',
|
3: 'image',
|
||||||
4: 'video',
|
4: 'video',
|
||||||
5: 'invention',
|
5: 'invention',
|
||||||
@@ -150,8 +150,14 @@ const app = new Hono<App>()
|
|||||||
// does the extension, which is why it goes on the key, not just the name.
|
// does the extension, which is why it goes on the key, not just the name.
|
||||||
const datePrefix = new Date().toISOString().slice(0, 10)
|
const datePrefix = new Date().toISOString().slice(0, 10)
|
||||||
const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}`
|
const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}`
|
||||||
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, await file.arrayBuffer(), {
|
const bytes = await file.arrayBuffer()
|
||||||
|
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
|
||||||
httpMetadata: { contentType: file.type || 'application/octet-stream' },
|
httpMetadata: { contentType: file.type || 'application/octet-stream' },
|
||||||
|
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the
|
||||||
|
// hashes the client is served (an invention's `BlobHash`) are SHA-256, and
|
||||||
|
// only a checksum given at put time is readable later — this lets the `api`
|
||||||
|
// worker answer one from a HEAD instead of downloading the blob to digest it.
|
||||||
|
sha256: await crypto.subtle.digest('SHA-256', bytes),
|
||||||
})
|
})
|
||||||
return c.json({ filename })
|
return c.json({ filename })
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-1
@@ -25,8 +25,9 @@ Upstream hosts are derived from the shared base domain (`auth.<DOMAIN>`,
|
|||||||
|
|
||||||
| Method | Path | Upstream |
|
| Method | Path | Upstream |
|
||||||
| ------ | --------------- | -------------------------------------------------------- |
|
| ------ | --------------- | -------------------------------------------------------- |
|
||||||
|
| GET | `/api/config` | none — whether signup is open, plus the Turnstile key |
|
||||||
| POST | `/api/signup` | auth `POST /connect/token` (`grant_type=create_account`) |
|
| POST | `/api/signup` | auth `POST /connect/token` (`grant_type=create_account`) |
|
||||||
| POST | `/api/login` | auth `POST /connect/token` (account id + password) |
|
| POST | `/api/login` | auth `POST /connect/token` (username + password) |
|
||||||
| POST | `/api/logout` | clears the session cookie |
|
| POST | `/api/logout` | clears the session cookie |
|
||||||
| GET | `/api/me` | accounts `GET /account/me` |
|
| GET | `/api/me` | accounts `GET /account/me` |
|
||||||
| POST | `/api/email` | accounts `POST /account/me/email` |
|
| POST | `/api/email` | accounts `POST /account/me/email` |
|
||||||
@@ -35,6 +36,51 @@ Upstream hosts are derived from the shared base domain (`auth.<DOMAIN>`,
|
|||||||
On signup/login the access token returned by `auth` is stored in an httpOnly
|
On signup/login the access token returned by `auth` is stored in an httpOnly
|
||||||
`rf_token` cookie; the other routes read it and forward it as a Bearer token.
|
`rf_token` cookie; the other routes read it and forward it as a Bearer token.
|
||||||
|
|
||||||
|
`/api/signup` also takes an optional `email`, saved with a second call to accounts
|
||||||
|
`POST /account/me/email` once the session exists — `create_account` has no email
|
||||||
|
field, the accounts worker owns it. The address is format-checked before the
|
||||||
|
account is created, since a rejection afterwards would leave a player with an
|
||||||
|
account whose email silently didn't save; a failure of the save itself is logged
|
||||||
|
and does not fail the signup, because by then the account is real and a retry
|
||||||
|
would spend another slot against auth's per-IP cap.
|
||||||
|
|
||||||
|
### Signup and Turnstile
|
||||||
|
|
||||||
|
`POST /api/signup` creates an account with no platform identity (a password
|
||||||
|
account), so it's the one BFF route a bot could farm — `auth` binds no Steam id to
|
||||||
|
it and only its coarse per-IP cap applies. It therefore runs behind a
|
||||||
|
[Turnstile](https://developers.cloudflare.com/turnstile/) check: the browser posts
|
||||||
|
the widget's token, and the worker verifies it against Turnstile's `siteverify`
|
||||||
|
server-side before calling `auth`. The secret key never leaves the worker, and the
|
||||||
|
browser never talks to `siteverify` itself.
|
||||||
|
|
||||||
|
Two Secrets Store secrets configure it, `TURNSTILE_SITE_KEY` and
|
||||||
|
`TURNSTILE_SECRET_KEY`, bound from the same account-level store every worker uses
|
||||||
|
for `JWT_SECRET` (see `wrangler.jsonc` and `src/turnstile.ts`) — the site key is
|
||||||
|
public, but keeping it with its secret makes the pair the single switch. Creating
|
||||||
|
both is what opens signup; if either fails to resolve, `/api/config` reports
|
||||||
|
`signupEnabled: false` (so the SPA shows sign-in only) and `/api/signup` returns
|
||||||
|
403, so an unconfigured worker serves no signup rather than an unprotected one.
|
||||||
|
A store read that throws is treated the same as a missing key — `/api/config` is on
|
||||||
|
the homepage's critical path and must not 500 when signup isn't set up.
|
||||||
|
|
||||||
|
Because `.get()` caches per isolate, changing either value in the store needs a
|
||||||
|
`www` redeploy before a warm worker picks it up.
|
||||||
|
|
||||||
|
For local dev, seed the two names into the **local** store (miniflare's, keyed by
|
||||||
|
the literal `local` store id — it is per-directory, so run these in `apps/www`)
|
||||||
|
with Turnstile's documented always-passes test keypair, which needs no widget and
|
||||||
|
no account:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests seed the same pair into their own local store in `beforeAll`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Run in dev mode
|
### Run in dev mode
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* What a refused `auth` `/connect/token` grant means to somebody filling in a form.
|
||||||
|
*
|
||||||
|
* Shared by the worker and the browser, because the two halves of the site refuse in
|
||||||
|
* different places and have to say the same thing. Signup is refused SERVER-side (it
|
||||||
|
* goes through www for the Turnstile check — see www.app.ts), while sign-in is refused
|
||||||
|
* by `auth` directly, which the SPA calls itself. Without this shared table the second
|
||||||
|
* one would put a bare OAuth code on screen.
|
||||||
|
*
|
||||||
|
* No runtime dependencies, so it's safe to pull into the client bundle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Which grant was being made, so a shared refusal reads right on either form. */
|
||||||
|
export type AuthAction = 'signup' | 'login'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyed on the exact `error_description` auth sends (see its `/connect/token` handler).
|
||||||
|
* The platform arms aren't reachable from the web today — signup posts `create_account`
|
||||||
|
* with no platform, sign-in posts `password` — but they're mapped anyway so a future web
|
||||||
|
* flow that does assert one can't regress to a bare code.
|
||||||
|
*/
|
||||||
|
const AUTH_MESSAGES: Record<string, string> = {
|
||||||
|
'too many accounts created from this network':
|
||||||
|
'Too many accounts have already been created from your network. Try again later, or from a different connection.',
|
||||||
|
'account limit reached for this platform account':
|
||||||
|
'This platform account has already created as many accounts as it is allowed.',
|
||||||
|
'invalid account_id or password': 'That username or password is incorrect.',
|
||||||
|
'account_id or username is required': 'Username and password are required.',
|
||||||
|
'invalid or missing platform_auth': 'Your platform sign-in could not be verified.',
|
||||||
|
'unsupported platform; only Steam and Meta can be verified':
|
||||||
|
'That platform cannot be verified — only Steam and Meta are supported.',
|
||||||
|
'no linked account for this platform identity':
|
||||||
|
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
|
||||||
|
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallbacks when nothing above matched, so a player never reads an OAuth code. */
|
||||||
|
const GENERIC_MESSAGES: Record<AuthAction, { rejected: string; broken: string }> = {
|
||||||
|
signup: {
|
||||||
|
rejected: 'Your account could not be created. Please check your details and try again.',
|
||||||
|
broken:
|
||||||
|
'Accounts cannot be created right now. This is a problem on our end — please try again later.',
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
rejected: 'You could not be signed in. Please check your details and try again.',
|
||||||
|
broken:
|
||||||
|
'Sign-in is unavailable right now. This is a problem on our end — please try again later.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Message for an `auth` that couldn't be reached at all (the request itself threw). */
|
||||||
|
export const authUnreachable = (action: AuthAction): string => GENERIC_MESSAGES[action].broken
|
||||||
|
|
||||||
|
/** A rejected `/connect/token` grant, translated. */
|
||||||
|
export interface AuthFailure {
|
||||||
|
/** The sentence to put in front of the player. */
|
||||||
|
message: string
|
||||||
|
/** 400 when the grant was refused, 502 when `auth` itself couldn't proceed. */
|
||||||
|
status: 400 | 502
|
||||||
|
/** The raw `error`/`error_description` pair, for the operator's log line only. */
|
||||||
|
upstream: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a refusal auth has already answered.
|
||||||
|
*
|
||||||
|
* auth answers the OAuth shape — `{ error: 'invalid_grant', error_description: … }` —
|
||||||
|
* where `error` is one of three machine codes and the DESCRIPTION carries the actual
|
||||||
|
* reason. Showing that body verbatim put "invalid_grant" on screen for every failure,
|
||||||
|
* including the ones a player can act on (the per-network signup cap). An unrecognised
|
||||||
|
* description falls back to the generic line for the action rather than leaking whatever
|
||||||
|
* it did say — those are written for an operator.
|
||||||
|
*/
|
||||||
|
export function authFailure(
|
||||||
|
action: AuthAction,
|
||||||
|
status: number,
|
||||||
|
code: string,
|
||||||
|
description: string
|
||||||
|
): AuthFailure {
|
||||||
|
// A 5xx (or a `server_error`) is an operator misconfiguration — an unset JWT_SECRET,
|
||||||
|
// an unset META_APP_SECRET — not something the player got wrong. Don't send them back
|
||||||
|
// to re-check a form that was fine; the real reason is in auth's log, not theirs.
|
||||||
|
const broken = status >= 500 || code === 'server_error'
|
||||||
|
const generic = GENERIC_MESSAGES[action]
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
(!broken && AUTH_MESSAGES[description]) || (broken ? generic.broken : generic.rejected),
|
||||||
|
status: broken ? 502 : 400,
|
||||||
|
upstream: description ? `${code || 'unknown'}: ${description}` : code || `HTTP ${status}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
+805
-99
@@ -1,41 +1,312 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { DISCORD_INVITE, DOWNLOAD_URL, LICENSE_URL, SOURCE_REPO } from '../links'
|
import { NotificationType } from '../../../notify/src/notification-types'
|
||||||
|
import { authFailure, authUnreachable } from '../auth-messages'
|
||||||
|
import {
|
||||||
|
DISCORD_INVITE,
|
||||||
|
DOWNLOAD_URL,
|
||||||
|
LICENSE_URL,
|
||||||
|
QUEST_DOWNLOAD_URL,
|
||||||
|
SOURCE_REPO,
|
||||||
|
} from '../links'
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
/** The self-account shape returned by the www BFF (`/api/me`, `/api/login`, …). */
|
/**
|
||||||
|
* The SPA calls the SAME endpoints the game does — `auth` for tokens and the password
|
||||||
|
* change, `accounts` for the profile, `api` for the photo feed, `notify` for the admin
|
||||||
|
* broadcasts — rather than proxying each one through `www`, exactly as rec.net's own
|
||||||
|
* site did. Those workers answer CORS for it (see their `withDefaultCors()`), and the
|
||||||
|
* access token lives here in the browser.
|
||||||
|
*
|
||||||
|
* `www` serves only two things of its own (see www.app.ts): the config below, and
|
||||||
|
* signup, which is Turnstile-gated and so cannot leave the server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Where each worker lives. From `/api/config`, never baked into this build. */
|
||||||
|
interface Hosts {
|
||||||
|
auth: string
|
||||||
|
accounts: string
|
||||||
|
api: string
|
||||||
|
img: string
|
||||||
|
notify: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site config from `www`. `signupEnabled` is false when the operator has no Turnstile
|
||||||
|
* keypair configured — web signup runs behind that bot check, so without it the endpoint
|
||||||
|
* is closed and the UI must not offer the form.
|
||||||
|
*/
|
||||||
|
interface SiteConfig {
|
||||||
|
signupEnabled: boolean
|
||||||
|
turnstileSiteKey: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The private self DTO from `accounts` (`GET /account/me`). */
|
||||||
interface SelfAccount {
|
interface SelfAccount {
|
||||||
accountId: number
|
accountId: number
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
email: string | null
|
email: string | null
|
||||||
/** Whether this session may use admin controls (from the token's role claim). */
|
/**
|
||||||
isAdmin?: boolean
|
* Username changes left on the account — each change spends one, and an account
|
||||||
|
* starts with one. Absent on an older self DTO, which reads as "unknown": the form
|
||||||
|
* stays usable and lets the server be the one to refuse.
|
||||||
|
*/
|
||||||
|
availableUsernameChanges?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call a www BFF endpoint. GET when no body is given, else POST JSON. Throws with
|
* RecNet (4) is the web platform, stamped as the token's `platform` claim on sign-in.
|
||||||
* the upstream error message (auth uses `error`/`error_description`, the account
|
* NOT passed on signup: create_account treats an asserted platform as one to verify
|
||||||
* mutations use `error`) so callers can surface it.
|
* against Steam and rejects RecNet — the web signup is the (platform-less) password
|
||||||
|
* account path.
|
||||||
*/
|
*/
|
||||||
async function api<T = unknown>(path: string, body?: unknown): Promise<T> {
|
const WEB_PLATFORM = '4'
|
||||||
const res = await fetch(path, {
|
|
||||||
method: body === undefined ? 'GET' : 'POST',
|
/**
|
||||||
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
|
* The session's access token, in localStorage so a reload stays signed in.
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
*
|
||||||
|
* Readable by page JS, which the httpOnly cookie this replaced was not — that is the
|
||||||
|
* tradeoff that comes with the browser calling the workers itself, and it's the same
|
||||||
|
* posture the game client has. Nothing third-party runs on this origin except the
|
||||||
|
* Turnstile widget, which is Cloudflare's own.
|
||||||
|
*/
|
||||||
|
const TOKEN_KEY = 'rf_token'
|
||||||
|
let token: string | null = localStorage.getItem(TOKEN_KEY)
|
||||||
|
|
||||||
|
function setToken(next: string | null) {
|
||||||
|
token = next
|
||||||
|
if (next === null) localStorage.removeItem(TOKEN_KEY)
|
||||||
|
else localStorage.setItem(TOKEN_KEY, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filled in once `/api/config` lands, before any worker call is made — a module value
|
||||||
|
* rather than a prop threaded through every form, since the components that call a
|
||||||
|
* worker only render after the config resolves.
|
||||||
|
*/
|
||||||
|
let hosts: Hosts | null = null
|
||||||
|
|
||||||
|
/** The hostnames, once known. Throws rather than guessing a domain. */
|
||||||
|
function where(): Hosts {
|
||||||
|
if (hosts === null) throw new Error('Still starting up — please reload the page.')
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roles that unlock the admin controls. Mirrors the notify worker's `ADMIN_ROLES` gate —
|
||||||
|
* this only decides whether to SHOW them; notify verifies the token on every call.
|
||||||
|
*/
|
||||||
|
const ADMIN_ROLES = new Set(['developer', 'moderator'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the session token carries an admin role. Decodes the `role` claim WITHOUT
|
||||||
|
* verifying it — a page holds no signing key, and faking one here only reveals buttons
|
||||||
|
* whose endpoints reject the same token. A malformed token reads as "not admin".
|
||||||
|
*/
|
||||||
|
function isAdmin(): boolean {
|
||||||
|
const payload = token?.split('.')[1]
|
||||||
|
if (!payload) return false
|
||||||
|
try {
|
||||||
|
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
|
||||||
|
const claims = JSON.parse(atob(padded)) as { role?: unknown }
|
||||||
|
return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An OAuth machine code (`invalid_grant`, `server_error`) rather than a sentence — a
|
||||||
|
* lower_snake_case word with no spaces. A worker that speaks OAuth puts one of these in
|
||||||
|
* `error`, where the readable reason is in `error_description`.
|
||||||
|
*/
|
||||||
|
const isErrorCode = (s: string) => /^[a-z][a-z\d]*(_[a-z\d]+)+$/.test(s)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message worth showing for a refusal. `error` wins, since that's where a worker
|
||||||
|
* puts a sentence it wrote for the player — but NOT when it's a bare OAuth code, which
|
||||||
|
* tells nobody anything. Some refusals carry no body at all (accounts answers a
|
||||||
|
* malformed email with an empty 400), hence the last-resort line.
|
||||||
|
*/
|
||||||
|
function errorMessage(data: Record<string, unknown>, status: number): string {
|
||||||
|
const error = typeof data.error === 'string' ? data.error : ''
|
||||||
|
const description = typeof data.error_description === 'string' ? data.error_description : ''
|
||||||
|
return (
|
||||||
|
(error && !(isErrorCode(error) && description) && error) ||
|
||||||
|
description ||
|
||||||
|
error ||
|
||||||
|
`Request failed (${status})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CallOptions {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT'
|
||||||
|
/** Form fields — auth and accounts read their input with Hono's `parseBody()`. */
|
||||||
|
form?: Record<string, string>
|
||||||
|
/** A JSON body — what notify's internal endpoints take instead. */
|
||||||
|
json?: unknown
|
||||||
|
/** Send the session token. */
|
||||||
|
authed?: boolean
|
||||||
|
/**
|
||||||
|
* What to say when the worker refuses with a 400 and NO body. Several accounts routes
|
||||||
|
* do exactly that (email, display name, bio), so without this the player reads
|
||||||
|
* "Request failed (400)" — the status, not the reason.
|
||||||
|
*/
|
||||||
|
refusal?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call a worker. Returns the parsed body, or throws with something worth showing. */
|
||||||
|
async function call<T = Record<string, unknown>>(url: string, opts: CallOptions = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (opts.authed && token) headers.authorization = `Bearer ${token}`
|
||||||
|
let body: string | undefined
|
||||||
|
if (opts.form) {
|
||||||
|
headers['content-type'] = 'application/x-www-form-urlencoded'
|
||||||
|
body = new URLSearchParams(opts.form).toString()
|
||||||
|
} else if (opts.json !== undefined) {
|
||||||
|
headers['content-type'] = 'application/json'
|
||||||
|
body = JSON.stringify(opts.json)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: opts.method ?? (body === undefined ? 'GET' : 'POST'),
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
})
|
})
|
||||||
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message =
|
// Expired or revoked. Cleared here so no caller has to remember to.
|
||||||
(typeof data.error === 'string' && data.error) ||
|
if (res.status === 401 && opts.authed) {
|
||||||
(typeof data.error_description === 'string' && data.error_description) ||
|
setToken(null)
|
||||||
`Request failed (${res.status})`
|
throw new Error('Your session has expired. Please sign in again.')
|
||||||
throw new Error(message)
|
}
|
||||||
|
// Only when the body really is empty — a worker that did send a reason keeps it.
|
||||||
|
if (opts.refusal !== undefined && res.status === 400 && Object.keys(data).length === 0) {
|
||||||
|
throw new Error(opts.refusal)
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage(data, res.status))
|
||||||
}
|
}
|
||||||
return data as T
|
return data as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The signed-in account, straight from `accounts`. */
|
||||||
|
const fetchMe = (): Promise<SelfAccount> =>
|
||||||
|
call<SelfAccount>(`${where().accounts}/account/me`, { authed: true })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign in with auth's password grant, posted directly the way the game posts it. The
|
||||||
|
* account is resolved by `username` (case-insensitive) — web players sign in with their
|
||||||
|
* username, not the numeric account id.
|
||||||
|
*
|
||||||
|
* A refusal is translated through the table shared with the worker (see
|
||||||
|
* `auth-messages.ts`): auth's `error` is always a machine code, and the reason in
|
||||||
|
* `error_description` is written for an operator, not a player.
|
||||||
|
*/
|
||||||
|
async function signIn(username: string, password: string): Promise<void> {
|
||||||
|
const res = await fetch(`${where().auth}/connect/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'password',
|
||||||
|
username,
|
||||||
|
platform: WEB_PLATFORM,
|
||||||
|
password,
|
||||||
|
}).toString(),
|
||||||
|
}).catch(() => null)
|
||||||
|
if (res === null) throw new Error(authUnreachable('login'))
|
||||||
|
|
||||||
|
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||||
|
if (!res.ok) {
|
||||||
|
const code = typeof data.error === 'string' ? data.error : ''
|
||||||
|
const description = typeof data.error_description === 'string' ? data.error_description : ''
|
||||||
|
throw new Error(authFailure('login', res.status, code, description).message)
|
||||||
|
}
|
||||||
|
if (typeof data.access_token !== 'string') throw new Error(authUnreachable('login'))
|
||||||
|
setToken(data.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an account — the one flow that goes through `www`, because it's gated by
|
||||||
|
* Turnstile and that check needs a secret key a page can't hold. www hands back auth's
|
||||||
|
* token response unchanged, so the session is established just as sign-in establishes it.
|
||||||
|
*/
|
||||||
|
async function signUp(password: string, turnstileToken: string): Promise<void> {
|
||||||
|
const data = await call<{ access_token?: string }>('/api/signup', {
|
||||||
|
json: { password, turnstileToken },
|
||||||
|
})
|
||||||
|
if (typeof data.access_token !== 'string') throw new Error(authUnreachable('signup'))
|
||||||
|
setToken(data.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the username.
|
||||||
|
*
|
||||||
|
* `accounts` answers this one in its own envelope — `{ success, error, value }` at HTTP
|
||||||
|
* 200 even when it refused (taken name, no changes left) — so a 200 is not enough to
|
||||||
|
* call it done. The sentences it writes are already player-facing, so they're shown as-is.
|
||||||
|
*
|
||||||
|
* On success the SELF account is re-read rather than using the envelope's `value`: that
|
||||||
|
* is the PUBLIC DTO, and it carries no `availableUsernameChanges` — the very field this
|
||||||
|
* form needs to know whether another change is left.
|
||||||
|
*/
|
||||||
|
async function changeUsername(username: string): Promise<SelfAccount> {
|
||||||
|
const result = await call<{ error?: unknown }>(`${where().accounts}/account/me/username`, {
|
||||||
|
method: 'PUT',
|
||||||
|
form: { username },
|
||||||
|
authed: true,
|
||||||
|
})
|
||||||
|
const refusal = typeof result.error === 'string' ? result.error : ''
|
||||||
|
if (refusal !== '') throw new Error(refusal)
|
||||||
|
return fetchMe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the account's email.
|
||||||
|
*
|
||||||
|
* The address is NOT checked here first. `accounts` validates it with `isemail`, which
|
||||||
|
* can't come along into the browser (it reaches for node's `util`, which vite stubs with
|
||||||
|
* a throwing Proxy in dev) — and a second, looser copy of the rule would only disagree
|
||||||
|
* with the real one. The server decides; this just names the refusal it answers with.
|
||||||
|
*/
|
||||||
|
const saveEmail = (email: string): Promise<unknown> =>
|
||||||
|
call(`${where().accounts}/account/me/email`, {
|
||||||
|
form: { email },
|
||||||
|
authed: true,
|
||||||
|
refusal: 'That email address looks wrong.',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Change the account's password. Lives on `auth`, not `accounts`. */
|
||||||
|
const changePassword = (oldPassword: string, newPassword: string): Promise<unknown> =>
|
||||||
|
call(`${where().auth}/account/me/changepassword`, {
|
||||||
|
form: { oldPassword, newPassword },
|
||||||
|
authed: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate
|
||||||
|
* — so a session without the role is rejected there (403) even though the UI shows no
|
||||||
|
* button. The maintenance frame carries `Msg: { StartsInMinutes }`, matching the game
|
||||||
|
* client's ServerMaintenance handler.
|
||||||
|
*/
|
||||||
|
const broadcastMaintenance = (startsInMinutes: number): Promise<{ delivered?: number }> =>
|
||||||
|
call<{ delivered?: number }>(`${where().notify}/internal/broadcast`, {
|
||||||
|
json: {
|
||||||
|
notificationType: NotificationType.ServerMaintenance,
|
||||||
|
data: { StartsInMinutes: startsInMinutes },
|
||||||
|
},
|
||||||
|
authed: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const coachMessageAll = (messageContent: string): Promise<{ sent?: number }> =>
|
||||||
|
call<{ sent?: number }>(`${where().notify}/internal/coach-message-all`, {
|
||||||
|
json: { messageContent },
|
||||||
|
authed: true,
|
||||||
|
})
|
||||||
|
|
||||||
/** Minimal history-based router: current pathname + a navigate() that pushes state. */
|
/** Minimal history-based router: current pathname + a navigate() that pushes state. */
|
||||||
function useRouter() {
|
function useRouter() {
|
||||||
const [path, setPath] = useState(() => window.location.pathname)
|
const [path, setPath] = useState(() => window.location.pathname)
|
||||||
@@ -85,16 +356,37 @@ function Link({
|
|||||||
export function App() {
|
export function App() {
|
||||||
// undefined = still checking the session; null = signed out.
|
// undefined = still checking the session; null = signed out.
|
||||||
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
|
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
|
||||||
|
// undefined until the config lands. Signup is treated as closed until told otherwise,
|
||||||
|
// so a slow (or failed) config fetch can't flash a form the server would refuse.
|
||||||
|
const [config, setConfig] = useState<SiteConfig | undefined>(undefined)
|
||||||
const { path, navigate } = useRouter()
|
const { path, navigate } = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<SelfAccount>('/api/me')
|
// Config first, and everything else after it: it carries the hostnames every other
|
||||||
.then((me) => setAccount(me))
|
// call needs. A config that doesn't land leaves the page signed out with signup
|
||||||
.catch(() => setAccount(null))
|
// closed rather than guessing where the workers are.
|
||||||
|
call<SiteConfig & { hosts: Hosts }>('/api/config')
|
||||||
|
.then(async ({ hosts: resolved, ...site }) => {
|
||||||
|
hosts = resolved
|
||||||
|
setConfig(site)
|
||||||
|
if (token === null) return setAccount(null)
|
||||||
|
// A stored token that `accounts` rejects is stale — `call` has already dropped
|
||||||
|
// it, so this just falls back to signed-out rather than surfacing an error.
|
||||||
|
await fetchMe()
|
||||||
|
.then(setAccount)
|
||||||
|
.catch(() => setAccount(null))
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setConfig({ signupEnabled: false, turnstileSiteKey: null })
|
||||||
|
setAccount(null)
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
// Nothing to tell a server: the access token is a stateless JWT, so dropping it here
|
||||||
await api('/api/logout', {})
|
// IS the sign-out. (The refresh token auth issues alongside it is never stored, so a
|
||||||
|
// closed session leaves nothing behind to redeem.)
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setToken(null)
|
||||||
setAccount(null)
|
setAccount(null)
|
||||||
navigate('/')
|
navigate('/')
|
||||||
}, [navigate])
|
}, [navigate])
|
||||||
@@ -102,12 +394,22 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
|
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
|
||||||
{path === '/login' ? (
|
{path === '/login' || path === '/signup' ? (
|
||||||
<LoginPage account={account} navigate={navigate} onAuthed={setAccount} />
|
// One page, two doors. `/signup` exists so the homepage's create-account link
|
||||||
|
// lands on that tab instead of dropping people on sign-in to find it — and so
|
||||||
|
// the URL is linkable. Unknown paths fall back to index.html (see the assets
|
||||||
|
// config in wrangler.jsonc), so a cold load of /signup reaches the SPA.
|
||||||
|
<LoginPage
|
||||||
|
account={account}
|
||||||
|
config={config}
|
||||||
|
initialTab={path === '/signup' ? 'signup' : 'login'}
|
||||||
|
navigate={navigate}
|
||||||
|
onAuthed={setAccount}
|
||||||
|
/>
|
||||||
) : path === '/account' ? (
|
) : path === '/account' ? (
|
||||||
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
|
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
|
||||||
) : (
|
) : (
|
||||||
<HomePage />
|
<HomePage account={account} config={config} navigate={navigate} />
|
||||||
)}
|
)}
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</>
|
</>
|
||||||
@@ -179,6 +481,14 @@ function NavBar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many photos the hero asks the feed for. Explicit rather than left to the api's
|
||||||
|
* default, since the count is a design decision here: the stage rotates one photo every
|
||||||
|
* six seconds, so ten is a minute of it — long enough that a repeat visitor sees fresh
|
||||||
|
* photos, short enough that the arrows stay walkable and the payload stays small.
|
||||||
|
*/
|
||||||
|
const SLIDESHOW_TAKE = 10
|
||||||
|
|
||||||
/** A recent public image plus who took it and where. */
|
/** A recent public image plus who took it and where. */
|
||||||
interface Slide {
|
interface Slide {
|
||||||
url: string
|
url: string
|
||||||
@@ -186,16 +496,35 @@ interface Slide {
|
|||||||
roomName: string | null
|
roomName: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads the public photo feed once. `slides === null` means still in flight. */
|
/**
|
||||||
function useSlideshow() {
|
* Loads the public photo feed once. `slides === null` means still in flight.
|
||||||
|
*
|
||||||
|
* Waits for the config, since the feed is served by the `api` worker — the same public
|
||||||
|
* endpoint the game reads it from — and its hostname arrives with the config. Each entry
|
||||||
|
* names an image; the browsable URL for it is on the `img` worker.
|
||||||
|
*/
|
||||||
|
function useSlideshow(config: SiteConfig | undefined) {
|
||||||
const [slides, setSlides] = useState<Slide[] | null>(null)
|
const [slides, setSlides] = useState<Slide[] | null>(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<{ images: Slide[] }>('/api/slideshow')
|
if (config === undefined) return
|
||||||
.then((d) => setSlides(d.images))
|
type Feed = { Images?: Array<{ ImageName: string; Username: string; RoomName: string | null }> }
|
||||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
// Wrapped in an async call rather than started directly, because `where()` THROWS
|
||||||
}, [])
|
// when the config didn't land — synchronously, which straight out of an effect
|
||||||
|
// would take the page down instead of leaving an empty stage behind the fold.
|
||||||
|
void (async () => {
|
||||||
|
const h = where()
|
||||||
|
const d = await call<Feed>(`${h.api}/api/images/v1/slideshow?take=${SLIDESHOW_TAKE}`)
|
||||||
|
setSlides(
|
||||||
|
(d.Images ?? []).map((i) => ({
|
||||||
|
url: `${h.img}/${i.ImageName}`,
|
||||||
|
username: i.Username,
|
||||||
|
roomName: i.RoomName,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
})().catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||||
|
}, [config])
|
||||||
|
|
||||||
return { slides, error }
|
return { slides, error }
|
||||||
}
|
}
|
||||||
@@ -205,12 +534,25 @@ function useSlideshow() {
|
|||||||
* on top of them. Everything about how the thing is built sits below, for whoever
|
* on top of them. Everything about how the thing is built sits below, for whoever
|
||||||
* scrolls looking for it.
|
* scrolls looking for it.
|
||||||
*/
|
*/
|
||||||
function HomePage() {
|
function HomePage({
|
||||||
const feed = useSlideshow()
|
account,
|
||||||
|
config,
|
||||||
|
navigate,
|
||||||
|
}: {
|
||||||
|
account: SelfAccount | null | undefined
|
||||||
|
config: SiteConfig | undefined
|
||||||
|
navigate: Navigate
|
||||||
|
}) {
|
||||||
|
const feed = useSlideshow(config)
|
||||||
|
|
||||||
|
// The signup offer only makes sense to a signed-out visitor when the server would
|
||||||
|
// actually take one. `account === undefined` is still-checking, so it shows nothing
|
||||||
|
// rather than offering an account to someone who already has one.
|
||||||
|
const offerSignup = account === null && config?.signupEnabled === true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<Stage slides={feed.slides} />
|
<Stage slides={feed.slides} offerSignup={offerSignup} navigate={navigate} />
|
||||||
<div className="shell home">
|
<div className="shell home">
|
||||||
<About slides={feed.slides} error={feed.error} />
|
<About slides={feed.slides} error={feed.error} />
|
||||||
</div>
|
</div>
|
||||||
@@ -219,31 +561,36 @@ function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The hero: a rotating in-game photo with the headline and the way in over it. The
|
* The hero: the headline and the way in on the left, a rotating in-game photo on the
|
||||||
* photo is the backdrop, never the payload — when the feed is slow or down the stage
|
* right. The photo is proof, never the payload — when the feed is slow or down the
|
||||||
* still renders, so "Play now!" is reachable either way.
|
* frame holds its space and the left half reads the same, so "Play now!" is reachable
|
||||||
|
* either way.
|
||||||
*/
|
*/
|
||||||
function Stage({ slides }: { slides: Slide[] | null }) {
|
function Stage({
|
||||||
|
slides,
|
||||||
|
offerSignup,
|
||||||
|
navigate,
|
||||||
|
}: {
|
||||||
|
slides: Slide[] | null
|
||||||
|
offerSignup: boolean
|
||||||
|
navigate: Navigate
|
||||||
|
}) {
|
||||||
const [idx, setIdx] = useState(0)
|
const [idx, setIdx] = useState(0)
|
||||||
|
const count = slides?.length ?? 0
|
||||||
|
|
||||||
|
// A timeout keyed on the current slide rather than one long-lived interval: steering
|
||||||
|
// by hand re-arms it, so a photo you just picked gets its full six seconds.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slides || slides.length < 2) return
|
if (count < 2) return
|
||||||
const t = setInterval(() => setIdx((i) => (i + 1) % slides.length), 6000)
|
const t = setTimeout(() => setIdx((i) => (i + 1) % count), 6000)
|
||||||
return () => clearInterval(t)
|
return () => clearTimeout(t)
|
||||||
}, [slides])
|
}, [count, idx])
|
||||||
|
|
||||||
const slide = slides && slides.length > 0 ? slides[idx] : null
|
const slide = slides && slides.length > 0 ? slides[idx] : null
|
||||||
|
const step = (by: number) => setIdx((i) => (i + by + count) % count)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stage">
|
<section className="stage">
|
||||||
{slide && (
|
|
||||||
<img
|
|
||||||
className="stage-photo"
|
|
||||||
key={slide.url}
|
|
||||||
src={slide.url}
|
|
||||||
alt={`Photo taken in game by ${slide.username}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="stage-body">
|
<div className="stage-body">
|
||||||
{/* Deliberately doesn't name the game: this is a fan project, so the
|
{/* Deliberately doesn't name the game: this is a fan project, so the
|
||||||
trademark stays out of the headline and appears lower down, in
|
trademark stays out of the headline and appears lower down, in
|
||||||
@@ -251,40 +598,90 @@ function Stage({ slides }: { slides: Slide[] | null }) {
|
|||||||
<h1 className="stage-title">
|
<h1 className="stage-title">
|
||||||
Play like it's <em>2023</em>.
|
Play like it's <em>2023</em>.
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="stage-lede">
|
||||||
|
The servers you remember, rebuilt and running — free, open source, and up right now.
|
||||||
|
</p>
|
||||||
<div className="stage-actions">
|
<div className="stage-actions">
|
||||||
<a className="cta" href={DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
<a className="cta" href={DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||||
Download for PC
|
Download for PC
|
||||||
</a>
|
</a>
|
||||||
|
<a className="cta" href={QUEST_DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||||
|
Download for Quest
|
||||||
|
</a>
|
||||||
<a className="cta discord" href={DISCORD_INVITE} target="_blank" rel="noreferrer">
|
<a className="cta discord" href={DISCORD_INVITE} target="_blank" rel="noreferrer">
|
||||||
Join the Discord
|
Join the Discord
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{/* A line rather than a fourth button: the download is the point of this page,
|
||||||
|
and launching the game makes an account by itself — signing up here is the
|
||||||
|
way in for someone who wants one first. Hidden entirely when signup is
|
||||||
|
closed, matching /login, which hides its create-account tab the same way. */}
|
||||||
|
{offerSignup && (
|
||||||
|
<p className="stage-alt">
|
||||||
|
New here?{' '}
|
||||||
|
<Link to="/signup" navigate={navigate}>
|
||||||
|
Create an account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{slide && (
|
<div className="stage-show">
|
||||||
|
<div className="stage-frame">
|
||||||
|
{slide && (
|
||||||
|
<img
|
||||||
|
className="stage-photo"
|
||||||
|
key={slide.url}
|
||||||
|
src={slide.url}
|
||||||
|
alt={`Photo taken in game by ${slide.username}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Always mounted, so the frame doesn't shift down when the feed lands. */}
|
||||||
<div className="stage-foot">
|
<div className="stage-foot">
|
||||||
<span className="credit">
|
{slide && (
|
||||||
Photo by @{slide.username}
|
<span className="credit">
|
||||||
{slide.roomName && ` in ${slide.roomName}`}
|
Photo by @{slide.username}
|
||||||
</span>
|
{slide.roomName && ` in ${slide.roomName}`}
|
||||||
{slides && slides.length > 1 && (
|
</span>
|
||||||
<span className="dots">
|
)}
|
||||||
{slides.map((s, i) => (
|
{/* Arrows and a count, not a dot per photo: a dot each is wide enough to
|
||||||
<button
|
shove the headline's half of the split off the page, and it would have
|
||||||
key={s.url}
|
to be rebuilt the moment SLIDESHOW_TAKE grows. */}
|
||||||
className={i === idx ? 'on' : ''}
|
{count > 1 && (
|
||||||
onClick={() => setIdx(i)}
|
<span className="steer">
|
||||||
aria-label={`Show photo ${i + 1} of ${slides.length}`}
|
<button onClick={() => step(-1)} aria-label="Previous photo">
|
||||||
aria-current={i === idx}
|
<Chevron />
|
||||||
/>
|
</button>
|
||||||
))}
|
<span className="count">
|
||||||
|
{idx + 1} / {count}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => step(1)} aria-label="Next photo">
|
||||||
|
<Chevron next />
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The slideshow's back/forward mark. Decorative — the buttons carry the label. */
|
||||||
|
function Chevron({ next }: { next?: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||||||
|
<path
|
||||||
|
d={next ? 'M9 5l7 7-7 7' : 'M15 5l-7 7 7 7'}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** What RecFlare is, under the fold, for whoever wants it. */
|
/** What RecFlare is, under the fold, for whoever wants it. */
|
||||||
function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
||||||
// The feed answering is proof the server replied, so the indicator can't claim
|
// The feed answering is proof the server replied, so the indicator can't claim
|
||||||
@@ -297,8 +694,8 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
|||||||
<h2 className="about-title">An open source rebuild of the 2023 servers</h2>
|
<h2 className="about-title">An open source rebuild of the 2023 servers</h2>
|
||||||
<p className="about-lede">
|
<p className="about-lede">
|
||||||
A free fan project, made by players who missed it. Aiming to be{' '}
|
A free fan project, made by players who missed it. Aiming to be{' '}
|
||||||
<strong>feature-complete</strong> and infinitely scalable — no gatekeeping, no basement
|
<strong>feature-complete</strong> and infinitely scalable —{' '}
|
||||||
server.
|
<strong>architected for the cloud</strong>, no gatekeeping, no basement server.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="about-side">
|
<div className="about-side">
|
||||||
@@ -324,34 +721,85 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The sign-in page. Redirects to the account page once a session exists. */
|
/**
|
||||||
|
* The sign-in page — sign in, plus create-account when the server says signup is open
|
||||||
|
* (it needs a Turnstile keypair; see SiteConfig). Redirects to the account page once a
|
||||||
|
* session exists, however it was obtained.
|
||||||
|
*/
|
||||||
function LoginPage({
|
function LoginPage({
|
||||||
account,
|
account,
|
||||||
|
config,
|
||||||
|
initialTab,
|
||||||
navigate,
|
navigate,
|
||||||
onAuthed,
|
onAuthed,
|
||||||
}: {
|
}: {
|
||||||
account: SelfAccount | null | undefined
|
account: SelfAccount | null | undefined
|
||||||
|
config: SiteConfig | undefined
|
||||||
|
initialTab: 'signup' | 'login'
|
||||||
navigate: Navigate
|
navigate: Navigate
|
||||||
onAuthed: (a: SelfAccount) => void
|
onAuthed: (a: SelfAccount) => void
|
||||||
}) {
|
}) {
|
||||||
|
// The tab IS the route (`/login` vs `/signup`) rather than local state, so the two can
|
||||||
|
// never disagree — switching tabs pushes history, and back goes back to the other one.
|
||||||
|
const tab = initialTab
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (account) navigate('/account')
|
if (account) navigate('/account')
|
||||||
}, [account, navigate])
|
}, [account, navigate])
|
||||||
|
|
||||||
|
const authed = (a: SelfAccount) => {
|
||||||
|
onAuthed(a)
|
||||||
|
navigate('/account')
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>Sign in</h2>
|
{siteKey && (
|
||||||
<p className="muted">
|
<div className="tabs">
|
||||||
Launch the game first — that creates an account linked to your Steam ID. Once you set a
|
<button className={tab === 'login' ? 'active' : ''} onClick={() => navigate('/login')}>
|
||||||
password, use your username and that password to sign in here.
|
Sign in
|
||||||
</p>
|
</button>
|
||||||
<LoginForm
|
<button
|
||||||
onAuthed={(a) => {
|
className={tab === 'signup' ? 'active' : ''}
|
||||||
onAuthed(a)
|
onClick={() => navigate('/signup')}
|
||||||
navigate('/account')
|
>
|
||||||
}}
|
Create account
|
||||||
/>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{siteKey && tab === 'signup' ? (
|
||||||
|
<>
|
||||||
|
<h2>Create account</h2>
|
||||||
|
<p className="muted">
|
||||||
|
A username is assigned for you — you'll see it on your account page. Choose a
|
||||||
|
password, and the two together sign you in here and in the game.
|
||||||
|
</p>
|
||||||
|
<SignupForm siteKey={siteKey} onAuthed={authed} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2>Sign in</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Use your username and password. Launching the game also creates an account, linked to
|
||||||
|
your Steam ID — set a password on it and it signs in here too.
|
||||||
|
</p>
|
||||||
|
<LoginForm onAuthed={authed} />
|
||||||
|
{/* The tabs above already offer this; the line under the button is where
|
||||||
|
someone who just found out they have no account is actually looking.
|
||||||
|
Gated on the same key, so it can't point at a door that isn't there. */}
|
||||||
|
{siteKey && (
|
||||||
|
<p className="muted swap">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/signup" navigate={navigate}>
|
||||||
|
Create one
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -409,9 +857,194 @@ function useAction() {
|
|||||||
return { pending, error, done, run }
|
return { pending, error, done, run }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual web signups are disabled for now, so only sign-in is exposed (accounts are
|
/**
|
||||||
// created via the game/platform, not the website). To bring signups back, restore a
|
* Turnstile's browser API, as much of it as the signup widget uses. Loaded from
|
||||||
// SignupForm calling POST /api/signup and re-enable that endpoint in www.app.ts.
|
* Cloudflare at runtime (see loadTurnstile) rather than bundled, so it isn't in
|
||||||
|
* node_modules and has no types of its own.
|
||||||
|
*/
|
||||||
|
interface TurnstileApi {
|
||||||
|
render: (
|
||||||
|
el: HTMLElement,
|
||||||
|
opts: {
|
||||||
|
sitekey: string
|
||||||
|
action?: string
|
||||||
|
callback?: (token: string) => void
|
||||||
|
'expired-callback'?: () => void
|
||||||
|
}
|
||||||
|
) => string | undefined
|
||||||
|
reset: (widgetId?: string) => void
|
||||||
|
remove: (widgetId?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
turnstile?: TurnstileApi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load Turnstile's script, once per page, resolving when `window.turnstile` is ready.
|
||||||
|
* `render=explicit` stops it scanning the document for widgets: this is a SPA, so the
|
||||||
|
* container mounts and unmounts with the form and we render into it ourselves.
|
||||||
|
*
|
||||||
|
* The promise is cached at module scope, so switching tabs back and forth reuses the
|
||||||
|
* loaded script instead of appending another tag. A rejection is cached too — the retry
|
||||||
|
* is a page reload, which is what the error message asks for.
|
||||||
|
*/
|
||||||
|
let turnstileScript: Promise<void> | null = null
|
||||||
|
function loadTurnstile(): Promise<void> {
|
||||||
|
turnstileScript ??= new Promise<void>((resolve, reject) => {
|
||||||
|
const el = document.createElement('script')
|
||||||
|
el.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||||
|
el.async = true
|
||||||
|
el.defer = true
|
||||||
|
el.onload = () => resolve()
|
||||||
|
el.onerror = () => reject(new Error('load failed'))
|
||||||
|
document.head.appendChild(el)
|
||||||
|
})
|
||||||
|
return turnstileScript
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount a Turnstile widget and hand back the token it produces. No token means no
|
||||||
|
* submit: the BFF refuses a signup without one, so the form gates its button on it
|
||||||
|
* rather than letting the request fail.
|
||||||
|
*
|
||||||
|
* `reset` re-arms the widget for another attempt — a token is single-use, so a rejected
|
||||||
|
* signup can't be retried with the same one.
|
||||||
|
*/
|
||||||
|
function useTurnstile(siteKey: string) {
|
||||||
|
const container = useRef<HTMLDivElement | null>(null)
|
||||||
|
const widgetId = useRef<string | undefined>(undefined)
|
||||||
|
const [token, setToken] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true
|
||||||
|
loadTurnstile()
|
||||||
|
.then(() => {
|
||||||
|
// StrictMode mounts twice, and the cleanup below removes the first widget; bail
|
||||||
|
// if this effect is the stale one so we don't render into a detached container.
|
||||||
|
if (!live || !container.current || !window.turnstile) return
|
||||||
|
widgetId.current = window.turnstile.render(container.current, {
|
||||||
|
sitekey: siteKey,
|
||||||
|
// Marker Cloudflare uses to segment Turnstile integrations; carries no user data.
|
||||||
|
action: 'turnstile-spin-v1',
|
||||||
|
callback: (t) => setToken(t),
|
||||||
|
// Tokens expire after a few minutes; drop ours so the button locks again and
|
||||||
|
// Turnstile can hand us a fresh one.
|
||||||
|
'expired-callback': () => setToken(''),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (live) setError("Couldn't load the bot check — reload the page to try again.")
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
live = false
|
||||||
|
if (widgetId.current) window.turnstile?.remove(widgetId.current)
|
||||||
|
widgetId.current = undefined
|
||||||
|
}
|
||||||
|
}, [siteKey])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setToken('')
|
||||||
|
if (widgetId.current) window.turnstile?.reset(widgetId.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { container, token, error, reset }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an account from the website: a password, plus a Turnstile token proving a human
|
||||||
|
* filled the form. The username comes back auto-assigned from `auth` (players don't pick
|
||||||
|
* one), and the session is live on success — so this lands on the account page, where the
|
||||||
|
* username is shown.
|
||||||
|
*/
|
||||||
|
function SignupForm({
|
||||||
|
siteKey,
|
||||||
|
onAuthed,
|
||||||
|
}: {
|
||||||
|
siteKey: string
|
||||||
|
onAuthed: (a: SelfAccount) => void
|
||||||
|
}) {
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const { container, token: widgetToken, error: widgetError, reset } = useTurnstile(siteKey)
|
||||||
|
const { pending, error, run } = useAction()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void run(async () => {
|
||||||
|
const wanted = email.trim()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signUp(password, widgetToken)
|
||||||
|
} catch (err) {
|
||||||
|
// The widget token is spent either way, so re-arm before they retry. Only
|
||||||
|
// a failed signup gets here — past this point the account exists, and a
|
||||||
|
// retry would spend another slot against auth's per-IP cap.
|
||||||
|
reset()
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saved with the new session's own token: `create_account` takes no email,
|
||||||
|
// `accounts` owns the field. Deliberately not fatal — the account exists and
|
||||||
|
// the session is live, and the same field is one call away on the account
|
||||||
|
// page.
|
||||||
|
if (wanted !== '') await saveEmail(wanted).catch(() => {})
|
||||||
|
|
||||||
|
// The session is already stored, so a failure here isn't one they can act on
|
||||||
|
// by retrying: a reload finds them signed in.
|
||||||
|
const me = await fetchMe().catch(() => {
|
||||||
|
throw new Error(
|
||||||
|
'Your account was created, but loading it failed. Reload the page — you are already signed in.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
onAuthed(me)
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
autoComplete="new-password"
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/* Optional, and the button doesn't wait on it — but it's the only contact detail
|
||||||
|
an account has, so the hint says plainly what it's for rather than leaving it
|
||||||
|
to be guessed. `type="email"` gets the right keyboard on mobile and a free
|
||||||
|
format check; the worker re-checks it before the account is created. */}
|
||||||
|
<label>
|
||||||
|
Email <span className="optional">optional</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
autoComplete="email"
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="hint">
|
||||||
|
How you get back in if you forget your password — there's no other way to reach you.
|
||||||
|
You can add it later on your account page.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="turnstile" ref={container} />
|
||||||
|
{widgetError && <p className="error">{widgetError}</p>}
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<button type="submit" disabled={pending || widgetToken === ''}>
|
||||||
|
{pending ? 'Creating…' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
@@ -422,11 +1055,8 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
|||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void run(async () => {
|
void run(async () => {
|
||||||
const { account } = await api<{ account: SelfAccount }>('/api/login', {
|
await signIn(username, password)
|
||||||
username,
|
onAuthed(await fetchMe())
|
||||||
password,
|
|
||||||
})
|
|
||||||
onAuthed(account)
|
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
@@ -469,13 +1099,18 @@ function Dashboard({
|
|||||||
// The dashboard sections, shown one at a time via the left tab rail. Admin-only
|
// The dashboard sections, shown one at a time via the left tab rail. Admin-only
|
||||||
// sections are appended when the session carries an admin role.
|
// sections are appended when the session carries an admin role.
|
||||||
const sections = [
|
const sections = [
|
||||||
|
{
|
||||||
|
id: 'username',
|
||||||
|
label: 'Username',
|
||||||
|
render: () => <UsernameForm account={account} onChange={onChange} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'email',
|
id: 'email',
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
render: () => <EmailForm account={account} onChange={onChange} />,
|
render: () => <EmailForm account={account} onChange={onChange} />,
|
||||||
},
|
},
|
||||||
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
|
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
|
||||||
...(account.isAdmin
|
...(isAdmin()
|
||||||
? [
|
? [
|
||||||
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
|
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
|
||||||
{ id: 'coach', label: 'Broadcast message', render: () => <CoachMessageForm /> },
|
{ id: 'coach', label: 'Broadcast message', render: () => <CoachMessageForm /> },
|
||||||
@@ -528,9 +1163,7 @@ function CoachMessageForm() {
|
|||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void run(async () => {
|
void run(async () => {
|
||||||
const { sent } = await api<{ sent?: number }>('/api/coach-message', {
|
const { sent } = await coachMessageAll(message.trim())
|
||||||
messageContent: message,
|
|
||||||
})
|
|
||||||
setMessage('')
|
setMessage('')
|
||||||
return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.`
|
return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.`
|
||||||
})
|
})
|
||||||
@@ -571,9 +1204,10 @@ function MaintenanceForm() {
|
|||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void run(async () => {
|
void run(async () => {
|
||||||
const { connections } = await api<{ connections?: number }>('/api/maintenance', {
|
// Coerced the way the worker used to: a blank or negative box means "now".
|
||||||
startsInMinutes: Number(minutes),
|
const asked = Number(minutes)
|
||||||
})
|
const startsIn = Number.isFinite(asked) && asked > 0 ? Math.floor(asked) : 0
|
||||||
|
const { delivered: connections } = await broadcastMaintenance(startsIn)
|
||||||
return `Notified ${connections ?? 0} connected client${connections === 1 ? '' : 's'}.`
|
return `Notified ${connections ?? 0} connected client${connections === 1 ? '' : 's'}.`
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
@@ -599,6 +1233,78 @@ function MaintenanceForm() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the account's username — the name used to sign in, here and in the game.
|
||||||
|
*
|
||||||
|
* Changes are rationed (an account starts with one), so the count is stated up front and
|
||||||
|
* the form locks itself once none are left rather than letting someone spend the attempt
|
||||||
|
* finding out. The server is still the one that decides: an unknown count leaves the form
|
||||||
|
* open, and a name taken since the page loaded is refused upstream.
|
||||||
|
*
|
||||||
|
* The response is the caller's whole self account, re-read after the write, so the
|
||||||
|
* remaining count on screen is the stored one and not a guess.
|
||||||
|
*/
|
||||||
|
function UsernameForm({
|
||||||
|
account,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
account: SelfAccount
|
||||||
|
onChange: (a: SelfAccount) => void
|
||||||
|
}) {
|
||||||
|
const [username, setUsername] = useState(account.username)
|
||||||
|
const { pending, error, done, run } = useAction()
|
||||||
|
|
||||||
|
const remaining = account.availableUsernameChanges
|
||||||
|
const spent = remaining !== undefined && remaining <= 0
|
||||||
|
// Retyping the current name would be refused upstream anyway ("already taken" is
|
||||||
|
// waived for your own name, but it would still spend a change).
|
||||||
|
const unchanged = username.trim() === account.username
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Username</h2>
|
||||||
|
<p className="muted">
|
||||||
|
What you sign in with, here and in the game — and what other players see you by.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void run(async () => {
|
||||||
|
const updated = await changeUsername(username.trim())
|
||||||
|
onChange(updated)
|
||||||
|
setUsername(updated.username)
|
||||||
|
return `You are now @${updated.username}.`
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
autoComplete="username"
|
||||||
|
disabled={spent}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className="hint">
|
||||||
|
{remaining === undefined
|
||||||
|
? 'Changing your username uses up one of a limited number of changes.'
|
||||||
|
: spent
|
||||||
|
? 'You have no username changes remaining, so this can no longer be changed.'
|
||||||
|
: `You have ${remaining} username change${remaining === 1 ? '' : 's'} remaining — this one is permanent once used.`}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{done && <p className="ok">{done}</p>}
|
||||||
|
<button type="submit" disabled={pending || spent || unchanged}>
|
||||||
|
{pending ? 'Changing…' : 'Change username'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function EmailForm({
|
function EmailForm({
|
||||||
account,
|
account,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -616,7 +1322,7 @@ function EmailForm({
|
|||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void run(async () => {
|
void run(async () => {
|
||||||
await api('/api/email', { email })
|
await saveEmail(email.trim())
|
||||||
onChange({ ...account, email })
|
onChange({ ...account, email })
|
||||||
return 'Email saved.'
|
return 'Email saved.'
|
||||||
})
|
})
|
||||||
@@ -654,7 +1360,7 @@ function PasswordForm() {
|
|||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void run(async () => {
|
void run(async () => {
|
||||||
await api('/api/password', { oldPassword, newPassword })
|
await changePassword(oldPassword, newPassword)
|
||||||
setOldPassword('')
|
setOldPassword('')
|
||||||
setNewPassword('')
|
setNewPassword('')
|
||||||
return 'Password changed.'
|
return 'Password changed.'
|
||||||
|
|||||||
+186
-70
@@ -84,7 +84,7 @@ body {
|
|||||||
max-width: 880px;
|
max-width: 880px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The homepage: the stage runs full-bleed above this, so it brings its own top space. */
|
/* The homepage: the stage above brings its own top space and shares this width. */
|
||||||
.shell.home {
|
.shell.home {
|
||||||
max-width: 1040px;
|
max-width: 1040px;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
@@ -153,19 +153,42 @@ body {
|
|||||||
/* ---- The stage (hero) --------------------------------------------------- */
|
/* ---- The stage (hero) --------------------------------------------------- */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Full-bleed, edge to edge under the nav: a photo somebody actually took in game,
|
* Split down the middle: what this is and the way in on the left, a photo somebody
|
||||||
* with the headline and the way in over it. The photo is the backdrop and never the
|
* actually took in game on the right. The photo is proof and never the payload — with
|
||||||
* payload — with no photo the stage is still a solid panel carrying the same words.
|
* no photo the frame holds its space and the left half carries the same words.
|
||||||
*/
|
*/
|
||||||
.stage {
|
.stage {
|
||||||
position: relative;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 48px;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 56px 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* min-width: 0 on both halves, or the split isn't one: a `1fr` track's automatic
|
||||||
|
minimum is its content's min-content width, so a wide child (the slideshow controls,
|
||||||
|
a long unbroken credit) grows its column past 50% and takes the space out of the
|
||||||
|
other one — which is how this last read as 30/70 with the buttons crushed. */
|
||||||
|
.stage-show {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-end;
|
gap: 12px;
|
||||||
min-height: min(40vh, 320px);
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fixed shape, filled by whatever lands: screenshots arrive at any aspect ratio, and a
|
||||||
|
frame that resized per photo would jog the headline beside it on every rotation. The
|
||||||
|
ratio is landscape rather than 4:3 so the photo doesn't tower over the column beside
|
||||||
|
it — equal columns still read unequal when one is half again as tall. */
|
||||||
|
.stage-frame {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
background: var(--surface-hi);
|
background: var(--surface-hi);
|
||||||
isolation: isolate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-photo {
|
.stage-photo {
|
||||||
@@ -174,11 +197,6 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
z-index: -2;
|
|
||||||
/* Softened so the headline reads over any screenshot. Scaled up past the frame
|
|
||||||
because blur samples past the edges and would otherwise feather them. */
|
|
||||||
filter: blur(5px) saturate(1.08);
|
|
||||||
transform: scale(1.06);
|
|
||||||
animation: photo-in 0.7s ease;
|
animation: photo-in 0.7s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,40 +206,20 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrim: enough weight at the bottom to hold white text over any screenshot. */
|
|
||||||
.stage::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: -1;
|
|
||||||
background: linear-gradient(
|
|
||||||
to top,
|
|
||||||
rgb(10 7 4 / 90%) 0%,
|
|
||||||
rgb(10 7 4 / 74%) 40%,
|
|
||||||
rgb(10 7 4 / 44%) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stage-body {
|
.stage-body {
|
||||||
max-width: 1040px;
|
min-width: 0;
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 20px 28px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* No width cap here, unlike the headings below: this one shares a row with the photo,
|
||||||
|
and a headline that stops short of its column makes the split read as 30/70. */
|
||||||
.stage-title {
|
.stage-title {
|
||||||
font-family: var(--display);
|
font-family: var(--display);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: clamp(2rem, 5vw, 3.4rem);
|
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.03em;
|
||||||
color: #fff;
|
margin: 0 0 16px;
|
||||||
margin: 0 0 20px;
|
|
||||||
max-width: 15ch;
|
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
/* Bloom: a wide, soft shadow rather than a hard one, so it separates the type from
|
|
||||||
a bright screenshot without reading as a drop shadow. */
|
|
||||||
text-shadow: 0 2px 30px rgb(8 5 2 / 55%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The one place the orange carries meaning in the headline: the year it restores. */
|
/* The one place the orange carries meaning in the headline: the year it restores. */
|
||||||
@@ -230,55 +228,84 @@ body {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Credit line and slide dots, sitting under the headline on the photo itself. */
|
.stage-lede {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The signup offer under the hero buttons. Deliberately quieter than a CTA — it sits
|
||||||
|
below the downloads without competing with them — but the link itself carries the
|
||||||
|
accent so it reads as the action it is. */
|
||||||
|
.stage-alt {
|
||||||
|
font-size: 0.925rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 18px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-alt a {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-alt a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Credit line and slideshow controls, under the photo rather than on it. */
|
||||||
.stage-foot {
|
.stage-foot {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px 20px;
|
gap: 8px 20px;
|
||||||
max-width: 1040px;
|
/* Reserved even while the feed is in flight, so nothing shifts when it lands. */
|
||||||
width: 100%;
|
min-height: 28px;
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 20px 22px;
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: rgb(255 255 255 / 72%);
|
color: var(--muted);
|
||||||
text-shadow: 0 1px 12px rgb(8 5 2 / 60%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The dots are the only way to steer the stage, so each one gets a 24px target
|
/* Long usernames and room names wrap instead of widening the column. */
|
||||||
even though the mark itself is 7px. */
|
.credit {
|
||||||
.dots {
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Back / forward, with the position between them. Fixed width whatever the feed
|
||||||
|
length is — see the note on .stage-show for what a per-photo control did here. */
|
||||||
|
.steer {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: -8px -6px;
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dots button {
|
.steer button {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
width: 24px;
|
width: 28px;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: 1px solid var(--line);
|
||||||
background: none;
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dots button::after {
|
.steer button:hover {
|
||||||
content: '';
|
color: var(--text);
|
||||||
width: 7px;
|
border-color: var(--muted);
|
||||||
height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgb(255 255 255 / 34%);
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dots button:hover::after {
|
/* Tabular figures so the frame doesn't twitch as the index rolls 9 → 10. */
|
||||||
background: rgb(255 255 255 / 65%);
|
.count {
|
||||||
}
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 0 6px;
|
||||||
.dots button.on::after {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- What it is (below the stage) --------------------------------------- */
|
/* ---- What it is (below the stage) --------------------------------------- */
|
||||||
@@ -544,6 +571,40 @@ h2 {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sign in / create account, at the top of the auth card. Two of a kind, so they read as
|
||||||
|
one control rather than as two buttons competing with the orange submit below. */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--body);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button.active {
|
||||||
|
background: var(--surface-hi);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Forms -------------------------------------------------------------- */
|
/* ---- Forms -------------------------------------------------------------- */
|
||||||
|
|
||||||
label {
|
label {
|
||||||
@@ -572,6 +633,27 @@ textarea {
|
|||||||
min-height: 76px;
|
min-height: 76px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Marks a field the form will submit without. Quiet, but next to the label rather than
|
||||||
|
inside the input, so it survives the field being filled in. */
|
||||||
|
.optional {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Why a field is worth filling in, under the input it belongs to. Sits inside the label,
|
||||||
|
so it's read out with the field rather than as loose text after it. */
|
||||||
|
.hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
@@ -579,6 +661,14 @@ textarea:focus {
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Where the Turnstile iframe mounts. It brings its own chrome, so this only reserves the
|
||||||
|
space (the widget is 300×65 at normal size) — otherwise the submit button jumps down
|
||||||
|
the moment the check finishes loading. */
|
||||||
|
.turnstile {
|
||||||
|
min-height: 65px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
button[type='submit'] {
|
button[type='submit'] {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -602,6 +692,22 @@ button[type='submit']:disabled {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The cross-link under an auth form ("Don't have an account? Create one"). Sits under
|
||||||
|
the submit button, which hugs its label, so it needs its own separation from it. */
|
||||||
|
.swap {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap a {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Utilities ---------------------------------------------------------- */
|
/* ---- Utilities ---------------------------------------------------------- */
|
||||||
|
|
||||||
.big {
|
.big {
|
||||||
@@ -633,6 +739,15 @@ button[type='submit']:disabled {
|
|||||||
|
|
||||||
/* ---- Responsive --------------------------------------------------------- */
|
/* ---- Responsive --------------------------------------------------------- */
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
/* One column: the words lead, the photo follows. */
|
||||||
|
.stage {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
padding: 40px 20px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
/* One column: the copy first, then the links and the status under it. */
|
/* One column: the copy first, then the links and the status under it. */
|
||||||
.about {
|
.about {
|
||||||
@@ -643,8 +758,9 @@ button[type='submit']:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
.stage {
|
.stage-actions .cta {
|
||||||
min-height: min(38vh, 300px);
|
flex: 1 1 auto;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-links .cta {
|
.about-links .cta {
|
||||||
|
|||||||
@@ -6,6 +6,33 @@ export type Env = SharedHonoEnv & {
|
|||||||
DOMAIN: string
|
DOMAIN: string
|
||||||
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
|
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
|
||||||
ASSETS: Fetcher
|
ASSETS: Fetcher
|
||||||
|
/**
|
||||||
|
* Service binding to the `auth` worker — how the BFF reaches it, so the browser's real
|
||||||
|
* IP survives the hop (see wrangler.jsonc and src/upstream.ts `postAuthForm`).
|
||||||
|
*
|
||||||
|
* OPTIONAL because a deployed www always has it (it's declared in wrangler.jsonc) but
|
||||||
|
* standalone local dev doesn't: `vite dev` runs www on its own against a deployed
|
||||||
|
* DOMAIN, with no `auth` session to bind to. Absent, `postAuthForm` falls back to
|
||||||
|
* fetching auth.<DOMAIN> — the pre-binding behaviour, correct except for the IP.
|
||||||
|
*/
|
||||||
|
AUTH?: Fetcher
|
||||||
|
/**
|
||||||
|
* The Turnstile widget's public site key. Public by design — it ships to the browser so
|
||||||
|
* the widget can render — but it lives in the Secrets Store beside its secret, so one
|
||||||
|
* place configures signup and there's a single place to look.
|
||||||
|
*
|
||||||
|
* Resolve the value with `await env.TURNSTILE_SITE_KEY.get()`.
|
||||||
|
*/
|
||||||
|
TURNSTILE_SITE_KEY: SecretsStoreSecret
|
||||||
|
/**
|
||||||
|
* The Turnstile widget's secret key — the one that turns a widget token into a verdict.
|
||||||
|
* Same shared account-level store as JWT_SECRET; the store id is spliced into
|
||||||
|
* wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
|
||||||
|
*
|
||||||
|
* Store values survive a deploy, so both are created once and left alone. Either one
|
||||||
|
* failing to resolve closes web signup — see src/turnstile.ts.
|
||||||
|
*/
|
||||||
|
TURNSTILE_SECRET_KEY: SecretsStoreSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Variables can be extended */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export const DISCORD_INVITE = 'https://discord.gg/HhmMAKhrz'
|
|||||||
/** Where the stage's "Download for PC" button goes: the client's release listing. */
|
/** Where the stage's "Download for PC" button goes: the client's release listing. */
|
||||||
export const DOWNLOAD_URL = 'https://github.com/djdevin/recflare-client/releases'
|
export const DOWNLOAD_URL = 'https://github.com/djdevin/recflare-client/releases'
|
||||||
|
|
||||||
|
/** The stage's "Download for Quest" button: the build's listing on the Meta store. */
|
||||||
|
export const QUEST_DOWNLOAD_URL = 'https://www.meta.com/s/22O3QO7ytn'
|
||||||
|
|
||||||
/** The public source repo, linked from the homepage and footer. */
|
/** The public source repo, linked from the homepage and footer. */
|
||||||
export const SOURCE_REPO = 'https://github.com/djdevin/recflare'
|
export const SOURCE_REPO = 'https://github.com/djdevin/recflare'
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,14 @@ import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL, SOURCE_REPO } from './links'
|
|||||||
* the claim Privacy.2 is judged on, and it goes stale the moment a worker stores
|
* the claim Privacy.2 is judged on, and it goes stale the moment a worker stores
|
||||||
* something new.
|
* something new.
|
||||||
*
|
*
|
||||||
* "How you sign in" describes Meta SSO (PlatformType.Oculus), which the auth worker
|
* "How you sign in" describes Meta SSO (PlatformType.Oculus), now implemented in
|
||||||
* still stubs — see the FAKE_OCULUS_CACHED_LOGIN branch in apps/auth/src/auth.app.ts.
|
* apps/auth/src/meta-nonce.ts. What that integration actually sends Meta is the login
|
||||||
* When that lands, check the text still matches what the integration actually requests
|
* nonce plus the user id it is claimed for, and all it gets back is valid/not valid —
|
||||||
* from Meta: Privacy.2 asks for extra detail about platform features specifically, and
|
* so the disclosure's claim that Meta "learns that a sign-in happened" is right, but it
|
||||||
* the same disclosure has to agree with the Data Use Checkup filed for the app.
|
* over-discloses on two points that should be squared with the Data Use Checkup filed
|
||||||
|
* for the app: we do NOT retrieve a display name (only the user id is stored, see
|
||||||
|
* accounts-db.ts), and nonce validation does not check app entitlement. Privacy.2 asks
|
||||||
|
* for extra detail about platform features specifically, so keep this exact.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Last substantive revision, shown in the header. Bump when the text changes. */
|
/** Last substantive revision, shown in the header. Bump when the text changes. */
|
||||||
|
|||||||
@@ -1,53 +1,226 @@
|
|||||||
import { SELF } from 'cloudflare:test'
|
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||||
import { expect, it } from 'vitest'
|
import { beforeAll, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { DOCUMENTED_SERVICES } from '../../docs'
|
import { DOCUMENTED_SERVICES } from '../../docs'
|
||||||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
||||||
|
import { turnstileKeys } from '../../turnstile'
|
||||||
|
import { postAuthForm, readAuthError } from '../../upstream'
|
||||||
|
|
||||||
it('rejects unauthenticated account reads', async () => {
|
import type { Env } from '../../context'
|
||||||
const res = await SELF.fetch('https://example.com/api/me')
|
|
||||||
expect(res.status).toBe(401)
|
declare module 'cloudflare:test' {
|
||||||
expect(await res.json()).toEqual({ error: 'not signed in' })
|
interface ProvidedEnv extends Env {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turnstile's documented always-passes test keypair, seeded into the LOCAL Secrets Store
|
||||||
|
// so the bindings resolve — the same way every other worker's tests seed JWT_SECRET. It
|
||||||
|
// stands in for the two account-level secrets a deployed www reads, and it's what OPENS
|
||||||
|
// signup (see src/turnstile.ts): without it every signup test would test the closed door.
|
||||||
|
const TEST_SITE_KEY = '1x00000000000000000000AA'
|
||||||
|
const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA'
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY)
|
||||||
|
await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_KEY)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refuses manual signups (disabled)', async () => {
|
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
|
||||||
|
// the pass path can't be tested here (it would call Cloudflare's siteverify for real).
|
||||||
|
//
|
||||||
|
// The hostnames matter as much as the key: the SPA calls auth/accounts/api/notify
|
||||||
|
// DIRECTLY (as rec.net's site did), and this is the only place it learns where they are.
|
||||||
|
// A build with them missing can't sign anyone in.
|
||||||
|
it('advertises signup and where the other workers live', async () => {
|
||||||
|
const res = await SELF.fetch('https://example.com/api/config')
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
// Read through the Secrets Store binding, from the value seeded above.
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
signupEnabled: true,
|
||||||
|
turnstileSiteKey: TEST_SITE_KEY,
|
||||||
|
hosts: {
|
||||||
|
auth: 'https://auth.rec.example.com',
|
||||||
|
accounts: 'https://accounts.rec.example.com',
|
||||||
|
api: 'https://api.rec.example.com',
|
||||||
|
img: 'https://img.rec.example.com',
|
||||||
|
notify: 'https://notify.rec.example.com',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// The BFF proxies are gone: the browser calls those workers itself. Pinned because
|
||||||
|
// nothing else would fail if one were left behind — a stale proxy keeps working, it just
|
||||||
|
// re-creates the maintenance burden (and the shared-IP bug) this removed. `/api/signup`
|
||||||
|
// is the deliberate exception, and it's covered below.
|
||||||
|
it('no longer proxies the endpoints the game already serves', async () => {
|
||||||
|
for (const path of [
|
||||||
|
'/api/me',
|
||||||
|
'/api/login',
|
||||||
|
'/api/logout',
|
||||||
|
'/api/username',
|
||||||
|
'/api/email',
|
||||||
|
'/api/password',
|
||||||
|
'/api/maintenance',
|
||||||
|
'/api/coach-message',
|
||||||
|
'/api/slideshow',
|
||||||
|
]) {
|
||||||
|
const res = await SELF.fetch(`https://example.com${path}`, { method: 'POST' })
|
||||||
|
// Falls through to the SPA catch-all, which has no ASSETS binding under test.
|
||||||
|
expect(res.status, path).toBe(404)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// The keypair is the on/off switch for signup, so a www whose keys don't resolve must
|
||||||
|
// report it closed — that's the state a fresh deploy starts in, before the operator
|
||||||
|
// creates the two secrets. Checked directly because the real bindings are seeded for the
|
||||||
|
// fetch tests above.
|
||||||
|
//
|
||||||
|
// A store read that THROWS (secret absent, store unreachable) has to close the door the
|
||||||
|
// same way rather than surface as an error: /api/config is on the homepage's critical
|
||||||
|
// path, and a 500 there costs the whole page, not just the signup form.
|
||||||
|
it('treats an unresolvable or half-configured keypair as signup being off', async () => {
|
||||||
|
const stub = (value: string | null): SecretsStoreSecret =>
|
||||||
|
({ get: async () => value ?? '' }) as SecretsStoreSecret
|
||||||
|
const throws = (): SecretsStoreSecret =>
|
||||||
|
({
|
||||||
|
get: async () => {
|
||||||
|
throw new Error('secret not found')
|
||||||
|
},
|
||||||
|
}) as unknown as SecretsStoreSecret
|
||||||
|
|
||||||
|
const withKeys = (site: SecretsStoreSecret, secret: SecretsStoreSecret) =>
|
||||||
|
({
|
||||||
|
ENVIRONMENT: 'development',
|
||||||
|
TURNSTILE_SITE_KEY: site,
|
||||||
|
TURNSTILE_SECRET_KEY: secret,
|
||||||
|
}) as Env
|
||||||
|
|
||||||
|
await expect(turnstileKeys(withKeys(throws(), throws()))).resolves.toBeNull()
|
||||||
|
await expect(turnstileKeys(withKeys(stub('0xsite'), throws()))).resolves.toBeNull()
|
||||||
|
await expect(turnstileKeys(withKeys(throws(), stub('0xsecret')))).resolves.toBeNull()
|
||||||
|
await expect(turnstileKeys(withKeys(stub(''), stub('0xsecret')))).resolves.toBeNull()
|
||||||
|
await expect(turnstileKeys(withKeys(stub('0xsite'), stub('0xsecret')))).resolves.toEqual({
|
||||||
|
siteKey: '0xsite',
|
||||||
|
secretKey: '0xsecret',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a signup with no Turnstile token', async () => {
|
||||||
const res = await SELF.fetch('https://example.com/api/signup', {
|
const res = await SELF.fetch('https://example.com/api/signup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ password: 'whatever' }),
|
body: JSON.stringify({ password: 'whatever' }),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(403)
|
// Rejected before any upstream call, so a bot can't reach create_account by omitting it.
|
||||||
expect(await res.json()).toEqual({ error: 'Account creation is currently disabled.' })
|
expect(res.status).toBe(400)
|
||||||
|
expect(await res.json()).toEqual({ error: 'Please complete the bot check.' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires credentials to log in', async () => {
|
it('refuses a signup with no password', async () => {
|
||||||
const res = await SELF.fetch('https://example.com/api/login', {
|
const res = await SELF.fetch('https://example.com/api/signup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ username: 'alice' }),
|
body: JSON.stringify({ turnstileToken: 'dummy' }),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
expect(await res.json()).toEqual({ error: 'Username and password are required.' })
|
expect(await res.json()).toEqual({ error: 'A password is required.' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects an unauthenticated maintenance broadcast', async () => {
|
// A refused grant reaches the form as a sentence, never as the OAuth code. auth answers
|
||||||
const res = await SELF.fetch('https://example.com/api/maintenance', {
|
// `{ error: 'invalid_grant', error_description: <the actual reason> }`, and www used to
|
||||||
method: 'POST',
|
// relay that untouched — so every failed signup, including one the player could act on
|
||||||
headers: { 'content-type': 'application/json' },
|
// (the per-network cap), read simply "invalid_grant". Checked directly because the pass
|
||||||
body: JSON.stringify({ startsInMinutes: 15 }),
|
// path can't be reached from here (it would call the real auth worker).
|
||||||
})
|
it('explains a refused signup instead of relaying invalid_grant', async () => {
|
||||||
expect(res.status).toBe(401)
|
const refused = (description: string, status = 400) =>
|
||||||
expect(await res.json()).toEqual({ error: 'not signed in' })
|
new Response(JSON.stringify({ error: 'invalid_grant', error_description: description }), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const capped = await readAuthError(
|
||||||
|
refused('too many accounts created from this network'),
|
||||||
|
'signup'
|
||||||
|
)
|
||||||
|
expect(capped.status).toBe(400)
|
||||||
|
expect(capped.message).toContain('Too many accounts have already been created from your network')
|
||||||
|
// The raw pair still reaches the operator's log line.
|
||||||
|
expect(capped.upstream).toBe('invalid_grant: too many accounts created from this network')
|
||||||
|
|
||||||
|
const badPassword = await readAuthError(refused('invalid account_id or password'), 'login')
|
||||||
|
expect(badPassword.message).toBe('That username or password is incorrect.')
|
||||||
|
|
||||||
|
// A description auth grew since this table was written must not leak through as-is:
|
||||||
|
// it's written for an operator, so an unmapped one falls back to the generic sentence.
|
||||||
|
const unmapped = await readAuthError(refused('some new internal reason'), 'signup')
|
||||||
|
expect(unmapped.message).not.toContain('some new internal reason')
|
||||||
|
expect(unmapped.message).toContain('could not be created')
|
||||||
|
|
||||||
|
// Nothing about the form was wrong — auth couldn't proceed (an unset JWT_SECRET). Don't
|
||||||
|
// send them back to re-check their details, and don't answer 400 for our own fault.
|
||||||
|
const broken = await readAuthError(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'server_error',
|
||||||
|
error_description: 'token signing is not configured',
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { 'content-type': 'application/json' } }
|
||||||
|
),
|
||||||
|
'signup'
|
||||||
|
)
|
||||||
|
expect(broken.status).toBe(502)
|
||||||
|
expect(broken.message).toContain('problem on our end')
|
||||||
|
|
||||||
|
// A body from something in front of auth (an edge error page) is not JSON at all.
|
||||||
|
const html = await readAuthError(new Response('<html>502</html>', { status: 502 }), 'signup')
|
||||||
|
expect(html.status).toBe(502)
|
||||||
|
expect(html.message).toContain('problem on our end')
|
||||||
|
expect(html.upstream).toBe('HTTP 502')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects an unauthenticated coach message', async () => {
|
// The signup cap counts auth's `CF-Connecting-IP` as the account's immutable `signupIp`,
|
||||||
const res = await SELF.fetch('https://example.com/api/coach-message', {
|
// and www used to reach auth over https://auth.<DOMAIN> — a Worker subrequest, which
|
||||||
method: 'POST',
|
// re-enters the Cloudflare edge, which REPLACES that header with Cloudflare's own
|
||||||
headers: { 'content-type': 'application/json' },
|
// address. Every browser signup therefore shared one IP, and the cap (3, never decaying)
|
||||||
body: JSON.stringify({ messageContent: 'hello all' }),
|
// refused the fourth web account ever created, for everyone. The service binding skips
|
||||||
})
|
// the edge, so the header set here is the one auth reads.
|
||||||
expect(res.status).toBe(401)
|
//
|
||||||
expect(await res.json()).toEqual({ error: 'not signed in' })
|
// Checked directly rather than through /api/signup: the pass path would call Cloudflare's
|
||||||
|
// siteverify for real (see the Turnstile tests above).
|
||||||
|
it('carries the browser IP across to auth instead of losing it to the edge', async () => {
|
||||||
|
const seen: Request[] = []
|
||||||
|
const withAuth = (fetcher?: Fetcher) =>
|
||||||
|
({
|
||||||
|
DOMAIN: 'rec.example.com',
|
||||||
|
AUTH: fetcher,
|
||||||
|
}) as unknown as Env
|
||||||
|
const capture = {
|
||||||
|
fetch: async (request: Request) => {
|
||||||
|
seen.push(request)
|
||||||
|
return new Response('{}', { headers: { 'content-type': 'application/json' } })
|
||||||
|
},
|
||||||
|
} as unknown as Fetcher
|
||||||
|
|
||||||
|
await postAuthForm(
|
||||||
|
withAuth(capture),
|
||||||
|
'/connect/token',
|
||||||
|
{ grant_type: 'create_account', password: 'hunter2' },
|
||||||
|
{ clientIp: '203.0.113.7' }
|
||||||
|
)
|
||||||
|
|
||||||
|
// The binding is used in preference to the hostname, and the real IP rides along.
|
||||||
|
expect(seen).toHaveLength(1)
|
||||||
|
expect(seen[0]!.headers.get('cf-connecting-ip')).toBe('203.0.113.7')
|
||||||
|
// Still the same host/path/body auth already answers — only the transport changed.
|
||||||
|
expect(seen[0]!.url).toBe('https://auth.rec.example.com/connect/token')
|
||||||
|
const body = await seen[0]!.formData()
|
||||||
|
expect(body.get('grant_type')).toBe('create_account')
|
||||||
|
expect(body.get('password')).toBe('hunter2')
|
||||||
|
|
||||||
|
// A call with no IP to forward must not invent one: an absent header leaves auth's
|
||||||
|
// own `clientIp` empty, which SKIPS the cap, rather than counting everyone together.
|
||||||
|
// Reachable in local dev, where the edge sets no `cf-connecting-ip` to pass on.
|
||||||
|
await postAuthForm(withAuth(capture), '/connect/token', { grant_type: 'create_account' })
|
||||||
|
expect(seen[1]!.headers.get('cf-connecting-ip')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('serves the aggregated docs page with a source per documented service', async () => {
|
it('serves the aggregated docs page with a source per documented service', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { logger } from '@repo/hono-helpers'
|
||||||
|
|
||||||
|
import type { Env } from './context'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cloudflare Turnstile, the bot check in front of web signup. Two keys make a widget:
|
||||||
|
* the SITE key, which is public (it ships in the page markup so the browser can render
|
||||||
|
* the widget), and the SECRET key, which stays on the worker and is the only thing that
|
||||||
|
* can turn a widget token into a verdict. Both are held in the shared Secrets Store.
|
||||||
|
*
|
||||||
|
* The verdict is fetched server-side, here in the BFF — never from the browser, which
|
||||||
|
* would hand the secret to anyone who viewed source. The browser's only job is to carry
|
||||||
|
* the widget's token to `POST /api/signup`.
|
||||||
|
*
|
||||||
|
* Turnstile is what makes web signup safe to leave open: `auth`'s per-IP cap is the only
|
||||||
|
* other thing standing in front of the password/anonymous account path (it has no
|
||||||
|
* platform identity to count), and that cap is coarse enough that it can't be the whole
|
||||||
|
* defence. So the keypair IS the switch — no keypair, no signup (see `turnstileKeys`).
|
||||||
|
* Nothing is ever inferred from the environment, so a worker can't end up with signup
|
||||||
|
* open and no bot check behind it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Turnstile's verdict endpoint. Called from the worker only; the secret never leaves it. */
|
||||||
|
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The keypair web signup runs on, or null when there isn't one — which is what closes
|
||||||
|
* signup (`/api/config` reports it, `/api/signup` refuses). Both keys come from the
|
||||||
|
* account-level Secrets Store the whole monorepo shares (see context.ts), so they're
|
||||||
|
* resolved per request rather than read off `env` as strings.
|
||||||
|
*
|
||||||
|
* Both must resolve. Half a configuration (a site key whose secret is missing from the
|
||||||
|
* store) counts as unconfigured rather than as a widget whose token nobody can check, and
|
||||||
|
* says so in the log — it's otherwise indistinguishable from signup being deliberately
|
||||||
|
* off. A `.get()` that throws (secret absent from the store, binding not deployed, store
|
||||||
|
* unreachable) is treated the same way, so a Worker that can't read its keys closes the
|
||||||
|
* door instead of 500ing on the homepage.
|
||||||
|
*
|
||||||
|
* `.get()` caches per isolate, so changing a value in the store needs a `www` redeploy to
|
||||||
|
* take effect on a warm worker — the same caveat the shared JWT_SECRET carries.
|
||||||
|
*
|
||||||
|
* For local dev, seed the two names into the LOCAL store (miniflare's, not your account's)
|
||||||
|
* with Turnstile's documented always-passes test keypair — see apps/www/README.md. That
|
||||||
|
* pair belongs to no account and passes without a human. Deliberately not a built-in
|
||||||
|
* fallback: the same code path then runs everywhere.
|
||||||
|
*/
|
||||||
|
export async function turnstileKeys(
|
||||||
|
env: Env
|
||||||
|
): Promise<{ siteKey: string; secretKey: string } | null> {
|
||||||
|
const [siteKey, secretKey] = await Promise.all([
|
||||||
|
readSecret(env.TURNSTILE_SITE_KEY, 'TURNSTILE_SITE_KEY'),
|
||||||
|
readSecret(env.TURNSTILE_SECRET_KEY, 'TURNSTILE_SECRET_KEY'),
|
||||||
|
])
|
||||||
|
if (siteKey !== '' && secretKey !== '') return { siteKey, secretKey }
|
||||||
|
if (siteKey !== '' || secretKey !== '') {
|
||||||
|
logger.error('turnstile is half-configured, so web signup is closed', {
|
||||||
|
hasSiteKey: siteKey !== '',
|
||||||
|
hasSecretKey: secretKey !== '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Secrets Store value as a string, or '' when it can't be read. The binding is
|
||||||
|
* declared in wrangler.jsonc, so it's always present on `env`; what varies is whether the
|
||||||
|
* store actually holds the secret — a missing one throws here rather than resolving empty.
|
||||||
|
*/
|
||||||
|
async function readSecret(secret: SecretsStoreSecret, name: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return (await secret.get()) ?? ''
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('failed to read a turnstile key from the secrets store', {
|
||||||
|
secret: name,
|
||||||
|
error: String(err),
|
||||||
|
})
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turnstile's siteverify response, narrowed to the fields we act on. */
|
||||||
|
interface SiteVerifyResponse {
|
||||||
|
success?: boolean
|
||||||
|
'error-codes'?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask Turnstile whether a widget token is good. `remoteIp` is the client's real IP per
|
||||||
|
* Cloudflare (`CF-Connecting-IP`), which Turnstile cross-checks against the one that
|
||||||
|
* solved the challenge; it's omitted when absent rather than sent empty.
|
||||||
|
*
|
||||||
|
* A token is single-use, so a failed verdict means the widget has to be reset before the
|
||||||
|
* player can retry — the client does that (see the signup form).
|
||||||
|
*
|
||||||
|
* Any failure to reach Turnstile is a rejection, not a pass: this is the only bot check
|
||||||
|
* in front of signup, so a broken verdict path must not open the door.
|
||||||
|
*/
|
||||||
|
export async function verifyTurnstile(
|
||||||
|
secretKey: string,
|
||||||
|
token: string,
|
||||||
|
remoteIp?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const fields: Record<string, string> = { secret: secretKey, response: token }
|
||||||
|
if (remoteIp) fields.remoteip = remoteIp
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(SITEVERIFY_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(fields).toString(),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.error('turnstile siteverify failed', { status: res.status })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const verdict = (await res.json()) as SiteVerifyResponse
|
||||||
|
if (verdict.success !== true) {
|
||||||
|
// The codes name the reason (`invalid-input-response`, `timeout-or-duplicate`,
|
||||||
|
// `invalid-input-secret`, …) — the last of those is a misconfiguration, not a bot,
|
||||||
|
// and this log line is the only place it shows up.
|
||||||
|
logger.info('turnstile rejected a signup', { codes: verdict['error-codes'] ?? [] })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('turnstile siteverify threw', { error: String(err) })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-13
@@ -1,11 +1,13 @@
|
|||||||
|
import { authFailure } from './auth-messages'
|
||||||
|
|
||||||
|
import type { AuthAction, AuthFailure } from './auth-messages'
|
||||||
import type { Env } from './context'
|
import type { Env } from './context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The www worker is a backend-for-frontend (BFF): the browser only ever talks to
|
* Where the other workers live. Derived from the shared base domain, matching how they
|
||||||
* www, and www forwards to the `auth` and `accounts` workers server-side. That
|
* are deployed. www serves these to the SPA (`/api/config`), which calls them DIRECTLY —
|
||||||
* keeps the JWT off other origins and sidesteps CORS (those workers set no CORS
|
* the same endpoints the game uses, as rec.net's own site did. The only one www still
|
||||||
* headers). Hosts are derived from the shared base domain (`auth.<DOMAIN>`,
|
* calls itself is `auth`, for the Turnstile-gated signup grant (see `postAuthForm`).
|
||||||
* `accounts.<DOMAIN>`), matching how the workers are deployed.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const authBase = (env: Env): string => `https://auth.${env.DOMAIN}`
|
export const authBase = (env: Env): string => `https://auth.${env.DOMAIN}`
|
||||||
@@ -15,22 +17,63 @@ export const apiBase = (env: Env): string => `https://api.${env.DOMAIN}`
|
|||||||
export const imgBase = (env: Env): string => `https://img.${env.DOMAIN}`
|
export const imgBase = (env: Env): string => `https://img.${env.DOMAIN}`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST a form-urlencoded body to an upstream worker. The auth/accounts endpoints
|
* POST a form body to the `auth` worker, carrying the browser's real IP across.
|
||||||
* read their inputs via Hono's `parseBody()`, so they expect form fields (not
|
*
|
||||||
* JSON). `bearer`, when given, authenticates the caller.
|
* The browser could post `/connect/token` itself — it does exactly that to sign in — but
|
||||||
|
* not to SIGN UP: that grant is gated by Turnstile, whose secret key can't ship to a
|
||||||
|
* page. So signup goes through www, and www has to solve a problem the browser doesn't
|
||||||
|
* have: `auth` reads the caller's address from `CF-Connecting-IP` and records it as the
|
||||||
|
* account's immutable `signupIp`, and a Worker subrequest to https://auth.<DOMAIN>
|
||||||
|
* re-enters the Cloudflare edge, which REPLACES that header with Cloudflare's own
|
||||||
|
* address. Every web signup therefore recorded one shared IP, and auth's per-IP cap —
|
||||||
|
* 3 accounts, never decaying — refused the fourth web account ever created, for everybody.
|
||||||
|
*
|
||||||
|
* Going through the service binding skips the edge, so the header set here is the one
|
||||||
|
* auth reads. That is safe precisely because the edge does overwrite it on the public
|
||||||
|
* route: a game client (or the SPA signing in) posting `/connect/token` directly still
|
||||||
|
* cannot spoof its own IP, so no shared secret is needed to tell the callers apart.
|
||||||
|
*
|
||||||
|
* `clientIp` is the caller's own edge-set `cf-connecting-ip`, and must never be anything
|
||||||
|
* a browser supplied. Absent, no header is sent at all — auth's `clientIp` then reads
|
||||||
|
* empty, which SKIPS the cap rather than counting every such signup together.
|
||||||
|
*
|
||||||
|
* Falls back to the public hostname when the binding is absent (local `vite dev` — see
|
||||||
|
* `Env.AUTH`); the edge then overwrites the header again, which is the old behaviour.
|
||||||
*/
|
*/
|
||||||
export async function postForm(
|
export async function postAuthForm(
|
||||||
url: string,
|
env: Env,
|
||||||
|
path: string,
|
||||||
fields: Record<string, string>,
|
fields: Record<string, string>,
|
||||||
bearer?: string
|
opts: { bearer?: string; clientIp?: string } = {}
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'content-type': 'application/x-www-form-urlencoded',
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
}
|
}
|
||||||
if (bearer) headers.authorization = `Bearer ${bearer}`
|
if (opts.bearer) headers.authorization = `Bearer ${opts.bearer}`
|
||||||
return fetch(url, {
|
if (opts.clientIp) headers['cf-connecting-ip'] = opts.clientIp
|
||||||
|
|
||||||
|
const request = new Request(`${authBase(env)}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: new URLSearchParams(fields).toString(),
|
body: new URLSearchParams(fields).toString(),
|
||||||
})
|
})
|
||||||
|
return env.AUTH ? env.AUTH.fetch(request) : fetch(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a failed `auth` response into something worth showing. The translation itself is
|
||||||
|
* shared with the browser (see `auth-messages.ts`); this only unpacks the body. A
|
||||||
|
* non-JSON one — from something in front of auth, like an edge error page — falls
|
||||||
|
* through to the generic line for the action.
|
||||||
|
*/
|
||||||
|
export async function readAuthError(res: Response, action: AuthAction): Promise<AuthFailure> {
|
||||||
|
const parsed = (await res.json().catch(() => null)) as {
|
||||||
|
error?: unknown
|
||||||
|
error_description?: unknown
|
||||||
|
} | null
|
||||||
|
const body = parsed ?? {}
|
||||||
|
const code = typeof body.error === 'string' ? body.error : ''
|
||||||
|
const description = typeof body.error_description === 'string' ? body.error_description : ''
|
||||||
|
|
||||||
|
return authFailure(action, res.status, code, description)
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-250
@@ -1,116 +1,40 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import { withOnError } from '@repo/hono-helpers'
|
import { logger, withOnError } from '@repo/hono-helpers'
|
||||||
|
|
||||||
import { NotificationType } from '../../notify/src/notification-types'
|
import { authUnreachable } from './auth-messages'
|
||||||
import { docsPage, fetchSpec } from './docs'
|
import { docsPage, fetchSpec } from './docs'
|
||||||
import { privacyPage } from './privacy'
|
import { privacyPage } from './privacy'
|
||||||
import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream'
|
import { turnstileKeys, verifyTurnstile } from './turnstile'
|
||||||
|
import {
|
||||||
|
accountsBase,
|
||||||
|
apiBase,
|
||||||
|
authBase,
|
||||||
|
imgBase,
|
||||||
|
notifyBase,
|
||||||
|
postAuthForm,
|
||||||
|
readAuthError,
|
||||||
|
} from './upstream'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
|
||||||
import type { CookieOptions } from 'hono/utils/cookie'
|
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* www — the first frontend worker. It serves the React SPA (create account, set
|
* www — the website worker. It serves the React SPA (create account, sign in, change
|
||||||
* email, change password) and acts as a backend-for-frontend: the browser talks
|
* username/email/password) and almost nothing else: the SPA calls the SAME endpoints
|
||||||
* only to www, and www forwards to the `auth`/`accounts` workers server-side (see
|
* the game does, on `auth`/`accounts`/`api`/`notify` directly, exactly as rec.net's own
|
||||||
* `upstream.ts`). The account's JWT lives in an httpOnly cookie set here, so it's
|
* site did. Those workers answer CORS for it, and the browser holds the access token.
|
||||||
* never exposed to page JS.
|
*
|
||||||
|
* Two things stay server-side here, both because they can't work any other way:
|
||||||
|
*
|
||||||
|
* - `/api/signup`, because it's gated by Turnstile and the secret key that turns a
|
||||||
|
* widget token into a verdict cannot ship to a browser. It's also the one account
|
||||||
|
* endpoint with no game equivalent — the game never creates password accounts — so
|
||||||
|
* there's no client contract being duplicated.
|
||||||
|
* - `/api/config`, which tells the SPA the Turnstile site key and where the other
|
||||||
|
* workers live, so one client build works for any operator's domain.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Name of the httpOnly session cookie holding the account's access token. */
|
|
||||||
const SESSION_COOKIE = 'rf_token'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RecNet (4) is the web platform, stamped as the token's `platform` claim on login.
|
|
||||||
* NOT passed on signup: create_account treats an asserted platform as one to verify
|
|
||||||
* against Steam and rejects RecNet — the web signup is the (platform-less) password
|
|
||||||
* account path.
|
|
||||||
*/
|
|
||||||
const WEB_PLATFORM = '4'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Roles that unlock the admin controls in the UI. Mirrors the notify worker's
|
|
||||||
* `ADMIN_ROLES` gate — www only decides whether to *show* the controls; notify does
|
|
||||||
* the real enforcement (it verifies the token) on every call.
|
|
||||||
*/
|
|
||||||
const ADMIN_ROLES = new Set(['developer', 'moderator'])
|
|
||||||
|
|
||||||
/** Cookie flags for the session token. `secure` is dropped for local http dev. */
|
|
||||||
function sessionCookieOptions(c: Context<App>, maxAge: number): CookieOptions {
|
|
||||||
const local = c.env.ENVIRONMENT === 'development' || c.env.ENVIRONMENT === 'VITEST'
|
|
||||||
return {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: !local,
|
|
||||||
sameSite: 'Lax',
|
|
||||||
path: '/',
|
|
||||||
maxAge,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pull the session token out of the request cookie, or null when absent. */
|
|
||||||
function sessionToken(c: Context<App>): string | null {
|
|
||||||
return getCookie(c, SESSION_COOKIE) ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the session token carries an admin role. Decodes the JWT's `role` claim
|
|
||||||
* WITHOUT verifying — www holds no signing key, and this only gates whether admin UI
|
|
||||||
* is shown; the notify worker verifies the token before acting on it. A malformed
|
|
||||||
* token simply reads as "not admin".
|
|
||||||
*/
|
|
||||||
function isAdminToken(token: string): boolean {
|
|
||||||
const payload = token.split('.')[1]
|
|
||||||
if (!payload) return false
|
|
||||||
try {
|
|
||||||
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
|
|
||||||
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
|
|
||||||
const claims = JSON.parse(atob(padded)) as { role?: unknown }
|
|
||||||
return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string))
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Relay an upstream worker's JSON response back to the browser unchanged. */
|
|
||||||
async function relay(c: Context<App>, res: Response) {
|
|
||||||
const body = await res.text()
|
|
||||||
return c.body(body, res.status as never, {
|
|
||||||
'content-type': res.headers.get('content-type') ?? 'application/json',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exchange an auth `/connect/token` response for a session: persist the returned
|
|
||||||
* access token in the httpOnly cookie, then return the caller's self account
|
|
||||||
* (fetched from the accounts worker with the fresh token).
|
|
||||||
*/
|
|
||||||
async function establishSession(c: Context<App>, tokenResponse: Response) {
|
|
||||||
if (!tokenResponse.ok) return relay(c, tokenResponse)
|
|
||||||
|
|
||||||
const token = (await tokenResponse.json()) as { access_token?: string; expires_in?: number }
|
|
||||||
if (!token.access_token) {
|
|
||||||
return c.json({ error: 'auth did not return an access token' }, 502)
|
|
||||||
}
|
|
||||||
|
|
||||||
setCookie(
|
|
||||||
c,
|
|
||||||
SESSION_COOKIE,
|
|
||||||
token.access_token,
|
|
||||||
sessionCookieOptions(c, token.expires_in ?? 3600)
|
|
||||||
)
|
|
||||||
|
|
||||||
const me = await fetch(`${accountsBase(c.env)}/account/me`, {
|
|
||||||
headers: { authorization: `Bearer ${token.access_token}` },
|
|
||||||
})
|
|
||||||
if (!me.ok) return c.json({ error: 'failed to load account after auth' }, 502)
|
|
||||||
const account = (await me.json()) as Record<string, unknown>
|
|
||||||
return c.json({ account: { ...account, isAdmin: isAdminToken(token.access_token) } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = new Hono<App>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -124,163 +48,98 @@ const app = new Hono<App>()
|
|||||||
|
|
||||||
.onError(withOnError())
|
.onError(withOnError())
|
||||||
|
|
||||||
// ---- BFF API ------------------------------------------------------------
|
// ---- Site config --------------------------------------------------------
|
||||||
|
|
||||||
// Manual web signups are disabled for now — accounts are created via the game /
|
// What the SPA has to know before it can do anything: whether web signup is open,
|
||||||
// platform, not the website. Kept as an explicit closed endpoint (rather than
|
// the Turnstile site key to mount its widget with, and the hostnames of the workers
|
||||||
// removed) so a direct POST is refused too, not just hidden in the UI. To reopen,
|
// it calls directly. All three are served rather than baked into the client build so
|
||||||
// forward a platform-less `grant_type=create_account` to auth and start a session
|
// one build works for any operator. The site key is public (it ships in the widget
|
||||||
// (see git history), and restore the SignupForm in the client.
|
// markup either way); the secret never leaves the worker.
|
||||||
.post('/api/signup', (c) => c.json({ error: 'Account creation is currently disabled.' }, 403))
|
.get('/api/config', async (c) => {
|
||||||
|
const keys = await turnstileKeys(c.env)
|
||||||
// Log in with a username + password, then start a session. The auth password grant
|
|
||||||
// resolves the account by `username` (case-insensitive) — web players sign in with
|
|
||||||
// their username, not the numeric account id.
|
|
||||||
.post('/api/login', async (c) => {
|
|
||||||
const { username, password } = await c.req
|
|
||||||
.json<{ username?: string; password?: string }>()
|
|
||||||
.catch(() => ({}) as { username?: string; password?: string })
|
|
||||||
if (!username || !password) {
|
|
||||||
return c.json({ error: 'Username and password are required.' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await postForm(`${authBase(c.env)}/connect/token`, {
|
|
||||||
grant_type: 'password',
|
|
||||||
username,
|
|
||||||
platform: WEB_PLATFORM,
|
|
||||||
password,
|
|
||||||
})
|
|
||||||
return establishSession(c, res)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Clear the session cookie.
|
|
||||||
.post('/api/logout', (c) => {
|
|
||||||
deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
|
||||||
return c.json({ success: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Public homepage slideshow. Proxies the api worker's (public) slideshow feed and
|
|
||||||
// projects each image to a full img.<domain> URL the browser can load directly, so
|
|
||||||
// the page JS never has to know the upstream hosts. No session required.
|
|
||||||
.get('/api/slideshow', async (c) => {
|
|
||||||
const res = await fetch(`${apiBase(c.env)}/api/images/v1/slideshow`)
|
|
||||||
if (!res.ok) return relay(c, res)
|
|
||||||
const data = (await res.json()) as {
|
|
||||||
Images?: Array<{ ImageName: string; Username: string; RoomName: string | null }>
|
|
||||||
ValidTill?: string
|
|
||||||
}
|
|
||||||
const images = (data.Images ?? []).map((i) => ({
|
|
||||||
url: `${imgBase(c.env)}/${i.ImageName}`,
|
|
||||||
username: i.Username,
|
|
||||||
roomName: i.RoomName,
|
|
||||||
}))
|
|
||||||
return c.json({ images, validTill: data.ValidTill ?? null })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Current session's self account (used to restore UI state on page load).
|
|
||||||
.get('/api/me', async (c) => {
|
|
||||||
const token = sessionToken(c)
|
|
||||||
if (!token) return c.json({ error: 'not signed in' }, 401)
|
|
||||||
|
|
||||||
const res = await fetch(`${accountsBase(c.env)}/account/me`, {
|
|
||||||
headers: { authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
// Token expired/invalid — drop the stale cookie so the client shows sign-in.
|
|
||||||
if (res.status === 401) {
|
|
||||||
deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
|
||||||
return c.json({ error: 'session expired' }, 401)
|
|
||||||
}
|
|
||||||
if (!res.ok) return relay(c, res)
|
|
||||||
// Augment the self account with whether this session may use admin controls,
|
|
||||||
// read from the token's role claim (see isAdminToken).
|
|
||||||
const account = (await res.json()) as Record<string, unknown>
|
|
||||||
return c.json({ ...account, isAdmin: isAdminToken(token) })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set the signed-in account's email.
|
|
||||||
.post('/api/email', async (c) => {
|
|
||||||
const token = sessionToken(c)
|
|
||||||
if (!token) return c.json({ error: 'not signed in' }, 401)
|
|
||||||
|
|
||||||
const { email } = await c.req.json<{ email?: string }>().catch(() => ({}) as { email?: string })
|
|
||||||
if (!email) return c.json({ error: 'An email is required.' }, 400)
|
|
||||||
|
|
||||||
const res = await postForm(`${accountsBase(c.env)}/account/me/email`, { email }, token)
|
|
||||||
return relay(c, res)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Change the signed-in account's password (current password required).
|
|
||||||
.post('/api/password', async (c) => {
|
|
||||||
const token = sessionToken(c)
|
|
||||||
if (!token) return c.json({ error: 'not signed in' }, 401)
|
|
||||||
|
|
||||||
const { oldPassword, newPassword } = await c.req
|
|
||||||
.json<{ oldPassword?: string; newPassword?: string }>()
|
|
||||||
.catch(() => ({}) as { oldPassword?: string; newPassword?: string })
|
|
||||||
if (!newPassword) return c.json({ error: 'A new password is required.' }, 400)
|
|
||||||
|
|
||||||
const res = await postForm(
|
|
||||||
`${authBase(c.env)}/account/me/changepassword`,
|
|
||||||
{ oldPassword: oldPassword ?? '', newPassword },
|
|
||||||
token
|
|
||||||
)
|
|
||||||
return relay(c, res)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Broadcast a ServerMaintenance countdown to every connected client. Forwards the
|
|
||||||
// session token to the notify worker, which enforces the admin-role gate — so a
|
|
||||||
// non-admin session is rejected upstream (403) even though www shows no button.
|
|
||||||
// The notification frame carries `Msg: { StartsInMinutes }`, matching the client's
|
|
||||||
// ServerMaintenance handler; the response mirrors the reference maintenance API.
|
|
||||||
.post('/api/maintenance', async (c) => {
|
|
||||||
const token = sessionToken(c)
|
|
||||||
if (!token) return c.json({ error: 'not signed in' }, 401)
|
|
||||||
|
|
||||||
const { startsInMinutes } = await c.req
|
|
||||||
.json<{ startsInMinutes?: number }>()
|
|
||||||
.catch(() => ({}) as { startsInMinutes?: number })
|
|
||||||
const minutes = Number(startsInMinutes)
|
|
||||||
const startsIn = Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : 0
|
|
||||||
|
|
||||||
const res = await fetch(`${notifyBase(c.env)}/internal/broadcast`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
||||||
body: JSON.stringify({
|
|
||||||
notificationType: NotificationType.ServerMaintenance,
|
|
||||||
data: { StartsInMinutes: startsIn },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!res.ok) return relay(c, res)
|
|
||||||
|
|
||||||
const result = (await res.json()) as { delivered?: number }
|
|
||||||
return c.json({
|
return c.json({
|
||||||
success: true,
|
signupEnabled: keys !== null,
|
||||||
starts_in_minutes: startsIn,
|
turnstileSiteKey: keys?.siteKey ?? null,
|
||||||
connections: result.delivered ?? 0,
|
hosts: {
|
||||||
|
auth: authBase(c.env),
|
||||||
|
accounts: accountsBase(c.env),
|
||||||
|
api: apiBase(c.env),
|
||||||
|
img: imgBase(c.env),
|
||||||
|
notify: notifyBase(c.env),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Send a coach/system message to every online player. Like maintenance, this
|
// ---- Signup -------------------------------------------------------------
|
||||||
// forwards the session token to notify, which enforces the admin-role gate.
|
|
||||||
.post('/api/coach-message', async (c) => {
|
|
||||||
const token = sessionToken(c)
|
|
||||||
if (!token) return c.json({ error: 'not signed in' }, 401)
|
|
||||||
|
|
||||||
const { messageContent } = await c.req
|
// Create an account from the website, behind a Turnstile bot check. The check is what
|
||||||
.json<{ messageContent?: string }>()
|
// makes this safe to leave open: `auth` binds no platform identity to a web account, so
|
||||||
.catch(() => ({}) as { messageContent?: string })
|
// its per-IP cap (3, never decaying) is the only other thing in front of this path —
|
||||||
const content = typeof messageContent === 'string' ? messageContent.trim() : ''
|
// and `auth` has no bot check of its own, which is why this one endpoint can't simply
|
||||||
if (content === '') return c.json({ error: 'A message is required.' }, 400)
|
// be called from the browser like the rest.
|
||||||
|
//
|
||||||
|
// Deliberately passes NO `platform`: create_account treats an asserted platform as one
|
||||||
|
// to verify against Steam and would reject RecNet, so this is the platform-less
|
||||||
|
// password-account path. The username is auto-assigned by auth — players don't pick one.
|
||||||
|
//
|
||||||
|
// On success auth's token response is returned VERBATIM, so the SPA stores it the same
|
||||||
|
// way it stores the one it gets from calling `/connect/token` itself to sign in. The
|
||||||
|
// account's email, when the player gave one, is saved by the client afterwards with
|
||||||
|
// that token — `create_account` takes no email, and `accounts` owns the field.
|
||||||
|
.post('/api/signup', async (c) => {
|
||||||
|
// No usable keypair means signup is closed rather than unprotected (see turnstile.ts).
|
||||||
|
const keys = await turnstileKeys(c.env)
|
||||||
|
if (!keys) return c.json({ error: 'Account creation is currently disabled.' }, 403)
|
||||||
|
|
||||||
const res = await fetch(`${notifyBase(c.env)}/internal/coach-message-all`, {
|
type SignupBody = { password?: string; turnstileToken?: string }
|
||||||
method: 'POST',
|
const { password, turnstileToken } = await c.req
|
||||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
.json<SignupBody>()
|
||||||
body: JSON.stringify({ messageContent: content }),
|
.catch(() => ({}) as SignupBody)
|
||||||
})
|
if (!password) return c.json({ error: 'A password is required.' }, 400)
|
||||||
if (!res.ok) return relay(c, res)
|
if (!turnstileToken) return c.json({ error: 'Please complete the bot check.' }, 400)
|
||||||
|
|
||||||
const result = (await res.json()) as { sent?: number }
|
// The IP Turnstile cross-checks the token against — set by the edge, so the client
|
||||||
return c.json({ success: true, sent: result.sent ?? 0 })
|
// can't spoof it (unlike X-Forwarded-For). `auth` records the same header as the
|
||||||
|
// account's signup IP, which is why it's forwarded to the grant below rather than
|
||||||
|
// left to the edge: see `postAuthForm`.
|
||||||
|
const clientIp = c.req.header('cf-connecting-ip')
|
||||||
|
const verified = await verifyTurnstile(keys.secretKey, turnstileToken, clientIp)
|
||||||
|
// A token is single-use, so the client resets its widget before letting them retry.
|
||||||
|
if (!verified) return c.json({ error: 'Bot check failed. Please try again.' }, 403)
|
||||||
|
|
||||||
|
// A throw here is auth being unreachable, not a rejected signup — answered as such
|
||||||
|
// rather than falling through to the generic 500 handler, whose "internal server
|
||||||
|
// error" tells the player nothing about whether they now have an account (they don't:
|
||||||
|
// nothing was created).
|
||||||
|
const res = await postAuthForm(
|
||||||
|
c.env,
|
||||||
|
'/connect/token',
|
||||||
|
{ grant_type: 'create_account', password },
|
||||||
|
{ clientIp }
|
||||||
|
).catch(() => null)
|
||||||
|
if (res === null) {
|
||||||
|
logger.error('could not reach auth to create an account')
|
||||||
|
return c.json({ error: authUnreachable('signup') }, 502)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refused grant is translated (see `readAuthError`) rather than relayed: auth
|
||||||
|
// answers the OAuth shape, whose `error` is always a code like `invalid_grant`, and
|
||||||
|
// that code is what the form used to show for every failure — including the
|
||||||
|
// per-network cap, which the player could otherwise understand. Sign-in doesn't need
|
||||||
|
// this (the browser calls `/connect/token` itself and reads `error_description`), but
|
||||||
|
// the cap is reachable only from signup, so the sentences live on this path.
|
||||||
|
if (!res.ok) {
|
||||||
|
const failure = await readAuthError(res, 'signup')
|
||||||
|
logger.info('auth refused a signup', { status: res.status, upstream: failure.upstream })
|
||||||
|
return c.json({ error: failure.message }, failure.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = (await res.json().catch(() => null)) as { access_token?: string } | null
|
||||||
|
if (!token?.access_token) {
|
||||||
|
logger.error('auth answered a signup with no access_token')
|
||||||
|
return c.json({ error: authUnreachable('signup') }, 502)
|
||||||
|
}
|
||||||
|
return c.json(token)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Privacy policy -----------------------------------------------------
|
// ---- Privacy policy -----------------------------------------------------
|
||||||
|
|||||||
@@ -6,8 +6,28 @@ export default defineConfig({
|
|||||||
cloudflareTest({
|
cloudflareTest({
|
||||||
wrangler: { configPath: `${__dirname}/wrangler.jsonc` },
|
wrangler: { configPath: `${__dirname}/wrangler.jsonc` },
|
||||||
miniflare: {
|
miniflare: {
|
||||||
|
// Stands in for the `auth` service binding wrangler.jsonc declares — the real
|
||||||
|
// worker isn't part of this project's test run, and without an override the
|
||||||
|
// runtime refuses to start ("no such service is defined"). It echoes the
|
||||||
|
// forwarded `cf-connecting-ip` back so a test can assert the browser's IP
|
||||||
|
// actually survives the hop (see src/upstream.ts `postAuthForm`); every other
|
||||||
|
// auth call in the tests fails before reaching it.
|
||||||
|
serviceBindings: {
|
||||||
|
AUTH: (request: Request) =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: request.headers.get('cf-connecting-ip') ?? 'no ip',
|
||||||
|
}),
|
||||||
|
{ status: 400, headers: { 'content-type': 'application/json' } }
|
||||||
|
),
|
||||||
|
},
|
||||||
bindings: {
|
bindings: {
|
||||||
ENVIRONMENT: 'VITEST',
|
ENVIRONMENT: 'VITEST',
|
||||||
|
// The Turnstile keypair is NOT bound here: both keys come from the Secrets
|
||||||
|
// Store now, and the tests seed the local store with the test pair (see
|
||||||
|
// src/test/integration/api.test.ts). A plain binding of the same name would
|
||||||
|
// shadow the store binding with a string.
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
+47
-5
@@ -13,7 +13,8 @@
|
|||||||
// routing entirely, so the Worker runs ONLY for the listed patterns and every other
|
// routing entirely, so the Worker runs ONLY for the listed patterns and every other
|
||||||
// path is served assets-first (with the SPA fallback → index.html). It must therefore
|
// path is served assets-first (with the SPA fallback → index.html). It must therefore
|
||||||
// list EVERY route the Worker handles, not just the new ones — otherwise `/api/*`
|
// list EVERY route the Worker handles, not just the new ones — otherwise `/api/*`
|
||||||
// falls through to index.html and the whole BFF breaks. Why it's needed at all: with
|
// falls through to index.html and both signup and the site config break (the SPA
|
||||||
|
// reads the other workers' hostnames from `/api/config`). Why it's needed at all: with
|
||||||
// SPA `not_found_handling`, a top-level *navigation* to a non-asset path (browsers
|
// SPA `not_found_handling`, a top-level *navigation* to a non-asset path (browsers
|
||||||
// send `Sec-Fetch-Mode: navigate`) is served index.html WITHOUT invoking the Worker,
|
// send `Sec-Fetch-Mode: navigate`) is served index.html WITHOUT invoking the Worker,
|
||||||
// so a browser hitting `/docs` got the homepage. Keep this in sync with the Worker's
|
// so a browser hitting `/docs` got the homepage. Keep this in sync with the Worker's
|
||||||
@@ -28,6 +29,46 @@
|
|||||||
"not_found_handling": "single-page-application",
|
"not_found_handling": "single-page-application",
|
||||||
"run_worker_first": ["/api/*", "/docs", "/docs/openapi/*", "/privacy"]
|
"run_worker_first": ["/api/*", "/docs", "/docs/openapi/*", "/privacy"]
|
||||||
},
|
},
|
||||||
|
// The Turnstile keypair guarding web signup, out of the same account-level Secrets
|
||||||
|
// Store every other worker binds for JWT_SECRET — values live there, never in this
|
||||||
|
// file. The "local" store_id placeholder is replaced with RECFLARE_SECRETS_STORE at
|
||||||
|
// deploy time, exactly as it is for the other workers.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
//
|
||||||
|
// Creating both is what OPENS signup; if either can't be resolved it stays closed, so
|
||||||
|
// an operator who skips this gets no signup rather than an unprotected one. The SITE
|
||||||
|
// key is public (it ships to the browser to render the widget) and is kept here beside
|
||||||
|
// its secret so one place configures signup. See src/turnstile.ts.
|
||||||
|
"secrets_store_secrets": [
|
||||||
|
{
|
||||||
|
"binding": "TURNSTILE_SITE_KEY",
|
||||||
|
"store_id": "local",
|
||||||
|
"secret_name": "TURNSTILE_SITE_KEY"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"binding": "TURNSTILE_SECRET_KEY",
|
||||||
|
"store_id": "local",
|
||||||
|
"secret_name": "TURNSTILE_SECRET_KEY"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// The `auth` worker, reached directly instead of over its public hostname. This is
|
||||||
|
// about the CLIENT IP, not latency: a Worker subrequest to https://auth.<DOMAIN>
|
||||||
|
// re-enters the Cloudflare edge, which overwrites CF-Connecting-IP with Cloudflare's
|
||||||
|
// own address — so auth recorded the SAME `signupIp` for every browser signup and its
|
||||||
|
// per-IP cap (3 by default) locked out every player after the third account ever
|
||||||
|
// created. A service binding skips the edge, so the real browser IP www forwards on
|
||||||
|
// that header survives (see src/upstream.ts `postAuthForm`).
|
||||||
|
//
|
||||||
|
// Only auth is bound, and only for SIGNUP — the one call this worker still makes on
|
||||||
|
// the browser's behalf, because Turnstile's secret key can't ship to a page. Sign-in,
|
||||||
|
// the profile mutations and the photo feed are posted by the browser straight to
|
||||||
|
// auth/accounts/api/notify (as rec.net's own site did), where the edge sets the real
|
||||||
|
// client IP for free.
|
||||||
|
"services": [{ "binding": "AUTH", "service": "auth" }],
|
||||||
"upload_source_maps": true,
|
"upload_source_maps": true,
|
||||||
"observability": {
|
"observability": {
|
||||||
"logs": {
|
"logs": {
|
||||||
@@ -38,10 +79,11 @@
|
|||||||
"vars": {
|
"vars": {
|
||||||
"ENVIRONMENT": "development", // overridden during deployment
|
"ENVIRONMENT": "development", // overridden during deployment
|
||||||
"SENTRY_RELEASE": "unknown", // overridden during deployment
|
"SENTRY_RELEASE": "unknown", // overridden during deployment
|
||||||
// Base domain the auth/accounts hosts are derived from (auth.<DOMAIN>,
|
// Base domain every worker hostname is derived from (auth.<DOMAIN>,
|
||||||
// accounts.<DOMAIN>). Overridden at deploy time with the real RECFLARE_DOMAIN
|
// accounts.<DOMAIN>, …). Overridden at deploy time with the real RECFLARE_DOMAIN
|
||||||
// (see run-wrangler-deploy). For local dev, point this at a deployed domain so
|
// (see run-wrangler-deploy). www serves these to the SPA via `/api/config`, which
|
||||||
// the BFF proxy can reach the auth/accounts workers.
|
// is how one client build works for any operator. For local dev, point it at a
|
||||||
|
// deployed domain so the page has real workers to call.
|
||||||
"DOMAIN": "rec.example.com"
|
"DOMAIN": "rec.example.com"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,8 @@
|
|||||||
"@cloudflare/workers-types": "4.20260630.1",
|
"@cloudflare/workers-types": "4.20260630.1",
|
||||||
"@repo/tools": "workspace:*",
|
"@repo/tools": "workspace:*",
|
||||||
"@repo/typescript-config": "workspace:*"
|
"@repo/typescript-config": "workspace:*"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"isemail": "^3.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,18 +11,20 @@
|
|||||||
* it from `@repo/domain` (each uses the subset it needs).
|
* it from `@repo/domain` (each uses the subset it needs).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations 0001_accounts + 0002_avatar, sans seed INSERTs). */
|
/**
|
||||||
|
* Schema DDL — the head schema, i.e. what the table looks like after every migration
|
||||||
|
* (0001_accounts + 0002_avatar, sans seed INSERTs; 0004 added a `platform_id` generated
|
||||||
|
* column and 0008 dropped it again, so it appears here in neither form).
|
||||||
|
*/
|
||||||
export const SCHEMA_DDL: string[] = [
|
export const SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS account (
|
`CREATE TABLE IF NOT EXISTS account (
|
||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
avatar TEXT,
|
avatar TEXT,
|
||||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
||||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL,
|
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL
|
||||||
platform_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.platformId')) VIRTUAL
|
|
||||||
)`,
|
)`,
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON account (username_lower)`,
|
`CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON account (username_lower)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_accounts_platform_id ON account (platform_id)`,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Client-facing account shape (camelCase, exactly as the client's AccountDTO). */
|
/** Client-facing account shape (camelCase, exactly as the client's AccountDTO). */
|
||||||
@@ -37,12 +39,14 @@ export interface Account {
|
|||||||
identityFlags: number
|
identityFlags: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
/**
|
/**
|
||||||
* The platform-native identity linked to this account (e.g. a SteamID64 for
|
* The account's PRIMARY platform identity — the first one linked (e.g. a SteamID64
|
||||||
* platform 0). Stored as a STRING on purpose — a SteamID64 exceeds 2^53 and
|
* for platform 0). Stored as a STRING on purpose: a SteamID64 exceeds 2^53 and
|
||||||
* would lose precision as a JS number. Set at account creation from the login's
|
* would lose precision as a JS number.
|
||||||
* `platform_id`. A cached login is authorized ONLY to the account whose stored
|
*
|
||||||
* `platformId` matches the (platform_auth-ticket-proven) platform id presented,
|
* An account can be reachable from SEVERAL platform identities (a PC and a headset),
|
||||||
* so no one but that platform user can log into the account.
|
* and those live in the `auth` worker's `platform_account` table — NOT here. Logins
|
||||||
|
* are authorized against that table alone; this pair is what the account DTO and a
|
||||||
|
* refreshed token's claims report, and it never gains a second value.
|
||||||
*/
|
*/
|
||||||
platformId?: string
|
platformId?: string
|
||||||
/** PlatformType int (0 = Steam) that `platformId` belongs to. */
|
/** PlatformType int (0 = Steam) that `platformId` belongs to. */
|
||||||
@@ -216,23 +220,6 @@ export async function searchAccounts(
|
|||||||
return parseAll(results)
|
return parseAll(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Accounts linked to a platform-native id (e.g. a SteamID64), for the cached-login
|
|
||||||
* account picker. Backed by the indexed `platform_id` generated column. Empty id
|
|
||||||
* yields no matches (avoids matching every account whose `platformId` is null).
|
|
||||||
*/
|
|
||||||
export async function getAccountsByPlatformId(
|
|
||||||
db: D1Database,
|
|
||||||
platformId: string
|
|
||||||
): Promise<Account[]> {
|
|
||||||
if (platformId === '') return []
|
|
||||||
const { results } = await db
|
|
||||||
.prepare('SELECT data FROM account WHERE platform_id = ?1')
|
|
||||||
.bind(platformId)
|
|
||||||
.all<AccountRow>()
|
|
||||||
return parseAll(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accounts last seen on a given device (the client-supplied `device_id` auth records
|
* Accounts last seen on a given device (the client-supplied `device_id` auth records
|
||||||
* at login). An empty id yields no matches (avoids matching every account with no
|
* at login). An empty id yields no matches (avoids matching every account with no
|
||||||
@@ -240,8 +227,9 @@ export async function getAccountsByPlatformId(
|
|||||||
*
|
*
|
||||||
* Reads `deviceId` straight out of the JSON blob, so this is a table scan — no
|
* Reads `deviceId` straight out of the JSON blob, so this is a table scan — no
|
||||||
* generated column, no migration. Fine at our account count and for the occasional
|
* generated column, no migration. Fine at our account count and for the occasional
|
||||||
* linkup lookup this exists for; if it ever gets hot, promote `deviceId` to an
|
* linkup lookup this exists for; if it ever gets hot, promote `deviceId` to an indexed
|
||||||
* indexed generated column the way `platformId` is (see the 0004 migration).
|
* generated column (migration 0004 did that for `platformId`, and 0008 undid it once
|
||||||
|
* nothing queried it — that pair is the recipe both ways).
|
||||||
*
|
*
|
||||||
* The device id is unverified client input, so treat a match as a *hint* (these
|
* The device id is unverified client input, so treat a match as a *hint* (these
|
||||||
* accounts share a device) and never as proof of identity.
|
* accounts share a device) and never as proof of identity.
|
||||||
@@ -258,7 +246,9 @@ export async function getAccountsByDeviceId(db: D1Database, deviceId: string): P
|
|||||||
/** Record the account's most recent successful login time (ISO-8601). */
|
/** Record the account's most recent successful login time (ISO-8601). */
|
||||||
export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise<void> {
|
export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.prepare("UPDATE account SET data = json_set(data, '$.lastLoginTime', ?2) WHERE account_id = ?1")
|
.prepare(
|
||||||
|
"UPDATE account SET data = json_set(data, '$.lastLoginTime', ?2) WHERE account_id = ?1"
|
||||||
|
)
|
||||||
.bind(id, time)
|
.bind(id, time)
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
@@ -295,9 +285,7 @@ export async function setLoginContext(
|
|||||||
}
|
}
|
||||||
if (sets.length === 0) return
|
if (sets.length === 0) return
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(`UPDATE account SET data = json_set(data, ${sets.join(', ')}) WHERE account_id = ?1`)
|
||||||
`UPDATE account SET data = json_set(data, ${sets.join(', ')}) WHERE account_id = ?1`
|
|
||||||
)
|
|
||||||
.bind(id, ...binds)
|
.bind(id, ...binds)
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
@@ -324,21 +312,6 @@ export async function countAccountsBySignupIp(db: D1Database, ip: string): Promi
|
|||||||
return row?.n ?? 0
|
return row?.n ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* How many accounts are linked to a platform-native id (e.g. one SteamID64) — the
|
|
||||||
* count a per-platform signup cap is enforced against. Backed by the indexed
|
|
||||||
* `platform_id` generated column. An empty id counts 0 (accounts with no platform
|
|
||||||
* identity aren't attributable to a platform user).
|
|
||||||
*/
|
|
||||||
export async function countAccountsByPlatformId(db: D1Database, platformId: string): Promise<number> {
|
|
||||||
if (platformId === '') return 0
|
|
||||||
const row = await db
|
|
||||||
.prepare('SELECT COUNT(*) AS n FROM account WHERE platform_id = ?1')
|
|
||||||
.bind(platformId)
|
|
||||||
.first<{ n: number }>()
|
|
||||||
return row?.n ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Look up multiple accounts by AccountId (order not guaranteed). */
|
/** Look up multiple accounts by AccountId (order not guaranteed). */
|
||||||
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
|
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
|
||||||
if (ids.length === 0) return []
|
if (ids.length === 0) return []
|
||||||
@@ -411,9 +384,7 @@ export async function getPasswordHash(db: D1Database, id: number): Promise<strin
|
|||||||
/** Persist the account's password hash. Returns false when no such account exists. */
|
/** Persist the account's password hash. Returns false when no such account exists. */
|
||||||
export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> {
|
export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> {
|
||||||
const { meta } = await db
|
const { meta } = await db
|
||||||
.prepare(
|
.prepare("UPDATE account SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1")
|
||||||
"UPDATE account SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1"
|
|
||||||
)
|
|
||||||
.bind(id, hash)
|
.bind(id, hash)
|
||||||
.run()
|
.run()
|
||||||
return meta.changes > 0
|
return meta.changes > 0
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ export * from './rooms-db'
|
|||||||
export * from './room-instance-db'
|
export * from './room-instance-db'
|
||||||
export * from './presence-db'
|
export * from './presence-db'
|
||||||
export * from './gifts-db'
|
export * from './gifts-db'
|
||||||
|
export * from './inventory-invention-db'
|
||||||
export * from './relationships-db'
|
export * from './relationships-db'
|
||||||
|
export * from './validation'
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Owned inventions on the shared `recflare` D1 database — the inventions a player has
|
||||||
|
* bought. One row per (account, invention), written at purchase time by the `econ`
|
||||||
|
* worker's `GET /api/storefronts/v2/buyInvention`.
|
||||||
|
*
|
||||||
|
* Only the invention id is stored: the invention record itself lives in the `invention`
|
||||||
|
* table, whose schema the `api` worker owns (apps/api/migrations/0002_invention.sql) on
|
||||||
|
* this same database, and copying its DTO here would leave two rows to keep in step. A
|
||||||
|
* creator is not listed here either — they own their invention through its
|
||||||
|
* `CreatorPlayerId`, and the buy path refuses to sell an invention to its own creator.
|
||||||
|
*
|
||||||
|
* The `econ` worker owns the schema/migration (apps/econ/migrations/
|
||||||
|
* 0008_inventory_invention.sql) and is the only writer; `api` only reads, to fold bought
|
||||||
|
* inventions into `GET /api/inventions/v2/mine`. Both import these helpers so the table
|
||||||
|
* name and row shape live in one place — the same split as gifts-db.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of migrations 0008_inventory_invention.sql) — also builds the table in tests. */
|
||||||
|
export const INVENTORY_INVENTION_SCHEMA_DDL: string[] = [
|
||||||
|
`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)
|
||||||
|
)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant an invention to a player. INSERT OR IGNORE on the (account, invention) primary
|
||||||
|
* key: owning an invention is boolean, so a second grant keeps the original
|
||||||
|
* `acquired_at` rather than back-dating the purchase to now.
|
||||||
|
*/
|
||||||
|
export async function grantInvention(
|
||||||
|
db: D1Database,
|
||||||
|
accountId: number,
|
||||||
|
inventionId: number
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT OR IGNORE INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)'
|
||||||
|
)
|
||||||
|
.bind(accountId, inventionId, new Date().toISOString())
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a player has bought an invention. This answers for BOUGHT inventions only —
|
||||||
|
* the creator of an invention owns it without a row here, so callers that mean "may use
|
||||||
|
* this invention" must check `CreatorPlayerId` as well.
|
||||||
|
*/
|
||||||
|
export async function ownsInvention(
|
||||||
|
db: D1Database,
|
||||||
|
accountId: number,
|
||||||
|
inventionId: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT 1 AS owned FROM inventory_invention WHERE account_id = ?1 AND invention_id = ?2'
|
||||||
|
)
|
||||||
|
.bind(accountId, inventionId)
|
||||||
|
.first<{ owned: number }>()
|
||||||
|
return row !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many times each invention was acquired at or after `since`, most-acquired first
|
||||||
|
* (ties broken by newest invention, so paging is stable). Backs the `api` worker's "top
|
||||||
|
* today" feed, which passes 24 hours ago.
|
||||||
|
*
|
||||||
|
* `acquired_at` holds `toISOString()` output, which is fixed-width UTC, so a lexical
|
||||||
|
* `>=` on the string is a chronological comparison — no date parsing in SQL.
|
||||||
|
*
|
||||||
|
* This counts ACQUISITIONS, not spend: a free invention's grant is a row here just like
|
||||||
|
* a paid one, and one player can only ever contribute a single row per invention (the
|
||||||
|
* table's primary key), so a popular invention can't be inflated by one buyer. Creators
|
||||||
|
* are absent by design — they own theirs through `CreatorPlayerId` and never buy it —
|
||||||
|
* which is what makes this a measure of what other people picked up.
|
||||||
|
*/
|
||||||
|
export async function getInventionAcquisitionCounts(
|
||||||
|
db: D1Database,
|
||||||
|
since: string
|
||||||
|
): Promise<Array<{ inventionId: number; count: number }>> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT invention_id, COUNT(*) AS count FROM inventory_invention
|
||||||
|
WHERE acquired_at >= ?1
|
||||||
|
GROUP BY invention_id
|
||||||
|
ORDER BY count DESC, invention_id DESC`
|
||||||
|
)
|
||||||
|
.bind(since)
|
||||||
|
.all<{ invention_id: number; count: number }>()
|
||||||
|
return results.map((r) => ({ inventionId: r.invention_id, count: r.count }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ids of every invention a player has bought, oldest purchase first. */
|
||||||
|
export async function getOwnedInventionIds(db: D1Database, accountId: number): Promise<number[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT invention_id FROM inventory_invention WHERE account_id = ?1 ORDER BY acquired_at, invention_id'
|
||||||
|
)
|
||||||
|
.bind(accountId)
|
||||||
|
.all<{ invention_id: number }>()
|
||||||
|
return results.map((r) => r.invention_id)
|
||||||
|
}
|
||||||
@@ -152,6 +152,57 @@ export async function countPlayersInInstance(
|
|||||||
return row?.n ?? 0
|
return row?.n ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live head-count per ROOM, keyed by room id — the players standing in any of a
|
||||||
|
* room's instances right now. One grouped query rather than a count per room, so
|
||||||
|
* feeds that rank by "who's playing" (the hot feed) stay a single read. Counts
|
||||||
|
* only unexpired presence; rooms nobody is in are simply absent from the map, and
|
||||||
|
* lobby (null-instance) presence is excluded.
|
||||||
|
*/
|
||||||
|
export async function countPlayersByRoom(
|
||||||
|
db: D1Database,
|
||||||
|
now = nowSeconds()
|
||||||
|
): Promise<Map<number, number>> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT room_id AS roomId, COUNT(*) AS n FROM presence
|
||||||
|
WHERE expires_at > ?1 AND room_instance_id IS NOT NULL AND room_id IS NOT NULL
|
||||||
|
GROUP BY room_id`
|
||||||
|
)
|
||||||
|
.bind(now)
|
||||||
|
.all<{ roomId: number; n: number }>()
|
||||||
|
return new Map(results.map((r) => [r.roomId, r.n]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who is standing in each of a room's instances right now, keyed by instance id —
|
||||||
|
* one grouped query rather than a lookup per instance, so the owner's instance list
|
||||||
|
* stays a single read. Reads only unexpired presence; instances nobody is in are
|
||||||
|
* simply absent from the map (callers default to an empty list), and lobby
|
||||||
|
* (null-instance) presence is excluded.
|
||||||
|
*/
|
||||||
|
export async function getPlayerIdsByRoomInstance(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
now = nowSeconds()
|
||||||
|
): Promise<Map<number, number[]>> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT room_instance_id AS instanceId, account_id AS accountId FROM presence
|
||||||
|
WHERE room_id = ?1 AND expires_at > ?2 AND room_instance_id IS NOT NULL
|
||||||
|
ORDER BY account_id`
|
||||||
|
)
|
||||||
|
.bind(roomId, now)
|
||||||
|
.all<{ instanceId: number; accountId: number }>()
|
||||||
|
const out = new Map<number, number[]>()
|
||||||
|
for (const r of results) {
|
||||||
|
const players = out.get(r.instanceId)
|
||||||
|
if (players) players.push(r.accountId)
|
||||||
|
else out.set(r.instanceId, [r.accountId])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The room instances that expired presence rows still point at — the instances a
|
* The room instances that expired presence rows still point at — the instances a
|
||||||
* player was in when they stopped heartbeating (a crash or a hard quit, where no
|
* player was in when they stopped heartbeating (a crash or a hard quit, where no
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* the client DTO (`toDto`).
|
* the client DTO (`toDto`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { countPlayersInInstance } from './presence-db'
|
import { countPlayersInInstance, getPlayerIdsByRoomInstance } from './presence-db'
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations/0004_room_instance.sql). */
|
/** Schema DDL (mirror of migrations/0004_room_instance.sql). */
|
||||||
export const ROOM_INSTANCE_SCHEMA_DDL: string[] = [
|
export const ROOM_INSTANCE_SCHEMA_DDL: string[] = [
|
||||||
@@ -69,6 +69,22 @@ export interface RoomInstanceDto {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner's view of one live instance of their room (`match`:
|
||||||
|
* `GET /room/:roomId/instances`). Deliberately NOT the client `RoomInstanceDto`:
|
||||||
|
* it's a management listing, so it carries who is in there (`playerIds`, from live
|
||||||
|
* presence) and drops the connection details (photon ids, data blob, room code) an
|
||||||
|
* owner has no business reading for a session they aren't in.
|
||||||
|
*/
|
||||||
|
export interface RoomInstanceSummary {
|
||||||
|
roomInstanceId: number
|
||||||
|
roomId: number
|
||||||
|
subRoomId: number
|
||||||
|
isFull: boolean
|
||||||
|
createdAt: string
|
||||||
|
playerIds: number[]
|
||||||
|
}
|
||||||
|
|
||||||
/** The full stored instance — the DTO plus the JsonIgnore fields (in the blob). */
|
/** The full stored instance — the DTO plus the JsonIgnore fields (in the blob). */
|
||||||
interface StoredRoomInstance extends RoomInstanceDto {
|
interface StoredRoomInstance extends RoomInstanceDto {
|
||||||
ownerAccountId: number
|
ownerAccountId: number
|
||||||
@@ -206,6 +222,35 @@ export async function setRoomInstanceInProgress(
|
|||||||
return toDto(stored)
|
return toDto(stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flip an instance's `isPrivate` flag, rewriting the JSON blob (the generated
|
||||||
|
* `is_private` column follows it). Returns the updated DTO, or null when the
|
||||||
|
* instance doesn't exist.
|
||||||
|
*
|
||||||
|
* Marking an instance private is what closes it to strangers: {@link
|
||||||
|
* getJoinableInstance} only ever reuses instances with `is_private = 0`, so a public
|
||||||
|
* matchmake stops landing new players here the moment this is set. Everyone already
|
||||||
|
* inside stays — this shuts the door, it doesn't clear the room.
|
||||||
|
*/
|
||||||
|
export async function setRoomInstancePrivate(
|
||||||
|
db: D1Database,
|
||||||
|
id: number,
|
||||||
|
isPrivate: boolean
|
||||||
|
): Promise<RoomInstanceDto | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM room_instance WHERE id = ?1')
|
||||||
|
.bind(id)
|
||||||
|
.first<{ data: string }>()
|
||||||
|
if (!row) return null
|
||||||
|
const stored = parse(row.data)
|
||||||
|
stored.isPrivate = isPrivate
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE room_instance SET data = ?1 WHERE id = ?2')
|
||||||
|
.bind(JSON.stringify(stored), id)
|
||||||
|
.run()
|
||||||
|
return toDto(stored)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recompute an instance's `isFull` flag from live match presence: full once the
|
* Recompute an instance's `isFull` flag from live match presence: full once the
|
||||||
* number of players currently present in the instance reaches its `maxCapacity`
|
* number of players currently present in the instance reaches its `maxCapacity`
|
||||||
@@ -292,3 +337,34 @@ export async function getRoomInstancesByRoom(
|
|||||||
.all<{ data: string }>()
|
.all<{ data: string }>()
|
||||||
return results.map((r) => toDto(parse(r.data)))
|
return results.map((r) => toDto(parse(r.data)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A room's instances as the owner's management listing sees them — the
|
||||||
|
* {@link RoomInstanceSummary} projection, each with the players currently standing
|
||||||
|
* in it. Presence is read once for the whole room (one grouped query), so this stays
|
||||||
|
* two reads regardless of how many instances are live; an instance nobody is in
|
||||||
|
* (everyone timed out, or it was just created) gets an empty `playerIds`.
|
||||||
|
*/
|
||||||
|
export async function getRoomInstanceSummariesByRoom(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number
|
||||||
|
): Promise<RoomInstanceSummary[]> {
|
||||||
|
const [{ results }, playersByInstance] = await Promise.all([
|
||||||
|
db
|
||||||
|
.prepare('SELECT data FROM room_instance WHERE room_id = ?1 ORDER BY id')
|
||||||
|
.bind(roomId)
|
||||||
|
.all<{ data: string }>(),
|
||||||
|
getPlayerIdsByRoomInstance(db, roomId),
|
||||||
|
])
|
||||||
|
return results.map((r) => {
|
||||||
|
const s = parse(r.data)
|
||||||
|
return {
|
||||||
|
roomInstanceId: s.roomInstanceId,
|
||||||
|
roomId: s.roomId,
|
||||||
|
subRoomId: s.subRoomId,
|
||||||
|
isFull: s.isFull,
|
||||||
|
createdAt: s.createdAt,
|
||||||
|
playerIds: playersByInstance.get(s.roomInstanceId) ?? [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+435
-29
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Accessibility, Role } from './enums'
|
import { Accessibility, Role } from './enums'
|
||||||
|
import { countPlayersByRoom } from './presence-db'
|
||||||
|
|
||||||
/** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */
|
/** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */
|
||||||
export const ROOM_SCHEMA_DDL: string[] = [
|
export const ROOM_SCHEMA_DDL: string[] = [
|
||||||
@@ -42,6 +43,23 @@ export const ROOM_SCHEMA_DDL: string[] = [
|
|||||||
last_visited_at TEXT,
|
last_visited_at TEXT,
|
||||||
PRIMARY KEY (player_id, room_id)
|
PRIMARY KEY (player_id, room_id)
|
||||||
)`,
|
)`,
|
||||||
|
// Per-room player bans (migrations/0010_room_ban.sql). One row per (room, player),
|
||||||
|
// so re-banning someone already banned updates their row rather than appending.
|
||||||
|
// `ban_mask` is the client's `banMask` field kept verbatim — its meaning isn't known
|
||||||
|
// yet (the client sends 0), so nothing interprets it.
|
||||||
|
//
|
||||||
|
// Deliberately NOT in the room's `data` blob: that blob is served to the client
|
||||||
|
// verbatim as the room, and a ban list is not something every reader of a room
|
||||||
|
// should receive.
|
||||||
|
`CREATE TABLE IF NOT EXISTS room_ban (
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
banned_player_id INTEGER NOT NULL,
|
||||||
|
ban_mask INTEGER NOT NULL DEFAULT 0,
|
||||||
|
banned_by_account_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (room_id, banned_player_id)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id)`,
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,6 +100,26 @@ export const SUBROOM_SCHEMA_DDL: string[] = [
|
|||||||
data TEXT NOT NULL
|
data TEXT NOT NULL
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id)`,
|
||||||
|
// Per-subroom permission overrides (migrations/0009_subroom_permissions.sql). The room
|
||||||
|
// owner's permission table for one subroom, keyed by (permission, role) — that pair is
|
||||||
|
// what the client's PUT addresses, and re-sending it overwrites the stored row rather
|
||||||
|
// than appending a second one.
|
||||||
|
//
|
||||||
|
// A row IS an override, which is why the client's `Override` flag is not a column: it's
|
||||||
|
// the checkbox next to the permission, so clearing it deletes the row and the pair falls
|
||||||
|
// back to its default. `value` is the client's string, stored verbatim.
|
||||||
|
//
|
||||||
|
// Deliberately NOT in the subroom's `data` blob: that blob is served to the client
|
||||||
|
// verbatim as part of the room, and these overrides are read on one path only
|
||||||
|
// (`GET /photon_access_token`, where they overwrite the matching default entries).
|
||||||
|
`CREATE TABLE IF NOT EXISTS subroom_permission (
|
||||||
|
sub_room_id INTEGER NOT NULL,
|
||||||
|
permission TEXT NOT NULL,
|
||||||
|
role INTEGER NOT NULL,
|
||||||
|
type INTEGER NOT NULL DEFAULT 0,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (sub_room_id, permission, role)
|
||||||
|
)`,
|
||||||
]
|
]
|
||||||
|
|
||||||
/** A stored room — the parsed JSON blob (full client-facing room response). */
|
/** A stored room — the parsed JSON blob (full client-facing room response). */
|
||||||
@@ -115,6 +153,96 @@ export function canManageRoom(room: Room, accountId: number): boolean {
|
|||||||
return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
|
return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A player banned from a room (a `room_ban` row). */
|
||||||
|
export interface RoomBan {
|
||||||
|
RoomId: number
|
||||||
|
BannedPlayerId: number
|
||||||
|
/** The client's `banMask`, stored verbatim — its meaning isn't known yet. */
|
||||||
|
BanMask: number
|
||||||
|
BannedByAccountId: number
|
||||||
|
CreatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomBanRow {
|
||||||
|
room_id: number
|
||||||
|
banned_player_id: number
|
||||||
|
ban_mask: number
|
||||||
|
banned_by_account_id: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const toRoomBan = (row: RoomBanRow): RoomBan => ({
|
||||||
|
RoomId: row.room_id,
|
||||||
|
BannedPlayerId: row.banned_player_id,
|
||||||
|
BanMask: row.ban_mask,
|
||||||
|
BannedByAccountId: row.banned_by_account_id,
|
||||||
|
CreatedAt: row.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ban a player from a room, returning the stored ban. One row per (room, player):
|
||||||
|
* re-banning someone already banned rewrites their row with the new mask and issuer
|
||||||
|
* rather than appending a second one, so the call is idempotent.
|
||||||
|
*/
|
||||||
|
export async function banPlayerFromRoom(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
bannedPlayerId: number,
|
||||||
|
banMask: number,
|
||||||
|
bannedByAccountId: number
|
||||||
|
): Promise<RoomBan> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(room_id, banned_player_id) DO UPDATE SET
|
||||||
|
ban_mask = ?3, banned_by_account_id = ?4, created_at = ?5
|
||||||
|
RETURNING *`
|
||||||
|
)
|
||||||
|
.bind(roomId, bannedPlayerId, banMask, bannedByAccountId, new Date().toISOString())
|
||||||
|
.first<RoomBanRow>()
|
||||||
|
// RETURNING always yields the upserted row.
|
||||||
|
return toRoomBan(row!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lift a player's ban on a room, returning the ban that was removed — or null when
|
||||||
|
* they weren't banned, which lets the caller tell a real unban from a no-op.
|
||||||
|
*/
|
||||||
|
export async function unbanPlayerFromRoom(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
bannedPlayerId: number
|
||||||
|
): Promise<RoomBan | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('DELETE FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2 RETURNING *')
|
||||||
|
.bind(roomId, bannedPlayerId)
|
||||||
|
.first<RoomBanRow>()
|
||||||
|
return row ? toRoomBan(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everyone banned from a room, most recently banned first. */
|
||||||
|
export async function getRoomBans(db: D1Database, roomId: number): Promise<RoomBan[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare('SELECT * FROM room_ban WHERE room_id = ?1 ORDER BY created_at DESC')
|
||||||
|
.bind(roomId)
|
||||||
|
.all<RoomBanRow>()
|
||||||
|
return results.map(toRoomBan)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a player is banned from a room. */
|
||||||
|
export async function isPlayerBannedFromRoom(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
playerId: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT 1 AS hit FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2')
|
||||||
|
.bind(roomId, playerId)
|
||||||
|
.first<{ hit: number }>()
|
||||||
|
return row !== null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone an existing room into a new one owned by `accountId`. Copies the source
|
* Clone an existing room into a new one owned by `accountId`. Copies the source
|
||||||
* room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
|
* room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
|
||||||
@@ -157,6 +285,9 @@ export async function cloneRoom(
|
|||||||
// client renders a virtual "RRO" tag on the clone.
|
// client renders a virtual "RRO" tag on the clone.
|
||||||
IsRRO: false,
|
IsRRO: false,
|
||||||
Roles: roles,
|
Roles: roles,
|
||||||
|
// A fresh room has no engagement of its own — don't inherit the source's counters
|
||||||
|
// (the derived ones are recomputed per read, but the clone is returned as-is here).
|
||||||
|
Stats: storedStats(source.Stats),
|
||||||
CreatedAt: new Date().toISOString(),
|
CreatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,6 +826,9 @@ export async function deleteSubRoom(
|
|||||||
// The saves go with it — nothing can reference them once the subroom is gone.
|
// The saves go with it — nothing can reference them once the subroom is gone.
|
||||||
// The blobs they point at are left in R2, like a deleted room's images.
|
// The blobs they point at are left in R2, like a deleted room's images.
|
||||||
db.prepare('DELETE FROM subroom_save WHERE sub_room_id = ?1').bind(subRoomId),
|
db.prepare('DELETE FROM subroom_save WHERE sub_room_id = ?1').bind(subRoomId),
|
||||||
|
// So do its permission overrides — subroom ids are minted from one global
|
||||||
|
// sequence, but leaving orphans would still be dead rows nothing can reach.
|
||||||
|
db.prepare('DELETE FROM subroom_permission WHERE sub_room_id = ?1').bind(subRoomId),
|
||||||
])
|
])
|
||||||
|
|
||||||
const room = await getRoomById(db, roomId)
|
const room = await getRoomById(db, roomId)
|
||||||
@@ -760,11 +894,13 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize a room for a full-blob write, dropping any hydrated `SubRooms` so it never
|
* Serialize a room for a full-blob write, dropping any hydrated `SubRooms` so it never
|
||||||
* gets denormalized back into the room JSON (subrooms are the `subroom` table's job).
|
* gets denormalized back into the room JSON (subrooms are the `subroom` table's job) and
|
||||||
|
* zeroing the derived engagement counters (those are the `interaction` table's job — see
|
||||||
|
* {@link attachStats}), so a write can't bake a snapshot of them into the blob.
|
||||||
*/
|
*/
|
||||||
const serializeRoom = (room: Room): string => {
|
const serializeRoom = (room: Room): string => {
|
||||||
const { SubRooms: _subRooms, ...rest } = room
|
const { SubRooms: _subRooms, Stats: stats, ...rest } = room
|
||||||
return JSON.stringify(rest)
|
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -831,15 +967,108 @@ async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> {
|
|||||||
for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? []
|
for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hydrate a single room's `SubRooms` (no-op for null). */
|
// ---- Room stats -----------------------------------------------------------
|
||||||
|
// A room's cheer/favorite counters are DERIVED from the `interaction` table rather than
|
||||||
|
// stored: they're recomputed on every read, so a cheer shows up immediately and the
|
||||||
|
// counts can't drift from the per-player rows they're made of. The blob keeps them at 0
|
||||||
|
// (see {@link serializeRoom}). `VisitorCount`/`VisitCount` are left as the blob has them
|
||||||
|
// — nothing records a visit yet, and `interaction.last_visited_at` is only stamped by the
|
||||||
|
// cheer/favorite toggles, so counting those rows would report cheerers as visitors.
|
||||||
|
|
||||||
|
/** One room's derived engagement counters (the aggregate maps below key these by RoomId). */
|
||||||
|
export interface RoomStats {
|
||||||
|
CheerCount: number
|
||||||
|
FavoriteCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomStatsRow {
|
||||||
|
room_id: number
|
||||||
|
cheers: number
|
||||||
|
favorites: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The counters a room starts life with (and the shape the client expects). */
|
||||||
|
const ZERO_STATS = { CheerCount: 0, FavoriteCount: 0, VisitorCount: 0, VisitCount: 0 }
|
||||||
|
|
||||||
|
/** D1 caps a query at 100 bound parameters, and a feed page can carry more ids than that. */
|
||||||
|
const STATS_ID_LIMIT = 90
|
||||||
|
|
||||||
|
/** A room's RoomId, or 0 for a blob without one. */
|
||||||
|
const roomIdOf = (room: Room): number => (typeof room.RoomId === 'number' ? room.RoomId : 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Stats` object to persist: whatever the room carried, with the derived counters
|
||||||
|
* back at 0 so the blob never holds a stale copy of them.
|
||||||
|
*/
|
||||||
|
function storedStats(stats: unknown): Record<string, unknown> {
|
||||||
|
const stored =
|
||||||
|
typeof stats === 'object' && stats !== null ? (stats as Record<string, unknown>) : {}
|
||||||
|
return { ...ZERO_STATS, ...stored, CheerCount: 0, FavoriteCount: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheer/favorite counts per room, aggregated from `interaction` in ONE grouped query.
|
||||||
|
* Restricted to `roomIds` when given (a feed page), otherwise covering every room —
|
||||||
|
* which is also what a page too large to bind gets, since scanning the whole table is
|
||||||
|
* cheaper than splitting the query. Rooms nobody has interacted with are absent.
|
||||||
|
*/
|
||||||
|
export async function getRoomStats(
|
||||||
|
db: D1Database,
|
||||||
|
roomIds?: number[]
|
||||||
|
): Promise<Map<number, RoomStats>> {
|
||||||
|
const byRoom = new Map<number, RoomStats>()
|
||||||
|
if (roomIds && roomIds.length === 0) return byRoom
|
||||||
|
const ids = roomIds && roomIds.length <= STATS_ID_LIMIT ? roomIds : []
|
||||||
|
const where =
|
||||||
|
ids.length > 0 ? `WHERE room_id IN (${ids.map((_, i) => `?${i + 1}`).join(',')})` : ''
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT room_id, SUM(cheered) AS cheers, SUM(favorited) AS favorites
|
||||||
|
FROM interaction ${where} GROUP BY room_id`
|
||||||
|
)
|
||||||
|
.bind(...ids)
|
||||||
|
.all<RoomStatsRow>()
|
||||||
|
for (const r of results) {
|
||||||
|
byRoom.set(r.room_id, { CheerCount: r.cheers ?? 0, FavoriteCount: r.favorites ?? 0 })
|
||||||
|
}
|
||||||
|
return byRoom
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite each room's derived counters from the interaction table, in one query for
|
||||||
|
* the whole batch. Callers that already aggregated (the feeds rank by these counts, so
|
||||||
|
* they need them before paging) pass their map in rather than paying for a second query.
|
||||||
|
*/
|
||||||
|
async function attachStats(
|
||||||
|
db: D1Database,
|
||||||
|
rooms: Room[],
|
||||||
|
stats?: Map<number, RoomStats>
|
||||||
|
): Promise<void> {
|
||||||
|
if (rooms.length === 0) return
|
||||||
|
const byRoom = stats ?? (await getRoomStats(db, [...new Set(rooms.map(roomIdOf))]))
|
||||||
|
for (const room of rooms) {
|
||||||
|
const counts = byRoom.get(roomIdOf(room))
|
||||||
|
room.Stats = {
|
||||||
|
...storedStats(room.Stats),
|
||||||
|
CheerCount: counts?.CheerCount ?? 0,
|
||||||
|
FavoriteCount: counts?.FavoriteCount ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hydrate a single room's `SubRooms` and derived `Stats` (no-op for null). */
|
||||||
async function hydrateRoom(db: D1Database, room: Room | null): Promise<Room | null> {
|
async function hydrateRoom(db: D1Database, room: Room | null): Promise<Room | null> {
|
||||||
if (room) await attachSubRooms(db, [room])
|
if (room) await hydrateRooms(db, [room])
|
||||||
return room
|
return room
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hydrate many rooms' `SubRooms` in one batched query. */
|
/** Hydrate many rooms' `SubRooms` and derived `Stats` (one batched query each). */
|
||||||
async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
|
async function hydrateRooms(
|
||||||
await attachSubRooms(db, rooms)
|
db: D1Database,
|
||||||
|
rooms: Room[],
|
||||||
|
stats?: Map<number, RoomStats>
|
||||||
|
): Promise<Room[]> {
|
||||||
|
await Promise.all([attachSubRooms(db, rooms), attachStats(db, rooms, stats)])
|
||||||
return rooms
|
return rooms
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -942,6 +1171,119 @@ export async function getSubRoomSaveById(
|
|||||||
return row ? parseSubRoomSaveRow(row) : null
|
return row ? parseSubRoomSaveRow(row) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Subroom permissions --------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry of a subroom's permission table, in the client's own shape. `Value` is a
|
||||||
|
* STRING, not a boolean — usually `"True"`/`"False"`, but a permission whose UI isn't a
|
||||||
|
* True/False picker carries something else, so it is stored and served verbatim. `Role`
|
||||||
|
* is the tier the entry applies to (0 = everyone, 30 = co-owner, …). `Permission` + `Role`
|
||||||
|
* identify an entry: the client PUTs the pair it wants changed, and the same pair
|
||||||
|
* overwrites the matching default in the photon access token's table.
|
||||||
|
*
|
||||||
|
* `Override` is the row's own existence, not data: the client's UI is a checkbox ("is
|
||||||
|
* this permission overridden in this subroom?") plus a True/False picker for the value.
|
||||||
|
* Unchecking it means "fall back to the default", so an entry arriving with
|
||||||
|
* `Override: false` DELETES the stored row rather than storing anything. Every stored
|
||||||
|
* entry is therefore an override, and reads always serve `Override: true`.
|
||||||
|
*/
|
||||||
|
export interface RoomPermission {
|
||||||
|
Permission: string
|
||||||
|
Role: number
|
||||||
|
Override: boolean
|
||||||
|
Type: number
|
||||||
|
Value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomPermissionRow {
|
||||||
|
permission: string
|
||||||
|
role: number
|
||||||
|
type: number
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const toRoomPermission = (row: RoomPermissionRow): RoomPermission => ({
|
||||||
|
// A stored row IS the override — the table holds nothing else (see RoomPermission).
|
||||||
|
Override: true,
|
||||||
|
Permission: row.permission,
|
||||||
|
Role: row.role,
|
||||||
|
Type: row.type,
|
||||||
|
Value: row.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The permission columns, in the order the read/copy statements use. */
|
||||||
|
const PERMISSION_COLUMNS = 'permission, role, type, value'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subroom's stored permission overrides, in the order they were first set. Empty for a
|
||||||
|
* subroom whose owner has never overridden a permission — the photon access token then
|
||||||
|
* serves its defaults untouched.
|
||||||
|
*/
|
||||||
|
export async function getSubRoomPermissions(
|
||||||
|
db: D1Database,
|
||||||
|
subRoomId: number
|
||||||
|
): Promise<RoomPermission[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${PERMISSION_COLUMNS} FROM subroom_permission WHERE sub_room_id = ?1 ORDER BY rowid`
|
||||||
|
)
|
||||||
|
.bind(subRoomId)
|
||||||
|
.all<RoomPermissionRow>()
|
||||||
|
return results.map(toRoomPermission)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply permission changes to a subroom, keyed by (`Permission`, `Role`). Only the pairs
|
||||||
|
* supplied are touched; every other stored entry is left alone.
|
||||||
|
*
|
||||||
|
* `Override` decides which way an entry goes, mirroring the checkbox the client draws
|
||||||
|
* next to each permission: true STORES the `Value` for that pair (overwriting whatever
|
||||||
|
* was there), false CLEARS it, so the pair falls back to the photon access token's
|
||||||
|
* default. Clearing a pair that was never overridden is a no-op.
|
||||||
|
*/
|
||||||
|
export async function setSubRoomPermissions(
|
||||||
|
db: D1Database,
|
||||||
|
subRoomId: number,
|
||||||
|
permissions: RoomPermission[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (permissions.length === 0) return
|
||||||
|
const upsert = db.prepare(
|
||||||
|
`INSERT INTO subroom_permission (sub_room_id, ${PERMISSION_COLUMNS})
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT (sub_room_id, permission, role)
|
||||||
|
DO UPDATE SET type = excluded.type, value = excluded.value`
|
||||||
|
)
|
||||||
|
const clear = db.prepare(
|
||||||
|
'DELETE FROM subroom_permission WHERE sub_room_id = ?1 AND permission = ?2 AND role = ?3'
|
||||||
|
)
|
||||||
|
await db.batch(
|
||||||
|
permissions.map((p) =>
|
||||||
|
p.Override
|
||||||
|
? upsert.bind(subRoomId, p.Permission, p.Role, p.Type, p.Value)
|
||||||
|
: clear.bind(subRoomId, p.Permission, p.Role)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a subroom's permission overrides onto another subroom — a clone inherits the
|
||||||
|
* source's permission table along with its scene and settings. Replaces any entry the
|
||||||
|
* destination already holds for the same (permission, role).
|
||||||
|
*/
|
||||||
|
async function copySubRoomPermissions(
|
||||||
|
db: D1Database,
|
||||||
|
fromSubRoomId: number,
|
||||||
|
toSubRoomId: number
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT OR REPLACE INTO subroom_permission (sub_room_id, ${PERMISSION_COLUMNS})
|
||||||
|
SELECT ?2, ${PERMISSION_COLUMNS} FROM subroom_permission WHERE sub_room_id = ?1`
|
||||||
|
)
|
||||||
|
.bind(fromSubRoomId, toSubRoomId)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a subroom for a room, minting a fresh globally-unique SubRoomId from the
|
* Insert a subroom for a room, minting a fresh globally-unique SubRoomId from the
|
||||||
* table's autoincrement sequence. Returns the created subroom (with its new id).
|
* table's autoincrement sequence. Returns the created subroom (with its new id).
|
||||||
@@ -963,6 +1305,12 @@ export async function insertSubRoom(
|
|||||||
CurrentSave: null,
|
CurrentSave: null,
|
||||||
StagedSubRoomDataSaveId: null,
|
StagedSubRoomDataSaveId: null,
|
||||||
}
|
}
|
||||||
|
// The permission overrides follow the copy too — they live in their own table (keyed by
|
||||||
|
// the id the caller is cloning FROM), so unlike the rest of the settings they aren't
|
||||||
|
// carried by the blob. A fresh subroom (`createSubRoom`) passes no id and copies nothing.
|
||||||
|
if (typeof sub.SubRoomId === 'number') {
|
||||||
|
await copySubRoomPermissions(db, sub.SubRoomId, subRoomId)
|
||||||
|
}
|
||||||
// A copied subroom (room clone, subroom clone) carries the source's save. It gets its
|
// A copied subroom (room clone, subroom clone) carries the source's save. It gets its
|
||||||
// OWN row — a save belongs to exactly one subroom, so sharing the source's id would
|
// OWN row — a save belongs to exactly one subroom, so sharing the source's id would
|
||||||
// make the copy's content follow the source's future saves.
|
// make the copy's content follow the source's future saves.
|
||||||
@@ -1037,12 +1385,18 @@ export async function deleteRoom(db: D1Database, roomId: number): Promise<void>
|
|||||||
await db.batch([
|
await db.batch([
|
||||||
db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId),
|
db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId),
|
||||||
db.prepare('DELETE FROM interaction WHERE room_id = ?1').bind(roomId),
|
db.prepare('DELETE FROM interaction WHERE room_id = ?1').bind(roomId),
|
||||||
// Saves first — they're keyed by subroom, so they'd be unreachable afterwards.
|
// Saves and permission overrides first — both are keyed by subroom, so they'd be
|
||||||
|
// unreachable once the subrooms themselves are gone.
|
||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
'DELETE FROM subroom_save WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)'
|
'DELETE FROM subroom_save WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)'
|
||||||
)
|
)
|
||||||
.bind(roomId),
|
.bind(roomId),
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
'DELETE FROM subroom_permission WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)'
|
||||||
|
)
|
||||||
|
.bind(roomId),
|
||||||
db.prepare('DELETE FROM subroom WHERE room_id = ?1').bind(roomId),
|
db.prepare('DELETE FROM subroom WHERE room_id = ?1').bind(roomId),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -1324,20 +1678,51 @@ export async function searchRooms(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Engagement score used to order the hot feed (cheers weigh most, then favorites). */
|
/**
|
||||||
function hotScore(room: Room): number {
|
* Engagement score used to order the hot feed (cheers weigh most, then favorites).
|
||||||
const stats = room.Stats as Record<string, unknown> | null | undefined
|
* Cheers/favorites come from the caller's aggregated {@link getRoomStats} map — ranking
|
||||||
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
|
* happens before hydration, so the room blob's copies are still zero at this point.
|
||||||
return n(stats?.CheerCount) * 3 + n(stats?.FavoriteCount) * 2 + n(stats?.VisitorCount)
|
*/
|
||||||
|
function hotScore(room: Room, stats: Map<number, RoomStats>): number {
|
||||||
|
const counts = stats.get(roomIdOf(room))
|
||||||
|
const stored = room.Stats as Record<string, unknown> | null | undefined
|
||||||
|
const visitors = typeof stored?.VisitorCount === 'number' ? stored.VisitorCount : 0
|
||||||
|
return (counts?.CheerCount ?? 0) * 3 + (counts?.FavoriteCount ?? 0) * 2 + visitors
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browse screen's "New" chip posts `tag=new` to the hot feed, but `new` is a
|
||||||
|
* PSEUDO-tag: no room carries it. It means "recently created by a player", so it
|
||||||
|
* selects the non-RRO rooms and orders them newest-first instead of by population.
|
||||||
|
*/
|
||||||
|
const NEW_TAG = 'new'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the room is a Rec Room Original. `IsRRO` is the flag the client renders a
|
||||||
|
* virtual "RRO" tag from; the auto-derived `rro` tag is checked too so a room that only
|
||||||
|
* carries the tag isn't mistaken for player-made.
|
||||||
|
*/
|
||||||
|
function isRRO(room: Room): boolean {
|
||||||
|
return room.IsRRO === true || roomHasAnyTag(room, new Set(['rro']))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A room's CreatedAt as epoch millis; 0 (i.e. oldest) when it's missing or unparseable. */
|
||||||
|
function createdAt(room: Room): number {
|
||||||
|
const ts = typeof room.CreatedAt === 'string' ? Date.parse(room.CreatedAt) : NaN
|
||||||
|
return Number.isNaN(ts) ? 0 : ts
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "hot" rooms feed: public, non-dorm rooms not excluded from lists, ordered
|
* The "hot" rooms feed: public, non-dorm rooms not excluded from lists, ordered
|
||||||
* by engagement and optionally filtered to a single `tag` (with the same aliases
|
* by how many players are in them RIGHT NOW (live presence summed across the
|
||||||
* as search). Paginated via skip/take; returns `{ Results, TotalResults }` like
|
* room's instances), and optionally filtered to a single `tag` (with the same
|
||||||
* search. Ties (and the all-zero seed data) fall back to RoomId order so paging
|
* aliases as search). "Hot" is a live-population feed, so current players lead;
|
||||||
* is stable. The dataset is small, so this filters/sorts in memory rather than
|
* rooms nobody is in — and the all-zero seed data — fall back to the stored
|
||||||
* in SQL.
|
* engagement score, then to RoomId order so paging stays stable. Paginated via
|
||||||
|
* skip/take; returns `{ Results, TotalResults }` like search. The dataset is
|
||||||
|
* small, so this filters/sorts in memory rather than in SQL.
|
||||||
|
*
|
||||||
|
* `tag=new` is the one filter that isn't a tag lookup — see {@link NEW_TAG}.
|
||||||
*/
|
*/
|
||||||
export async function getHotRooms(
|
export async function getHotRooms(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -1351,15 +1736,34 @@ export async function getHotRooms(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const t = tag.trim().toLowerCase()
|
const t = tag.trim().toLowerCase()
|
||||||
|
if (t === NEW_TAG) {
|
||||||
|
// Newest player-made rooms first; RoomId (which is minted in creation order)
|
||||||
|
// breaks ties so rooms created in the same instant still page stably.
|
||||||
|
const fresh = rooms
|
||||||
|
.filter((r) => !isRRO(r))
|
||||||
|
.sort((a, b) => createdAt(b) - createdAt(a) || roomIdOf(b) - roomIdOf(a))
|
||||||
|
return {
|
||||||
|
Results: await hydrateRooms(db, fresh.slice(skip, skip + take)),
|
||||||
|
TotalResults: fresh.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (t !== '') {
|
if (t !== '') {
|
||||||
const accepted = new Set([t, ...(TAG_ALIASES[t] ?? [])])
|
const accepted = new Set([t, ...(TAG_ALIASES[t] ?? [])])
|
||||||
rooms = rooms.filter((r) => roomHasAnyTag(r, accepted))
|
rooms = rooms.filter((r) => roomHasAnyTag(r, accepted))
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
|
const players = await countPlayersByRoom(db)
|
||||||
rooms.sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b))
|
const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0
|
||||||
|
const stats = await getRoomStats(db)
|
||||||
|
rooms.sort(
|
||||||
|
(a, b) =>
|
||||||
|
playerCount(b) - playerCount(a) ||
|
||||||
|
hotScore(b, stats) - hotScore(a, stats) ||
|
||||||
|
roomIdOf(a) - roomIdOf(b)
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
|
Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
|
||||||
TotalResults: rooms.length,
|
TotalResults: rooms.length,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1378,13 +1782,14 @@ export async function getRecommendedRooms(
|
|||||||
take: number
|
take: number
|
||||||
): Promise<Room[]> {
|
): Promise<Room[]> {
|
||||||
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
|
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
|
||||||
const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
|
const stats = await getRoomStats(db)
|
||||||
return hydrateRooms(
|
return hydrateRooms(
|
||||||
db,
|
db,
|
||||||
parseAll(results)
|
parseAll(results)
|
||||||
.filter((r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true)
|
.filter((r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true)
|
||||||
.sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b))
|
.sort((a, b) => hotScore(b, stats) - hotScore(a, stats) || roomIdOf(a) - roomIdOf(b))
|
||||||
.slice(skip, skip + take)
|
.slice(skip, skip + take),
|
||||||
|
stats
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1463,7 +1868,7 @@ export async function getSimilarRooms(
|
|||||||
|
|
||||||
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
|
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
|
||||||
const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length
|
const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length
|
||||||
const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
|
const stats = await getRoomStats(db)
|
||||||
|
|
||||||
const scored = parseAll(results)
|
const scored = parseAll(results)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -1479,12 +1884,12 @@ export async function getSimilarRooms(
|
|||||||
scored.sort(
|
scored.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
b.shared - a.shared ||
|
b.shared - a.shared ||
|
||||||
hotScore(b.room) - hotScore(a.room) ||
|
hotScore(b.room, stats) - hotScore(a.room, stats) ||
|
||||||
roomIdOf(a.room) - roomIdOf(b.room)
|
roomIdOf(a.room) - roomIdOf(b.room)
|
||||||
)
|
)
|
||||||
const rooms = scored.map((x) => x.room)
|
const rooms = scored.map((x) => x.room)
|
||||||
return {
|
return {
|
||||||
Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
|
Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
|
||||||
TotalResults: rooms.length,
|
TotalResults: rooms.length,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1499,7 +1904,6 @@ export async function getSimilarRooms(
|
|||||||
export async function getBaseRooms(db: D1Database, skip: number, take: number): Promise<Room[]> {
|
export async function getBaseRooms(db: D1Database, skip: number, take: number): Promise<Room[]> {
|
||||||
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
|
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
|
||||||
const base = new Set(['base'])
|
const base = new Set(['base'])
|
||||||
const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
|
|
||||||
return hydrateRooms(
|
return hydrateRooms(
|
||||||
db,
|
db,
|
||||||
parseAll(results)
|
parseAll(results)
|
||||||
@@ -1574,6 +1978,8 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr
|
|||||||
Roles: [
|
Roles: [
|
||||||
{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 },
|
{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||||
],
|
],
|
||||||
|
// Counters start at zero rather than inheriting the template dorm's (see cloneRoom).
|
||||||
|
Stats: storedStats(template?.Stats),
|
||||||
CreatedAt: new Date().toISOString(),
|
CreatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
// serializeRoom drops any SubRooms carried over from the template; the dorm's own
|
// serializeRoom drops any SubRooms carried over from the template; the dorm's own
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import isEmail from 'isemail'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limits on the free text a player can put into their account and their rooms.
|
||||||
|
*
|
||||||
|
* Shared by `accounts` and `rooms` so one rule can't drift from the other — a username
|
||||||
|
* and a room name are held to the same shape, and both are typed into the same client.
|
||||||
|
*
|
||||||
|
* These check only what a player SUPPLIES. Names the server generates go around them:
|
||||||
|
* a dorm is called `@<username>'s Dorm` (see `rooms-db.ts`), which the name rule below
|
||||||
|
* would reject, and auto-assigned usernames (`SwiftFox4821`, `Player42`) happen to
|
||||||
|
* satisfy it. So validate at the request handler, never inside the db helpers.
|
||||||
|
*
|
||||||
|
* Emptiness is deliberately NOT checked here. Every caller already rejects an empty
|
||||||
|
* value in its own words, and those sentences reach players through response envelopes
|
||||||
|
* the client renders verbatim — see the client-contract notes in CLAUDE.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name lengths. All three come from what the CLIENT will accept in the matching input
|
||||||
|
* box, not from a round number: accepting more here would store a name the game can't
|
||||||
|
* re-enter or edit, so the server matches the box rather than being generous.
|
||||||
|
*/
|
||||||
|
export const MAX_USERNAME_LENGTH = 50
|
||||||
|
export const MAX_DISPLAY_NAME_LENGTH = 15
|
||||||
|
export const MAX_ROOM_NAME_LENGTH = 32
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Club and event limits. Longer than the name limits above because these aren't
|
||||||
|
* identifiers — a club name and an event name are titles, and both allow the
|
||||||
|
* punctuation and spaces a title needs (clubs enforce their own charset rule; events
|
||||||
|
* enforce none at all, since an event is called things like "Building a Better Room
|
||||||
|
* Using Trigonometry").
|
||||||
|
*/
|
||||||
|
export const MAX_CLUB_NAME_LENGTH = 40
|
||||||
|
export const MAX_CLUB_DESCRIPTION_LENGTH = 512
|
||||||
|
export const MAX_EVENT_NAME_LENGTH = 64
|
||||||
|
export const MAX_EVENT_DESCRIPTION_LENGTH = 512
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invention limits. A name is a title a player types into the invention-save box and
|
||||||
|
* reads back in a browse tile, so it allows the punctuation a title needs — but
|
||||||
|
* nothing else, since it is also what invention search matches on. The minimum is real:
|
||||||
|
* one- and two-character names are unsearchable and unreadable in a tile, and the client
|
||||||
|
* offers `Untitled` rather than an empty box.
|
||||||
|
*/
|
||||||
|
export const MIN_INVENTION_NAME_LENGTH = 3
|
||||||
|
export const MAX_INVENTION_NAME_LENGTH = 24
|
||||||
|
export const MAX_INVENTION_DESCRIPTION_LENGTH = 512
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One invention tag. Short and letters-only because tags are a controlled vocabulary the
|
||||||
|
* browse chips are derived from (see `getInventionTagFilters`) — a tag with digits,
|
||||||
|
* punctuation or spaces makes a chip nobody else will ever type again. Tags are stored
|
||||||
|
* lowercased, so the rule is checked against the normalized form, not what was typed.
|
||||||
|
*/
|
||||||
|
export const MAX_INVENTION_TAG_LENGTH = 15
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Length in code points rather than UTF-16 units, so an emoji or other astral character
|
||||||
|
* counts once instead of twice — the way a player counts what they typed.
|
||||||
|
*/
|
||||||
|
export const glyphLength = (value: string): number => Array.from(value).length
|
||||||
|
|
||||||
|
/** Max length of a profile bio. */
|
||||||
|
export const MAX_BIO_LENGTH = 255
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Letters and digits only — no spaces, punctuation, or accents.
|
||||||
|
*
|
||||||
|
* Deliberately narrow: these names are shown to other players, used to search, and (for
|
||||||
|
* usernames) typed into a sign-in box, so anything that can be confused for another name
|
||||||
|
* is worth refusing. It also rules out the homoglyph and right-to-left tricks that come
|
||||||
|
* with allowing arbitrary Unicode.
|
||||||
|
*/
|
||||||
|
const NAME_PATTERN = /^[A-Za-z0-9]+$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a player-supplied name is unacceptable, or `null` when it's fine.
|
||||||
|
*
|
||||||
|
* `label` names the thing in the returned sentence ('username', 'room name'), so the
|
||||||
|
* message reads correctly wherever it's surfaced. `max` is required rather than
|
||||||
|
* defaulted: the three limits differ, and a caller that forgets which one it wants
|
||||||
|
* should have to say so instead of silently taking someone else's.
|
||||||
|
*/
|
||||||
|
export function nameRejection(value: string, label: string, max: number): string | null {
|
||||||
|
if (value.length > max) {
|
||||||
|
return `Your ${label} can be at most ${max} characters.`
|
||||||
|
}
|
||||||
|
if (!NAME_PATTERN.test(value)) {
|
||||||
|
return `Your ${label} can only contain letters and numbers.`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Letters, digits, spaces, dashes and colons — the title charset. Wider than
|
||||||
|
* `NAME_PATTERN` because an invention is a thing with a name ("Grappling Hook v2",
|
||||||
|
* "Speed-Boost Pad"), not an identifier someone types into a sign-in box. Still no
|
||||||
|
* arbitrary Unicode, for the same homoglyph reasons.
|
||||||
|
*
|
||||||
|
* The colon is not decorative: an invention the player never named is called after the
|
||||||
|
* moment it was saved (`071126 13:10:50`), generated by the CLIENT, so a rule without it
|
||||||
|
* would refuse every unnamed save the game makes. The dash stays last in the class so it
|
||||||
|
* reads as a literal rather than a range.
|
||||||
|
*/
|
||||||
|
const INVENTION_NAME_PATTERN = /^[A-Za-z0-9 :-]+$/
|
||||||
|
|
||||||
|
/** Lowercase letters only — the normalized form a tag is stored in. */
|
||||||
|
const INVENTION_TAG_PATTERN = /^[a-z]+$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a player-supplied invention name is unacceptable, or `null` when it's fine.
|
||||||
|
*
|
||||||
|
* Callers pass the TRIMMED name: leading and trailing spaces are the player's typing,
|
||||||
|
* not part of what they named the thing, and counting them toward the minimum would let
|
||||||
|
* `" a "` through.
|
||||||
|
*/
|
||||||
|
export function inventionNameRejection(value: string): string | null {
|
||||||
|
if (glyphLength(value) < MIN_INVENTION_NAME_LENGTH) {
|
||||||
|
return `Invention names must be at least ${MIN_INVENTION_NAME_LENGTH} characters.`
|
||||||
|
}
|
||||||
|
if (glyphLength(value) > MAX_INVENTION_NAME_LENGTH) {
|
||||||
|
return `Invention names can be at most ${MAX_INVENTION_NAME_LENGTH} characters.`
|
||||||
|
}
|
||||||
|
if (!INVENTION_NAME_PATTERN.test(value)) {
|
||||||
|
return 'Invention names can only contain letters, numbers, spaces, dashes and colons.'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why an invention description is unacceptable, or `null` when it's fine. Length only —
|
||||||
|
* a description is prose, so nothing is refused for the characters it's made of, and an
|
||||||
|
* empty one is fine (it's how a creator clears the field).
|
||||||
|
*/
|
||||||
|
export function inventionDescriptionRejection(value: string): string | null {
|
||||||
|
if (glyphLength(value) > MAX_INVENTION_DESCRIPTION_LENGTH) {
|
||||||
|
return `Invention descriptions can be at most ${MAX_INVENTION_DESCRIPTION_LENGTH} characters.`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why an invention tag is unacceptable, or `null` when it's fine. Pass the NORMALIZED
|
||||||
|
* tag (trimmed and lowercased, as `setInventionTags` stores it) — checking what was typed
|
||||||
|
* instead would refuse `Racing` for a capital that never reaches the database.
|
||||||
|
*/
|
||||||
|
export function inventionTagRejection(value: string): string | null {
|
||||||
|
if (value.length > MAX_INVENTION_TAG_LENGTH) {
|
||||||
|
return `Invention tags can be at most ${MAX_INVENTION_TAG_LENGTH} characters.`
|
||||||
|
}
|
||||||
|
if (!INVENTION_TAG_PATTERN.test(value)) {
|
||||||
|
return 'Invention tags can only contain letters.'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a supplied email is one worth storing — RFC 5321/5322 syntax, via `isemail`.
|
||||||
|
*
|
||||||
|
* A hand-rolled pattern is the wrong shape of work here: this is a contact address
|
||||||
|
* nothing is ever sent to in order to prove it, so the only thing a stricter regex buys
|
||||||
|
* is more edge cases to get wrong. Note it also enforces the RFC's 254-character maximum
|
||||||
|
* itself, which is why there's no separate length cap.
|
||||||
|
*
|
||||||
|
* It accepts a dotless domain (`someone@localhost`), which a dotted-domain rule would
|
||||||
|
* refuse. That's the RFC being right and the shortcut being wrong, and an undeliverable
|
||||||
|
* address costs nothing here.
|
||||||
|
*/
|
||||||
|
export function isValidEmail(value: string): boolean {
|
||||||
|
return isEmail.validate(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a supplied bio is within the stored length. */
|
||||||
|
export function isValidBio(value: string): boolean {
|
||||||
|
return value.length <= MAX_BIO_LENGTH
|
||||||
|
}
|
||||||
@@ -33,9 +33,44 @@ function addIntegerExamples(node: unknown): void {
|
|||||||
for (const value of Object.values(obj)) addIntegerExamples(value)
|
for (const value of Object.values(obj)) addIntegerExamples(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hono-openapi registers a `validator('form', …)` body under `multipart/form-data` ONLY,
|
||||||
|
* and there is no way to ask it for another media type: its media selection reads
|
||||||
|
* `options?.media ?? target === 'json' ? 'application/json' : 'multipart/form-data'`,
|
||||||
|
* which by JS precedence is `((options?.media ?? target) === 'json') ? … : …` — so the
|
||||||
|
* `media` option can never produce anything else.
|
||||||
|
*
|
||||||
|
* That leaves the spec claiming a validated route accepts only multipart, when the real
|
||||||
|
* callers (the Rec Room client and the website) post `application/x-www-form-urlencoded`
|
||||||
|
* and Hono's `parseBody()` reads both. So the urlencoded variant is mirrored back in.
|
||||||
|
*
|
||||||
|
* Safe because nothing here documents a genuinely multipart-only body — there are no file
|
||||||
|
* uploads on these workers, and hand-written form bodies already declare both types. If
|
||||||
|
* one is ever added, it will need to opt out of this.
|
||||||
|
*/
|
||||||
|
function mirrorFormBodies(node: unknown): void {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const item of node) mirrorFormBodies(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (node === null || typeof node !== 'object') return
|
||||||
|
|
||||||
|
const obj = node as Record<string, unknown>
|
||||||
|
const content = obj.content
|
||||||
|
if (content !== null && typeof content === 'object') {
|
||||||
|
const media = content as Record<string, unknown>
|
||||||
|
const multipart = media['multipart/form-data']
|
||||||
|
if (multipart !== undefined && media['application/x-www-form-urlencoded'] === undefined) {
|
||||||
|
media['application/x-www-form-urlencoded'] = multipart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const value of Object.values(obj)) mirrorFormBodies(value)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap `openAPIRouteHandler(...)` so the generated document gets example values for its
|
* Wrap `openAPIRouteHandler(...)` so the generated document gets example values for its
|
||||||
* integer fields. Purely cosmetic — nothing about the documented shapes changes.
|
* integer fields, and so a validated form body documents both content types it really
|
||||||
|
* accepts. Nothing about the runtime behaviour changes — this only corrects the document.
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
* app.get('/openapi.json', describeRoute({ hide: true }), withCleanSpec(openAPIRouteHandler(app, { ... })))
|
* app.get('/openapi.json', describeRoute({ hide: true }), withCleanSpec(openAPIRouteHandler(app, { ... })))
|
||||||
@@ -47,6 +82,7 @@ export function withCleanSpec(handler: Handler | MiddlewareHandler): Handler {
|
|||||||
if (!(res instanceof Response)) return res as never
|
if (!(res instanceof Response)) return res as never
|
||||||
const spec: unknown = await res.json()
|
const spec: unknown = await res.json()
|
||||||
addIntegerExamples(spec)
|
addIntegerExamples(spec)
|
||||||
|
mirrorFormBodies(spec)
|
||||||
return c.json(spec as Record<string, unknown>)
|
return c.json(spec as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,16 +48,61 @@ CONFIG="wrangler.jsonc"
|
|||||||
# (main: index.js), the resolved assets.directory, and no_bundle. The committed
|
# (main: index.js), the resolved assets.directory, and no_bundle. The committed
|
||||||
# wrangler.jsonc leaves assets.directory out on purpose — the plugin fills it in —
|
# wrangler.jsonc leaves assets.directory out on purpose — the plugin fills it in —
|
||||||
# so deploying the source config fails with "assets ... missing the required
|
# so deploying the source config fails with "assets ... missing the required
|
||||||
# directory property". Prefer the generated config when it exists. These workers
|
# directory property". Prefer the generated config when it exists.
|
||||||
# have no D1/KV/Secrets bindings, so the id-splicing below is skipped.
|
#
|
||||||
|
# The plugin copies the bindings across verbatim, placeholders and all, so these
|
||||||
|
# configs need the same id-splicing as the rest — www binds the Secrets Store for its
|
||||||
|
# Turnstile keys. It gets its own branch below because the emitted file is minified
|
||||||
|
# single-line JSON, which the line-oriented sed/awk passes can't edit correctly.
|
||||||
VITE_CONFIG="dist/$DIR/wrangler.json"
|
VITE_CONFIG="dist/$DIR/wrangler.json"
|
||||||
|
IS_VITE=""
|
||||||
if [ -f "$VITE_CONFIG" ]; then
|
if [ -f "$VITE_CONFIG" ]; then
|
||||||
CONFIG="$VITE_CONFIG"
|
CONFIG="$VITE_CONFIG"
|
||||||
|
IS_VITE=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
NEEDS_D1=$(grep -q '"database_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
|
NEEDS_D1=$(grep -q '"database_id": *"local"' "$CONFIG" 2>/dev/null && echo 1 || true)
|
||||||
NEEDS_KV=$(grep -q '"id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
|
NEEDS_KV=$(grep -q '"id": *"local"' "$CONFIG" 2>/dev/null && echo 1 || true)
|
||||||
NEEDS_STORE=$(grep -q '"store_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
|
NEEDS_STORE=$(grep -q '"store_id": *"local"' "$CONFIG" 2>/dev/null && echo 1 || true)
|
||||||
|
|
||||||
|
# Vite-built config: plain JSON, so jq does the splicing structurally (by binding
|
||||||
|
# name for KV) rather than by line. Written beside the original so its relative
|
||||||
|
# paths (main, assets.directory) still resolve. Gitignored; removed on exit.
|
||||||
|
if [ "$CONFIG" = "$VITE_CONFIG" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; }; then
|
||||||
|
GENERATED="dist/$DIR/wrangler.generated.json"
|
||||||
|
trap 'rm -f "$GENERATED"' EXIT
|
||||||
|
|
||||||
|
if [ -n "$NEEDS_D1" ] && [ -z "${RECFLARE_D1:-}" ]; then
|
||||||
|
echo "error: RECFLARE_D1 is not set — add the recflare D1 id to .env (see .env.example)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -n "$NEEDS_STORE" ] && [ -z "${RECFLARE_SECRETS_STORE:-}" ]; then
|
||||||
|
echo "error: RECFLARE_SECRETS_STORE is not set — add the secrets store id to .env (see .env.example)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
KV_JSON=${RECFLARE_KV:-}
|
||||||
|
[ -n "$KV_JSON" ] || KV_JSON='{}'
|
||||||
|
|
||||||
|
jq \
|
||||||
|
--arg db "${RECFLARE_D1:-}" \
|
||||||
|
--arg store "${RECFLARE_SECRETS_STORE:-}" \
|
||||||
|
--argjson kv "$KV_JSON" '
|
||||||
|
(.d1_databases // []) |= map(
|
||||||
|
if .database_id == "local" then .database_id = $db else . end
|
||||||
|
)
|
||||||
|
| (.kv_namespaces // []) |= map(
|
||||||
|
if .id == "local" then
|
||||||
|
.id = ($kv[.binding] //
|
||||||
|
error("no KV id for binding [" + .binding + "] in RECFLARE_KV — add it to .env (see .env.example)"))
|
||||||
|
else . end
|
||||||
|
)
|
||||||
|
| (.secrets_store_secrets // []) |= map(
|
||||||
|
if .store_id == "local" then .store_id = $store else . end
|
||||||
|
)
|
||||||
|
' "$VITE_CONFIG" >"$GENERATED" || exit 1
|
||||||
|
CONFIG="$GENERATED"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$CONFIG" = "wrangler.jsonc" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; }; then
|
if [ "$CONFIG" = "wrangler.jsonc" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; }; then
|
||||||
CONFIG="wrangler.generated.jsonc"
|
CONFIG="wrangler.generated.jsonc"
|
||||||
@@ -131,8 +176,10 @@ EXTRA_VARS=$(recflare_vars)
|
|||||||
|
|
||||||
# Vite-built configs set no_bundle (vite already bundled and minified), which is
|
# Vite-built configs set no_bundle (vite already bundled and minified), which is
|
||||||
# incompatible with --minify. Only pass --minify when wrangler does the bundling.
|
# incompatible with --minify. Only pass --minify when wrangler does the bundling.
|
||||||
|
# Keyed on IS_VITE, not on $CONFIG: the splicing above may have swapped $CONFIG for
|
||||||
|
# the generated copy, which is just as no_bundle as the file it came from.
|
||||||
MINIFY="--minify"
|
MINIFY="--minify"
|
||||||
[ "$CONFIG" = "$VITE_CONFIG" ] && MINIFY=""
|
[ -n "$IS_VITE" ] && MINIFY=""
|
||||||
|
|
||||||
# Deploy with wrangler using the extracted values as binding variables
|
# Deploy with wrangler using the extracted values as binding variables
|
||||||
echo "Deploying worker $NAME version $VERSION to $HOST"
|
echo "Deploying worker $NAME version $VERSION to $HOST"
|
||||||
|
|||||||
Generated
+18
@@ -864,6 +864,10 @@ importers:
|
|||||||
version: 4.105.0(@cloudflare/workers-types@4.20260630.1)
|
version: 4.105.0(@cloudflare/workers-types@4.20260630.1)
|
||||||
|
|
||||||
packages/domain:
|
packages/domain:
|
||||||
|
dependencies:
|
||||||
|
isemail:
|
||||||
|
specifier: ^3.2.0
|
||||||
|
version: 3.2.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@cloudflare/workers-types':
|
'@cloudflare/workers-types':
|
||||||
specifier: 4.20260630.1
|
specifier: 4.20260630.1
|
||||||
@@ -3174,6 +3178,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==}
|
resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
isemail@3.2.0:
|
||||||
|
resolution: {integrity: sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==}
|
||||||
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
jiti@2.6.1:
|
jiti@2.6.1:
|
||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -3542,6 +3550,10 @@ packages:
|
|||||||
property-information@7.2.0:
|
property-information@7.2.0:
|
||||||
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
|
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
|
||||||
|
|
||||||
|
punycode@2.3.1:
|
||||||
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
quansync@0.2.11:
|
quansync@0.2.11:
|
||||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||||
|
|
||||||
@@ -6154,6 +6166,10 @@ snapshots:
|
|||||||
|
|
||||||
is-regexp@3.1.0: {}
|
is-regexp@3.1.0: {}
|
||||||
|
|
||||||
|
isemail@3.2.0:
|
||||||
|
dependencies:
|
||||||
|
punycode: 2.3.1
|
||||||
|
|
||||||
jiti@2.6.1:
|
jiti@2.6.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6677,6 +6693,8 @@ snapshots:
|
|||||||
|
|
||||||
property-information@7.2.0: {}
|
property-information@7.2.0: {}
|
||||||
|
|
||||||
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
quansync@0.2.11: {}
|
quansync@0.2.11: {}
|
||||||
|
|
||||||
radix-vue@1.9.17(vue@3.5.40(typescript@6.0.3)):
|
radix-vue@1.9.17(vue@3.5.40(typescript@6.0.3)):
|
||||||
|
|||||||
Reference in New Issue
Block a user