Merge branch 'main' into photon-testing

This commit is contained in:
Devin Zuczek
2026-07-29 10:46:54 -04:00
18 changed files with 1933 additions and 250 deletions
+34
View File
@@ -73,6 +73,40 @@ inconsistency here without checking the client first.
- Endpoints the client re-renders from must return the updated entity, not - Endpoints the client re-renders from must return the updated entity, not
`{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old `{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old
clubhouse on screen until it answered the full details envelope. clubhouse on screen until it answered the full details envelope.
- Every subroom mutation (`rooms`: create, delete, `/subrooms/:sid/clone`,
`/subrooms/:sid/accessibility`, `/subrooms/:sid/publish_save`) answers
`{ success, error, value }` with the whole updated ROOM — the client re-renders the room
from `value`. Notably `value` is the room even for `clone`, whose product is a new
SUBROOM; only the room-level `POST /rooms/:id/clone` returns the thing it created.
- The room save (`rooms`: `POST /subrooms/:sid/data`) is the ONE exception to that shape:
`value` is `{ room, subRoomDataSave }`, and `error` is NULL rather than `""`. The
`subRoomDataSave` is camelCase with a different field set from the PascalCase
`CurrentSave` embedded in the room (no persistence/OM/UGC versions, no moderation state,
no asset arrays; but `unityAsset`/`unityAssetHash`). Don't unify the two projections.
- A subroom's saved scene loads from `CurrentSave.DataBlob` (`rooms`: `GET /rooms/:id`),
NOT the flat `DataBlob` on the subroom — a subroom with no `CurrentSave` silently loads
nothing. The key must be present (null before the first publish); read it via
`subRoomDataBlob()` so `match`/`auth` instance payloads resolve it the same way.
- A room save (`rooms`: `POST …/subrooms/:sid/data`) publishes only when the body says
`AutoPublish: true`; otherwise it STAGES onto `StagedSubRoomDataSaveId` and leaves
`CurrentSave` alone, so players keep loading the last published version until the owner
posts `…/subrooms/:sid/publish_save` with `subRoomDataSaveId=<id>`. DORMS always
publish: no publish step exists in the client for them. Saves live in the
`subroom_save` table with globally-unique ids (a bare id has to resolve —
`StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so
`…/saves` is real history and `publish_save` doubles as restore-a-save. `…/saves` is
auth-gated and CREATOR-only (not co-owners) — it lists unpublished staged saves. There
is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path.
- Matchmaking (`match`: `/matchmake/room/:roomId/:subRoomId`) always serves the PUBLISHED
`CurrentSave` blob, creator included. Joining a private instance, the client itself asks
the owner whether to load the latest or the published version and resolves it from the
`/subrooms/:sid/saves` list — the matchmake call is identical either way. Don't make
this server-side: it would put two people in one instance on different versions.
- Accessibility is sent as the `RoomAccessibility` enum NAME on
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
(Private, Public, Unlisted, Dev_only, Dev_Unlisted); parse via `parseAccessibility`,
which accepts either form.
</client-contract-notes> </client-contract-notes>
<critical-notes> <critical-notes>
+2 -1
View File
@@ -18,6 +18,7 @@ import {
setLoginContext, setLoginContext,
setPasswordHash, setPasswordHash,
setPresence, setPresence,
subRoomDataBlob,
verifyPassword, verifyPassword,
} from '@repo/domain' } from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
@@ -119,7 +120,7 @@ async function placeNewPlayerInOrientation(
subRoomId: num(sub?.SubRoomId, 1), subRoomId: num(sub?.SubRoomId, 1),
roomInstanceType: RoomInstanceType.Public, roomInstanceType: RoomInstanceType.Public,
location: str(sub?.UnitySceneId), location: str(sub?.UnitySceneId),
dataBlob: str(sub?.DataBlob), dataBlob: subRoomDataBlob(sub),
eventId: 0, eventId: 0,
clubId: 0, clubId: 0,
roomCode: '', roomCode: '',
+10 -3
View File
@@ -27,6 +27,7 @@ import {
RoomInstanceType, RoomInstanceType,
setPresence, setPresence,
setRoomInstanceInProgress, setRoomInstanceInProgress,
subRoomDataBlob,
} from '@repo/domain' } from '@repo/domain'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { generatePhotonAuthToken, validateAndGetAccountId } from '@repo/jwt' import { generatePhotonAuthToken, validateAndGetAccountId } from '@repo/jwt'
@@ -34,7 +35,6 @@ import { generatePhotonAuthToken, validateAndGetAccountId } from '@repo/jwt'
// Value import of the notify worker's NotificationType enum (its bundle has no runtime // Value import of the notify worker's NotificationType enum (its bundle has no runtime
// deps), so /invite sends a typed MessageReceived frame instead of a magic number. // deps), so /invite sends a typed MessageReceived frame instead of a magic number.
import { NotificationType } from '../../notify/src/notification-types' import { NotificationType } from '../../notify/src/notification-types'
import { import {
AUTHED, AUTHED,
ConnectionInfoResponse, ConnectionInfoResponse,
@@ -413,7 +413,11 @@ function instanceFieldsFromRoom(room: Room, subRoomId?: number) {
roomId: num(room.RoomId, 1), roomId: num(room.RoomId, 1),
subRoomId: num(sub?.SubRoomId, 1), subRoomId: num(sub?.SubRoomId, 1),
location: str(sub?.UnitySceneId), location: str(sub?.UnitySceneId),
dataBlob: str(sub?.DataBlob), // Always the PUBLISHED save. A creator who wants their unpublished work is offered
// the choice client-side from the `/subrooms/{id}/saves` list — matchmaking is not
// involved, and serving a staged blob here would put two people in one instance on
// different versions.
dataBlob: subRoomDataBlob(sub),
name, name,
maxCapacity: num(sub?.MaxPlayers, 4), maxCapacity: num(sub?.MaxPlayers, 4),
roomInstanceType: room.IsDorm === true ? RoomInstanceType.Dormroom : RoomInstanceType.Public, roomInstanceType: room.IsDorm === true ? RoomInstanceType.Dormroom : RoomInstanceType.Public,
@@ -743,7 +747,10 @@ const app = new Hono<App>()
'`PlayerId`/`RoomInstanceId`). Currently just logged and acked — presence is cleared', '`PlayerId`/`RoomInstanceId`). Currently just logged and acked — presence is cleared',
'by logout and otherwise expires on its TTL — but the hook is here for a future check.', 'by logout and otherwise expires on its TTL — but the hook is here for a future check.',
].join(' '), ].join(' '),
requestBody: form(NotifyDisconnectRequest, 'The disconnecting player and the instance they left'), requestBody: form(
NotifyDisconnectRequest,
'The disconnecting player and the instance they left'
),
responses: { 200: EMPTY_OK }, responses: { 200: EMPTY_OK },
}), }),
async (c) => { async (c) => {
@@ -351,6 +351,59 @@ describe('public endpoints', () => {
expect(unknown).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE }) expect(unknown).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE })
}) })
test('matchmaking serves the PUBLISHED save to everyone, creator included', async () => {
// The client offers the owner "latest or published" itself, from the
// `/subrooms/{id}/saves` list — matchmaking never picks. Serving a staged blob to
// the creator here would put them on a different version to everyone else in the
// same instance.
const room = {
RoomId: 78,
Name: 'StagedRoom',
IsDorm: false,
Accessibility: 1,
CreatorAccountId: 400,
Roles: [{ AccountId: 401, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
SubRooms: [
{
SubRoomId: 36,
UnitySceneId: RECCENTER_SCENE,
MaxPlayers: 10,
// Seeded as the published save (seedRoomWithSubRooms mirrors the backfill).
CurrentSave: { DataBlob: 'published.room' },
},
],
}
await seedRoomWithSubRooms(env.DB, room as unknown as Record<string, unknown>)
// Stage a newer save the creator hasn't published.
const staged = await env.DB.prepare(
'INSERT INTO subroom_save (sub_room_id, data) VALUES (?1, ?2) RETURNING sub_room_data_save_id'
)
.bind(36, JSON.stringify({ DataBlob: 'staged.room' }))
.first<{ sub_room_data_save_id: number }>()
await env.DB.prepare('UPDATE subroom SET staged_save_id = ?2 WHERE sub_room_id = ?1')
.bind(36, staged!.sub_room_data_save_id)
.run()
const matchmake = async (sub: string) => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/78/36`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'JoinMode=0',
})
expect(res.status).toBe(200)
return ((await res.json()) as { roomInstance: { dataBlob: string } }).roomInstance
}
// Creator, co-owner and ordinary player all land on the same published version —
// having a newer staged save changes nothing here.
expect((await matchmake('400')).dataBlob).toBe('published.room')
expect((await matchmake('401')).dataBlob).toBe('published.room')
expect((await matchmake('402')).dataBlob).toBe('published.room')
})
test('POST /matchmake/club/:clubId places members into the clubhouse', async () => { test('POST /matchmake/club/:clubId places members into the clubhouse', async () => {
const matchmake = async (path: string, sub?: string) => const matchmake = async (path: string, sub?: string) =>
exports.default.fetch(`${ORIGIN}${path}`, { exports.default.fetch(`${ORIGIN}${path}`, {
@@ -0,0 +1,66 @@
-- Room saves as first-class entities. A subroom POINTS at its saves by bare id — the
-- live/published one the loader downloads (`current_save_id`) and the creator's
-- unpublished one (`staged_save_id`, unused for now) — and `StagedSubRoomDataSaveId`
-- carries no subroom context, so a save id has to be globally unique to be resolvable.
-- Numbering saves per subroom (what the embedded `CurrentSave` did) makes every
-- subroom's first save id 1 and those pointers ambiguous. Same reasoning as 0007 for
-- SubRoomId. It also gives `GET …/subrooms/{id}/saves` real history to page over.
--
-- Generated from packages/domain/src/rooms-db.ts (SUBROOM_SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS subroom_save (
sub_room_data_save_id INTEGER PRIMARY KEY AUTOINCREMENT,
sub_room_id INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id);
ALTER TABLE subroom ADD COLUMN current_save_id INTEGER;
ALTER TABLE subroom ADD COLUMN staged_save_id INTEGER;
-- Backfill 1: subrooms that already carry an embedded `CurrentSave` object. The two id
-- fields are dropped from the stored blob — the columns are authoritative and are
-- re-injected on read.
INSERT INTO subroom_save (sub_room_id, data)
SELECT
sub_room_id,
json_remove(json_extract(data, '$.CurrentSave'), '$.SubRoomDataSaveId', '$.SubRoomId')
FROM subroom
WHERE json_extract(data, '$.CurrentSave') IS NOT NULL;
-- Backfill 2: subrooms saved before `CurrentSave` existed, whose blob key sits in the
-- flat `DataBlob`/`DataSavedAt`/`PersistenceVersion` fields. They hold real saved content
-- the client can't see (it reads only `CurrentSave`), so they become saves too rather
-- than reading as never-saved. Shape matches the reference's MapSave projection.
INSERT INTO subroom_save (sub_room_id, data)
SELECT
sub_room_id,
json_object(
'UnitySubAssets', json('[]'),
'ReferencedUnityAssets', json('[]'),
'DataBlob', json_extract(data, '$.DataBlob'),
'ReferencedUnityAssetIds', json('[]'),
'PersistenceVersion', COALESCE(json_extract(data, '$.PersistenceVersion'), 0),
'OMVersion', 0,
'UgcSubVersion', 0,
'SavedByAccountId', json_extract(data, '$.CreatorAccountId'),
'SavedOnPlatform', 0,
'SavedOnDeviceClass', 0,
'Description', '',
'Tags', json('[]'),
'ModerationState', 0,
'CreatedAt', COALESCE(json_extract(data, '$.DataSavedAt'), '1970-01-01T00:00:00.000Z')
)
FROM subroom
WHERE json_extract(data, '$.CurrentSave') IS NULL
AND COALESCE(json_extract(data, '$.DataBlob'), '') <> '';
-- Point each subroom at the save just minted for it. At this moment a subroom has at
-- most one save row, so the correlated subquery is unambiguous.
UPDATE subroom SET current_save_id = (
SELECT s.sub_room_data_save_id FROM subroom_save s WHERE s.sub_room_id = subroom.sub_room_id
);
-- Single source of truth: the save now lives in `subroom_save`, and
-- `StagedSubRoomDataSaveId` is served from the `staged_save_id` column.
UPDATE subroom SET data = json_remove(data, '$.CurrentSave', '$.StagedSubRoomDataSaveId');
+109 -17
View File
@@ -138,14 +138,68 @@ export const LoadScreenDto = z.object({
Subtitle: z.string(), Subtitle: z.string(),
}) })
/**
* The save as the room-save RESPONSE renders it — camelCase, and a different field set
* from the PascalCase `CurrentSave` embedded in a room (no persistence/OM/UGC versions,
* no moderation state, no asset arrays; but `unityAsset`/`unityAssetHash`/`dataBlobHash`
* that `CurrentSave` doesn't show). The two are deliberately not unified.
*/
export const SubRoomDataSaveResponseDto = z.object({
subRoomDataSaveId: z.int(),
subRoomId: z.int(),
unityAssetId: z.string().nullable().describe('Null unless the save carried one'),
unityAsset: z.string().nullable().describe('Always null — we resolve no baked assets'),
unityAssetHash: z.string().nullable().describe('Always null — we resolve no baked assets'),
dataBlob: z.string(),
dataBlobHash: z.string().nullable().describe('Echoed from the requests `SubRoomData.Hash`'),
savedByAccountId: z.int().nullable(),
savedOnPlatform: z
.int()
.describe(
'Steam=0 Oculus=1 PlayStation=2 Xbox=3 RecNet=4 IOS=5 GooglePlay=6 Standalone=7 Pico=8'
),
savedOnDeviceClass: z.int().describe('Unknown=0 VR=1 Screen=2 Mobile=3 VRLow=4 Quest2=5'),
description: z.string().nullable(),
createdAt: z.string(),
})
/**
* A subroom's most recent room save — the `SubRoomDataSave` the client reads to find the
* scene-data blob to download. This is the ONLY place the loader looks for it, so a
* subroom whose `CurrentSave` is missing loads no saved content at all.
*
* The array fields are always empty here: we neither resolve nor record referenced Unity
* assets. They are still emitted because the client's parser expects them present.
*/
export const SubRoomDataSaveDto = z.object({
UnitySubAssets: z.array(z.unknown()).describe('Always empty'),
ReferencedUnityAssets: z.array(z.unknown()).describe('Always empty'),
SubRoomDataSaveId: z.int().describe('Numbered from 1, incremented on every save'),
SubRoomId: z.int().describe('The owning subroom — re-pointed when a subroom is cloned'),
DataBlob: z.string().describe('The scene-data key the client downloads from the CDN'),
ReferencedUnityAssetIds: z.array(z.string()).describe('Always empty'),
PersistenceVersion: z.int(),
OMVersion: z.int(),
UgcSubVersion: z.int(),
SavedByAccountId: z.int().nullable(),
SavedOnPlatform: z.int().describe('0 — the save request carries no platform'),
SavedOnDeviceClass: z.int().describe('0 — the save request carries no device class'),
Description: z.string().describe('The save comment; empty string when none'),
Tags: z.array(z.unknown()).describe('Always empty'),
ModerationState: z.int(),
CreatedAt: z.string(),
UnityAssetId: z.string().optional().describe('Emitted only when the save carried one'),
})
/** /**
* A subroom — a room's individual scene. Subrooms are their own table with a globally * 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 * unique, autoincrementing `SubRoomId` (the original game mints them from a single
* sequence, not per-room); a room's `SubRooms` array is reconstructed on read. * 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 — * `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 * the client NREs on a null one. `CurrentSave` is null until the first save; the flat
* appear once the subroom has been saved at least once. * `DataBlob`/`RoomDataBlob`/`DataSavedAt` fields are legacy and are NOT what the client
* loads from.
*/ */
export const SubRoomDto = z.object({ export const SubRoomDto = z.object({
SubRoomId: z.int(), SubRoomId: z.int(),
@@ -156,10 +210,15 @@ export const SubRoomDto = z.object({
LastModeratedSaveModerationState: z.int(), LastModeratedSaveModerationState: z.int(),
IsSandbox: z.boolean(), IsSandbox: z.boolean(),
MaxPlayers: z.int(), MaxPlayers: z.int(),
Accessibility: z.int().describe('0 = Private, 1 = Public, 2 = Unlisted'), Accessibility: z
.int()
.describe('0 Private, 1 Public, 2 Unlisted, 3 Dev_only, 4 Dev_Unlisted — set independently'),
ShouldAutoStageSaves: z.boolean(), ShouldAutoStageSaves: z.boolean(),
StagedSubRoomDataSaveId: z.int().nullable(), StagedSubRoomDataSaveId: z.int().nullable(),
DataBlob: z.string().optional().describe('Uploaded scene-data key; absent until first save'), CurrentSave: SubRoomDataSaveDto.nullable().describe(
'The latest room save — where the client finds the scene blob. Null until first save'
),
DataBlob: z.string().optional().describe('Legacy flat key; the client reads `CurrentSave`'),
RoomDataBlob: z.string().optional().describe('Uploaded room-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'), DataSavedAt: z.string().optional().describe('ISO timestamp of the last save'),
PersistenceVersion: z.int().optional(), PersistenceVersion: z.int().optional(),
@@ -318,17 +377,17 @@ export const RoomEnvelope = z.object({
}) })
/** /**
* What a room save answers: the saved subroom on success (no envelope — the client * What `POST /rooms/{roomId}/subrooms/{subRoomId}/data` answers: `value` carries BOTH the
* deserializes the body directly as the subroom), or the PascalCase result envelope when * updated room and the save that was just created. Note `error` is NULL here, not the
* the room or subroom doesn't exist. Both at HTTP 200. * empty string the other room envelopes use.
*/ */
export const SubRoomSaveResult = z.union([SubRoomDto, RoomResultEnvelope]) export const RoomSaveEnvelope = z.object({
/** The same envelope carrying a subroom (`POST …/subrooms/{subRoomId}/clone`). */
export const SubRoomEnvelope = z.object({
success: z.boolean(), success: z.boolean(),
error: z.string().describe('Empty on success'), error: z.string().nullable().describe('Null on success'),
value: SubRoomDto.nullable(), value: z
.object({ room: RoomDto, subRoomDataSave: SubRoomDataSaveResponseDto })
.nullable()
.describe('Null on a rejection'),
}) })
/** The 401 the envelope-returning routes answer with — the only one that isnt HTTP 200. */ /** The 401 the envelope-returning routes answer with — the only one that isnt HTTP 200. */
@@ -406,6 +465,28 @@ export const AccessibilityRequest = z.object({
accessibility: z.string().describe('0 = Private, 1 = Public, 2 = Unlisted'), accessibility: z.string().describe('0 = Private, 1 = Public, 2 = Unlisted'),
}) })
/**
* `PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility`. Unlike the room-level route
* above, the client sends the enum NAME here (`accessibility=Private`), so both the name
* and the number are accepted.
*/
export const SubRoomAccessibilityRequest = z.object({
accessibility: z
.string()
.describe(
'A `RoomAccessibility` name — `Private`, `Public`, `Unlisted`, `Dev_only`, ' +
'`Dev_Unlisted` (case-insensitive) — or its ordinal 04'
),
})
/**
* `POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save` — promotes one save to live.
* Any id from the subroom's history works, so this is both publish and restore.
*/
export const PublishSaveRequest = z.object({
subRoomDataSaveId: z.string().describe('The `SubRoomDataSaveId` to make live'),
})
/** `POST /rooms/{roomId}/subrooms`. */ /** `POST /rooms/{roomId}/subrooms`. */
export const CreateSubRoomRequest = z.object({ export const CreateSubRoomRequest = z.object({
name: z.string().describe('The new subrooms name'), name: z.string().describe('The new subrooms name'),
@@ -414,7 +495,10 @@ export const CreateSubRoomRequest = z.object({
/** `PUT /rooms/{roomId}/subrooms/{subRoomId}/modify`. */ /** `PUT /rooms/{roomId}/subrooms/{subRoomId}/modify`. */
export const ModifySubRoomRequest = z.object({ export const ModifySubRoomRequest = z.object({
name: z.string().describe('Required — an empty name is rejected'), name: z.string().describe('Required — an empty name is rejected'),
accessibility: z.string().optional().describe('0 = Private, 1 = Public, 2 = Unlisted'), accessibility: z
.string()
.optional()
.describe('A `RoomAccessibility` name (case-insensitive) or its ordinal 04'),
maxPlayers: z.string().optional().describe('Ignored when not a positive integer'), maxPlayers: z.string().optional().describe('Ignored when not a positive integer'),
}) })
@@ -428,14 +512,19 @@ export const SaveSubRoomDataRequest = z.object({
SubRoomData: z SubRoomData: z
.object({ Filename: z.string() }) .object({ Filename: z.string() })
.optional() .optional()
.describe('The uploaded scene-data blob — becomes the subrooms `DataBlob`'), .describe('The uploaded scene-data blob — becomes the subrooms `CurrentSave.DataBlob`'),
RoomData: z RoomData: z
.object({ Filename: z.string() }) .object({ Filename: z.string() })
.optional() .optional()
.describe('The uploaded room-level data blob — becomes `RoomDataBlob`'), .describe('The uploaded room-level data blob — becomes `RoomDataBlob`'),
Description: z.string().optional().describe('Written to the ROOM, not the subroom'), Description: z.string().optional().describe('The save comment; also written to the ROOM'),
PersistenceVersion: z.int().optional(), PersistenceVersion: z.int().optional(),
InventionUsage: z.string().optional().describe('Written to the room'), InventionUsage: z.string().optional().describe('Written to the room'),
UnityAssetId: z.string().nullable().optional().describe('Recorded on the save when set'),
AutoPublish: z
.boolean()
.optional()
.describe('True publishes the save immediately; otherwise it is staged'),
}) })
/** /**
@@ -443,8 +532,11 @@ export const SaveSubRoomDataRequest = z.object({
* save history (a save overwrites the subroom's blob inline), so it's always empty. * save history (a save overwrites the subroom's blob inline), so it's always empty.
*/ */
export const SubRoomSavesPage = z.object({ export const SubRoomSavesPage = z.object({
Results: z.array(z.unknown()).describe('Always empty — no save history is kept'), Results: z
.array(SubRoomDataSaveDto)
.describe('At most one — the current save; we keep no history'),
TotalResults: z.int(), TotalResults: z.int(),
TotalCount: z.int().describe('Same value as `TotalResults` — the two references disagree'),
}) })
// ---- Session --------------------------------------------------------------- // ---- Session ---------------------------------------------------------------
+272 -82
View File
@@ -3,6 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { import {
Accessibility,
canManageRoom, canManageRoom,
cloneRoom, cloneRoom,
cloneSubRoom, cloneSubRoom,
@@ -24,8 +25,10 @@ import {
getRoomsByCreator, getRoomsByCreator,
getRoomsByIds, getRoomsByIds,
getSimilarRooms, getSimilarRooms,
getSubRoomSaves,
getVisitedRooms, getVisitedRooms,
modifySubRoom, modifySubRoom,
publishSubRoomSave,
removeCheer, removeCheer,
removeFavorite, removeFavorite,
saveSubRoomData, saveSubRoomData,
@@ -64,6 +67,7 @@ import {
pageParams, pageParams,
PhotonAccessTokenDto, PhotonAccessTokenDto,
PlayerDataDto, PlayerDataDto,
PublishSaveRequest,
RestrictionsRequest, RestrictionsRequest,
RoleRequest, RoleRequest,
RoomDto, RoomDto,
@@ -71,13 +75,12 @@ import {
roomIdParam, roomIdParam,
RoomLookup, RoomLookup,
RoomResultEnvelope, RoomResultEnvelope,
RoomSaveEnvelope,
SaveSubRoomDataRequest, SaveSubRoomDataRequest,
ServiceStatus, ServiceStatus,
stringQuery, stringQuery,
SubRoomDto, SubRoomAccessibilityRequest,
SubRoomEnvelope,
subRoomIdParam, subRoomIdParam,
SubRoomSaveResult,
SubRoomSavesPage, SubRoomSavesPage,
TagRequest, TagRequest,
UNAUTHORIZED_EMPTY, UNAUTHORIZED_EMPTY,
@@ -195,6 +198,22 @@ function unauthorized(c: Context<App>) {
return c.json({ error: 'Unauthorized' }, 401) return c.json({ error: 'Unauthorized' }, 401)
} }
/**
* Parse an `accessibility` form field into a `RoomAccessibility` value. The client
* sends the enum NAME on the subroom route (`accessibility=Private`), not the number
* the room-level route takes, so both forms are accepted. Returns undefined when the
* field is missing or names nothing in the enum.
*/
function parseAccessibility(value: unknown): number | undefined {
if (typeof value !== 'string') return undefined
const raw = value.trim()
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10)
const named = Object.entries(Accessibility).find(
([name, ordinal]) => typeof ordinal === 'number' && name.toLowerCase() === raw.toLowerCase()
)
return named ? (named[1] as number) : undefined
}
/** The notifications hub is a single global DO instance (see the `notify` worker). */ /** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global' const HUB_INSTANCE = 'global'
@@ -253,6 +272,33 @@ function roomResult(
}) })
} }
/**
* The room save's `value.subRoomDataSave` — a camelCase projection with a DIFFERENT
* field set from the PascalCase `CurrentSave` embedded in a room (no persistence/OM/UGC
* versions, no moderation state, no asset arrays; but `unityAsset`/`unityAssetHash`
* that `CurrentSave` never shows). Don't unify the two without checking the client.
*
* `unityAsset`/`unityAssetHash` are always null: we resolve no baked Unity assets.
*/
function toSaveResponse(save: Record<string, unknown>) {
const str = (v: unknown) => (typeof v === 'string' ? v : null)
const num = (v: unknown) => (typeof v === 'number' ? v : null)
return {
subRoomDataSaveId: num(save.SubRoomDataSaveId),
subRoomId: num(save.SubRoomId),
unityAssetId: str(save.UnityAssetId),
unityAsset: null,
unityAssetHash: null,
dataBlob: str(save.DataBlob) ?? '',
dataBlobHash: str(save.DataBlobHash),
savedByAccountId: num(save.SavedByAccountId),
savedOnPlatform: num(save.SavedOnPlatform) ?? 0,
savedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0,
description: str(save.Description),
createdAt: str(save.CreatedAt) ?? '',
}
}
/** Client envelope for room mutations: `{ success, error, value }` (lowercase). */ /** Client envelope for room mutations: `{ success, error, value }` (lowercase). */
function roomEnvelope(c: Context<App>, value: unknown, error = '') { function roomEnvelope(c: Context<App>, value: unknown, error = '') {
return c.json({ success: error === '', error, value }) return c.json({ success: error === '', error, value })
@@ -1403,62 +1449,64 @@ const app = new Hono<App>()
} }
) )
// A subroom's data descriptor (the SubRoom object from the room's SubRooms // A subroom's saved-data versions — the room-history / "restore a save" list. Every
// array). Public — the client fetches it while loading the room. 404 when the // save is its own `subroom_save` row (nothing is overwritten), so this is real
// room or subroom is unknown. // history, newest first, paged by skip/take. Auth-gated (401) and creator-only (403):
.get( // the list exposes unpublished saves, which only the owner is entitled to see.
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data',
describeRoute({
tags: ['Subrooms'],
summary: 'A subrooms data descriptor',
description: [
'The `SubRoom` object from the rooms `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( .get(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves', '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves',
describeRoute({ describeRoute({
tags: ['Subrooms'], tags: ['Subrooms'],
summary: 'A subrooms saved-data versions', summary: 'A subrooms saved-data versions',
description: [ description: [
'The room-history / “restore a save” list, paged as', 'The room-history / “restore a save” list, newest first. Every room save appends a',
'`PagedResultsDTO<SubRoomDataSaveDTO>`. We keep no save history — a save (`POST', 'row rather than overwriting, so this is the subrooms full history; it is empty',
'…/data`) overwrites the subrooms current blob inline, so there are no distinct', 'only when the subroom has never been saved.',
'versions to list — and this is always an empty page. The', '`unityAssetTarget`/`unityAssetVersion` are accepted and ignored.',
'`unityAssetTarget`/`unityAssetVersion`/`skip`/`take` params are accepted and ignored.', '',
'Owner-only (403 otherwise) — the list includes STAGED saves that were never',
'published, so it is not public. It is what the client reads to offer the owner',
'“load the latest or the published version?” when they enter a private instance.',
'',
'`TotalResults` and `TotalCount` carry the same number: the clients paged DTO and',
'the reference disagree on the name, so both are emitted.',
].join(' '), ].join(' '),
security: AUTHED,
parameters: [ parameters: [
roomIdParam, roomIdParam,
subRoomIdParam, subRoomIdParam,
stringQuery('unityAssetTarget', 'Accepted and ignored'), stringQuery('unityAssetTarget', 'Accepted and ignored'),
stringQuery('unityAssetVersion', 'Accepted and ignored'), stringQuery('unityAssetVersion', 'Accepted and ignored'),
stringQuery('skip', 'Accepted and ignored — the page is always empty'), stringQuery('skip', 'How many saves to skip (default 0)'),
stringQuery('take', 'Accepted and ignored — the page is always empty'), stringQuery('take', 'How many saves to return (default all)'),
], ],
responses: { 200: json(SubRoomSavesPage, 'Always an empty page') }, responses: {
200: json(SubRoomSavesPage, 'The subrooms saves, newest first'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}), }),
(c) => c.json({ Results: [], TotalResults: 0 }) async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
// Scoped through the room so a subroom id from another room can't read its saves.
const room = await getRoomById(c.env.DB, roomId)
if (!room || !findSubRoom(room, subRoomId)) {
return c.json({ Results: [], TotalResults: 0, TotalCount: 0 })
}
if (room.CreatorAccountId !== accountId) return c.body(null, 403)
const saves = await getSubRoomSaves(c.env.DB, subRoomId)
const skip = Number.parseInt(c.req.query('skip') ?? '', 10)
const take = Number.parseInt(c.req.query('take') ?? '', 10)
const from = Number.isNaN(skip) || skip < 0 ? 0 : skip
const page = saves.slice(from, Number.isNaN(take) || take < 0 ? undefined : from + take)
return c.json({ Results: page, TotalResults: saves.length, TotalCount: saves.length })
}
) )
// Save a subroom's data (room save). Auth-gated (401 with empty body). Editable // Save a subroom's data (room save). Auth-gated (401 with empty body). Editable
@@ -1472,14 +1520,22 @@ const app = new Hono<App>()
tags: ['Subrooms'], tags: ['Subrooms'],
summary: 'Save a subrooms data (room save)', summary: 'Save a subrooms data (room save)',
description: [ description: [
'Points the subroom at the blobs the client has already uploaded through the `storage`', 'Records a save against the subroom from the blobs the client has already uploaded',
'worker and stamps the save; the room-level fields the save carries (`Description`,', 'through the `storage` worker; the room-level fields it carries (`Description`,',
'`PersistenceVersion`, `InventionUsage`) are written to the room. Editable by the', '`PersistenceVersion`, `InventionUsage`) are written to the room. Editable by the',
'rooms creator or a co-owner (403 otherwise); a missing token is an EMPTY-body 401,', 'rooms creator or a co-owner (403 otherwise); a missing token is an EMPTY-body 401,',
'unlike the other room writes.', 'unlike the other room writes.',
'', '',
'The push notification carries the whole room, but the RESPONSE is the saved SUBROOM', '`AutoPublish: true` makes the save live immediately. Otherwise it is STAGED: it',
'itself with no envelope — the client deserializes the body directly as the subroom.', 'lands on `StagedSubRoomDataSaveId` with the live `CurrentSave` untouched, so',
'players keep loading the last published version until the owner calls',
'`POST …/subrooms/{subRoomId}/publish_save`. DORMS always publish — they have no',
'publish step in the client, so staging one would hide the players own edits.',
'',
'`value` carries BOTH the updated `room` and the `subRoomDataSave` just created,',
'and `error` is NULL here rather than the empty string the other room envelopes',
'use. The save is projected in camelCase with a different field set from the',
'PascalCase `CurrentSave` embedded in the room — the two are not the same shape.',
'A subroom with no `CreatorAccountId` yet (the seeded rooms start null) gets the', 'A subroom with no `CreatorAccountId` yet (the seeded rooms start null) gets the',
'savers id here, because the client NREs on a null one.', 'savers id here, because the client NREs on a null one.',
].join('\n'), ].join('\n'),
@@ -1487,10 +1543,7 @@ const app = new Hono<App>()
parameters: [roomIdParam, subRoomIdParam], parameters: [roomIdParam, subRoomIdParam],
requestBody: jsonBody(SaveSubRoomDataRequest, 'The uploaded blob keys and save fields'), requestBody: jsonBody(SaveSubRoomDataRequest, 'The uploaded blob keys and save fields'),
responses: { responses: {
200: json( 200: json(RoomSaveEnvelope, 'The updated room + the new save, or a rejection'),
SubRoomSaveResult,
'The saved subroom, or the result envelope when the room/subroom is unknown'
),
401: UNAUTHORIZED_EMPTY, 401: UNAUTHORIZED_EMPTY,
403: FORBIDDEN_RESPONSE, 403: FORBIDDEN_RESPONSE,
}, },
@@ -1504,44 +1557,49 @@ const app = new Hono<App>()
const room = await getRoomById(c.env.DB, roomId) const room = await getRoomById(c.env.DB, roomId)
if (!room) { if (!room) {
return roomResult(c, { return c.json({ success: false, error: 'This room does not exist!', value: null })
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 // A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token). // already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403) if (!canManageRoom(room, accountId)) return c.body(null, 403)
// The client uploads BOTH blobs to `storage` first and sends their keys here:
// `SubRoomData` is the scene blob (what the loader downloads), `RoomData` the
// metadata blob. `OwnershipProof` is accepted and ignored.
const body = (await c.req.json().catch(() => ({}))) as { const body = (await c.req.json().catch(() => ({}))) as {
RoomData?: { Filename?: string } RoomData?: { Filename?: string }
SubRoomData?: { Filename?: string } SubRoomData?: { Filename?: string; Hash?: string | null }
UnityAssetId?: string | null
Description?: string Description?: string
PersistenceVersion?: number PersistenceVersion?: number
InventionUsage?: string InventionUsage?: string
AutoPublish?: boolean
} }
const updated = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, { const result = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, {
subRoomDataFilename: body.SubRoomData?.Filename, subRoomDataFilename: body.SubRoomData?.Filename,
subRoomDataHash:
typeof body.SubRoomData?.Hash === 'string' ? body.SubRoomData.Hash : undefined,
roomDataFilename: body.RoomData?.Filename, roomDataFilename: body.RoomData?.Filename,
unityAssetId: typeof body.UnityAssetId === 'string' ? body.UnityAssetId : undefined,
autoPublish: body.AutoPublish === true,
description: typeof body.Description === 'string' ? body.Description : undefined, description: typeof body.Description === 'string' ? body.Description : undefined,
persistenceVersion: persistenceVersion:
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined, typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined, inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined,
}) })
if (!updated) { if (!result) {
return roomResult(c, { return c.json({ success: false, error: 'This subroom does not exist!', value: null })
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
} }
// RoomUpdate carries the full room, but the HTTP response is the saved SUBROOM // `value` carries BOTH the updated room and the save just created — and `error`
// itself — no envelope. The client deserializes the body directly as the subroom. // is null here, not the empty string the other room envelopes use.
await pushRoomUpdate(c, accountId, updated) await pushRoomUpdate(c, accountId, result.room)
return c.json(findSubRoom(updated, subRoomId) ?? {}) return c.json({
success: true,
error: null,
value: { room: result.room, subRoomDataSave: toSaveResponse(result.save) },
})
} }
) )
@@ -1607,16 +1665,14 @@ const app = new Hono<App>()
Error: 'You must enter a name for your room!', Error: 'You must enter a name for your room!',
}) })
} }
const accessibility =
typeof body.accessibility === 'string'
? Number.parseInt(body.accessibility, 10)
: Number.NaN
const maxPlayers = const maxPlayers =
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, { const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, {
name, name,
accessibility: Number.isNaN(accessibility) ? undefined : accessibility, // Accepts the enum name as well as the ordinal — the dedicated
// `/accessibility` route below is sent names, so this may be too.
accessibility: parseAccessibility(body.accessibility),
maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers, maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers,
}) })
if (!updated) { if (!updated) {
@@ -1632,11 +1688,144 @@ const app = new Hono<App>()
} }
) )
// Publish a subroom's staged save — promote it to the live one players load. Every
// non-dorm room save only STAGES (see the save route), so this is the manual step that
// makes edits visible. Auth-gated (401) and creator-only: co-owners may save, but
// only the room's owner decides what goes live. Answers the updated ROOM in the
// `{ success, error, value }` envelope, like the other subroom mutations.
.post(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/publish_save',
describeRoute({
tags: ['Subrooms'],
summary: 'Publish one of a subrooms saves',
description: [
'Makes the save named by the `subRoomDataSaveId` form field the one players load —',
'it becomes the subrooms `CurrentSave`. A room save only STAGES (dorms excepted),',
'so nothing a creator saves reaches players until this is called.',
'',
'The id may be any save in the subrooms history, so this doubles as restore-a-save.',
'`StagedSubRoomDataSaveId` is cleared only when the published save IS the staged',
'one — restoring an older version keeps newer unpublished work staged.',
'',
'Owner-only: co-owners may save but not decide what goes live. A save id belonging',
'to another subroom is rejected.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam],
requestBody: form(PublishSaveRequest, 'The save to publish'),
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 body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const saveId =
typeof body.subRoomDataSaveId === 'string'
? Number.parseInt(body.subRoomDataSaveId, 10)
: Number.NaN
if (Number.isNaN(saveId)) {
return roomEnvelope(c, null, 'You must provide a valid save!')
}
const result = await publishSubRoomSave(c.env.DB, roomId, subRoomId, saveId)
if (!result.ok) {
return roomEnvelope(
c,
null,
result.reason === 'unknown_save'
? 'That save does not exist!'
: 'This subroom does not exist!'
)
}
await pushRoomUpdate(c, accountId, result.room)
return roomEnvelope(c, result.room)
}
)
// Set a single subroom's `Accessibility`. Same effect as the `accessibility` field of
// the subroom `modify` call, but this is what the client actually calls when the
// player flips one subroom's visibility, and the body carries the enum NAME
// (`accessibility=Private`), not the number the room-level `/accessibility` takes.
// Auth-gated (401) and owner-only, like the other subroom mutations. Answers the
// updated ROOM in the `{ success, error, value }` envelope — the client re-renders
// the room's subroom list from `value`, the same as subroom create/delete.
.put(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/accessibility',
describeRoute({
tags: ['Subrooms'],
summary: 'Set a subrooms accessibility',
description: [
'A subrooms own visibility, independent of the rooms top-level `Accessibility`.',
'The client sends the `RoomAccessibility` NAME here (`accessibility=Private`) rather',
'than the ordinal the room-level route takes, so both forms are accepted; an',
'unrecognised value is rejected. Owner-only — only the rooms creator may change',
'its subrooms, not co-owners.',
'',
'Answers the updated ROOM, not the bare subroom, so the client can re-render the',
'rooms subroom list from `value`.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam],
requestBody: form(SubRoomAccessibilityRequest, 'The new accessibility'),
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!')
}
if (!findSubRoom(room, subRoomId)) {
return roomEnvelope(c, null, 'This subroom does not exist!')
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const accessibility = parseAccessibility(body.accessibility)
if (accessibility === undefined) {
return roomEnvelope(c, null, 'You must provide a valid accessibility!')
}
const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, { accessibility })
if (!updated) return roomEnvelope(c, null, 'This subroom does not exist!')
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
}
)
// Clone a subroom into a new subroom of the same room (fresh SubRoomId, same // Clone a subroom into a new subroom of the same room (fresh SubRoomId, same
// scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and // scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and
// returns the `{ success, error, value }` envelope with the new subroom as `value`, // returns the updated ROOM in the `{ success, error, value }` envelope — NOT the new
// mirroring the room-level `/clone`. Response shape is a best guess (the real // subroom, even though the new subroom is what the call produces. The client
// client's expected body is unknown). // re-renders the room's subroom list from `value`, the same as subroom
// create/delete/accessibility.
.post( .post(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/clone', '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/clone',
describeRoute({ describeRoute({
@@ -1647,13 +1836,14 @@ const app = new Hono<App>()
'data blobs, so it loads identical content — with a fresh globally-unique `SubRoomId`.', 'data blobs, so it loads identical content — with a fresh globally-unique `SubRoomId`.',
'Owner-only.', 'Owner-only.',
'', '',
'The response shape is a best guess: it mirrors the room-level `/clone` envelope, but', 'Answers the updated ROOM, not the new subroom — the client re-renders the rooms',
'the real clients expected body for this call is unknown.', 'subroom list from `value`. Unlike the room-level `/clone`, whose `value` IS the new',
'room, the thing this call creates is not what comes back.',
].join('\n'), ].join('\n'),
security: AUTHED, security: AUTHED,
parameters: [roomIdParam, subRoomIdParam], parameters: [roomIdParam, subRoomIdParam],
responses: { responses: {
200: json(SubRoomEnvelope, 'The new subroom, or a rejection with `success: false`'), 200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_ENVELOPE, 401: UNAUTHORIZED_ENVELOPE,
}, },
}), }),
@@ -1676,7 +1866,7 @@ const app = new Hono<App>()
if (!result) return roomEnvelope(c, null, 'This subroom does not exist!') if (!result) return roomEnvelope(c, null, 'This subroom does not exist!')
await pushRoomUpdate(c, accountId, result.room) await pushRoomUpdate(c, accountId, result.room)
return roomEnvelope(c, result.subRoom) return roomEnvelope(c, result.room)
} }
) )
+458 -56
View File
@@ -583,6 +583,18 @@ describe('rooms endpoints', () => {
type RoomEnv = { success: boolean; error: string; value: Record<string, unknown> | null } type RoomEnv = { success: boolean; error: string; value: Record<string, unknown> | null }
const envOf = async (res: Response) => (await res.json()) as RoomEnv const envOf = async (res: Response) => (await res.json()) as RoomEnv
// A subroom as the client sees it. There is no GET for a single subroom — the client
// reads them off the room — so tests do the same.
const subRoomOf = async (
roomId: number,
subRoomId: number
): Promise<Record<string, unknown> | undefined> => {
const res = await SELF.fetch(`${ORIGIN}/rooms/${roomId}`)
if (res.status !== 200) return undefined
const room = (await res.json()) as { SubRooms?: Array<Record<string, unknown>> }
return (room.SubRooms ?? []).find((s) => s.SubRoomId === subRoomId)
}
it('PUT /rooms/:id/description is auth-gated, owner-only, and persists', async () => { it('PUT /rooms/:id/description is auth-gated, owner-only, and persists', async () => {
// No token → 401 (auth gate). // No token → 401 (auth gate).
expect((await putForm('/rooms/2/description', { description: 'x' })).status).toBe(401) expect((await putForm('/rooms/2/description', { description: 'x' })).status).toBe(401)
@@ -929,16 +941,11 @@ describe('rooms endpoints', () => {
expect(pub.value?.Accessibility).toBe(1) expect(pub.value?.Accessibility).toBe(1)
}) })
it('GET /rooms/:id/subrooms/:sid/data returns the subroom descriptor (404 when unknown)', async () => { it('there is no GET for a single subroom — only the room carries them', async () => {
// Room 2 has SubRoomId 2 in the seed. // The real API has no `GET …/subrooms/{id}/data`; the client reads subrooms off the
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`) // room. Only the POST (the room save) exists on that path, and it is auth-gated.
expect(res.status).toBe(200) expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).status).toBe(404)
expect((await res.json()) as { SubRoomId: number }).toMatchObject({ SubRoomId: 2 }) expect(await subRoomOf(2, 2)).toMatchObject({ SubRoomId: 2 })
// Unknown subroom → 404.
expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/9999/data`)).status).toBe(404)
// Unknown room → 404.
expect((await SELF.fetch(`${ORIGIN}/rooms/99999/subrooms/2/data`)).status).toBe(404)
}) })
it('POST /rooms/:id/subrooms/:sid/data is auth-gated, owner-only, and saves the blobs', async () => { it('POST /rooms/:id/subrooms/:sid/data is auth-gated, owner-only, and saves the blobs', async () => {
@@ -975,36 +982,84 @@ describe('rooms endpoints', () => {
// A valid token but no role on the room → 403. // A valid token but no role on the room → 403.
expect((await authed(2, 2, '999')).status).toBe(403) expect((await authed(2, 2, '999')).status).toBe(403)
// The response uses the PascalCase `{ Success, Value, ErrorId, Error }` envelope. // Rejections use the same lowercase envelope as the success case.
// Unknown room → DoesntExist. expect(await envOf(await authed(99999, 2, '1'))).toMatchObject({
expect(await bodyOf(await authed(99999, 2, '1'))).toMatchObject({ success: false,
Success: false, error: 'This room does not exist!',
ErrorId: 'Rooms.DoesntExist',
}) })
expect(await envOf(await authed(2, 9999, '1'))).toMatchObject({ success: false })
// Owner saves → 200 with the saved SUBROOM as the bare body (no envelope), // Owner saves → 200. `value` carries BOTH the updated room and the new save, and
// carrying the new blobs and populated creator. // `error` is null (not ''). This fixture sends `AutoPublish: true`, so it goes live.
const ok = await authed(2, 2, '1') const ok = await authed(2, 2, '1')
expect(ok.status).toBe(200) expect(ok.status).toBe(200)
expect(await bodyOf(ok)).toMatchObject({ const saved = (await ok.json()) as {
SubRoomId: 2, success: boolean
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f', error: string | null
value: {
room: Record<string, unknown>
subRoomDataSave: Record<string, unknown>
}
}
expect(saved.success).toBe(true)
expect(saved.error).toBeNull()
expect(saved.value.room).toMatchObject({ RoomId: 2, Description: 'mydescription here' })
// The save is a camelCase projection, NOT the PascalCase CurrentSave shape.
expect(saved.value.subRoomDataSave).toEqual({
subRoomDataSaveId: expect.any(Number),
subRoomId: 2,
unityAssetId: null,
unityAsset: null,
unityAssetHash: null,
dataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
dataBlobHash: null,
savedByAccountId: 1,
savedOnPlatform: 0,
savedOnDeviceClass: 0,
description: 'mydescription here',
createdAt: expect.any(String),
})
// The saved subroom rides along inside the room's SubRooms, carrying the new save.
const savedSub = (saved.value.room.SubRooms as Array<Record<string, unknown>>).find(
(s) => s.SubRoomId === 2
)!
expect(savedSub).toMatchObject({
RoomDataBlob: '5c618c920f6247efb8327e327d0b4417', RoomDataBlob: '5c618c920f6247efb8327e327d0b4417',
CreatorAccountId: 1, CreatorAccountId: 1,
PersistenceVersion: 41, PersistenceVersion: 41,
}) })
expect(savedSub.CurrentSave).toMatchObject({
// It also persists — the GET returns the subroom with the new blob + creator.
const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as {
SubRoomId: number
DataBlob: string
CreatorAccountId: number
}
expect(sub).toMatchObject({
SubRoomId: 2,
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f', DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
CreatorAccountId: 1,
}) })
// It also persists — reading the room back shows the save live.
const sub = (await subRoomOf(2, 2)) as unknown as {
SubRoomId: number
CreatorAccountId: number
CurrentSave: {
DataBlob: string
SubRoomDataSaveId: number
SavedByAccountId: number
PersistenceVersion: number
UnitySubAssets: unknown[]
Tags: unknown[]
}
StagedSubRoomDataSaveId: number | null
}
expect(sub).toMatchObject({ SubRoomId: 2, CreatorAccountId: 1 })
expect(sub.CurrentSave).toMatchObject({
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
SavedByAccountId: 1,
PersistenceVersion: 41,
UnitySubAssets: [],
Tags: [],
})
expect(sub.CurrentSave.SubRoomDataSaveId).toBeGreaterThan(0)
expect(sub.StagedSubRoomDataSaveId).toBeNull()
// Room-level fields land on the room too.
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
Description: string Description: string
PersistenceVersion: number PersistenceVersion: number
@@ -1013,10 +1068,250 @@ describe('rooms endpoints', () => {
expect(room.PersistenceVersion).toBe(41) expect(room.PersistenceVersion).toBe(41)
// A CoOwner (account 2 holds Role 30 in the seeded rooms) may also save — 200 // A CoOwner (account 2 holds Role 30 in the seeded rooms) may also save — 200
// with the subroom body. The creator stays account 1 (not clobbered). // with the room envelope. The creator stays account 1 (not clobbered).
const coOwner = await authed(2, 2, '2') const coOwner = await authed(2, 2, '2')
expect(coOwner.status).toBe(200) expect(coOwner.status).toBe(200)
expect(await bodyOf(coOwner)).toMatchObject({ SubRoomId: 2, CreatorAccountId: 1 }) const coOwnerEnv = (await coOwner.json()) as {
success: boolean
value: { room: { SubRooms: Array<Record<string, unknown>> }; subRoomDataSave: unknown }
}
expect(coOwnerEnv.success).toBe(true)
expect(coOwnerEnv.value.room.SubRooms.find((s) => s.SubRoomId === 2)).toMatchObject({
CreatorAccountId: 1,
})
// The save records who actually saved it, not the room's creator.
expect(coOwnerEnv.value.subRoomDataSave).toMatchObject({ savedByAccountId: 2 })
})
it('GET /rooms/:id gives every subroom a CurrentSave key (null before the first save)', async () => {
// The client loads a subroom's scene data from CurrentSave and nothing else, so
// the key must be PRESENT — the seeded rooms predate it and have no such field in
// their stored blob. `in` rather than a value check: absent and null differ here.
// Room 3 is seeded and never saved by another test (room 2 is the save fixture).
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/3`)).json()) as {
SubRooms: Array<Record<string, unknown>>
}
expect(room.SubRooms.length).toBeGreaterThan(0)
for (const sub of room.SubRooms) {
expect('CurrentSave' in sub).toBe(true)
expect(sub.CurrentSave).toBeNull()
}
})
it('a real client room-save body stages, and publish_save makes it live', async () => {
const save = async (body: unknown) =>
SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/data`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await bearer('1')) },
body: JSON.stringify(body),
})
type Sub = {
CurrentSave: Record<string, unknown> | null
StagedSubRoomDataSaveId: number | null
}
const subOf = async () => (await subRoomOf(5, 5)) as unknown as Sub
const publish = async (saveId: number, sub = '1') =>
SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/publish_save`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ subRoomDataSaveId: String(saveId) }).toString(),
})
// The exact body the live client posts after uploading both blobs to `storage`:
// SubRoomData is the scene blob, RoomData the metadata blob.
const res = await save({
UnityAssetId: null,
RoomData: { Filename: '2026-07-28/b266ccd5-metadata', Hash: null, OwnershipProof: null },
SubRoomData: { Filename: '2026-07-28/f176fc3b-scene', Hash: null, OwnershipProof: null },
InventionUsage: 'CAE=',
PersistenceVersion: 51,
Description: 'TEST',
AutoPublish: false,
})
expect(res.status).toBe(200)
// Room 5 is not a dorm → staged, nothing live yet.
const stagedSub = await subOf()
expect(stagedSub.CurrentSave).toBeNull()
const firstId = stagedSub.StagedSubRoomDataSaveId!
expect(firstId).toBeGreaterThan(0)
// Publishing makes it what the loader fetches, and clears the staging slot.
expect((await publish(firstId)).status).toBe(200)
const live = await subOf()
expect(live.StagedSubRoomDataSaveId).toBeNull()
expect(live.CurrentSave).toMatchObject({
SubRoomId: 5,
SubRoomDataSaveId: firstId,
DataBlob: '2026-07-28/f176fc3b-scene',
PersistenceVersion: 51,
SavedByAccountId: 1,
Description: 'TEST',
OMVersion: 0,
UgcSubVersion: 0,
ModerationState: 0,
})
// DataBlobHash rides along (null — the client sent `Hash: null`); UnityAssetId is
// omitted entirely rather than nulled, since the save carried none.
expect(live.CurrentSave!.DataBlobHash).toBeNull()
expect('UnityAssetId' in live.CurrentSave!).toBe(false)
// It's on the room read too — that's what the loader actually fetches.
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/5`)).json()) as {
SubRooms: Array<{ SubRoomId: number; CurrentSave: { SubRoomDataSaveId: number } | null }>
}
expect(room.SubRooms.find((s) => s.SubRoomId === 5)!.CurrentSave!.SubRoomDataSaveId).toBe(
firstId
)
// A second save appends and stages — what players load does NOT change.
await save({ SubRoomData: { Filename: 'second.room' } })
const afterSecond = await subOf()
const secondId = afterSecond.StagedSubRoomDataSaveId!
expect(secondId).toBeGreaterThan(firstId)
expect(afterSecond.CurrentSave).toMatchObject({ SubRoomDataSaveId: firstId })
// Both saves are in the history, newest first — the first one is not lost.
const history = (await (
await SELF.fetch(`${ORIGIN}/rooms/5/subrooms/5/saves`, { headers: await bearer('1') })
).json()) as {
Results: Array<{ DataBlob: string; Description: string }>
TotalResults: number
}
expect(history.TotalResults).toBe(2)
expect(history.Results.map((s) => s.DataBlob)).toEqual([
'second.room',
'2026-07-28/f176fc3b-scene',
])
// A save with no Description records an empty string, not null.
expect(history.Results[0]!.Description).toBe('')
// Publishing an OLDER save is a restore — and keeps the newer staged work.
expect((await publish(secondId)).status).toBe(200)
expect((await subOf()).StagedSubRoomDataSaveId).toBeNull()
expect((await publish(firstId)).status).toBe(200)
const restored = await subOf()
expect(restored.CurrentSave).toMatchObject({ SubRoomDataSaveId: firstId })
// A save id from a different subroom is rejected, even though ids are global.
const foreign = (await (
await publish(
((await subRoomOf(2, 2)) as unknown as Sub).CurrentSave!.SubRoomDataSaveId as number
)
).json()) as { success: boolean; error: string }
expect(foreign.success).toBe(false)
expect(foreign.error).toBe('That save does not exist!')
// Co-owners may save but not publish.
expect(((await (await publish(firstId, '2')).json()) as { success: boolean }).success).toBe(
false
)
})
it('migrates a pre-CurrentSave subroom into a real save row (0008 backfill 2)', async () => {
// A subroom saved by the older code has its blob in the flat DataBlob field and no
// CurrentSave at all. seedRoomWithSubRooms mirrors the migration, so this covers
// the backfill: the flat fields become a save row the subroom points at, rather
// than reading as never-saved and hiding real content from the loader.
await seedRoomWithSubRooms(env.DB, {
RoomId: 820,
Name: 'LegacyShaped',
CreatorAccountId: 1,
SubRooms: [
{
SubRoomId: 830,
Name: 'Legacy',
CreatorAccountId: 7,
UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163',
MaxPlayers: 4,
Accessibility: 2,
DataBlob: 'legacy-blob.room',
DataSavedAt: '2024-03-04T05:06:07.000Z',
PersistenceVersion: 12,
},
],
})
const sub = (await subRoomOf(820, 830)) as unknown as {
CurrentSave: Record<string, unknown>
}
expect(sub.CurrentSave).toMatchObject({
SubRoomId: 830,
DataBlob: 'legacy-blob.room',
PersistenceVersion: 12,
SavedByAccountId: 7,
CreatedAt: '2024-03-04T05:06:07.000Z',
UnitySubAssets: [],
Tags: [],
})
// Stable across reads — it's a stored row now, not something rebuilt per request.
const again = (await subRoomOf(820, 830)) as unknown as {
CurrentSave: { SubRoomDataSaveId: number }
}
expect(again.CurrentSave.SubRoomDataSaveId).toBe(sub.CurrentSave.SubRoomDataSaveId)
})
it('a dorm save publishes immediately instead of staging', async () => {
// Room 1 is the seeded DormRoom (IsDorm). A dorm is the player's own space with no
// publish step in the client, so staging one would make their edits permanently
// invisible — dorm saves go straight live.
const res = await SELF.fetch(`${ORIGIN}/rooms/1/subrooms/1/data`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await bearer('1')) },
body: JSON.stringify({ SubRoomData: { Filename: 'dorm.room' } }),
})
expect(res.status).toBe(200)
const sub = (await subRoomOf(1, 1)) as unknown as {
CurrentSave: { DataBlob: string } | null
StagedSubRoomDataSaveId: number | null
}
expect(sub.CurrentSave).toMatchObject({ DataBlob: 'dorm.room' })
expect(sub.StagedSubRoomDataSaveId).toBeNull()
})
it('save ids are globally unique across subrooms, so a bare id resolves', async () => {
// StagedSubRoomDataSaveId points at a save by bare id with no subroom context, so
// per-subroom numbering (every subroom's first save being 1) would be ambiguous.
const save = async (roomId: number, subRoomId: number) =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/data`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await bearer('1')) },
body: JSON.stringify({ SubRoomData: { Filename: `blob-${subRoomId}.room` } }),
})
// Non-dorm saves stage, so the fresh id lands on StagedSubRoomDataSaveId.
const idOf = async (roomId: number, subRoomId: number) =>
((await subRoomOf(roomId, subRoomId)) as unknown as { StagedSubRoomDataSaveId: number })
.StagedSubRoomDataSaveId
// Two different subrooms, each getting their FIRST save.
await save(6, 6)
await save(7, 7)
expect(await idOf(6, 6)).not.toBe(await idOf(7, 7))
})
it('a cloned subroom re-points CurrentSave at the copy, not the source', async () => {
// Save room 2's subroom so there is a CurrentSave to copy.
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`, {
method: 'POST',
headers: { ...(await bearer('1')), 'Content-Type': 'application/json' },
body: JSON.stringify({ SubRoomData: { Filename: 'cloned-source.room' } }),
})
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, {
method: 'POST',
headers: await bearer('1'),
})
const body = (await res.json()) as {
value: { SubRooms: Array<{ SubRoomId: number; CurrentSave: { SubRoomId: number } | null }> }
}
const clone = body.value.SubRooms.find((s) => s.SubRoomId !== 2 && s.CurrentSave !== null)!
expect(clone).toBeDefined()
// The copy's save must claim the COPY, or the client resolves it against the source.
expect(clone.CurrentSave!.SubRoomId).toBe(clone.SubRoomId)
}) })
it('PUT /rooms/:id/tags is auth-gated, owner-only, and toggles (add/remove)', async () => { it('PUT /rooms/:id/tags is auth-gated, owner-only, and toggles (add/remove)', async () => {
@@ -1326,7 +1621,7 @@ describe('rooms endpoints', () => {
const ok = await putForm('/rooms/2/subrooms/2/modify', fields, '1') const ok = await putForm('/rooms/2/subrooms/2/modify', fields, '1')
expect(ok.status).toBe(200) expect(ok.status).toBe(200)
expect(await bodyOf(ok)).toMatchObject({ Success: true }) expect(await bodyOf(ok)).toMatchObject({ Success: true })
const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as { const sub = (await subRoomOf(2, 2)) as unknown as {
Name: string Name: string
Accessibility: number Accessibility: number
MaxPlayers: number MaxPlayers: number
@@ -1334,17 +1629,65 @@ describe('rooms endpoints', () => {
expect(sub).toMatchObject({ Name: 'My Cool Subroom', Accessibility: 1, MaxPlayers: 20 }) expect(sub).toMatchObject({ Name: 'My Cool Subroom', Accessibility: 1, MaxPlayers: 20 })
}) })
it('PUT /rooms/:id/subrooms/:sid/accessibility takes the enum name the client sends', async () => {
const path = '/rooms/2/subrooms/2/accessibility'
const accessibilityOf = async () =>
((await subRoomOf(2, 2)) as unknown as { Accessibility: number }).Accessibility
// No token → 401.
expect((await putForm(path, { accessibility: 'Private' })).status).toBe(401)
// Not the owner (room 2 is owned by account 1) → failure envelope.
expect(await envOf(await putForm(path, { accessibility: 'Private' }, '999'))).toMatchObject({
success: false,
error: 'You are not the owner of this room!',
})
// Unknown room / unknown subroom → failure envelope.
expect(
await envOf(
await putForm('/rooms/99999/subrooms/2/accessibility', { accessibility: '0' }, '1')
)
).toMatchObject({ success: false })
expect(
await envOf(
await putForm('/rooms/2/subrooms/9999/accessibility', { accessibility: '0' }, '1')
)
).toMatchObject({ success: false })
// A value that names nothing in the enum → rejected, not silently stored.
expect(await envOf(await putForm(path, { accessibility: 'Nonsense' }, '1'))).toMatchObject({
success: false,
error: 'You must provide a valid accessibility!',
})
// The name form is what the live client sends.
const priv = await envOf(await putForm(path, { accessibility: 'Private' }, '1'))
expect(priv.success).toBe(true)
// The envelope carries the updated ROOM, so the client can re-render the subroom list.
expect(priv.value).toMatchObject({ RoomId: 2 })
expect(await accessibilityOf()).toBe(0)
// Case-insensitive, and the later enum members resolve too.
expect((await envOf(await putForm(path, { accessibility: 'dev_unlisted' }, '1'))).success).toBe(
true
)
expect(await accessibilityOf()).toBe(4)
// The ordinal still works.
expect((await envOf(await putForm(path, { accessibility: '1' }, '1'))).success).toBe(true)
expect(await accessibilityOf()).toBe(1)
})
it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => { it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => {
const clone = async (roomId: number, subRoomId: number, sub?: string) => const clone = async (roomId: number, subRoomId: number, sub?: string) =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, { SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, {
method: 'POST', method: 'POST',
headers: sub ? await bearer(sub) : {}, headers: sub ? await bearer(sub) : {},
}) })
type SubRoom = { SubRoomId: number; CreatorAccountId: number }
const envelope = async (res: Response) => const envelope = async (res: Response) =>
(await res.json()) as { (await res.json()) as {
success: boolean success: boolean
error: string error: string
value: { SubRoomId: number; CreatorAccountId: number } | null value: { RoomId: number; SubRooms: SubRoom[] } | null
} }
// No token → 401. // No token → 401.
@@ -1354,17 +1697,27 @@ describe('rooms endpoints', () => {
// Unknown subroom → success:false envelope. // Unknown subroom → success:false envelope.
expect((await envelope(await clone(2, 9999, '1'))).success).toBe(false) expect((await envelope(await clone(2, 9999, '1'))).success).toBe(false)
// Owner clones → success, a fresh SubRoomId owned by the caller, fetchable on the room. const before = new Set(
(
(await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { SubRooms: SubRoom[] }
).SubRooms.map((s) => s.SubRoomId)
)
// Owner clones → success. `value` is the updated ROOM, not the new subroom, so the
// clone shows up as one extra entry in its re-attached SubRooms list.
const res = await clone(2, 2, '1') const res = await clone(2, 2, '1')
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = await envelope(res) const body = await envelope(res)
expect(body.success).toBe(true) expect(body.success).toBe(true)
expect(body.value?.SubRoomId).not.toBe(2) expect(body.value?.RoomId).toBe(2)
expect(body.value?.CreatorAccountId).toBe(1) const added = body.value!.SubRooms.filter((s) => !before.has(s.SubRoomId))
const fetched = (await ( expect(added).toHaveLength(1)
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/${body.value?.SubRoomId}/data`) expect(added[0]!.CreatorAccountId).toBe(1)
).json()) as { SubRoomId: number } // A fresh id, and fetchable as a subroom of the room.
expect(fetched.SubRoomId).toBe(body.value?.SubRoomId) expect(added[0]!.SubRoomId).not.toBe(2)
expect(await subRoomOf(2, added[0]!.SubRoomId)).toMatchObject({
SubRoomId: added[0]!.SubRoomId,
})
}) })
it('subroom clone mints a globally-unique SubRoomId (no cross-room clash)', async () => { it('subroom clone mints a globally-unique SubRoomId (no cross-room clash)', async () => {
@@ -1379,14 +1732,18 @@ describe('rooms endpoints', () => {
method: 'POST', method: 'POST',
headers: await bearer('1'), headers: await bearer('1'),
}) })
const body = (await res.json()) as { value: { SubRoomId: number; RoomId: number } } // `value` is the updated room; the clone is its highest-numbered subroom.
const body = (await res.json()) as {
value: { RoomId: number; SubRooms: Array<{ SubRoomId: number }> }
}
const cloned = Math.max(...body.value.SubRooms.map((s) => s.SubRoomId))
// Above every prior subroom id — a fresh global id, not a per-room collision. // Above every prior subroom id — a fresh global id, not a per-room collision.
expect(body.value.SubRoomId).toBeGreaterThan(maxBefore) expect(cloned).toBeGreaterThan(maxBefore)
expect(body.value.RoomId).toBe(2) expect(body.value.RoomId).toBe(2)
// The id is unique across the whole table (exactly one row owns it). // The id is unique across the whole table (exactly one row owns it).
const dupes = (await env.DB.prepare('SELECT COUNT(*) AS n FROM subroom WHERE sub_room_id = ?1') const dupes = (await env.DB.prepare('SELECT COUNT(*) AS n FROM subroom WHERE sub_room_id = ?1')
.bind(body.value.SubRoomId) .bind(cloned)
.first<{ n: number }>())!.n .first<{ n: number }>())!.n
expect(dupes).toBe(1) expect(dupes).toBe(1)
}) })
@@ -1431,9 +1788,11 @@ describe('rooms endpoints', () => {
expect(created).toMatchObject({ RoomId: 2, Name: 'ffff', CreatorAccountId: 1 }) expect(created).toMatchObject({ RoomId: 2, Name: 'ffff', CreatorAccountId: 1 })
expect(created!.SubRoomId).toBeGreaterThan(maxBefore) expect(created!.SubRoomId).toBeGreaterThan(maxBefore)
const fetched = (await ( const fetched = (await subRoomOf(2, created!.SubRoomId)) as unknown as {
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/${created?.SubRoomId}/data`) SubRoomId: number
).json()) as { SubRoomId: number; Name: string; UnitySceneId: string } Name: string
UnitySceneId: string
}
expect(fetched).toMatchObject({ SubRoomId: created?.SubRoomId, Name: 'ffff' }) expect(fetched).toMatchObject({ SubRoomId: created?.SubRoomId, Name: 'ffff' })
// It inherits room 2's own existing (first) subroom scene. // It inherits room 2's own existing (first) subroom scene.
@@ -1476,7 +1835,7 @@ describe('rooms endpoints', () => {
const body = await envelope(await del(2, newId, '1')) const body = await envelope(await del(2, newId, '1'))
expect(body.success).toBe(true) expect(body.success).toBe(true)
expect(body.value?.SubRooms.some((s) => s.SubRoomId === newId)).toBe(false) expect(body.value?.SubRooms.some((s) => s.SubRoomId === newId)).toBe(false)
expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/${newId}/data`)).status).toBe(404) expect(await subRoomOf(2, newId)).toBeUndefined()
// A room's only subroom can't be deleted (would leave it with no scene). Seed a // A room's only subroom can't be deleted (would leave it with no scene). Seed a
// dedicated single-subroom room owned by account 1 to exercise the guard. // dedicated single-subroom room owned by account 1 to exercise the guard.
@@ -1488,15 +1847,57 @@ describe('rooms endpoints', () => {
}) })
expect((await envelope(await del(700, 900, '1'))).success).toBe(false) expect((await envelope(await del(700, 900, '1'))).success).toBe(false)
// The lone subroom survives the refused delete. // The lone subroom survives the refused delete.
expect((await SELF.fetch(`${ORIGIN}/rooms/700/subrooms/900/data`)).status).toBe(200) expect(await subRoomOf(700, 900)).toBeDefined()
}) })
it('GET /rooms/:id/subrooms/:sid/saves returns an empty paged result', async () => { it('GET /rooms/:id/subrooms/:sid/saves pages the save history, newest first', async () => {
const res = await SELF.fetch( type Page = {
`${ORIGIN}/rooms/2/subrooms/2/saves?unityAssetTarget=0&unityAssetVersion=1&skip=0&take=20` Results: Array<{ SubRoomId: number; SubRoomDataSaveId: number }>
) TotalResults: number
expect(res.status).toBe(200) TotalCount: number
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 }) }
const page = async (query: string) =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves${query}`, {
headers: await bearer('1'),
})
).json()) as Page
// Room 2's subroom is saved several times by the tests above — each save appended.
const all = await page('?unityAssetTarget=0&unityAssetVersion=1')
expect(all.Results.length).toBeGreaterThan(1)
expect(all.Results.every((s) => s.SubRoomId === 2)).toBe(true)
// Newest first: ids descend.
const ids = all.Results.map((s) => s.SubRoomDataSaveId)
expect([...ids].sort((a, b) => b - a)).toEqual(ids)
// Both spellings of the count, and they agree with the list.
expect(all.TotalResults).toBe(all.Results.length)
expect(all.TotalCount).toBe(all.TotalResults)
// skip/take actually page rather than being ignored.
const paged = await page('?skip=1&take=1')
expect(paged.Results).toHaveLength(1)
expect(paged.Results[0]!.SubRoomDataSaveId).toBe(ids[1])
expect(paged.TotalResults).toBe(all.TotalResults)
// A never-saved subroom pages empty rather than 404ing.
const empty = await SELF.fetch(`${ORIGIN}/rooms/3/subrooms/3/saves`, {
headers: await bearer('1'),
})
expect(await empty.json()).toEqual({ Results: [], TotalResults: 0, TotalCount: 0 })
// The list exposes unpublished saves, so it is owner-only: no token → 401, and a
// valid token that isn't the room's creator → 403.
expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`)).status).toBe(401)
expect(
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') }))
.status
).toBe(403)
// Even a co-owner (account 2 holds Role 30 on the seeded rooms) is refused.
expect(
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('2') }))
.status
).toBe(403)
}) })
it('GET /openapi.json documents every route', async () => { it('GET /openapi.json documents every route', async () => {
@@ -1542,7 +1943,6 @@ describe('rooms endpoints', () => {
'GET /rooms/{roomId}/interactionby/me', 'GET /rooms/{roomId}/interactionby/me',
'GET /rooms/{roomId}/playerdata/me', 'GET /rooms/{roomId}/playerdata/me',
'GET /rooms/{roomId}/similar', 'GET /rooms/{roomId}/similar',
'GET /rooms/{roomId}/subrooms/{subRoomId}/data',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves', 'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
'GET /roomserver/photon_access_token', 'GET /roomserver/photon_access_token',
'GET /roomserver/rooms/createdby/me', 'GET /roomserver/rooms/createdby/me',
@@ -1550,6 +1950,7 @@ describe('rooms endpoints', () => {
'POST /rooms/{roomId}/subrooms', 'POST /rooms/{roomId}/subrooms',
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone', 'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
'POST /rooms/{roomId}/subrooms/{subRoomId}/data', 'POST /rooms/{roomId}/subrooms/{subRoomId}/data',
'POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save',
'PUT /rooms/{roomId}/accessibility', 'PUT /rooms/{roomId}/accessibility',
'PUT /rooms/{roomId}/cloning', 'PUT /rooms/{roomId}/cloning',
'PUT /rooms/{roomId}/description', 'PUT /rooms/{roomId}/description',
@@ -1560,6 +1961,7 @@ describe('rooms endpoints', () => {
'PUT /rooms/{roomId}/name', 'PUT /rooms/{roomId}/name',
'PUT /rooms/{roomId}/restrictions', 'PUT /rooms/{roomId}/restrictions',
'PUT /rooms/{roomId}/roles/{accountId}', 'PUT /rooms/{roomId}/roles/{accountId}',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
'PUT /rooms/{roomId}/tags', 'PUT /rooms/{roomId}/tags',
'PUT /rooms/{roomId}/warning', 'PUT /rooms/{roomId}/warning',
+4 -7
View File
@@ -3,10 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RecFlare — an open source implementation of the 2023 RecNet servers</title> <title>RecFlare — play like it&apos;s 2023</title>
<meta <meta
name="description" name="description"
content="RecFlare is an open source implementation of the 2023 RecNet servers, designed for the cloud and running on Cloudflare Workers." content="RecFlare is a free, open source fan rebuild of the 2023 RecNet servers. Download for PC, join the Discord, and play like it's 2023."
/> />
<meta name="theme-color" content="#14100c" /> <meta name="theme-color" content="#14100c" />
<!-- Inline so the mark costs no request: the orange spark from the RecFlare logo. --> <!-- Inline so the mark costs no request: the orange spark from the RecFlare logo. -->
@@ -14,14 +14,11 @@
rel="icon" rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%2314100c'/%3E%3Cpath d='M16 5c1.8 4.2 4.3 6 6.4 8.1a8.9 8.9 0 1 1-12.8 0C11.7 11 14.2 9.2 16 5z' fill='%23fe7101'/%3E%3C/svg%3E" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%2314100c'/%3E%3Cpath d='M16 5c1.8 4.2 4.3 6 6.4 8.1a8.9 8.9 0 1 1-12.8 0C11.7 11 14.2 9.2 16 5z' fill='%23fe7101'/%3E%3C/svg%3E"
/> />
<!-- <!-- Archivo sets the nameplate; IBM Plex Sans carries everything else. -->
Archivo sets the nameplate; IBM Plex Sans/Mono is the machine's own voice
(body copy and the mono metadata on the photo feed).
-->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;700;800&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
</head> </head>
+13 -21
View File
@@ -1,19 +1,9 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { DISCORD_INVITE, DOWNLOAD_URL, LICENSE_URL, SOURCE_REPO } from '../links'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
/** The community Discord — the join instructions and the build both live there. */
const DISCORD_INVITE = 'https://discord.gg/HhmMAKhrz'
/** Where the stage's "Download for PC" button goes: the client's release listing. */
const DOWNLOAD_URL = 'https://github.com/djdevin/recflare-client/releases'
/** The public source repo, linked from the homepage and footer. */
const SOURCE_REPO = 'https://github.com/djdevin/recflare'
/** The repo's licence, behind the footer's "MIT licensed". */
const LICENSE_URL = `${SOURCE_REPO}/blob/main/LICENSE`
/** The self-account shape returned by the www BFF (`/api/me`, `/api/login`, …). */ /** The self-account shape returned by the www BFF (`/api/me`, `/api/login`, …). */
interface SelfAccount { interface SelfAccount {
accountId: number accountId: number
@@ -132,9 +122,12 @@ function SiteFooter() {
<a href={LICENSE_URL} target="_blank" rel="noreferrer"> <a href={LICENSE_URL} target="_blank" rel="noreferrer">
MIT licensed MIT licensed
</a>{' '} </a>{' '}
· a fan project, not affiliated with Rec Room Inc. made by fans, not affiliated with Rec Room Inc.
</span> </span>
<nav> <nav>
{/* A real navigation, not a client-side route: /privacy is rendered by the
Worker (see src/privacy.ts) so it reads without JavaScript. */}
<a href="/privacy">Privacy</a>
<a href={DISCORD_INVITE} target="_blank" rel="noreferrer"> <a href={DISCORD_INVITE} target="_blank" rel="noreferrer">
Discord Discord
</a> </a>
@@ -301,12 +294,11 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
return ( return (
<section className="about"> <section className="about">
<div> <div>
<h2 className="about-title"> <h2 className="about-title">An open source rebuild of the 2023 servers</h2>
An open source implementation of the 2023 RecNet servers, designed for the cloud
</h2>
<p className="about-lede"> <p className="about-lede">
A free, independent fan project, aiming to be <strong>feature-complete</strong> and A free fan project, made by players who missed it. Aiming to be{' '}
infinitely scalable. No gatekeeping, no basement server. Designed for Cloudflare Workers. <strong>feature-complete</strong> and infinitely scalable no gatekeeping, no basement
server.
</p> </p>
</div> </div>
<div className="about-side"> <div className="about-side">
@@ -319,10 +311,10 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
<p className={`status ${state}`}> <p className={`status ${state}`}>
<span className="dot" /> <span className="dot" />
{state === 'online' {state === 'online'
? 'Server online' ? 'Servers are up'
: state === 'down' : state === 'down'
? 'Server unreachable' ? "Can't reach the servers"
: 'Checking the server'} : 'Checking'}
</p> </p>
{/* Only when it's actually up: when it isn't, people want the status, not the joke. */} {/* Only when it's actually up: when it isn't, people want the status, not the joke. */}
{state === 'online' && <p className="status-quip">The cloud never goes down, right?</p>} {state === 'online' && <p className="status-quip">The cloud never goes down, right?</p>}
+16 -22
View File
@@ -1,14 +1,15 @@
/* /*
* RecFlare — a live-service readout. * RecFlare — a poster for a game that's up right now.
* *
* The page's job is to show that this server is actually up and that real people are * The page's job is to show that real people are playing on this server, then get you
* on it, then get you into the Discord. So the surface is warm-dark (the room the * into the game or the Discord. So the surface is warm-dark (the room the
* screenshots are lit against), and the logo orange (#FE7101) is spent only on actions * screenshots are lit against), and the logo orange (#FE7101) is spent only on actions
* and the brand mark — never as decoration, or it stops reading as "click this". Green * and the brand mark — never as decoration, or it stops reading as "click this". Green
* means one thing throughout, healthy: the server is up, or the change saved. * means one thing throughout, healthy: the server is up, or the change saved.
* *
* Type: Archivo for the nameplate, IBM Plex Sans for prose, IBM Plex Mono for anything * Type: Archivo for the nameplate, IBM Plex Sans for everything else. There is
* the server itself would say (handles, room names, counts, endpoint paths). * deliberately no monospace anywhere — this is a game, and mono type made the whole
* page read like a status dashboard.
*/ */
:root { :root {
color-scheme: dark light; color-scheme: dark light;
@@ -29,7 +30,6 @@
--display: Archivo, system-ui, sans-serif; --display: Archivo, system-ui, sans-serif;
--body: 'IBM Plex Sans', system-ui, -apple-system, Segoe UI, Roboto, sans-serif; --body: 'IBM Plex Sans', system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
--mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--radius: 10px; --radius: 10px;
} }
@@ -109,9 +109,8 @@ body {
gap: 9px; gap: 9px;
font-family: var(--display); font-family: var(--display);
font-weight: 800; font-weight: 800;
font-size: 1.05rem; font-size: 1.2rem;
letter-spacing: 0.02em; letter-spacing: -0.015em;
text-transform: uppercase;
color: var(--text); color: var(--text);
text-decoration: none; text-decoration: none;
} }
@@ -132,9 +131,8 @@ body {
.nav-links a, .nav-links a,
.linkish { .linkish {
font-family: var(--mono); font-family: var(--body);
font-size: 0.8rem; font-size: 0.95rem;
letter-spacing: 0.02em;
color: var(--muted); color: var(--muted);
text-decoration: none; text-decoration: none;
} }
@@ -345,16 +343,15 @@ body {
opacity: 0.8; opacity: 0.8;
} }
/* Status of the actual server, not decoration — see About. */ /* Status of the actual server, not decoration — see About. Set in plain sentence
case: uppercase mono made a friendly "we're up" read like a monitoring alert. */
.status { .status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 9px; gap: 9px;
margin: 0; margin: 0;
font-family: var(--mono); font-size: 0.95rem;
font-size: 0.75rem; font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted); color: var(--muted);
} }
@@ -443,8 +440,7 @@ body {
margin: 0 auto; margin: 0 auto;
padding: 24px 20px 40px; padding: 24px 20px 40px;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
font-family: var(--mono); font-size: 0.875rem;
font-size: 0.75rem;
color: var(--muted); color: var(--muted);
} }
@@ -543,10 +539,8 @@ h2 {
margin-bottom: 0; margin-bottom: 0;
} }
/* Identity strip on the account page: the account's own record, set in mono. */
.identity .handle { .identity .handle {
font-family: var(--mono); font-size: 0.9rem;
font-size: 0.85rem;
color: var(--muted); color: var(--muted);
} }
+34
View File
@@ -0,0 +1,34 @@
/**
* Outbound links shared by the React client and the Worker-rendered pages.
*
* The privacy policy (`privacy.ts`) is rendered server-side while the rest of the site
* is a React SPA, so both need the same Discord/GitHub URLs. They live here so a moved
* invite or a renamed repo is one edit, not two that can silently drift — the policy
* naming a dead contact channel is exactly what VRC.Privacy.4 fails on.
*/
/** The community Discord — the join instructions and the build both live there. */
export const DISCORD_INVITE = 'https://discord.gg/HhmMAKhrz'
/** Where the stage's "Download for PC" button goes: the client's release listing. */
export const DOWNLOAD_URL = 'https://github.com/djdevin/recflare-client/releases'
/** The public source repo, linked from the homepage and footer. */
export const SOURCE_REPO = 'https://github.com/djdevin/recflare'
/** The repo's licence, behind the footer's "MIT licensed". */
export const LICENSE_URL = `${SOURCE_REPO}/blob/main/LICENSE`
/** Where a data-deletion or privacy request can be opened without a Discord account. */
export const ISSUES_URL = `${SOURCE_REPO}/issues/new`
/**
* Mailbox for privacy and data-deletion requests, or '' when there isn't one.
*
* Empty by default on purpose: an address printed here that nobody reads is worse than
* no address at all, and Meta re-validates the policy after approval. While it's empty
* the policy routes deletion requests through Discord and GitHub issues, both of which
* are free and open to anyone in any region — which is what VRC.Privacy.4 asks for. Set
* it to a real, monitored mailbox and the policy adds it as the preferred contact.
*/
export const PRIVACY_EMAIL: string = 'privacy@recflare.net'
+359
View File
@@ -0,0 +1,359 @@
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL, SOURCE_REPO } from './links'
/**
* The privacy policy, served on www at `/privacy`.
*
* Rendered by the Worker rather than by the React SPA on purpose. The Meta Horizon
* Store's VRC.Privacy.1 check fetches this URL periodically and looks for a live page
* whose text contains "Privacy Policy"; a client-rendered route would answer that fetch
* with an empty `<div id="root">` and could be flagged non-compliant even though a
* browser renders it fine. Server-rendering also means the policy survives a JS error
* or a blocked script, which is the one page on this site that has to.
*
* `/privacy` must therefore stay listed in `run_worker_first` in wrangler.jsonc —
* without it a top-level navigation is served index.html and never reaches this module.
*
* The four VRCs this is written against (developers.meta.com/horizon/resources/):
* Privacy.1 — the URL is live, public, HTTPS, and owned by the app's team.
* Privacy.2 — states what data is processed, collected and stored.
* Privacy.3 — states what that data is used for.
* Privacy.4 — states how any user, in any region, can request deletion, for free.
* Keep the "What we collect" section honest against the schemas it describes
* (packages/domain/src/accounts-db.ts and the per-worker migrations) — that section is
* the claim Privacy.2 is judged on, and it goes stale the moment a worker stores
* something new.
*
* "How you sign in" describes Meta SSO (PlatformType.Oculus), which the auth worker
* still stubs — see the FAKE_OCULUS_CACHED_LOGIN branch in apps/auth/src/auth.app.ts.
* When that lands, check the text still matches what the integration actually requests
* from Meta: Privacy.2 asks for extra detail about platform features specifically, and
* the same disclosure has to agree with the Data Use Checkup filed for the app.
*/
/** Last substantive revision, shown in the header. Bump when the text changes. */
const EFFECTIVE_DATE = '26 July 2026'
/** The palette and type of the main site, inlined — this page loads no stylesheet. */
const STYLES = `
:root {
color-scheme: dark light;
--bg: #14100c;
--surface: #1e1813;
--line: #33291f;
--text: #f5ede1;
--muted: #a8927c;
--accent: #fe7101;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f7f5f2;
--surface: #ffffff;
--line: #e2ddd6;
--text: #201a14;
--muted: #736656;
--accent: #e05f00;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: 'IBM Plex Sans', system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
font-size: 1rem;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); }
.nav {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
max-width: 760px;
margin: 0 auto;
padding: 20px;
border-bottom: 1px solid var(--line);
}
.brand {
font-family: Archivo, system-ui, sans-serif;
font-weight: 800;
font-size: 1.2rem;
letter-spacing: -0.015em;
color: var(--text);
text-decoration: none;
}
.nav a.back { color: var(--muted); text-decoration: none; font-size: 0.95rem; }
.nav a.back:hover { color: var(--text); }
main { max-width: 760px; margin: 0 auto; padding: 40px 20px 8px; }
h1 {
font-family: Archivo, system-ui, sans-serif;
font-weight: 800;
font-size: clamp(1.9rem, 4vw, 2.6rem);
line-height: 1.1;
letter-spacing: -0.03em;
margin: 0 0 8px;
}
h2 {
font-family: Archivo, system-ui, sans-serif;
font-weight: 700;
font-size: 1.3rem;
letter-spacing: -0.02em;
margin: 40px 0 10px;
}
h3 { font-size: 1rem; font-weight: 600; margin: 24px 0 6px; }
p, li { max-width: 68ch; }
.updated { color: var(--muted); font-size: 0.9rem; margin: 0 0 8px; }
.lede { font-size: 1.075rem; }
ul { padding-left: 22px; }
li { margin-bottom: 8px; }
li > strong { font-weight: 600; }
.callout {
background: var(--surface);
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
border-radius: 10px;
padding: 18px 22px;
margin: 20px 0;
}
.callout p:first-child { margin-top: 0; }
.callout p:last-child { margin-bottom: 0; }
footer {
max-width: 760px;
margin: 0 auto;
padding: 24px 20px 48px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.875rem;
}
footer a { color: var(--muted); }
`
/**
* How to reach a human about a privacy or deletion request. Rendered in both the
* deletion section and the footer, so the mailbox (when there is one — see
* PRIVACY_EMAIL) can't be listed in one place and forgotten in the other.
*/
function contactList(): string {
const email = PRIVACY_EMAIL
? `<li><strong>Email</strong> — <a href="mailto:${PRIVACY_EMAIL}">${PRIVACY_EMAIL}</a>.</li>`
: ''
return `<ul>
${email}
<li><strong>Discord</strong> — ask a moderator in <a href="${DISCORD_INVITE}" target="_blank" rel="noreferrer">our Discord server</a>.</li>
<li><strong>GitHub</strong> — <a href="${ISSUES_URL}" target="_blank" rel="noreferrer">open an issue</a> on the project repo. Don't post personal details in a public issue; your username is enough for us to find you.</li>
</ul>`
}
/** The `/privacy` HTML page. Static text — nothing here is interpolated from a request. */
export function privacyPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Privacy Policy — RecFlare</title>
<meta name="description" content="What RecFlare collects, why, and how to have your data deleted." />
<meta name="theme-color" content="#14100c" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
<style>${STYLES}</style>
</head>
<body>
<header class="nav">
<a class="brand" href="/">RecFlare</a>
<a class="back" href="/">Back to the site</a>
</header>
<main>
<h1>Privacy Policy</h1>
<p class="updated">Last updated ${EFFECTIVE_DATE}</p>
<p class="lede">
RecFlare is a free, open source, fan-run game server. It is not a business, it sells
nothing, and it has no interest in your data beyond making the game work. This page
explains exactly what we store, why we store it, and how to make us delete it.
</p>
<div class="callout">
<p>
<strong>The short version.</strong> We store your account, the things you make in
game (photos, rooms, messages, inventory), and the technical details needed to log
you in and keep the server from being abused. We don't sell anything, we run no
advertising, and we've integrated no third-party analytics or tracking. Ask us and
we'll delete your account and its data, free, wherever you live.
</p>
</div>
<h2>Who runs this</h2>
<p>
RecFlare is maintained by a volunteer community, and its source code is public at
<a href="${SOURCE_REPO}" target="_blank" rel="noreferrer">github.com/djdevin/recflare</a>.
This policy covers the RecFlare game servers and this website. It is a fan project, not
affiliated with, endorsed by, or connected to Rec Room Inc.
</p>
<p>
Because the server code is open source, anyone can read exactly how the data described
below is handled, and anyone can run their own separate copy of RecFlare. This policy
applies only to the servers we operate. If you play on someone else's instance, their
operator is responsible for your data, not us.
</p>
<h2>What we collect</h2>
<p>
Almost all of it is data you create by playing. We do not buy data about you from
anyone, and we do not combine what's here with data from other services.
</p>
<h3>Your account</h3>
<ul>
<li>Your username, display name, profile picture, pronoun and identity settings, bio, and the date the account was created.</li>
<li>An email address and phone number <em>only if you choose to add them</em>. Neither is required to play, and neither is used for marketing.</li>
</ul>
<h3>How you sign in</h3>
<p>
You can sign in with an account from the platform you play on, or with a password.
Whichever you use, we store the minimum needed to recognise you next time.
</p>
<ul>
<li><strong>Steam.</strong> Your SteamID64, so the account can be matched to the right player. Steam signs the login ticket the game sends us, and we check that signature on our own servers — nothing about you is sent to Steam to do it.</li>
<li><strong>Meta.</strong> The user ID Meta issues for you <em>for this app</em>, and the display name attached to it. This is an app-scoped ID: it identifies you within RecFlare and is not your Meta account identity anywhere else. To confirm a sign-in is genuine, and that the account is entitled to the app, we send the token your headset gives us to Meta for verification — so Meta learns that a sign-in to RecFlare happened. We don't receive your Meta email address, friends list or profile beyond the ID and display name, and we don't ask Meta for them.</li>
<li><strong>A password on this website.</strong> Stored only as a salted PBKDF2 hash. We never store the password itself and cannot read it.</li>
</ul>
<p>
Linking a platform account is how you log in — we don't use it to look you up on that
platform, post anything there, or match you to advertising.
</p>
<h3>Device and technical data</h3>
<ul>
<li>The time of your most recent sign-in.</li>
<li>A device identifier the game client generates for each installation, and the kind of device it is — a PC or a headset, for example.</li>
<li>The IP address the account was created from, and the IP address of your most recent sign-in.</li>
<li>Session tokens. Refresh tokens are stored only as a one-way hash and are single-use.</li>
<li>Ordinary server request logs, held by our hosting provider, which include IP addresses, timestamps and the requests made.</li>
</ul>
<h3>What you make and do in game</h3>
<ul>
<li>Photos you take in game, and their details: who took them, the room they were taken in, and any players tagged.</li>
<li>Rooms and subrooms you create, inventions you build, and clubs you own or join.</li>
<li>Chat messages you send and the conversations they belong to, so they can be delivered and read later.</li>
<li>Your relationships with other players — friends, invites and blocks — and your interactions with rooms, such as favourites and cheers.</li>
<li>Your in-game economy: token balance, inventory, outfits and gifts received.</li>
<li>Your presence — which room instance you are currently in — so friends can find you and join. Presence records expire automatically on their own.</li>
<li>Your player settings and preferences.</li>
</ul>
<h2>Why we use it</h2>
<ul>
<li><strong>To run the game.</strong> Nearly everything above exists so the world can be reassembled the next time you log in — your avatar, your rooms, your inventory, your photos, your conversations.</li>
<li><strong>To sign you in.</strong> Your platform identity, password hash and session tokens are what prove an account is yours and stop anyone else using it.</li>
<li><strong>To let players find each other.</strong> Presence, friend lists and public feeds — including the photo slideshow on this website's front page, which shows public in-game photos along with the username of the player who took each one.</li>
<li><strong>To keep the server usable.</strong> IP addresses, device identifiers and logs are used to investigate abuse, ban evasion and bugs, and to limit how many accounts can be created from one place. This is the only reason we keep them.</li>
<li><strong>To contact you, if you asked us to.</strong> An email address you add is used for account recovery and account notices, nothing else.</li>
</ul>
<p>
We do not use your data for advertising or profiling, we do not sell or rent it, and we
do not share it for anyone else's marketing. There are no advertising SDKs, analytics
SDKs or tracking pixels in the game client or on this website.
</p>
<h2>Who else sees it</h2>
<ul>
<li><strong>Other players.</strong> Some of what you create is public by design: your username, display name, profile picture, bio, the rooms you publish, photos you make public, and messages you send to the people you send them to.</li>
<li><strong>Our hosting provider.</strong> The servers, databases, file storage and logs run on Cloudflare, which processes this data on our behalf in order to host the service.</li>
<li><strong>Meta, when you sign in with a Meta account.</strong> We send Meta the sign-in token from your headset so it can be verified, which tells Meta that a RecFlare sign-in took place. That exchange is governed by Meta's own privacy policy. Signing in with Steam involves no such call.</li>
<li><strong>Nobody else</strong> — except where we're required by law to disclose something, or where it's necessary to investigate a serious safety issue or abuse of the service.</li>
</ul>
<p>
Our community Discord server and our GitHub repository are run by Discord and GitHub
under their own privacy policies. Anything you post there is covered by their terms,
not this one.
</p>
<h2>Cookies</h2>
<p>
This website sets one cookie, <code>rf_token</code>, which holds your sign-in session.
It is strictly necessary to stay signed in, it is not readable by page scripts, and it
is cleared when you sign out. We set no advertising or analytics cookies. The game
client itself uses no cookies.
</p>
<h2>How long we keep it</h2>
<p>
Account and game data is kept for as long as your account exists, so your progress is
there when you come back. Presence records expire within minutes. Session tokens expire
on their own schedule. Server logs are retained for a short period and then age out
automatically. When you ask us to delete your account, we delete it as described below.
</p>
<h2>Deleting your data</h2>
<p>
<strong>You can ask us to delete your account and the data we hold about you at any
time, from anywhere in the world.</strong> There is no charge for this, and you don't
need to give a reason. Contact us by any of these routes:
</p>
${contactList()}
<p>
Tell us your RecFlare username, and be ready to prove the account is yours — normally
by signing in to it, or by sending the request from the email address on the account.
We ask because otherwise anyone could delete anyone else's account. We'll confirm when
it's done, and we aim to complete every request within 30 days.
</p>
<p>Deleting your account removes:</p>
<ul>
<li>Your account record — username, display name, profile picture, bio, email, phone number and password hash.</li>
<li>The link between the account and your Steam or Meta identity, and the stored IP addresses and device identifier.</li>
<li>Your session and refresh tokens, ending any active sign-in.</li>
<li>Your photos, rooms, inventions, inventory, settings and presence.</li>
</ul>
<p>
Two honest limits. Messages you sent live in shared conversations, so copies already
delivered to other players may remain in their message history, no longer attached to
an account. And routine server logs and backups age out on their own timers rather than
being edited, so a record of a request may persist for a short period after deletion.
Beyond those, if there's ever a reason we can't complete a deletion request, we'll tell
you what it is.
</p>
<p>
You can also change or correct most of your details yourself, either in game or on the
<a href="/account">account page</a> of this website, and you can ask us for a copy of
the data we hold about you using the same contact routes above.
</p>
<h2>Security</h2>
<p>
All traffic between the game client, this website and our servers is encrypted in
transit. Passwords are stored only as salted hashes and refresh tokens only as one-way
hashes, so a copy of our database would not reveal either. Access to the production
data is limited to the maintainers who operate the service. No system is perfectly
secure, and we won't pretend otherwise — but this is a hobby server, so please don't
reuse a password here that you use anywhere important.
</p>
<h2>Children</h2>
<p>
RecFlare is not directed at children under 13, and we don't knowingly collect data from
them. If you believe a child under 13 has created an account, contact us using any of
the routes above and we will delete the account and its data.
</p>
<h2>Changes to this policy</h2>
<p>
If this policy changes we'll update the date at the top of this page, and the change
will be visible in the project's public commit history. Significant changes will be
announced in our Discord server.
</p>
<h2>Contact</h2>
<p>Questions about this policy, or about any data we hold:</p>
${contactList()}
</main>
<footer>
<a href="/">RecFlare</a> — a fan project, not affiliated with Rec Room Inc.
</footer>
</body>
</html>`
}
+29
View File
@@ -2,6 +2,7 @@ import { SELF } from 'cloudflare:test'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import { DOCUMENTED_SERVICES } from '../../docs' import { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
it('rejects unauthenticated account reads', async () => { it('rejects unauthenticated account reads', async () => {
const res = await SELF.fetch('https://example.com/api/me') const res = await SELF.fetch('https://example.com/api/me')
@@ -69,3 +70,31 @@ it('404s a spec proxy for an unknown service (not an open proxy)', async () => {
const res = await SELF.fetch('https://example.com/docs/openapi/evil.json') const res = await SELF.fetch('https://example.com/docs/openapi/evil.json')
expect(res.status).toBe(404) expect(res.status).toBe(404)
}) })
// The privacy policy is what the Meta Horizon Store's VRC.Privacy.14 checks are run
// against, and a reviewer only sees the rendered page — so the four things they look
// for are pinned here. If a section is renamed, re-read the VRC before loosening the
// assertion: these strings are the requirement, not incidental copy.
it('serves the privacy policy as real server-rendered HTML', async () => {
const res = await SELF.fetch('https://example.com/privacy')
// VRC.Privacy.1 — live, public, no sign-in, and text without JavaScript.
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('text/html')
const html = await res.text()
expect(html).toContain('Privacy Policy')
// VRC.Privacy.2 — what is collected, VRC.Privacy.3 — what it is used for.
expect(html).toContain('What we collect')
expect(html).toContain('Why we use it')
// VRC.Privacy.4 — deletion is explained, free, and open to every region.
expect(html).toContain('Deleting your data')
expect(html).toMatch(/delete your account[^.]*at any\s+time, from anywhere in the world/)
expect(html).toContain('There is no charge for this')
// A deletion route a reader can actually follow. Discord and GitHub are always
// listed; the mailbox only when one is configured (see PRIVACY_EMAIL).
expect(html).toContain(DISCORD_INVITE)
expect(html).toContain(ISSUES_URL)
if (PRIVACY_EMAIL) expect(html).toContain(`mailto:${PRIVACY_EMAIL}`)
})
+7
View File
@@ -6,6 +6,7 @@ import { withOnError } from '@repo/hono-helpers'
import { NotificationType } from '../../notify/src/notification-types' import { NotificationType } from '../../notify/src/notification-types'
import { docsPage, fetchSpec } from './docs' import { docsPage, fetchSpec } from './docs'
import { privacyPage } from './privacy'
import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream' import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream'
import type { Context } from 'hono' import type { Context } from 'hono'
@@ -282,6 +283,12 @@ const app = new Hono<App>()
return c.json({ success: true, sent: result.sent ?? 0 }) return c.json({ success: true, sent: result.sent ?? 0 })
}) })
// ---- Privacy policy -----------------------------------------------------
// Server-rendered rather than a SPA route so the page has real text without
// JavaScript: the Meta Horizon Store re-fetches this URL to check the policy is
// still live, and an empty SPA shell can read as a broken link (see privacy.ts).
.get('/privacy', (c) => c.html(privacyPage()))
// ---- Aggregated API docs ------------------------------------------------ // ---- Aggregated API docs ------------------------------------------------
// `/docs` serves the self-hosted Scalar UI; `/docs/openapi/:service.json` proxies // `/docs` serves the self-hosted Scalar UI; `/docs/openapi/:service.json` proxies
// each worker's spec same-origin (see docs.ts). The Scalar bundle itself // each worker's spec same-origin (see docs.ts). The Scalar bundle itself
+6 -2
View File
@@ -17,12 +17,16 @@
// SPA `not_found_handling`, a top-level *navigation* to a non-asset path (browsers // SPA `not_found_handling`, a top-level *navigation* to a non-asset path (browsers
// send `Sec-Fetch-Mode: navigate`) is served index.html WITHOUT invoking the Worker, // send `Sec-Fetch-Mode: navigate`) is served index.html WITHOUT invoking the Worker,
// so a browser hitting `/docs` got the homepage. Keep this in sync with the Worker's // so a browser hitting `/docs` got the homepage. Keep this in sync with the Worker's
// routes (all under `/api/*` and `/docs*`). `/docs/scalar.standalone.js` is // routes (`/api/*`, `/docs*` and `/privacy`). `/docs/scalar.standalone.js` is
// deliberately excluded so it's served directly as the static asset it is. // deliberately excluded so it's served directly as the static asset it is.
//
// `/privacy` is here for the same reason `/docs` is, and it matters more: the Meta
// Horizon Store re-fetches the privacy policy URL to confirm it's live, and dropping
// it from this list would serve that fetch the SPA shell instead of the policy.
"assets": { "assets": {
"binding": "ASSETS", "binding": "ASSETS",
"not_found_handling": "single-page-application", "not_found_handling": "single-page-application",
"run_worker_first": ["/api/*", "/docs", "/docs/openapi/*"] "run_worker_first": ["/api/*", "/docs", "/docs/openapi/*", "/privacy"]
}, },
"upload_source_maps": true, "upload_source_maps": true,
"observability": { "observability": {
+7 -1
View File
@@ -64,11 +64,17 @@ export enum MessageType {
VirtualRoomNotification = 100008, VirtualRoomNotification = 100008,
} }
/** A room's (or image's) visibility, matching the client's `RoomAccessibility`. */ /**
* A room's (or image's) visibility, matching the client's `RoomAccessibility`. The
* client declares the enum without explicit values, so these are its ordinals — and
* it sends the NAME, not the number, on the subroom accessibility route.
*/
export enum Accessibility { export enum Accessibility {
Private = 0, Private = 0,
Public = 1, Public = 1,
Unlisted = 2, Unlisted = 2,
Dev_only = 3,
Dev_Unlisted = 4,
} }
/** /**
+453 -37
View File
@@ -57,9 +57,31 @@ export const SUBROOM_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS subroom ( `CREATE TABLE IF NOT EXISTS subroom (
sub_room_id INTEGER PRIMARY KEY AUTOINCREMENT, sub_room_id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL, room_id INTEGER NOT NULL,
data TEXT NOT NULL data TEXT NOT NULL,
current_save_id INTEGER,
staged_save_id INTEGER
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_subroom_room ON subroom (room_id)`, `CREATE INDEX IF NOT EXISTS idx_subroom_room ON subroom (room_id)`,
// Room saves (migrations/0008_subroom_saves.sql). A save is its own entity with a
// globally-unique, autoincrementing `SubRoomDataSaveId` — the same reason subrooms got
// their own table in 0007. It HAS to be global because a subroom points at saves by
// bare id: `current_save_id` is the live/published save the loader downloads,
// `staged_save_id` the creator's unpublished one. Per-subroom numbering would make
// every subroom's first save id 1 and those pointers ambiguous.
//
// `data` holds the save's client shape minus its two id fields; the columns are
// authoritative and are re-injected on read, exactly how `subroom` treats its own ids.
// A subroom's `CurrentSave` is inlined from `current_save_id` on every read and is
// never stored in the subroom blob.
//
// Part of this DDL rather than its own export: reading a subroom joins this table, so
// applying one without the other yields a schema that can't serve a room.
`CREATE TABLE IF NOT EXISTS subroom_save (
sub_room_data_save_id INTEGER PRIMARY KEY AUTOINCREMENT,
sub_room_id INTEGER NOT NULL,
data TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id)`,
] ]
/** A stored room — the parsed JSON blob (full client-facing room response). */ /** A stored room — the parsed JSON blob (full client-facing room response). */
@@ -288,21 +310,135 @@ export function findSubRoom(room: Room, subRoomId: number): SubRoom | undefined
/** Fields from the client's room-save POST body. */ /** Fields from the client's room-save POST body. */
export interface SaveSubRoomDataInput { export interface SaveSubRoomDataInput {
/** Uploaded blob key for this subroom's scene data (becomes the subroom's DataBlob). */ /** Uploaded blob key for this subroom's scene data (becomes `CurrentSave.DataBlob`). */
subRoomDataFilename?: string subRoomDataFilename?: string
/** Uploaded blob key for the room-level data. */ /** `SubRoomData.Hash` — echoed back as the save response's `dataBlobHash`. */
subRoomDataHash?: string
/** Uploaded blob key for the room-level METADATA blob (a separate upload). */
roomDataFilename?: string roomDataFilename?: string
description?: string description?: string
persistenceVersion?: number persistenceVersion?: number
inventionUsage?: string inventionUsage?: string
/** Optional baked-asset id; emitted on the save only when present. */
unityAssetId?: string
/**
* The client's `AutoPublish`. True publishes the save outright (the author wants it
* live now); false/absent stages it for a manual `publish_save`. Dorms ignore this and
* always publish.
*/
autoPublish?: boolean
} }
/** /**
* Persist a room-save against a specific subroom: point the subroom at its newly * A subroom's `CurrentSave` — the `SubRoomDataSave` the client reads to find the scene
* uploaded data blob (what the loader later downloads) and record the room-level * data blob to download. The loader looks ONLY here: a subroom with no `CurrentSave`
* fields from the save. Returns the updated (hydrated) room, or null when the room or * loads nothing, no matter what the (legacy, flat) `DataBlob` field says.
* subroom doesn't exist. The subroom row is updated in the `subroom` table; the */
* room-level fields are written to the room blob. export type SubRoomDataSave = Record<string, unknown>
/**
* The scene-data blob key the client should download for a subroom. Prefers the
* authoritative `CurrentSave.DataBlob` and falls back to the flat `DataBlob` that
* subrooms written before `CurrentSave` existed (and the `0001_init.sql` dorm seed)
* still carry. Shared so the `match` and `auth` room-instance payloads resolve the
* blob the same way the client's own loader does.
*/
export function subRoomDataBlob(sub: SubRoom | undefined | null): string {
const save = sub?.CurrentSave
if (save && typeof save === 'object') {
const blob = (save as SubRoomDataSave).DataBlob
if (typeof blob === 'string' && blob !== '') return blob
}
return typeof sub?.DataBlob === 'string' ? sub.DataBlob : ''
}
/** Fields that vary between a real save and one reconstructed from the legacy shape. */
interface BuildSaveInput {
subRoomId: unknown
dataBlob: string
dataBlobHash: string | null
persistenceVersion: number
savedByAccountId: unknown
description: string
createdAt: string
unityAssetId?: string
}
/**
* Build a `SubRoomDataSave` in the shape the client parses — the reference's `MapSave`
* projection. The four array fields are always empty (we neither resolve nor record
* referenced Unity assets) but must be PRESENT, and `UnityAssetId` is emitted only when
* the save actually carried one, exactly as the reference does. There is deliberately no
* `DataBlobHash`: it is commented out of the reference DTO and absent from its output.
*
* `SavedOnPlatform`/`SavedOnDeviceClass` are 0 — the reference fills them from the saving
* player's live platform/device, which the save request doesn't carry and we don't track.
*
* Shared by the save path and the legacy-shape reconstruction so the two can't drift.
*/
function buildSubRoomSave(input: BuildSaveInput): SubRoomDataSave {
const save: SubRoomDataSave = {
UnitySubAssets: [],
ReferencedUnityAssets: [],
SubRoomId: input.subRoomId,
DataBlob: input.dataBlob,
// The client sends `SubRoomData.Hash` (usually null); the room-save response echoes
// it as `dataBlobHash`. One observed room payload carries it on `CurrentSave` and
// another omits it, so storing it and letting it ride along is the safe reading.
DataBlobHash: input.dataBlobHash,
ReferencedUnityAssetIds: [],
PersistenceVersion: input.persistenceVersion,
OMVersion: 0,
UgcSubVersion: 0,
SavedByAccountId: input.savedByAccountId,
SavedOnPlatform: 0,
SavedOnDeviceClass: 0,
Description: input.description,
Tags: [],
ModerationState: 0,
CreatedAt: input.createdAt,
}
if (input.unityAssetId) save.UnityAssetId = input.unityAssetId
return save
}
/**
* Build a save row from a subroom stored in the pre-`CurrentSave` shape, where the blob
* key sat in the flat `DataBlob`/`DataSavedAt`/`PersistenceVersion` fields. Those
* subrooms hold real saved content the client cannot see (it reads `CurrentSave` only),
* so they get a save of their own rather than reading as never-saved. Mirrors backfill 2
* of migration 0008 — keep the two in sync.
*
* Returns null when there is genuinely nothing saved, the honest answer for a fresh
* subroom.
*/
function legacySubRoomSave(sub: SubRoom): SubRoomDataSave | null {
const blob = sub.DataBlob
if (typeof blob !== 'string' || blob === '') return null
const savedAt = typeof sub.DataSavedAt === 'string' ? sub.DataSavedAt : new Date(0).toISOString()
return buildSubRoomSave({
subRoomId: sub.SubRoomId,
dataBlob: blob,
dataBlobHash: null,
persistenceVersion: typeof sub.PersistenceVersion === 'number' ? sub.PersistenceVersion : 0,
// The legacy shape never recorded who saved; the subroom's creator is the best
// available answer (the save path is owner/co-owner gated).
savedByAccountId: sub.CreatorAccountId ?? null,
description: '',
createdAt: savedAt,
})
}
/**
* Persist a room-save against a specific subroom and record the room-level fields the
* save carries. Returns the updated (hydrated) room AND the save that was just created —
* the route answers with both — or null when the room or subroom doesn't exist.
*
* Whether the save goes live is the client's call: `AutoPublish: true` publishes it
* outright, otherwise it becomes the subroom's `staged_save_id` with the live
* `current_save_id` untouched, so what players load doesn't change until the room's
* creator publishes (see {@link publishSubRoomSave}). Dorms always publish — they have
* no publish flow in the client.
*/ */
export async function saveSubRoomData( export async function saveSubRoomData(
db: D1Database, db: D1Database,
@@ -310,34 +446,129 @@ export async function saveSubRoomData(
subRoomId: number, subRoomId: number,
accountId: number, accountId: number,
input: SaveSubRoomDataInput input: SaveSubRoomDataInput
): Promise<Room | null> { ): Promise<{ room: Room; save: SubRoomDataSave } | null> {
const room = await getRoomById(db, roomId) const room = await getRoomById(db, roomId)
if (!room) return null if (!room) return null
const sub = await getSubRoom(db, roomId, subRoomId) // Read off the already-hydrated room rather than re-querying the subroom and its
// save — getRoomById has both, and this path is write-heavy enough already.
const sub = findSubRoom(room, subRoomId)
if (!sub) return null if (!sub) return null
// Populate the subroom's creator on first save — it starts null, and the // Populate the subroom's creator on first save — it starts null, and the
// client NREs on a null CreatorAccountId. Only the owner reaches this path. // client NREs on a null CreatorAccountId. Only the owner reaches this path.
if (sub.CreatorAccountId == null) sub.CreatorAccountId = accountId if (sub.CreatorAccountId == null) sub.CreatorAccountId = accountId
// Point the subroom at the newly-uploaded data blobs and stamp the save. // Append a new save row. The blob the loader downloads lives on the save — a subroom
if (input.subRoomDataFilename) sub.DataBlob = input.subRoomDataFilename // whose current_save_id resolves to nothing loads nothing — so this never touches the
// flat DataBlob field. Previous saves stay in the table as history.
//
// A staged save carries forward from the previous STAGED one when there is one, so a
// creator's second edit builds on their first rather than on what's live.
const staged =
typeof sub.StagedSubRoomDataSaveId === 'number'
? await getSubRoomSaveById(db, subRoomId, sub.StagedSubRoomDataSaveId)
: null
const previous =
staged ??
(sub.CurrentSave && typeof sub.CurrentSave === 'object'
? (sub.CurrentSave as SubRoomDataSave)
: undefined)
const priorVersion = previous?.PersistenceVersion
const priorBlob = previous?.DataBlob
const save = await insertSubRoomSave(
db,
subRoomId,
buildSubRoomSave({
subRoomId,
// A save that carries no new blob (e.g. a description-only save) keeps the one
// the subroom already loads from.
dataBlob: input.subRoomDataFilename ?? (typeof priorBlob === 'string' ? priorBlob : ''),
dataBlobHash: input.subRoomDataHash ?? null,
persistenceVersion:
input.persistenceVersion ?? (typeof priorVersion === 'number' ? priorVersion : 0),
savedByAccountId: accountId,
// The save comment — empty string, not null, when the save carries none (the
// reference's `roomDesc ?? ""`). Also written to the room below.
description: input.description ?? '',
createdAt: new Date().toISOString(),
unityAssetId: input.unityAssetId,
})
)
const saveId = Number(save.SubRoomDataSaveId)
if (input.roomDataFilename) sub.RoomDataBlob = input.roomDataFilename if (input.roomDataFilename) sub.RoomDataBlob = input.roomDataFilename
sub.DataSavedAt = new Date().toISOString() sub.DataSavedAt = new Date().toISOString()
if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion
await updateSubRoom(db, sub)
// Room-level fields carried by the save. // Room-level fields carried by the save.
if (typeof input.description === 'string') room.Description = input.description if (typeof input.description === 'string') room.Description = input.description
if (input.persistenceVersion !== undefined) room.PersistenceVersion = input.persistenceVersion if (input.persistenceVersion !== undefined) room.PersistenceVersion = input.persistenceVersion
if (input.inventionUsage !== undefined) room.InventionUsage = input.inventionUsage if (input.inventionUsage !== undefined) room.InventionUsage = input.inventionUsage
await db
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') // Publish outright when the client asked to (`AutoPublish`), or for a dorm — a dorm is
.bind(roomId, serializeRoom(room)) // the player's own private space with no publish step in the client, so staging one
.run() // would leave their edits permanently invisible. Otherwise stage it and wait for
// `publish_save`. One round trip for the rest of the save.
const publishNow = input.autoPublish === true || room.IsDorm === true
await db.batch([
publishNow
? db
.prepare(
'UPDATE subroom SET current_save_id = ?2, staged_save_id = NULL WHERE sub_room_id = ?1'
)
.bind(subRoomId, saveId)
: db
.prepare('UPDATE subroom SET staged_save_id = ?2 WHERE sub_room_id = ?1')
.bind(subRoomId, saveId),
db
.prepare('UPDATE subroom SET data = ?2 WHERE sub_room_id = ?1')
.bind(subRoomId, serializeSubRoom(sub, roomId)),
db.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1').bind(roomId, serializeRoom(room)),
])
// Re-hydrate so the returned room reflects the just-saved subroom. // Re-hydrate so the returned room reflects the just-saved subroom.
return hydrateRoom(db, room) await attachSubRooms(db, [room])
return { room, save }
}
/**
* Publish one of a subroom's saves by id: make it the `current_save_id` players load.
* This is the manual step every non-dorm room save waits on ({@link saveSubRoomData}
* only stages). Because it takes an explicit id it doubles as restore-a-save — the id
* can be any save in the subroom's history, not just the staged one.
*
* The staging slot is cleared only when the save being published IS the staged one, so
* restoring an older version doesn't silently discard newer unpublished work.
*
* The id is looked up scoped to the subroom, so one subroom can't publish another's save
* (ids are globally unique, so an unscoped lookup would happily resolve).
*
* Returns the updated (hydrated) room, or a reason: `not_found` (no such room/subroom) /
* `unknown_save` (no such save on this subroom).
*/
export async function publishSubRoomSave(
db: D1Database,
roomId: number,
subRoomId: number,
saveId: number
): Promise<{ ok: true; room: Room } | { ok: false; reason: 'not_found' | 'unknown_save' }> {
const sub = await getSubRoom(db, roomId, subRoomId)
if (!sub) return { ok: false, reason: 'not_found' }
if (!(await getSubRoomSaveById(db, subRoomId, saveId))) {
return { ok: false, reason: 'unknown_save' }
}
await db
.prepare(
`UPDATE subroom SET current_save_id = ?2,
staged_save_id = CASE WHEN staged_save_id = ?2 THEN NULL ELSE staged_save_id END
WHERE sub_room_id = ?1`
)
.bind(subRoomId, saveId)
.run()
const room = await getRoomById(db, roomId)
if (!room) return { ok: false, reason: 'not_found' }
return { ok: true, room }
} }
/** Fields from the client's subroom `modify` form (each applied only when supplied). */ /** Fields from the client's subroom `modify` form (each applied only when supplied). */
@@ -434,7 +665,8 @@ export async function createSubRoom(
IsSandbox: true, IsSandbox: true,
LastModeratedSaveModerationState: 0, LastModeratedSaveModerationState: 0,
ShouldAutoStageSaves: true, ShouldAutoStageSaves: true,
StagedSubRoomDataSaveId: null, // Nothing saved yet — the first room save mints one and points current_save_id
// at it. Until then the subroom reads with `CurrentSave: null`.
}) })
// Refresh the hydrated SubRooms so the returned room includes the one just inserted. // Refresh the hydrated SubRooms so the returned room includes the one just inserted.
await attachSubRooms(db, [room]) await attachSubRooms(db, [room])
@@ -456,10 +688,14 @@ export async function deleteSubRoom(
if (!subRooms.some((s) => s.SubRoomId === subRoomId)) return { ok: false, reason: 'not_found' } if (!subRooms.some((s) => s.SubRoomId === subRoomId)) return { ok: false, reason: 'not_found' }
if (subRooms.length <= 1) return { ok: false, reason: 'last_subroom' } if (subRooms.length <= 1) return { ok: false, reason: 'last_subroom' }
await db await db.batch([
db
.prepare('DELETE FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2') .prepare('DELETE FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2')
.bind(roomId, subRoomId) .bind(roomId, subRoomId),
.run() // The saves go with it — nothing can reference them once the subroom is gone.
// The blobs they point at are left in R2, like a deleted room's images.
db.prepare('DELETE FROM subroom_save WHERE sub_room_id = ?1').bind(subRoomId),
])
const room = await getRoomById(db, roomId) const room = await getRoomById(db, roomId)
if (!room) return { ok: false, reason: 'not_found' } if (!room) return { ok: false, reason: 'not_found' }
@@ -484,18 +720,41 @@ interface SubRoomRow {
sub_room_id: number sub_room_id: number
room_id: number room_id: number
data: string data: string
current_save_id: number | null
staged_save_id: number | null
} }
/** Materialize a subroom row into its client shape, with the columns authoritative. */ /** The columns every subroom read needs — the blob plus its two save pointers. */
const SUBROOM_COLUMNS = 'sub_room_id, room_id, data, current_save_id, staged_save_id'
/**
* Materialize a subroom row into its client shape, with the columns authoritative.
* `CurrentSave` is left undefined here and filled in by {@link attachCurrentSaves} — it
* lives in `subroom_save`, and resolving it per row would be a query each. Callers must
* go through the helpers below so the key is never missing: the client reads the scene
* blob from `CurrentSave` and nowhere else, so a subroom without one loads nothing.
*/
const parseSubRoomRow = (row: SubRoomRow): SubRoom => ({ const parseSubRoomRow = (row: SubRoomRow): SubRoom => ({
...(JSON.parse(row.data) as SubRoom), ...(JSON.parse(row.data) as SubRoom),
SubRoomId: row.sub_room_id, SubRoomId: row.sub_room_id,
RoomId: row.room_id, RoomId: row.room_id,
// Served from the column, not the blob — the creator's unpublished save (unused for
// now, but the client expects the key present).
StagedSubRoomDataSaveId: row.staged_save_id,
}) })
/** Serialize a subroom for storage — drop the id/room columns from the JSON blob. */ /**
* Serialize a subroom for storage — drop the id/room columns and the save fields that
* are columns or their own table, so the blob never holds a stale copy of either.
*/
const serializeSubRoom = (sub: SubRoom, roomId: number): string => { const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
const { SubRoomId: _id, RoomId: _room, ...rest } = sub const {
SubRoomId: _id,
RoomId: _room,
CurrentSave: _save,
StagedSubRoomDataSaveId: _staged,
...rest
} = sub
return JSON.stringify({ ...rest, RoomId: roomId }) return JSON.stringify({ ...rest, RoomId: roomId })
} }
@@ -508,6 +767,45 @@ const serializeRoom = (room: Room): string => {
return JSON.stringify(rest) return JSON.stringify(rest)
} }
/**
* Fill in each subroom's `CurrentSave` from `subroom_save`, in ONE query for the whole
* batch. Every subroom ends up with the key present — null when it points at no save
* (never saved) or the pointer dangles — because the client's loader reads it directly.
*
* `rows` must line up with `subs` positionally; the pointer lives on the row, not the
* parsed blob.
*/
async function attachCurrentSaves(
db: D1Database,
subs: SubRoom[],
rows: SubRoomRow[]
): Promise<void> {
const saveIds = [...new Set(rows.map((r) => r.current_save_id).filter((id) => id != null))]
const byId = new Map<number, SubRoomDataSave>()
if (saveIds.length > 0) {
const placeholders = saveIds.map((_, i) => `?${i + 1}`).join(',')
const { results } = await db
.prepare(
`SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save
WHERE sub_room_data_save_id IN (${placeholders})`
)
.bind(...saveIds)
.all<SubRoomSaveRow>()
for (const r of results) byId.set(r.sub_room_data_save_id, parseSubRoomSaveRow(r))
}
subs.forEach((sub, i) => {
const id = rows[i]!.current_save_id
sub.CurrentSave = id == null ? null : (byId.get(id) ?? null)
})
}
/** Parse subroom rows and resolve their `CurrentSave` in one batched query. */
async function parseSubRoomRows(db: D1Database, rows: SubRoomRow[]): Promise<SubRoom[]> {
const subs = rows.map(parseSubRoomRow)
await attachCurrentSaves(db, subs, rows)
return subs
}
/** Attach each room's `SubRooms` array from the subroom table (one batched query). */ /** Attach each room's `SubRooms` array from the subroom table (one batched query). */
async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> { async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> {
const ids = rooms.map((r) => Number(r.RoomId)).filter((n) => Number.isFinite(n)) const ids = rooms.map((r) => Number(r.RoomId)).filter((n) => Number.isFinite(n))
@@ -518,17 +816,18 @@ async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> {
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',') const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
const { results } = await db const { results } = await db
.prepare( .prepare(
`SELECT sub_room_id, room_id, data FROM subroom `SELECT ${SUBROOM_COLUMNS} FROM subroom
WHERE room_id IN (${placeholders}) ORDER BY sub_room_id` WHERE room_id IN (${placeholders}) ORDER BY sub_room_id`
) )
.bind(...ids) .bind(...ids)
.all<SubRoomRow>() .all<SubRoomRow>()
const subs = await parseSubRoomRows(db, results)
const byRoom = new Map<number, SubRoom[]>() const byRoom = new Map<number, SubRoom[]>()
for (const r of results) { results.forEach((r, i) => {
const list = byRoom.get(r.room_id) ?? [] const list = byRoom.get(r.room_id) ?? []
list.push(parseSubRoomRow(r)) list.push(subs[i]!)
byRoom.set(r.room_id, list) byRoom.set(r.room_id, list)
} })
for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? [] for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? []
} }
@@ -551,19 +850,96 @@ export async function getSubRoom(
subRoomId: number subRoomId: number
): Promise<SubRoom | null> { ): Promise<SubRoom | null> {
const row = await db const row = await db
.prepare('SELECT sub_room_id, room_id, data FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2') .prepare(`SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2`)
.bind(roomId, subRoomId) .bind(roomId, subRoomId)
.first<SubRoomRow>() .first<SubRoomRow>()
return row ? parseSubRoomRow(row) : null if (!row) return null
return (await parseSubRoomRows(db, [row]))[0]!
} }
/** All of a room's subrooms, ordered by SubRoomId. */ /** All of a room's subrooms, ordered by SubRoomId. */
export async function getSubRooms(db: D1Database, roomId: number): Promise<SubRoom[]> { export async function getSubRooms(db: D1Database, roomId: number): Promise<SubRoom[]> {
const { results } = await db const { results } = await db
.prepare('SELECT sub_room_id, room_id, data FROM subroom WHERE room_id = ?1 ORDER BY sub_room_id') .prepare(`SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id = ?1 ORDER BY sub_room_id`)
.bind(roomId) .bind(roomId)
.all<SubRoomRow>() .all<SubRoomRow>()
return results.map(parseSubRoomRow) return parseSubRoomRows(db, results)
}
// ---- Subroom saves --------------------------------------------------------
interface SubRoomSaveRow {
sub_room_data_save_id: number
sub_room_id: number
data: string
}
/** Materialize a save row, with its two id columns authoritative over the blob. */
const parseSubRoomSaveRow = (row: SubRoomSaveRow): SubRoomDataSave => ({
...(JSON.parse(row.data) as SubRoomDataSave),
SubRoomDataSaveId: row.sub_room_data_save_id,
SubRoomId: row.sub_room_id,
})
/** Serialize a save for storage — the id columns own those two fields, not the blob. */
const serializeSubRoomSave = (save: SubRoomDataSave): string => {
const { SubRoomDataSaveId: _id, SubRoomId: _sub, ...rest } = save
return JSON.stringify(rest)
}
/**
* Insert a save for a subroom, minting a fresh globally-unique `SubRoomDataSaveId` from
* the table's autoincrement sequence. Returns the stored save with its new id.
*/
async function insertSubRoomSave(
db: D1Database,
subRoomId: number,
save: SubRoomDataSave
): Promise<SubRoomDataSave> {
const row = await db
.prepare(
'INSERT INTO subroom_save (sub_room_id, data) VALUES (?1, ?2) RETURNING sub_room_data_save_id'
)
.bind(subRoomId, serializeSubRoomSave(save))
.first<{ sub_room_data_save_id: number }>()
return { ...save, SubRoomDataSaveId: row!.sub_room_data_save_id, SubRoomId: subRoomId }
}
/**
* A subroom's save history, newest first. Unlike the old inline model this is real
* history: every save is its own row and none are overwritten.
*/
export async function getSubRoomSaves(
db: D1Database,
subRoomId: number
): Promise<SubRoomDataSave[]> {
const { results } = await db
.prepare(
`SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save
WHERE sub_room_id = ?1 ORDER BY sub_room_data_save_id DESC`
)
.bind(subRoomId)
.all<SubRoomSaveRow>()
return results.map(parseSubRoomSaveRow)
}
/**
* A single save by its globally-unique id, scoped to the subroom that owns it (the
* restore-a-save lookup). Null when the id is unknown or belongs to another subroom.
*/
export async function getSubRoomSaveById(
db: D1Database,
subRoomId: number,
saveId: number
): Promise<SubRoomDataSave | null> {
const row = await db
.prepare(
`SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save
WHERE sub_room_data_save_id = ?1 AND sub_room_id = ?2`
)
.bind(saveId, subRoomId)
.first<SubRoomSaveRow>()
return row ? parseSubRoomSaveRow(row) : null
} }
/** /**
@@ -579,7 +955,23 @@ export async function insertSubRoom(
.prepare('INSERT INTO subroom (room_id, data) VALUES (?1, ?2) RETURNING sub_room_id') .prepare('INSERT INTO subroom (room_id, data) VALUES (?1, ?2) RETURNING sub_room_id')
.bind(roomId, serializeSubRoom(sub, roomId)) .bind(roomId, serializeSubRoom(sub, roomId))
.first<{ sub_room_id: number }>() .first<{ sub_room_id: number }>()
return { ...sub, SubRoomId: row!.sub_room_id, RoomId: roomId } const subRoomId = row!.sub_room_id
const created: SubRoom = {
...sub,
SubRoomId: subRoomId,
RoomId: roomId,
CurrentSave: null,
StagedSubRoomDataSaveId: null,
}
// A copied subroom (room clone, subroom clone) carries the source's save. It gets its
// OWN row — a save belongs to exactly one subroom, so sharing the source's id would
// make the copy's content follow the source's future saves.
if (sub.CurrentSave && typeof sub.CurrentSave === 'object') {
const copy = await insertSubRoomSave(db, subRoomId, sub.CurrentSave as SubRoomDataSave)
await setCurrentSave(db, subRoomId, Number(copy.SubRoomDataSaveId))
created.CurrentSave = copy
}
return created
} }
/** Overwrite a subroom's stored data blob in place. */ /** Overwrite a subroom's stored data blob in place. */
@@ -590,20 +982,37 @@ async function updateSubRoom(db: D1Database, sub: SubRoom): Promise<void> {
.run() .run()
} }
/** Point a subroom at its live/published save, clearing any staged one. */
async function setCurrentSave(db: D1Database, subRoomId: number, saveId: number): Promise<void> {
await db
.prepare(
'UPDATE subroom SET current_save_id = ?2, staged_save_id = NULL WHERE sub_room_id = ?1'
)
.bind(subRoomId, saveId)
.run()
}
/** /**
* Seed a room together with its subrooms — inserts the room (SubRooms stripped from the * Seed a room together with its subrooms — inserts the room (SubRooms stripped from the
* blob) and each embedded subroom into the `subroom` table, preserving explicit ids. * blob) and each embedded subroom into the `subroom` table, preserving explicit ids. Any
* Used by the migration's data model in tests (mirrors 0007_subrooms.sql's backfill). * subroom carrying a `CurrentSave` gets it inserted into `subroom_save` and pointed at,
* mirroring 0008's backfill the way this mirrors 0007's.
*/ */
export async function seedRoomWithSubRooms(db: D1Database, room: Room): Promise<void> { export async function seedRoomWithSubRooms(db: D1Database, room: Room): Promise<void> {
const roomId = Number(room.RoomId) const roomId = Number(room.RoomId)
const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : [] const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : []
await db.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run() await db.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run()
for (const sub of subRooms) { for (const sub of subRooms) {
const subRoomId = Number(sub.SubRoomId)
await db await db
.prepare('INSERT INTO subroom (sub_room_id, room_id, data) VALUES (?1, ?2, ?3)') .prepare('INSERT INTO subroom (sub_room_id, room_id, data) VALUES (?1, ?2, ?3)')
.bind(Number(sub.SubRoomId), roomId, serializeSubRoom(sub, roomId)) .bind(subRoomId, roomId, serializeSubRoom(sub, roomId))
.run() .run()
const seeded = sub.CurrentSave ?? legacySubRoomSave(sub)
if (seeded && typeof seeded === 'object') {
const save = await insertSubRoomSave(db, subRoomId, seeded as SubRoomDataSave)
await setCurrentSave(db, subRoomId, Number(save.SubRoomDataSaveId))
}
} }
} }
@@ -628,6 +1037,13 @@ export async function deleteRoom(db: D1Database, roomId: number): Promise<void>
await db.batch([ await db.batch([
db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId), db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId),
db.prepare('DELETE FROM interaction WHERE room_id = ?1').bind(roomId), db.prepare('DELETE FROM interaction WHERE room_id = ?1').bind(roomId),
// Saves first — they're keyed by subroom, so they'd be unreachable afterwards.
db
.prepare(
'DELETE FROM subroom_save WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)'
)
.bind(roomId),
db.prepare('DELETE FROM subroom WHERE room_id = ?1').bind(roomId),
]) ])
} }