mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
rooms openapi
This commit is contained in:
+31
-1
@@ -1,6 +1,36 @@
|
|||||||
# rooms
|
# rooms
|
||||||
|
|
||||||
A Cloudflare Workers application using Hono
|
Room Worker served on the `rooms` subdomain (`rooms.recflare.net`) — a Hono app owning
|
||||||
|
room storage, the browse/search feeds, per-player cheers and favorites, the owner's room
|
||||||
|
settings, and subrooms.
|
||||||
|
|
||||||
|
Rooms live in the shared `recflare` D1 as one JSON blob per room (queryable fields are
|
||||||
|
SQLite generated columns); reads serve that blob verbatim, which is why every shape is
|
||||||
|
the client's PascalCase one. Subrooms are their own table — their ids come from a single
|
||||||
|
global sequence, not per room — and are re-attached to each room on read. The seed rooms,
|
||||||
|
including the dorm, come from `static/ImportRooms.json`.
|
||||||
|
|
||||||
|
## API documentation
|
||||||
|
|
||||||
|
`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit
|
||||||
|
alongside each handler, with the schemas in `src/openapi.ts`. It's also aggregated into
|
||||||
|
the docs page www serves at `/docs`.
|
||||||
|
|
||||||
|
**The spec is descriptive, not enforced** — same rationale as the `auth`/`match`/`clubs`
|
||||||
|
workers: a reverse-engineered protocol, lenient handlers, no runtime validation. A test
|
||||||
|
asserts every route appears in the spec, so adding one without documenting it fails.
|
||||||
|
|
||||||
|
## Response envelopes
|
||||||
|
|
||||||
|
Two envelopes appear side by side, and which one a route uses is dictated by the client's
|
||||||
|
deserializer for that call — not a choice, and not something to unify:
|
||||||
|
|
||||||
|
- `{ Success, Value, ErrorId, Error }` (PascalCase) — a bare result with a message.
|
||||||
|
- `{ success, error, value }` (lowercase) — carries the updated room, which the client
|
||||||
|
re-renders from.
|
||||||
|
|
||||||
|
Both answer HTTP 200 even for a rejection: the client reads the flag, not the status.
|
||||||
|
Only a missing or invalid token is a real 401, and only the owner/co-owner gate is a 403.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,13 @@
|
|||||||
"@repo/domain": "workspace:*",
|
"@repo/domain": "workspace:*",
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
|
"@standard-community/standard-json": "0.3.5",
|
||||||
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.12.27",
|
||||||
"workers-tagged-logger": "1.0.1"
|
"hono-openapi": "1.3.1",
|
||||||
|
"openapi-types": "12.1.3",
|
||||||
|
"workers-tagged-logger": "1.0.1",
|
||||||
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vitest-pool-workers": "0.16.20",
|
"@cloudflare/vitest-pool-workers": "0.16.20",
|
||||||
|
|||||||
@@ -0,0 +1,480 @@
|
|||||||
|
import { resolver } from 'hono-openapi'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI schemas for the rooms worker.
|
||||||
|
*
|
||||||
|
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||||
|
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
|
||||||
|
* rationale as the auth/accounts/match/econ/clubs workers: a reverse-engineered
|
||||||
|
* protocol, lenient handlers, no runtime validation.
|
||||||
|
*
|
||||||
|
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
|
||||||
|
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
|
||||||
|
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
|
||||||
|
* schema inline, which renders correctly in any tool.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Emit a zod schema as an `application/json` response body. */
|
||||||
|
export function json(schema: z.ZodType, description: string) {
|
||||||
|
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
|
||||||
|
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||||
|
return jsonSchema as OpenAPIV3_1.SchemaObject
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A form-urlencoded / multipart request body (the client posts both). */
|
||||||
|
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||||
|
const s = toOpenApiSchema(schema)
|
||||||
|
return {
|
||||||
|
description,
|
||||||
|
content: {
|
||||||
|
'application/x-www-form-urlencoded': { schema: s },
|
||||||
|
'multipart/form-data': { schema: s },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An `application/json` request body. */
|
||||||
|
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||||
|
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||||
|
export const AUTHED = [{ bearerAuth: [] }]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 401 the auth-gated routes return. Most answer `{ error: 'Unauthorized' }`; the
|
||||||
|
* subroom writes answer an empty body (see UNAUTHORIZED_EMPTY / UNAUTHORIZED_ENVELOPE).
|
||||||
|
*/
|
||||||
|
export const UNAUTHORIZED_RESPONSE = json(
|
||||||
|
z.object({ error: z.literal('Unauthorized') }),
|
||||||
|
'Missing or invalid bearer token'
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The empty-body 401 the subroom-save route returns. */
|
||||||
|
export const UNAUTHORIZED_EMPTY = { description: 'Missing or invalid bearer token (empty body)' }
|
||||||
|
|
||||||
|
/** The 403 the owner/co-owner-gated routes return (empty body). */
|
||||||
|
export const FORBIDDEN_RESPONSE = {
|
||||||
|
description: 'A valid token, but not the room’s creator or a co-owner (empty body)',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Parameters ------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A digits-only id path parameter (the route patterns constrain these to `[0-9]+`). */
|
||||||
|
function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
description,
|
||||||
|
schema: { type: 'string', pattern: '^[0-9]+$' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `:roomId` path parameter. */
|
||||||
|
export const roomIdParam = idParam('roomId', 'Room id')
|
||||||
|
|
||||||
|
/** The `:subRoomId` path parameter. */
|
||||||
|
export const subRoomIdParam = idParam('subRoomId', 'Subroom id (globally unique, not per-room)')
|
||||||
|
|
||||||
|
/** An optional string query parameter. */
|
||||||
|
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||||
|
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An optional integer query parameter. */
|
||||||
|
function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||||
|
return { name, in: 'query', required: false, description, schema: { type: 'integer' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `skip`/`take` pair every paginated room list accepts. */
|
||||||
|
export function pageParams(defaultTake: number): OpenAPIV3_1.ParameterObject[] {
|
||||||
|
return [
|
||||||
|
intQuery('skip', 'How many rooms to skip (default 0)'),
|
||||||
|
intQuery('take', `How many rooms to return (default ${defaultTake})`),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Core entities ---------------------------------------------------------
|
||||||
|
|
||||||
|
/** `GET /` — the liveness probe body. */
|
||||||
|
export const ServiceStatus = z.object({
|
||||||
|
service: z.literal('rooms'),
|
||||||
|
status: z.literal('ok'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** A room-role assignment. `Role`: 10 Host, 20 Moderator, 30 CoOwner, 255 Creator. */
|
||||||
|
export const RoomRoleDto = z.object({
|
||||||
|
AccountId: z.int(),
|
||||||
|
Role: z.int().describe('10 = Host, 20 = Moderator, 30 = CoOwner, 255 = Creator'),
|
||||||
|
LastChangedByAccountId: z.int().nullable(),
|
||||||
|
InvitedRole: z.int(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** A tag on a room. `Type` 0 = set by the owner, 2 = auto-derived (e.g. `rro`). */
|
||||||
|
export const RoomTagDto = z.object({
|
||||||
|
Tag: z.string(),
|
||||||
|
Type: z.int().describe('0 = owner-set, 2 = auto'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** A room's engagement counters. Nothing increments these yet, so they stay at 0. */
|
||||||
|
export const RoomStatsDto = z.object({
|
||||||
|
CheerCount: z.int(),
|
||||||
|
FavoriteCount: z.int(),
|
||||||
|
VisitorCount: z.int(),
|
||||||
|
VisitCount: z.int(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** One of the images shown while the room loads. */
|
||||||
|
export const LoadScreenDto = z.object({
|
||||||
|
ImageName: z.string().describe('A CDN bucket key under `room/`'),
|
||||||
|
Title: z.string(),
|
||||||
|
Subtitle: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subroom — a room's individual scene. Subrooms are their own table with a globally
|
||||||
|
* unique, autoincrementing `SubRoomId` (the original game mints them from a single
|
||||||
|
* sequence, not per-room); a room's `SubRooms` array is reconstructed on read.
|
||||||
|
*
|
||||||
|
* `CreatorAccountId` starts null on the seeded rooms and is filled in on the first save —
|
||||||
|
* the client NREs on a null one. The `DataBlob`/`RoomDataBlob`/`DataSavedAt` fields only
|
||||||
|
* appear once the subroom has been saved at least once.
|
||||||
|
*/
|
||||||
|
export const SubRoomDto = z.object({
|
||||||
|
SubRoomId: z.int(),
|
||||||
|
RoomId: z.int(),
|
||||||
|
CreatorAccountId: z.int().nullable().describe('Null until the subroom’s first save'),
|
||||||
|
UnitySceneId: z.string().describe('The Unity scene the client loads'),
|
||||||
|
Name: z.string(),
|
||||||
|
LastModeratedSaveModerationState: z.int(),
|
||||||
|
IsSandbox: z.boolean(),
|
||||||
|
MaxPlayers: z.int(),
|
||||||
|
Accessibility: z.int().describe('0 = Private, 1 = Public, 2 = Unlisted'),
|
||||||
|
ShouldAutoStageSaves: z.boolean(),
|
||||||
|
StagedSubRoomDataSaveId: z.int().nullable(),
|
||||||
|
DataBlob: z.string().optional().describe('Uploaded scene-data key; absent until first save'),
|
||||||
|
RoomDataBlob: z.string().optional().describe('Uploaded room-data key; absent until first save'),
|
||||||
|
DataSavedAt: z.string().optional().describe('ISO timestamp of the last save'),
|
||||||
|
PersistenceVersion: z.int().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** A room's localization settings — carried through verbatim; nothing localizes yet. */
|
||||||
|
export const LocalizationContextDto = z.object({
|
||||||
|
TargetLocale: z.string().nullable(),
|
||||||
|
Scope: z.string().nullable(),
|
||||||
|
LocalizedFields: z.array(z.string()),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A room, exactly as stored: each room is a single JSON blob in D1 and every read
|
||||||
|
* serves it verbatim (PascalCase, the client-facing shape), with `SubRooms` re-attached
|
||||||
|
* from the subroom table. The seed data comes from `static/ImportRooms.json`.
|
||||||
|
*/
|
||||||
|
export const RoomDto = z.object({
|
||||||
|
RoomId: z.int(),
|
||||||
|
Name: z.string().describe('Unique, case-insensitively'),
|
||||||
|
Description: z.string(),
|
||||||
|
ImageName: z.string().describe('A CDN bucket key served back under `room/`'),
|
||||||
|
WarningMask: z.int().describe('Content-warning bit flags'),
|
||||||
|
CustomWarning: z.string().nullable(),
|
||||||
|
CreatorAccountId: z.int().describe('The room’s owner — always passes the role checks'),
|
||||||
|
State: z.int(),
|
||||||
|
Accessibility: z
|
||||||
|
.int()
|
||||||
|
.describe('0 = Private, 1 = Public, 2 = Unlisted. The room-browse feeds serve Public only'),
|
||||||
|
PublishState: z.int(),
|
||||||
|
SupportsLevelVoting: z.boolean(),
|
||||||
|
IsRRO: z.boolean().describe('A Rec Room Original — the client renders a virtual `rro` tag'),
|
||||||
|
IsRecRoomApproved: z.boolean(),
|
||||||
|
ExcludeFromLists: z.boolean(),
|
||||||
|
ExcludeFromSearch: z.boolean(),
|
||||||
|
SupportsScreens: z.boolean(),
|
||||||
|
SupportsWalkVR: z.boolean(),
|
||||||
|
SupportsTeleportVR: z.boolean(),
|
||||||
|
SupportsVRLow: z.boolean(),
|
||||||
|
SupportsQuest2: z.boolean(),
|
||||||
|
SupportsMobile: z.boolean(),
|
||||||
|
SupportsJuniors: z.boolean(),
|
||||||
|
MinLevel: z.int(),
|
||||||
|
AgeRating: z.int(),
|
||||||
|
CreatedAt: z.string(),
|
||||||
|
PublishedAt: z.string(),
|
||||||
|
BecameRRStudioRoomAt: z.string().nullable(),
|
||||||
|
Stats: RoomStatsDto,
|
||||||
|
RankingContext: z.unknown().nullable(),
|
||||||
|
IsDorm: z.boolean().describe('Auto-provisioned personal room; excluded from every feed'),
|
||||||
|
IsPlacePlay: z.boolean(),
|
||||||
|
MaxPlayerCalculationMode: z.int(),
|
||||||
|
MaxPlayers: z.int(),
|
||||||
|
CloningAllowed: z.boolean().describe('False blocks `POST /rooms/{roomId}/clone`'),
|
||||||
|
DisableMicAutoMute: z.boolean(),
|
||||||
|
DisableRoomComments: z.boolean(),
|
||||||
|
EncryptVoiceChat: z.boolean(),
|
||||||
|
ToxmodEnabled: z.boolean(),
|
||||||
|
LoadScreenLocked: z.boolean(),
|
||||||
|
UgcVersion: z.int(),
|
||||||
|
PersistenceVersion: z.int(),
|
||||||
|
UgcSubVersion: z.int().nullable(),
|
||||||
|
MinUgcSubVersion: z.int().nullable(),
|
||||||
|
AutoLocalizeRoom: z.boolean(),
|
||||||
|
LocalizationContext: LocalizationContextDto,
|
||||||
|
IsDeveloperOwned: z.boolean(),
|
||||||
|
RankedEntityId: z.string(),
|
||||||
|
SubRooms: z.array(SubRoomDto).describe('Re-attached from the subroom table on every read'),
|
||||||
|
Roles: z.array(RoomRoleDto),
|
||||||
|
IsJuniorCreated: z.boolean(),
|
||||||
|
Tags: z.array(RoomTagDto),
|
||||||
|
PromoImages: z.array(z.unknown()),
|
||||||
|
PromoExternalContent: z.array(z.unknown()),
|
||||||
|
LoadScreens: z.array(LoadScreenDto),
|
||||||
|
RestrictedCircuitsAllowListNames: z.array(z.string()),
|
||||||
|
InventionUsage: z.string().optional().describe('Recorded by a room save; absent until then'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** A paged room list (`PagedResultsDTO<RoomDTO>`) — search, hot, similar. */
|
||||||
|
export const PagedRooms = z.object({
|
||||||
|
Results: z.array(RoomDto),
|
||||||
|
TotalResults: z.int().describe('The full match count, not the page size'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A room lookup result: the room, or `{}` when nothing matched. The by-id/by-name
|
||||||
|
* lookups answer an empty object rather than a 404 — the client reads that as "no room".
|
||||||
|
*/
|
||||||
|
export const RoomLookup = z.union([RoomDto, z.object({})])
|
||||||
|
|
||||||
|
/** The bare JSON string the lookup routes answer with when neither `id` nor `name` is given. */
|
||||||
|
export const MissingLookupParam = z
|
||||||
|
.string()
|
||||||
|
.describe("`\"Either 'id' or 'name' query parameter is required\"`")
|
||||||
|
|
||||||
|
// ---- Interaction -----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A player's own state on a room: whether they've cheered/favorited it, plus the last
|
||||||
|
* visit. `LastVisitedAt` is stamped with "now" on every read rather than served from the
|
||||||
|
* stored value — the client only uses it to order the recently-visited list.
|
||||||
|
*/
|
||||||
|
export const InteractionDto = z.object({
|
||||||
|
Cheered: z.boolean(),
|
||||||
|
Favorited: z.boolean(),
|
||||||
|
LastVisitedAt: z.string().describe('Always "now" — not the stored visit time'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Featured rooms --------------------------------------------------------
|
||||||
|
|
||||||
|
/** The compact room projection a featured-room group carries. */
|
||||||
|
export const FeaturedRoomDto = z.object({
|
||||||
|
RoomId: z.int(),
|
||||||
|
RoomName: z.string(),
|
||||||
|
ImageName: z.string(),
|
||||||
|
IsRecRoomApproved: z.boolean(),
|
||||||
|
ExcludeFromLists: z.boolean(),
|
||||||
|
ExcludeFromSearch: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** A time-boxed group of featured rooms. There's one, and it's always active. */
|
||||||
|
export const FeaturedRoomGroupDto = z.object({
|
||||||
|
FeaturedRoomGroupId: z.int(),
|
||||||
|
name: z.string(),
|
||||||
|
StartAt: z.string(),
|
||||||
|
EndAt: z.string(),
|
||||||
|
Rooms: z.array(FeaturedRoomDto).describe('Randomly ordered — no editorial curation yet'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Envelopes -------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The room writes answer one of two envelopes, both at HTTP 200 — the client reads the
|
||||||
|
// success flag, not the status. Which one a route uses is not ours to choose: it's what
|
||||||
|
// the client's deserializer for that call expects, so the two live side by side.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PascalCase result envelope (`Results.Ok(new RoomResult{...})`): a bare
|
||||||
|
* success/failure with a message, carrying no entity. `ErrorId` is a stable code the
|
||||||
|
* client may branch on; `Error` is the text it shows.
|
||||||
|
*/
|
||||||
|
export const RoomResultEnvelope = z.object({
|
||||||
|
Success: z.boolean(),
|
||||||
|
Value: z.unknown().nullable().describe('Always null — these routes carry no entity'),
|
||||||
|
ErrorId: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.describe('e.g. `Rooms.DoesntExist`, `Rooms.NotOwner`; null on success'),
|
||||||
|
Error: z.string().nullable().describe('The message shown to the player; null on success'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The lowercase envelope carrying the updated room — the client re-renders from `value`. */
|
||||||
|
export const RoomEnvelope = z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.string().describe('Empty on success'),
|
||||||
|
value: RoomDto.nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a room save answers: the saved subroom on success (no envelope — the client
|
||||||
|
* deserializes the body directly as the subroom), or the PascalCase result envelope when
|
||||||
|
* the room or subroom doesn't exist. Both at HTTP 200.
|
||||||
|
*/
|
||||||
|
export const SubRoomSaveResult = z.union([SubRoomDto, RoomResultEnvelope])
|
||||||
|
|
||||||
|
/** The same envelope carrying a subroom (`POST …/subrooms/{subRoomId}/clone`). */
|
||||||
|
export const SubRoomEnvelope = z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.string().describe('Empty on success'),
|
||||||
|
value: SubRoomDto.nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The 401 the envelope-returning routes answer with — the only one that isn’t HTTP 200. */
|
||||||
|
export const UNAUTHORIZED_ENVELOPE = json(
|
||||||
|
z.object({ success: z.literal(false), error: z.literal('Unauthorized'), value: z.null() }),
|
||||||
|
'Missing or invalid bearer token'
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Request bodies --------------------------------------------------------
|
||||||
|
|
||||||
|
/** `POST /rooms/{roomId}/clone` — also accepted as a `?name=` query param. */
|
||||||
|
export const CloneRoomRequest = z.object({
|
||||||
|
name: z.string().describe('The new room’s name; must be unique'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `PUT /rooms/{roomId}/description`. */
|
||||||
|
export const DescriptionRequest = z.object({
|
||||||
|
description: z.string().describe('An absent field clears the description'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `PUT /rooms/{roomId}/name`. */
|
||||||
|
export const NameRequest = z.object({
|
||||||
|
name: z.string().describe('Non-empty, and not already taken by another room'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `PUT /rooms/{roomId}/tags` — a toggle, not a set. */
|
||||||
|
export const TagRequest = z.object({
|
||||||
|
tag: z.string().describe('Added when absent, removed when present'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `PUT /rooms/{roomId}/image`. */
|
||||||
|
export const ImageRequest = z.object({
|
||||||
|
imageName: z.string().describe('A key from the storage upload, stored un-prefixed'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `PUT /rooms/{roomId}/roles/{accountId}`. */
|
||||||
|
export const RoleRequest = z.object({
|
||||||
|
role: z.string().describe('The role tier: 10 Host, 20 Moderator, 30 CoOwner, 255 Creator'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `PUT /rooms/{roomId}/warning`. */
|
||||||
|
export const WarningRequest = z.object({
|
||||||
|
warningMask: z.string().describe('Content-warning bit flags, as an integer'),
|
||||||
|
customWarning: z.string().optional().describe('Set when present; an empty value clears it'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `PUT /rooms/{roomId}/cloning`. */
|
||||||
|
export const CloningRequest = z.object({
|
||||||
|
cloningAllowed: z.string().describe('`True` / `False`'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `PUT /rooms/{roomId}/restrictions` — the room's platform/movement support flags. Only
|
||||||
|
* the fields actually posted are changed, and the names are matched case-insensitively.
|
||||||
|
*/
|
||||||
|
export const RestrictionsRequest = z.object({
|
||||||
|
supportsScreens: z.string().optional().describe('`True` / `False`'),
|
||||||
|
supportsWalkVR: z.string().optional().describe('`True` / `False`'),
|
||||||
|
supportsTeleportVR: z.string().optional().describe('`True` / `False`'),
|
||||||
|
supportsVRLow: z.string().optional().describe('`True` / `False`'),
|
||||||
|
supportsQuest2: z.string().optional().describe('`True` / `False`'),
|
||||||
|
supportsMobile: z.string().optional().describe('`True` / `False`'),
|
||||||
|
supportsJuniors: z.string().optional().describe('`True` / `False`'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `PUT /rooms/{roomId}/loadscreen` — appends one screen to the list. */
|
||||||
|
export const LoadScreenRequest = z.object({
|
||||||
|
imageName: z.string().describe('A key from the storage upload'),
|
||||||
|
title: z.string().optional(),
|
||||||
|
subtitle: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `PUT /rooms/{roomId}/accessibility`. */
|
||||||
|
export const AccessibilityRequest = z.object({
|
||||||
|
accessibility: z.string().describe('0 = Private, 1 = Public, 2 = Unlisted'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `POST /rooms/{roomId}/subrooms`. */
|
||||||
|
export const CreateSubRoomRequest = z.object({
|
||||||
|
name: z.string().describe('The new subroom’s name'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `PUT /rooms/{roomId}/subrooms/{subRoomId}/modify`. */
|
||||||
|
export const ModifySubRoomRequest = z.object({
|
||||||
|
name: z.string().describe('Required — an empty name is rejected'),
|
||||||
|
accessibility: z.string().optional().describe('0 = Private, 1 = Public, 2 = Unlisted'),
|
||||||
|
maxPlayers: z.string().optional().describe('Ignored when not a positive integer'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /rooms/{roomId}/subrooms/{subRoomId}/data` — the room save. The blobs are
|
||||||
|
* uploaded to the CDN through the `storage` worker first; this call points the subroom
|
||||||
|
* at them. Every field is optional: a save that carries only `SubRoomData` still stamps
|
||||||
|
* the save time.
|
||||||
|
*/
|
||||||
|
export const SaveSubRoomDataRequest = z.object({
|
||||||
|
SubRoomData: z
|
||||||
|
.object({ Filename: z.string() })
|
||||||
|
.optional()
|
||||||
|
.describe('The uploaded scene-data blob — becomes the subroom’s `DataBlob`'),
|
||||||
|
RoomData: z
|
||||||
|
.object({ Filename: z.string() })
|
||||||
|
.optional()
|
||||||
|
.describe('The uploaded room-level data blob — becomes `RoomDataBlob`'),
|
||||||
|
Description: z.string().optional().describe('Written to the ROOM, not the subroom'),
|
||||||
|
PersistenceVersion: z.int().optional(),
|
||||||
|
InventionUsage: z.string().optional().describe('Written to the room'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /rooms/{roomId}/subrooms/{subRoomId}/saves` — the room-history page. We keep no
|
||||||
|
* save history (a save overwrites the subroom's blob inline), so it's always empty.
|
||||||
|
*/
|
||||||
|
export const SubRoomSavesPage = z.object({
|
||||||
|
Results: z.array(z.unknown()).describe('Always empty — no save history is kept'),
|
||||||
|
TotalResults: z.int(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Session ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/** One entry of the permission table the client applies when it spawns into a room. */
|
||||||
|
export const RoomPermissionDto = z.object({
|
||||||
|
Override: z.boolean(),
|
||||||
|
Permission: z.string().describe('e.g. `CAN_USE_MAKER_PEN`, `CAN_SAVE_INVENTIONS`'),
|
||||||
|
Role: z.int().describe('The role tier the permission applies to (0 = everyone)'),
|
||||||
|
Type: z.int(),
|
||||||
|
Value: z.string().describe('Always `True` — a permission is present or absent'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The permissions + Photon credentials the client needs to spawn into a room.
|
||||||
|
*
|
||||||
|
* `PhotonAccessToken` is deliberately empty: the reference server signs it with a
|
||||||
|
* secret/algorithm we don't have, and our Photon setup accepts an empty token. The
|
||||||
|
* global (Role 0) maker pen is granted only to the hardcoded dev accounts.
|
||||||
|
*/
|
||||||
|
export const PhotonAccessTokenDto = z.object({
|
||||||
|
Permissions: z.array(RoomPermissionDto),
|
||||||
|
PhotonAccessToken: z.string().describe('Always empty — see above'),
|
||||||
|
RoomInstanceId: z
|
||||||
|
.int()
|
||||||
|
.nullable()
|
||||||
|
.describe('The caller’s current instance, from presence; null when they’re in none'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `GET /rooms/{roomId}/playerdata/me` — per-room player data. Nothing stores any yet. */
|
||||||
|
export const PlayerDataDto = z.object({
|
||||||
|
Data: z.string().describe('Always empty — no per-room player data is stored'),
|
||||||
|
})
|
||||||
+1470
-601
@@ -1,4 +1,5 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -38,9 +39,53 @@ import {
|
|||||||
toggleRoomTag,
|
toggleRoomTag,
|
||||||
updateRoomFields,
|
updateRoomFields,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
|
import {
|
||||||
|
AccessibilityRequest,
|
||||||
|
AUTHED,
|
||||||
|
CloneRoomRequest,
|
||||||
|
CloningRequest,
|
||||||
|
CreateSubRoomRequest,
|
||||||
|
DescriptionRequest,
|
||||||
|
FeaturedRoomGroupDto,
|
||||||
|
FORBIDDEN_RESPONSE,
|
||||||
|
form,
|
||||||
|
ImageRequest,
|
||||||
|
InteractionDto,
|
||||||
|
json,
|
||||||
|
jsonBody,
|
||||||
|
LoadScreenRequest,
|
||||||
|
MissingLookupParam,
|
||||||
|
ModifySubRoomRequest,
|
||||||
|
NameRequest,
|
||||||
|
PagedRooms,
|
||||||
|
pageParams,
|
||||||
|
PhotonAccessTokenDto,
|
||||||
|
PlayerDataDto,
|
||||||
|
RestrictionsRequest,
|
||||||
|
RoleRequest,
|
||||||
|
RoomDto,
|
||||||
|
RoomEnvelope,
|
||||||
|
roomIdParam,
|
||||||
|
RoomLookup,
|
||||||
|
RoomResultEnvelope,
|
||||||
|
SaveSubRoomDataRequest,
|
||||||
|
ServiceStatus,
|
||||||
|
stringQuery,
|
||||||
|
SubRoomDto,
|
||||||
|
SubRoomEnvelope,
|
||||||
|
subRoomIdParam,
|
||||||
|
SubRoomSaveResult,
|
||||||
|
SubRoomSavesPage,
|
||||||
|
TagRequest,
|
||||||
|
UNAUTHORIZED_EMPTY,
|
||||||
|
UNAUTHORIZED_ENVELOPE,
|
||||||
|
UNAUTHORIZED_RESPONSE,
|
||||||
|
WarningRequest,
|
||||||
|
} from './openapi'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
|
||||||
@@ -245,63 +290,152 @@ const app = new Hono<App>()
|
|||||||
.onError(withOnError())
|
.onError(withOnError())
|
||||||
.notFound(withNotFound())
|
.notFound(withNotFound())
|
||||||
|
|
||||||
.get('/', (c) => c.json({ service: 'rooms', status: 'ok' }))
|
.get(
|
||||||
|
'/',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Service'],
|
||||||
|
summary: 'Service liveness',
|
||||||
|
description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.',
|
||||||
|
responses: { 200: json(ServiceStatus, 'Always `{ service: "rooms", status: "ok" }`') },
|
||||||
|
}),
|
||||||
|
(c) => c.json({ service: 'rooms', status: 'ok' })
|
||||||
|
)
|
||||||
|
|
||||||
// Room lookup by `id` (first match wins) or `name`. 400s when neither is
|
// Room lookup by `id` (first match wins) or `name`. 400s when neither is
|
||||||
// supplied and returns `{}` when nothing matches.
|
// supplied and returns `{}` when nothing matches.
|
||||||
.get('/rooms', async (c) => {
|
.get(
|
||||||
const idParam = c.req.query('id')
|
'/rooms',
|
||||||
const nameParam = c.req.query('name')
|
describeRoute({
|
||||||
if (!idParam && !nameParam) {
|
tags: ['Rooms'],
|
||||||
return c.json("Either 'id' or 'name' query parameter is required", 400)
|
summary: 'Look up a room by id or name',
|
||||||
}
|
description: [
|
||||||
if (idParam) {
|
'A single room by `id` or `name`. `id` may be a comma-separated list — the first',
|
||||||
const id = firstId(idParam)
|
'valid integer wins. An unknown room is `{}`, not a 404: the client reads an empty',
|
||||||
const room = id === undefined ? null : await getRoomById(c.env.DB, id)
|
'object as “no such room”.',
|
||||||
|
].join(' '),
|
||||||
|
parameters: [
|
||||||
|
stringQuery('id', 'Room id (comma-separated; the first valid one is used)'),
|
||||||
|
stringQuery('name', 'Room name (matched case-insensitively). Ignored when `id` is given'),
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: json(RoomLookup, 'The room, or `{}` when there’s no match'),
|
||||||
|
400: json(MissingLookupParam, 'Neither `id` nor `name` was supplied'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const idParam = c.req.query('id')
|
||||||
|
const nameParam = c.req.query('name')
|
||||||
|
if (!idParam && !nameParam) {
|
||||||
|
return c.json("Either 'id' or 'name' query parameter is required", 400)
|
||||||
|
}
|
||||||
|
if (idParam) {
|
||||||
|
const id = firstId(idParam)
|
||||||
|
const room = id === undefined ? null : await getRoomById(c.env.DB, id)
|
||||||
|
return c.json(room ?? {})
|
||||||
|
}
|
||||||
|
const room = await getRoomByName(c.env.DB, nameParam ?? '')
|
||||||
return c.json(room ?? {})
|
return c.json(room ?? {})
|
||||||
}
|
}
|
||||||
const room = await getRoomByName(c.env.DB, nameParam ?? '')
|
)
|
||||||
return c.json(room ?? {})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Room search: `query` is space/`+`-separated terms — `#tag` matches room tags,
|
// Room search: `query` is space/`+`-separated terms — `#tag` matches room tags,
|
||||||
// plain terms match the name. Public, non-dorm rooms only. Paginated via
|
// plain terms match the name. Public, non-dorm rooms only. Paginated via
|
||||||
// skip/take. Returns `{ Results, TotalResults }`.
|
// skip/take. Returns `{ Results, TotalResults }`.
|
||||||
.get('/rooms/search', async (c) => {
|
.get(
|
||||||
const query = c.req.query('query') ?? ''
|
'/rooms/search',
|
||||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
describeRoute({
|
||||||
const take = Number.parseInt(c.req.query('take') ?? '30', 10) || 30
|
tags: ['Discovery'],
|
||||||
return c.json(await searchRooms(c.env.DB, query, skip, take))
|
summary: 'Search rooms',
|
||||||
})
|
description: [
|
||||||
|
'Full room search. `query` is space- or `+`-separated terms: a `#tag` term matches the',
|
||||||
|
'room’s tags, a plain term matches its name. Public, non-dorm rooms only.',
|
||||||
|
].join(' '),
|
||||||
|
parameters: [
|
||||||
|
stringQuery('query', 'Search terms — `#tag` matches tags, plain terms match the name'),
|
||||||
|
...pageParams(30),
|
||||||
|
],
|
||||||
|
responses: { 200: json(PagedRooms, 'The matching rooms') },
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const query = c.req.query('query') ?? ''
|
||||||
|
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||||
|
const take = Number.parseInt(c.req.query('take') ?? '30', 10) || 30
|
||||||
|
return c.json(await searchRooms(c.env.DB, query, skip, take))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// "Hot" rooms feed — public, non-dorm rooms ordered by engagement, optionally
|
// "Hot" rooms feed — public, non-dorm rooms ordered by engagement, optionally
|
||||||
// filtered to a single `tag` (e.g. `rro`). Paginated via skip/take (take
|
// filtered to a single `tag` (e.g. `rro`). Paginated via skip/take (take
|
||||||
// defaults to 100). Returns `{ Results, TotalResults }` like search.
|
// defaults to 100). Returns `{ Results, TotalResults }` like search.
|
||||||
.get('/rooms/hot', async (c) => {
|
.get(
|
||||||
const tag = c.req.query('tag') ?? ''
|
'/rooms/hot',
|
||||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
describeRoute({
|
||||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
tags: ['Discovery'],
|
||||||
return c.json(await getHotRooms(c.env.DB, tag, skip, take))
|
summary: 'The “hot” rooms feed',
|
||||||
})
|
description: [
|
||||||
|
'Public, non-dorm rooms ordered by engagement, optionally narrowed to a single `tag`',
|
||||||
|
'(the browse screen’s filter chips post one, e.g. `rro`).',
|
||||||
|
].join(' '),
|
||||||
|
parameters: [stringQuery('tag', 'Restrict to rooms carrying this tag'), ...pageParams(100)],
|
||||||
|
responses: { 200: json(PagedRooms, 'The feed page') },
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const tag = c.req.query('tag') ?? ''
|
||||||
|
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||||
|
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||||
|
return c.json(await getHotRooms(c.env.DB, tag, skip, take))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// "Base" rooms — template rooms (tagged `base`) the client offers when creating
|
// "Base" rooms — template rooms (tagged `base`) the client offers when creating
|
||||||
// a room. Returned regardless of accessibility. Paginated via skip/take (take
|
// a room. Returned regardless of accessibility. Paginated via skip/take (take
|
||||||
// defaults to 100). Returns a bare array.
|
// defaults to 100). Returns a bare array.
|
||||||
.get('/rooms/base', async (c) => {
|
.get(
|
||||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
'/rooms/base',
|
||||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
describeRoute({
|
||||||
return c.json(await getBaseRooms(c.env.DB, skip, take))
|
tags: ['Discovery'],
|
||||||
})
|
summary: 'Base (template) rooms',
|
||||||
|
description: [
|
||||||
|
'The template rooms — those tagged `base` — the client offers when a player creates a',
|
||||||
|
'room. Served regardless of accessibility, and as a bare array rather than a page.',
|
||||||
|
].join(' '),
|
||||||
|
parameters: pageParams(100),
|
||||||
|
responses: { 200: json(RoomDto.array(), 'The template rooms') },
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||||
|
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||||
|
return c.json(await getBaseRooms(c.env.DB, skip, take))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Recommended rooms feed — public, non-dorm rooms ranked by engagement, returned
|
// Recommended rooms feed — public, non-dorm rooms ranked by engagement, returned
|
||||||
// as a bare array (the client's recommendation room-source expects a plain list).
|
// as a bare array (the client's recommendation room-source expects a plain list).
|
||||||
// The `splitTestId`/`splitTestValue` A/B params are accepted and ignored.
|
// The `splitTestId`/`splitTestValue` A/B params are accepted and ignored.
|
||||||
// Paginated via skip/take (take defaults to 100).
|
// Paginated via skip/take (take defaults to 100).
|
||||||
.get('/rooms/recommendations', async (c) => {
|
.get(
|
||||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
'/rooms/recommendations',
|
||||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
describeRoute({
|
||||||
return c.json(await getRecommendedRooms(c.env.DB, skip, take))
|
tags: ['Discovery'],
|
||||||
})
|
summary: 'Recommended rooms',
|
||||||
|
description: [
|
||||||
|
'Public, non-dorm rooms ranked by engagement. Unlike search and hot this is a BARE',
|
||||||
|
'array — the client’s recommendation room-source expects a plain list. The',
|
||||||
|
'`splitTestId`/`splitTestValue` A/B params are accepted and ignored.',
|
||||||
|
].join(' '),
|
||||||
|
parameters: [
|
||||||
|
stringQuery('splitTestId', 'A/B test id — accepted and ignored'),
|
||||||
|
stringQuery('splitTestValue', 'A/B test bucket — accepted and ignored'),
|
||||||
|
...pageParams(100),
|
||||||
|
],
|
||||||
|
responses: { 200: json(RoomDto.array(), 'The recommended rooms') },
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||||
|
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||||
|
return c.json(await getRecommendedRooms(c.env.DB, skip, take))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Featured rooms — a single always-active group whose `Rooms` are a randomly
|
// Featured rooms — a single always-active group whose `Rooms` are a randomly
|
||||||
// ordered set of public, non-dorm rooms. No real curation yet, so `current`
|
// ordered set of public, non-dorm rooms. No real curation yet, so `current`
|
||||||
@@ -310,232 +444,496 @@ const app = new Hono<App>()
|
|||||||
// completely with NREs. I think it is the featured room load that is somehow
|
// completely with NREs. I think it is the featured room load that is somehow
|
||||||
// corrupting the room cache. I tried sending the normal room shape but that
|
// corrupting the room cache. I tried sending the normal room shape but that
|
||||||
// did not seem to work.
|
// did not seem to work.
|
||||||
.get('/XXXfeaturedrooms/current', async (c) => {
|
.get(
|
||||||
return c.json(await getFeaturedRooms(c.env.DB))
|
'/XXXfeaturedrooms/current',
|
||||||
})
|
describeRoute({
|
||||||
|
tags: ['Discovery'],
|
||||||
|
summary: 'Featured rooms (parked — the path is deliberately broken)',
|
||||||
|
description: [
|
||||||
|
'A single always-active group of featured rooms: a random shuffle of eligible public',
|
||||||
|
'rooms, since there is no editorial curation yet.',
|
||||||
|
'',
|
||||||
|
'**Parked.** The path the client calls is `/featuredrooms/current`; this is registered',
|
||||||
|
'under an `XXX` prefix so the client never reaches it. Serving it made the OTHER room',
|
||||||
|
'listings fail with NREs in the client, apparently by corrupting its room cache —',
|
||||||
|
'sending the normal room shape instead did not help. It stays registered so the shape',
|
||||||
|
'is documented and the route is one rename away once the cause is found.',
|
||||||
|
].join('\n'),
|
||||||
|
responses: { 200: json(FeaturedRoomGroupDto, 'The featured-room group') },
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
return c.json(await getFeaturedRooms(c.env.DB))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Bulk room lookup by `id` or `name` — returns an array of matched rooms (the
|
// Bulk room lookup by `id` or `name` — returns an array of matched rooms (the
|
||||||
// client calls this bare on the rooms host). Rooms not in D1 are simply absent
|
// client calls this bare on the rooms host). Rooms not in D1 are simply absent
|
||||||
// from the result; the client treats an empty result as NoSuchRoom.
|
// from the result; the client treats an empty result as NoSuchRoom.
|
||||||
.get('/rooms/bulk', async (c) => {
|
.get(
|
||||||
const idParam = c.req.query('id')
|
'/rooms/bulk',
|
||||||
const nameParam = c.req.query('name')
|
describeRoute({
|
||||||
if (!idParam && !nameParam) {
|
tags: ['Rooms'],
|
||||||
return c.json("Either 'id' or 'name' query parameter is required", 400)
|
summary: 'Look up several rooms at once',
|
||||||
|
description: [
|
||||||
|
'Rooms by a comma-separated `id` list, or a single `name`. Ids that aren’t in D1 are',
|
||||||
|
'simply absent from the result rather than an error — the client reads an empty result',
|
||||||
|
'as NoSuchRoom.',
|
||||||
|
].join(' '),
|
||||||
|
parameters: [
|
||||||
|
stringQuery('id', 'Comma-separated room ids'),
|
||||||
|
stringQuery('name', 'A single room name. Ignored when `id` is given'),
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: json(RoomDto.array(), 'The rooms that matched (missing ids are omitted)'),
|
||||||
|
400: json(MissingLookupParam, 'Neither `id` nor `name` was supplied'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const idParam = c.req.query('id')
|
||||||
|
const nameParam = c.req.query('name')
|
||||||
|
if (!idParam && !nameParam) {
|
||||||
|
return c.json("Either 'id' or 'name' query parameter is required", 400)
|
||||||
|
}
|
||||||
|
if (idParam) {
|
||||||
|
return c.json(await getRoomsByIds(c.env.DB, allIds(idParam)))
|
||||||
|
}
|
||||||
|
const room = await getRoomByName(c.env.DB, nameParam ?? '')
|
||||||
|
return c.json(room ? [room] : [])
|
||||||
}
|
}
|
||||||
if (idParam) {
|
)
|
||||||
return c.json(await getRoomsByIds(c.env.DB, allIds(idParam)))
|
|
||||||
}
|
|
||||||
const room = await getRoomByName(c.env.DB, nameParam ?? '')
|
|
||||||
return c.json(room ? [room] : [])
|
|
||||||
})
|
|
||||||
|
|
||||||
// Rooms created/owned by the caller. Auth-gated — no token is a 401, never
|
// Rooms created/owned by the caller. Auth-gated — no token is a 401, never
|
||||||
// account 1. `ownedby/me` drops the dorm (it's not a room the player made);
|
// account 1. `ownedby/me` drops the dorm (it's not a room the player made);
|
||||||
// the `createdby` variants return everything the account created.
|
// the `createdby` variants return everything the account created.
|
||||||
.get('/roomserver/rooms/createdby/me', ownedRooms)
|
.get(
|
||||||
.get('/rooms/ownedby/me', ownedRoomsExcludingDorm)
|
'/roomserver/rooms/createdby/me',
|
||||||
.get('/rooms/createdby/me', ownedRooms)
|
describeRoute({
|
||||||
|
tags: ['My rooms'],
|
||||||
|
summary: 'Rooms the caller created (legacy path)',
|
||||||
|
description: [
|
||||||
|
'Every room the caller created, dorm included. Identical to',
|
||||||
|
'`GET /rooms/createdby/me` — the 2023 client calls this one under the `/roomserver`',
|
||||||
|
'prefix, so both forms are registered.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
responses: { 200: json(RoomDto.array(), 'The caller’s rooms'), 401: UNAUTHORIZED_RESPONSE },
|
||||||
|
}),
|
||||||
|
ownedRooms
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
'/rooms/ownedby/me',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['My rooms'],
|
||||||
|
summary: 'Rooms the caller owns (excluding their dorm)',
|
||||||
|
description: [
|
||||||
|
'The caller’s own rooms with the dorm filtered out: a dorm is auto-provisioned, not a',
|
||||||
|
'room the player made, so it doesn’t belong in the “rooms you own” list. Use',
|
||||||
|
'`createdby/me` for everything the account created.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
responses: {
|
||||||
|
200: json(RoomDto.array(), 'The caller’s rooms, dorm excluded'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ownedRoomsExcludingDorm
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
'/rooms/createdby/me',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['My rooms'],
|
||||||
|
summary: 'Rooms the caller created',
|
||||||
|
description: 'Every room the caller created, dorm included.',
|
||||||
|
security: AUTHED,
|
||||||
|
responses: { 200: json(RoomDto.array(), 'The caller’s rooms'), 401: UNAUTHORIZED_RESPONSE },
|
||||||
|
}),
|
||||||
|
ownedRooms
|
||||||
|
)
|
||||||
|
|
||||||
// Public: the rooms a given account owns that are publicly viewable. No auth —
|
// Public: the rooms a given account owns that are publicly viewable. No auth —
|
||||||
// returns a bare array (empty when the account owns no public rooms).
|
// returns a bare array (empty when the account owns no public rooms).
|
||||||
.get('/rooms/ownedby/:accountId{[0-9]+}', async (c) =>
|
.get(
|
||||||
c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
|
'/rooms/ownedby/:accountId{[0-9]+}',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Rooms'],
|
||||||
|
summary: 'Another player’s public rooms',
|
||||||
|
description: [
|
||||||
|
'The rooms an account owns that are publicly viewable — what the client shows on a',
|
||||||
|
'player’s profile. No auth; empty when the account owns no public rooms.',
|
||||||
|
].join(' '),
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'accountId',
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
description: 'The account whose rooms to list',
|
||||||
|
schema: { type: 'string', pattern: '^[0-9]+$' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: { 200: json(RoomDto.array(), 'That account’s public rooms') },
|
||||||
|
}),
|
||||||
|
async (c) =>
|
||||||
|
c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rooms the caller has favorited (from the interaction table). Auth-gated.
|
// Rooms the caller has favorited (from the interaction table). Auth-gated.
|
||||||
// Paginated via skip/take (take defaults to 100). Returns a bare array, like the
|
// Paginated via skip/take (take defaults to 100). Returns a bare array, like the
|
||||||
// other room-source `*by/me` lists the client loads.
|
// other room-source `*by/me` lists the client loads.
|
||||||
.get('/rooms/favoritedby/me', async (c) => {
|
.get(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/favoritedby/me',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
tags: ['My rooms'],
|
||||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
summary: 'Rooms the caller favorited',
|
||||||
return c.json(await getFavoritedRooms(c.env.DB, accountId, skip, take))
|
description:
|
||||||
})
|
'The rooms the caller has favorited (from the interaction table), as a bare array.',
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: pageParams(100),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomDto.array(), 'The favorited rooms'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||||
|
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||||
|
return c.json(await getFavoritedRooms(c.env.DB, accountId, skip, take))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Rooms the caller has visited (interaction rows with a last-visited time).
|
// Rooms the caller has visited (interaction rows with a last-visited time).
|
||||||
// Auth-gated. Paginated via skip/take (take defaults to 100). Returns a bare array.
|
// Auth-gated. Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||||
.get('/rooms/visitedby/me', async (c) => {
|
.get(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/visitedby/me',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
tags: ['My rooms'],
|
||||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
summary: 'Rooms the caller visited',
|
||||||
return c.json(await getVisitedRooms(c.env.DB, accountId, skip, take))
|
description: [
|
||||||
})
|
'The rooms the caller has visited — interaction rows carrying a last-visited time —',
|
||||||
|
'as a bare array.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: pageParams(100),
|
||||||
|
responses: { 200: json(RoomDto.array(), 'The visited rooms'), 401: UNAUTHORIZED_RESPONSE },
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||||
|
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||||
|
return c.json(await getVisitedRooms(c.env.DB, accountId, skip, take))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// The current player's interaction state with a room (cheered/favorited/last
|
// The current player's interaction state with a room (cheered/favorited/last
|
||||||
// visited), read from the `interaction` table. Auth-gated.
|
// visited), read from the `interaction` table. Auth-gated.
|
||||||
.get('/rooms/:roomId{[0-9]+}/interactionby/me', async (c) => {
|
.get(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/interactionby/me',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
const interaction = await getInteraction(
|
tags: ['Interaction'],
|
||||||
c.env.DB,
|
summary: 'The caller’s state on a room',
|
||||||
accountId,
|
description: [
|
||||||
Number.parseInt(c.req.param('roomId'), 10)
|
'Whether the caller has cheered/favorited the room. An unknown room (or one the caller',
|
||||||
)
|
'has never touched) reads as all-false rather than 404. `LastVisitedAt` is stamped',
|
||||||
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
|
'with “now” on every read, not served from storage.',
|
||||||
})
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
responses: { 200: json(InteractionDto, 'The interaction'), 401: UNAUTHORIZED_RESPONSE },
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
const interaction = await getInteraction(
|
||||||
|
c.env.DB,
|
||||||
|
accountId,
|
||||||
|
Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
)
|
||||||
|
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Toggle the player's cheer/favorite on a room. Both are auth-gated PUTs that
|
// Toggle the player's cheer/favorite on a room. Both are auth-gated PUTs that
|
||||||
// flip the stored flag and return the updated interaction.
|
// flip the stored flag and return the updated interaction.
|
||||||
.put('/rooms/:roomId{[0-9]+}/interactionby/me/cheer', async (c) => {
|
.put(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/interactionby/me/cheer',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
const interaction = await toggleCheer(
|
tags: ['Interaction'],
|
||||||
c.env.DB,
|
summary: 'Toggle the caller’s cheer on a room',
|
||||||
accountId,
|
description: 'Flips the stored cheer flag and answers the updated interaction.',
|
||||||
Number.parseInt(c.req.param('roomId'), 10)
|
security: AUTHED,
|
||||||
)
|
parameters: [roomIdParam],
|
||||||
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
|
responses: {
|
||||||
})
|
200: json(InteractionDto, 'The interaction after the toggle'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
const interaction = await toggleCheer(
|
||||||
|
c.env.DB,
|
||||||
|
accountId,
|
||||||
|
Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
)
|
||||||
|
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
|
||||||
|
}
|
||||||
|
)
|
||||||
// Explicitly un-cheer a room (DELETE clears the cheer, vs the PUT toggle).
|
// Explicitly un-cheer a room (DELETE clears the cheer, vs the PUT toggle).
|
||||||
// Auth-gated; idempotent — un-cheering when there's no cheer is a no-op.
|
// Auth-gated; idempotent — un-cheering when there's no cheer is a no-op.
|
||||||
.delete('/rooms/:roomId{[0-9]+}/interactionby/me/cheer', async (c) => {
|
.delete(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/interactionby/me/cheer',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
const interaction = await removeCheer(
|
tags: ['Interaction'],
|
||||||
c.env.DB,
|
summary: 'Un-cheer a room',
|
||||||
accountId,
|
description: [
|
||||||
Number.parseInt(c.req.param('roomId'), 10)
|
'Clears the cheer outright, where the PUT toggles it. Idempotent — un-cheering a room',
|
||||||
)
|
'that isn’t cheered is a no-op.',
|
||||||
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
|
].join(' '),
|
||||||
})
|
security: AUTHED,
|
||||||
.put('/rooms/:roomId{[0-9]+}/interactionby/me/favorite', async (c) => {
|
parameters: [roomIdParam],
|
||||||
const accountId = await authedAccountId(c)
|
responses: { 200: json(InteractionDto, 'The interaction'), 401: UNAUTHORIZED_RESPONSE },
|
||||||
if (accountId === null) return unauthorized(c)
|
}),
|
||||||
const interaction = await toggleFavorite(
|
async (c) => {
|
||||||
c.env.DB,
|
const accountId = await authedAccountId(c)
|
||||||
accountId,
|
if (accountId === null) return unauthorized(c)
|
||||||
Number.parseInt(c.req.param('roomId'), 10)
|
const interaction = await removeCheer(
|
||||||
)
|
c.env.DB,
|
||||||
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
|
accountId,
|
||||||
})
|
Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
)
|
||||||
|
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.put(
|
||||||
|
'/rooms/:roomId{[0-9]+}/interactionby/me/favorite',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Interaction'],
|
||||||
|
summary: 'Toggle the caller’s favorite on a room',
|
||||||
|
description: 'Flips the stored favorite flag and answers the updated interaction.',
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
responses: {
|
||||||
|
200: json(InteractionDto, 'The interaction after the toggle'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
const interaction = await toggleFavorite(
|
||||||
|
c.env.DB,
|
||||||
|
accountId,
|
||||||
|
Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
)
|
||||||
|
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
|
||||||
|
}
|
||||||
|
)
|
||||||
// Explicitly un-favorite a room (DELETE clears the favorite, vs the PUT toggle).
|
// Explicitly un-favorite a room (DELETE clears the favorite, vs the PUT toggle).
|
||||||
// Auth-gated; idempotent — un-favoriting when there's no favorite is a no-op.
|
// Auth-gated; idempotent — un-favoriting when there's no favorite is a no-op.
|
||||||
.delete('/rooms/:roomId{[0-9]+}/interactionby/me/favorite', async (c) => {
|
.delete(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/interactionby/me/favorite',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
const interaction = await removeFavorite(
|
tags: ['Interaction'],
|
||||||
c.env.DB,
|
summary: 'Un-favorite a room',
|
||||||
accountId,
|
description: [
|
||||||
Number.parseInt(c.req.param('roomId'), 10)
|
'Clears the favorite outright, where the PUT toggles it. Idempotent — un-favoriting a',
|
||||||
)
|
'room that isn’t favorited is a no-op.',
|
||||||
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
|
].join(' '),
|
||||||
})
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
responses: { 200: json(InteractionDto, 'The interaction'), 401: UNAUTHORIZED_RESPONSE },
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
const interaction = await removeFavorite(
|
||||||
|
c.env.DB,
|
||||||
|
accountId,
|
||||||
|
Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
)
|
||||||
|
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Clone a room into a new one owned by the caller, using the `name` form field
|
// Clone a room into a new one owned by the caller, using the `name` form field
|
||||||
// (also accepted as a query param). Auth is required — no valid token is a 401,
|
// (also accepted as a query param). Auth is required — no valid token is a 401,
|
||||||
// with no stub-account fallback. Returns the `{ success, error, value }` envelope
|
// with no stub-account fallback. Returns the `{ success, error, value }` envelope
|
||||||
// the client expects; business failures are 200 with success:false.
|
// the client expects; business failures are 200 with success:false.
|
||||||
.post('/rooms/:roomId{[0-9]+}/clone', async (c) => {
|
.post(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/clone',
|
||||||
if (accountId === null) {
|
describeRoute({
|
||||||
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
|
tags: ['Room settings'],
|
||||||
}
|
summary: 'Clone a room',
|
||||||
|
description: [
|
||||||
|
'Copies a room’s content (scene, subrooms, settings) into a new room owned by the',
|
||||||
|
'caller. Cloning is the only way to make a room, so the per-account room cap is',
|
||||||
|
'enforced here — it counts the rooms the account created, minus their auto-provisioned',
|
||||||
|
'dorm (`MAX_ROOMS_PER_ACCOUNT`; 0 lifts the cap). The clone starts with no tags and',
|
||||||
|
'`IsRRO` cleared.',
|
||||||
|
'',
|
||||||
|
'Rejections — a blank or taken name, the cap, a source that disallows cloning — are',
|
||||||
|
'HTTP 200 with `success: false` and the message the client shows.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
requestBody: form(CloneRoomRequest, 'The new room’s name (also read from `?name=`)'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomEnvelope, 'The new room, or a rejection with `success: false`'),
|
||||||
|
401: UNAUTHORIZED_ENVELOPE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) {
|
||||||
|
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
const raw = body.name ?? c.req.query('name') ?? ''
|
const raw = body.name ?? c.req.query('name') ?? ''
|
||||||
const name = typeof raw === 'string' ? raw.trim() : ''
|
const name = typeof raw === 'string' ? raw.trim() : ''
|
||||||
|
|
||||||
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your room.')
|
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your room.')
|
||||||
if (await getRoomByName(c.env.DB, name)) {
|
if (await getRoomByName(c.env.DB, name)) {
|
||||||
return roomEnvelope(c, null, 'A room with that name already exists!')
|
return roomEnvelope(c, null, 'A room with that name already exists!')
|
||||||
|
}
|
||||||
|
// Cloning is how a player makes a room, so the per-account cap belongs here.
|
||||||
|
// Checked after the cheap validations so a rejected name costs no extra D1 read.
|
||||||
|
const maxRooms = intVar(c.env.MAX_ROOMS_PER_ACCOUNT, DEFAULT_MAX_ROOMS_PER_ACCOUNT)
|
||||||
|
if (maxRooms > 0 && (await countRoomsByCreator(c.env.DB, accountId)) >= maxRooms) {
|
||||||
|
logger.info('room create rejected: per-account room limit', { accountId })
|
||||||
|
return roomEnvelope(c, null, `You can only have ${maxRooms} rooms.`)
|
||||||
|
}
|
||||||
|
const room = await cloneRoom(
|
||||||
|
c.env.DB,
|
||||||
|
Number.parseInt(c.req.param('roomId'), 10),
|
||||||
|
name,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
|
if (!room) return roomEnvelope(c, null, "You can't clone this room!")
|
||||||
|
return roomEnvelope(c, room)
|
||||||
}
|
}
|
||||||
// Cloning is how a player makes a room, so the per-account cap belongs here.
|
)
|
||||||
// Checked after the cheap validations so a rejected name costs no extra D1 read.
|
|
||||||
const maxRooms = intVar(c.env.MAX_ROOMS_PER_ACCOUNT, DEFAULT_MAX_ROOMS_PER_ACCOUNT)
|
|
||||||
if (maxRooms > 0 && (await countRoomsByCreator(c.env.DB, accountId)) >= maxRooms) {
|
|
||||||
logger.info('room create rejected: per-account room limit', { accountId })
|
|
||||||
return roomEnvelope(c, null, `You can only have ${maxRooms} rooms.`)
|
|
||||||
}
|
|
||||||
const room = await cloneRoom(
|
|
||||||
c.env.DB,
|
|
||||||
Number.parseInt(c.req.param('roomId'), 10),
|
|
||||||
name,
|
|
||||||
accountId
|
|
||||||
)
|
|
||||||
if (!room) return roomEnvelope(c, null, "You can't clone this room!")
|
|
||||||
return roomEnvelope(c, room)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Update a room's description. Auth-gated (401) and owner-only. Business results
|
// Update a room's description. Auth-gated (401) and owner-only. Business results
|
||||||
// use the `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
// use the `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
||||||
.put('/rooms/:roomId{[0-9]+}/description', async (c) => {
|
.put(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/description',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Set a room’s description',
|
||||||
|
description: [
|
||||||
|
'Owner-only (the room’s `CreatorAccountId` — co-owners cannot). An unknown room or a',
|
||||||
|
'non-owner is HTTP 200 with `Success: false` and an `ErrorId`; only a missing token is',
|
||||||
|
'a real 401. An absent `description` field clears the description.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
requestBody: form(DescriptionRequest, 'The new description'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.DoesntExist',
|
ErrorId: 'Rooms.DoesntExist',
|
||||||
Error: 'This room does not exist!',
|
Error: 'This room does not exist!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (room.CreatorAccountId !== accountId) {
|
if (room.CreatorAccountId !== accountId) {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.NotOwner',
|
ErrorId: 'Rooms.NotOwner',
|
||||||
Error: 'You are not the owner of this room!',
|
Error: 'You are not the owner of this room!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
const description = typeof body.description === 'string' ? body.description : ''
|
const description = typeof body.description === 'string' ? body.description : ''
|
||||||
await setRoomDescription(c.env.DB, roomId, description)
|
await setRoomDescription(c.env.DB, roomId, description)
|
||||||
return roomResult(c, { Success: true })
|
return roomResult(c, { Success: true })
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Rename a room. Auth-gated (401) and owner-only; the new name must be non-empty
|
// Rename a room. Auth-gated (401) and owner-only; the new name must be non-empty
|
||||||
// and not already taken by another room. Business results use the
|
// and not already taken by another room. Business results use the
|
||||||
// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
||||||
// NOTE: the ErrorId strings (besides Rooms.DoesntExist) are best guesses.
|
// NOTE: the ErrorId strings (besides Rooms.DoesntExist) are best guesses.
|
||||||
.put('/rooms/:roomId{[0-9]+}/name', async (c) => {
|
.put(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/name',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Rename a room',
|
||||||
|
description: [
|
||||||
|
'Owner-only. The new name must be non-empty and not already taken by another room',
|
||||||
|
'(names are compared case-insensitively). Rejections are HTTP 200 with',
|
||||||
|
'`Success: false`.',
|
||||||
|
'',
|
||||||
|
'NOTE: the `ErrorId` strings other than `Rooms.DoesntExist` are best guesses — the',
|
||||||
|
'client only renders `Error`, so they have never been confirmed against the real one.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
requestBody: form(NameRequest, 'The new name'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.DoesntExist',
|
ErrorId: 'Rooms.DoesntExist',
|
||||||
Error: 'This room does not exist!',
|
Error: 'This room does not exist!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (room.CreatorAccountId !== accountId) {
|
if (room.CreatorAccountId !== accountId) {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.NotOwner',
|
ErrorId: 'Rooms.NotOwner',
|
||||||
Error: 'You are not the owner of this room!',
|
Error: 'You are not the owner of this room!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||||
if (name === '') {
|
if (name === '') {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.InvalidName',
|
ErrorId: 'Rooms.InvalidName',
|
||||||
Error: 'You must enter a name for your room!',
|
Error: 'You must enter a name for your room!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject if a different room already uses this name (case-insensitive).
|
// Reject if a different room already uses this name (case-insensitive).
|
||||||
const existing = await getRoomByName(c.env.DB, name)
|
const existing = await getRoomByName(c.env.DB, name)
|
||||||
if (existing && existing.RoomId !== roomId) {
|
if (existing && existing.RoomId !== roomId) {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.AlreadyExists',
|
ErrorId: 'Rooms.AlreadyExists',
|
||||||
Error: 'A room with that name already exists!',
|
Error: 'A room with that name already exists!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await setRoomName(c.env.DB, roomId, name)
|
await setRoomName(c.env.DB, roomId, name)
|
||||||
return roomResult(c, { Success: true })
|
return roomResult(c, { Success: true })
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Toggle a tag on a room. Auth-gated (401) and owner-only. Body is the `tag`
|
// Toggle a tag on a room. Auth-gated (401) and owner-only. Body is the `tag`
|
||||||
// form field. There's no delete/patch endpoint, so this call toggles: it adds
|
// form field. There's no delete/patch endpoint, so this call toggles: it adds
|
||||||
@@ -543,103 +941,161 @@ const app = new Hono<App>()
|
|||||||
// (#pvp/#quest/#game/#hangout/#art) are radio buttons — setting one clears the
|
// (#pvp/#quest/#game/#hangout/#art) are radio buttons — setting one clears the
|
||||||
// others. Returns the `{ success, error, value }` envelope with the updated
|
// others. Returns the `{ success, error, value }` envelope with the updated
|
||||||
// room as `value`; business failures are 200 with success:false.
|
// room as `value`; business failures are 200 with success:false.
|
||||||
.put('/rooms/:roomId{[0-9]+}/tags', async (c) => {
|
.put(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/tags',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Toggle a tag on a room',
|
||||||
|
description: [
|
||||||
|
'Owner-only. There is no delete/patch counterpart, so this call TOGGLES: it adds the',
|
||||||
|
'tag (Type 0) when absent and removes it when present. The “main” tags',
|
||||||
|
'(`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio buttons — setting one clears',
|
||||||
|
'the others. Answers the lowercase envelope with the updated room, which the client',
|
||||||
|
're-renders from.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
requestBody: form(TagRequest, 'The tag to toggle'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||||
if (room.CreatorAccountId !== accountId) {
|
if (room.CreatorAccountId !== accountId) {
|
||||||
return roomEnvelope(c, null, 'You are not the owner of this room!')
|
return roomEnvelope(c, null, 'You are not the owner of this room!')
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
|
const tag = typeof body.tag === 'string' ? body.tag.trim() : ''
|
||||||
|
if (tag === '') return roomEnvelope(c, null, 'You must provide a tag!')
|
||||||
|
|
||||||
|
const updated = await toggleRoomTag(c.env.DB, roomId, room, tag)
|
||||||
|
return roomEnvelope(c, updated)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
|
||||||
const tag = typeof body.tag === 'string' ? body.tag.trim() : ''
|
|
||||||
if (tag === '') return roomEnvelope(c, null, 'You must provide a tag!')
|
|
||||||
|
|
||||||
const updated = await toggleRoomTag(c.env.DB, roomId, room, tag)
|
|
||||||
return roomEnvelope(c, updated)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set a room's image. Auth-gated (401) and owner-only. Body is the `imageName`
|
// Set a room's image. Auth-gated (401) and owner-only. Body is the `imageName`
|
||||||
// form field (a key from the storage/image upload). Business results use the
|
// form field (a key from the storage/image upload). Business results use the
|
||||||
// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
||||||
.put('/rooms/:roomId{[0-9]+}/image', async (c) => {
|
.put(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/image',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Set a room’s image',
|
||||||
|
description: [
|
||||||
|
'Owner-only. `imageName` is a key from the storage upload, stored un-prefixed (the',
|
||||||
|
'`cdn` worker serves it back under `room/`). Pushes a `RoomUpdate` to the owner so',
|
||||||
|
'their client re-renders with the new image.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
requestBody: form(ImageRequest, 'The uploaded image key'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.DoesntExist',
|
ErrorId: 'Rooms.DoesntExist',
|
||||||
Error: 'This room does not exist!',
|
Error: 'This room does not exist!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (room.CreatorAccountId !== accountId) {
|
if (room.CreatorAccountId !== accountId) {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.NotOwner',
|
ErrorId: 'Rooms.NotOwner',
|
||||||
Error: 'You are not the owner of this room!',
|
Error: 'You are not the owner of this room!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
|
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
|
||||||
if (imageName === '') {
|
if (imageName === '') {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.InvalidImage',
|
ErrorId: 'Rooms.InvalidImage',
|
||||||
Error: 'You must provide an image!',
|
Error: 'You must provide an image!',
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
await setRoomImage(c.env.DB, roomId, imageName)
|
||||||
|
// Notify the owner so their client refreshes the room (RoomUpdate carries the
|
||||||
|
// updated room). The reference sends the post-update room, so merge the change.
|
||||||
|
await pushRoomUpdate(c, accountId, { ...room, ImageName: imageName })
|
||||||
|
return roomResult(c, { Success: true })
|
||||||
}
|
}
|
||||||
await setRoomImage(c.env.DB, roomId, imageName)
|
)
|
||||||
// Notify the owner so their client refreshes the room (RoomUpdate carries the
|
|
||||||
// updated room). The reference sends the post-update room, so merge the change.
|
|
||||||
await pushRoomUpdate(c, accountId, { ...room, ImageName: imageName })
|
|
||||||
return roomResult(c, { Success: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Delete a room. Auth-gated (401) and owner-only (the room's CreatorAccountId).
|
// Delete a room. Auth-gated (401) and owner-only (the room's CreatorAccountId).
|
||||||
// Removes the room record (and per-player interactions with it) and the room's
|
// Removes the room record (and per-player interactions with it) and the room's
|
||||||
// image object from the shared CDN bucket. Images players *took* in the room are
|
// image object from the shared CDN bucket. Images players *took* in the room are
|
||||||
// left alone — they live in the api/img world and outlast the room.
|
// left alone — they live in the api/img world and outlast the room.
|
||||||
.delete('/rooms/:roomId{[0-9]+}', async (c) => {
|
.delete(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Delete a room',
|
||||||
|
description: [
|
||||||
|
'Owner-only. Removes the room record, the per-player interactions with it, and the',
|
||||||
|
'room’s image object from the shared CDN bucket. Photos players TOOK in the room are',
|
||||||
|
'left alone — those live in the api/img world and outlive the room.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
responses: {
|
||||||
|
200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.DoesntExist',
|
ErrorId: 'Rooms.DoesntExist',
|
||||||
Error: 'This room does not exist!',
|
Error: 'This room does not exist!',
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
if (room.CreatorAccountId !== accountId) {
|
||||||
|
return roomResult(c, {
|
||||||
|
Success: false,
|
||||||
|
ErrorId: 'Rooms.NotOwner',
|
||||||
|
Error: 'You are not the owner of this room!',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteRoom(c.env.DB, roomId)
|
||||||
|
|
||||||
|
// Remove the room image from the CDN bucket. The stored ImageName is the
|
||||||
|
// un-prefixed key the `cdn` worker serves back under `room/` (see storage
|
||||||
|
// upload + the `GET /room/:dataBlob` route), so the object key is `room/<name>`.
|
||||||
|
// R2 deletes are idempotent, so a canonical/static or already-gone image is fine.
|
||||||
|
const imageName = typeof room.ImageName === 'string' ? room.ImageName : ''
|
||||||
|
if (imageName !== '') {
|
||||||
|
await c.env.CDN_ASSETS.delete(`room/${imageName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return roomResult(c, { Success: true })
|
||||||
}
|
}
|
||||||
if (room.CreatorAccountId !== accountId) {
|
)
|
||||||
return roomResult(c, {
|
|
||||||
Success: false,
|
|
||||||
ErrorId: 'Rooms.NotOwner',
|
|
||||||
Error: 'You are not the owner of this room!',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteRoom(c.env.DB, roomId)
|
|
||||||
|
|
||||||
// Remove the room image from the CDN bucket. The stored ImageName is the
|
|
||||||
// un-prefixed key the `cdn` worker serves back under `room/` (see storage
|
|
||||||
// upload + the `GET /room/:dataBlob` route), so the object key is `room/<name>`.
|
|
||||||
// R2 deletes are idempotent, so a canonical/static or already-gone image is fine.
|
|
||||||
const imageName = typeof room.ImageName === 'string' ? room.ImageName : ''
|
|
||||||
if (imageName !== '') {
|
|
||||||
await c.env.CDN_ASSETS.delete(`room/${imageName}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return roomResult(c, { Success: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set a member's role in a room (`Roles[].Role`). Auth-gated (401) and gated to
|
// Set a member's role in a room (`Roles[].Role`). Auth-gated (401) and gated to
|
||||||
// the room creator or a co-owner (403 otherwise) — the same owner/co-owner check
|
// the room creator or a co-owner (403 otherwise) — the same owner/co-owner check
|
||||||
@@ -647,191 +1103,362 @@ const app = new Hono<App>()
|
|||||||
// tier). Updates the target account's existing role entry or adds one, notifies the
|
// tier). Updates the target account's existing role entry or adds one, notifies the
|
||||||
// affected member so their client refreshes permissions, and returns the updated
|
// affected member so their client refreshes permissions, and returns the updated
|
||||||
// room in the lowercase `{ success, error, value }` envelope.
|
// room in the lowercase `{ success, error, value }` envelope.
|
||||||
.put('/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}', async (c) => {
|
.put(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Set a member’s role in a room',
|
||||||
|
description: [
|
||||||
|
'Updates the target account’s entry in the room’s `Roles` (or adds one). Gated to the',
|
||||||
|
'room’s creator or a co-owner — a valid token from anyone else is a 403. The affected',
|
||||||
|
'MEMBER gets the `RoomUpdate` push, not the caller, so their client refreshes the',
|
||||||
|
'permissions it just gained or lost.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [
|
||||||
|
roomIdParam,
|
||||||
|
{
|
||||||
|
name: 'accountId',
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
description: 'The member whose role changes',
|
||||||
|
schema: { type: 'string', pattern: '^[0-9]+$' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requestBody: form(RoleRequest, 'The role tier to grant'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomEnvelope, 'The updated room, 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 roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const targetAccountId = Number.parseInt(c.req.param('accountId'), 10)
|
const targetAccountId = Number.parseInt(c.req.param('accountId'), 10)
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||||
// already returned 401 for a missing/invalid token).
|
// already returned 401 for a missing/invalid token).
|
||||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
const role = typeof body.role === 'string' ? Number.parseInt(body.role, 10) : Number.NaN
|
const role = typeof body.role === 'string' ? Number.parseInt(body.role, 10) : Number.NaN
|
||||||
if (Number.isNaN(role)) return roomEnvelope(c, null, 'You must provide a valid role!')
|
if (Number.isNaN(role)) return roomEnvelope(c, null, 'You must provide a valid role!')
|
||||||
|
|
||||||
const updated = await setRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
|
const updated = await setRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
|
||||||
// Notify the member whose role changed so their client refreshes the room
|
// Notify the member whose role changed so their client refreshes the room
|
||||||
// (and the permissions it grants them).
|
// (and the permissions it grants them).
|
||||||
await pushRoomUpdate(c, targetAccountId, updated)
|
await pushRoomUpdate(c, targetAccountId, updated)
|
||||||
return roomEnvelope(c, updated)
|
return roomEnvelope(c, updated)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Set a room's content warning: the `WarningMask` bit flags plus an optional
|
// Set a room's content warning: the `WarningMask` bit flags plus an optional
|
||||||
// free-text `CustomWarning`. Auth-gated (401) and owner/co-owner-only (403). Body is
|
// free-text `CustomWarning`. Auth-gated (401) and owner/co-owner-only (403). Body is
|
||||||
// the `warningMask` form field (an integer) and an optional `customWarning` string
|
// the `warningMask` form field (an integer) and an optional `customWarning` string
|
||||||
// (set when present — an empty value clears it). Returns the updated room in the
|
// (set when present — an empty value clears it). Returns the updated room in the
|
||||||
// `{ success, error, value }` envelope.
|
// `{ success, error, value }` envelope.
|
||||||
.put('/rooms/:roomId{[0-9]+}/warning', async (c) => {
|
.put(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/warning',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Set a room’s content warning',
|
||||||
|
description: [
|
||||||
|
'The `WarningMask` bit flags plus an optional free-text `CustomWarning`. Owner or',
|
||||||
|
'co-owner only (403 otherwise). `CustomWarning` is only touched when the field is',
|
||||||
|
'present — sending it empty clears it, omitting it leaves it alone.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
requestBody: form(WarningRequest, 'The warning flags and optional custom text'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomEnvelope, 'The updated room, 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 roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||||
// already returned 401 for a missing/invalid token).
|
// already returned 401 for a missing/invalid token).
|
||||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
const warningMask =
|
const warningMask =
|
||||||
typeof body.warningMask === 'string' ? Number.parseInt(body.warningMask, 10) : Number.NaN
|
typeof body.warningMask === 'string' ? Number.parseInt(body.warningMask, 10) : Number.NaN
|
||||||
if (Number.isNaN(warningMask))
|
if (Number.isNaN(warningMask))
|
||||||
return roomEnvelope(c, null, 'You must provide a valid warning mask!')
|
return roomEnvelope(c, null, 'You must provide a valid warning mask!')
|
||||||
|
|
||||||
const patch: Record<string, unknown> = { WarningMask: warningMask }
|
const patch: Record<string, unknown> = { WarningMask: warningMask }
|
||||||
// Only touch CustomWarning when the field is present (an empty string clears it).
|
// Only touch CustomWarning when the field is present (an empty string clears it).
|
||||||
if (typeof body.customWarning === 'string') patch.CustomWarning = body.customWarning
|
if (typeof body.customWarning === 'string') patch.CustomWarning = body.customWarning
|
||||||
|
|
||||||
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
|
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
|
||||||
// Notify the owner so their client refreshes the room with the updated warning.
|
// Notify the owner so their client refreshes the room with the updated warning.
|
||||||
await pushRoomUpdate(c, accountId, updated)
|
await pushRoomUpdate(c, accountId, updated)
|
||||||
return roomEnvelope(c, updated)
|
return roomEnvelope(c, updated)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Toggle whether a room may be cloned (`CloningAllowed`). Auth-gated (401) and
|
// Toggle whether a room may be cloned (`CloningAllowed`). Auth-gated (401) and
|
||||||
// owner/co-owner-only (403). Body is the `cloningAllowed` form field (`True`/`False`).
|
// owner/co-owner-only (403). Body is the `cloningAllowed` form field (`True`/`False`).
|
||||||
// Returns the updated room in the `{ success, error, value }` envelope.
|
// Returns the updated room in the `{ success, error, value }` envelope.
|
||||||
.put('/rooms/:roomId{[0-9]+}/cloning', async (c) => {
|
.put(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/cloning',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Allow or block cloning of a room',
|
||||||
|
description: [
|
||||||
|
'Sets `CloningAllowed` — false makes `POST /rooms/{roomId}/clone` refuse. Owner or',
|
||||||
|
'co-owner only (403 otherwise).',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
requestBody: form(CloningRequest, 'Whether cloning is allowed'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomEnvelope, 'The updated room, 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 roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||||
// already returned 401 for a missing/invalid token).
|
// already returned 401 for a missing/invalid token).
|
||||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
if (typeof body.cloningAllowed !== 'string') {
|
if (typeof body.cloningAllowed !== 'string') {
|
||||||
return roomEnvelope(c, null, 'You must provide cloningAllowed.')
|
return roomEnvelope(c, null, 'You must provide cloningAllowed.')
|
||||||
|
}
|
||||||
|
const cloningAllowed = body.cloningAllowed.toLowerCase() === 'true'
|
||||||
|
|
||||||
|
const updated = await updateRoomFields(c.env.DB, roomId, room, {
|
||||||
|
CloningAllowed: cloningAllowed,
|
||||||
|
})
|
||||||
|
await pushRoomUpdate(c, accountId, updated)
|
||||||
|
return roomEnvelope(c, updated)
|
||||||
}
|
}
|
||||||
const cloningAllowed = body.cloningAllowed.toLowerCase() === 'true'
|
)
|
||||||
|
|
||||||
const updated = await updateRoomFields(c.env.DB, roomId, room, {
|
|
||||||
CloningAllowed: cloningAllowed,
|
|
||||||
})
|
|
||||||
await pushRoomUpdate(c, accountId, updated)
|
|
||||||
return roomEnvelope(c, updated)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set a room's platform/movement support flags (its `Supports*` restrictions).
|
// Set a room's platform/movement support flags (its `Supports*` restrictions).
|
||||||
// Auth-gated (401) and owner/co-owner-only (403). Body is a form of
|
// Auth-gated (401) and owner/co-owner-only (403). Body is a form of
|
||||||
// `supports*=True|False` fields (see RESTRICTION_FIELDS); only the fields present
|
// `supports*=True|False` fields (see RESTRICTION_FIELDS); only the fields present
|
||||||
// are changed. Returns the updated room in the `{ success, error, value }` envelope.
|
// are changed. Returns the updated room in the `{ success, error, value }` envelope.
|
||||||
.put('/rooms/:roomId{[0-9]+}/restrictions', async (c) => {
|
.put(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/restrictions',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Set a room’s platform/movement support flags',
|
||||||
|
description: [
|
||||||
|
'The room’s `Supports*` restrictions — which platforms and movement modes may enter.',
|
||||||
|
'Owner or co-owner only (403 otherwise). Only the fields actually posted are changed,',
|
||||||
|
'and field names are matched case-insensitively.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
requestBody: form(RestrictionsRequest, 'The flags to change'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomEnvelope, 'The updated room, 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 roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||||
// already returned 401 for a missing/invalid token).
|
// already returned 401 for a missing/invalid token).
|
||||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
const patch: Record<string, boolean> = {}
|
const patch: Record<string, boolean> = {}
|
||||||
for (const [key, value] of Object.entries(body)) {
|
for (const [key, value] of Object.entries(body)) {
|
||||||
const field = RESTRICTION_FIELDS[key.toLowerCase()]
|
const field = RESTRICTION_FIELDS[key.toLowerCase()]
|
||||||
if (field !== undefined && typeof value === 'string') {
|
if (field !== undefined && typeof value === 'string') {
|
||||||
patch[field] = value.toLowerCase() === 'true'
|
patch[field] = value.toLowerCase() === 'true'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
|
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
|
||||||
await pushRoomUpdate(c, accountId, updated)
|
await pushRoomUpdate(c, accountId, updated)
|
||||||
return roomEnvelope(c, updated)
|
return roomEnvelope(c, updated)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Add a load screen to a room (`LoadScreens[]` — the images shown while the room
|
// 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`
|
// loads). Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName`
|
||||||
// form field plus optional `title`/`subtitle`. Appends one
|
// form field plus optional `title`/`subtitle`. Appends one
|
||||||
// `{ ImageName, Title, Subtitle }` to the existing list and returns the updated
|
// `{ ImageName, Title, Subtitle }` to the existing list and returns the updated
|
||||||
// room in the `{ success, error, value }` envelope.
|
// room in the `{ success, error, value }` envelope.
|
||||||
.put('/rooms/:roomId{[0-9]+}/loadscreen', async (c) => {
|
.put(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/loadscreen',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Add a load screen to a room',
|
||||||
|
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).',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
requestBody: form(LoadScreenRequest, 'The load screen to append'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomEnvelope, 'The updated room, 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 roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||||
// already returned 401 for a missing/invalid token).
|
// already returned 401 for a missing/invalid token).
|
||||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
|
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
|
||||||
if (imageName === '') return roomEnvelope(c, null, 'You must provide an image!')
|
if (imageName === '') return roomEnvelope(c, null, 'You must provide an image!')
|
||||||
const title = typeof body.title === 'string' ? body.title : ''
|
const title = typeof body.title === 'string' ? body.title : ''
|
||||||
const subtitle = typeof body.subtitle === 'string' ? body.subtitle : ''
|
const subtitle = typeof body.subtitle === 'string' ? body.subtitle : ''
|
||||||
|
|
||||||
const existing = Array.isArray(room.LoadScreens) ? (room.LoadScreens as unknown[]) : []
|
const existing = Array.isArray(room.LoadScreens) ? (room.LoadScreens as unknown[]) : []
|
||||||
const loadScreens = [...existing, { ImageName: imageName, Title: title, Subtitle: subtitle }]
|
const loadScreens = [...existing, { ImageName: imageName, Title: title, Subtitle: subtitle }]
|
||||||
const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens })
|
const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens })
|
||||||
await pushRoomUpdate(c, accountId, updated)
|
await pushRoomUpdate(c, accountId, updated)
|
||||||
return roomEnvelope(c, updated)
|
return roomEnvelope(c, updated)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Set a room's top-level `Accessibility` (the visibility the public-room/search
|
// Set a room's top-level `Accessibility` (the visibility the public-room/search
|
||||||
// filters key on — see the RoomAccessibility enum). Auth-gated (401) and
|
// filters key on — see the RoomAccessibility enum). Auth-gated (401) and
|
||||||
// owner/co-owner-only (403). Body is the `accessibility` form field (an integer).
|
// owner/co-owner-only (403). Body is the `accessibility` form field (an integer).
|
||||||
// Returns the updated room in the `{ success, error, value }` envelope.
|
// Returns the updated room in the `{ success, error, value }` envelope.
|
||||||
.put('/rooms/:roomId{[0-9]+}/accessibility', async (c) => {
|
.put(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/accessibility',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'Set a room’s accessibility',
|
||||||
|
description: [
|
||||||
|
'The room’s top-level visibility — the field the public-room and search filters key',
|
||||||
|
'on (0 Private, 1 Public, 2 Unlisted). Owner or co-owner only (403 otherwise).',
|
||||||
|
'Subrooms carry their own `Accessibility`, set through the subroom `modify` call.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
requestBody: form(AccessibilityRequest, 'The new accessibility'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomEnvelope, 'The updated room, 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 roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||||
// already returned 401 for a missing/invalid token).
|
// already returned 401 for a missing/invalid token).
|
||||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
const accessibility =
|
const accessibility =
|
||||||
typeof body.accessibility === 'string' ? Number.parseInt(body.accessibility, 10) : Number.NaN
|
typeof body.accessibility === 'string'
|
||||||
if (Number.isNaN(accessibility)) {
|
? Number.parseInt(body.accessibility, 10)
|
||||||
return roomEnvelope(c, null, 'You must provide a valid accessibility!')
|
: Number.NaN
|
||||||
|
if (Number.isNaN(accessibility)) {
|
||||||
|
return roomEnvelope(c, null, 'You must provide a valid accessibility!')
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateRoomFields(c.env.DB, roomId, room, {
|
||||||
|
Accessibility: accessibility,
|
||||||
|
})
|
||||||
|
await pushRoomUpdate(c, accountId, updated)
|
||||||
|
return roomEnvelope(c, updated)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
const updated = await updateRoomFields(c.env.DB, roomId, room, { Accessibility: accessibility })
|
|
||||||
await pushRoomUpdate(c, accountId, updated)
|
|
||||||
return roomEnvelope(c, updated)
|
|
||||||
})
|
|
||||||
|
|
||||||
// A subroom's data descriptor (the SubRoom object from the room's SubRooms
|
// A subroom's data descriptor (the SubRoom object from the room's SubRooms
|
||||||
// array). Public — the client fetches it while loading the room. 404 when the
|
// array). Public — the client fetches it while loading the room. 404 when the
|
||||||
// room or subroom is unknown.
|
// room or subroom is unknown.
|
||||||
.get('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data', async (c) => {
|
.get(
|
||||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data',
|
||||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
describeRoute({
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
tags: ['Subrooms'],
|
||||||
const sub = room ? findSubRoom(room, subRoomId) : undefined
|
summary: 'A subroom’s data descriptor',
|
||||||
return sub ? c.json(sub) : c.notFound()
|
description: [
|
||||||
})
|
'The `SubRoom` object from the room’s `SubRooms` array — the descriptor the client',
|
||||||
|
'fetches while loading the room, carrying the scene id and the saved-data blob keys.',
|
||||||
|
'Public; an unknown room or subroom is a 404.',
|
||||||
|
].join(' '),
|
||||||
|
parameters: [roomIdParam, subRoomIdParam],
|
||||||
|
responses: {
|
||||||
|
200: json(SubRoomDto, 'The subroom'),
|
||||||
|
404: { description: 'No such room or subroom' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
const sub = room ? findSubRoom(room, subRoomId) : undefined
|
||||||
|
return sub ? c.json(sub) : c.notFound()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// A subroom's saved-data versions — the room-history / "restore a save" list, paged as
|
// A subroom's saved-data versions — the room-history / "restore a save" list, paged as
|
||||||
// PagedResultsDTO<SubRoomDataSaveDTO> (`{ Results, TotalResults }`). We don't keep a save
|
// PagedResultsDTO<SubRoomDataSaveDTO> (`{ Results, TotalResults }`). We don't keep a save
|
||||||
// history yet: a save (POST …/data) overwrites the current blob inline on the subroom, so
|
// history yet: a save (POST …/data) overwrites the current blob inline on the subroom, so
|
||||||
// there are no distinct versions to list — this returns an empty page. The
|
// there are no distinct versions to list — this returns an empty page. The
|
||||||
// unityAssetTarget/unityAssetVersion/skip/take query params are accepted and ignored.
|
// unityAssetTarget/unityAssetVersion/skip/take query params are accepted and ignored.
|
||||||
.get('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves', (c) =>
|
.get(
|
||||||
c.json({ Results: [], TotalResults: 0 })
|
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Subrooms'],
|
||||||
|
summary: 'A subroom’s saved-data versions',
|
||||||
|
description: [
|
||||||
|
'The room-history / “restore a save” list, paged as',
|
||||||
|
'`PagedResultsDTO<SubRoomDataSaveDTO>`. We keep no save history — a save (`POST',
|
||||||
|
'…/data`) overwrites the subroom’s current blob inline, so there are no distinct',
|
||||||
|
'versions to list — and this is always an empty page. The',
|
||||||
|
'`unityAssetTarget`/`unityAssetVersion`/`skip`/`take` params are accepted and ignored.',
|
||||||
|
].join(' '),
|
||||||
|
parameters: [
|
||||||
|
roomIdParam,
|
||||||
|
subRoomIdParam,
|
||||||
|
stringQuery('unityAssetTarget', 'Accepted and ignored'),
|
||||||
|
stringQuery('unityAssetVersion', 'Accepted and ignored'),
|
||||||
|
stringQuery('skip', 'Accepted and ignored — the page is always empty'),
|
||||||
|
stringQuery('take', 'Accepted and ignored — the page is always empty'),
|
||||||
|
],
|
||||||
|
responses: { 200: json(SubRoomSavesPage, 'Always an empty page') },
|
||||||
|
}),
|
||||||
|
(c) => c.json({ Results: [], TotalResults: 0 })
|
||||||
)
|
)
|
||||||
|
|
||||||
// Save a subroom's data (room save). Auth-gated (401 with empty body). Editable
|
// Save a subroom's data (room save). Auth-gated (401 with empty body). Editable
|
||||||
@@ -839,234 +1466,476 @@ const app = new Hono<App>()
|
|||||||
// the uploaded data blobs and records the room-level save fields, notifies the
|
// the uploaded data blobs and records the room-level save fields, notifies the
|
||||||
// owner, and returns the updated ROOM in the lowercase `{ success, error, value }`
|
// owner, and returns the updated ROOM in the lowercase `{ success, error, value }`
|
||||||
// envelope the reference's SetRoomData uses.
|
// envelope the reference's SetRoomData uses.
|
||||||
.post('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data', async (c) => {
|
.post(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data',
|
||||||
if (accountId === null) return c.body(null, 401)
|
describeRoute({
|
||||||
|
tags: ['Subrooms'],
|
||||||
|
summary: 'Save a subroom’s data (room save)',
|
||||||
|
description: [
|
||||||
|
'Points the subroom at the blobs the client has already uploaded through the `storage`',
|
||||||
|
'worker and stamps the save; the room-level fields the save carries (`Description`,',
|
||||||
|
'`PersistenceVersion`, `InventionUsage`) are written to the room. Editable by the',
|
||||||
|
'room’s creator or a co-owner (403 otherwise); a missing token is an EMPTY-body 401,',
|
||||||
|
'unlike the other room writes.',
|
||||||
|
'',
|
||||||
|
'The push notification carries the whole room, but the RESPONSE is the saved SUBROOM',
|
||||||
|
'itself with no envelope — the client deserializes the body directly as the subroom.',
|
||||||
|
'A subroom with no `CreatorAccountId` yet (the seeded rooms start null) gets the',
|
||||||
|
'saver’s id here, because the client NREs on a null one.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam, subRoomIdParam],
|
||||||
|
requestBody: jsonBody(SaveSubRoomDataRequest, 'The uploaded blob keys and save fields'),
|
||||||
|
responses: {
|
||||||
|
200: json(
|
||||||
|
SubRoomSaveResult,
|
||||||
|
'The saved subroom, or the result envelope when the room/subroom is unknown'
|
||||||
|
),
|
||||||
|
401: UNAUTHORIZED_EMPTY,
|
||||||
|
403: FORBIDDEN_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return c.body(null, 401)
|
||||||
|
|
||||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||||
|
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.DoesntExist',
|
ErrorId: 'Rooms.DoesntExist',
|
||||||
Error: 'This room does not exist!',
|
Error: 'This room does not exist!',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||||
|
// already returned 401 for a missing/invalid token).
|
||||||
|
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||||
|
|
||||||
|
const body = (await c.req.json().catch(() => ({}))) as {
|
||||||
|
RoomData?: { Filename?: string }
|
||||||
|
SubRoomData?: { Filename?: string }
|
||||||
|
Description?: string
|
||||||
|
PersistenceVersion?: number
|
||||||
|
InventionUsage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, {
|
||||||
|
subRoomDataFilename: body.SubRoomData?.Filename,
|
||||||
|
roomDataFilename: body.RoomData?.Filename,
|
||||||
|
description: typeof body.Description === 'string' ? body.Description : undefined,
|
||||||
|
persistenceVersion:
|
||||||
|
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
|
||||||
|
inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined,
|
||||||
})
|
})
|
||||||
}
|
if (!updated) {
|
||||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
return roomResult(c, {
|
||||||
// already returned 401 for a missing/invalid token).
|
Success: false,
|
||||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
ErrorId: 'Rooms.DoesntExist',
|
||||||
|
Error: 'This room does not exist!',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const body = (await c.req.json().catch(() => ({}))) as {
|
// RoomUpdate carries the full room, but the HTTP response is the saved SUBROOM
|
||||||
RoomData?: { Filename?: string }
|
// itself — no envelope. The client deserializes the body directly as the subroom.
|
||||||
SubRoomData?: { Filename?: string }
|
await pushRoomUpdate(c, accountId, updated)
|
||||||
Description?: string
|
return c.json(findSubRoom(updated, subRoomId) ?? {})
|
||||||
PersistenceVersion?: number
|
|
||||||
InventionUsage?: string
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
const updated = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, {
|
|
||||||
subRoomDataFilename: body.SubRoomData?.Filename,
|
|
||||||
roomDataFilename: body.RoomData?.Filename,
|
|
||||||
description: typeof body.Description === 'string' ? body.Description : undefined,
|
|
||||||
persistenceVersion:
|
|
||||||
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
|
|
||||||
inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined,
|
|
||||||
})
|
|
||||||
if (!updated) {
|
|
||||||
return roomResult(c, {
|
|
||||||
Success: false,
|
|
||||||
ErrorId: 'Rooms.DoesntExist',
|
|
||||||
Error: 'This room does not exist!',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RoomUpdate carries the full room, but the HTTP response is the saved SUBROOM
|
|
||||||
// itself — no envelope. The client deserializes the body directly as the subroom.
|
|
||||||
await pushRoomUpdate(c, accountId, updated)
|
|
||||||
return c.json(findSubRoom(updated, subRoomId) ?? {})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Modify a subroom's settings (Name/Accessibility/MaxPlayers) from the form body.
|
// Modify a subroom's settings (Name/Accessibility/MaxPlayers) from the form body.
|
||||||
// Auth-gated (401) and owner-only — only the room creator may change its subrooms.
|
// Auth-gated (401) and owner-only — only the room creator may change its subrooms.
|
||||||
// Notifies the owner (RoomUpdate) and returns the `{ Success, Value, ErrorId, Error }`
|
// Notifies the owner (RoomUpdate) and returns the `{ Success, Value, ErrorId, Error }`
|
||||||
// envelope at HTTP 200, matching the other owner-gated room mutations.
|
// envelope at HTTP 200, matching the other owner-gated room mutations.
|
||||||
.put('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/modify', async (c) => {
|
.put(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/modify',
|
||||||
if (accountId === null) return unauthorized(c)
|
describeRoute({
|
||||||
|
tags: ['Subrooms'],
|
||||||
|
summary: 'Modify a subroom’s settings',
|
||||||
|
description: [
|
||||||
|
'Sets a subroom’s `Name`, `Accessibility` and `MaxPlayers`. Owner-only — only the',
|
||||||
|
'room’s creator may change its subrooms, not co-owners. `name` is required;',
|
||||||
|
'`accessibility` and `maxPlayers` are applied only when supplied, and a non-positive',
|
||||||
|
'`maxPlayers` is ignored rather than rejected.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam, subRoomIdParam],
|
||||||
|
requestBody: form(ModifySubRoomRequest, 'The settings to change'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) return unauthorized(c)
|
||||||
|
|
||||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||||
|
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.DoesntExist',
|
ErrorId: 'Rooms.DoesntExist',
|
||||||
Error: 'This room does not exist!',
|
Error: 'This room does not exist!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (room.CreatorAccountId !== accountId) {
|
if (room.CreatorAccountId !== accountId) {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.NotOwner',
|
ErrorId: 'Rooms.NotOwner',
|
||||||
Error: 'You are not the owner of this room!',
|
Error: 'You are not the owner of this room!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!findSubRoom(room, subRoomId)) {
|
if (!findSubRoom(room, subRoomId)) {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.DoesntExist',
|
ErrorId: 'Rooms.DoesntExist',
|
||||||
Error: 'This subroom does not exist!',
|
Error: 'This subroom does not exist!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||||
if (name === '') {
|
if (name === '') {
|
||||||
return roomResult(c, {
|
return roomResult(c, {
|
||||||
Success: false,
|
Success: false,
|
||||||
ErrorId: 'Rooms.InvalidName',
|
ErrorId: 'Rooms.InvalidName',
|
||||||
Error: 'You must enter a name for your room!',
|
Error: 'You must enter a name for your room!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const accessibility =
|
const accessibility =
|
||||||
typeof body.accessibility === 'string' ? Number.parseInt(body.accessibility, 10) : Number.NaN
|
typeof body.accessibility === 'string'
|
||||||
const maxPlayers =
|
? Number.parseInt(body.accessibility, 10)
|
||||||
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
|
: Number.NaN
|
||||||
|
const maxPlayers =
|
||||||
|
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
|
||||||
|
|
||||||
const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, {
|
const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, {
|
||||||
name,
|
name,
|
||||||
accessibility: Number.isNaN(accessibility) ? undefined : accessibility,
|
accessibility: Number.isNaN(accessibility) ? undefined : accessibility,
|
||||||
maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers,
|
maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers,
|
||||||
})
|
|
||||||
if (!updated) {
|
|
||||||
return roomResult(c, {
|
|
||||||
Success: false,
|
|
||||||
ErrorId: 'Rooms.DoesntExist',
|
|
||||||
Error: 'This subroom does not exist!',
|
|
||||||
})
|
})
|
||||||
}
|
if (!updated) {
|
||||||
|
return roomResult(c, {
|
||||||
|
Success: false,
|
||||||
|
ErrorId: 'Rooms.DoesntExist',
|
||||||
|
Error: 'This subroom does not exist!',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
await pushRoomUpdate(c, accountId, updated)
|
await pushRoomUpdate(c, accountId, updated)
|
||||||
return roomResult(c, { Success: true })
|
return roomResult(c, { Success: true })
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Clone a subroom into a new subroom of the same room (fresh SubRoomId, same
|
// Clone a subroom into a new subroom of the same room (fresh SubRoomId, same
|
||||||
// scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and
|
// scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and
|
||||||
// returns the `{ success, error, value }` envelope with the new subroom as `value`,
|
// returns the `{ success, error, value }` envelope with the new subroom as `value`,
|
||||||
// mirroring the room-level `/clone`. Response shape is a best guess (the real
|
// mirroring the room-level `/clone`. Response shape is a best guess (the real
|
||||||
// client's expected body is unknown).
|
// client's expected body is unknown).
|
||||||
.post('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/clone', async (c) => {
|
.post(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/clone',
|
||||||
if (accountId === null) {
|
describeRoute({
|
||||||
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
|
tags: ['Subrooms'],
|
||||||
|
summary: 'Clone a subroom',
|
||||||
|
description: [
|
||||||
|
'Copies a subroom into a new subroom of the SAME room — same scene, settings and saved',
|
||||||
|
'data blobs, so it loads identical content — with a fresh globally-unique `SubRoomId`.',
|
||||||
|
'Owner-only.',
|
||||||
|
'',
|
||||||
|
'The response shape is a best guess: it mirrors the room-level `/clone` envelope, but',
|
||||||
|
'the real client’s expected body for this call is unknown.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam, subRoomIdParam],
|
||||||
|
responses: {
|
||||||
|
200: json(SubRoomEnvelope, 'The new subroom, or a rejection with `success: false`'),
|
||||||
|
401: UNAUTHORIZED_ENVELOPE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) {
|
||||||
|
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||||
|
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||||
|
if (room.CreatorAccountId !== accountId) {
|
||||||
|
return roomEnvelope(c, null, 'You are not the owner of this room!')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cloneSubRoom(c.env.DB, roomId, subRoomId, accountId)
|
||||||
|
if (!result) return roomEnvelope(c, null, 'This subroom does not exist!')
|
||||||
|
|
||||||
|
await pushRoomUpdate(c, accountId, result.room)
|
||||||
|
return roomEnvelope(c, result.subRoom)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
|
||||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
|
||||||
|
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
|
||||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
|
||||||
if (room.CreatorAccountId !== accountId) {
|
|
||||||
return roomEnvelope(c, null, 'You are not the owner of this room!')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await cloneSubRoom(c.env.DB, roomId, subRoomId, accountId)
|
|
||||||
if (!result) return roomEnvelope(c, null, 'This subroom does not exist!')
|
|
||||||
|
|
||||||
await pushRoomUpdate(c, accountId, result.room)
|
|
||||||
return roomEnvelope(c, result.subRoom)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create a new (empty) subroom in a room (form body `name`). Auth-gated (401) and
|
// Create a new (empty) subroom in a room (form body `name`). Auth-gated (401) and
|
||||||
// owner-only. Mints a fresh globally-unique SubRoomId, bases the scene/capacity on the
|
// owner-only. Mints a fresh globally-unique SubRoomId, bases the scene/capacity on the
|
||||||
// room's first subroom, notifies the owner (RoomUpdate), and returns the updated ROOM
|
// room's first subroom, notifies the owner (RoomUpdate), and returns the updated ROOM
|
||||||
// in the `{ success, error, value }` envelope (the client re-renders the room's subroom
|
// in the `{ success, error, value }` envelope (the client re-renders the room's subroom
|
||||||
// list from `value`, so it's the whole room, not the bare subroom).
|
// list from `value`, so it's the whole room, not the bare subroom).
|
||||||
.post('/rooms/:roomId{[0-9]+}/subrooms', async (c) => {
|
.post(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/subrooms',
|
||||||
if (accountId === null) {
|
describeRoute({
|
||||||
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
|
tags: ['Subrooms'],
|
||||||
|
summary: 'Create a subroom',
|
||||||
|
description: [
|
||||||
|
'Adds an empty subroom to a room. Owner-only. It mints a fresh globally-unique',
|
||||||
|
'`SubRoomId` (the game numbers subrooms from one sequence, not per room) and inherits',
|
||||||
|
'the scene and capacity of the room’s first existing subroom.',
|
||||||
|
'',
|
||||||
|
'Answers the updated ROOM, not the bare subroom — the client re-renders the room’s',
|
||||||
|
'subroom list from `value`. Delete answers the same shape.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
requestBody: form(CreateSubRoomRequest, 'The new subroom’s name'),
|
||||||
|
responses: {
|
||||||
|
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
|
||||||
|
401: UNAUTHORIZED_ENVELOPE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) {
|
||||||
|
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||||
|
if (room.CreatorAccountId !== accountId) {
|
||||||
|
return roomEnvelope(c, null, 'You are not the owner of this room!')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 result = await createSubRoom(c.env.DB, roomId, accountId, name)
|
||||||
|
if (!result) return roomEnvelope(c, null, 'This room does not exist!')
|
||||||
|
|
||||||
|
await pushRoomUpdate(c, accountId, result.room)
|
||||||
|
return roomEnvelope(c, result.room)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
|
||||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
|
||||||
if (room.CreatorAccountId !== accountId) {
|
|
||||||
return roomEnvelope(c, null, 'You are not the owner of this room!')
|
|
||||||
}
|
|
||||||
|
|
||||||
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 result = await createSubRoom(c.env.DB, roomId, accountId, name)
|
|
||||||
if (!result) return roomEnvelope(c, null, 'This room does not exist!')
|
|
||||||
|
|
||||||
await pushRoomUpdate(c, accountId, result.room)
|
|
||||||
return roomEnvelope(c, result.room)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Delete a subroom from a room. Auth-gated (401) and owner-only. Refuses to remove a
|
// Delete a subroom from a room. Auth-gated (401) and owner-only. Refuses to remove a
|
||||||
// room's only subroom. Notifies the owner (RoomUpdate) and returns the updated ROOM in
|
// room's only subroom. Notifies the owner (RoomUpdate) and returns the updated ROOM in
|
||||||
// the `{ success, error, value }` envelope (same shape as create, so the client
|
// the `{ success, error, value }` envelope (same shape as create, so the client
|
||||||
// re-renders the subroom list from `value`).
|
// re-renders the subroom list from `value`).
|
||||||
.delete('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}', async (c) => {
|
.delete(
|
||||||
const accountId = await authedAccountId(c)
|
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}',
|
||||||
if (accountId === null) {
|
describeRoute({
|
||||||
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
|
tags: ['Subrooms'],
|
||||||
|
summary: 'Delete a subroom',
|
||||||
|
description: [
|
||||||
|
'Removes a subroom from a room. Owner-only, and it refuses to remove a room’s only',
|
||||||
|
'subroom — that would leave the room with no scene to load. Any saved-data blob the',
|
||||||
|
'subroom pointed at is left in R2, the same way deleting a room leaves the photos',
|
||||||
|
'taken in it. Answers the updated ROOM, like create.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam, subRoomIdParam],
|
||||||
|
responses: {
|
||||||
|
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
|
||||||
|
401: UNAUTHORIZED_ENVELOPE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const accountId = await authedAccountId(c)
|
||||||
|
if (accountId === null) {
|
||||||
|
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||||
|
|
||||||
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
|
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||||
|
if (room.CreatorAccountId !== accountId) {
|
||||||
|
return roomEnvelope(c, null, 'You are not the owner of this room!')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deleteSubRoom(c.env.DB, roomId, subRoomId)
|
||||||
|
if (!result.ok) {
|
||||||
|
return roomEnvelope(
|
||||||
|
c,
|
||||||
|
null,
|
||||||
|
result.reason === 'last_subroom'
|
||||||
|
? "You can't delete a room's only subroom!"
|
||||||
|
: 'This subroom does not exist!'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await pushRoomUpdate(c, accountId, result.room)
|
||||||
|
return roomEnvelope(c, result.room)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
|
||||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
|
||||||
|
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
|
||||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
|
||||||
if (room.CreatorAccountId !== accountId) {
|
|
||||||
return roomEnvelope(c, null, 'You are not the owner of this room!')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await deleteSubRoom(c.env.DB, roomId, subRoomId)
|
|
||||||
if (!result.ok) {
|
|
||||||
return roomEnvelope(
|
|
||||||
c,
|
|
||||||
null,
|
|
||||||
result.reason === 'last_subroom'
|
|
||||||
? "You can't delete a room's only subroom!"
|
|
||||||
: 'This subroom does not exist!'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await pushRoomUpdate(c, accountId, result.room)
|
|
||||||
return roomEnvelope(c, result.room)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Rooms similar to the given room (sharing tags). Paginated via skip/take (take
|
// Rooms similar to the given room (sharing tags). Paginated via skip/take (take
|
||||||
// defaults to 100). Returns `{ Results, TotalResults }`; empty when the room is
|
// defaults to 100). Returns `{ Results, TotalResults }`; empty when the room is
|
||||||
// unknown/untagged.
|
// unknown/untagged.
|
||||||
.get('/rooms/:roomId{[0-9]+}/similar', async (c) => {
|
.get(
|
||||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
'/rooms/:roomId{[0-9]+}/similar',
|
||||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
describeRoute({
|
||||||
return c.json(
|
tags: ['Discovery'],
|
||||||
await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take)
|
summary: 'Rooms similar to a room',
|
||||||
)
|
description: [
|
||||||
})
|
'Rooms sharing tags with the given one — the “more like this” rail. Empty when the',
|
||||||
|
'room is unknown or carries no tags.',
|
||||||
|
].join(' '),
|
||||||
|
parameters: [roomIdParam, ...pageParams(100)],
|
||||||
|
responses: { 200: json(PagedRooms, 'The similar rooms') },
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||||
|
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||||
|
return c.json(
|
||||||
|
await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// The caller's per-room player data. Stub → empty blob (client reads `Data`).
|
// The caller's per-room player data. Stub → empty blob (client reads `Data`).
|
||||||
.get('/rooms/:roomId{[0-9]+}/playerdata/me', (c) => c.json({ Data: '' }))
|
.get(
|
||||||
|
'/rooms/:roomId{[0-9]+}/playerdata/me',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Rooms'],
|
||||||
|
summary: 'The caller’s per-room player data',
|
||||||
|
description: [
|
||||||
|
'Per-room save data for the calling player. Nothing stores any yet, so this is a stub',
|
||||||
|
'serving an empty blob — which the client reads as “no saved data”. No auth: there’s',
|
||||||
|
'no caller-specific state to protect until something writes here.',
|
||||||
|
].join(' '),
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
responses: { 200: json(PlayerDataDto, 'An empty data blob') },
|
||||||
|
}),
|
||||||
|
(c) => c.json({ Data: '' })
|
||||||
|
)
|
||||||
|
|
||||||
// Single room by id. 404 when the room isn't in D1. Ignores the
|
// Single room by id. 404 when the room isn't in D1. Ignores the
|
||||||
// include/unityAsset* query params.
|
// include/unityAsset* query params.
|
||||||
.get('/rooms/:roomId{[0-9]+}', async (c) => {
|
.get(
|
||||||
const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
|
'/rooms/:roomId{[0-9]+}',
|
||||||
return room ? c.json(room) : c.notFound()
|
describeRoute({
|
||||||
})
|
tags: ['Rooms'],
|
||||||
|
summary: 'A room by id',
|
||||||
|
description: [
|
||||||
|
'The room as stored, with its `SubRooms` re-attached. Unlike `GET /rooms?id=`, an',
|
||||||
|
'unknown room here is a 404, not `{}`. The `include`/`unityAsset*` query params the',
|
||||||
|
'client sends are accepted and ignored.',
|
||||||
|
].join(' '),
|
||||||
|
parameters: [
|
||||||
|
roomIdParam,
|
||||||
|
stringQuery('include', 'Accepted and ignored'),
|
||||||
|
stringQuery('unityAssetTarget', 'Accepted and ignored'),
|
||||||
|
stringQuery('unityAssetVersion', 'Accepted and ignored'),
|
||||||
|
],
|
||||||
|
responses: { 200: json(RoomDto, 'The room'), 404: { description: 'No such room' } },
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
|
||||||
|
return room ? c.json(room) : c.notFound()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Photon access token + room permissions the client needs to spawn into a
|
// Photon access token + room permissions the client needs to spawn into a
|
||||||
// room. The client calls it on the rooms host both bare and under `/roomserver`.
|
// room. The client calls it on the rooms host both bare and under `/roomserver`.
|
||||||
.get('/photon_access_token', handlePhotonAccessToken)
|
.get(
|
||||||
.get('/roomserver/photon_access_token', handlePhotonAccessToken)
|
'/photon_access_token',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Session'],
|
||||||
|
summary: 'Photon token + room permissions',
|
||||||
|
description: [
|
||||||
|
'The permission table and Photon credentials the client needs to spawn into a room.',
|
||||||
|
'`RoomInstanceId` is the caller’s current instance, read from the shared `presence`',
|
||||||
|
'table (null when they’re in none).',
|
||||||
|
'',
|
||||||
|
'`PhotonAccessToken` is always empty: the reference server signs it with a',
|
||||||
|
'secret/algorithm we don’t have, and our Photon setup accepts an empty token. The',
|
||||||
|
'global (Role 0) maker pen is granted only to the hardcoded dev accounts.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
responses: {
|
||||||
|
200: json(PhotonAccessTokenDto, 'The permissions and (empty) token'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
handlePhotonAccessToken
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
'/roomserver/photon_access_token',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Session'],
|
||||||
|
summary: 'Photon token + room permissions (legacy path)',
|
||||||
|
description: [
|
||||||
|
'Identical to `GET /photon_access_token` — the client calls it both bare and under the',
|
||||||
|
'`/roomserver` prefix, so both forms are registered.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
responses: {
|
||||||
|
200: json(PhotonAccessTokenDto, 'The permissions and (empty) token'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
handlePhotonAccessToken
|
||||||
|
)
|
||||||
|
|
||||||
|
// The generated spec. Documentation only — no request is validated against it (see
|
||||||
|
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||||
|
app.get(
|
||||||
|
'/openapi.json',
|
||||||
|
describeRoute({ hide: true }),
|
||||||
|
withCleanSpec(
|
||||||
|
openAPIRouteHandler(app, {
|
||||||
|
documentation: {
|
||||||
|
info: {
|
||||||
|
title: 'recflare rooms',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: [
|
||||||
|
'The room server for recflare, a private-server reimplementation of the Rec Room',
|
||||||
|
'backend: room storage, the browse/search feeds, per-player cheers and favorites,',
|
||||||
|
'the owner’s room settings, and subrooms.',
|
||||||
|
'',
|
||||||
|
'A room is a single JSON blob in the shared `recflare` D1, with generated columns',
|
||||||
|
'for the queryable fields; reads serve that blob verbatim, which is why the shapes',
|
||||||
|
'here are the client’s PascalCase ones. Subrooms live in their own table (their ids',
|
||||||
|
'come from one global sequence, not per room) and are re-attached to each room on',
|
||||||
|
'read. The seed rooms — including the dorm — come from `static/ImportRooms.json`.',
|
||||||
|
'',
|
||||||
|
'Two response envelopes appear side by side: a PascalCase',
|
||||||
|
'`{ Success, Value, ErrorId, Error }` and a lowercase `{ success, error, value }`.',
|
||||||
|
'Which one a route uses is dictated by the client’s deserializer for that call, so',
|
||||||
|
'the inconsistency is deliberate. Both answer HTTP 200 even for a rejection — the',
|
||||||
|
'client reads the flag, not the status.',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
servers: [{ url: 'https://rooms.recflare.net', description: 'Production' }],
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
|
|||||||
@@ -349,7 +349,12 @@ describe('rooms endpoints', () => {
|
|||||||
expect(body.length).toBeLessThanOrEqual(3)
|
expect(body.length).toBeLessThanOrEqual(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('GET /featuredrooms/current returns a featured-room group of public rooms', async () => {
|
// Skipped: the endpoint is disabled. Serving it broke the client — the other room
|
||||||
|
// listings started failing with NREs, apparently because the featured-room load
|
||||||
|
// corrupts the client's room cache — so the route is registered under an `XXX`
|
||||||
|
// prefix (see rooms.app.ts) and this path 404s. The handler and its test are kept
|
||||||
|
// intact for whenever the cause is found; un-prefix the route to re-enable both.
|
||||||
|
it.skip('GET /featuredrooms/current returns a featured-room group of public rooms', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/featuredrooms/current`)
|
const res = await SELF.fetch(`${ORIGIN}/featuredrooms/current`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
@@ -1493,4 +1498,77 @@ describe('rooms endpoints', () => {
|
|||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
|
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('GET /openapi.json documents every route', async () => {
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const spec = (await res.json()) as {
|
||||||
|
openapi: string
|
||||||
|
paths: Record<string, Record<string, { summary?: string }>>
|
||||||
|
}
|
||||||
|
expect(spec.openapi).toMatch(/^3\.1/)
|
||||||
|
|
||||||
|
// The spec route hides itself.
|
||||||
|
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||||
|
|
||||||
|
// Every route the worker serves is described. This is the drift guard: adding a
|
||||||
|
// route without a describeRoute() block fails here rather than silently shipping
|
||||||
|
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
|
||||||
|
const documented = new Set(
|
||||||
|
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||||
|
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expect([...documented].sort()).toEqual([
|
||||||
|
'DELETE /rooms/{roomId}',
|
||||||
|
'DELETE /rooms/{roomId}/interactionby/me/cheer',
|
||||||
|
'DELETE /rooms/{roomId}/interactionby/me/favorite',
|
||||||
|
'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
|
||||||
|
'GET /',
|
||||||
|
'GET /XXXfeaturedrooms/current',
|
||||||
|
'GET /photon_access_token',
|
||||||
|
'GET /rooms',
|
||||||
|
'GET /rooms/base',
|
||||||
|
'GET /rooms/bulk',
|
||||||
|
'GET /rooms/createdby/me',
|
||||||
|
'GET /rooms/favoritedby/me',
|
||||||
|
'GET /rooms/hot',
|
||||||
|
'GET /rooms/ownedby/me',
|
||||||
|
'GET /rooms/ownedby/{accountId}',
|
||||||
|
'GET /rooms/recommendations',
|
||||||
|
'GET /rooms/search',
|
||||||
|
'GET /rooms/visitedby/me',
|
||||||
|
'GET /rooms/{roomId}',
|
||||||
|
'GET /rooms/{roomId}/interactionby/me',
|
||||||
|
'GET /rooms/{roomId}/playerdata/me',
|
||||||
|
'GET /rooms/{roomId}/similar',
|
||||||
|
'GET /rooms/{roomId}/subrooms/{subRoomId}/data',
|
||||||
|
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
|
||||||
|
'GET /roomserver/photon_access_token',
|
||||||
|
'GET /roomserver/rooms/createdby/me',
|
||||||
|
'POST /rooms/{roomId}/clone',
|
||||||
|
'POST /rooms/{roomId}/subrooms',
|
||||||
|
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
|
||||||
|
'POST /rooms/{roomId}/subrooms/{subRoomId}/data',
|
||||||
|
'PUT /rooms/{roomId}/accessibility',
|
||||||
|
'PUT /rooms/{roomId}/cloning',
|
||||||
|
'PUT /rooms/{roomId}/description',
|
||||||
|
'PUT /rooms/{roomId}/image',
|
||||||
|
'PUT /rooms/{roomId}/interactionby/me/cheer',
|
||||||
|
'PUT /rooms/{roomId}/interactionby/me/favorite',
|
||||||
|
'PUT /rooms/{roomId}/loadscreen',
|
||||||
|
'PUT /rooms/{roomId}/name',
|
||||||
|
'PUT /rooms/{roomId}/restrictions',
|
||||||
|
'PUT /rooms/{roomId}/roles/{accountId}',
|
||||||
|
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
|
||||||
|
'PUT /rooms/{roomId}/tags',
|
||||||
|
'PUT /rooms/{roomId}/warning',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Every operation carries a summary — a path present but undescribed is not
|
||||||
|
// documentation.
|
||||||
|
for (const ops of Object.values(spec.paths)) {
|
||||||
|
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { Env } from './context'
|
|||||||
export const DOCUMENTED_SERVICES: ReadonlyArray<{ slug: string; title: string }> = [
|
export const DOCUMENTED_SERVICES: ReadonlyArray<{ slug: string; title: string }> = [
|
||||||
{ slug: 'auth', title: 'auth — authentication & tokens' },
|
{ slug: 'auth', title: 'auth — authentication & tokens' },
|
||||||
{ slug: 'accounts', title: 'accounts — profiles & lookups' },
|
{ slug: 'accounts', title: 'accounts — profiles & lookups' },
|
||||||
|
{ slug: 'rooms', title: 'rooms — rooms, subrooms & browse feeds' },
|
||||||
{ slug: 'match', title: 'match — matchmaking & presence' },
|
{ slug: 'match', title: 'match — matchmaking & presence' },
|
||||||
{ slug: 'econ', title: 'econ — avatar & economy' },
|
{ slug: 'econ', title: 'econ — avatar & economy' },
|
||||||
{ slug: 'clubs', title: 'clubs — clubs & clubhouses' },
|
{ slug: 'clubs', title: 'clubs — clubs & clubhouses' },
|
||||||
|
|||||||
Generated
+15
@@ -703,12 +703,27 @@ importers:
|
|||||||
'@repo/jwt':
|
'@repo/jwt':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/jwt
|
version: link:../../packages/jwt
|
||||||
|
'@standard-community/standard-json':
|
||||||
|
specifier: 0.3.5
|
||||||
|
version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)
|
||||||
|
'@standard-community/standard-openapi':
|
||||||
|
specifier: 0.2.9
|
||||||
|
version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3)
|
||||||
hono:
|
hono:
|
||||||
specifier: 4.12.27
|
specifier: 4.12.27
|
||||||
version: 4.12.27
|
version: 4.12.27
|
||||||
|
hono-openapi:
|
||||||
|
specifier: 1.3.1
|
||||||
|
version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3)
|
||||||
|
openapi-types:
|
||||||
|
specifier: 12.1.3
|
||||||
|
version: 12.1.3
|
||||||
workers-tagged-logger:
|
workers-tagged-logger:
|
||||||
specifier: 1.0.1
|
specifier: 1.0.1
|
||||||
version: 1.0.1
|
version: 1.0.1
|
||||||
|
zod:
|
||||||
|
specifier: 4.4.3
|
||||||
|
version: 4.4.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@cloudflare/vitest-pool-workers':
|
'@cloudflare/vitest-pool-workers':
|
||||||
specifier: 0.16.20
|
specifier: 0.16.20
|
||||||
|
|||||||
Reference in New Issue
Block a user