mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
rooms openapi
This commit is contained in:
+31
-1
@@ -1,6 +1,36 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -19,8 +19,13 @@
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "0.16.20",
|
||||
|
||||
@@ -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 { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
@@ -38,9 +39,53 @@ import {
|
||||
toggleRoomTag,
|
||||
updateRoomFields,
|
||||
} 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 {
|
||||
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 { App } from './context'
|
||||
|
||||
@@ -245,63 +290,152 @@ const app = new Hono<App>()
|
||||
.onError(withOnError())
|
||||
.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
|
||||
// supplied and returns `{}` when nothing matches.
|
||||
.get('/rooms', 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)
|
||||
.get(
|
||||
'/rooms',
|
||||
describeRoute({
|
||||
tags: ['Rooms'],
|
||||
summary: 'Look up a room by id or name',
|
||||
description: [
|
||||
'A single room by `id` or `name`. `id` may be a comma-separated list — the first',
|
||||
'valid integer wins. An unknown room is `{}`, not a 404: the client reads an empty',
|
||||
'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 ?? {})
|
||||
}
|
||||
const room = await getRoomByName(c.env.DB, nameParam ?? '')
|
||||
return c.json(room ?? {})
|
||||
})
|
||||
)
|
||||
|
||||
// Room search: `query` is space/`+`-separated terms — `#tag` matches room tags,
|
||||
// plain terms match the name. Public, non-dorm rooms only. Paginated via
|
||||
// skip/take. Returns `{ Results, TotalResults }`.
|
||||
.get('/rooms/search', 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))
|
||||
})
|
||||
.get(
|
||||
'/rooms/search',
|
||||
describeRoute({
|
||||
tags: ['Discovery'],
|
||||
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
|
||||
// filtered to a single `tag` (e.g. `rro`). Paginated via skip/take (take
|
||||
// defaults to 100). Returns `{ Results, TotalResults }` like search.
|
||||
.get('/rooms/hot', 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))
|
||||
})
|
||||
.get(
|
||||
'/rooms/hot',
|
||||
describeRoute({
|
||||
tags: ['Discovery'],
|
||||
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
|
||||
// a room. Returned regardless of accessibility. Paginated via skip/take (take
|
||||
// defaults to 100). Returns a bare array.
|
||||
.get('/rooms/base', 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))
|
||||
})
|
||||
.get(
|
||||
'/rooms/base',
|
||||
describeRoute({
|
||||
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
|
||||
// as a bare array (the client's recommendation room-source expects a plain list).
|
||||
// The `splitTestId`/`splitTestValue` A/B params are accepted and ignored.
|
||||
// Paginated via skip/take (take defaults to 100).
|
||||
.get('/rooms/recommendations', 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))
|
||||
})
|
||||
.get(
|
||||
'/rooms/recommendations',
|
||||
describeRoute({
|
||||
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
|
||||
// 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
|
||||
// corrupting the room cache. I tried sending the normal room shape but that
|
||||
// did not seem to work.
|
||||
.get('/XXXfeaturedrooms/current', async (c) => {
|
||||
return c.json(await getFeaturedRooms(c.env.DB))
|
||||
})
|
||||
.get(
|
||||
'/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
|
||||
// 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.
|
||||
.get('/rooms/bulk', 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)
|
||||
.get(
|
||||
'/rooms/bulk',
|
||||
describeRoute({
|
||||
tags: ['Rooms'],
|
||||
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
|
||||
// account 1. `ownedby/me` drops the dorm (it's not a room the player made);
|
||||
// the `createdby` variants return everything the account created.
|
||||
.get('/roomserver/rooms/createdby/me', ownedRooms)
|
||||
.get('/rooms/ownedby/me', ownedRoomsExcludingDorm)
|
||||
.get('/rooms/createdby/me', ownedRooms)
|
||||
.get(
|
||||
'/roomserver/rooms/createdby/me',
|
||||
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 —
|
||||
// returns a bare array (empty when the account owns no public rooms).
|
||||
.get('/rooms/ownedby/:accountId{[0-9]+}', async (c) =>
|
||||
c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
|
||||
.get(
|
||||
'/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.
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array, like the
|
||||
// other room-source `*by/me` lists the client loads.
|
||||
.get('/rooms/favoritedby/me', 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))
|
||||
})
|
||||
.get(
|
||||
'/rooms/favoritedby/me',
|
||||
describeRoute({
|
||||
tags: ['My rooms'],
|
||||
summary: 'Rooms the caller favorited',
|
||||
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).
|
||||
// Auth-gated. Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
.get('/rooms/visitedby/me', 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))
|
||||
})
|
||||
.get(
|
||||
'/rooms/visitedby/me',
|
||||
describeRoute({
|
||||
tags: ['My rooms'],
|
||||
summary: 'Rooms the caller visited',
|
||||
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
|
||||
// visited), read from the `interaction` table. Auth-gated.
|
||||
.get('/rooms/:roomId{[0-9]+}/interactionby/me', 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() })
|
||||
})
|
||||
.get(
|
||||
'/rooms/:roomId{[0-9]+}/interactionby/me',
|
||||
describeRoute({
|
||||
tags: ['Interaction'],
|
||||
summary: 'The caller’s state on a room',
|
||||
description: [
|
||||
'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',
|
||||
'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
|
||||
// flip the stored flag and return the updated interaction.
|
||||
.put('/rooms/:roomId{[0-9]+}/interactionby/me/cheer', 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() })
|
||||
})
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/interactionby/me/cheer',
|
||||
describeRoute({
|
||||
tags: ['Interaction'],
|
||||
summary: 'Toggle the caller’s cheer on a room',
|
||||
description: 'Flips the stored cheer 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 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).
|
||||
// Auth-gated; idempotent — un-cheering when there's no cheer is a no-op.
|
||||
.delete('/rooms/:roomId{[0-9]+}/interactionby/me/cheer', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
const interaction = await removeCheer(
|
||||
c.env.DB,
|
||||
accountId,
|
||||
Number.parseInt(c.req.param('roomId'), 10)
|
||||
)
|
||||
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
|
||||
})
|
||||
.put('/rooms/:roomId{[0-9]+}/interactionby/me/favorite', 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() })
|
||||
})
|
||||
.delete(
|
||||
'/rooms/:roomId{[0-9]+}/interactionby/me/cheer',
|
||||
describeRoute({
|
||||
tags: ['Interaction'],
|
||||
summary: 'Un-cheer a room',
|
||||
description: [
|
||||
'Clears the cheer outright, where the PUT toggles it. Idempotent — un-cheering a room',
|
||||
'that isn’t cheered is a no-op.',
|
||||
].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 removeCheer(
|
||||
c.env.DB,
|
||||
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).
|
||||
// Auth-gated; idempotent — un-favoriting when there's no favorite is a no-op.
|
||||
.delete('/rooms/:roomId{[0-9]+}/interactionby/me/favorite', 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() })
|
||||
})
|
||||
.delete(
|
||||
'/rooms/:roomId{[0-9]+}/interactionby/me/favorite',
|
||||
describeRoute({
|
||||
tags: ['Interaction'],
|
||||
summary: 'Un-favorite a room',
|
||||
description: [
|
||||
'Clears the favorite outright, where the PUT toggles it. Idempotent — un-favoriting a',
|
||||
'room that isn’t favorited is a no-op.',
|
||||
].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
|
||||
// (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
|
||||
// the client expects; business failures are 200 with success:false.
|
||||
.post('/rooms/:roomId{[0-9]+}/clone', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) {
|
||||
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
|
||||
}
|
||||
.post(
|
||||
'/rooms/:roomId{[0-9]+}/clone',
|
||||
describeRoute({
|
||||
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 raw = body.name ?? c.req.query('name') ?? ''
|
||||
const name = typeof raw === 'string' ? raw.trim() : ''
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const raw = body.name ?? c.req.query('name') ?? ''
|
||||
const name = typeof raw === 'string' ? raw.trim() : ''
|
||||
|
||||
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your room.')
|
||||
if (await getRoomByName(c.env.DB, name)) {
|
||||
return roomEnvelope(c, null, 'A room with that name already exists!')
|
||||
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your room.')
|
||||
if (await getRoomByName(c.env.DB, name)) {
|
||||
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
|
||||
// use the `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
||||
.put('/rooms/:roomId{[0-9]+}/description', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/description',
|
||||
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 room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
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!',
|
||||
})
|
||||
}
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
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!',
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const description = typeof body.description === 'string' ? body.description : ''
|
||||
await setRoomDescription(c.env.DB, roomId, description)
|
||||
return roomResult(c, { Success: true })
|
||||
})
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const description = typeof body.description === 'string' ? body.description : ''
|
||||
await setRoomDescription(c.env.DB, roomId, description)
|
||||
return roomResult(c, { Success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// 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
|
||||
// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
||||
// NOTE: the ErrorId strings (besides Rooms.DoesntExist) are best guesses.
|
||||
.put('/rooms/:roomId{[0-9]+}/name', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/name',
|
||||
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 room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
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!',
|
||||
})
|
||||
}
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
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!',
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
if (name === '') {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidName',
|
||||
Error: 'You must enter a name for your room!',
|
||||
})
|
||||
}
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
if (name === '') {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidName',
|
||||
Error: 'You must enter a name for your room!',
|
||||
})
|
||||
}
|
||||
|
||||
// Reject if a different room already uses this name (case-insensitive).
|
||||
const existing = await getRoomByName(c.env.DB, name)
|
||||
if (existing && existing.RoomId !== roomId) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.AlreadyExists',
|
||||
Error: 'A room with that name already exists!',
|
||||
})
|
||||
}
|
||||
// Reject if a different room already uses this name (case-insensitive).
|
||||
const existing = await getRoomByName(c.env.DB, name)
|
||||
if (existing && existing.RoomId !== roomId) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.AlreadyExists',
|
||||
Error: 'A room with that name already exists!',
|
||||
})
|
||||
}
|
||||
|
||||
await setRoomName(c.env.DB, roomId, name)
|
||||
return roomResult(c, { Success: true })
|
||||
})
|
||||
await setRoomName(c.env.DB, roomId, name)
|
||||
return roomResult(c, { Success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -543,103 +941,161 @@ const app = new Hono<App>()
|
||||
// (#pvp/#quest/#game/#hangout/#art) are radio buttons — setting one clears the
|
||||
// others. Returns the `{ success, error, value }` envelope with the updated
|
||||
// room as `value`; business failures are 200 with success:false.
|
||||
.put('/rooms/:roomId{[0-9]+}/tags', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/tags',
|
||||
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 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 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 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`
|
||||
// form field (a key from the storage/image upload). Business results use the
|
||||
// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
|
||||
.put('/rooms/:roomId{[0-9]+}/image', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/image',
|
||||
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 room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
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!',
|
||||
})
|
||||
}
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
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!',
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
|
||||
if (imageName === '') {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidImage',
|
||||
Error: 'You must provide an image!',
|
||||
})
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
|
||||
if (imageName === '') {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidImage',
|
||||
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).
|
||||
// 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
|
||||
// left alone — they live in the api/img world and outlast the room.
|
||||
.delete('/rooms/:roomId{[0-9]+}', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
.delete(
|
||||
'/rooms/:roomId{[0-9]+}',
|
||||
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 room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This room does not exist!',
|
||||
})
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
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
|
||||
// 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
|
||||
// affected member so their client refreshes permissions, and returns the updated
|
||||
// room in the lowercase `{ success, error, value }` envelope.
|
||||
.put('/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}',
|
||||
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 targetAccountId = Number.parseInt(c.req.param('accountId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
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
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const targetAccountId = Number.parseInt(c.req.param('accountId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
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
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
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!')
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
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!')
|
||||
|
||||
const updated = await setRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
|
||||
// Notify the member whose role changed so their client refreshes the room
|
||||
// (and the permissions it grants them).
|
||||
await pushRoomUpdate(c, targetAccountId, updated)
|
||||
return roomEnvelope(c, updated)
|
||||
})
|
||||
const updated = await setRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
|
||||
// Notify the member whose role changed so their client refreshes the room
|
||||
// (and the permissions it grants them).
|
||||
await pushRoomUpdate(c, targetAccountId, updated)
|
||||
return roomEnvelope(c, updated)
|
||||
}
|
||||
)
|
||||
|
||||
// Set a room's content warning: the `WarningMask` bit flags plus an optional
|
||||
// free-text `CustomWarning`. Auth-gated (401) and owner/co-owner-only (403). Body is
|
||||
// the `warningMask` form field (an integer) and an optional `customWarning` string
|
||||
// (set when present — an empty value clears it). Returns the updated room in the
|
||||
// `{ success, error, value }` envelope.
|
||||
.put('/rooms/:roomId{[0-9]+}/warning', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/warning',
|
||||
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 room = await getRoomById(c.env.DB, roomId)
|
||||
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
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
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!')
|
||||
// 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.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const warningMask =
|
||||
typeof body.warningMask === 'string' ? Number.parseInt(body.warningMask, 10) : Number.NaN
|
||||
if (Number.isNaN(warningMask))
|
||||
return roomEnvelope(c, null, 'You must provide a valid warning mask!')
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const warningMask =
|
||||
typeof body.warningMask === 'string' ? Number.parseInt(body.warningMask, 10) : Number.NaN
|
||||
if (Number.isNaN(warningMask))
|
||||
return roomEnvelope(c, null, 'You must provide a valid warning mask!')
|
||||
|
||||
const patch: Record<string, unknown> = { WarningMask: warningMask }
|
||||
// Only touch CustomWarning when the field is present (an empty string clears it).
|
||||
if (typeof body.customWarning === 'string') patch.CustomWarning = body.customWarning
|
||||
const patch: Record<string, unknown> = { WarningMask: warningMask }
|
||||
// Only touch CustomWarning when the field is present (an empty string clears it).
|
||||
if (typeof body.customWarning === 'string') patch.CustomWarning = body.customWarning
|
||||
|
||||
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
|
||||
// Notify the owner so their client refreshes the room with the updated warning.
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return roomEnvelope(c, updated)
|
||||
})
|
||||
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
|
||||
// Notify the owner so their client refreshes the room with the updated warning.
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return roomEnvelope(c, updated)
|
||||
}
|
||||
)
|
||||
|
||||
// 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`).
|
||||
// Returns the updated room in the `{ success, error, value }` envelope.
|
||||
.put('/rooms/:roomId{[0-9]+}/cloning', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/cloning',
|
||||
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 room = await getRoomById(c.env.DB, roomId)
|
||||
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
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
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!')
|
||||
// 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.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
if (typeof body.cloningAllowed !== 'string') {
|
||||
return roomEnvelope(c, null, 'You must provide cloningAllowed.')
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
if (typeof body.cloningAllowed !== 'string') {
|
||||
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).
|
||||
// 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
|
||||
// are changed. Returns the updated room in the `{ success, error, value }` envelope.
|
||||
.put('/rooms/:roomId{[0-9]+}/restrictions', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/restrictions',
|
||||
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 room = await getRoomById(c.env.DB, roomId)
|
||||
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
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
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!')
|
||||
// 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.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const patch: Record<string, boolean> = {}
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
const field = RESTRICTION_FIELDS[key.toLowerCase()]
|
||||
if (field !== undefined && typeof value === 'string') {
|
||||
patch[field] = value.toLowerCase() === 'true'
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const patch: Record<string, boolean> = {}
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
const field = RESTRICTION_FIELDS[key.toLowerCase()]
|
||||
if (field !== undefined && typeof value === 'string') {
|
||||
patch[field] = value.toLowerCase() === 'true'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return roomEnvelope(c, updated)
|
||||
})
|
||||
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return roomEnvelope(c, updated)
|
||||
}
|
||||
)
|
||||
|
||||
// Add a load screen to a room (`LoadScreens[]` — the images shown while the room
|
||||
// loads). Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName`
|
||||
// form field plus optional `title`/`subtitle`. Appends one
|
||||
// `{ ImageName, Title, Subtitle }` to the existing list and returns the updated
|
||||
// room in the `{ success, error, value }` envelope.
|
||||
.put('/rooms/:roomId{[0-9]+}/loadscreen', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/loadscreen',
|
||||
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 room = await getRoomById(c.env.DB, roomId)
|
||||
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
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
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!')
|
||||
// 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.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
|
||||
if (imageName === '') return roomEnvelope(c, null, 'You must provide an image!')
|
||||
const title = typeof body.title === 'string' ? body.title : ''
|
||||
const subtitle = typeof body.subtitle === 'string' ? body.subtitle : ''
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
|
||||
if (imageName === '') return roomEnvelope(c, null, 'You must provide an image!')
|
||||
const title = typeof body.title === 'string' ? body.title : ''
|
||||
const subtitle = typeof body.subtitle === 'string' ? body.subtitle : ''
|
||||
|
||||
const existing = Array.isArray(room.LoadScreens) ? (room.LoadScreens as unknown[]) : []
|
||||
const loadScreens = [...existing, { ImageName: imageName, Title: title, Subtitle: subtitle }]
|
||||
const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens })
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return roomEnvelope(c, updated)
|
||||
})
|
||||
const existing = Array.isArray(room.LoadScreens) ? (room.LoadScreens as unknown[]) : []
|
||||
const loadScreens = [...existing, { ImageName: imageName, Title: title, Subtitle: subtitle }]
|
||||
const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens })
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return roomEnvelope(c, updated)
|
||||
}
|
||||
)
|
||||
|
||||
// Set a room's top-level `Accessibility` (the visibility the public-room/search
|
||||
// filters key on — see the RoomAccessibility enum). Auth-gated (401) and
|
||||
// owner/co-owner-only (403). Body is the `accessibility` form field (an integer).
|
||||
// Returns the updated room in the `{ success, error, value }` envelope.
|
||||
.put('/rooms/:roomId{[0-9]+}/accessibility', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/accessibility',
|
||||
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 room = await getRoomById(c.env.DB, roomId)
|
||||
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
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
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!')
|
||||
// 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.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const accessibility =
|
||||
typeof body.accessibility === 'string' ? Number.parseInt(body.accessibility, 10) : Number.NaN
|
||||
if (Number.isNaN(accessibility)) {
|
||||
return roomEnvelope(c, null, 'You must provide a valid accessibility!')
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const accessibility =
|
||||
typeof body.accessibility === 'string'
|
||||
? Number.parseInt(body.accessibility, 10)
|
||||
: 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
|
||||
// array). Public — the client fetches it while loading the room. 404 when the
|
||||
// room or subroom is unknown.
|
||||
.get('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data', 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()
|
||||
})
|
||||
.get(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data',
|
||||
describeRoute({
|
||||
tags: ['Subrooms'],
|
||||
summary: 'A subroom’s data descriptor',
|
||||
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
|
||||
// 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
|
||||
// there are no distinct versions to list — this returns an empty page. The
|
||||
// unityAssetTarget/unityAssetVersion/skip/take query params are accepted and ignored.
|
||||
.get('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves', (c) =>
|
||||
c.json({ Results: [], TotalResults: 0 })
|
||||
.get(
|
||||
'/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
|
||||
@@ -839,234 +1466,476 @@ const app = new Hono<App>()
|
||||
// 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 }`
|
||||
// envelope the reference's SetRoomData uses.
|
||||
.post('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return c.body(null, 401)
|
||||
.post(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data',
|
||||
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 subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||
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 roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This room does not exist!',
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
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,
|
||||
})
|
||||
}
|
||||
// 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)
|
||||
if (!updated) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This room does not exist!',
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
RoomData?: { Filename?: string }
|
||||
SubRoomData?: { Filename?: string }
|
||||
Description?: string
|
||||
PersistenceVersion?: number
|
||||
InventionUsage?: string
|
||||
// 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) ?? {})
|
||||
}
|
||||
|
||||
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.
|
||||
// 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 }`
|
||||
// envelope at HTTP 200, matching the other owner-gated room mutations.
|
||||
.put('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/modify', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/modify',
|
||||
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 subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||
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 roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
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!',
|
||||
})
|
||||
}
|
||||
if (!findSubRoom(room, subRoomId)) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This subroom does not exist!',
|
||||
})
|
||||
}
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
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!',
|
||||
})
|
||||
}
|
||||
if (!findSubRoom(room, subRoomId)) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This subroom does not exist!',
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
if (name === '') {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidName',
|
||||
Error: 'You must enter a name for your room!',
|
||||
})
|
||||
}
|
||||
const accessibility =
|
||||
typeof body.accessibility === 'string' ? Number.parseInt(body.accessibility, 10) : Number.NaN
|
||||
const maxPlayers =
|
||||
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
if (name === '') {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidName',
|
||||
Error: 'You must enter a name for your room!',
|
||||
})
|
||||
}
|
||||
const accessibility =
|
||||
typeof body.accessibility === 'string'
|
||||
? Number.parseInt(body.accessibility, 10)
|
||||
: Number.NaN
|
||||
const maxPlayers =
|
||||
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
|
||||
|
||||
const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, {
|
||||
name,
|
||||
accessibility: Number.isNaN(accessibility) ? undefined : accessibility,
|
||||
maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers,
|
||||
})
|
||||
if (!updated) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This subroom does not exist!',
|
||||
const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, {
|
||||
name,
|
||||
accessibility: Number.isNaN(accessibility) ? undefined : accessibility,
|
||||
maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers,
|
||||
})
|
||||
}
|
||||
if (!updated) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This subroom does not exist!',
|
||||
})
|
||||
}
|
||||
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return roomResult(c, { Success: true })
|
||||
})
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return roomResult(c, { Success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// client's expected body is unknown).
|
||||
.post('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/clone', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) {
|
||||
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
|
||||
.post(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/clone',
|
||||
describeRoute({
|
||||
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
|
||||
// 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
|
||||
// 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).
|
||||
.post('/rooms/:roomId{[0-9]+}/subrooms', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) {
|
||||
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
|
||||
.post(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms',
|
||||
describeRoute({
|
||||
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
|
||||
// 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
|
||||
// re-renders the subroom list from `value`).
|
||||
.delete('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) {
|
||||
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
|
||||
.delete(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}',
|
||||
describeRoute({
|
||||
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
|
||||
// defaults to 100). Returns `{ Results, TotalResults }`; empty when the room is
|
||||
// unknown/untagged.
|
||||
.get('/rooms/:roomId{[0-9]+}/similar', 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)
|
||||
)
|
||||
})
|
||||
.get(
|
||||
'/rooms/:roomId{[0-9]+}/similar',
|
||||
describeRoute({
|
||||
tags: ['Discovery'],
|
||||
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`).
|
||||
.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
|
||||
// include/unityAsset* query params.
|
||||
.get('/rooms/:roomId{[0-9]+}', async (c) => {
|
||||
const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
|
||||
return room ? c.json(room) : c.notFound()
|
||||
})
|
||||
.get(
|
||||
'/rooms/:roomId{[0-9]+}',
|
||||
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
|
||||
// room. The client calls it on the rooms host both bare and under `/roomserver`.
|
||||
.get('/photon_access_token', handlePhotonAccessToken)
|
||||
.get('/roomserver/photon_access_token', handlePhotonAccessToken)
|
||||
.get(
|
||||
'/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
|
||||
|
||||
@@ -349,7 +349,12 @@ describe('rooms endpoints', () => {
|
||||
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`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
@@ -1493,4 +1498,77 @@ describe('rooms endpoints', () => {
|
||||
expect(res.status).toBe(200)
|
||||
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 }> = [
|
||||
{ slug: 'auth', title: 'auth — authentication & tokens' },
|
||||
{ slug: 'accounts', title: 'accounts — profiles & lookups' },
|
||||
{ slug: 'rooms', title: 'rooms — rooms, subrooms & browse feeds' },
|
||||
{ slug: 'match', title: 'match — matchmaking & presence' },
|
||||
{ slug: 'econ', title: 'econ — avatar & economy' },
|
||||
{ slug: 'clubs', title: 'clubs — clubs & clubhouses' },
|
||||
|
||||
Generated
+15
@@ -703,12 +703,27 @@ importers:
|
||||
'@repo/jwt':
|
||||
specifier: workspace:*
|
||||
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:
|
||||
specifier: 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:
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1
|
||||
zod:
|
||||
specifier: 4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@cloudflare/vitest-pool-workers':
|
||||
specifier: 0.16.20
|
||||
|
||||
Reference in New Issue
Block a user