mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc43d57172 | |||
| 8b804eaa33 | |||
| 6b7acc9435 | |||
| 93a46871de | |||
| 4111bc49aa | |||
| f6561f1ec9 | |||
| bc96a6245b | |||
| a73dec7c13 | |||
| ae3bef4cc4 | |||
| 1f615bab4f | |||
| a986d012f5 | |||
| b82a5e1dc0 | |||
| 6bfd4d9e50 | |||
| 079c889ccb | |||
| 9f4ce07aca | |||
| dfb1e9ab21 | |||
| 1d08ed8296 | |||
| f3e2ab422c | |||
| aa304dbede | |||
| 10eb89ac12 | |||
| 65611c15d8 | |||
| d6a0e3e6a6 | |||
| 7d300fa836 | |||
| 73bb7c4609 | |||
| dbc6d15ef5 | |||
| db003d54ef | |||
| 05b56e698e | |||
| dee7497fe5 | |||
| 12f6d7ab61 | |||
| 8d1539de03 | |||
| 7e0c26a100 | |||
| f94877347c |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)'),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Player-report storage. Like the relationship table (and unlike the JSON-blob
|
||||
-- tables in this shared database), a report is genuinely columnar, so it gets a
|
||||
-- normal relational table. Owned by the `api` worker; generated from
|
||||
-- src/reports-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- One row per submitted report; nothing updates or dedupes them, so the table is
|
||||
-- an append-only log of what players sent. `reporter_player_id` comes from the
|
||||
-- caller's bearer token, everything else from the form body.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS report (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reporter_player_id INTEGER NOT NULL,
|
||||
reported_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
details TEXT,
|
||||
height_reporter REAL,
|
||||
height_reported REAL,
|
||||
room_id INTEGER,
|
||||
room_instance_type TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Moderator-issued player warnings. The counterpart to the `report` table (0004):
|
||||
-- reports are what players submit, warnings are what a moderator hands down. Also
|
||||
-- columnar rather than a JSON blob, and likewise append-only. Owned by the `api`
|
||||
-- worker; generated from src/warnings-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `moderator_player_id` is the acting moderator, taken from the caller's bearer
|
||||
-- token (the endpoint is gated on the `moderator` role); everything else comes
|
||||
-- from the form body. `display_reason` is what the warned player is shown,
|
||||
-- `moderator_note` is internal.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS warning (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
moderator_player_id INTEGER NOT NULL,
|
||||
warned_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
display_reason TEXT,
|
||||
moderator_note TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Player-event storage (scheduled events: a room, a window of time, and the
|
||||
-- settings the event runs under). Like the image/invention/rooms/accounts tables
|
||||
-- in this shared database, an event is a single JSON blob in the `data` column,
|
||||
-- with queryable fields exposed as SQLite generated (virtual) columns extracted
|
||||
-- from that JSON. Owned by the `api` worker; generated from src/events-db.ts
|
||||
-- (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- The stored blob IS the DTO: every read endpoint serves it verbatim, so the
|
||||
-- PascalCase field set matches Rec Room's `PlayerEvent` exactly. `start_time` /
|
||||
-- `end_time` extract ISO-8601 UTC strings, which compare lexicographically — the
|
||||
-- browse query filters finished events in SQL on that.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
|
||||
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
||||
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
|
||||
start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
|
||||
end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Player-event RSVPs: one row per player per event, recording how they answered
|
||||
-- (`POST /api/playerevents/v1/respond`). Unlike the `event` table next to it, this
|
||||
-- one is genuinely columnar — like the relationship/report tables — so it's a
|
||||
-- normal relational table rather than a JSON blob. Owned by the `api` worker;
|
||||
-- generated from src/events-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go. Only Going
|
||||
-- counts toward the event's `AttendeeCount`, which is recomputed from this table on
|
||||
-- every response. A decline is recorded rather than deleted, so the client can show
|
||||
-- a player their own answer and changing your mind is an UPDATE (the composite
|
||||
-- primary key is what makes the upsert a replace).
|
||||
--
|
||||
-- An event's creator gets a Going row at create time — that's why a fresh event's
|
||||
-- AttendeeCount is 1.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_attendee (
|
||||
event_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
responded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (event_id, player_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Break the two visibility flags out of the invention JSON blob into queryable
|
||||
-- generated columns, the same way 0003 did for `IsFeatured`. `IsPublished` and
|
||||
-- `HideFromPlayer` are always tested together — every feed, the search/browse list and
|
||||
-- the per-room list ask for "published and not hidden" — so they move together.
|
||||
-- Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- SQLite allows ALTER TABLE ADD COLUMN only for VIRTUAL generated columns (a STORED one
|
||||
-- would need rewriting existing rows), which is what we want anyway: the value stays
|
||||
-- derived from `data`, so nothing can drift out of sync with it. json_extract of a JSON
|
||||
-- `true` is 1, so both columns read 1/0 — and NULL for a blob missing the key, which is
|
||||
-- neither 1 nor 0 and so fails both filters exactly as the json_extract predicates it
|
||||
-- replaces did. This is a rename, not a behaviour change.
|
||||
--
|
||||
-- No index: both columns are booleans that are overwhelmingly one value (nearly every
|
||||
-- invention is published and not hidden), so an index on them would be read past rather
|
||||
-- than used. The selective one is idx_invention_featured, added in 0003, which stays.
|
||||
|
||||
ALTER TABLE invention
|
||||
ADD COLUMN is_published INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsPublished')) VIRTUAL;
|
||||
ALTER TABLE invention
|
||||
ADD COLUMN hide_from_player INTEGER GENERATED ALWAYS AS (json_extract(data, '$.HideFromPlayer')) VIRTUAL;
|
||||
+14
-4
@@ -2,10 +2,11 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { 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',
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* Player-event storage on the shared `recflare` D1 database. Each event is a single
|
||||
* JSON blob in the `data` column; queryable fields (id, creator, club, start time)
|
||||
* are SQLite generated (virtual) columns extracted from that JSON — the same
|
||||
* JSON-blob pattern the image/invention/rooms/accounts tables use.
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0006_event.sql and
|
||||
* 0007_event_attendee.sql, applied under its own `migrations_table` so they don't
|
||||
* clash with the other workers' migrations on the shared database).
|
||||
*
|
||||
* The stored record IS the DTO: every read endpoint serves the blob verbatim, so the
|
||||
* field set and casing here are exactly what the client parses. Timestamps are
|
||||
* normalized to `2020-11-29T22:00:00Z` (no fractional seconds) to match.
|
||||
*
|
||||
* RSVPs live alongside in `event_attendee`, one row per player per event. That one is
|
||||
* genuinely columnar (like the relationship/report tables), so it's a normal
|
||||
* relational table rather than a JSON blob.
|
||||
*/
|
||||
|
||||
import {
|
||||
glyphLength,
|
||||
MAX_EVENT_DESCRIPTION_LENGTH,
|
||||
MAX_EVENT_NAME_LENGTH,
|
||||
} from '@repo/domain'
|
||||
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any
|
||||
* seed rows).
|
||||
*/
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS event (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
|
||||
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
||||
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
|
||||
start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
|
||||
end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time)`,
|
||||
`CREATE TABLE IF NOT EXISTS event_attendee (
|
||||
event_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
responded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (event_id, player_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* How a player answered an event invitation — the `Type` on
|
||||
* `POST /api/playerevents/v1/respond`, stored as `event_attendee.status`.
|
||||
*
|
||||
* Only `going` counts toward an event's `AttendeeCount`: interested is a maybe, and
|
||||
* declining is recorded rather than deleted so the client can show the player their own
|
||||
* answer (and so changing your mind is an update, not an insert).
|
||||
*/
|
||||
export const EVENT_RESPONSE = {
|
||||
going: 0,
|
||||
interested: 1,
|
||||
cantGo: 2,
|
||||
} as const
|
||||
|
||||
/** The response types, for validating an incoming `Type`. */
|
||||
const EVENT_RESPONSE_VALUES: number[] = Object.values(EVENT_RESPONSE)
|
||||
|
||||
/** Whether a number is one of the three response types. */
|
||||
export function isEventResponseType(value: number): boolean {
|
||||
return EVENT_RESPONSE_VALUES.includes(value)
|
||||
}
|
||||
|
||||
/** One player's answer to one event. */
|
||||
export interface EventAttendeeRow {
|
||||
event_id: number
|
||||
player_id: number
|
||||
status: number
|
||||
responded_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A scheduled player event (Rec Room's `PlayerEvent`) — a room, a window of time and
|
||||
* the settings the event runs under. Served verbatim by every read endpoint.
|
||||
*
|
||||
* `SubRoomId`/`ClubId`/`ImageName` are genuinely nullable: an event can name the room
|
||||
* without pinning a subroom, needn't belong to a club, and has no banner until one is
|
||||
* uploaded. The three `*Permissions`/`State`/`Accessibility` ints are stored as the
|
||||
* client sends them — their enums aren't reversed yet, so nothing here interprets
|
||||
* them beyond the defaults below.
|
||||
*/
|
||||
export interface PlayerEvent {
|
||||
PlayerEventId: number
|
||||
CreatorPlayerId: number
|
||||
ImageName: string | null
|
||||
RoomId: number
|
||||
SubRoomId: number | null
|
||||
ClubId: number | null
|
||||
Name: string
|
||||
Description: string
|
||||
/** ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`). */
|
||||
StartTime: string
|
||||
EndTime: string
|
||||
AttendeeCount: number
|
||||
State: number
|
||||
Accessibility: number
|
||||
IsMultiInstance: boolean
|
||||
SupportMultiInstanceRoomChat: boolean
|
||||
DefaultBroadcastPermissions: number
|
||||
CanRequestBroadcastPermissions: number
|
||||
}
|
||||
|
||||
interface EventRow {
|
||||
data: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The envelope the create/update writes answer with — the event nested under a status,
|
||||
* rather than the bare record the read endpoints serve. `Result` is 0 on success.
|
||||
*
|
||||
* `TagModifyResult` is always null: the real API reports the outcome of the tag edit
|
||||
* that rides along with the write, and we store no event tags (see the tag-filter
|
||||
* chips, which are static). The field stays present because the client's parser
|
||||
* expects it.
|
||||
*/
|
||||
export interface PlayerEventResult {
|
||||
Result: number
|
||||
TagModifyResult: null
|
||||
PlayerEvent: PlayerEvent
|
||||
}
|
||||
|
||||
/** Wrap a stored event in the write envelope. */
|
||||
export function toEventResult(event: PlayerEvent): PlayerEventResult {
|
||||
return { Result: 0, TagModifyResult: null, PlayerEvent: event }
|
||||
}
|
||||
|
||||
/**
|
||||
* The projection of an event carried on a hub notification frame (`PlayerEventCreated`
|
||||
* and its siblings). Deliberately NOT the stored record, in three ways — don't unify
|
||||
* them:
|
||||
*
|
||||
* - it is camelCase, where the record and every read endpoint are PascalCase;
|
||||
* - it carries `tags` and `broadcastingRoomInstanceId`, which the record has no fields
|
||||
* for (no event tags are stored, and nothing broadcasts an event yet, so both are
|
||||
* empty/null), and drops `State`;
|
||||
* - its timestamps are padded to .NET tick precision (`…T19:00:00.0000000Z`) while the
|
||||
* record stores them bare. That asymmetry is the reference server's: its notification
|
||||
* frames carry the padded form and its event reads don't.
|
||||
*/
|
||||
export interface PlayerEventNotification {
|
||||
tags: Array<{ tag: string; type: number }>
|
||||
playerEventId: number
|
||||
creatorPlayerId: number
|
||||
roomId: number
|
||||
subRoomId: number | null
|
||||
clubId: number | null
|
||||
name: string
|
||||
description: string
|
||||
imageName: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
attendeeCount: number
|
||||
accessibility: number
|
||||
isMultiInstance: boolean
|
||||
supportMultiInstanceRoomChat: boolean
|
||||
defaultBroadcastPermissions: number
|
||||
canRequestBroadcastPermissions: number
|
||||
broadcastingRoomInstanceId: number | null
|
||||
}
|
||||
|
||||
/** Pad a stored timestamp out to .NET tick precision (seven fractional digits). */
|
||||
function toTickPrecision(iso: string): string {
|
||||
const match = /^(.*?)(?:\.(\d+))?Z$/.exec(iso)
|
||||
if (match === null) return iso
|
||||
return `${match[1]}.${(match[2] ?? '').padEnd(7, '0').slice(0, 7)}Z`
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored event into its notification frame. `imageName` becomes an empty
|
||||
* string rather than null when the event has no banner: the frame carries `""`, and a
|
||||
* null wouldn't survive the trip anyway — the hub drops null values from `Msg`.
|
||||
*/
|
||||
export function toEventNotification(event: PlayerEvent): PlayerEventNotification {
|
||||
return {
|
||||
tags: [],
|
||||
playerEventId: event.PlayerEventId,
|
||||
creatorPlayerId: event.CreatorPlayerId,
|
||||
roomId: event.RoomId,
|
||||
subRoomId: event.SubRoomId,
|
||||
clubId: event.ClubId,
|
||||
name: event.Name,
|
||||
description: event.Description,
|
||||
imageName: event.ImageName ?? '',
|
||||
startTime: toTickPrecision(event.StartTime),
|
||||
endTime: toTickPrecision(event.EndTime),
|
||||
attendeeCount: event.AttendeeCount,
|
||||
accessibility: event.Accessibility,
|
||||
isMultiInstance: event.IsMultiInstance,
|
||||
supportMultiInstanceRoomChat: event.SupportMultiInstanceRoomChat,
|
||||
defaultBroadcastPermissions: event.DefaultBroadcastPermissions,
|
||||
canRequestBroadcastPermissions: event.CanRequestBroadcastPermissions,
|
||||
broadcastingRoomInstanceId: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a timestamp to the form the client sends and reads back —
|
||||
* `2020-11-29T22:00:00Z`, with no fractional seconds. `toISOString()` always emits
|
||||
* milliseconds, which the samples never carry, so they're trimmed.
|
||||
*/
|
||||
function eventTime(ms: number): string {
|
||||
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields a create or update supplies, camelCased. Every one is optional: create
|
||||
* defaults what's missing, and update leaves anything absent at its stored value —
|
||||
* which is why the nullable ids are `number | null` rather than merely absent, so a
|
||||
* posted `"ClubId": null` can genuinely clear a club.
|
||||
*/
|
||||
export interface EventInput {
|
||||
imageName?: string | null
|
||||
roomId?: number
|
||||
subRoomId?: number | null
|
||||
clubId?: number | null
|
||||
name?: string
|
||||
description?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
state?: number
|
||||
accessibility?: number
|
||||
isMultiInstance?: boolean
|
||||
supportMultiInstanceRoomChat?: boolean
|
||||
defaultBroadcastPermissions?: number
|
||||
canRequestBroadcastPermissions?: number
|
||||
}
|
||||
|
||||
/** Read a value as an integer, or undefined when absent / not a number. */
|
||||
function asInt(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value)
|
||||
if (typeof value === 'string') {
|
||||
const n = Number.parseInt(value, 10)
|
||||
if (!Number.isNaN(n)) return n
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a posted event body into an {@link EventInput}.
|
||||
*
|
||||
* Accepts the event's fields either at the top level or nested under `PlayerEvent`:
|
||||
* the client posts the same envelope it reads back, and both forms are in circulation.
|
||||
* A field the body doesn't carry stays undefined (create defaults it, update keeps the
|
||||
* stored value); an explicit `null` on one of the nullable ids is preserved so it can
|
||||
* clear the value. Timestamps are normalized here, so an unparseable one is dropped
|
||||
* rather than stored.
|
||||
*/
|
||||
/**
|
||||
* Why a parsed event body can't be stored, or `null` when it's fine.
|
||||
*
|
||||
* Length only. An event name is a title, not an identifier — "Building a Better Room
|
||||
* Using Trigonometry" is a real one — so the alphanumeric rule the account and room
|
||||
* names carry would be wrong here. Absent fields are skipped: an update posts only what
|
||||
* it changes, and create defaults a missing name rather than refusing it.
|
||||
*
|
||||
* The name is measured AFTER trimming, matching what create/update actually store.
|
||||
*/
|
||||
export function eventInputRejection(input: EventInput): string | null {
|
||||
const name = input.name?.trim()
|
||||
if (name !== undefined && glyphLength(name) > MAX_EVENT_NAME_LENGTH) {
|
||||
return `Event names can be at most ${MAX_EVENT_NAME_LENGTH} characters.`
|
||||
}
|
||||
if (
|
||||
input.description !== undefined &&
|
||||
glyphLength(input.description) > MAX_EVENT_DESCRIPTION_LENGTH
|
||||
) {
|
||||
return `Event descriptions can be at most ${MAX_EVENT_DESCRIPTION_LENGTH} characters.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function parseEventBody(body: unknown): EventInput {
|
||||
const outer = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>
|
||||
const nested = outer.PlayerEvent
|
||||
const obj = (typeof nested === 'object' && nested !== null ? nested : outer) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
const has = (key: string): boolean => Object.hasOwn(obj, key)
|
||||
// A nullable id: absent leaves it alone, an explicit null clears it.
|
||||
const nullableInt = (key: string): number | null | undefined => {
|
||||
if (!has(key)) return undefined
|
||||
return obj[key] === null ? null : asInt(obj[key])
|
||||
}
|
||||
const time = (key: string): string | undefined => {
|
||||
const raw = obj[key]
|
||||
if (typeof raw !== 'string') return undefined
|
||||
const parsed = Date.parse(raw)
|
||||
return Number.isNaN(parsed) ? undefined : eventTime(parsed)
|
||||
}
|
||||
const bool = (key: string): boolean | undefined => {
|
||||
const raw = obj[key]
|
||||
if (typeof raw === 'boolean') return raw
|
||||
if (raw === 'true') return true
|
||||
if (raw === 'false') return false
|
||||
return undefined
|
||||
}
|
||||
// The banner name: same absent/null distinction as the nullable ids.
|
||||
const nullableString = (key: string): string | null | undefined => {
|
||||
if (!has(key)) return undefined
|
||||
if (obj[key] === null) return null
|
||||
return typeof obj[key] === 'string' ? (obj[key] as string) : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
imageName: nullableString('ImageName'),
|
||||
roomId: asInt(obj.RoomId),
|
||||
subRoomId: nullableInt('SubRoomId'),
|
||||
clubId: nullableInt('ClubId'),
|
||||
name: typeof obj.Name === 'string' ? obj.Name : undefined,
|
||||
description: typeof obj.Description === 'string' ? obj.Description : undefined,
|
||||
startTime: time('StartTime'),
|
||||
endTime: time('EndTime'),
|
||||
state: asInt(obj.State),
|
||||
accessibility: asInt(obj.Accessibility),
|
||||
isMultiInstance: bool('IsMultiInstance'),
|
||||
supportMultiInstanceRoomChat: bool('SupportMultiInstanceRoomChat'),
|
||||
defaultBroadcastPermissions: asInt(obj.DefaultBroadcastPermissions),
|
||||
canRequestBroadcastPermissions: asInt(obj.CanRequestBroadcastPermissions),
|
||||
}
|
||||
}
|
||||
|
||||
/** How long an event runs when the body names a start but no end. */
|
||||
const DEFAULT_DURATION_MS = 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Insert a new event, returning the stored record.
|
||||
*
|
||||
* Lenient about what the body carries, like the other writes here: an event with no
|
||||
* name or no time window is defaulted rather than rejected, because a rejection the
|
||||
* client can't render is worse than a placeholder the creator can edit. `State` starts
|
||||
* at 0 (scheduled). The creator comes from the bearer token, never the body.
|
||||
*
|
||||
* The creator is recorded as Going in `event_attendee`, which is what makes
|
||||
* `AttendeeCount` start at 1: the count is derived from that table, so the creator
|
||||
* needs a row there for the number to stay right once other players respond.
|
||||
*/
|
||||
export async function createEvent(
|
||||
db: D1Database,
|
||||
creatorPlayerId: number,
|
||||
input: EventInput
|
||||
): Promise<PlayerEvent> {
|
||||
// Sequential id: one past the current max (the table starts empty).
|
||||
const row = await db
|
||||
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM event')
|
||||
.first<{ next: number }>()
|
||||
const now = Date.now()
|
||||
const startTime = input.startTime ?? eventTime(now)
|
||||
const event: PlayerEvent = {
|
||||
PlayerEventId: row?.next ?? 1,
|
||||
CreatorPlayerId: creatorPlayerId,
|
||||
ImageName: input.imageName ?? null,
|
||||
RoomId: input.roomId ?? 0,
|
||||
SubRoomId: input.subRoomId ?? null,
|
||||
ClubId: input.clubId ?? null,
|
||||
Name: input.name?.trim() || 'Untitled Event',
|
||||
Description: input.description ?? '',
|
||||
StartTime: startTime,
|
||||
EndTime: input.endTime ?? eventTime(Date.parse(startTime) + DEFAULT_DURATION_MS),
|
||||
AttendeeCount: 1,
|
||||
State: input.state ?? 0,
|
||||
Accessibility: input.accessibility ?? 1,
|
||||
IsMultiInstance: input.isMultiInstance ?? false,
|
||||
SupportMultiInstanceRoomChat: input.supportMultiInstanceRoomChat ?? false,
|
||||
DefaultBroadcastPermissions: input.defaultBroadcastPermissions ?? 0,
|
||||
CanRequestBroadcastPermissions: input.canRequestBroadcastPermissions ?? 0,
|
||||
}
|
||||
await db.batch([
|
||||
db.prepare('INSERT INTO event (data) VALUES (?1)').bind(JSON.stringify(event)),
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
)
|
||||
.bind(event.PlayerEventId, creatorPlayerId, EVENT_RESPONSE.going, eventTime(now)),
|
||||
])
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a player's answer to an event, replacing whatever they said before — one row
|
||||
* per player per event, so changing your mind is an update rather than a second RSVP.
|
||||
* The event's `AttendeeCount` is recomputed from the table afterwards.
|
||||
*
|
||||
* Returns the updated event, or null when there's no such event. Anyone who can see an
|
||||
* event may respond to it, the creator included (they're already Going from create, and
|
||||
* nothing stops them declining their own event).
|
||||
*/
|
||||
export async function setEventResponse(
|
||||
db: D1Database,
|
||||
eventId: number,
|
||||
playerId: number,
|
||||
status: number
|
||||
): Promise<PlayerEvent | null> {
|
||||
const event = await getEventById(db, eventId)
|
||||
if (event === null) return null
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4`
|
||||
)
|
||||
.bind(eventId, playerId, status, eventTime(Date.now()))
|
||||
.run()
|
||||
|
||||
const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
|
||||
await writeEvent(db, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
/** How many players said they're Going — an event's `AttendeeCount`. */
|
||||
export async function countGoing(db: D1Database, eventId: number): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT COUNT(*) AS going FROM event_attendee WHERE event_id = ?1 AND status = ?2')
|
||||
.bind(eventId, EVENT_RESPONSE.going)
|
||||
.first<{ going: number }>()
|
||||
return row?.going ?? 0
|
||||
}
|
||||
|
||||
/** One player's answer to one event, or null when they haven't responded. */
|
||||
export async function getEventResponse(
|
||||
db: D1Database,
|
||||
eventId: number,
|
||||
playerId: number
|
||||
): Promise<EventAttendeeRow | null> {
|
||||
return db
|
||||
.prepare('SELECT * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2')
|
||||
.bind(eventId, playerId)
|
||||
.first<EventAttendeeRow>()
|
||||
}
|
||||
|
||||
/** Everyone who answered an event, in the order they responded. Backs a future guest list. */
|
||||
export async function getEventAttendees(
|
||||
db: D1Database,
|
||||
eventId: number
|
||||
): Promise<EventAttendeeRow[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT * FROM event_attendee WHERE event_id = ?1 ORDER BY responded_at, player_id')
|
||||
.bind(eventId)
|
||||
.all<EventAttendeeRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/** Overwrite an event's stored blob in place. */
|
||||
async function writeEvent(db: D1Database, event: PlayerEvent): Promise<void> {
|
||||
await db
|
||||
.prepare('UPDATE event SET data = ?1 WHERE id = ?2')
|
||||
.bind(JSON.stringify(event), event.PlayerEventId)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an edit to an event. Only the fields the body carried change; everything else
|
||||
* keeps its stored value, so a partial post can't blank out the rest of the event.
|
||||
* The id, the creator and the attendee count are not editable — ownership doesn't
|
||||
* transfer and RSVPs aren't set by hand. Returns the updated event, or null when
|
||||
* there's no such row.
|
||||
*/
|
||||
export async function updateEvent(
|
||||
db: D1Database,
|
||||
eventId: number,
|
||||
input: EventInput
|
||||
): Promise<PlayerEvent | null> {
|
||||
const event = await getEventById(db, eventId)
|
||||
if (event === null) return null
|
||||
|
||||
const updated: PlayerEvent = {
|
||||
...event,
|
||||
ImageName: input.imageName === undefined ? event.ImageName : input.imageName,
|
||||
RoomId: input.roomId ?? event.RoomId,
|
||||
SubRoomId: input.subRoomId === undefined ? event.SubRoomId : input.subRoomId,
|
||||
ClubId: input.clubId === undefined ? event.ClubId : input.clubId,
|
||||
Name: input.name?.trim() || event.Name,
|
||||
Description: input.description ?? event.Description,
|
||||
StartTime: input.startTime ?? event.StartTime,
|
||||
EndTime: input.endTime ?? event.EndTime,
|
||||
State: input.state ?? event.State,
|
||||
Accessibility: input.accessibility ?? event.Accessibility,
|
||||
IsMultiInstance: input.isMultiInstance ?? event.IsMultiInstance,
|
||||
SupportMultiInstanceRoomChat:
|
||||
input.supportMultiInstanceRoomChat ?? event.SupportMultiInstanceRoomChat,
|
||||
DefaultBroadcastPermissions:
|
||||
input.defaultBroadcastPermissions ?? event.DefaultBroadcastPermissions,
|
||||
CanRequestBroadcastPermissions:
|
||||
input.canRequestBroadcastPermissions ?? event.CanRequestBroadcastPermissions,
|
||||
}
|
||||
await writeEvent(db, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
/** One event by id, or null when there's no such row. */
|
||||
export async function getEventById(db: D1Database, eventId: number): Promise<PlayerEvent | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM event WHERE id = ?1')
|
||||
.bind(eventId)
|
||||
.first<EventRow>()
|
||||
return row ? (JSON.parse(row.data) as PlayerEvent) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Several events by id — the bulk fetch. Answers in the order the ids were asked for
|
||||
* (the client renders them in the order it requested), skipping ids with no row rather
|
||||
* than leaving a hole. Duplicated ids resolve to the same event.
|
||||
*/
|
||||
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
|
||||
if (ids.length === 0) return []
|
||||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT data FROM event WHERE id IN (${placeholders})`)
|
||||
.bind(...ids)
|
||||
.all<EventRow>()
|
||||
const byId = new Map<number, PlayerEvent>()
|
||||
for (const r of results) {
|
||||
const event = JSON.parse(r.data) as PlayerEvent
|
||||
byId.set(event.PlayerEventId, event)
|
||||
}
|
||||
return ids.map((id) => byId.get(id)).filter((e): e is PlayerEvent => e !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* The events a player created — their "my events" list, soonest first. Uses the
|
||||
* creator_player_id index; the per-player set is small, so ordering is done in memory.
|
||||
*/
|
||||
export async function getEventsByCreator(
|
||||
db: D1Database,
|
||||
creatorPlayerId: number
|
||||
): Promise<PlayerEvent[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM event WHERE creator_player_id = ?1')
|
||||
.bind(creatorPlayerId)
|
||||
.all<EventRow>()
|
||||
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
|
||||
}
|
||||
|
||||
/**
|
||||
* The events belonging to a set of clubs — the events shelf on a club's page, soonest
|
||||
* first. Selected on the indexed club_id column. An empty id list is an empty shelf
|
||||
* rather than every event.
|
||||
*/
|
||||
export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promise<PlayerEvent[]> {
|
||||
if (clubIds.length === 0) return []
|
||||
const placeholders = clubIds.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT data FROM event WHERE club_id IN (${placeholders})`)
|
||||
.bind(...clubIds)
|
||||
.all<EventRow>()
|
||||
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
|
||||
}
|
||||
|
||||
/**
|
||||
* The events happening right now — started and not yet finished. Backs the "happening
|
||||
* now" browse query. Both bounds compare lexicographically on the generated ISO-8601
|
||||
* columns, so the whole filter stays in SQL.
|
||||
*/
|
||||
export async function getLiveEvents(db: D1Database, now = Date.now()): Promise<PlayerEvent[]> {
|
||||
const at = eventTime(now)
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM event WHERE start_time <= ?1 AND end_time >= ?1')
|
||||
.bind(at)
|
||||
.all<EventRow>()
|
||||
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
|
||||
}
|
||||
|
||||
/** Soonest start first; ties broken by id so paging is stable. */
|
||||
function bySoonest(a: PlayerEvent, b: PlayerEvent): number {
|
||||
return a.StartTime.localeCompare(b.StartTime) || a.PlayerEventId - b.PlayerEventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Event search — the browse query on the player-events screen. `query` is matched
|
||||
* case-insensitively against the name and description, term by term; an empty query
|
||||
* browses everything upcoming. Paginated via skip/take, soonest first.
|
||||
*
|
||||
* Events that have already finished are excluded: this backs a browse screen, where a
|
||||
* name match on something that ended last month is noise. The per-event history a
|
||||
* creator wants comes from `getEventsByCreator`, which keeps them.
|
||||
*/
|
||||
export async function searchEvents(
|
||||
db: D1Database,
|
||||
query: string,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<PlayerEvent[]> {
|
||||
// end_time is a generated column of an ISO-8601 UTC string, so it compares
|
||||
// lexicographically — the filter stays in SQL.
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM event WHERE end_time >= ?1')
|
||||
.bind(eventTime(Date.now()))
|
||||
.all<EventRow>()
|
||||
let events = results.map((r) => JSON.parse(r.data) as PlayerEvent)
|
||||
|
||||
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
for (const term of terms) {
|
||||
events = events.filter(
|
||||
(e) => e.Name.toLowerCase().includes(term) || e.Description.toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
|
||||
return events.sort(bySoonest).slice(skip, skip + take)
|
||||
}
|
||||
+11
-1
@@ -1,4 +1,4 @@
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { 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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 caller‘s own flag'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/messages/v2/send` form body — a message sent to another player. Everything
|
||||
* is a string on the wire (it's form-encoded). The sender is NOT in the body — it's
|
||||
* taken from the bearer token.
|
||||
*/
|
||||
export const SendMessageRequest = z.object({
|
||||
ToPlayerId: z.string().describe('Account id of the recipient'),
|
||||
Type: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Message-model type, e.g. `10`. Passed through unmapped; defaults to 0'),
|
||||
Data: z.string().optional().describe('The message payload; often empty'),
|
||||
})
|
||||
|
||||
/** The `{ Success, Message }` ack the flag toggles answer with. */
|
||||
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
|
||||
|
||||
/**
|
||||
* One entry of `GET /api/relationships/mutualfriends` — a friend both players share.
|
||||
* A trimmed account card, not a relationship: no relationship type or flags.
|
||||
*/
|
||||
export const MutualFriendDto = z.object({
|
||||
AccountId: z.int(),
|
||||
Username: z.string(),
|
||||
DisplayName: z.string(),
|
||||
ProfileImage: z.string().describe('The image name; an empty string when the account has none'),
|
||||
})
|
||||
|
||||
// ---- Progression -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -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('3–24 chars: letters, digits, spaces, dashes, colons. Omitted/blank ⇒ “Untitled”'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('At most 512 chars. Omitted/blank ⇒ “No description yet”'),
|
||||
imageName: z.string().optional(),
|
||||
instantiationCost: z.int().optional(),
|
||||
lightsCost: z.int().optional(),
|
||||
@@ -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 doesn’t pin a subroom'),
|
||||
ClubId: z.int().nullable().describe('Null when the event isn’t a club’s'),
|
||||
Name: z.string(),
|
||||
Description: z.string(),
|
||||
StartTime: z.string().describe('ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`)'),
|
||||
EndTime: z.string().describe('ISO 8601 UTC, seconds precision'),
|
||||
AttendeeCount: z.int().describe('Starts at 1 — the creator attends their own event'),
|
||||
State: z.int().describe('0 = scheduled'),
|
||||
Accessibility: z.int(),
|
||||
IsMultiInstance: z.boolean(),
|
||||
SupportMultiInstanceRoomChat: z.boolean(),
|
||||
DefaultBroadcastPermissions: z.int(),
|
||||
CanRequestBroadcastPermissions: z.int(),
|
||||
})
|
||||
|
||||
/** The `{ Result, TagModifyResult, PlayerEvent }` envelope the event writes answer with. */
|
||||
export const PlayerEventResultDto = z.object({
|
||||
Result: z.int().describe('0 = success'),
|
||||
TagModifyResult: z
|
||||
.null()
|
||||
.describe('Always null — the write carries no tag edit, as no event tags are stored'),
|
||||
PlayerEvent: PlayerEventDto,
|
||||
})
|
||||
|
||||
/**
|
||||
* The JSON body of an event create / update. Every field is optional: create defaults
|
||||
* what's missing, update leaves anything absent at its stored value. The fields may be
|
||||
* posted at the top level or nested under `PlayerEvent` — the client posts back the
|
||||
* same envelope it read — and both forms are accepted. `PlayerEventId`,
|
||||
* `CreatorPlayerId` and `AttendeeCount` are ignored if present: the id is assigned
|
||||
* here, the creator comes from the bearer token, and RSVPs aren't set by hand.
|
||||
*/
|
||||
export const PlayerEventRequest = PlayerEventDto.partial().extend({
|
||||
PlayerEvent: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('The event’s fields, if nested rather than posted at the top level'),
|
||||
})
|
||||
|
||||
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
|
||||
export const PlayerEventRespondRequest = z.object({
|
||||
PlayerEventId: z.int(),
|
||||
Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'),
|
||||
})
|
||||
|
||||
/** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */
|
||||
export const PlayerEventsAll = z.object({
|
||||
Created: JsonArray,
|
||||
Responses: JsonArray,
|
||||
Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'),
|
||||
Responses: JsonArray.describe(
|
||||
'Events the caller RSVP’d to — always empty; RSVPs are stored, but this field’s ' +
|
||||
'entry shape has not been observed yet'
|
||||
),
|
||||
})
|
||||
|
||||
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
|
||||
@@ -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('Reporter’s player height in metres at report time, e.g. `1.64`'),
|
||||
HeightReported: z.string().optional().describe('Reported player’s height in metres'),
|
||||
RoomId: z.string().optional().describe('Room the report was raised in, if any'),
|
||||
RoomInstanceType: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything
|
||||
* is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
|
||||
* moderator is NOT in the body — it's taken from the bearer token.
|
||||
*/
|
||||
export const CreateWarningRequest = z.object({
|
||||
WarnedPlayerId: z.string().describe('Account id of the player being warned'),
|
||||
ReportCategory: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The reason category, e.g. `101`. Stored verbatim; unmapped'),
|
||||
DisplayReason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('What the warned player is shown, e.g. `Sexual gestures`'),
|
||||
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
|
||||
})
|
||||
|
||||
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
|
||||
export const DeviceIdRequest = z.object({
|
||||
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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', '3–24 chars, letters/digits/spaces/dashes/colons; empty is ignored'),
|
||||
stringQuery('description', 'Max 512 chars; present-but-empty clears it'),
|
||||
stringQuery('imageName', 'New thumbnail; empty is ignored'),
|
||||
stringQuery('allowTrial', '`true`/`1` to allow trials'),
|
||||
stringQuery('permission', 'A name like `useonly`, or the raw permission number'),
|
||||
],
|
||||
responses: {
|
||||
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
|
||||
400: json(ErrorResponse, 'A supplied name or description breaks its rule'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
@@ -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 creator’s own (Type 0), `AutoTags` the ones the client ' +
|
||||
'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' +
|
||||
'only.\n\n' +
|
||||
'Every tag in either list must be at most 15 letters (a–z once lowercased); one ' +
|
||||
'that isn’t fails the whole call, so no tag is ever silently dropped.\n\n' +
|
||||
'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' +
|
||||
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
|
||||
responses: {
|
||||
200: json(SetTagsResponse, 'The resulting tag names'),
|
||||
400: json(ErrorResponse, 'Unparseable body'),
|
||||
400: json(ErrorResponse, 'Unparseable body, or a tag that breaks the rule'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
@@ -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 caller’s 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 caller’s 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 invention’s metadata. The data file itself is uploaded separately ' +
|
||||
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
|
||||
'one required field, since an invention with no data blob is unusable. An omitted ' +
|
||||
'name/description is defaulted rather than rejected.\n\n' +
|
||||
'name/description is defaulted rather than rejected; a supplied one must be 3–24 ' +
|
||||
'characters of letters, digits, spaces, dashes and colons (name) or at most 512 ' +
|
||||
'characters (description).\n\n' +
|
||||
'A freshly saved invention is private: it shows up only in the creator’s own list ' +
|
||||
'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),
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
||||
// as a value — the enum has no runtime dependencies.
|
||||
import { NotificationType } from '../../../notify/src/notification-types'
|
||||
import {
|
||||
createEvent,
|
||||
getEventById,
|
||||
getEventsByClubs,
|
||||
getEventsByCreator,
|
||||
getEventsByIds,
|
||||
getLiveEvents,
|
||||
isEventResponseType,
|
||||
eventInputRejection,
|
||||
parseEventBody,
|
||||
searchEvents,
|
||||
setEventResponse,
|
||||
toEventNotification,
|
||||
toEventResult,
|
||||
updateEvent,
|
||||
} from '../events-db'
|
||||
import { authedId, queryIds, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
idParam,
|
||||
intQuery,
|
||||
json,
|
||||
jsonBody,
|
||||
pageParams,
|
||||
PlayerEventDto,
|
||||
PlayerEventRequest,
|
||||
PlayerEventRespondRequest,
|
||||
PlayerEventResultDto,
|
||||
PlayerEventsAll,
|
||||
PlayerEventsPage,
|
||||
stringQuery,
|
||||
TagFilters,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
import type { PlayerEvent } from '../events-db'
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/**
|
||||
* Push a `PlayerEventCreated` notification for a freshly scheduled event to its
|
||||
* creator — what makes the event appear on their own screen without a refetch.
|
||||
*
|
||||
* Hub failures are logged and swallowed: the event is already stored, so a hub hiccup
|
||||
* must not fail the create. Note the frame carries the camelCase
|
||||
* {@link toEventNotification} projection, not the PascalCase record the response does.
|
||||
*/
|
||||
async function notifyEventCreated(c: Context<App>, event: PlayerEvent): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
event.CreatorPlayerId,
|
||||
NotificationType.PlayerEventCreated,
|
||||
{ ...toEventNotification(event) }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push PlayerEventCreated notification', {
|
||||
playerEventId: event.PlayerEventId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Player events — scheduled events players and clubs host in a room.
|
||||
*
|
||||
* D1-backed (the `event` table, owned by this worker; see events-db.ts). The stored
|
||||
* blob IS the DTO, so every read here serves it verbatim; only the create/update
|
||||
* writes wrap it, in the `{ Result, TagModifyResult, PlayerEvent }` envelope.
|
||||
*
|
||||
* Watch the response shapes: the two club feeds deliberately differ (bare array for
|
||||
* the multi-club form, paged envelope for the single-club one) and the client chokes
|
||||
* if they're unified.
|
||||
*/
|
||||
export const eventRoutes = new Hono<App>({ strict: false })
|
||||
.get(
|
||||
'/api/playerevents/v1/all',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'The caller’s player events',
|
||||
description:
|
||||
'Events the player created and events they have RSVP’d to. `Created` is served ' +
|
||||
'from the event table, soonest first.\n\n' +
|
||||
'`Responses` is still always empty. RSVPs ARE stored now (see ' +
|
||||
'`/api/playerevents/v1/respond` and the `event_attendee` table) — what isn’t known ' +
|
||||
'is the shape this field wants: whether an entry is a bare event like `Created`, ' +
|
||||
'or the event plus the answer, which is the useful thing to render. Serving the ' +
|
||||
'wrong one renders nothing rather than erroring, so it stays empty until a real ' +
|
||||
'response is observed.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(PlayerEventsAll, 'The caller’s created events, and an empty RSVP list'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({ Created: await getEventsByCreator(c.env.DB, id), Responses: [] })
|
||||
}
|
||||
)
|
||||
|
||||
// The tag filter chips on the player-events browse screen. Static: these are the
|
||||
// categories the client offers when creating an event, so the list doesn't depend on
|
||||
// what's stored. `TrendingFilters` is null even in the reference — it needs
|
||||
// recent-activity data we don't keep, and the client renders no trending row for null.
|
||||
.get(
|
||||
'/api/playerevents/v1/tagfilters',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Player-event filter chips',
|
||||
description:
|
||||
'The filter chips on the player-events browse screen — the event categories the ' +
|
||||
'client offers. Static: the same set regardless of what is stored. ' +
|
||||
'`TrendingFilters` is null even in the reference (it needs recent-activity data), ' +
|
||||
'and the client renders no trending row for null.',
|
||||
security: AUTHED,
|
||||
responses: { 200: json(TagFilters, 'The filter chips'), 401: UNAUTHORIZED_RESPONSE },
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({
|
||||
PinnedFilters: [
|
||||
'workshops',
|
||||
'celebration',
|
||||
'game',
|
||||
'meetup',
|
||||
'performance',
|
||||
'coop',
|
||||
'grandopening',
|
||||
'class',
|
||||
'competition',
|
||||
],
|
||||
PopularFilters: [
|
||||
'workshops',
|
||||
'celebration',
|
||||
'class',
|
||||
'coop',
|
||||
'competition',
|
||||
'game',
|
||||
'grandopening',
|
||||
'meetup',
|
||||
'performance',
|
||||
],
|
||||
TrendingFilters: null,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
|
||||
// page. A bare array: the client deserializes this one as a list, and chokes on the
|
||||
// `{ ContinuationToken, Events }` envelope the single-club form uses.
|
||||
.get(
|
||||
'/api/playerevents/v1/clubs',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Player events across several clubs',
|
||||
description:
|
||||
'The events shelf for a set of clubs (`?id=1&id=2`), soonest first. This form ' +
|
||||
'returns a BARE ARRAY — the client deserializes it as a list and chokes on the ' +
|
||||
'paged envelope the single-club form below uses. Do not unify the two. No ids ' +
|
||||
'means an empty shelf, not every event.',
|
||||
parameters: [intQuery('id', 'Repeatable club id')],
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The clubs’ events') },
|
||||
}),
|
||||
async (c) => c.json(await getEventsByClubs(c.env.DB, queryIds(c)))
|
||||
)
|
||||
|
||||
// The same feed for a single club (`/club/1`) — the form the reference serves,
|
||||
// which *does* wrap the events with a paging cursor (empty = no next page).
|
||||
.get(
|
||||
'/api/playerevents/v1/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Player events for one club',
|
||||
description:
|
||||
'The same feed for a single club — and this form DOES wrap the events with a ' +
|
||||
'paging cursor, matching the reference. The cursor is always empty: a club’s event ' +
|
||||
'list is small enough to serve in one page.',
|
||||
parameters: [idParam('clubId', 'Club id')],
|
||||
responses: { 200: json(PlayerEventsPage, 'The club’s events, in a single page') },
|
||||
}),
|
||||
async (c) => {
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const events = await getEventsByClubs(c.env.DB, [clubId])
|
||||
return c.json({ ContinuationToken: '', Events: events })
|
||||
}
|
||||
)
|
||||
|
||||
// Live player-event search (the "happening now" browse query) — events that have
|
||||
// started and not yet finished. A bare array, like the multi-club feed.
|
||||
.get(
|
||||
'/api/playerevents/v1/searchlive',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Live player events',
|
||||
description:
|
||||
'The "happening now" row on the player-events browse screen: events that have ' +
|
||||
'started and not yet ended, soonest first. A bare array.',
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The events running right now') },
|
||||
}),
|
||||
async (c) => c.json(await getLiveEvents(c.env.DB))
|
||||
)
|
||||
|
||||
// Event search — the browse query. Text is matched term by term against name and
|
||||
// description; finished events are left out (this backs a browse screen).
|
||||
.get(
|
||||
'/api/playerevents/v1/search',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Search player events',
|
||||
description:
|
||||
'The browse query on the player-events screen. `query` is matched ' +
|
||||
'case-insensitively against the event name and description, term by term; an empty ' +
|
||||
'query browses everything upcoming. Events that have already finished are left ' +
|
||||
'out — a name match on something that ended last month is noise on a browse ' +
|
||||
'screen. Soonest first, paginated via skip/take. A bare array.',
|
||||
parameters: [
|
||||
stringQuery('query', 'Search text; every term must match the name or description'),
|
||||
...pageParams(50),
|
||||
],
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The matching events') },
|
||||
}),
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
|
||||
return c.json(await searchEvents(c.env.DB, c.req.query('query') ?? '', skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// Bulk fetch (`?id=1&id=2`) — the events behind a list of ids the client already
|
||||
// holds. Answers in the order asked for; ids with no event are skipped.
|
||||
.get(
|
||||
'/api/playerevents/v1/bulk',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Several player events by id',
|
||||
description:
|
||||
'The events behind a list of ids the client already holds (`?id=1&id=2`). Answers ' +
|
||||
'in the order the ids were asked for — the client renders them in request order — ' +
|
||||
'and skips ids with no event rather than leaving a hole, so the result may be ' +
|
||||
'shorter than the request. A bare array.',
|
||||
parameters: [intQuery('id', 'Repeatable event id')],
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The events that exist, in request order') },
|
||||
}),
|
||||
async (c) => c.json(await getEventsByIds(c.env.DB, queryIds(c)))
|
||||
)
|
||||
|
||||
// RSVP. One row per player per event, so responding again replaces the previous
|
||||
// answer rather than stacking up. Note this is the v1 path while create/update are
|
||||
// v2 — that's how the client calls them.
|
||||
.post(
|
||||
'/api/playerevents/v1/respond',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Answer a player event',
|
||||
description:
|
||||
'Records how the caller is answering an event — `Type` is 0 Going, 1 Interested, ' +
|
||||
'2 Can’t go. Responding again replaces the previous answer; there is one row per ' +
|
||||
'player per event, and a decline is recorded rather than deleted so the client can ' +
|
||||
'show a player what they said.\n\n' +
|
||||
'Only Going counts toward the event’s `AttendeeCount`, which is recomputed from ' +
|
||||
'the RSVP table on every response. Anyone may respond, the creator included — ' +
|
||||
'they are already Going from create, and nothing stops them declining their own ' +
|
||||
'event. Answers the same `{ Result, TagModifyResult, PlayerEvent }` envelope the ' +
|
||||
'v2 writes do, carrying the event with its updated count, so the client can ' +
|
||||
're-render from the response.\n\n' +
|
||||
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400; an ' +
|
||||
'unknown event is a 404.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'),
|
||||
400: { description: 'Missing `PlayerEventId` or an unknown `Type` (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: { description: 'No such event (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req
|
||||
.json<{ PlayerEventId?: unknown; Type?: unknown }>()
|
||||
.catch(() => ({}) as { PlayerEventId?: unknown; Type?: unknown })
|
||||
const eventId = Number(body.PlayerEventId)
|
||||
const type = Number(body.Type)
|
||||
// Both are rejected rather than defaulted: an unrecognized answer stored as
|
||||
// Going would silently inflate the count.
|
||||
if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400)
|
||||
|
||||
const updated = await setEventResponse(c.env.DB, eventId, id, type)
|
||||
return updated === null ? c.body(null, 404) : c.json(toEventResult(updated))
|
||||
}
|
||||
)
|
||||
|
||||
// Create. The creator comes from the bearer token, never the body — posting someone
|
||||
// else's `CreatorPlayerId` doesn't make it theirs.
|
||||
.post(
|
||||
'/api/playerevents/v2',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Create a player event',
|
||||
description:
|
||||
'Schedules a new event. The creator is taken from the bearer token, never the ' +
|
||||
'body; the id is assigned here. Lenient about the rest, like the other writes ' +
|
||||
'here — a missing name becomes “Untitled Event” and a missing time window becomes ' +
|
||||
'an hour from now, rather than an error the client can’t render.\n\n' +
|
||||
'`State` starts at 0, and the creator is recorded as Going in the RSVP table — ' +
|
||||
'which is what makes `AttendeeCount` start at 1, since that count is derived from ' +
|
||||
'the table. Answers the `{ Result, TagModifyResult, PlayerEvent }` envelope — NOT ' +
|
||||
'the bare event the read endpoints serve.\n\n' +
|
||||
'Also pushes a `PlayerEventCreated` (80) hub notification to the creator, carrying ' +
|
||||
'the event in its camelCase notification projection. A hub failure is logged and ' +
|
||||
'swallowed — the event is already stored by then.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PlayerEventRequest, 'The event to schedule'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The created event'),
|
||||
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = await c.req.json<unknown>().catch(() => ({}))
|
||||
const input = parseEventBody(body)
|
||||
// The one thing this route isn't lenient about. Everything else here defaults a
|
||||
// missing or unusable field, but a name or description past the stored length
|
||||
// can't be defaulted into something sensible — and truncating a player's event
|
||||
// description silently is worse than refusing it.
|
||||
if (eventInputRejection(input) !== null) return c.body(null, 400)
|
||||
const event = await createEvent(c.env.DB, id, input)
|
||||
await notifyEventCreated(c, event)
|
||||
return c.json(toEventResult(event))
|
||||
}
|
||||
)
|
||||
|
||||
// Update. Creator-only, and a partial body only changes what it carries.
|
||||
.post(
|
||||
'/api/playerevents/v2/:eventId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Update a player event',
|
||||
description:
|
||||
'Edits an event the caller created. Only the fields the body carries change; ' +
|
||||
'everything else keeps its stored value, so a partial post can’t blank out the ' +
|
||||
'rest of the event. A posted `null` on `ImageName` / `SubRoomId` / `ClubId` does ' +
|
||||
'clear it.\n\n' +
|
||||
'The id, the creator and the attendee count are not editable: ownership doesn’t ' +
|
||||
'transfer and RSVPs aren’t set by hand. Creator only — anyone else gets 403, and ' +
|
||||
'an unknown event is 404. Answers the same envelope as create.',
|
||||
security: AUTHED,
|
||||
parameters: [idParam('eventId', 'Event id')],
|
||||
requestBody: jsonBody(PlayerEventRequest, 'The fields to change'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The updated event'),
|
||||
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'Not the event’s creator (empty body)' },
|
||||
404: { description: 'No such event (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const eventId = Number.parseInt(c.req.param('eventId'), 10)
|
||||
const existing = await getEventById(c.env.DB, eventId)
|
||||
if (existing === null) return c.body(null, 404)
|
||||
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
|
||||
|
||||
const body = await c.req.json<unknown>().catch(() => ({}))
|
||||
const input = parseEventBody(body)
|
||||
if (eventInputRejection(input) !== null) return c.body(null, 400)
|
||||
const updated = await updateEvent(c.env.DB, eventId, input)
|
||||
// updateEvent only returns null when the row vanished, which the read above rules out.
|
||||
return c.json(toEventResult(updated!))
|
||||
}
|
||||
)
|
||||
|
||||
// A single event. Registered last so the literal `/bulk` and `/search` paths above
|
||||
// are matched first; the `[0-9]+` constraint keeps them apart regardless.
|
||||
.get(
|
||||
'/api/playerevents/v1/:eventId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'One player event',
|
||||
description:
|
||||
'A single event by id, served as the bare record — no envelope, unlike the ' +
|
||||
'create/update writes. 404 when there is no such event.',
|
||||
parameters: [idParam('eventId', 'Event id')],
|
||||
responses: {
|
||||
200: json(PlayerEventDto, 'The event'),
|
||||
404: { description: 'No such event (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const event = await getEventById(c.env.DB, Number.parseInt(c.req.param('eventId'), 10))
|
||||
return event === null ? c.body(null, 404) : c.json(event)
|
||||
}
|
||||
)
|
||||
@@ -6,19 +6,15 @@ import communityBoard from '../../static/community-board.json'
|
||||
import {
|
||||
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 caller’s player events',
|
||||
description:
|
||||
'Events the player created and events they have RSVP’d to. No player-event ' +
|
||||
'storage yet, so both lists are empty.',
|
||||
responses: { 200: json(PlayerEventsAll, 'Two empty lists') },
|
||||
}),
|
||||
(c) => c.json({ Created: [], Responses: [] })
|
||||
)
|
||||
|
||||
// The tag filter chips on the player-events browse screen. Derived from the tags in
|
||||
// use across events — we store no events, so there are no chips to offer.
|
||||
// `TrendingFilters` is null even in the reference (it needs recent-activity data).
|
||||
.get(
|
||||
'/api/playerevents/v1/tagfilters',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Player-event filter chips',
|
||||
description:
|
||||
'The filter chips on the player-events browse screen, derived from the tags in use ' +
|
||||
'across events. We store no events, so there are no chips to offer. ' +
|
||||
'`TrendingFilters` is null even in the reference — it needs recent-activity data.',
|
||||
responses: { 200: json(TagFilters, 'Empty chip lists') },
|
||||
}),
|
||||
(c) => c.json({ PinnedFilters: [], PopularFilters: [], TrendingFilters: null })
|
||||
)
|
||||
|
||||
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
|
||||
// page. A bare array: the client deserializes this one as a list, and chokes on the
|
||||
// `{ ContinuationToken, Events }` envelope the single-club form uses. No
|
||||
// player-event storage yet, so the feed is empty.
|
||||
.get(
|
||||
'/api/playerevents/v1/clubs',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Player events across several clubs',
|
||||
description:
|
||||
'The events shelf for a set of clubs (`?id=1&id=2`). This form returns a BARE ' +
|
||||
'ARRAY — the client deserializes it as a list and chokes on the paged envelope the ' +
|
||||
'single-club form below uses. Do not unify the two. No player-event storage yet, ' +
|
||||
'so the feed is empty.',
|
||||
parameters: [intQuery('id', 'Repeatable club id')],
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// The same feed for a single club (`/club/1`) — the form the reference serves,
|
||||
// which *does* wrap the events with a paging cursor (empty = no next page).
|
||||
.get(
|
||||
'/api/playerevents/v1/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Player events for one club',
|
||||
description:
|
||||
'The same feed for a single club — and this form DOES wrap the events with a ' +
|
||||
'paging cursor, matching the reference. An empty `ContinuationToken` means no next ' +
|
||||
'page.',
|
||||
parameters: [idParam('clubId', 'Club id')],
|
||||
responses: { 200: json(PlayerEventsPage, 'An empty page') },
|
||||
}),
|
||||
(c) => c.json({ ContinuationToken: '', Events: [] })
|
||||
)
|
||||
// Live player-event search (the "happening now" browse query). No player-event
|
||||
// storage yet, so there's nothing live to return — a bare empty array.
|
||||
.get(
|
||||
'/api/playerevents/v1/searchlive',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Search live player events',
|
||||
description:
|
||||
'The "happening now" search on the player-events browse screen. No player-event ' +
|
||||
'storage yet, so there are no live events — returns an empty list.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
// 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({
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 service’s `{ success, error }` envelope, where `error` is an ' +
|
||||
'empty string rather than null. The rejected branch uses the same envelope so ' +
|
||||
'the client only ever parses one shape.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateReportRequest, 'The report'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No `PlayerIdReported` in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const reporterId = await authedId(c)
|
||||
if (reporterId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const reportedPlayerId = asInt(formField(body, c, 'PlayerIdReported'))
|
||||
if (reportedPlayerId === null) {
|
||||
return c.json({ success: false, error: 'PlayerIdReported is required' }, 400)
|
||||
}
|
||||
|
||||
// 0 / -1 are the client's "no room" values — store null rather than a bogus id.
|
||||
const roomId = asInt(formField(body, c, 'RoomId'))
|
||||
|
||||
await createReport(c.env.DB, {
|
||||
reporterPlayerId: reporterId,
|
||||
reportedPlayerId,
|
||||
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
|
||||
details: formField(body, c, 'Details') ?? null,
|
||||
heightReporter: asFloat(formField(body, c, 'HeightReporter')),
|
||||
heightReported: asFloat(formField(body, c, 'HeightReported')),
|
||||
roomId: roomId !== null && roomId > 0 ? roomId : null,
|
||||
roomInstanceType: formField(body, c, 'RoomInstanceType') ?? null,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// A warning handed down by a moderator — the staff-side counterpart to a report.
|
||||
// Gated on the `moderator` role in the token, not just a valid one.
|
||||
.post(
|
||||
'/api/playerwarnings',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Issue a player warning',
|
||||
description:
|
||||
'Records a moderator-issued warning in the `warning` table — an append-only log ' +
|
||||
'like `report`; nothing dispatches the warning to the player or acts on the rows ' +
|
||||
'yet.\n\n' +
|
||||
'**Staff only.** The token must carry the `moderator` or `developer` role (granted ' +
|
||||
'per account by the operator, see the admin CLI’s `grant-moderator` / ' +
|
||||
'`grant-developer`); a valid token with neither gets a 403. The acting moderator ' +
|
||||
'is the caller, NOT a body field.\n\n' +
|
||||
'Only `WarnedPlayerId` is required; the rest are stored as NULL when absent. ' +
|
||||
'`ReportCategory` is stored verbatim — the enum is not mapped here. ' +
|
||||
'`DisplayReason` is what the warned player would be shown; `ModeratorNote` is ' +
|
||||
'internal and never surfaced to them.\n\n' +
|
||||
'Answers the same `{ success, error }` envelope as the report write, with `error` ' +
|
||||
'an empty string rather than null — including on the rejected branches, so there ' +
|
||||
'is only one shape to parse.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateWarningRequest, 'The warning'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No `WarnedPlayerId` in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(SuccessErrorEnvelope, 'A valid token with neither staff role'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const moderatorId = await authedId(c)
|
||||
if (moderatorId === null) return unauthorized(c)
|
||||
|
||||
const roles = await authedRoles(c)
|
||||
if (!roles?.some((role) => MODERATOR_ROLES.has(role))) {
|
||||
return c.json({ success: false, error: 'Forbidden' }, 403)
|
||||
}
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const warnedPlayerId = asInt(formField(body, c, 'WarnedPlayerId'))
|
||||
if (warnedPlayerId === null) {
|
||||
return c.json({ success: false, error: 'WarnedPlayerId is required' }, 400)
|
||||
}
|
||||
|
||||
await createWarning(c.env.DB, {
|
||||
moderatorPlayerId: moderatorId,
|
||||
warnedPlayerId,
|
||||
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
|
||||
displayReason: formField(body, c, 'DisplayReason') ?? null,
|
||||
moderatorNote: formField(body, c, 'ModeratorNote') ?? null,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// The client reporting its device id (form-encoded `oldDeviceId`, `newDeviceId`,
|
||||
// `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
|
||||
|
||||
@@ -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` worker’s ' +
|
||||
'`coachMessageAll`), except `FromPlayerId` is the caller rather than the Coach ' +
|
||||
'account and it goes to one player. The hub queues it when the recipient is ' +
|
||||
'offline, so it arrives on their next connect.\n\n' +
|
||||
'Nothing is persisted here — there is no message store, the notification is the ' +
|
||||
'whole delivery. The sender is the caller (from the bearer token), NOT a body ' +
|
||||
'field. `Type` is a Message-model type (a different enum from `NotificationType`) ' +
|
||||
'passed through unmapped, defaulting to 0; `Data` is the payload and is commonly ' +
|
||||
'empty.\n\n' +
|
||||
'Answers the same `{ success, error }` envelope as the report / warning writes, ' +
|
||||
'`error` an empty string on success. A hub failure is reported honestly as a 500 ' +
|
||||
'with `success: false` — with no store behind it, a swallowed error would be a ' +
|
||||
'silently dropped message.',
|
||||
security: AUTHED,
|
||||
requestBody: form(SendMessageRequest, 'The message'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No `ToPlayerId` in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const fromPlayerId = await authedId(c)
|
||||
if (fromPlayerId === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
const toPlayerId = Number.parseInt(str(body.ToPlayerId) ?? '', 10)
|
||||
if (Number.isNaN(toPlayerId)) {
|
||||
return c.json({ success: false, error: 'ToPlayerId is required' }, 400)
|
||||
}
|
||||
|
||||
// The Message the notification carries. Mirrors the coach message's shape with
|
||||
// a real sender and recipient; `Data` stays a string, empty included (the hub
|
||||
// drops only null/undefined from the frame).
|
||||
const message = {
|
||||
FromPlayerId: fromPlayerId,
|
||||
ToPlayerId: toPlayerId,
|
||||
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0,
|
||||
Data: str(body.Data) ?? '',
|
||||
}
|
||||
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
toPlayerId,
|
||||
NotificationType.MessageReceived,
|
||||
message
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push MessageReceived notification', {
|
||||
toPlayerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
|
||||
}
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// Send a friend request to another player (the target arrives as `?id=`). The
|
||||
// client calls this as a GET; the mutations accept GET or POST (the Go handlers
|
||||
// matched any method). Auth-gated. Returns the resulting relationship from the
|
||||
|
||||
@@ -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 caller’s own', async () => {
|
||||
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
|
||||
// exactly this row) and also creates one of their own.
|
||||
const save = async (sub: string, name: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, inventionDataFilename: `${name}.inv` }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as InventionSaveResult).Invention
|
||||
}
|
||||
const mine = async (sub: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as SavedInvention[]
|
||||
}
|
||||
|
||||
const bought = await save('6100', 'bought-invention')
|
||||
const own = await save('6101', 'own-invention')
|
||||
await grantInvention(env.DB, 6101, bought.InventionId)
|
||||
|
||||
// Newest first, whichever set it came from: 6101 saved theirs after buying.
|
||||
const list = await mine('6101')
|
||||
expect(list.map((i) => i.InventionId)).toEqual([own.InventionId, bought.InventionId])
|
||||
// A bought invention is still the creator's — it is listed, not re-attributed.
|
||||
expect(list.find((i) => i.InventionId === bought.InventionId)?.CreatorPlayerId).toBe(6100)
|
||||
// It is unpublished (a fresh save is), and stays on the buyer's shelf regardless.
|
||||
expect(list.find((i) => i.InventionId === bought.InventionId)?.IsPublished).toBe(false)
|
||||
|
||||
// The seller's own list is unaffected by the sale.
|
||||
expect((await mine('6100')).map((i) => i.InventionId)).toEqual([bought.InventionId])
|
||||
|
||||
// An ownership row pointing at an invention that no longer exists just drops out.
|
||||
await grantInvention(env.DB, 6101, 999_888)
|
||||
expect((await mine('6101')).map((i) => i.InventionId)).toEqual([
|
||||
own.InventionId,
|
||||
bought.InventionId,
|
||||
])
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
@@ -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 3–24 characters of letters, digits, spaces, dashes and colons.
|
||||
expect((await save({ name: 'ab' })).status).toBe(400)
|
||||
expect((await save({ name: 'a'.repeat(25) })).status).toBe(400)
|
||||
expect((await save({ name: 'Rocket!' })).status).toBe(400)
|
||||
expect((await save({ name: 'Café Lamp' })).status).toBe(400)
|
||||
const ok = await save({ name: 'Rocket Sofa-Bed 2' })
|
||||
expect(ok.status).toBe(200)
|
||||
expect(((await ok.json()) as InventionSaveResult).Invention.Name).toBe('Rocket Sofa-Bed 2')
|
||||
|
||||
// The rejection carries the player-facing sentence, not a code.
|
||||
const short = await save({ name: 'ab' })
|
||||
expect((await short.json()) as { error: string }).toEqual({
|
||||
error: 'Invention names must be at least 3 characters.',
|
||||
})
|
||||
|
||||
// A description is prose: any characters, at most 512 of them.
|
||||
expect((await save({ name: 'Long Winded', description: 'x'.repeat(513) })).status).toBe(400)
|
||||
expect((await save({ name: 'Long Winded', description: 'x'.repeat(512) })).status).toBe(200)
|
||||
expect((await save({ name: 'Punctuated', description: 'Yes! It’s 100% good.' })).status).toBe(
|
||||
200
|
||||
)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save accepts the client’s auto-generated timestamp name', async () => {
|
||||
// The real client names an unnamed invention after the moment it was saved
|
||||
// (`071126 13:10:50`, captured from a live save), so the colon is in the allowed name
|
||||
// charset on purpose. Dropping it from the pattern would 400 every unnamed save the
|
||||
// game makes — this test is what would catch that.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('6363')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inventionDataFilename: 'a.inv', name: '071126 13:10:50' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as InventionSaveResult).Invention.Name).toBe('071126 13:10:50')
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
|
||||
expect(res.status).toBe(404)
|
||||
@@ -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 caller’s own events, auth-gated', async () => {
|
||||
expect((await get('/api/playerevents/v1/all')).status).toBe(401)
|
||||
|
||||
const mine = (await (await get('/api/playerevents/v1/all', '42')).json()) as {
|
||||
Created: PlayerEvent[]
|
||||
Responses: unknown[]
|
||||
}
|
||||
const ids = mine.Created.map((e) => e.PlayerEventId)
|
||||
expect(ids).toContain(upcoming.PlayerEventId)
|
||||
// 43 created that one, not 42.
|
||||
expect(ids).not.toContain(liveEvent.PlayerEventId)
|
||||
// Finished events stay in the creator's own list — only the browse queries drop them.
|
||||
expect(ids).toContain(pastEvent.PlayerEventId)
|
||||
// Nothing records an RSVP yet.
|
||||
expect(mine.Responses).toEqual([])
|
||||
|
||||
const theirs = (await (await get('/api/playerevents/v1/all', '43')).json()) as {
|
||||
Created: PlayerEvent[]
|
||||
}
|
||||
expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId])
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => {
|
||||
const respond = async (body: unknown, sub = '42'): Promise<Response> =>
|
||||
post('/api/playerevents/v1/respond', body, sub)
|
||||
|
||||
const event = await create({ RoomId: 3, Name: 'RSVP Test', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
// The creator is Going from create, which is where the initial 1 comes from.
|
||||
expect(event.AttendeeCount).toBe(1)
|
||||
expect(await countGoing(env.DB, id)).toBe(1)
|
||||
|
||||
// 43 says Going → 2 attendees, and the envelope carries the updated event.
|
||||
const res = await respond({ PlayerEventId: id, Type: 0 }, '43')
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as PlayerEventResult
|
||||
expect(body.Result).toBe(0)
|
||||
expect(body.PlayerEvent.AttendeeCount).toBe(2)
|
||||
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({
|
||||
event_id: id,
|
||||
player_id: 43,
|
||||
status: 0,
|
||||
})
|
||||
|
||||
// Changing the answer REPLACES it — one row per player, not a second RSVP.
|
||||
const changed = await respond({ PlayerEventId: id, Type: 2 }, '43')
|
||||
expect(((await changed.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
|
||||
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({ player_id: 43, status: 2 })
|
||||
expect((await getEventAttendees(env.DB, id)).map((a) => a.player_id)).toEqual([42, 43])
|
||||
|
||||
// Interested is a maybe — recorded, but not counted.
|
||||
await respond({ PlayerEventId: id, Type: 1 }, '43')
|
||||
expect(await countGoing(env.DB, id)).toBe(1)
|
||||
|
||||
// And the count sticks on the stored event, not just the response.
|
||||
const fetched = (await (await get(`/api/playerevents/v1/${id}`)).json()) as PlayerEvent
|
||||
expect(fetched.AttendeeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/respond rejects a bad body, an unknown event and no token', async () => {
|
||||
const event = await create({ RoomId: 3, Name: 'Guarded' })
|
||||
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/respond`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ PlayerEventId: event.PlayerEventId, Type: 0 }),
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
|
||||
// An unrecognized Type is rejected rather than defaulted — stored as Going it
|
||||
// would silently inflate the count.
|
||||
expect((await post('/api/playerevents/v1/respond', { PlayerEventId: 1, Type: 7 })).status).toBe(
|
||||
400
|
||||
)
|
||||
expect((await post('/api/playerevents/v1/respond', { Type: 0 })).status).toBe(400)
|
||||
expect((await post('/api/playerevents/v1/respond', {})).status).toBe(400)
|
||||
expect(
|
||||
(await post('/api/playerevents/v1/respond', { PlayerEventId: 999999, Type: 0 })).status
|
||||
).toBe(404)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2/:eventId edits only what the body carries, creator-only', async () => {
|
||||
const event = await create({
|
||||
RoomId: 5,
|
||||
SubRoomId: 6,
|
||||
ClubId: 9,
|
||||
Name: 'Original',
|
||||
Description: 'Original description',
|
||||
StartTime: at(5 * HOUR),
|
||||
EndTime: at(6 * HOUR),
|
||||
})
|
||||
const path = `/api/playerevents/v2/${event.PlayerEventId}`
|
||||
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}${path}`, { method: 'POST', body: '{}' })).status
|
||||
).toBe(401)
|
||||
// 43 didn't create it.
|
||||
expect((await post(path, { Name: 'Hijacked' }, '43')).status).toBe(403)
|
||||
expect((await post('/api/playerevents/v2/999999', { Name: 'Nope' })).status).toBe(404)
|
||||
|
||||
const res = await post(path, { Name: 'Renamed' })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as PlayerEventResult
|
||||
expect(body.Result).toBe(0)
|
||||
// Only the name moved; a partial post can't blank out the rest.
|
||||
expect(body.PlayerEvent).toEqual({ ...event, Name: 'Renamed' })
|
||||
|
||||
// And it stuck.
|
||||
expect(await (await get(`/api/playerevents/v1/${event.PlayerEventId}`)).json()).toEqual(
|
||||
body.PlayerEvent
|
||||
)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2/:eventId clears a nullable id when the body sends null', async () => {
|
||||
const event = await create({ RoomId: 5, SubRoomId: 6, ClubId: 9, Name: 'Clearable' })
|
||||
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
|
||||
// Nested form again, and an explicit null — absent leaves the value alone,
|
||||
// null genuinely clears it.
|
||||
PlayerEvent: { ClubId: null, ImageName: null },
|
||||
})
|
||||
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
|
||||
expect(updated.ClubId).toBeNull()
|
||||
expect(updated.ImageName).toBeNull()
|
||||
expect(updated.SubRoomId).toBe(6)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2/:eventId cannot move ownership or the attendee count', async () => {
|
||||
const event = await create({ RoomId: 5, Name: 'Fixed' })
|
||||
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
|
||||
PlayerEventId: 424242,
|
||||
CreatorPlayerId: 43,
|
||||
AttendeeCount: 500,
|
||||
})
|
||||
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
|
||||
expect(updated.PlayerEventId).toBe(event.PlayerEventId)
|
||||
expect(updated.CreatorPlayerId).toBe(42)
|
||||
expect(updated.AttendeeCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openapi', () => {
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 account’s primary',
|
||||
'(what the account DTO and a refreshed token report); later ones only link.',
|
||||
'',
|
||||
'The one platform id that is never verified and never linked is `1` on platform `1`',
|
||||
'— what a SIDELOADED Oculus APK reports, having no Meta SDK to ask. Every such',
|
||||
'build reports it, so it identifies nobody. A password login that carries it still',
|
||||
'succeeds; it simply links nothing, and the player types their password each launch.',
|
||||
'',
|
||||
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||
'powers refresh on every login and every refresh grant.',
|
||||
].join('\n'),
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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
@@ -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.',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 client’s `requestedPrice` still matches',
|
||||
'its stored `Price`, debits the buyer and pays the creator that price in',
|
||||
'RecCenterTokens (a free invention moves nothing), records ownership in',
|
||||
'`inventory_invention`, and returns the invention alongside the buyer’s resulting',
|
||||
'balance. When tokens moved, both players get a StorefrontBalanceUpdate push carrying',
|
||||
'their CHANGE (the buyer’s negative, the creator’s positive), which the client adds to',
|
||||
'the balance it is showing — unlike this response body, which replaces it.',
|
||||
'A GET because that is how the client sends it.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'inventionId',
|
||||
in: 'query',
|
||||
required: true,
|
||||
description: 'Invention id; missing or non-numeric is 400',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
{
|
||||
name: 'requestedPrice',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'The price the client rendered; a mismatch is 409. Defaults to 0',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(BuyInventionResponse, 'The purchase result (invention + balance)'),
|
||||
400: json(
|
||||
ErrorResponse,
|
||||
'Missing/non-numeric inventionId, buying your own, or insufficient balance'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'The invention is not published, so it is not for sale'),
|
||||
404: json(ErrorResponse, 'No such invention'),
|
||||
409: json(ErrorResponse, 'Already owned, or the price has changed'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
// Absent/non-numeric requestedPrice reads as 0, which only matches a free invention —
|
||||
// a priced one then fails the confirmation below rather than selling for nothing.
|
||||
const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0
|
||||
|
||||
const invention = await getInventionById(c.env.DB, inventionId)
|
||||
if (invention === null) return c.json({ error: 'Invention not found' }, 404)
|
||||
// An unpublished invention is a draft: it isn't on sale, not even for free.
|
||||
if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403)
|
||||
if (invention.CreatorPlayerId === id) {
|
||||
return c.json({ error: 'Cannot buy your own invention' }, 400)
|
||||
}
|
||||
if (await ownsInvention(c.env.DB, id, inventionId)) {
|
||||
return c.json({ error: 'Already owned' }, 409)
|
||||
}
|
||||
|
||||
// The price the client rendered must still be the stored one: a mismatch is a stale
|
||||
// catalog or a tampered request, never a sale.
|
||||
if (invention.Price !== requestedPrice) {
|
||||
return c.json({ error: 'Price has changed' }, 409)
|
||||
}
|
||||
|
||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
// Inventions are priced in RecCenterTokens only — the store shows no other currency
|
||||
// for them, and `Price` carries no currency of its own to pick a different one from.
|
||||
const price = invention.Price
|
||||
if (price > 0) {
|
||||
// Debit the buyer atomically; false means they couldn't afford it and nothing
|
||||
// changed, so no ownership is recorded and the creator is not paid.
|
||||
const paid = await spendCurrency(
|
||||
c.env.DB,
|
||||
id,
|
||||
CurrencyType.RecCenterTokens,
|
||||
price,
|
||||
startingTokens
|
||||
)
|
||||
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
|
||||
}
|
||||
|
||||
// Grant before paying out: these are three separate D1 writes with no transaction
|
||||
// around them, so order them by what a failure costs. A buyer who paid and got the
|
||||
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
|
||||
// is not.
|
||||
await grantInvention(c.env.DB, id, inventionId)
|
||||
|
||||
if (price > 0) {
|
||||
// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
|
||||
// the balance row, and `ensureStartingBalances` is an INSERT OR IGNORE, so a
|
||||
// creator who had never touched their balance would otherwise have the row created
|
||||
// here and lose their starting tokens forever.
|
||||
await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
|
||||
await creditCurrency(
|
||||
c.env.DB,
|
||||
invention.CreatorPlayerId,
|
||||
CurrencyType.RecCenterTokens,
|
||||
price,
|
||||
startingTokens
|
||||
)
|
||||
// The creator is a different, probably-online player: push the payout so a sale
|
||||
// lands on their shown balance without a re-fetch. Positive, because the frame
|
||||
// carries the change. Best-effort, as everywhere.
|
||||
await pushBalanceUpdate(c, invention.CreatorPlayerId, CurrencyType.RecCenterTokens, price)
|
||||
}
|
||||
|
||||
// Unlike buyItem — whose `Balance` is the change applied — the reference server
|
||||
// answers this one with the RESULTING total (a first read seeds the buyer's starting
|
||||
// grant, as everywhere else). The socket frame below is the other way round: the HTTP
|
||||
// body REPLACES the shown balance, the push ADDS to it.
|
||||
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
|
||||
// A free invention moved nothing, so there is no change to push for it.
|
||||
if (price > 0) {
|
||||
await pushBalanceUpdate(c, id, CurrencyType.RecCenterTokens, -price)
|
||||
}
|
||||
return c.json({
|
||||
BalanceUpdateResponse: {
|
||||
Balance: balance,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
BalanceUpdates: [{ UpdateResponse: 0, Data: invention }],
|
||||
},
|
||||
// The same `{ Status, Invention, InventionVersion }` envelope the invention
|
||||
// save/read endpoints serve — the client re-renders the invention from it.
|
||||
InventionResponse: toSaveResult(invention),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Storefront ad-carousel items. Served from the bundled static JSON — one
|
||||
// placeholder banner with no purchasable items until real promo data exists.
|
||||
.get(
|
||||
|
||||
@@ -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 -------------------------------------------------------
|
||||
|
||||
@@ -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 buyer’s tokens', async () => {
|
||||
// Invention 9 costs 250 and was made by account 999. Buying it moves 250 tokens from
|
||||
// the buyer to that creator — no house cut, so the two sides are equal and opposite.
|
||||
await drainFrames()
|
||||
const res = await buyInvention('51', 9, 250)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { BalanceUpdateResponse: { Balance: number } }
|
||||
// `Balance` is the buyer's RESULTING total, so it already has the debit in it.
|
||||
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS - 250)
|
||||
expect(
|
||||
await getBalance(env.DB, 51, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
).toBe(DEFAULT_STARTING_TOKENS - 250)
|
||||
// The creator had never touched their balance: they keep their starting grant AND get
|
||||
// paid, rather than the payout standing in for the grant.
|
||||
expect(
|
||||
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
).toBe(DEFAULT_STARTING_TOKENS + 250)
|
||||
expect(await getOwnedInventionIds(env.DB, 51)).toEqual([9])
|
||||
|
||||
// Both sides get a socket frame carrying their CHANGE, not their new total: the client
|
||||
// ADDS what it receives to the balance it is showing, so a total would have the creator
|
||||
// reading their own balance plus the payout. Equal and opposite, like the ledger.
|
||||
expect(await drainFrames()).toEqual([
|
||||
{
|
||||
accountId: 999,
|
||||
notificationType: STOREFRONT_BALANCE_UPDATE,
|
||||
payload: { Balance: 250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 },
|
||||
},
|
||||
{
|
||||
accountId: 51,
|
||||
notificationType: STOREFRONT_BALANCE_UPDATE,
|
||||
payload: { Balance: -250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /api/storefronts/v2/buyInvention rejects a stale price and an unaffordable one', async () => {
|
||||
// Sending 0 for the 250-token invention 9 is a stale (or tampered) price.
|
||||
expect((await buyInvention('53', 9, 0)).status).toBe(409)
|
||||
|
||||
// Account 54 can't afford it: nothing is debited, nobody is paid, nothing is owned.
|
||||
await spendCurrency(
|
||||
env.DB,
|
||||
54,
|
||||
CurrencyType.RecCenterTokens,
|
||||
DEFAULT_STARTING_TOKENS,
|
||||
DEFAULT_STARTING_TOKENS
|
||||
)
|
||||
const creatorBefore = await getBalance(
|
||||
env.DB,
|
||||
999,
|
||||
CurrencyType.RecCenterTokens,
|
||||
DEFAULT_STARTING_TOKENS
|
||||
)
|
||||
expect((await buyInvention('54', 9, 250)).status).toBe(400)
|
||||
expect(
|
||||
await getBalance(env.DB, 54, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
).toBe(0)
|
||||
expect(
|
||||
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
).toBe(creatorBefore)
|
||||
expect(await getOwnedInventionIds(env.DB, 53)).toEqual([])
|
||||
expect(await getOwnedInventionIds(env.DB, 54)).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/storefronts/v2/buyInvention rejects drafts, self-buys and unknown ids', async () => {
|
||||
// Unpublished — a draft is not on sale, free or not.
|
||||
expect((await buyInvention('52', 10)).status).toBe(403)
|
||||
// Account 60 created invention 11; a creator already owns it.
|
||||
expect((await buyInvention('60', 11)).status).toBe(400)
|
||||
expect((await buyInvention('52', 9999)).status).toBe(404)
|
||||
// Missing/non-numeric inventionId.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyInvention`, {
|
||||
headers: await bearer('52'),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(await getOwnedInventionIds(env.DB, 52)).toEqual([])
|
||||
expect(await getOwnedInventionIds(env.DB, 60)).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
|
||||
// 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',
|
||||
|
||||
@@ -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,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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 isn’t a member.',
|
||||
'club is unknown, has no clubhouse set, or the caller isn’t a member — and errorCode',
|
||||
'55 when they are banned from the clubhouse room.',
|
||||
].join(' '),
|
||||
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 can’t be entered)'
|
||||
'The clubhouse instance (or a null instance with errorCode 20 / 55 when it can’t 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 target’s stored presence. FRIENDS ONLY: the caller must be a mutual friend',
|
||||
'of the target (otherwise anyone could read a player’s presence and warp to them).',
|
||||
'Returns errorCode 20 with a null instance when the target isn’t a friend, is the',
|
||||
'caller themselves, or isn’t currently in a room.',
|
||||
'caller themselves, or isn’t currently in a room, and errorCode 55 when the caller is',
|
||||
'banned from the room the friend is in — this path hands out join coordinates without',
|
||||
'going through the room resolver, so it carries its own ban check.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
@@ -940,7 +978,7 @@ const app = new Hono<App>()
|
||||
responses: {
|
||||
200: json(
|
||||
MatchmakeResponse,
|
||||
'The friend’s instance (or errorCode 20 with null when it can’t be joined)'
|
||||
'The friend’s instance (or a null instance with errorCode 20 / 55 when it can’t be joined)'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
@@ -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 owner’s instance listing. Gated to the room’s creator or a co-owner.',
|
||||
'Unlike the other matchmakes this never reuses or creates an instance, and enters',
|
||||
'even a full or in-progress one. Returns errorCode 20 with a null instance when the',
|
||||
'instance or its room is gone, or the caller doesn’t manage that room; errorCode 55',
|
||||
'when banned.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'instanceId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Room instance id (digits only)',
|
||||
schema: { type: 'string', pattern: '^[0-9]+$' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(
|
||||
MatchmakeResponse,
|
||||
'The instance (or a null instance with errorCode 20 / 55 when it can’t be joined)'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const instanceId = Number.parseInt(c.req.param('instanceId'), 10)
|
||||
const stored = await getRoomInstance(c.env.DB, instanceId)
|
||||
// One opaque refusal for "no such instance", "no such room" and "not yours":
|
||||
// a distinct code for the last would confirm which instance ids are live.
|
||||
if (!stored) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||
const room = await getRoomById(c.env.DB, stored.roomId)
|
||||
if (!room) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||
if (!canManageRoom(room, id)) {
|
||||
logger.info('instance matchmake refused: not the room’s owner', {
|
||||
roomInstanceId: instanceId,
|
||||
roomId: stored.roomId,
|
||||
accountId: id,
|
||||
})
|
||||
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||
}
|
||||
|
||||
// Like the follow-a-friend path, this hands out a Photon room id without going
|
||||
// through resolveRoomInstance, so the room's bans are checked here too. An owner
|
||||
// can't ban themselves out of their own room in practice, but a co-owner can be
|
||||
// banned, and a ban must beat every route that yields join coordinates.
|
||||
if (await isPlayerBannedFromRoom(c.env.DB, stored.roomId, id)) {
|
||||
logger.info('instance matchmake refused: player banned from room', {
|
||||
roomId: stored.roomId,
|
||||
id,
|
||||
})
|
||||
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
|
||||
}
|
||||
|
||||
// Rebuild the wire instance from the room (fresh scene + published save) keyed to
|
||||
// this instance's own id and Photon room, so the owner lands in exactly the
|
||||
// session they picked rather than a new one alongside it.
|
||||
const instance = roomInstanceFromRoom(
|
||||
room,
|
||||
stored.isPrivate,
|
||||
stored.roomInstanceId,
|
||||
stored.photonRoomId,
|
||||
stored.subRoomId
|
||||
)
|
||||
await enterRoom(c, id, instance)
|
||||
return c.json({ errorCode: 0, roomInstance: instance })
|
||||
}
|
||||
)
|
||||
|
||||
// Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}`
|
||||
// — 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 instance’s in-progress flag when a session starts (e.g. a',
|
||||
'round begins). Body is `inProgress=True|False`.',
|
||||
'Flips the instance’s 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',
|
||||
'room’s 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',
|
||||
'instance’s room’s creator or a co-owner (403 otherwise). Empty ack.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Room instance id',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: EMPTY_OK,
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||||
404: { description: 'Non-numeric id or no such instance (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const instanceId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (Number.isNaN(instanceId)) return c.body(null, 404)
|
||||
|
||||
const stored = await getRoomInstance(c.env.DB, instanceId)
|
||||
if (!stored) return c.body(null, 404)
|
||||
const room = await getRoomById(c.env.DB, stored.roomId)
|
||||
if (!room || !canManageRoom(room, id)) return c.body(null, 403)
|
||||
|
||||
await setRoomInstancePrivate(c.env.DB, instanceId, true)
|
||||
return c.body(null, 200)
|
||||
}
|
||||
)
|
||||
|
||||
// The room's live instances — the owner's view of active sessions of their room.
|
||||
// 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 room’s live instances',
|
||||
description: [
|
||||
'The owner’s view of active sessions of their room. Auth-gated and gated to the',
|
||||
'room’s creator or a co-owner (403 otherwise). Unknown room → 404.',
|
||||
'The owner’s view of active sessions of their room — each instance with the',
|
||||
'players currently in it. Auth-gated and gated to the room’s 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 room’s 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))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -24,7 +24,7 @@ export enum NotificationType {
|
||||
ModerationUpdateRequired = 21,
|
||||
ModerationKick = 22,
|
||||
ModerationKickAttemptFailed = 23,
|
||||
ModerationRoomBan = 24,
|
||||
ModerationRoomBan = "ModerationRoomBan",
|
||||
ServerMaintenance = 25,
|
||||
GiftPackageReceived = 30,
|
||||
GiftPackageReceivedImmediate = 31,
|
||||
|
||||
@@ -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('*')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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, 100–104 CoC_*, 200 InappropriateClothing.
|
||||
*/
|
||||
const REPORT_CATEGORY_MODERATOR = -1
|
||||
|
||||
/**
|
||||
* Eject a player from the room they're in — a `ModerationKick` push (id 22), the frame
|
||||
* the client acts on to remove someone. Sent on a ban: the row keeps them out of future
|
||||
* matchmakes, this gets them out of the instance they're in right now.
|
||||
*
|
||||
* The payload is the client's moderation shape, camelCase, in wire order:
|
||||
* `reportCategory`, `duration`, `gameSessionId`, `isHostKick`, `message`,
|
||||
* `playerIdReporter`, `isBan`, `isVoiceModAutoban`. `duration` is 0 (a room ban has no
|
||||
* expiry — it's lifted by DELETE, not by time) and `gameSessionId` is 0 (nothing here
|
||||
* tracks one).
|
||||
*
|
||||
* `isHostKick` says the room's HOST ejected the player, as opposed to the room
|
||||
* majority vote-kicking them. There is no vote-kick path yet, so the only false case
|
||||
* here is a staff moderator acting in a room they don't host. `playerIdReporter` is
|
||||
* whoever caused it — the host today, and the player who started the vote once
|
||||
* vote-kicks exist (those will carry `reportCategory` 10 and `isHostKick` false).
|
||||
*
|
||||
* Like {@link pushRoomUpdate}, hub failures are logged and swallowed: the ban row has
|
||||
* already committed, so a hub hiccup must not fail the request.
|
||||
*/
|
||||
async function pushRoomBan(
|
||||
c: Context<App>,
|
||||
ban: RoomBan,
|
||||
roomName: string,
|
||||
isHostKick: boolean
|
||||
): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
ban.BannedPlayerId,
|
||||
NotificationType.ModerationKick,
|
||||
{
|
||||
reportCategory: REPORT_CATEGORY_MODERATOR,
|
||||
duration: 0,
|
||||
gameSessionId: 0,
|
||||
isHostKick,
|
||||
message: `You have been banned from ${roomName}.`,
|
||||
playerIdReporter: ban.BannedByAccountId,
|
||||
isBan: true,
|
||||
isVoiceModAutoban: false,
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push ModerationKick notification', {
|
||||
playerId: ban.BannedPlayerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Room-mutation result envelope: `{ Success, Value, ErrorId, Error }`, always
|
||||
* 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 room’s ban list',
|
||||
description: [
|
||||
'Everyone banned from the room, most recently banned first. Auth-gated, then gated',
|
||||
'exactly like issuing a ban: the room’s creator or a co-owner, or an account whose',
|
||||
'token carries the `developer` / `moderator` role. A ban list says who a room’s',
|
||||
'owner has had trouble with, so it is not public.',
|
||||
'',
|
||||
'A bare array, NOT the `{ success, error, value }` envelope the ban write answers,',
|
||||
'and the entries are camelCase with a different field set: no room id (the path',
|
||||
'already says which room) and no ban mask. An unknown room is an empty list rather',
|
||||
'than an error — it reads the same as a room nobody is banned from.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
parameters: [roomIdParam],
|
||||
responses: {
|
||||
200: json(RoomBanEntryDto.array(), 'The room’s bans, newest first'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
// No room → nothing banned. Same answer as a room with an empty ban list, so
|
||||
// this doesn't become a way to probe which room ids exist.
|
||||
if (!room) return c.json([])
|
||||
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
|
||||
|
||||
const bans = await getRoomBans(c.env.DB, roomId)
|
||||
return c.json(
|
||||
bans.map((ban) => ({
|
||||
accountId: ban.BannedPlayerId,
|
||||
bannedByAccountId: ban.BannedByAccountId,
|
||||
banStartTime: ban.CreatedAt,
|
||||
}))
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Ban a player from a room (form body `id` + `banMask`). Auth-gated (401), then
|
||||
// gated to the room's owner/co-owner OR a staff token (403). One row per
|
||||
// (room, player) — re-banning rewrites it, so the call is idempotent.
|
||||
.post(
|
||||
'/rooms/:roomId{[0-9]+}/bans',
|
||||
describeRoute({
|
||||
tags: ['Room settings'],
|
||||
summary: 'Ban a player from a room',
|
||||
description: [
|
||||
'Records a ban in the `room_ban` table — one row per (room, player), so re-banning',
|
||||
'someone already banned rewrites their row rather than adding a second. The row is',
|
||||
'what the `match` worker checks: a banned player’s matchmake into this room is',
|
||||
'refused with errorCode 55 and never gets a Photon room id.',
|
||||
'',
|
||||
'Gated to the room’s creator or a co-owner, OR to any account whose token carries the',
|
||||
'`developer` / `moderator` role — a valid token from anyone else is a 403. Banning',
|
||||
'yourself, or banning someone who can manage the room, is refused: otherwise a',
|
||||
'co-owner could ban the owner out of their own room.',
|
||||
'',
|
||||
'`banMask` is stored verbatim and nothing interprets it — the client sends `0` and',
|
||||
'what it selects is not known yet. It defaults to 0 when absent.',
|
||||
'',
|
||||
'The BANNED player (not the caller) gets a `ModerationKick` push (id 22) — the frame',
|
||||
'the client acts on to eject someone — so a ban takes effect immediately rather than',
|
||||
'only at their next matchmake. `isBan` is true, `duration` 0 (a room ban has no',
|
||||
'expiry; it is lifted by DELETE, not by time) and `reportCategory` -1 (Moderator).',
|
||||
'',
|
||||
'`isHostKick` means the room’s HOST ejected them rather than the room majority',
|
||||
'vote-kicking them; with no vote-kick path yet the only false case is a staff',
|
||||
'moderator acting in a room they do not host. `playerIdReporter` is whoever caused',
|
||||
'it — the host today, the player who started the vote once vote-kicks exist. The hub',
|
||||
'queues the frame if they are offline.',
|
||||
'',
|
||||
'Answers the same lowercase `{ success, error, value }` envelope the room writes use,',
|
||||
'but `value` is the BAN, not the room — a ban is not part of the room the client',
|
||||
'renders. This shape is unverified against the real service.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
parameters: [roomIdParam],
|
||||
requestBody: form(BanRequest, 'The player to ban'),
|
||||
responses: {
|
||||
200: json(RoomBanEnvelope, 'The stored ban, or a rejection with `success: false`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return banEnvelope(c, null, 'This room does not exist!')
|
||||
|
||||
// The room's own owners, or a staffer acting across rooms. Roles are only
|
||||
// looked up when the cheaper room check fails. The room's own owner IS the
|
||||
// host, which is what the kick frame's `isHostKick` reports.
|
||||
const isHostKick = canManageRoom(room, accountId)
|
||||
if (!isHostKick && !(await isStaff(c))) return c.body(null, 403)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||||
const bannedPlayerId = Number.parseInt(str(body.id), 10)
|
||||
if (Number.isNaN(bannedPlayerId)) {
|
||||
return banEnvelope(c, null, 'You must provide a valid player to ban!')
|
||||
}
|
||||
if (bannedPlayerId === accountId) return banEnvelope(c, null, 'You cannot ban yourself!')
|
||||
// Without this a co-owner could ban the room's creator out of their own room.
|
||||
if (canManageRoom(room, bannedPlayerId)) {
|
||||
return banEnvelope(c, null, 'You cannot ban an owner of this room!')
|
||||
}
|
||||
// Absent or unparseable → 0, the value the client sends.
|
||||
const banMask = Number.parseInt(str(body.banMask), 10) || 0
|
||||
|
||||
const ban = await banPlayerFromRoom(c.env.DB, roomId, bannedPlayerId, banMask, accountId)
|
||||
// The banned player is told, not the caller — their client acts on the kick.
|
||||
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
|
||||
await pushRoomBan(c, ban, roomName, isHostKick)
|
||||
return banEnvelope(c, ban)
|
||||
}
|
||||
)
|
||||
|
||||
// Lift a player's ban on a room. Same gate as issuing one: auth-gated (401), then the
|
||||
// room's owner/co-owner OR a staff token (403).
|
||||
.delete(
|
||||
'/rooms/:roomId{[0-9]+}/bans/:playerId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Room settings'],
|
||||
summary: 'Unban a player from a room',
|
||||
description: [
|
||||
'Removes the player’s `room_ban` row, so they can matchmake into the room again.',
|
||||
'Gated exactly like issuing a ban: the room’s creator or a co-owner, or an account',
|
||||
'whose token carries the `developer` / `moderator` role.',
|
||||
'',
|
||||
'Unbanning someone who is not banned is a rejection (`success: false`), not a silent',
|
||||
'success — the caller asked to undo something that was not there.',
|
||||
'',
|
||||
'Answers the same envelope as the ban write, with the REMOVED ban as `value`. No',
|
||||
'notification is pushed: nothing tells a player their ban was lifted.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
parameters: [roomIdParam, bannedPlayerIdParam],
|
||||
responses: {
|
||||
200: json(RoomBanEnvelope, 'The removed ban, or a rejection with `success: false`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return banEnvelope(c, null, 'This room does not exist!')
|
||||
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
|
||||
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const removed = await unbanPlayerFromRoom(c.env.DB, roomId, playerId)
|
||||
if (!removed) return banEnvelope(c, null, 'This player is not banned from this room!')
|
||||
return banEnvelope(c, removed)
|
||||
}
|
||||
)
|
||||
|
||||
// Set a room's content warning: the `WarningMask` bit flags plus an optional
|
||||
// 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 room’s load screen',
|
||||
description: [
|
||||
'APPENDS one `{ ImageName, Title, Subtitle }` to the room’s `LoadScreens` — the images',
|
||||
'shown while the room loads. There is no remove or replace counterpart. Owner or',
|
||||
'co-owner only (403 otherwise).',
|
||||
'REPLACES the room’s `LoadScreens` with the single posted `{ ImageName, Title,',
|
||||
'Subtitle }` — the image shown while the room loads. The field is an array (the',
|
||||
'client’s parser expects one) but the client only supports a single screen, so this',
|
||||
'never appends. Owner or co-owner only (403 otherwise).',
|
||||
].join(' '),
|
||||
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!')
|
||||
|
||||
@@ -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 room’s owners or staff, and persists', async () => {
|
||||
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
|
||||
const bansOf = async (roomId: number) =>
|
||||
(
|
||||
await env.DB.prepare(
|
||||
'SELECT banned_player_id, ban_mask, banned_by_account_id FROM room_ban WHERE room_id = ?1'
|
||||
)
|
||||
.bind(roomId)
|
||||
.all<{ banned_player_id: number; ban_mask: number; banned_by_account_id: number }>()
|
||||
).results
|
||||
|
||||
// No token → 401 (auth gate).
|
||||
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '205' })).status).toBe(401)
|
||||
// A valid token, no role on the room and no staff role → 403.
|
||||
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '205' }, '999')).status).toBe(403)
|
||||
// Unknown room → failure envelope.
|
||||
expect(
|
||||
await envOf(await postForm('/rooms/99999/bans', { banMask: '0', id: '205' }, '1'))
|
||||
).toMatchObject({ success: false, error: 'This room does not exist!' })
|
||||
|
||||
// The owner bans player 205 — the real client body.
|
||||
const ok = await postForm('/rooms/2/bans', { banMask: '0', id: '205' }, '1')
|
||||
expect(ok.status).toBe(200)
|
||||
expect(await envOf(ok)).toMatchObject({
|
||||
success: true,
|
||||
error: '',
|
||||
value: { RoomId: 2, BannedPlayerId: 205, BanMask: 0, BannedByAccountId: 1 },
|
||||
})
|
||||
expect(await bansOf(2)).toEqual([
|
||||
{ banned_player_id: 205, ban_mask: 0, banned_by_account_id: 1 },
|
||||
])
|
||||
|
||||
// Re-banning rewrites the one row rather than appending a second.
|
||||
expect((await postForm('/rooms/2/bans', { banMask: '7', id: '205' }, '2')).status).toBe(200)
|
||||
expect(await bansOf(2)).toEqual([
|
||||
{ banned_player_id: 205, ban_mask: 7, banned_by_account_id: 2 },
|
||||
])
|
||||
|
||||
// A staff token bans in a room they have no role on.
|
||||
const byStaff = await postForm('/rooms/2/bans', { id: '206' }, '999', [
|
||||
'gameClient',
|
||||
'moderator',
|
||||
])
|
||||
expect(byStaff.status).toBe(200)
|
||||
// banMask defaults to 0 when the field is absent.
|
||||
expect(await envOf(byStaff)).toMatchObject({ value: { BannedPlayerId: 206, BanMask: 0 } })
|
||||
|
||||
// Refusals: no id, yourself, and an owner of the room (a co-owner must not be
|
||||
// able to ban the creator out of their own room).
|
||||
expect(await envOf(await postForm('/rooms/2/bans', { id: 'nope' }, '1'))).toMatchObject({
|
||||
success: false,
|
||||
value: null,
|
||||
})
|
||||
expect(await envOf(await postForm('/rooms/2/bans', { id: '1' }, '1'))).toMatchObject({
|
||||
success: false,
|
||||
error: 'You cannot ban yourself!',
|
||||
})
|
||||
expect(await envOf(await postForm('/rooms/2/bans', { id: '1' }, '2'))).toMatchObject({
|
||||
success: false,
|
||||
error: 'You cannot ban an owner of this room!',
|
||||
})
|
||||
// Nothing was written by any of the refusals.
|
||||
expect(await bansOf(2)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('POST /rooms/:id/bans kicks the banned player', async () => {
|
||||
type Sent = { playerId: number; notificationType: string | number; data: unknown }
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
const sentSince = async (): Promise<Sent[]> =>
|
||||
(await (await hub().fetch('http://do/all')).json()) as Sent[]
|
||||
|
||||
// The room's current name, read rather than hardcoded — earlier tests rename it.
|
||||
const { Name } = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Name: string }
|
||||
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '207' }, '1')).status).toBe(200)
|
||||
|
||||
// A ModerationKick (id 22) to the BANNED player, not the caller — it ejects them
|
||||
// from the instance they're in now; the row keeps them out of future matchmakes.
|
||||
// Asserted against the enum rather than a literal: the ids are notify's to change.
|
||||
expect(await sentSince()).toEqual([
|
||||
{
|
||||
playerId: 207,
|
||||
notificationType: NotificationType.ModerationKick,
|
||||
// The client's moderation payload, camelCase, in wire order.
|
||||
data: {
|
||||
reportCategory: -1, // Moderator — a person acted, not the system
|
||||
duration: 0, // a room ban has no expiry
|
||||
gameSessionId: 0,
|
||||
// The host ejected them (as opposed to a room vote-kick, which doesn't
|
||||
// exist yet). Account 1 owns RecCenter, so it hosts it.
|
||||
isHostKick: true,
|
||||
message: `You have been banned from ${Name}.`,
|
||||
playerIdReporter: 1,
|
||||
isBan: true,
|
||||
isVoiceModAutoban: false,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// A staff moderator doesn't host the room, so it isn't a host kick — and
|
||||
// `playerIdReporter` is still whoever caused it.
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
expect(
|
||||
(await postForm('/rooms/2/bans', { id: '208' }, '999', ['gameClient', 'moderator'])).status
|
||||
).toBe(200)
|
||||
expect((await sentSince())[0]).toMatchObject({
|
||||
playerId: 208,
|
||||
data: { isHostKick: false, playerIdReporter: 999 },
|
||||
})
|
||||
})
|
||||
|
||||
it('GET /rooms/:id/bans lists the room’s bans, under the same gate', async () => {
|
||||
type Entry = { accountId: number; bannedByAccountId: number; banStartTime: string }
|
||||
const list = async (path: string, sub?: string, roles?: string[]) =>
|
||||
SELF.fetch(`${ORIGIN}${path}`, { headers: sub ? await bearer(sub, roles) : {} })
|
||||
|
||||
// Room 3 is owned by account 1 (it has no bans yet) — ban two players into it.
|
||||
expect((await postForm('/rooms/3/bans', { id: '401' }, '1')).status).toBe(200)
|
||||
expect((await postForm('/rooms/3/bans', { id: '402' }, '1')).status).toBe(200)
|
||||
|
||||
// No token → 401; a valid token with no room role and no staff role → 403.
|
||||
expect((await list('/rooms/3/bans')).status).toBe(401)
|
||||
expect((await list('/rooms/3/bans', '999')).status).toBe(403)
|
||||
|
||||
const res = await list('/rooms/3/bans', '1')
|
||||
expect(res.status).toBe(200)
|
||||
// A bare array in the client's camelCase shape — no room id, no ban mask.
|
||||
const bans = (await res.json()) as Entry[]
|
||||
expect(bans.map((b) => b.accountId).sort((a, b) => a - b)).toEqual([401, 402])
|
||||
expect(bans[0]).toEqual({
|
||||
accountId: expect.any(Number),
|
||||
bannedByAccountId: 1,
|
||||
banStartTime: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
|
||||
})
|
||||
|
||||
// A staffer can read a list for a room they have no role on.
|
||||
expect((await list('/rooms/3/bans', '999', ['gameClient', 'moderator'])).status).toBe(200)
|
||||
|
||||
// An unknown room reads the same as a room with nobody banned — no probing which
|
||||
// room ids exist.
|
||||
expect(await (await list('/rooms/99999/bans', '1')).json()).toEqual([])
|
||||
})
|
||||
|
||||
it('DELETE /rooms/:id/bans/:playerId lifts a ban, under the same gate', async () => {
|
||||
const del = async (path: string, sub?: string, roles?: string[]) =>
|
||||
SELF.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: sub ? await bearer(sub, roles) : {},
|
||||
})
|
||||
const isBanned = async (roomId: number, playerId: number) =>
|
||||
(await env.DB.prepare(
|
||||
'SELECT 1 AS hit FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2'
|
||||
)
|
||||
.bind(roomId, playerId)
|
||||
.first()) !== null
|
||||
|
||||
// Two bans to lift: one removed by the owner, one by a staffer.
|
||||
expect((await postForm('/rooms/2/bans', { id: '305' }, '1')).status).toBe(200)
|
||||
expect((await postForm('/rooms/2/bans', { id: '306' }, '1')).status).toBe(200)
|
||||
|
||||
// No token → 401; a valid token with no room role and no staff role → 403.
|
||||
expect((await del('/rooms/2/bans/305')).status).toBe(401)
|
||||
expect((await del('/rooms/2/bans/305', '999')).status).toBe(403)
|
||||
expect(await isBanned(2, 305)).toBe(true)
|
||||
|
||||
// Unknown room → failure envelope.
|
||||
expect(await envOf(await del('/rooms/99999/bans/305', '1'))).toMatchObject({
|
||||
success: false,
|
||||
error: 'This room does not exist!',
|
||||
})
|
||||
|
||||
// The owner lifts it; the removed ban comes back as `value`.
|
||||
const ok = await del('/rooms/2/bans/305', '1')
|
||||
expect(ok.status).toBe(200)
|
||||
expect(await envOf(ok)).toMatchObject({
|
||||
success: true,
|
||||
error: '',
|
||||
value: { RoomId: 2, BannedPlayerId: 305 },
|
||||
})
|
||||
expect(await isBanned(2, 305)).toBe(false)
|
||||
|
||||
// Unbanning someone who isn't banned is a rejection, not a silent success.
|
||||
expect(await envOf(await del('/rooms/2/bans/305', '1'))).toMatchObject({
|
||||
success: false,
|
||||
error: 'This player is not banned from this room!',
|
||||
value: null,
|
||||
})
|
||||
|
||||
// A staff token may lift a ban in a room they have no role on.
|
||||
expect((await del('/rooms/2/bans/306', '999', ['gameClient', 'developer'])).status).toBe(200)
|
||||
expect(await isBanned(2, 306)).toBe(false)
|
||||
})
|
||||
|
||||
it('PUT /rooms/:id/warning is auth-gated, owner/co-owner-only, and persists', async () => {
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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') } }
|
||||
`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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.'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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 -----------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,5 +13,8 @@
|
||||
"@cloudflare/workers-types": "4.20260630.1",
|
||||
"@repo/tools": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"isemail": "^3.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) ?? [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import isEmail from 'isemail'
|
||||
|
||||
/**
|
||||
* Limits on the free text a player can put into their account and their rooms.
|
||||
*
|
||||
* Shared by `accounts` and `rooms` so one rule can't drift from the other — a username
|
||||
* and a room name are held to the same shape, and both are typed into the same client.
|
||||
*
|
||||
* These check only what a player SUPPLIES. Names the server generates go around them:
|
||||
* a dorm is called `@<username>'s Dorm` (see `rooms-db.ts`), which the name rule below
|
||||
* would reject, and auto-assigned usernames (`SwiftFox4821`, `Player42`) happen to
|
||||
* satisfy it. So validate at the request handler, never inside the db helpers.
|
||||
*
|
||||
* Emptiness is deliberately NOT checked here. Every caller already rejects an empty
|
||||
* value in its own words, and those sentences reach players through response envelopes
|
||||
* the client renders verbatim — see the client-contract notes in CLAUDE.md.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Name lengths. All three come from what the CLIENT will accept in the matching input
|
||||
* box, not from a round number: accepting more here would store a name the game can't
|
||||
* re-enter or edit, so the server matches the box rather than being generous.
|
||||
*/
|
||||
export const MAX_USERNAME_LENGTH = 50
|
||||
export const MAX_DISPLAY_NAME_LENGTH = 15
|
||||
export const MAX_ROOM_NAME_LENGTH = 32
|
||||
|
||||
/**
|
||||
* Club and event limits. Longer than the name limits above because these aren't
|
||||
* identifiers — a club name and an event name are titles, and both allow the
|
||||
* punctuation and spaces a title needs (clubs enforce their own charset rule; events
|
||||
* enforce none at all, since an event is called things like "Building a Better Room
|
||||
* Using Trigonometry").
|
||||
*/
|
||||
export const MAX_CLUB_NAME_LENGTH = 40
|
||||
export const MAX_CLUB_DESCRIPTION_LENGTH = 512
|
||||
export const MAX_EVENT_NAME_LENGTH = 64
|
||||
export const MAX_EVENT_DESCRIPTION_LENGTH = 512
|
||||
|
||||
/**
|
||||
* Invention limits. A name is a title a player types into the invention-save box and
|
||||
* reads back in a browse tile, so it allows the punctuation a title needs — but
|
||||
* nothing else, since it is also what invention search matches on. The minimum is real:
|
||||
* one- and two-character names are unsearchable and unreadable in a tile, and the client
|
||||
* offers `Untitled` rather than an empty box.
|
||||
*/
|
||||
export const MIN_INVENTION_NAME_LENGTH = 3
|
||||
export const MAX_INVENTION_NAME_LENGTH = 24
|
||||
export const MAX_INVENTION_DESCRIPTION_LENGTH = 512
|
||||
|
||||
/**
|
||||
* One invention tag. Short and letters-only because tags are a controlled vocabulary the
|
||||
* browse chips are derived from (see `getInventionTagFilters`) — a tag with digits,
|
||||
* punctuation or spaces makes a chip nobody else will ever type again. Tags are stored
|
||||
* lowercased, so the rule is checked against the normalized form, not what was typed.
|
||||
*/
|
||||
export const MAX_INVENTION_TAG_LENGTH = 15
|
||||
|
||||
/**
|
||||
* Length in code points rather than UTF-16 units, so an emoji or other astral character
|
||||
* counts once instead of twice — the way a player counts what they typed.
|
||||
*/
|
||||
export const glyphLength = (value: string): number => Array.from(value).length
|
||||
|
||||
/** Max length of a profile bio. */
|
||||
export const MAX_BIO_LENGTH = 255
|
||||
|
||||
/**
|
||||
* Letters and digits only — no spaces, punctuation, or accents.
|
||||
*
|
||||
* Deliberately narrow: these names are shown to other players, used to search, and (for
|
||||
* usernames) typed into a sign-in box, so anything that can be confused for another name
|
||||
* is worth refusing. It also rules out the homoglyph and right-to-left tricks that come
|
||||
* with allowing arbitrary Unicode.
|
||||
*/
|
||||
const NAME_PATTERN = /^[A-Za-z0-9]+$/
|
||||
|
||||
/**
|
||||
* Why a player-supplied name is unacceptable, or `null` when it's fine.
|
||||
*
|
||||
* `label` names the thing in the returned sentence ('username', 'room name'), so the
|
||||
* message reads correctly wherever it's surfaced. `max` is required rather than
|
||||
* defaulted: the three limits differ, and a caller that forgets which one it wants
|
||||
* should have to say so instead of silently taking someone else's.
|
||||
*/
|
||||
export function nameRejection(value: string, label: string, max: number): string | null {
|
||||
if (value.length > max) {
|
||||
return `Your ${label} can be at most ${max} characters.`
|
||||
}
|
||||
if (!NAME_PATTERN.test(value)) {
|
||||
return `Your ${label} can only contain letters and numbers.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Letters, digits, spaces, dashes and colons — the title charset. Wider than
|
||||
* `NAME_PATTERN` because an invention is a thing with a name ("Grappling Hook v2",
|
||||
* "Speed-Boost Pad"), not an identifier someone types into a sign-in box. Still no
|
||||
* arbitrary Unicode, for the same homoglyph reasons.
|
||||
*
|
||||
* The colon is not decorative: an invention the player never named is called after the
|
||||
* moment it was saved (`071126 13:10:50`), generated by the CLIENT, so a rule without it
|
||||
* would refuse every unnamed save the game makes. The dash stays last in the class so it
|
||||
* reads as a literal rather than a range.
|
||||
*/
|
||||
const INVENTION_NAME_PATTERN = /^[A-Za-z0-9 :-]+$/
|
||||
|
||||
/** Lowercase letters only — the normalized form a tag is stored in. */
|
||||
const INVENTION_TAG_PATTERN = /^[a-z]+$/
|
||||
|
||||
/**
|
||||
* Why a player-supplied invention name is unacceptable, or `null` when it's fine.
|
||||
*
|
||||
* Callers pass the TRIMMED name: leading and trailing spaces are the player's typing,
|
||||
* not part of what they named the thing, and counting them toward the minimum would let
|
||||
* `" a "` through.
|
||||
*/
|
||||
export function inventionNameRejection(value: string): string | null {
|
||||
if (glyphLength(value) < MIN_INVENTION_NAME_LENGTH) {
|
||||
return `Invention names must be at least ${MIN_INVENTION_NAME_LENGTH} characters.`
|
||||
}
|
||||
if (glyphLength(value) > MAX_INVENTION_NAME_LENGTH) {
|
||||
return `Invention names can be at most ${MAX_INVENTION_NAME_LENGTH} characters.`
|
||||
}
|
||||
if (!INVENTION_NAME_PATTERN.test(value)) {
|
||||
return 'Invention names can only contain letters, numbers, spaces, dashes and colons.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an invention description is unacceptable, or `null` when it's fine. Length only —
|
||||
* a description is prose, so nothing is refused for the characters it's made of, and an
|
||||
* empty one is fine (it's how a creator clears the field).
|
||||
*/
|
||||
export function inventionDescriptionRejection(value: string): string | null {
|
||||
if (glyphLength(value) > MAX_INVENTION_DESCRIPTION_LENGTH) {
|
||||
return `Invention descriptions can be at most ${MAX_INVENTION_DESCRIPTION_LENGTH} characters.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an invention tag is unacceptable, or `null` when it's fine. Pass the NORMALIZED
|
||||
* tag (trimmed and lowercased, as `setInventionTags` stores it) — checking what was typed
|
||||
* instead would refuse `Racing` for a capital that never reaches the database.
|
||||
*/
|
||||
export function inventionTagRejection(value: string): string | null {
|
||||
if (value.length > MAX_INVENTION_TAG_LENGTH) {
|
||||
return `Invention tags can be at most ${MAX_INVENTION_TAG_LENGTH} characters.`
|
||||
}
|
||||
if (!INVENTION_TAG_PATTERN.test(value)) {
|
||||
return 'Invention tags can only contain letters.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a supplied email is one worth storing — RFC 5321/5322 syntax, via `isemail`.
|
||||
*
|
||||
* A hand-rolled pattern is the wrong shape of work here: this is a contact address
|
||||
* nothing is ever sent to in order to prove it, so the only thing a stricter regex buys
|
||||
* is more edge cases to get wrong. Note it also enforces the RFC's 254-character maximum
|
||||
* itself, which is why there's no separate length cap.
|
||||
*
|
||||
* It accepts a dotless domain (`someone@localhost`), which a dotted-domain rule would
|
||||
* refuse. That's the RFC being right and the shortcut being wrong, and an undeliverable
|
||||
* address costs nothing here.
|
||||
*/
|
||||
export function isValidEmail(value: string): boolean {
|
||||
return isEmail.validate(value)
|
||||
}
|
||||
|
||||
/** Whether a supplied bio is within the stored length. */
|
||||
export function isValidBio(value: string): boolean {
|
||||
return value.length <= MAX_BIO_LENGTH
|
||||
}
|
||||
@@ -33,9 +33,44 @@ function addIntegerExamples(node: unknown): void {
|
||||
for (const value of Object.values(obj)) addIntegerExamples(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>)
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+18
@@ -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)):
|
||||
|
||||
Reference in New Issue
Block a user