32 Commits

Author SHA1 Message Date
Devin Zuczek cc43d57172 Reapply "the client uploading something with 2 as the type expects it to have a /data/ prefix (holotar for example it seems)"
This reverts commit 6b7acc9435.
2026-08-05 17:58:11 -04:00
Devin Zuczek 8b804eaa33 add CDN /data/ endpoint 2026-08-05 17:54:07 -04:00
Devin Zuczek 6b7acc9435 Revert "the client uploading something with 2 as the type expects it to have a /data/ prefix (holotar for example it seems)"
This reverts commit 93a46871de.
2026-08-05 17:53:42 -04:00
Devin Zuczek 93a46871de the client uploading something with 2 as the type expects it to have a /data/ prefix (holotar for example it seems) 2026-08-05 17:27:27 -04:00
Devin Zuczek 4111bc49aa limit slideshow to 10 by default 2026-08-05 17:01:45 -04:00
Devin Zuczek f6561f1ec9 adjust "top" window 2026-08-05 15:49:59 -04:00
Devin Zuczek bc96a6245b hot inventions 2026-08-05 15:45:48 -04:00
Devin Zuczek a73dec7c13 extract published field for convenience 2026-08-05 15:39:25 -04:00
Devin Zuczek ae3bef4cc4 real featured inventions 2026-08-05 15:30:50 -04:00
Devin Zuczek 1f615bab4f fix incorrect balance push notification 2026-08-05 15:27:13 -04:00
Devin Zuczek a986d012f5 invention purchase, at least, how I think they should work 2026-08-05 14:58:55 -04:00
Devin Zuczek b82a5e1dc0 add some basic validation 2026-08-05 14:00:31 -04:00
Devin Zuczek 6bfd4d9e50 validation in some areas, maybe move this to schema later 2026-08-05 12:08:04 -04:00
Devin Zuczek 079c889ccb remove proxies and use native services w/cors 2026-08-05 11:13:15 -04:00
Devin Zuczek 9f4ce07aca track owned inventions 2026-08-04 22:33:47 -04:00
Devin Zuczek dfb1e9ab21 put back stubbed meta auth for sideloaded apks 2026-08-04 21:13:33 -04:00
Devin Zuczek 1d08ed8296 fix too many accounts error because the worker was behind a Cloudflare call, add username change 2026-08-04 21:04:00 -04:00
Devin Zuczek f3e2ab422c add friendly message 2026-08-04 20:10:16 -04:00
Devin Zuczek aa304dbede (wip) attendees 2026-08-04 18:54:40 -04:00
Devin Zuczek 10eb89ac12 (wip) events 2026-08-04 18:44:11 -04:00
Devin Zuczek 65611c15d8 fix custom loading screens, add private endpoint for match into instance 2026-08-04 17:50:21 -04:00
Devin Zuczek d6a0e3e6a6 add a few admin endpoints for matching into instances 2026-08-04 17:49:53 -04:00
Devin Zuczek 7d300fa836 update shape of room instance browser 2026-08-04 17:19:03 -04:00
Devin Zuczek 73bb7c4609 implement room ban 2026-08-04 15:41:49 -04:00
Devin Zuczek dbc6d15ef5 fix issue where polaroids come in with a different URL, pull from other bucket 2026-08-04 14:08:39 -04:00
Devin Zuczek db003d54ef add a non working banlist 2026-08-04 13:26:12 -04:00
Devin Zuczek 05b56e698e add room invites 2026-08-04 12:45:11 -04:00
Devin Zuczek dee7497fe5 add mutual friends endpoint 2026-08-04 12:26:44 -04:00
Devin Zuczek 12f6d7ab61 add warning table (maybe this is just a notification, later) 2026-08-04 12:06:09 -04:00
Devin Zuczek 8d1539de03 add player reports 2026-08-04 11:42:53 -04:00
Devin Zuczek 7e0c26a100 update quest invite 2026-08-04 09:47:16 -04:00
Devin Zuczek f94877347c free invention purchase 2026-08-04 02:01:51 -04:00
73 changed files with 6876 additions and 793 deletions
+5
View File
@@ -70,6 +70,11 @@ inconsistency here without checking the client first.
- A club's `AdditionalImages` (`clubs`) is an array of whole `SavedImage` records, not
image names — a bare string array fails the client's parser ("expected '{'"). The list
is packed: removing an image shifts the rest up, never leaving a blank slot.
- A room's `LoadScreens` (`rooms`: `PUT /rooms/:id/loadscreen`) is an array — the
client's parser wants one — but the client renders only the FIRST entry and only ever
posts one. So the endpoint REPLACES the list rather than appending: an appended screen
sits unreachable behind the old one and setting a load screen looks like it did
nothing. Keep the array shape for eventual multi-screen support.
- Endpoints the client re-renders from must return the updated entity, not
`{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old
clubhouse on screen until it answered the full details envelope.
+9
View File
@@ -267,6 +267,15 @@ printf '1x0000000000000000000000000000000AA' |
Both `auth` account caps above still apply on top of the bot check, and the per-IP one is
the only cap that can see a web signup.
`www` reaches `auth` through a **service binding**, not over `auth.<DOMAIN>`, so that the
player's real IP survives the hop: a Worker subrequest to the public hostname re-enters
the Cloudflare edge, which rewrites `CF-Connecting-IP` to Cloudflare's own address, and
`auth` would then record one shared `signupIp` for every web account and cap the whole
internet at three. Two consequences: **deploy `auth` before `www`** on a fresh account
(the binding refuses to resolve otherwise), and web accounts created before this change
carry that shared address as their permanent `signupIp` — harmless, but they are not
counted against any real network.
## Repository Structure
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
+54 -27
View File
@@ -1,5 +1,5 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { describeRoute, openAPIRouteHandler, validator } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
@@ -11,7 +11,7 @@ import {
searchAccounts,
updateAccount,
} 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 {
@@ -69,12 +69,18 @@ function unauthorized(c: Context<App>) {
const DEFAULT_USERNAME_CHANGES = 1
/**
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
* On success `value` is the updated account; on error `error` carries the message
* and `value` is an empty string.
* Username-change result envelope: `{ success, error, value }`. On success `value` is
* the updated account; on a refusal `error` carries the message and `value` is an empty
* string.
*
* A refusal is a 400. The body shape is unchanged — anything reading `error` still
* works — but it used to come back at HTTP 200, which meant a caller keying off the
* status read every refusal as a success. That envelope-at-200 was the reference's
* (`RecNet`) convention and is kept by `POST /account/create`; here it was traded for a
* status a client can actually branch on.
*/
function usernameResult(c: Context<App>, error = '', value: unknown = '') {
return c.json({ success: error === '', error, value })
return c.json({ success: error === '', error, value }, error === '' ? 200 : 400)
}
/** Read a single string field from a form-urlencoded / multipart body. */
@@ -161,6 +167,14 @@ const app = new Hono<App>()
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -411,18 +425,20 @@ const app = new Hono<App>()
summary: 'Set display name',
description: 'Persisted and broadcast via an AccountUpdate notification.',
security: AUTHED,
requestBody: form(DisplayNameRequest, 'The new display name'),
responses: {
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,
},
}),
// An EMPTY 400, which is what this route already answered for an empty name: it
// acks with a bare SuccessResponse and has never sent the client a body on
// failure, so enforcing the schema doesn't change what a refusal looks like.
validator('form', DisplayNameRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const displayName = (await formField(c, 'displayName')).trim()
if (displayName === '') return c.body(null, 400)
const { displayName } = c.req.valid('form')
const account = await updateAccount(c.env.DB, id, { displayName })
await pushAccountUpdate(c, account)
return c.json({ success: true })
@@ -438,23 +454,34 @@ const app = new Hono<App>()
tags: ['Profile'],
summary: 'Change username',
description: [
'Rejects a name taken by another account and requires a remaining change; on',
'success the name is persisted and the counter decremented. Always HTTP 200 —',
'failures carry a message in `error` (see the UsernameResult envelope).',
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
'account and requires a remaining change; on success the name is persisted and',
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
'(see the UsernameResult envelope).',
].join(' '),
security: AUTHED,
requestBody: form(UsernameRequest, 'The desired username'),
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,
},
}),
// Shape is checked before the handler runs, so a rejected name costs no D1 read and
// — the part that matters — can never spend one of the account's rationed changes.
// The message is relayed rather than zod's issue array: `nameRejection` writes the
// sentence the player reads, and nothing can render an array of issues.
// `c` is annotated so the hook's context matches this app's bindings, and `error` is
// Standard Schema's flat issue list rather than a zod error object.
validator('form', UsernameRequest, (r, c: Context<App>) =>
r.success
? undefined
: usernameResult(c, r.error[0]?.message ?? 'That username cannot be used.')
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const username = (await formField(c, 'username')).trim()
if (username === '') return usernameResult(c, 'You must enter a username.')
const { username } = c.req.valid('form')
// Duplicate check first (case-insensitive); keeping your own name is allowed.
const existing = await getAccountByUsername(c.env.DB, username)
@@ -486,18 +513,17 @@ const app = new Hono<App>()
summary: 'Set email',
description: 'Persisted; surfaced only by `/account/me`. Not broadcast.',
security: AUTHED,
requestBody: form(EmailRequest, 'The new email'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Email without an “@” (empty body)' },
400: { description: 'Not a syntactically valid address (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
validator('form', EmailRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const email = (await formField(c, 'email')).trim()
if (!email.includes('@')) return c.body(null, 400)
const { email } = c.req.valid('form')
await updateAccount(c.env.DB, id, { email })
return c.json({ success: true })
}
@@ -511,18 +537,17 @@ const app = new Hono<App>()
summary: 'Set phone number',
description: 'Persisted on the account row. Not broadcast.',
security: AUTHED,
requestBody: form(PhoneRequest, 'The new phone number'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty phone (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
validator('form', PhoneRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const phone = (await formField(c, 'phone')).trim()
if (phone === '') return c.body(null, 400)
const { phone } = c.req.valid('form')
await updateAccount(c.env.DB, id, { phone })
return c.json({ success: true })
}
@@ -597,18 +622,20 @@ const app = new Hono<App>()
describeRoute({
tags: ['Profile'],
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,
requestBody: form(BioRequest, 'The new bio'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Bio over 255 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
// Refused rather than truncated: silently storing half a sentence reads as data loss.
validator('form', BioRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const bio = await formField(c, 'bio')
const { bio } = c.req.valid('form')
const account = await updateAccount(c.env.DB, id, { bio })
await pushAccountUpdate(c, account)
return c.json({ success: true })
+65 -14
View File
@@ -1,20 +1,35 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import {
isValidBio,
isValidEmail,
MAX_DISPLAY_NAME_LENGTH,
MAX_USERNAME_LENGTH,
nameRejection,
} from '@repo/domain'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the accounts worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`.
* Most of these are DESCRIPTIVE ONLY: they are passed to `describeRoute` to generate the
* spec, and the handler stays lenient. That is deliberate — the Rec Room client is the
* real consumer, form fields are read as `typeof value === 'string' ? value : ''`, and
* missing or malformed input falls through to a graceful path (or a synthesized default
* account) rather than a hard error. A schema that rejected what the client actually
* sends would break the game, not protect it.
*
* As with the auth worker, this is deliberate. The Rec Room client is the only real
* consumer and the handlers are intentionally lenient — form fields are read as
* `typeof value === 'string' ? value : ''` and missing/malformed input falls through
* to a graceful path (or a synthesized default account) rather than a hard error.
* These schemas record what the client is observed to send and what we send back; to
* enforce one, do it per-route and land a test with it.
* The EXCEPTION is the profile mutations a player types into a box — displayName,
* username, email, phone, bio. Those carry real rules (see `@repo/domain`), and each is
* wired into `hono-openapi`'s `validator()` per route, with tests, exactly as the older
* version of this note prescribed. Wiring one up means the schema both validates the
* request and generates the spec, so a limit can't be changed in one and not the other —
* which is precisely how the documented email limit came to disagree with the real one.
*
* A validated route drops `requestBody: form(...)` from its `describeRoute`: the
* validator registers the body itself, and declaring it twice would emit it twice.
*/
/** Emit a zod schema as an `application/json` response body. */
@@ -122,21 +137,54 @@ export const CreateAccountRequest = z.object({
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({
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({
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({
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({
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({
@@ -147,7 +195,10 @@ export const PronounsRequest = z.object({
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({
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
+178 -3
View File
@@ -220,8 +220,9 @@ describe('auth-gated endpoints', () => {
...form({ username: 'Coach' }),
headers: { ...(await bearer('893')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
// Business errors are HTTP 200 with the { success, error, value } envelope.
expect(res.status).toBe(200)
// A refusal is a 400 carrying the same { success, error, value } envelope. It used
// to be HTTP 200, which read as a success to anything branching on the status.
expect(res.status).toBe(400)
const body = (await res.json()) as { success: boolean; error: string; value: string }
expect(body.success).toBe(false)
expect(body.error).toMatch(/already taken/i)
@@ -260,7 +261,7 @@ describe('auth-gated endpoints', () => {
...form({ username: 'coachy' }),
headers,
})
expect(blocked.status).toBe(200)
expect(blocked.status).toBe(400)
const blockedBody = (await blocked.json()) as { success: boolean; error: string }
expect(blockedBody.success).toBe(false)
expect(blockedBody.error).toMatch(/no username changes/i)
@@ -459,4 +460,178 @@ describe('auth-gated endpoints', () => {
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')
})
+23
View File
@@ -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);
+21
View File
@@ -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);
+25
View File
@@ -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
View File
@@ -2,10 +2,11 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
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 { configRoutes } from './routes/config'
import { eventRoutes } from './routes/events'
import { gameplayRoutes } from './routes/gameplay'
import { imageRoutes } from './routes/images'
import { inventoryRoutes } from './routes/inventory'
@@ -39,6 +40,14 @@ const app = new Hono<App>({ strict: false })
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -48,6 +57,7 @@ const app = new Hono<App>({ strict: false })
.route('/', progressionRoutes)
.route('/', avatarRoutes)
.route('/', gameplayRoutes)
.route('/', eventRoutes)
.route('/', moderationRoutes)
.route('/', inventoryRoutes)
.route('/', roomRoutes)
@@ -68,9 +78,9 @@ app.get(
'The catch-all Game API for recflare, a private-server reimplementation of the Rec',
'Room backend: everything the client calls that has not been split out into its own',
'worker yet. Today that is config, the friend graph, inventions, saved photos,',
'reputation and the assorted sinks the client hits while loading. Relationships,',
'inventions and images are D1-backed; several endpoints are still stubs, noted per',
'route.',
'player events, reputation and the assorted sinks the client hits while loading.',
'Relationships, inventions, images and player events are D1-backed; several',
'endpoints are still stubs, noted per route.',
'',
'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,',
'equipment, consumables and objectives on `econ`) are already served there — the',
+614
View File
@@ -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
View File
@@ -1,4 +1,4 @@
import { validateAndGetAccountId } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import type { Context } from 'hono'
import type { App } from './context'
@@ -12,6 +12,16 @@ export async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* The `role` claim from a Bearer token — the operator-granted roles the auth worker
* stamps from the account's flags (a plain player's token is just `['gameClient']`).
* `null` when the request carries no valid token, which callers treat as a 401; an
* empty array means a valid token with no roles. Shaped to mirror {@link authedId}.
*/
export async function authedRoles(c: Context<App>): Promise<string[] | null> {
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Results.Unauthorized() equivalent — 401 with empty body. */
export function unauthorized(c: Context<App>) {
return c.body(null, 401)
+9 -2
View File
@@ -299,8 +299,15 @@ export function toImagesPlayer(img: SavedImage): ImagesPlayer {
}
}
/** Default number of recent images the slideshow feed returns. */
export const SLIDESHOW_LIMIT = 130
/** How many recent images the slideshow feed returns when the caller doesn't say. */
export const SLIDESHOW_LIMIT = 10
/**
* The most a caller can ask the slideshow feed for. The endpoint is public and
* unauthenticated, so the cap is what keeps an arbitrary `take` from turning into a
* scan of the whole image table plus the two batched joins behind it.
*/
export const SLIDESHOW_MAX_LIMIT = 100
/** The slideshow projection of an image — creator username + room name joined in. */
export interface SlideshowImage {
+89 -31
View File
@@ -1,8 +1,8 @@
/**
* Saved-invention storage on the shared `recflare` D1 database. Each invention is
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId)
* are SQLite generated (virtual) columns extracted from that JSON — the same
* JSON-blob pattern the image/rooms/accounts tables use.
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId, the
* visibility flags) are SQLite generated (virtual) columns extracted from that JSON —
* the same JSON-blob pattern the image/rooms/accounts tables use.
*
* The `api` worker owns this schema/migration (migrations/0002_invention.sql,
* applied under its own `migrations_table`). The invention's data file itself is
@@ -12,19 +12,29 @@
* The stored/returned DTO mirrors Rec Room's `RRInvention` (PascalCase), including
* the nested `CurrentVersion` that carries the blob name and per-version costs —
* shaped after a real `GET /api/inventions/v1?inventionId=…` response.
*
* Who OWNS an invention is a separate table (`inventory_invention`, written by the
* `econ` worker at purchase time); this module only reads it — to fold bought inventions
* into the caller's own list, and to rank the "top today" feed by what players actually
* picked up today. See @repo/domain's inventory-invention-db.ts.
*/
import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql,
* sans any seed rows). `is_featured` backs the featured feed's query; json_extract
* of a JSON `true` is 1, so the column is 1/0.
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql +
* 0008_invention_visibility.sql, sans any seed rows). `is_featured` backs the featured
* feed's query and `is_published`/`hide_from_player` the "may anyone see this" filter
* every feed shares; json_extract of a JSON `true` is 1, so those columns are 1/0 — and
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS invention (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.InventionId')) VIRTUAL,
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
is_published INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsPublished')) VIRTUAL,
hide_from_player INTEGER GENERATED ALWAYS AS (json_extract(data, '$.HideFromPlayer')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
@@ -265,6 +275,32 @@ export async function getInventionsByCreator(
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
}
/**
* The player's "my inventions" shelf (`v2/mine`): everything they created, plus
* everything they BOUGHT. Ownership of a bought invention lives in the
* `inventory_invention` table the `econ` worker writes at purchase time — a creator is
* never listed there (they own theirs through `CreatorPlayerId`), so the two sets are
* disjoint in practice and merged by id anyway.
*
* Bought inventions are returned whatever their state: unpublished or hidden since the
* purchase, they are still on the shelf of the player who paid for them. An owned id
* with no invention row left (deleted) simply drops out. Newest first, like the other
* invention lists; not paginated.
*/
export async function getMyInventions(db: D1Database, playerId: number): Promise<SavedInvention[]> {
const [created, ownedIds] = await Promise.all([
getInventionsByCreator(db, playerId),
getOwnedInventionIds(db, playerId),
])
const bought = await getInventionsByIds(db, ownedIds)
const byId = new Map<number, SavedInvention>()
for (const invention of [...created, ...bought]) byId.set(invention.InventionId, invention)
return [...byId.values()].sort(
(a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId
)
}
/**
* Invention search — the browse/search list the client shows when picking an
* invention to spawn. Only published, non-hidden inventions are visible here (a
@@ -304,50 +340,73 @@ export async function searchInventions(
* ones via the indexed `is_featured` column.
*/
async function publicInventions(db: D1Database, featuredOnly = false): Promise<SavedInvention[]> {
// json_extract of a JSON `true` is 1, so these filters stay in SQL.
// All three are generated columns off the JSON blob, so the filter stays in SQL.
const { results } = await db
.prepare(
`SELECT data FROM invention
WHERE json_extract(data, '$.IsPublished') = 1
AND json_extract(data, '$.HideFromPlayer') = 0
WHERE is_published = 1
AND hide_from_player = 0
${featuredOnly ? 'AND is_featured = 1' : ''}`
)
.all<InventionRow>()
return results.map((r) => JSON.parse(r.data) as SavedInvention)
}
/** Engagement score used to rank the top feed (downloads weigh most, then cheers). */
function topScore(invention: SavedInvention): number {
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
return (
n(invention.NumDownloads) * 3 +
n(invention.CheerCount) * 2 +
n(invention.NumPlayersHaveUsedInRoom)
)
/** Length of the "today" window — a trailing day, not the calendar one. */
const TOP_TODAY_WINDOW_MS = 24 * 60 * 60 * 1000
/** 24 hours ago, as the ISO timestamp `acquired_at` is compared against. */
function startOfWindow(): string {
return new Date(Date.now() - TOP_TODAY_WINDOW_MS).toISOString()
}
/**
* The "top today" feed — published inventions ranked by engagement. The real feed
* ranks by *today's* activity; we don't track per-day counters, so this ranks by
* lifetime engagement instead. Ties fall back to invention id so paging is stable.
* Paginated via skip/take; returns a bare array, like the other invention feeds.
* The "top today" feed — the inventions other players picked up in the last 24 hours,
* most first.
*
* Ranked from the acquisitions the `econ` worker records in `inventory_invention` at
* purchase time, grouped by invention, rather than from the lifetime counters on the
* invention itself: those never reset, so "top today" used to mean "top ever" and the
* shelf only changed when something overtook a total built up over months.
*
* "Today" is a TRAILING 24 hours, not the calendar UTC day, so the feed doesn't empty
* itself at midnight UTC and slowly refill through the small hours — it always covers a
* full day's worth of activity. It is still genuinely a window: an invention nobody has
* picked up since yesterday falls off, and the feed IS EMPTY when nothing at all was
* acquired in a day. Nothing stands in for it, the same way the featured feed serves
* nothing while nothing is curated.
*
* An acquired invention that has since been unpublished or hidden drops out: this is a
* public feed, so it is filtered like every other one. Paginated via skip/take AFTER
* that filtering, so a hidden invention doesn't leave a hole in a page.
*/
export async function getTopInventions(
db: D1Database,
skip: number,
take: number
): Promise<SavedInvention[]> {
const inventions = await publicInventions(db)
return inventions
.sort((a, b) => topScore(b) - topScore(a) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
const counts = await getInventionAcquisitionCounts(db, startOfWindow())
if (counts.length === 0) return []
// getInventionsByIds answers in the order it is asked, so the ranking survives the
// load; ids with no invention row left (deleted) simply drop out.
const ranked = await getInventionsByIds(
db,
counts.map((c) => c.inventionId)
)
return ranked.filter((i) => i.IsPublished && !i.HideFromPlayer).slice(skip, skip + take)
}
/**
* The featured feed — published inventions flagged `IsFeatured`, newest first.
* Selected on the indexed `is_featured` column rather than by parsing every public
* invention. Nothing sets that flag yet, so this falls back to the top feed rather
* than handing the client an empty shelf; once inventions are curated it serves them.
* invention.
*
* Curated means curated: when nothing is flagged this serves an EMPTY list rather than
* standing in the top feed. It used to fall back, from when no invention could be
* featured at all, but a fallback makes the shelf lie — the client labels these as
* hand-picked, and a feed that silently becomes "top today" hides the fact that nobody
* has picked anything.
*/
export async function getFeaturedInventions(
db: D1Database,
@@ -355,7 +414,6 @@ export async function getFeaturedInventions(
take: number
): Promise<SavedInvention[]> {
const featured = await publicInventions(db, true)
if (featured.length === 0) return getTopInventions(db, skip, take)
return featured
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
@@ -579,8 +637,8 @@ export async function getInventionsByRoom(
.prepare(
`SELECT data FROM invention
WHERE json_extract(data, '$.CreationRoomId') = ?1
AND json_extract(data, '$.IsPublished') = 1
AND json_extract(data, '$.HideFromPlayer') = 0`
AND is_published = 1
AND hide_from_player = 0`
)
.bind(roomId)
.all<InventionRow>()
+153 -6
View File
@@ -97,6 +97,16 @@ export const BareString = z.string()
/** The `{ error }` body the 400 / 403 branches return. */
export const ErrorResponse = z.object({ error: z.string() })
/**
* The `{ success, error }` envelope the report / warning writes and the message send
* answer with — `error` is an empty string on success, never null, and the rejected
* branches use the same shape so there is only one thing to parse.
*/
export const SuccessErrorEnvelope = z.object({
success: z.boolean(),
error: z.string().describe('Empty string when the call succeeded'),
})
// ---- Config ----------------------------------------------------------------
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
@@ -160,9 +170,34 @@ export const RelationshipDto = z.object({
Muted: z.int().describe('0/1 — the callers own flag'),
})
/**
* `POST /api/messages/v2/send` form body — a message sent to another player. Everything
* is a string on the wire (it's form-encoded). The sender is NOT in the body — it's
* taken from the bearer token.
*/
export const SendMessageRequest = z.object({
ToPlayerId: z.string().describe('Account id of the recipient'),
Type: z
.string()
.optional()
.describe('The Message-model type, e.g. `10`. Passed through unmapped; defaults to 0'),
Data: z.string().optional().describe('The message payload; often empty'),
})
/** The `{ Success, Message }` ack the flag toggles answer with. */
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
/**
* One entry of `GET /api/relationships/mutualfriends` — a friend both players share.
* A trimmed account card, not a relationship: no relationship type or flags.
*/
export const MutualFriendDto = z.object({
AccountId: z.int(),
Username: z.string(),
DisplayName: z.string(),
ProfileImage: z.string().describe('The image name; an empty string when the account has none'),
})
// ---- Progression -----------------------------------------------------------
/**
@@ -285,8 +320,14 @@ export const InventionPersonalDetails = z.object({
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
export const SetTagsRequest = z.object({
InventionId: z.int(),
AutoTags: z.array(z.string()).optional().describe('Client-derived tags (Type 2)'),
CustomTags: z.array(z.string()).optional().describe('Creator-submitted tags (Type 0)'),
AutoTags: z
.array(z.string())
.optional()
.describe('Client-derived tags (Type 2); each at most 15 letters once lowercased'),
CustomTags: z
.array(z.string())
.optional()
.describe('Creator-submitted tags (Type 0); each at most 15 letters once lowercased'),
})
/** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */
@@ -306,8 +347,14 @@ export const SaveInventionRequest = z.object({
inventionDataFilename: z
.string()
.describe('The blob uploaded through the storage worker; the one required field'),
name: z.string().optional().describe('Defaults to “Untitled”'),
description: z.string().optional(),
name: z
.string()
.optional()
.describe('324 chars: letters, digits, spaces, dashes, colons. Omitted/blank ⇒ “Untitled”'),
description: z
.string()
.optional()
.describe('At most 512 chars. Omitted/blank ⇒ “No description yet”'),
imageName: z.string().optional(),
instantiationCost: z.int().optional(),
lightsCost: z.int().optional(),
@@ -373,10 +420,68 @@ export const KeepsakeConfig = z.object({
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 doesnt pin a subroom'),
ClubId: z.int().nullable().describe('Null when the event isnt a clubs'),
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 events 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 Cant go'),
})
/** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */
export const PlayerEventsAll = z.object({
Created: JsonArray,
Responses: JsonArray,
Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'),
Responses: JsonArray.describe(
'Events the caller RSVPd to — always empty; RSVPs are stored, but this fields ' +
'entry shape has not been observed yet'
),
})
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
@@ -411,6 +516,48 @@ export const ModerationBlockDetails = z.object({
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('Reporters player height in metres at report time, e.g. `1.64`'),
HeightReported: z.string().optional().describe('Reported players height in metres'),
RoomId: z.string().optional().describe('Room the report was raised in, if any'),
RoomInstanceType: z
.string()
.optional()
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
})
/**
* `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything
* is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
* moderator is NOT in the body — it's taken from the bearer token.
*/
export const CreateWarningRequest = z.object({
WarnedPlayerId: z.string().describe('Account id of the player being warned'),
ReportCategory: z
.string()
.optional()
.describe('The reason category, e.g. `101`. Stored verbatim; unmapped'),
DisplayReason: z
.string()
.optional()
.describe('What the warned player is shown, e.g. `Sexual gestures`'),
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
})
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
export const DeviceIdRequest = z.object({
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
+42
View File
@@ -155,6 +155,48 @@ export async function getRelationshipsForPlayer(
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
* requester. Inserts a new row or, if one already exists for the pair (either
+103
View File
@@ -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
}
+95 -27
View File
@@ -1,17 +1,23 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
inventionDescriptionRejection,
inventionNameRejection,
inventionTagRejection,
} from '@repo/domain'
import { authedId, unauthorized } from '../http'
import {
createInvention,
getFeaturedInventions,
getInventionById,
getInventionsByCreator,
getInventionsByIds,
getInventionsByRoom,
getInventionTagFilters,
getInventionTags,
getInventionVersion,
getMyInventions,
getTopInventions,
parsePermissionLevel,
publishInvention,
@@ -387,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 ' +
'query params. Absent params keep their stored value. An empty `description` ' +
'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' +
'invention. 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,
parameters: [
intQuery('inventionId', 'Invention id; required'),
stringQuery('name', 'New name; empty is ignored'),
stringQuery('description', 'New description; present-but-empty clears it'),
stringQuery('name', '324 chars, letters/digits/spaces/dashes/colons; empty is ignored'),
stringQuery('description', 'Max 512 chars; present-but-empty clears it'),
stringQuery('imageName', 'New thumbnail; empty is ignored'),
stringQuery('allowTrial', '`true`/`1` to allow trials'),
stringQuery('permission', 'A name like `useonly`, or the raw permission number'),
],
responses: {
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
400: json(ErrorResponse, 'A supplied name or description breaks its rule'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers invention'),
404: { description: 'No such invention' },
@@ -416,10 +424,23 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const allowTrial = c.req.query('allowTrial')
const permission = c.req.query('permission')
// Only a name that's actually being changed is checked — an absent or empty one
// keeps the stored name, which was already validated when it was set.
const name = nonEmpty('name')
const nameRejection = name === undefined ? null : inventionNameRejection(name)
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
// The description is checked on presence, not emptiness: empty is how a creator
// clears it, and the length rule accepts that.
const description = c.req.query('description')
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
name: nonEmpty('name'),
name,
// Present-but-empty clears the description, so this checks presence.
description: c.req.query('description'),
description,
imageName: nonEmpty('imageName'),
allowTrial:
allowTrial === undefined
@@ -523,13 +544,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'`CustomTags` are the creators own (Type 0), `AutoTags` the ones the client ' +
'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' +
'only.\n\n' +
'Every tag in either list must be at most 15 letters (az once lowercased); one ' +
'that isnt fails the whole call, so no tag is ever silently dropped.\n\n' +
'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' +
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
security: AUTHED,
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
responses: {
200: json(SetTagsResponse, 'The resulting tag names'),
400: json(ErrorResponse, 'Unparseable body'),
400: json(ErrorResponse, 'Unparseable body, or a tag that breaks the rule'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the callers invention'),
404: { description: 'No such invention' },
@@ -546,11 +569,29 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const strings = (v: unknown): 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(
c.env.DB,
gate.invention.InventionId,
strings(body.AutoTags),
strings(body.CustomTags)
autoTags,
customTags
)
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
}
@@ -581,17 +622,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The "top today" invention feed — published inventions ranked by engagement
// (lifetime, not per-day: we keep no daily counters). Paginated via skip/take
// (take defaults to 50, as the client asks for). Bare array.
// The "top today" invention feed — the inventions most acquired in the last 24 hours,
// counted from the purchase rows the `econ` worker writes. A real day window, so an
// empty list is a quiet day rather than a bug. Paginated via skip/take (take defaults
// to 50, as the client asks for). Bare array.
.get(
'/api/inventions/v1/toptoday',
describeRoute({
tags: ['Inventions'],
summary: 'The “top today” feed',
description:
'Published inventions ranked by engagement — lifetime, not per-day: we keep no ' +
'daily counters, so “today” is a label, not a window.',
'Published inventions ranked by how many players acquired them in the last 24 ' +
'hours, counted from the purchase records — free grants included, one per ' +
'player per invention. Genuinely a window: an invention nobody has picked up ' +
'since yesterday falls off, and a day with no acquisitions at all serves an ' +
'empty list. It trails the clock rather than resetting at midnight.',
parameters: pageParams(50),
responses: { 200: json(InventionDto.array(), 'The top inventions') },
}),
@@ -602,16 +647,17 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The featured invention feed — curated (`IsFeatured`) inventions, falling back
// to the top feed while nothing is curated. Bare array, like toptoday.
// The featured invention feed — the curated (`IsFeatured`) inventions and nothing
// else, newest first. Empty until someone flags one. Bare array, like toptoday.
.get(
'/api/inventions/v1/featured',
describeRoute({
tags: ['Inventions'],
summary: 'The featured feed',
description:
'Curated (`IsFeatured`) inventions, falling back to the top feed while nothing is ' +
'curated — so this is never empty just because no one has picked favourites.',
'Curated (`IsFeatured`) inventions, newest first — published and non-hidden only. ' +
'Serves an empty list while nothing is flagged rather than standing in the top ' +
'feed: the client presents these as hand-picked, so a fallback would be a lie.',
parameters: pageParams(50),
responses: { 200: json(InventionDto.array(), 'The featured inventions') },
}),
@@ -648,16 +694,20 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The signed-in player's saved inventions ("my inventions"), newest first.
// Auth-gated; returns a bare array (empty when the player has saved none).
// The signed-in player's invention shelf ("my inventions"), newest first — the ones
// they created AND the ones they bought (`inventory_invention`, written by the `econ`
// worker's buyInvention). A bought invention stays on the shelf whatever happens to it
// afterwards: unpublished or hidden since, the buyer paid for it.
// Auth-gated; returns a bare array (empty when the player has neither).
.get(
'/api/inventions/v2/mine',
describeRoute({
tags: ['Inventions'],
summary: 'The callers own inventions',
description:
'“My inventions”, newest first — including unpublished ones, which nobody else can ' +
'see. Not paginated.',
'“My inventions”, newest first — the ones the caller created plus the ones they ' +
'bought. Includes unpublished ones, which nobody else can see, and keeps a bought ' +
'invention listed even if it has since been unpublished or hidden. Not paginated.',
security: AUTHED,
responses: {
200: json(InventionDto.array(), 'The callers inventions'),
@@ -667,7 +717,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
async (c) => {
const id = await authedId(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))
}
)
@@ -686,14 +736,19 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'Records an inventions metadata. The data file itself is uploaded separately ' +
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
'one required field, since an invention with no data blob is unusable. An omitted ' +
'name/description is defaulted rather than rejected.\n\n' +
'name/description is defaulted rather than rejected; a supplied one must be 324 ' +
'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 creators own list ' +
'until they call `v3/publish`.',
security: AUTHED,
requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'),
responses: {
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
400: json(ErrorResponse, 'Unparseable body, or no inventionDataFilename'),
400: json(
ErrorResponse,
'Unparseable body, no inventionDataFilename, or an invalid name/description'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -712,11 +767,24 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return c.json({ error: 'inventionDataFilename is required' }, 400)
}
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
// "No description yet"), so only a supplied one is held to the rules — otherwise
// saving an unnamed invention would fail the 3-character minimum on a name the
// player never typed.
const name = str(body.name)?.trim()
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
const description = str(body.description)
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
creatorPlayerId: id,
inventionDataFilename,
name: str(body.name),
description: str(body.description),
name,
description,
imageName: str(body.imageName),
instantiationCost: num(body.instantiationCost),
lightsCost: num(body.lightsCost),
+413
View File
@@ -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 callers player events',
description:
'Events the player created and events they have RSVPd 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 isnt 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 callers 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 clubs event ' +
'list is small enough to serve in one page.',
parameters: [idParam('clubId', 'Club id')],
responses: { 200: json(PlayerEventsPage, 'The clubs 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 Cant 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 events `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 02, 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 cant 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 cant 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 doesnt ' +
'transfer and RSVPs arent 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 events 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)
}
)
+2 -84
View File
@@ -6,19 +6,15 @@ import communityBoard from '../../static/community-board.json'
import {
BareString,
idParam,
intQuery,
IsPureResponse,
json,
JsonArray,
jsonBody,
JsonObject,
KeepsakeConfig,
PlayerEventsAll,
PlayerEventsPage,
SanitizeRequest,
stringParam,
SubscriptionResponse,
TagFilters,
} from '../openapi'
import type { App } from '../context'
@@ -128,86 +124,8 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
}),
(c) => c.json(communityBoard)
)
.get(
'/api/playerevents/v1/all',
describeRoute({
tags: ['Gameplay'],
summary: 'The callers player events',
description:
'Events the player created and events they have RSVPd 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([])
)
// Player events live in their own controller (routes/events.ts) — they're D1-backed
// now, unlike the stubs around them here.
.get(
'/api/announcement/v1/get',
describeRoute({
+18 -1
View File
@@ -13,6 +13,8 @@ import {
getSlideshowImages,
SavedImageType,
setImageCheer,
SLIDESHOW_LIMIT,
SLIDESHOW_MAX_LIMIT,
toImagesPlayer,
} from '../images-db'
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
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
// Serves 10 by default and never more than SLIDESHOW_MAX_LIMIT (100): it's public and
// unauthenticated, so an unclamped `take` would let anyone ask for the whole image
// table — and the callers that rotate one photo at a time (the website's hero) don't
// want a long feed anyway.
.get(
'/api/images/v1/slideshow',
describeRoute({
@@ -323,10 +329,21 @@ export const imageRoutes = new Hono<App>({ strict: false })
'Deliberately public — it surfaces only already-public images and backs the ' +
'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' +
'client refreshes against.',
parameters: [
intQuery(
'take',
`How many photos to return (default ${SLIDESHOW_LIMIT}, capped at ${SLIDESHOW_MAX_LIMIT})`
),
],
responses: { 200: json(SlideshowResponse, 'The feed plus its cache hint') },
}),
async (c) => {
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()
return c.json({ Images, ValidTill })
}
+158
View File
@@ -1,17 +1,62 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { authedId, authedRoles, unauthorized } from '../http'
import {
AUTHED,
BareBoolean,
CreateReportRequest,
CreateWarningRequest,
DeviceIdRequest,
form,
json,
JsonArray,
ModerationBlockDetails,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import { createReport } from '../reports-db'
import { createWarning } from '../warnings-db'
import type { Context } from 'hono'
import type { App } from '../context'
/**
* Roles allowed to hand down a warning the operator-granted elevated roles the auth
* worker stamps from an account's isModerator/isDeveloper flags (see the admin CLI's
* `grant-moderator` / `grant-developer`). Same set the `notify` / `www` workers gate
* their admin surfaces on: a warning is a moderation action, but staff hold both.
*/
const MODERATOR_ROLES = new Set(['moderator', 'developer'])
/**
* Read one field of a submitted form. The client posts these form-encoded, but the
* same names also arrive as a query string on some builds, so both are accepted.
*/
function formField(
body: Record<string, unknown>,
c: Context<App>,
name: string
): string | undefined {
const raw = body[name]
if (typeof raw === 'string' && raw !== '') return raw
return c.req.query(name) || undefined
}
/** Parse a field as an integer, or null when absent / not a number. */
const asInt = (v: string | undefined): number | null => {
if (v === undefined) return null
const n = Number.parseInt(v, 10)
return Number.isNaN(n) ? null : n
}
/** Parse a field as a float (the reported heights), or null when absent / not a number. */
const asFloat = (v: string | undefined): number | null => {
if (v === undefined) return null
const n = Number.parseFloat(v)
return Number.isNaN(n) ? null : n
}
// ---- Player reporting ------------------------------------------------------
export const moderationRoutes = new Hono<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
@@ -69,6 +114,119 @@ export const moderationRoutes = new Hono<App>({ strict: 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 services `{ 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 CLIs `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`,
// `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
+133 -4
View File
@@ -1,23 +1,33 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { getAccountsByIds } from '@repo/domain'
import { logger } from '@repo/hono-helpers'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../../notify/src/notification-types'
import { authedId, unauthorized } from '../http'
import {
AckResponse,
AUTHED,
ErrorResponse,
form,
intQuery,
json,
JsonArray,
MutualFriendDto,
RelationshipDto,
SendMessageRequest,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import {
acceptFriendRequest,
addFriend,
getMutualFriendIds,
getRelationshipsForPlayer,
MUTUAL_FRIENDS_LIMIT,
removeFriend,
sendFriendRequest,
setRelationshipFlag,
@@ -34,9 +44,6 @@ import type {
/** The notifications hub is a single global DO instance (see the `notify` worker). */
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
* logged and swallowed the DB write has already committed, so a hub hiccup must not fail
@@ -50,7 +57,7 @@ async function notifyRelationship(
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
RELATIONSHIP_CHANGED,
NotificationType.RelationshipChanged,
{ ...rel }
)
} 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` workers ' +
'`coachMessageAll`), except `FromPlayerId` is the caller rather than the Coach ' +
'account and it goes to one player. The hub queues it when the recipient is ' +
'offline, so it arrives on their next connect.\n\n' +
'Nothing is persisted here — there is no message store, the notification is the ' +
'whole delivery. The sender is the caller (from the bearer token), NOT a body ' +
'field. `Type` is a Message-model type (a different enum from `NotificationType`) ' +
'passed through unmapped, defaulting to 0; `Data` is the payload and is commonly ' +
'empty.\n\n' +
'Answers the same `{ success, error }` envelope as the report / warning writes, ' +
'`error` an empty string on success. A hub failure is reported honestly as a 500 ' +
'with `success: false` — with no store behind it, a swallowed error would be a ' +
'silently dropped message.',
security: AUTHED,
requestBody: form(SendMessageRequest, 'The message'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No `ToPlayerId` in the request'),
401: UNAUTHORIZED_RESPONSE,
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
},
}),
async (c) => {
const fromPlayerId = await authedId(c)
if (fromPlayerId === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const toPlayerId = Number.parseInt(str(body.ToPlayerId) ?? '', 10)
if (Number.isNaN(toPlayerId)) {
return c.json({ success: false, error: 'ToPlayerId is required' }, 400)
}
// The Message the notification carries. Mirrors the coach message's shape with
// a real sender and recipient; `Data` stays a string, empty included (the hub
// drops only null/undefined from the frame).
const message = {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0,
Data: str(body.Data) ?? '',
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toPlayerId,
NotificationType.MessageReceived,
message
)
} catch (err) {
logger.error('failed to push MessageReceived notification', {
toPlayerId,
error: err instanceof Error ? err.message : String(err),
})
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
}
return c.json({ success: true, error: '' })
}
)
// Send a friend request to another player (the target arrives as `?id=`). The
// client calls this as a GET; the mutations accept GET or POST (the Go handlers
// matched any method). Auth-gated. Returns the resulting relationship from the
+1039 -46
View File
@@ -4,6 +4,8 @@ import { beforeAll, describe, expect, test } from 'vitest'
import {
GAME_VERSION,
grantInvention,
INVENTORY_INVENTION_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
@@ -11,11 +13,20 @@ import {
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 { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
import { getReportsAgainst, SCHEMA_DDL as REPORTS_SCHEMA_DDL } from '../../reports-db'
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
import type { Env } from '../../context'
import type { PlayerEvent, PlayerEventResult } from '../../events-db'
import type { SavedImage } from '../../images-db'
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
@@ -81,6 +92,18 @@ beforeAll(async () => {
// Inventions table (owned by the api worker) — invention save/mine use it.
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Bought-invention ownership (owned by the econ worker) — `v2/mine` folds it in.
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Reports table (owned by the api worker) — player reports are recorded here.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Warnings table (owned by the api worker) — moderator-issued warnings land here.
for (const stmt of WARNINGS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Player events table (owned by the api worker) — scheduled events live here.
for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
@@ -94,10 +117,13 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub = '42'): Promise<Record<string, string>> {
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
// off, the token carries none, which is what a plain player's looks like to the
// role-gated routes.
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -194,17 +220,6 @@ describe('public endpoints', () => {
expect(reps.map((r) => r.AccountId)).toEqual([1, 2])
})
test('GET /api/playerevents/v1/tagfilters returns empty filter chips', async () => {
// No player-event storage → no tags in use → no chips. Trending is null.
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/tagfilters`)
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 () => {
const res = await exports.default.fetch(`${ORIGIN}/api/activities/charades/v1/words/Charades`)
expect(res.status).toBe(200)
@@ -214,26 +229,6 @@ describe('public endpoints', () => {
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 () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
@@ -431,7 +426,7 @@ describe('public endpoints', () => {
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Already .inv', inventionDataFilename: '2026-07-12/x.inv' }),
body: JSON.stringify({ name: 'Already Suffixed', inventionDataFilename: '2026-07-12/x.inv' }),
})
expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe(
'2026-07-12/x.inv'
@@ -463,6 +458,49 @@ describe('public endpoints', () => {
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
})
test('GET /api/inventions/v2/mine lists bought inventions alongside the callers own', async () => {
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
// exactly this row) and also creates one of their own.
const save = async (sub: string, name: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: `${name}.inv` }),
})
expect(res.status).toBe(200)
return ((await res.json()) as InventionSaveResult).Invention
}
const mine = async (sub: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer(sub),
})
expect(res.status).toBe(200)
return (await res.json()) as SavedInvention[]
}
const bought = await save('6100', 'bought-invention')
const own = await save('6101', 'own-invention')
await grantInvention(env.DB, 6101, bought.InventionId)
// Newest first, whichever set it came from: 6101 saved theirs after buying.
const list = await mine('6101')
expect(list.map((i) => i.InventionId)).toEqual([own.InventionId, bought.InventionId])
// A bought invention is still the creator's — it is listed, not re-attributed.
expect(list.find((i) => i.InventionId === bought.InventionId)?.CreatorPlayerId).toBe(6100)
// It is unpublished (a fresh save is), and stays on the buyer's shelf regardless.
expect(list.find((i) => i.InventionId === bought.InventionId)?.IsPublished).toBe(false)
// The seller's own list is unaffected by the sale.
expect((await mine('6100')).map((i) => i.InventionId)).toEqual([bought.InventionId])
// An ownership row pointing at an invention that no longer exists just drops out.
await grantInvention(env.DB, 6101, 999_888)
expect((await mine('6101')).map((i) => i.InventionId)).toEqual([
own.InventionId,
bought.InventionId,
])
})
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -494,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 324 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! Its 100% good.' })).status).toBe(
200
)
})
test('POST /api/inventions/v6/save accepts the clients auto-generated timestamp name', async () => {
// The real client names an unnamed invention after the moment it was saved
// (`071126 13:10:50`, captured from a live save), so the colon is in the allowed name
// charset on purpose. Dropping it from the pattern would 400 every unnamed save the
// game makes — this test is what would catch that.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('6363')), 'Content-Type': 'application/json' },
body: JSON.stringify({ inventionDataFilename: 'a.inv', name: '071126 13:10:50' }),
})
expect(res.status).toBe(200)
expect(((await res.json()) as InventionSaveResult).Invention.Name).toBe('071126 13:10:50')
})
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
expect(res.status).toBe(404)
@@ -573,6 +656,39 @@ describe('public endpoints', () => {
})
expect(await replaced.json()).toEqual({ Result: 0, Tags: ['modern', 'bed'] })
// A tag is at most 15 letters once lowercased. One bad tag in either list fails the
// whole call — nothing is dropped silently — and leaves the stored tags alone.
const punctuated = await settags({
InventionId: Invention.InventionId,
CustomTags: ['racing', 'Cool Stuff!'],
})
expect(punctuated.status).toBe(400)
expect((await punctuated.json()) as { error: string }).toEqual({
error: 'Invention tags can only contain letters. (“cool stuff!”)',
})
expect(
(await settags({ InventionId: Invention.InventionId, AutoTags: ['a'.repeat(16)] })).status
).toBe(400)
expect(
(await settags({ InventionId: Invention.InventionId, CustomTags: ['tag2'] })).status
).toBe(400)
const stillThere = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}`
)
expect(await stillThere.json()).toEqual({
Tags: [
{ Tag: 'modern', Type: 0 },
{ Tag: 'bed', Type: 0 },
],
})
// Blank entries are skipped rather than rejected: the store already drops them.
const padded = await settags({
InventionId: Invention.InventionId,
CustomTags: ['modern', '', ' '],
})
expect(await padded.json()).toEqual({ Result: 0, Tags: ['modern'] })
// Only the creator may retag; unknown inventions 404; no token → 401.
const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999')
expect(notMine.status).toBe(403)
@@ -901,6 +1017,16 @@ describe('public endpoints', () => {
const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult
expect(cleared.Invention).toMatchObject({ Description: '', Name: 'Draft Lamp' })
// A supplied name/description is held to the same rules as the save path, and a
// rejected edit changes nothing.
expect((await update('name=xy')).status).toBe(400)
expect((await update(`name=${encodeURIComponent('Lamp?')}`)).status).toBe(400)
expect((await update(`description=${'x'.repeat(513)}`)).status).toBe(400)
const unchanged = (await (await update('permission=20')).json()) as InventionSaveResult
expect(unchanged.Invention).toMatchObject({ Name: 'Draft Lamp', Description: '' })
const renamed = (await (await update('name=Draft-Lamp%20Two')).json()) as InventionSaveResult
expect(renamed.Invention.Name).toBe('Draft-Lamp Two')
// allowTrial takes true/1.
const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult
expect(trial.Invention.AllowTrial).toBe(true)
@@ -1012,12 +1138,15 @@ describe('public endpoints', () => {
const ids = async (res: Response): Promise<number[]> =>
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
// Nothing is flagged IsFeatured yet → featured falls back to the top feed.
const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
const beforeFeatured = await ids(
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`)
// Both feeds start EMPTY, for different reasons: nothing is flagged IsFeatured, and
// the only inventions acquired so far in this file are an unpublished one and an id
// with no invention row — neither of which a public feed may show.
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))).toEqual(
[]
)
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))).toEqual(
[]
)
expect(beforeFeatured).toEqual(beforeTop)
const feedInvention = (
id: number,
@@ -1055,19 +1184,42 @@ describe('public endpoints', () => {
.run()
}
// Top: engagement-ranked, so the biggest download counts lead.
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
expect(top.slice(0, 3)).toEqual([202, 203, 201])
expect(top).not.toContain(204)
expect(top).not.toContain(205)
// Recent acquisitions, which is what "top today" now counts: 201 picked up by three
// players, 203 by one. 204/205 are acquired too — an unpublished and a hidden
// invention can still be owned — and must not surface in a public feed.
for (const accountId of [7001, 7002, 7003]) await grantInvention(env.DB, accountId, 201)
await grantInvention(env.DB, 7001, 203)
await grantInvention(env.DB, 7001, 204)
await grantInvention(env.DB, 7002, 205)
// 202 was acquired 25 hours ago, just past the trailing 24-hour window, so it is out —
// the feed really does forget, rather than accumulating every acquisition ever.
await env.DB.prepare(
'INSERT INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)'
)
.bind(7004, 202, new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString())
.run()
// Featured: only the flagged, visible inventions — newest first.
// Top: most acquisitions in the window first. Download counts no longer rank anything —
// 202 has the biggest of them and is absent entirely.
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
expect(top).toEqual([201, 203])
// Featured: only the flagged, visible inventions — newest first. 201 is published but
// unflagged, so it stays out however popular it is.
const featured = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))
expect(featured).toEqual([203, 202])
// skip/take paginate the top feed.
// skip/take paginate both feeds.
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
expect(await ids(page)).toEqual([203])
// Pagination happens after the visibility filter, so the hidden/unpublished
// acquisitions don't leave holes in a page.
const firstPage = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?take=1`)
expect(await ids(firstPage)).toEqual([201])
const featuredPage = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/featured?skip=1&take=1`
)
expect(await ids(featuredPage)).toEqual([202])
})
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
@@ -1099,6 +1251,179 @@ describe('auth-gated endpoints', () => {
})
})
describe('player reports', () => {
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
test('POST /api/PlayerReporting/v3/create records the report', async () => {
const res = await submit(
{
PlayerIdReported: '205',
ReportCategory: '100',
Details: 'ya know',
HeightReporter: '1.64',
HeightReported: '1.65',
RoomId: '58',
RoomInstanceType: 'Public',
},
await bearer()
)
expect(res.status).toBe(200)
// `error` is an empty string, not null — the real service's envelope.
expect(await res.json()).toEqual({ success: true, error: '' })
const [row] = await getReportsAgainst(env.DB, 205)
expect(row).toMatchObject({
// The reporter is the token's subject, not a body field.
reporter_player_id: 42,
reported_player_id: 205,
report_category: 100,
details: 'ya know',
height_reporter: 1.64,
height_reported: 1.65,
room_id: 58,
room_instance_type: 'Public',
})
expect(row?.created_at).toBeTruthy()
})
// Everything but the reported player is optional — a report raised outside a room
// carries no RoomId, and 0 means "no room" rather than room zero.
test('POST /api/PlayerReporting/v3/create stores absent fields as null', async () => {
const res = await submit({ PlayerIdReported: '206', RoomId: '0' }, await bearer())
expect(res.status).toBe(200)
const [row] = await getReportsAgainst(env.DB, 206)
expect(row).toMatchObject({
reporter_player_id: 42,
reported_player_id: 206,
report_category: 0,
details: null,
height_reporter: null,
height_reported: null,
room_id: null,
room_instance_type: null,
})
})
// Append-only: a second report against the same player is a second row.
test('POST /api/PlayerReporting/v3/create appends rather than dedupes', async () => {
await submit({ PlayerIdReported: '207', Details: 'first' }, await bearer())
await submit({ PlayerIdReported: '207', Details: 'second' }, await bearer())
const rows = await getReportsAgainst(env.DB, 207)
expect(rows).toHaveLength(2)
// Newest first.
expect(rows.map((r) => r.details)).toEqual(['second', 'first'])
})
test('POST /api/PlayerReporting/v3/create 401s without a bearer token', async () => {
const res = await submit({ PlayerIdReported: '205' })
expect(res.status).toBe(401)
})
test('POST /api/PlayerReporting/v3/create 400s without a reported player', async () => {
const res = await submit({ Details: 'ya know' }, await bearer())
expect(res.status).toBe(400)
// Same envelope as the success branch — the client parses only one shape.
expect(await res.json()).toEqual({ success: false, error: 'PlayerIdReported is required' })
})
})
describe('player warnings', () => {
const MOD = ['gameClient', 'moderator']
const issue = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/playerwarnings`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
test('POST /api/playerwarnings records the warning', async () => {
const res = await issue(
{
WarnedPlayerId: '205',
ReportCategory: '101',
DisplayReason: 'Sexual gestures',
ModeratorNote: 'dfg',
},
await bearer('42', MOD)
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
const [row] = await getWarningsAgainst(env.DB, 205)
expect(row).toMatchObject({
// The moderator is the token's subject, not a body field.
moderator_player_id: 42,
warned_player_id: 205,
report_category: 101,
display_reason: 'Sexual gestures',
moderator_note: 'dfg',
})
expect(row?.created_at).toBeTruthy()
})
test('POST /api/playerwarnings stores absent fields as null', async () => {
const res = await issue({ WarnedPlayerId: '206' }, await bearer('42', MOD))
expect(res.status).toBe(200)
const [row] = await getWarningsAgainst(env.DB, 206)
expect(row).toMatchObject({
warned_player_id: 206,
report_category: 0,
display_reason: null,
moderator_note: null,
})
})
// Append-only, like reports: warning the same player twice is two rows.
test('POST /api/playerwarnings appends rather than dedupes', async () => {
await issue({ WarnedPlayerId: '207', ModeratorNote: 'first' }, await bearer('42', MOD))
await issue({ WarnedPlayerId: '207', ModeratorNote: 'second' }, await bearer('42', MOD))
const rows = await getWarningsAgainst(env.DB, 207)
expect(rows).toHaveLength(2)
// Newest first.
expect(rows.map((r) => r.moderator_note)).toEqual(['second', 'first'])
})
test('POST /api/playerwarnings 401s without a bearer token', async () => {
const res = await issue({ WarnedPlayerId: '205' })
expect(res.status).toBe(401)
})
// A valid token is not enough — a plain player's carries neither staff role.
// Nothing is written on the rejected branch.
test('POST /api/playerwarnings 403s without a staff role', async () => {
for (const roles of [undefined, ['gameClient']]) {
const res = await issue({ WarnedPlayerId: '208' }, await bearer('42', roles))
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
}
expect(await getWarningsAgainst(env.DB, 208)).toHaveLength(0)
})
// `developer` gets in as well as `moderator` — staff hold both.
test('POST /api/playerwarnings accepts the developer role', async () => {
const res = await issue(
{ WarnedPlayerId: '209' },
await bearer('42', ['gameClient', 'developer'])
)
expect(res.status).toBe(200)
expect(await getWarningsAgainst(env.DB, 209)).toHaveLength(1)
})
test('POST /api/playerwarnings 400s without a warned player', async () => {
const res = await issue({ ModeratorNote: 'dfg' }, await bearer('42', MOD))
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ success: false, error: 'WarnedPlayerId is required' })
})
})
describe('rooms', () => {
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
const verify = async (fields: Record<string, string>, sub?: string): Promise<boolean> => {
@@ -1206,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 () => {
// Seed an image to cheer.
// Its own player id: 700's photos are asserted on exactly in the player-list test.
@@ -1919,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 callers own events, auth-gated', async () => {
expect((await get('/api/playerevents/v1/all')).status).toBe(401)
const mine = (await (await get('/api/playerevents/v1/all', '42')).json()) as {
Created: PlayerEvent[]
Responses: unknown[]
}
const ids = mine.Created.map((e) => e.PlayerEventId)
expect(ids).toContain(upcoming.PlayerEventId)
// 43 created that one, not 42.
expect(ids).not.toContain(liveEvent.PlayerEventId)
// Finished events stay in the creator's own list — only the browse queries drop them.
expect(ids).toContain(pastEvent.PlayerEventId)
// Nothing records an RSVP yet.
expect(mine.Responses).toEqual([])
const theirs = (await (await get('/api/playerevents/v1/all', '43')).json()) as {
Created: PlayerEvent[]
}
expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId])
})
test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => {
const respond = async (body: unknown, sub = '42'): Promise<Response> =>
post('/api/playerevents/v1/respond', body, sub)
const event = await create({ RoomId: 3, Name: 'RSVP Test', StartTime: at(HOUR) })
const id = event.PlayerEventId
// The creator is Going from create, which is where the initial 1 comes from.
expect(event.AttendeeCount).toBe(1)
expect(await countGoing(env.DB, id)).toBe(1)
// 43 says Going → 2 attendees, and the envelope carries the updated event.
const res = await respond({ PlayerEventId: id, Type: 0 }, '43')
expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
expect(body.PlayerEvent.AttendeeCount).toBe(2)
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({
event_id: id,
player_id: 43,
status: 0,
})
// Changing the answer REPLACES it — one row per player, not a second RSVP.
const changed = await respond({ PlayerEventId: id, Type: 2 }, '43')
expect(((await changed.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({ player_id: 43, status: 2 })
expect((await getEventAttendees(env.DB, id)).map((a) => a.player_id)).toEqual([42, 43])
// Interested is a maybe — recorded, but not counted.
await respond({ PlayerEventId: id, Type: 1 }, '43')
expect(await countGoing(env.DB, id)).toBe(1)
// And the count sticks on the stored event, not just the response.
const fetched = (await (await get(`/api/playerevents/v1/${id}`)).json()) as PlayerEvent
expect(fetched.AttendeeCount).toBe(1)
})
test('POST /api/playerevents/v1/respond rejects a bad body, an unknown event and no token', async () => {
const event = await create({ RoomId: 3, Name: 'Guarded' })
expect(
(
await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/respond`, {
method: 'POST',
body: JSON.stringify({ PlayerEventId: event.PlayerEventId, Type: 0 }),
})
).status
).toBe(401)
// An unrecognized Type is rejected rather than defaulted — stored as Going it
// would silently inflate the count.
expect((await post('/api/playerevents/v1/respond', { PlayerEventId: 1, Type: 7 })).status).toBe(
400
)
expect((await post('/api/playerevents/v1/respond', { Type: 0 })).status).toBe(400)
expect((await post('/api/playerevents/v1/respond', {})).status).toBe(400)
expect(
(await post('/api/playerevents/v1/respond', { PlayerEventId: 999999, Type: 0 })).status
).toBe(404)
})
test('POST /api/playerevents/v2/:eventId edits only what the body carries, creator-only', async () => {
const event = await create({
RoomId: 5,
SubRoomId: 6,
ClubId: 9,
Name: 'Original',
Description: 'Original description',
StartTime: at(5 * HOUR),
EndTime: at(6 * HOUR),
})
const path = `/api/playerevents/v2/${event.PlayerEventId}`
expect(
(await exports.default.fetch(`${ORIGIN}${path}`, { method: 'POST', body: '{}' })).status
).toBe(401)
// 43 didn't create it.
expect((await post(path, { Name: 'Hijacked' }, '43')).status).toBe(403)
expect((await post('/api/playerevents/v2/999999', { Name: 'Nope' })).status).toBe(404)
const res = await post(path, { Name: 'Renamed' })
expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
// Only the name moved; a partial post can't blank out the rest.
expect(body.PlayerEvent).toEqual({ ...event, Name: 'Renamed' })
// And it stuck.
expect(await (await get(`/api/playerevents/v1/${event.PlayerEventId}`)).json()).toEqual(
body.PlayerEvent
)
})
test('POST /api/playerevents/v2/:eventId clears a nullable id when the body sends null', async () => {
const event = await create({ RoomId: 5, SubRoomId: 6, ClubId: 9, Name: 'Clearable' })
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
// Nested form again, and an explicit null — absent leaves the value alone,
// null genuinely clears it.
PlayerEvent: { ClubId: null, ImageName: null },
})
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
expect(updated.ClubId).toBeNull()
expect(updated.ImageName).toBeNull()
expect(updated.SubRoomId).toBe(6)
})
test('POST /api/playerevents/v2/:eventId cannot move ownership or the attendee count', async () => {
const event = await create({ RoomId: 5, Name: 'Fixed' })
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
PlayerEventId: 424242,
CreatorPlayerId: 43,
AttendeeCount: 500,
})
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
expect(updated.PlayerEventId).toBe(event.PlayerEventId)
expect(updated.CreatorPlayerId).toBe(42)
expect(updated.AttendeeCount).toBe(1)
})
})
describe('openapi', () => {
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
@@ -1990,13 +2973,17 @@ describe('openapi', () => {
'GET /api/playerReputation/v1/{id}',
'GET /api/playerReputation/v2/bulk',
'GET /api/playerevents/v1/all',
'GET /api/playerevents/v1/bulk',
'GET /api/playerevents/v1/club/{clubId}',
'GET /api/playerevents/v1/clubs',
'GET /api/playerevents/v1/search',
'GET /api/playerevents/v1/searchlive',
'GET /api/playerevents/v1/tagfilters',
'GET /api/playerevents/v1/{eventId}',
'GET /api/players/v1/progression/{id}',
'GET /api/players/v2/progression/bulk',
'GET /api/quickPlay/v1/getandclear',
'GET /api/relationships/mutualfriends',
'GET /api/relationships/v1/favorite',
'GET /api/relationships/v1/ignore',
'GET /api/relationships/v1/mute',
@@ -2016,6 +3003,7 @@ describe('openapi', () => {
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
'POST /api/PlayerReporting/v1/deviceId',
'POST /api/PlayerReporting/v1/hile',
'POST /api/PlayerReporting/v3/create',
'POST /api/avatar/v2/gifts/generate',
'POST /api/gamesight/event',
'POST /api/images/v1/cheer',
@@ -2023,10 +3011,15 @@ describe('openapi', () => {
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v6/save',
'POST /api/messages/v2/send',
'POST /api/playerReputation/v1/bulk',
'POST /api/playerReputation/v2/bulk',
'POST /api/playerevents/v1/respond',
'POST /api/playerevents/v2',
'POST /api/playerevents/v2/{eventId}',
'POST /api/players/v1/progression/bulk',
'POST /api/players/v2/progression/bulk',
'POST /api/playerwarnings',
'POST /api/relationships/v1/favorite',
'POST /api/relationships/v1/ignore',
'POST /api/relationships/v1/mute',
+87
View File
@@ -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
}
+75 -2
View File
@@ -21,7 +21,7 @@ import {
updateAccount,
verifyPassword,
} 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 { verifyMetaNonce } from './meta-nonce'
@@ -29,6 +29,7 @@ import {
CachedLogin,
ChangePasswordRequest,
ChangePasswordResponse,
FakeCachedLogin,
form,
json,
OAuthError,
@@ -57,6 +58,30 @@ import type { PlatformLink } from './platform-db'
const TOKEN_SCOPE =
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
/**
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
* build has no Meta SDK to ask, so it has nothing real to report and every sideloaded
* headset reports this same value. Two things follow, and both are enforced below:
* - it is never verifiable (`verifyPlatformProof` refuses it outright), and
* - it is therefore never LINKED to an account. A link is a password-free way in, so
* one link on a shared id would open that account to every sideloaded build.
* It exists only to get such a client onto the username/password login screen.
*/
const SIDELOAD_PLATFORM_ID = '1'
/**
* The canned entry served for the one Oculus cached-login lookup below the sideloaded
* APK's way onto the password login screen. Not backed by a link, an account or a
* platform proof, hence `requirePassword: true`.
*/
const FAKE_OCULUS_CACHED_LOGIN = {
platform: PlatformType.Oculus,
platformId: SIDELOAD_PLATFORM_ID,
accountId: 1,
lastLoginTime: '2026-07-19T17:13:29.225Z',
requirePassword: true,
} as const
/**
* Signup caps, enforced on create_account only (never on login an existing account
* always stays reachable, however many accounts its owner has since accumulated).
@@ -292,6 +317,20 @@ async function verifyPlatformProof(
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' }
@@ -325,6 +364,14 @@ const app = new Hono<App>()
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -361,6 +408,10 @@ const app = new Hono<App>()
'`cached_login` grant (both read the same table). An account linked to several',
'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)',
'and the client falls back to a fresh login or create_account.',
'EXCEPT the exact identity `1/1` (Oculus, id `1`), which is stubbed for SIDELOADED',
'APKs: with no Meta SDK they have no real identity to ask about and stall on an',
'empty picker. It consults nothing and returns one canned, non-redeemable entry',
'with `requirePassword: true`, sending the build to username/password login.',
].join(' '),
parameters: [
{
@@ -379,13 +430,30 @@ const app = new Hono<App>()
},
],
responses: {
200: json(CachedLogin.array(), 'Matching accounts; `[]` if none'),
200: json(
CachedLogin.or(FakeCachedLogin).array(),
'Matching accounts; `[]` if none. The canned entry for `1/1`.'
),
},
}),
async (c) => {
const { platform, id } = c.req.param()
logger.info('cached login lookup', { platform, id })
const platformInt = Number.parseInt(platform, 10)
// SIDELOADED APKs ONLY. A sideloaded build has no Meta SDK behind it, so it can't
// produce a real Meta identity or a nonce to prove one with — it asks about the
// placeholder identity `1/1`, and an empty picker leaves it stuck on the platform
// login screen with nothing to do. Hand back one canned entry to push it onto the
// username/password login instead, which is the only flow such a build can finish.
// `requirePassword` is true for exactly that reason: there's no platform proof here,
// and the `cached_login` grant would (correctly) refuse this entry.
//
// Scoped to that ONE identity rather than to all of platform 1 — store builds do
// real Meta logins, and shadowing the whole platform would hide genuine links from
// their pickers.
if (platformInt === PlatformType.Oculus && id === SIDELOAD_PLATFORM_ID) {
return c.json([FAKE_OCULUS_CACHED_LOGIN])
}
// Listed straight from the link table, which is also what the `cached_login`
// grant authorizes against — so the picker can't offer an account the grant
// then refuses.
@@ -471,6 +539,11 @@ const app = new Hono<App>()
'when it is unset. The first identity linked also becomes the accounts primary',
'(what the account DTO and a refreshed token report); later ones only link.',
'',
'The one platform id that is never verified and never linked is `1` on platform `1`',
'— what a SIDELOADED Oculus APK reports, having no Meta SDK to ask. Every such',
'build reports it, so it identifies nobody. A password login that carries it still',
'succeeds; it simply links nothing, and the player types their password each launch.',
'',
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant.',
].join('\n'),
+9
View File
@@ -103,6 +103,15 @@ export const CachedLogin = z.object({
.describe('Always false — platform ownership is the credential for a cached login'),
})
/**
* The stubbed Oculus cached login served to sideloaded APKs. Same shape as `CachedLogin`,
* but `requirePassword` is true with no Meta SDK there is nothing to prove platform
* ownership with, so the client falls through to username/password.
*/
export const FakeCachedLogin = CachedLogin.extend({
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
})
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
export const OAuthError = z.object({
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
+101
View File
@@ -198,6 +198,23 @@ describe('auth worker routes', () => {
}
)
// The one stubbed identity: `1/1` consults nothing and always answers the canned
// entry, which is how a sideloaded APK (no Meta SDK, so no real identity) gets off
// the platform login screen and onto username/password.
test('GET /cachedlogin/forplatformid/1/1 returns the canned Oculus entry', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/1`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
{
platform: 1,
platformId: '1',
accountId: 1,
lastLoginTime: '2026-07-19T17:13:29.225Z',
requirePassword: true,
},
])
})
// Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth
// ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected
// on the platform-authenticated grants: we won't bind or authorize an identity we
@@ -876,6 +893,26 @@ describe('auth worker routes', () => {
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.
@@ -1047,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'
)
})
})
+26 -3
View File
@@ -195,6 +195,28 @@ const app = new Hono<App>()
(c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`)
)
// Generic client data by name. Anything the client uploads as FileType 2 lands
// under `data/` (a Holotar recording is the one seen in the wild) and the client
// fetches it back from this prefix. Date-foldered like the room and invention
// blobs, so the rest of the path is matched as-is.
.get(
'/data/:id{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve a client data blob',
description: [
'Streams the object stored under `data/<id>` — whatever the client uploaded as',
'`UploadFileType` 2 (see the `storage` worker), a Holotar recording being the case',
'observed. Like room and invention blobs the name is date-foldered by the upload,',
'e.g. `2026-02-03/<uuid>`, so it contains slashes. The worker does not interpret the',
'bytes — the prefix exists because the client expects to read these back from `/data/`.',
].join(' '),
parameters: [keyParam('id', 'The blob name.', true), ...CONDITIONAL_HEADERS],
responses: assetResponses('The data blob'),
}),
(c) => serveAsset(c, `data/${c.req.param('id')}`)
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
@@ -209,10 +231,11 @@ app.get(
description: [
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
'signatures, saved room scenes and invention data — out of the shared `recflare-cdn`',
'R2 bucket, plus the one bundled config file the loading screen reads.',
'signatures, saved room scenes, invention data and generic client uploads — out of',
'the shared `recflare-cdn` R2 bucket, plus the one bundled config file the loading',
'screen reads.',
'',
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`) and served as',
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
'are unauthenticated — a caller needs the exact key, which only comes from an',
'authenticated call to another worker.',
+16
View File
@@ -101,6 +101,21 @@ describe('cdn endpoints', () => {
expect(res.status).toBe(404)
})
test('GET /data/:id streams the data blob from R2', async () => {
// Date-foldered — the name the storage worker generates for a FileType 2 upload.
const name = '2026-08-05/3b9c1f0a-5d2e-4c1b-9a77-2e6f0b4d8c31'
await env.CDN_ASSETS.put(`data/${name}`, new Uint8Array([4, 5, 6]))
const res = await exports.default.fetch(`${ORIGIN}/data/${name}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('application/octet-stream')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([4, 5, 6]))
})
test('GET /data/:id 404s when the blob is absent', async () => {
const res = await exports.default.fetch(`${ORIGIN}/data/missing`)
expect(res.status).toBe(404)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
@@ -124,6 +139,7 @@ describe('cdn endpoints', () => {
expect([...documented].sort()).toEqual([
'GET /',
'GET /config/LoadingScreenTipData',
'GET /data/{id}',
'GET /invention/{dataBlob}',
'GET /room/{dataBlob}',
'GET /sigs/{sigName}',
+24 -3
View File
@@ -2,6 +2,11 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
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 { validateAndGetAccountId } from '@repo/jwt'
@@ -101,8 +106,6 @@ async function authedId(c: Context<App>): Promise<number | null> {
*/
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
@@ -665,6 +668,14 @@ const app = new Hono<App>()
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
}
// Counted in code points like the name above, so an emoji-heavy description is
// measured the way a player sees it rather than by UTF-16 units.
if (glyphLength(description) > MAX_CLUB_DESCRIPTION_LENGTH) {
return clubError(
c,
`Club descriptions can be at most ${MAX_CLUB_DESCRIPTION_LENGTH} characters.`
)
}
// The per-account cap, checked after the cheap validations so a rejected name
// costs no extra D1 read.
const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT)
@@ -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, {
name,
description: field('description') || undefined,
description,
category: field('category')?.trim() || undefined,
visibility: parseVisibility(field('visibility')),
joinability: parseJoinability(field('joinability')),
+8 -5
View File
@@ -65,7 +65,7 @@ export const EmptyObject = z.object({})
*/
export const ClubDto = z.object({
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(),
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
@@ -277,8 +277,8 @@ export const ChatDisabledResponse = z.boolean()
export const CreateClubRequest = z.object({
name: z
.string()
.describe('Required; at most 16 characters, letters/digits/basic punctuation only'),
description: z.string().optional(),
.describe('Required; at most 40 characters, letters/digits/basic punctuation only'),
description: z.string().optional().describe('At most 512 characters'),
category: z.string().optional().describe('Defaults to Social when unset'),
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
joinability: z
@@ -292,8 +292,11 @@ export const CreateClubRequest = z.object({
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
export const ModifyClubRequest = z.object({
name: z.string().optional().describe('Empty means unchanged, not "clear it"'),
description: z.string().optional().describe('Empty means unchanged'),
name: z
.string()
.optional()
.describe('At most 40 characters. Empty means unchanged, not "clear it"'),
description: z.string().optional().describe('At most 512 characters. Empty means unchanged'),
category: z.string().optional(),
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
joinability: z
+10 -3
View File
@@ -241,9 +241,16 @@ describe('clubs endpoints', () => {
expect(emoji.status).toBe(400)
expect(await emoji.json()).toMatchObject({ success: false, value: null })
// Names cap at 16 characters.
expect((await create({ name: 'a'.repeat(17) })).status).toBe(400)
expect((await create({ name: 'a'.repeat(16) })).status).toBe(200)
// Names cap at 40 characters.
expect((await create({ name: 'a'.repeat(41) })).status).toBe(400)
expect((await create({ name: 'a'.repeat(40) })).status).toBe(200)
// Descriptions cap at 512. Counted in code points, so an emoji-heavy one isn't
// refused at half the length a player can see (the description has no charset rule
// — only the name does).
expect((await create({ name: 'DescTooLong', description: 'd'.repeat(513) })).status).toBe(400)
expect((await create({ name: 'DescAtLimit', description: 'd'.repeat(512) })).status).toBe(200)
expect((await create({ name: 'DescEmoji', description: '🎉'.repeat(512) })).status).toBe(200)
// Basic punctuation is allowed.
expect((await create({ name: "Bob's Club (2)" })).status).toBe(200)
@@ -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
View File
@@ -2,10 +2,21 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
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 { validateAndGetAccountId } from '@repo/jwt'
// Invention storage (owned by the `api` worker, on this same `recflare` database).
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
// their own, and buyInvention has to read the very rows `api` writes.
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../notify/src/notification-types'
@@ -17,7 +28,10 @@ import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db'
import {
ALL_PLATFORMS,
creditCurrency,
CurrencyType,
DEFAULT_STARTING_TOKENS,
ensureStartingBalances,
getBalance,
isSpendable,
spendCurrency,
@@ -34,6 +48,7 @@ import {
AUTHED,
AvatarV2Dto,
BalanceEntry,
BuyInventionResponse,
BuyItemRequest,
BuyItemResponse,
ChallengeProgressRequest,
@@ -69,7 +84,8 @@ import type { Outfit } from './outfit-db'
/**
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
* the `econ` service (these are separate from the main `api` worker). 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
* 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
* reference's
* `HubSendToPlayer(accountID, NotifFrame(StorefrontBalanceUpdate, {Balance, CurrencyType, BalanceType}))`.
* The client applies it to the shown balance so a purchase debit reflects immediately,
* without waiting for a `GET /balance` re-fetch. `Balance` is the resulting total in that
* 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.
* The client applies it to the shown balance so a purchase reflects immediately, without
* waiting for a `GET /balance` re-fetch.
*
* `Balance` is the CHANGE negative for a debit, positive for a payout not the
* resulting total. The client ADDS what it receives to the balance it is already showing,
* so sending the total made a 10,000-token player who earned 250 read 20,250: their own
* balance plus the new total. That also makes this frame non-idempotent, so push exactly
* once per change and never re-send it as a "refresh".
*
* `BalanceType` is -2 (account-wide, all platforms). Best-effort: a hub failure is logged
* and swallowed, since the balance change has already committed.
*/
async function pushBalanceUpdate(
c: Context<App>,
accountId: number,
currencyType: number,
balance: number
change: number
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
accountId,
NotificationType.StorefrontBalanceUpdate,
{
Balance: balance,
Balance: change,
CurrencyType: currencyType,
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',
'consumable table), and returns a gift box. A `Gift` block routes the item to another',
'player, but the caller always pays. `Balance` in the response is the CHANGE (negated',
'price), not the new total. Pushes a StorefrontBalanceUpdate socket 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(' '),
security: AUTHED,
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
// shown total immediately — the buyer (`id`) is who was debited, in the currency they
// spent. Best-effort; the HTTP response still carries the change either way.
const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
await pushBalanceUpdate(c, id, currencyType as number, newBalance)
// Push the debit over the socket so the buyer's client updates the shown total
// immediately — the buyer (`id`) is who was charged, in the currency they spent. The
// frame carries the CHANGE, so a purchase is negative. Best-effort; the HTTP response
// carries the same change either way.
await pushBalanceUpdate(c, id, currencyType as number, -price.Price)
// The response mirrors a captured real buyItem: `Balance` is the change applied (the
// negated price), not the resulting balance (the client reads its new total from
@@ -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 clients `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 buyers resulting',
'balance. When tokens moved, both players get a StorefrontBalanceUpdate push carrying',
'their CHANGE (the buyers negative, the creators positive), which the client adds to',
'the balance it is showing — unlike this response body, which replaces it.',
'A GET because that is how the client sends it.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'inventionId',
in: 'query',
required: true,
description: 'Invention id; missing or non-numeric is 400',
schema: { type: 'integer' },
},
{
name: 'requestedPrice',
in: 'query',
required: false,
description: 'The price the client rendered; a mismatch is 409. Defaults to 0',
schema: { type: 'integer' },
},
],
responses: {
200: json(BuyInventionResponse, 'The purchase result (invention + balance)'),
400: json(
ErrorResponse,
'Missing/non-numeric inventionId, buying your own, or insufficient balance'
),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'The invention is not published, so it is not for sale'),
404: json(ErrorResponse, 'No such invention'),
409: json(ErrorResponse, 'Already owned, or the price has changed'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
// Absent/non-numeric requestedPrice reads as 0, which only matches a free invention —
// a priced one then fails the confirmation below rather than selling for nothing.
const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return c.json({ error: 'Invention not found' }, 404)
// An unpublished invention is a draft: it isn't on sale, not even for free.
if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403)
if (invention.CreatorPlayerId === id) {
return c.json({ error: 'Cannot buy your own invention' }, 400)
}
if (await ownsInvention(c.env.DB, id, inventionId)) {
return c.json({ error: 'Already owned' }, 409)
}
// The price the client rendered must still be the stored one: a mismatch is a stale
// catalog or a tampered request, never a sale.
if (invention.Price !== requestedPrice) {
return c.json({ error: 'Price has changed' }, 409)
}
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
// Inventions are priced in RecCenterTokens only — the store shows no other currency
// for them, and `Price` carries no currency of its own to pick a different one from.
const price = invention.Price
if (price > 0) {
// Debit the buyer atomically; false means they couldn't afford it and nothing
// changed, so no ownership is recorded and the creator is not paid.
const paid = await spendCurrency(
c.env.DB,
id,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
}
// Grant before paying out: these are three separate D1 writes with no transaction
// around them, so order them by what a failure costs. A buyer who paid and got the
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
// is not.
await grantInvention(c.env.DB, id, inventionId)
if (price > 0) {
// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
// the balance row, and `ensureStartingBalances` is an INSERT OR IGNORE, so a
// creator who had never touched their balance would otherwise have the row created
// here and lose their starting tokens forever.
await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
await creditCurrency(
c.env.DB,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
// The creator is a different, probably-online player: push the payout so a sale
// lands on their shown balance without a re-fetch. Positive, because the frame
// carries the change. Best-effort, as everywhere.
await pushBalanceUpdate(c, invention.CreatorPlayerId, CurrencyType.RecCenterTokens, price)
}
// Unlike buyItem — whose `Balance` is the change applied — the reference server
// answers this one with the RESULTING total (a first read seeds the buyer's starting
// grant, as everywhere else). The socket frame below is the other way round: the HTTP
// body REPLACES the shown balance, the push ADDS to it.
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
// A free invention moved nothing, so there is no change to push for it.
if (price > 0) {
await pushBalanceUpdate(c, id, CurrencyType.RecCenterTokens, -price)
}
return c.json({
BalanceUpdateResponse: {
Balance: balance,
BalanceType: ALL_PLATFORMS,
CurrencyType: CurrencyType.RecCenterTokens,
BalanceUpdates: [{ UpdateResponse: 0, Data: invention }],
},
// The same `{ Status, Invention, InventionVersion }` envelope the invention
// save/read endpoints serve — the client re-renders the invention from it.
InventionResponse: toSaveResult(invention),
})
}
)
// Storefront ad-carousel items. Served from the bundled static JSON — one
// placeholder banner with no purchasable items until real promo data exists.
.get(
+28 -1
View File
@@ -130,7 +130,34 @@ export const BuyItemResponse = z.object({
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() })
// ---- Request schemas -------------------------------------------------------
+232 -1
View File
@@ -4,8 +4,15 @@ import { beforeAll, describe, expect, test } from 'vitest'
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 {
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 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 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)')
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
.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
* (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 () => {
// 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`, {
method: 'POST',
headers: { ...(await bearer('20')), 'Content-Type': 'application/json' },
@@ -656,6 +729,16 @@ describe('econ endpoints', () => {
expect(gift.AvatarItemDesc).not.toBe('')
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).
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('20'),
@@ -920,6 +1003,153 @@ describe('econ endpoints', () => {
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 buyers 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 () => {
// 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=..`).
@@ -1182,6 +1412,7 @@ describe('econ endpoints', () => {
'GET /api/roomkeys/v1/mine',
'GET /api/roomkeys/v1/room',
'GET /api/storefronts/v1/adcarouselitems',
'GET /api/storefronts/v2/buyInvention',
'GET /api/storefronts/v3/giftdropstore/{id}',
'GET /api/storefronts/v4/balance/{currencyType}',
'GET /econ/customAvatarItems/v1/owned',
+17 -1
View File
@@ -14,6 +14,12 @@ export default defineConfig({
// isolated test, so provide a minimal stub exposing the same NotificationsHub
// RPC surface — enough for the runtime to start and for notification sends to
// 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: [
{
name: 'notify',
@@ -24,8 +30,18 @@ export default defineConfig({
script: `
import { DurableObject } from 'cloudflare:workers'
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 } }
/** 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') } }
`,
+6
View File
@@ -6,6 +6,12 @@ export type Env = SharedHonoEnv & {
DB: D1Database
/** R2 bucket holding the served image objects, keyed by filename. */
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/`. */
ASSETS: Fetcher
/**
+33 -4
View File
@@ -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. */
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,
* 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
// lifetime of the Worker, so caching the promise is safe.
let signingKey: Promise<CryptoKey | null> | undefined
@@ -231,9 +251,11 @@ app.get(
description: [
'Image hosting for recflare, a private-server reimplementation of the Rec Room',
'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',
'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`',
'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',
'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',
'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
// one. Skip the precondition when a transform is requested.
const ifNoneMatch = transform ? undefined : c.req.header('if-none-match')?.replace(/"/g, '')
const object = await c.env.IMAGES.get(
key,
const { bucket, objectKey } = resolveObject(c.env, key)
const object = await bucket.get(
objectKey,
ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined
)
if (!object) {
+39
View File
@@ -34,10 +34,17 @@ const PUBLIC_SPKI_B64 =
// bucket path rather than a static asset.
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 () => {
await env.IMAGES.put(R2_KEY, IMAGE_BYTES, {
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.
await env.IMAGES.put('3DCharades.jpg', IMAGE_BYTES, {
httpMetadata: { contentType: 'image/jpeg' },
@@ -58,6 +65,38 @@ describe('img endpoints', () => {
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 () => {
const res = await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)
expect(res.status).toBe(200)
+8
View File
@@ -17,10 +17,18 @@
"run_worker_first": true
},
// 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": [
{
"binding": "IMAGES",
"bucket_name": "recflare-img"
},
{
"binding": "CDN_ASSETS",
"bucket_name": "recflare-cdn"
}
],
// Shared `recflare` D1 the `img` worker owns the `images` metadata table
+234 -32
View File
@@ -21,12 +21,15 @@ import {
getRoomByName,
getRoomInstance,
getRoomInstancesByRoom,
getRoomInstanceSummariesByRoom,
isClubMember,
isPlayerBannedFromRoom,
MessageType,
refreshInstanceFullness,
RoomInstanceType,
setPresence,
setRoomInstanceInProgress,
setRoomInstancePrivate,
subRoomDataBlob,
} from '@repo/domain'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
@@ -50,6 +53,7 @@ import {
NotifyDisconnectRequest,
PlayerDto,
RoomInstanceDto,
RoomInstanceSummaryDto,
StatusVisibilityRequest,
UNAUTHORIZED_RESPONSE,
} 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. */
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). */
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
* 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(
c: Context<App>,
@@ -487,14 +509,24 @@ async function resolveRoomInstance(
isPrivate: boolean,
ownerId: number,
subRoomId?: number
): Promise<RoomInstance | null> {
): Promise<ResolvedInstance> {
const id = Number.parseInt(roomKey, 10)
const room = Number.isNaN(id)
? await getRoomByName(c.env.DB, roomKey)
: await getRoomById(c.env.DB, id)
if (!room) return null
if (!room) return { instance: null, errorCode: NO_SUCH_ROOM }
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
// 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)
@@ -525,13 +557,16 @@ async function resolveRoomInstance(
roomInstanceType: f.roomInstanceType,
})
}
return roomInstanceFromRoom(
room,
isPrivate,
instance.roomInstanceId,
instance.photonRoomId,
f.subRoomId
)
return {
instance: roomInstanceFromRoom(
room,
isPrivate,
instance.roomInstanceId,
instance.photonRoomId,
f.subRoomId
),
errorCode: 0,
}
}
/**
@@ -859,7 +894,8 @@ const app = new Hono<App>()
description: [
'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',
'club is unknown, has no clubhouse set, or the caller isnt a member.',
'club is unknown, has no clubhouse set, or the caller isnt a member — and errorCode',
'55 when they are banned from the clubhouse room.',
].join(' '),
security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
@@ -875,7 +911,7 @@ const app = new Hono<App>()
responses: {
200: json(
MatchmakeResponse,
'The clubhouse instance (or errorCode 20 with null when it cant be entered)'
'The clubhouse instance (or a null instance with errorCode 20 / 55 when it cant be entered)'
),
401: UNAUTHORIZED_RESPONSE,
},
@@ -895,13 +931,13 @@ const app = new Hono<App>()
}
const joinMode = await readJoinMode(c)
const instance = await resolveRoomInstance(
const { instance, errorCode } = await resolveRoomInstance(
c,
String(club.clubhouseRoomId),
joinMode === 2,
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)
return c.json({ errorCode: 0, roomInstance: instance })
}
@@ -925,7 +961,9 @@ const app = new Hono<App>()
'from the targets stored presence. FRIENDS ONLY: the caller must be a mutual friend',
'of the target (otherwise anyone could read a players presence and warp to them).',
'Returns errorCode 20 with a null instance when the target isnt a friend, is the',
'caller themselves, or isnt currently in a room.',
'caller themselves, or isnt 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(' '),
security: AUTHED,
parameters: [
@@ -940,7 +978,7 @@ const app = new Hono<App>()
responses: {
200: json(
MatchmakeResponse,
'The friends instance (or errorCode 20 with null when it cant be joined)'
'The friends instance (or a null instance with errorCode 20 / 55 when it cant be joined)'
),
401: UNAUTHORIZED_RESPONSE,
},
@@ -961,6 +999,14 @@ const app = new Hono<App>()
const instance = targetPresence?.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
// presence, so the heartbeat replays it and their own friend fan-out fires.
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 owners instance listing. Gated to the rooms 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 doesnt 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 cant 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 rooms 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}`
// — 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
@@ -994,7 +1127,10 @@ const app = new Hono<App>()
},
],
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,
},
}),
@@ -1003,14 +1139,14 @@ const app = new Hono<App>()
if (id === null) return unauthorized(c)
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const instance = await resolveRoomInstance(
const { instance, errorCode } = await resolveRoomInstance(
c,
c.req.param('roomId'),
joinMode === 2,
id,
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)
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
await inviteParty(c, id, additionalPlayerIds, instance)
@@ -1033,7 +1169,10 @@ const app = new Hono<App>()
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
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,
},
}),
@@ -1041,8 +1180,13 @@ const app = new Hono<App>()
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
const instance = await resolveRoomInstance(c, c.req.param('roomId'), joinMode === 2, id)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
const { instance, errorCode } = await resolveRoomInstance(
c,
c.req.param('roomId'),
joinMode === 2,
id
)
if (!instance) return c.json({ errorCode, roomInstance: null })
await enterRoom(c, id, instance)
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
await inviteParty(c, id, additionalPlayerIds, instance)
@@ -1165,16 +1309,20 @@ const app = new Hono<App>()
(c) => c.body(null, 200)
)
// The room owner flips the instance's in-progress flag once the session starts
// (e.g. a game round begins). Body is a form post: `inProgress=True|False`.
// The instance's in-progress flag, flipped when a session starts (e.g. a game round
// 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(
'/roominstance/:id/inprogress',
describeRoute({
tags: ['Room instance'],
summary: 'Set instance in-progress flag',
description: [
'The room owner flips the instances in-progress flag when a session starts (e.g. a',
'round begins). Body is `inProgress=True|False`.',
'Flips the instances in-progress flag when a session starts (e.g. a round begins).',
'Set by whoever in the room starts the game — any authenticated player, not just the',
'rooms owner. Body is `inProgress=True|False`.',
].join(' '),
security: AUTHED,
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',
'instances rooms 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 rooms 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.
// 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
// bare RoomInstance DTO array (empty when the room has no live instances).
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns a
// 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(
'/room/:roomId{[0-9]+}/instances',
describeRoute({
tags: ['Room instance'],
summary: 'A rooms live instances',
description: [
'The owners view of active sessions of their room. Auth-gated and gated to the',
'rooms creator or a co-owner (403 otherwise). Unknown room → 404.',
'The owners view of active sessions of their room — each instance with the',
'players currently in it. Auth-gated and gated to the rooms creator or a',
'co-owner (403 otherwise). Unknown room → 404.',
].join(' '),
security: AUTHED,
parameters: [
@@ -1226,7 +1428,7 @@ const app = new Hono<App>()
},
],
responses: {
200: json(RoomInstanceDto.array(), 'Live instances (empty when none)'),
200: json(RoomInstanceSummaryDto.array(), 'Live instances (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the rooms creator or a co-owner (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.
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))
}
)
+19 -1
View File
@@ -95,6 +95,22 @@ export const RoomInstanceDto = z.object({
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`).
* `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`.
*/
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(),
})
+224 -1
View File
@@ -984,10 +984,32 @@ describe('auth-gated endpoints', () => {
headers: await bearer('42'),
})
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.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.
const coOwner = await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
headers: await bearer('43'),
@@ -996,6 +1018,141 @@ describe('auth-gated endpoints', () => {
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 () => {
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
type Sent = {
@@ -1241,6 +1398,70 @@ describe('auth-gated endpoints', () => {
// No token → 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 () => {
@@ -1334,6 +1555,7 @@ describe('auth-gated endpoints', () => {
'POST /invite',
'POST /matchmake/club/{clubId}',
'POST /matchmake/dorm',
'POST /matchmake/instance/{instanceId}',
'POST /matchmake/player/{playerId}',
'POST /matchmake/room/{roomId}',
'POST /matchmake/room/{roomId}/{subRoomId}',
@@ -1342,6 +1564,7 @@ describe('auth-gated endpoints', () => {
'POST /player/login',
'POST /player/logout',
'POST /player/notifydisconnect',
'POST /roominstance/{id}/markprivate',
'POST /roominstance/{id}/reportjoinresult',
'PUT /player/gameserverregionpings',
'PUT /player/photonregionpings',
+1 -1
View File
@@ -24,7 +24,7 @@ export enum NotificationType {
ModerationUpdateRequired = 21,
ModerationKick = 22,
ModerationKickAttemptFailed = 23,
ModerationRoomBan = 24,
ModerationRoomBan = "ModerationRoomBan",
ServerMaintenance = 25,
GiftPackageReceived = 30,
GiftPackageReceivedImmediate = 31,
+9 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono'
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 { NotificationsHub, OWNER_HEADER } from './notifications-hub'
@@ -80,6 +80,14 @@ const app = new Hono<App>()
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — so the responses need CORS headers or
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
// endpoints authenticate with a bearer token in the `Authorization` header, never a
// cookie: a hostile page can't read another origin's stored token, so there is no
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
@@ -581,3 +581,44 @@ describe('clearing pending notifications', () => {
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('*')
})
})
+24
View File
@@ -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);
+40 -1
View File
@@ -92,6 +92,9 @@ export const subRoomIdParam = idParam('subRoomId', 'Subroom id (globally unique,
/** 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. */
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
@@ -441,6 +444,42 @@ export const RoleRequest = z.object({
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`. */
export const WarningRequest = z.object({
warningMask: z.string().describe('Content-warning bit flags, as an integer'),
@@ -466,7 +505,7 @@ export const RestrictionsRequest = z.object({
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({
imageName: z.string().describe('A key from the storage upload'),
title: z.string().optional(),
+300 -14
View File
@@ -5,6 +5,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
Accessibility,
areFriends,
banPlayerFromRoom,
canManageRoom,
cloneRoom,
cloneSubRoom,
@@ -21,6 +22,7 @@ import {
getPresence,
getPublicRoomsByCreator,
getRecommendedRooms,
getRoomBans,
getRoomById,
getRoomByName,
getRoomsByCreator,
@@ -29,7 +31,9 @@ import {
getSubRoomPermissions,
getSubRoomSaves,
getVisitedRooms,
MAX_ROOM_NAME_LENGTH,
modifySubRoom,
nameRejection,
publishSubRoomSave,
removeCheer,
removeFavorite,
@@ -43,14 +47,20 @@ import {
toggleCheer,
toggleFavorite,
toggleRoomTag,
unbanPlayerFromRoom,
updateRoomFields,
} from '@repo/domain'
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 {
AccessibilityRequest,
AUTHED,
bannedPlayerIdParam,
BanRequest,
CloneRoomRequest,
CloningRequest,
CreateSubRoomRequest,
@@ -75,6 +85,8 @@ import {
PublishSaveRequest,
RestrictionsRequest,
RoleRequest,
RoomBanEnvelope,
RoomBanEntryDto,
RoomDto,
RoomEnvelope,
roomIdParam,
@@ -96,7 +108,7 @@ import {
} from './openapi'
import type { Context } from 'hono'
import type { RoomPermission } from '@repo/domain'
import type { RoomBan, RoomPermission } from '@repo/domain'
import type { App } from './context'
/**
@@ -234,6 +246,22 @@ async function authedAccountId(c: Context<App>): Promise<number | null> {
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. */
function unauthorized(c: Context<App>) {
return c.json({ error: 'Unauthorized' }, 401)
@@ -350,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, 100104 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
* HTTP 200 (the client reads `Success`). `ErrorId`/`Error` are null on success.
@@ -398,6 +483,12 @@ function roomEnvelope(c: Context<App>, value: unknown, error = '') {
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). */
async function ownedRooms(c: Context<App>) {
const accountId = await authedAccountId(c)
@@ -982,6 +1073,9 @@ const app = new Hono<App>()
const name = typeof raw === 'string' ? raw.trim() : ''
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)) {
return roomEnvelope(c, null, 'A room with that name already exists!')
}
@@ -1106,6 +1200,12 @@ const app = new Hono<App>()
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).
const existing = await getRoomByName(c.env.DB, name)
@@ -1343,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 rooms ban list',
description: [
'Everyone banned from the room, most recently banned first. Auth-gated, then gated',
'exactly like issuing a ban: the rooms creator or a co-owner, or an account whose',
'token carries the `developer` / `moderator` role. A ban list says who a rooms',
'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 rooms 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 players matchmake into this room is',
'refused with errorCode 55 and never gets a Photon room id.',
'',
'Gated to the rooms 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 rooms 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 players `room_ban` row, so they can matchmake into the room again.',
'Gated exactly like issuing a ban: the rooms 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
// 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
@@ -1490,24 +1763,31 @@ const app = new Hono<App>()
}
)
// Add a load screen to a room (`LoadScreens[]` — the images shown while the room
// loads). Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName`
// form field plus optional `title`/`subtitle`. Appends one
// `{ ImageName, Title, Subtitle }` to the existing list and returns the updated
// room in the `{ success, error, value }` envelope.
// Set a room's load screen (`LoadScreens[]` — the image shown while the room loads).
// Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName` form field
// plus optional `title`/`subtitle`. REPLACES the list with the single posted
// `{ ImageName, Title, Subtitle }` and returns the updated room in the
// `{ 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(
'/rooms/:roomId{[0-9]+}/loadscreen',
describeRoute({
tags: ['Room settings'],
summary: 'Add a load screen to a room',
summary: 'Set a rooms load screen',
description: [
'APPENDS one `{ ImageName, Title, Subtitle }` to the rooms `LoadScreens` the images',
'shown while the room loads. There is no remove or replace counterpart. Owner or',
'co-owner only (403 otherwise).',
'REPLACES the rooms `LoadScreens` with the single posted `{ ImageName, Title,',
'Subtitle }` — the image shown while the room loads. The field is an array (the',
'clients parser expects one) but the client only supports a single screen, so this',
'never appends. Owner or co-owner only (403 otherwise).',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(LoadScreenRequest, 'The load screen to append'),
requestBody: form(LoadScreenRequest, 'The load screen to set'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_RESPONSE,
@@ -1531,8 +1811,8 @@ const app = new Hono<App>()
const title = typeof body.title === 'string' ? body.title : ''
const subtitle = typeof body.subtitle === 'string' ? body.subtitle : ''
const existing = Array.isArray(room.LoadScreens) ? (room.LoadScreens as unknown[]) : []
const loadScreens = [...existing, { ImageName: imageName, Title: title, Subtitle: subtitle }]
// The posted screen becomes the whole list — the client shows one load screen.
const loadScreens = [{ ImageName: imageName, Title: title, Subtitle: subtitle }]
const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens })
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
@@ -1806,6 +2086,10 @@ const app = new Hono<App>()
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 =
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
@@ -2114,6 +2398,8 @@ const app = new Hono<App>()
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const name = typeof body.name === 'string' ? body.name.trim() : ''
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)
if (!result) return roomEnvelope(c, null, 'This room does not exist!')
+310 -20
View File
@@ -13,6 +13,7 @@ import {
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import { NotificationType } from '../../../../notify/src/notification-types'
import importRooms from '../../../static/ImportRooms.json'
import type { Env } from '../../context'
@@ -31,10 +32,12 @@ function b64url(input: ArrayBuffer | string): string {
for (const byte of bytes) binary += String.fromCharCode(byte)
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 signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -736,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) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'PUT',
@@ -923,6 +941,200 @@ describe('rooms endpoints', () => {
expect(roles).toContainEqual(expect.objectContaining({ AccountId: 2, Role: 30 }))
})
it('POST /rooms/:id/bans is gated to the rooms 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 rooms 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 () => {
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/warning', { warningMask: '2' })).status).toBe(401)
@@ -1046,7 +1258,7 @@ describe('rooms endpoints', () => {
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 room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
LoadScreens?: Array<Record<string, unknown>>
@@ -1066,10 +1278,8 @@ describe('rooms endpoints', () => {
success: false,
})
const before = (await screensOf()).length
// Owner adds one (imageName + title + subtitle) — appended, and the success
// envelope carries the updated room.
// Owner sets one (imageName + title + subtitle) — the success envelope carries the
// updated room, and the posted screen is the ONLY entry.
const added = await envOf(
await putForm(
'/rooms/2/loadscreen',
@@ -1078,18 +1288,17 @@ describe('rooms endpoints', () => {
)
)
expect(added).toMatchObject({ success: true })
expect(added.value?.LoadScreens as unknown[]).toContainEqual({
ImageName: 'sharecamera/2026-07-15/abc.jpg',
Title: 'asdf',
Subtitle: 'sdf',
})
expect(await screensOf()).toHaveLength(before + 1)
expect(added.value?.LoadScreens).toEqual([
{ ImageName: 'sharecamera/2026-07-15/abc.jpg', Title: 'asdf', Subtitle: 'sdf' },
])
expect(await screensOf()).toHaveLength(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'))
expect(co).toMatchObject({ success: true })
expect(await screensOf()).toHaveLength(before + 2)
expect(await screensOf()).toContainEqual({ ImageName: 'second.jpg', Title: '', Subtitle: '' })
expect(await screensOf()).toEqual([{ ImageName: 'second.jpg', Title: '', Subtitle: '' }])
})
it('PUT /rooms/:id/accessibility sets the room-level Accessibility (auth-gated, owner/co-owner-only)', async () => {
@@ -1817,7 +2026,7 @@ describe('rooms endpoints', () => {
})
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).
expect((await putForm('/rooms/2/subrooms/2/modify', fields)).status).toBe(401)
// Not the owner (room 2 is owned by account 1) → NotOwner.
@@ -1847,7 +2056,7 @@ describe('rooms endpoints', () => {
Accessibility: 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 () => {
@@ -2290,10 +2499,10 @@ describe('rooms endpoints', () => {
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms`, {
method: 'POST',
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[] } }
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.
expect((await del(2, newId)).status).toBe(401)
@@ -2391,6 +2600,7 @@ describe('rooms endpoints', () => {
)
expect([...documented].sort()).toEqual([
'DELETE /rooms/{roomId}',
'DELETE /rooms/{roomId}/bans/{playerId}',
'DELETE /rooms/{roomId}/interactionby/me/cheer',
'DELETE /rooms/{roomId}/interactionby/me/favorite',
'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
@@ -2410,11 +2620,13 @@ describe('rooms endpoints', () => {
'GET /rooms/visitedby/me',
'GET /rooms/visitedby/{playerId}',
'GET /rooms/{roomId}',
'GET /rooms/{roomId}/bans',
'GET /rooms/{roomId}/interactionby/me',
'GET /rooms/{roomId}/playerdata/me',
'GET /rooms/{roomId}/similar',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
'GET /roomserver/rooms/createdby/me',
'POST /rooms/{roomId}/bans',
'POST /rooms/{roomId}/clone',
'POST /rooms/{roomId}/subrooms',
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
@@ -2444,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)
})
})
+15 -1
View File
@@ -21,11 +21,25 @@ export default defineConfig({
compatibilityDate: '2026-06-16',
compatibilityFlags: ['nodejs_compat'],
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
// notifyPlayer records every call so tests can assert the notifications the
// worker pushed (type + payload). GET /all for the whole list, DELETE to
// reset it between assertions.
script: `
import { DurableObject } from 'cloudflare:workers'
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 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') } }
`,
+1 -1
View File
@@ -35,7 +35,7 @@ import type { App } from './context'
*/
const UPLOAD_SUBFOLDER: Record<number, string> = {
1: 'room',
2: 'holotar',
2: 'data',
3: 'image',
4: 'video',
5: 'invention',
+92
View File
@@ -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}`,
}
}
+450 -66
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { NotificationType } from '../../../notify/src/notification-types'
import { authFailure, authUnreachable } from '../auth-messages'
import {
DISCORD_INVITE,
DOWNLOAD_URL,
@@ -10,48 +12,301 @@ import {
import type { ReactNode } from 'react'
/** The self-account shape returned by the www BFF (`/api/me`, `/api/login`, …). */
interface SelfAccount {
accountId: number
username: string
displayName: string
email: string | null
/** Whether this session may use admin controls (from the token's role claim). */
isAdmin?: boolean
/**
* 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 the BFF (`/api/config`). `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.
* 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 {
accountId: number
username: string
displayName: string
email: string | null
/**
* 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
* the upstream error message (auth uses `error`/`error_description`, the account
* mutations use `error`) so callers can surface it.
* RecNet (4) is the web platform, stamped as the token's `platform` claim on sign-in.
* 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.
*/
async function api<T = unknown>(path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method: body === undefined ? 'GET' : 'POST',
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
const WEB_PLATFORM = '4'
/**
* The session's access token, in localStorage so a reload stays signed in.
*
* 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>
if (!res.ok) {
const message =
(typeof data.error === 'string' && data.error) ||
(typeof data.error_description === 'string' && data.error_description) ||
`Request failed (${res.status})`
throw new Error(message)
// Expired or revoked. Cleared here so no caller has to remember to.
if (res.status === 401 && opts.authed) {
setToken(null)
throw new Error('Your session has expired. Please sign in again.')
}
// 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
}
/** 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. */
function useRouter() {
const [path, setPath] = useState(() => window.location.pathname)
@@ -107,16 +362,31 @@ export function App() {
const { path, navigate } = useRouter()
useEffect(() => {
api<SelfAccount>('/api/me')
.then((me) => setAccount(me))
.catch(() => setAccount(null))
api<SiteConfig>('/api/config')
.then((c) => setConfig(c))
.catch(() => setConfig({ signupEnabled: false, turnstileSiteKey: null }))
// Config first, and everything else after it: it carries the hostnames every other
// call needs. A config that doesn't land leaves the page signed out with signup
// 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 () => {
await api('/api/logout', {})
// Nothing to tell a server: the access token is a stateless JWT, so dropping it here
// 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)
navigate('/')
}, [navigate])
@@ -211,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. */
interface Slide {
url: string
@@ -218,16 +496,35 @@ interface Slide {
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 [error, setError] = useState('')
useEffect(() => {
api<{ images: Slide[] }>('/api/slideshow')
.then((d) => setSlides(d.images))
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [])
if (config === undefined) return
type Feed = { Images?: Array<{ ImageName: string; Username: string; RoomName: string | null }> }
// 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 }
}
@@ -246,7 +543,7 @@ function HomePage({
config: SiteConfig | undefined
navigate: Navigate
}) {
const feed = useSlideshow()
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
@@ -347,9 +644,9 @@ function Stage({
{slide.roomName && ` in ${slide.roomName}`}
</span>
)}
{/* Arrows and a count, not a dot per photo: the feed runs to SLIDESHOW_LIMIT
(130) images, and a dot each is both unusable and wide enough to shove
the headline's half of the split off the page. */}
{/* Arrows and a count, not a dot per photo: a dot each is wide enough to
shove the headline's half of the split off the page, and it would have
to be rebuilt the moment SLIDESHOW_TAKE grows. */}
{count > 1 && (
<span className="steer">
<button onClick={() => step(-1)} aria-label="Previous photo">
@@ -673,7 +970,7 @@ function SignupForm({
}) {
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
const { container, token, error: widgetError, reset } = useTurnstile(siteKey)
const { container, token: widgetToken, error: widgetError, reset } = useTurnstile(siteKey)
const { pending, error, run } = useAction()
return (
@@ -681,19 +978,33 @@ function SignupForm({
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const wanted = email.trim()
try {
const { account } = await api<{ account: SelfAccount }>('/api/signup', {
password,
email,
turnstileToken: token,
})
onAuthed(account)
return ''
await signUp(password, widgetToken)
} catch (err) {
// The token is spent either way, so re-arm the widget before they retry.
// 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 ''
})
}}
>
@@ -727,7 +1038,7 @@ function SignupForm({
<div className="turnstile" ref={container} />
{widgetError && <p className="error">{widgetError}</p>}
{error && <p className="error">{error}</p>}
<button type="submit" disabled={pending || token === ''}>
<button type="submit" disabled={pending || widgetToken === ''}>
{pending ? 'Creating…' : 'Create account'}
</button>
</form>
@@ -744,11 +1055,8 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { account } = await api<{ account: SelfAccount }>('/api/login', {
username,
password,
})
onAuthed(account)
await signIn(username, password)
onAuthed(await fetchMe())
return ''
})
}}
@@ -791,13 +1099,18 @@ function Dashboard({
// 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.
const sections = [
{
id: 'username',
label: 'Username',
render: () => <UsernameForm account={account} onChange={onChange} />,
},
{
id: 'email',
label: 'Email',
render: () => <EmailForm account={account} onChange={onChange} />,
},
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
...(account.isAdmin
...(isAdmin()
? [
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
{ id: 'coach', label: 'Broadcast message', render: () => <CoachMessageForm /> },
@@ -850,9 +1163,7 @@ function CoachMessageForm() {
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { sent } = await api<{ sent?: number }>('/api/coach-message', {
messageContent: message,
})
const { sent } = await coachMessageAll(message.trim())
setMessage('')
return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.`
})
@@ -893,9 +1204,10 @@ function MaintenanceForm() {
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { connections } = await api<{ connections?: number }>('/api/maintenance', {
startsInMinutes: Number(minutes),
})
// Coerced the way the worker used to: a blank or negative box means "now".
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'}.`
})
}}
@@ -921,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({
account,
onChange,
@@ -938,7 +1322,7 @@ function EmailForm({
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
await api('/api/email', { email })
await saveEmail(email.trim())
onChange({ ...account, email })
return 'Email saved.'
})
@@ -976,7 +1360,7 @@ function PasswordForm() {
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
await api('/api/password', { oldPassword, newPassword })
await changePassword(oldPassword, newPassword)
setOldPassword('')
setNewPassword('')
return 'Password changed.'
+10
View File
@@ -6,6 +6,16 @@ export type Env = SharedHonoEnv & {
DOMAIN: string
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
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
+1 -1
View File
@@ -14,7 +14,7 @@ export const DISCORD_INVITE = 'https://discord.gg/HhmMAKhrz'
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/6w1HPL3j2'
export const QUEST_DOWNLOAD_URL = 'https://www.meta.com/s/22O3QO7ytn'
/** The public source repo, linked from the homepage and footer. */
export const SOURCE_REPO = 'https://github.com/djdevin/recflare'
+128 -45
View File
@@ -4,6 +4,7 @@ import { beforeAll, expect, it } from 'vitest'
import { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
import { turnstileKeys } from '../../turnstile'
import { postAuthForm, readAuthError } from '../../upstream'
import type { Env } from '../../context'
@@ -23,24 +24,51 @@ beforeAll(async () => {
await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_KEY)
})
it('rejects unauthenticated account reads', async () => {
const res = await SELF.fetch('https://example.com/api/me')
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'not signed in' })
})
// 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).
it('advertises signup with the Turnstile site key the widget needs', async () => {
//
// 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
@@ -87,19 +115,6 @@ it('refuses a signup with no Turnstile token', async () => {
expect(await res.json()).toEqual({ error: 'Please complete the bot check.' })
})
// The email is optional, but a malformed one is rejected BEFORE the account is created —
// the accounts worker would refuse to store it, and by then the account exists and the
// player would be left with an account whose email silently didn't save.
it('refuses a signup whose email could not be stored', async () => {
const res = await SELF.fetch('https://example.com/api/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password: 'whatever', email: 'not-an-address', turnstileToken: 'x' }),
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ error: 'That email address looks wrong.' })
})
it('refuses a signup with no password', async () => {
const res = await SELF.fetch('https://example.com/api/signup', {
method: 'POST',
@@ -110,34 +125,102 @@ it('refuses a signup with no password', async () => {
expect(await res.json()).toEqual({ error: 'A password is required.' })
})
it('requires credentials to log in', async () => {
const res = await SELF.fetch('https://example.com/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'alice' }),
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ error: 'Username and password are required.' })
// A refused grant reaches the form as a sentence, never as the OAuth code. auth answers
// `{ error: 'invalid_grant', error_description: <the actual reason> }`, and www used to
// relay that untouched — so every failed signup, including one the player could act on
// (the per-network cap), read simply "invalid_grant". Checked directly because the pass
// 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 () => {
const refused = (description: string, status = 400) =>
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 maintenance broadcast', async () => {
const res = await SELF.fetch('https://example.com/api/maintenance', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ startsInMinutes: 15 }),
})
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'not signed in' })
})
// The signup cap counts auth's `CF-Connecting-IP` as the account's immutable `signupIp`,
// and www used to reach auth over https://auth.<DOMAIN> — a Worker subrequest, which
// re-enters the Cloudflare edge, which REPLACES that header with Cloudflare's own
// address. Every browser signup therefore shared one IP, and the cap (3, never decaying)
// 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.
//
// 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
it('rejects an unauthenticated coach message', async () => {
const res = await SELF.fetch('https://example.com/api/coach-message', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ messageContent: 'hello all' }),
})
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'not signed in' })
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 () => {
+56 -13
View File
@@ -1,11 +1,13 @@
import { authFailure } from './auth-messages'
import type { AuthAction, AuthFailure } from './auth-messages'
import type { Env } from './context'
/**
* The www worker is a backend-for-frontend (BFF): the browser only ever talks to
* www, and www forwards to the `auth` and `accounts` workers server-side. That
* keeps the JWT off other origins and sidesteps CORS (those workers set no CORS
* headers). Hosts are derived from the shared base domain (`auth.<DOMAIN>`,
* `accounts.<DOMAIN>`), matching how the workers are deployed.
* Where the other workers live. Derived from the shared base domain, matching how they
* are deployed. www serves these to the SPA (`/api/config`), which calls them DIRECTLY
* the same endpoints the game uses, as rec.net's own site did. The only one www still
* calls itself is `auth`, for the Turnstile-gated signup grant (see `postAuthForm`).
*/
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}`
/**
* POST a form-urlencoded body to an upstream worker. The auth/accounts endpoints
* read their inputs via Hono's `parseBody()`, so they expect form fields (not
* JSON). `bearer`, when given, authenticates the caller.
* POST a form body to the `auth` worker, carrying the browser's real IP across.
*
* 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(
url: string,
export async function postAuthForm(
env: Env,
path: string,
fields: Record<string, string>,
bearer?: string
opts: { bearer?: string; clientIp?: string } = {}
): Promise<Response> {
const headers: Record<string, string> = {
'content-type': 'application/x-www-form-urlencoded',
}
if (bearer) headers.authorization = `Bearer ${bearer}`
return fetch(url, {
if (opts.bearer) headers.authorization = `Bearer ${opts.bearer}`
if (opts.clientIp) headers['cf-connecting-ip'] = opts.clientIp
const request = new Request(`${authBase(env)}${path}`, {
method: 'POST',
headers,
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)
}
+85 -301
View File
@@ -1,139 +1,39 @@
import { Hono } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { useWorkersLogger } from 'workers-tagged-logger'
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 { privacyPage } from './privacy'
import { turnstileKeys, verifyTurnstile } from './turnstile'
import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream'
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'
/**
* www the first frontend worker. It serves the React SPA (create account, set
* email, change password) and acts as a backend-for-frontend: the browser talks
* only to www, and www forwards to the `auth`/`accounts` workers server-side (see
* `upstream.ts`). The account's JWT lives in an httpOnly cookie set here, so it's
* never exposed to page JS.
*/
/** 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).
* www the website worker. It serves the React SPA (create account, sign in, change
* username/email/password) and almost nothing else: the SPA calls the SAME endpoints
* the game does, on `auth`/`accounts`/`api`/`notify` directly, exactly as rec.net's own
* site did. Those workers answer CORS for it, and the browser holds the access token.
*
* `email`, when given, is saved onto the new account before that fetch, so the account
* comes back already carrying it. `create_account` takes no email the accounts worker
* owns that field which is why this is a second call rather than another grant field.
* 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.
*/
async function establishSession(c: Context<App>, tokenResponse: Response, email?: string) {
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)
)
// Deliberately not fatal: the account exists and the session is live by now, so failing
// the request would leave the player holding an account they think they don't have —
// and a retry would burn another slot against auth's per-IP signup cap. They land on
// the account page instead, where the email field is the same one call away. The
// address is validated before signup starts, so reaching here means something upstream
// went wrong, not that the input was bad.
if (email) {
const res = await postForm(
`${accountsBase(c.env)}/account/me/email`,
{ email },
token.access_token
)
if (!res.ok) {
logger.error('failed to save the signup email; the account was still created', {
status: res.status,
})
}
}
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>()
.use(
@@ -148,214 +48,98 @@ const app = new Hono<App>()
.onError(withOnError())
// ---- BFF API ------------------------------------------------------------
// ---- Site config --------------------------------------------------------
// What the SPA has to know before it can render the sign-in page: whether web signup
// is open, and the Turnstile site key to mount its widget with. The site key is public
// (it ships in the widget markup either way); the secret never leaves the worker.
// Served rather than baked into the client build so one build works for any operator.
// What the SPA has to know before it can do anything: whether web signup is open,
// the Turnstile site key to mount its widget with, and the hostnames of the workers
// it calls directly. All three are served rather than baked into the client build so
// one build works for any operator. The site key is public (it ships in the widget
// markup either way); the secret never leaves the worker.
.get('/api/config', async (c) => {
const keys = await turnstileKeys(c.env)
return c.json({ signupEnabled: keys !== null, turnstileSiteKey: keys?.siteKey ?? null })
return c.json({
signupEnabled: keys !== null,
turnstileSiteKey: keys?.siteKey ?? null,
hosts: {
auth: authBase(c.env),
accounts: accountsBase(c.env),
api: apiBase(c.env),
img: imgBase(c.env),
notify: notifyBase(c.env),
},
})
})
// ---- Signup -------------------------------------------------------------
// Create an account from the website, behind a Turnstile bot check. The check is what
// makes this safe to leave open: `auth` binds no platform identity to a web account, so
// its per-IP cap is the only other thing in front of this path.
// its per-IP cap (3, never decaying) is the only other thing in front of this path
// and `auth` has no bot check of its own, which is why this one endpoint can't simply
// 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 (see WEB_PLATFORM), so this is the
// platform-less password-account path. The username is auto-assigned by auth — players
// don't pick one — and the new session is established from the token response.
// 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)
type SignupBody = { password?: string; email?: string; turnstileToken?: string }
const { password, email, turnstileToken } = await c.req
type SignupBody = { password?: string; turnstileToken?: string }
const { password, turnstileToken } = await c.req
.json<SignupBody>()
.catch(() => ({}) as SignupBody)
if (!password) return c.json({ error: 'A password is required.' }, 400)
if (!turnstileToken) return c.json({ error: 'Please complete the bot check.' }, 400)
// Optional — an account works without one; it's the address a locked-out player
// would be reached at. Checked HERE, before anything is created, because the
// accounts worker rejects an address with no `@` and by then the account exists:
// better to fail the form than to hand back an account whose email silently didn't
// save. Same rule the accounts worker applies, deliberately no stricter — this is
// a contact address, not an identity, and nothing is sent to it to prove it.
const signupEmail = typeof email === 'string' ? email.trim() : ''
if (signupEmail !== '' && !signupEmail.includes('@')) {
return c.json({ error: 'That email address looks wrong.' }, 400)
}
// The IP Turnstile cross-checks the token against — set by the edge, so the client
// can't spoof it (unlike X-Forwarded-For). `auth` records the same header as the
// account's signup IP.
const verified = await verifyTurnstile(
keys.secretKey,
turnstileToken,
c.req.header('cf-connecting-ip')
)
// 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)
const res = await postForm(`${authBase(c.env)}/connect/token`, {
grant_type: 'create_account',
password,
})
return establishSession(c, res, signupEmail || undefined)
})
// 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)
// 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)
}
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
// 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 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)
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)
}
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({
success: true,
starts_in_minutes: startsIn,
connections: result.delivered ?? 0,
})
})
// Send a coach/system message to every online player. Like maintenance, this
// 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
.json<{ messageContent?: string }>()
.catch(() => ({}) as { messageContent?: string })
const content = typeof messageContent === 'string' ? messageContent.trim() : ''
if (content === '') return c.json({ error: 'A message is required.' }, 400)
const res = await fetch(`${notifyBase(c.env)}/internal/coach-message-all`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ messageContent: content }),
})
if (!res.ok) return relay(c, res)
const result = (await res.json()) as { sent?: number }
return c.json({ success: true, sent: result.sent ?? 0 })
return c.json(token)
})
// ---- Privacy policy -----------------------------------------------------
+16
View File
@@ -6,6 +6,22 @@ export default defineConfig({
cloudflareTest({
wrangler: { configPath: `${__dirname}/wrangler.jsonc` },
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: {
ENVIRONMENT: 'VITEST',
// The Turnstile keypair is NOT bound here: both keys come from the Secrets
+21 -5
View File
@@ -13,7 +13,8 @@
// 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
// 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
// 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
@@ -54,6 +55,20 @@
"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,
"observability": {
"logs": {
@@ -64,10 +79,11 @@
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown", // overridden during deployment
// Base domain the auth/accounts hosts are derived from (auth.<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
// the BFF proxy can reach the auth/accounts workers.
// Base domain every worker hostname is derived from (auth.<DOMAIN>,
// accounts.<DOMAIN>, ). Overridden at deploy time with the real RECFLARE_DOMAIN
// (see run-wrangler-deploy). www serves these to the SPA via `/api/config`, which
// is how one client build works for any operator. For local dev, point it at a
// deployed domain so the page has real workers to call.
"DOMAIN": "rec.example.com"
}
}
+3
View File
@@ -13,5 +13,8 @@
"@cloudflare/workers-types": "4.20260630.1",
"@repo/tools": "workspace:*",
"@repo/typescript-config": "workspace:*"
},
"dependencies": {
"isemail": "^3.2.0"
}
}
+2
View File
@@ -7,4 +7,6 @@ export * from './rooms-db'
export * from './room-instance-db'
export * from './presence-db'
export * from './gifts-db'
export * from './inventory-invention-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)
}
+29
View File
@@ -174,6 +174,35 @@ export async function countPlayersByRoom(
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
* player was in when they stopped heartbeating (a crash or a hard quit, where no
+77 -1
View File
@@ -12,7 +12,7 @@
* the client DTO (`toDto`).
*/
import { countPlayersInInstance } from './presence-db'
import { countPlayersInInstance, getPlayerIdsByRoomInstance } from './presence-db'
/** Schema DDL (mirror of migrations/0004_room_instance.sql). */
export const ROOM_INSTANCE_SCHEMA_DDL: string[] = [
@@ -69,6 +69,22 @@ export interface RoomInstanceDto {
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). */
interface StoredRoomInstance extends RoomInstanceDto {
ownerAccountId: number
@@ -206,6 +222,35 @@ export async function setRoomInstanceInProgress(
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
* number of players currently present in the instance reaches its `maxCapacity`
@@ -292,3 +337,34 @@ export async function getRoomInstancesByRoom(
.all<{ data: string }>()
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) ?? [],
}
})
}
+107
View File
@@ -43,6 +43,23 @@ export const ROOM_SCHEMA_DDL: string[] = [
last_visited_at TEXT,
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)`,
]
/**
@@ -136,6 +153,96 @@ export function canManageRoom(room: Room, accountId: number): boolean {
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
* room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
+178
View File
@@ -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
}
+37 -1
View File
@@ -33,9 +33,44 @@ function addIntegerExamples(node: unknown): void {
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
* 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
* 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
const spec: unknown = await res.json()
addIntegerExamples(spec)
mirrorFormBodies(spec)
return c.json(spec as Record<string, unknown>)
}
}
+18
View File
@@ -864,6 +864,10 @@ importers:
version: 4.105.0(@cloudflare/workers-types@4.20260630.1)
packages/domain:
dependencies:
isemail:
specifier: ^3.2.0
version: 3.2.0
devDependencies:
'@cloudflare/workers-types':
specifier: 4.20260630.1
@@ -3174,6 +3178,10 @@ packages:
resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==}
engines: {node: '>=12'}
isemail@3.2.0:
resolution: {integrity: sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==}
engines: {node: '>=4.0.0'}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -3542,6 +3550,10 @@ packages:
property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -6154,6 +6166,10 @@ snapshots:
is-regexp@3.1.0: {}
isemail@3.2.0:
dependencies:
punycode: 2.3.1
jiti@2.6.1:
optional: true
@@ -6677,6 +6693,8 @@ snapshots:
property-information@7.2.0: {}
punycode@2.3.1: {}
quansync@0.2.11: {}
radix-vue@1.9.17(vue@3.5.40(typescript@6.0.3)):