From 489ac04574cdd784cb45ed1426d9be3550b819de Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 15 Jul 2026 16:12:24 -0400 Subject: [PATCH] fix some custom room authoring issues --- apps/api/src/images-db.ts | 75 ++++++ apps/api/src/routes/images.ts | 31 ++- apps/api/src/test/integration/api.test.ts | 86 ++++++- .../img/migrations/0002_image_interaction.sql | 15 ++ apps/img/src/images-db.ts | 12 + apps/rooms/src/rooms.app.ts | 177 +++++++++++--- apps/rooms/src/test/integration/api.test.ts | 216 ++++++++++++++++-- packages/domain/src/rooms-db.ts | 27 ++- 8 files changed, 569 insertions(+), 70 deletions(-) create mode 100644 apps/img/migrations/0002_image_interaction.sql diff --git a/apps/api/src/images-db.ts b/apps/api/src/images-db.ts index ea47d81..945eb4a 100644 --- a/apps/api/src/images-db.ts +++ b/apps/api/src/images-db.ts @@ -21,6 +21,19 @@ export const SCHEMA_DDL: string[] = [ `CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`, `CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`, `CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`, + // A player's interaction with a saved image — one row per (player, image). Only + // `cheered` for now; named generically so other per-user interactions (e.g. + // favorited) can be added as columns. This worker writes it (cheer endpoints) and + // keeps the image's denormalized `CheerCount` in sync from it. Schema owned by the + // `img` worker (migrations/0002_image_interaction.sql) — keep in sync. + `CREATE TABLE IF NOT EXISTS image_interaction ( + player_id INTEGER NOT NULL, + saved_image_id INTEGER NOT NULL, + cheered INTEGER NOT NULL DEFAULT 0, + created_at TEXT, + PRIMARY KEY (player_id, saved_image_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`, ] /** A stored image record (the client-facing SavedImage shape). */ @@ -81,6 +94,68 @@ export async function createImage(db: D1Database, input: NewImage): Promise { + const row = await db + .prepare('SELECT COUNT(*) AS n FROM image_interaction WHERE saved_image_id = ?1 AND cheered = 1') + .bind(savedImageId) + .first<{ n: number }>() + const count = row?.n ?? 0 + await db + .prepare("UPDATE image SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1") + .bind(savedImageId, count) + .run() + return count +} + +/** + * Set (or clear) a player's cheer on a saved image — upserts the one row per + * (player, image) — then resyncs the image's `CheerCount`. Idempotent: re-cheering + * an already-cheered image is a no-op on the count. + */ +export async function setImageCheer( + db: D1Database, + playerId: number, + savedImageId: number, + cheer: boolean +): Promise { + await db + .prepare( + `INSERT INTO image_interaction (player_id, saved_image_id, cheered, created_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(player_id, saved_image_id) DO UPDATE SET cheered = ?3` + ) + .bind(playerId, savedImageId, cheer ? 1 : 0, new Date().toISOString()) + .run() + await syncImageCheerCount(db, savedImageId) +} + +/** + * Which of the given saved-image ids the player has cheered — the set of cheered + * ids (a subset of `ids`). Backs the bulk `cheered` lookup. Empty input → empty set. + */ +export async function getCheeredImageIds( + db: D1Database, + playerId: number, + ids: number[] +): Promise> { + if (ids.length === 0) return new Set() + const inList = ids.map((_, i) => `?${i + 2}`).join(',') + const { results } = await db + .prepare( + `SELECT saved_image_id AS id FROM image_interaction + WHERE player_id = ?1 AND cheered = 1 AND saved_image_id IN (${inList})` + ) + .bind(playerId, ...ids) + .all<{ id: number }>() + return new Set(results.map((r) => r.id)) +} + /** Look up an image record by its ImageName (the R2 key / filename), or null. */ export async function getImageByName(db: D1Database, name: string): Promise { const row = await db diff --git a/apps/api/src/routes/images.ts b/apps/api/src/routes/images.ts index ffb28ba..9362961 100644 --- a/apps/api/src/routes/images.ts +++ b/apps/api/src/routes/images.ts @@ -2,11 +2,13 @@ import { Hono } from 'hono' import { createImage, + getCheeredImageIds, getImageByName, getImagesByPlayer, getImagesByRoom, getPlayerFeed, getSlideshowImages, + setImageCheer, } from '../images-db' import { authedId, unauthorized } from '../http' @@ -169,12 +171,33 @@ export const imageRoutes = new Hono({ strict: false }) return image ? c.json(image) : c.notFound() }) - // Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Stubbed - // for now — accepted but not persisted; cheer storage is still TBD. + // Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Persists the + // caller's cheer to `image_interaction` and resyncs the image's CheerCount. .post('/api/images/v1/cheer', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - // TODO: record the cheer against the image once cheer storage is designed. - await c.req.json().catch(() => null) + const body = (await c.req.json().catch(() => null)) as { + SavedImageId?: number + Cheer?: boolean + } | null + if (body && typeof body.SavedImageId === 'number') { + await setImageCheer(c.env.DB, id, body.SavedImageId, body.Cheer === true) + } return c.json({ success: true }) }) + + // Whether the caller has cheered each of the given saved-image ids (`?id=55&id=54`, + // and each `id` may itself be a comma-separated list). Auth-gated. Returns one + // `{ SavedImageId, IsCheered }` per requested id, in order. + .get('/api/images/v5/cheered/bulk', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const ids = + c.req + .queries('id') + ?.flatMap((raw) => raw.split(',')) + .map((raw) => Number.parseInt(raw.trim(), 10)) + .filter((imageId) => !Number.isNaN(imageId)) ?? [] + const cheered = await getCheeredImageIds(c.env.DB, id, ids) + return c.json(ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) }))) + }) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index d6ad854..4bf7232 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -6,7 +6,7 @@ import '../../api.app' import { createGift, getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain' -import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db' +import { createImage, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db' import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db' import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db' @@ -1203,26 +1203,92 @@ describe('images', () => { }) }) - test('POST /api/images/v1/cheer is auth-gated and stubs success', async () => { - const body = JSON.stringify({ SavedImageId: 2, Cheer: true }) + test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => { + // Seed an image to cheer. + const img = await createImage(env.DB, { imageName: 'cheerme.jpg', playerId: 700 }) + const cheerBody = JSON.stringify({ SavedImageId: img.Id, Cheer: true }) + // No token → 401. expect( ( await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body, + body: cheerBody, }) ).status ).toBe(401) - // With a token → accepted. - const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, { + + const cheer = async (cheerVal: boolean, sub = '42') => + exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' }, + body: JSON.stringify({ SavedImageId: img.Id, Cheer: cheerVal }), + }) + const cheerCount = async (): Promise => { + const row = await env.DB.prepare('SELECT data FROM image WHERE id = ?1') + .bind(img.Id) + .first<{ data: string }>() + return (JSON.parse(row!.data) as { CheerCount: number }).CheerCount + } + + // Account 42 cheers → CheerCount syncs to 1 (a real integer, not 1.0). + expect((await cheer(true)).status).toBe(200) + const rawAfter = await env.DB.prepare('SELECT data FROM image WHERE id = ?1') + .bind(img.Id) + .first<{ data: string }>() + expect(rawAfter!.data).toContain('"CheerCount":1') + expect(rawAfter!.data).not.toContain('"CheerCount":1.0') + expect(await cheerCount()).toBe(1) + + // Re-cheering is idempotent on the count. + await cheer(true) + expect(await cheerCount()).toBe(1) + + // Un-cheer → count back to 0. + await cheer(false) + expect(await cheerCount()).toBe(0) + }) + + test('GET /api/images/v5/cheered/bulk reports per-id cheer state for the caller (auth-gated)', async () => { + const img = await createImage(env.DB, { imageName: 'bulkcheer.jpg', playerId: 701 }) + const other = 999999 + + // No token → 401. + expect( + (await exports.default.fetch(`${ORIGIN}/api/images/v5/cheered/bulk?id=${img.Id}`)).status + ).toBe(401) + + const bulk = async (sub: string) => + (await ( + await exports.default.fetch( + `${ORIGIN}/api/images/v5/cheered/bulk?id=${img.Id}&id=${other}`, + { headers: await bearer(sub) } + ) + ).json()) as Array<{ SavedImageId: number; IsCheered: boolean }> + + // Before cheering: one entry per requested id, in order, all false. + expect(await bulk('42')).toEqual([ + { SavedImageId: img.Id, IsCheered: false }, + { SavedImageId: other, IsCheered: false }, + ]) + + // Account 42 cheers the image. + await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, { method: 'POST', - headers: { ...(await bearer()), 'Content-Type': 'application/json' }, - body, + headers: { ...(await bearer('42')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ SavedImageId: img.Id, Cheer: true }), }) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ success: true }) + + // The cheerer sees it cheered; a different player does not. + expect((await bulk('42')).find((x) => x.SavedImageId === img.Id)?.IsCheered).toBe(true) + expect((await bulk('43')).find((x) => x.SavedImageId === img.Id)?.IsCheered).toBe(false) + + // No ids → empty array. + const empty = await exports.default.fetch(`${ORIGIN}/api/images/v5/cheered/bulk`, { + headers: await bearer('42'), + }) + expect(await empty.json()).toEqual([]) }) test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => { diff --git a/apps/img/migrations/0002_image_interaction.sql b/apps/img/migrations/0002_image_interaction.sql new file mode 100644 index 0000000..e61c04f --- /dev/null +++ b/apps/img/migrations/0002_image_interaction.sql @@ -0,0 +1,15 @@ +-- A player's interaction with a saved image — one row per (player, image). Only +-- `cheered` for now; named generically so other per-user interactions (e.g. +-- favorited) can be added as columns later. Written by the `api` worker's cheer +-- endpoints (/api/images/v1/cheer, /api/images/v5/cheered/bulk), which also keeps +-- the image's denormalized CheerCount in sync from it. Generated from +-- src/images-db.ts (SCHEMA_DDL) — keep in sync. + +CREATE TABLE IF NOT EXISTS image_interaction ( + player_id INTEGER NOT NULL, + saved_image_id INTEGER NOT NULL, + cheered INTEGER NOT NULL DEFAULT 0, + created_at TEXT, + PRIMARY KEY (player_id, saved_image_id) + ); +CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id); diff --git a/apps/img/src/images-db.ts b/apps/img/src/images-db.ts index 629d682..8a62e45 100644 --- a/apps/img/src/images-db.ts +++ b/apps/img/src/images-db.ts @@ -23,6 +23,18 @@ export const SCHEMA_DDL: string[] = [ `CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`, `CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`, `CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`, + // A player's interaction with a saved image — one row per (player, image). Only + // `cheered` for now; named generically so other per-user interactions (e.g. + // favorited) can be added as columns. The `api` worker writes it (cheer endpoints) + // and keeps the image's denormalized `CheerCount` in sync from it. + `CREATE TABLE IF NOT EXISTS image_interaction ( + player_id INTEGER NOT NULL, + saved_image_id INTEGER NOT NULL, + cheered INTEGER NOT NULL DEFAULT 0, + created_at TEXT, + PRIMARY KEY (player_id, saved_image_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`, ] /** A stored image record (the client-facing SavedImage shape). */ diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 5160523..376a57c 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -29,6 +29,7 @@ import { setRoomImage, setRoomName, setRoomRole, + updateRoomFields, toggleCheer, toggleFavorite, toggleRoomTag, @@ -139,6 +140,20 @@ function unauthorized(c: Context) { /** The notifications hub is a single global DO instance (see the `notify` worker). */ const HUB_INSTANCE = 'global' +/** + * The room `Supports*` flags the `/restrictions` endpoint can toggle, keyed by the + * lowercased form field the client posts. Only fields present in the body are changed. + */ +const RESTRICTION_FIELDS: Record = { + supportsscreens: 'SupportsScreens', + supportswalkvr: 'SupportsWalkVR', + supportsteleportvr: 'SupportsTeleportVR', + supportsvrlow: 'SupportsVRLow', + supportsquest2: 'SupportsQuest2', + supportsmobile: 'SupportsMobile', + supportsjuniors: 'SupportsJuniors', +} + /** * Push a RoomUpdate notification to a player after their room changes, mirroring * the reference server's `HubSendToPlayer(playerId, NotifFrame("RoomUpdate", room))`. @@ -568,11 +583,11 @@ const app = new Hono() }) // Set a member's role in a room (`Roles[].Role`). Auth-gated (401) and gated to - // the room creator or a co-owner — the same owner/co-owner check the other - // room-admin actions use. Body is the `role` form field (an integer role tier). - // Updates the target account's existing role entry or adds one, notifies the - // affected member so their client refreshes permissions, and returns the - // `{ Success, Value, ErrorId, Error }` envelope at HTTP 200. + // the room creator or a co-owner (403 otherwise) — the same owner/co-owner check + // the other room-admin actions use. Body is the `role` form field (an integer role + // tier). Updates the target account's existing role entry or adds one, notifies the + // affected member so their client refreshes permissions, and returns the updated + // room in the lowercase `{ success, error, value }` envelope. .put('/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}', async (c) => { const accountId = await authedAccountId(c) if (accountId === null) return unauthorized(c) @@ -580,36 +595,134 @@ const app = new Hono() const roomId = Number.parseInt(c.req.param('roomId'), 10) const targetAccountId = Number.parseInt(c.req.param('accountId'), 10) const room = await getRoomById(c.env.DB, roomId) - if (!room) { - return roomResult(c, { - Success: false, - ErrorId: 'Rooms.DoesntExist', - Error: 'This room does not exist!', - }) - } - if (!canManageRoom(room, accountId)) { - return roomResult(c, { - Success: false, - ErrorId: 'Rooms.PermissionDenied', - Error: 'You are not the owner of this room!', - }) - } + if (!room) return roomEnvelope(c, null, 'This room does not exist!') + // A valid token but not the room's owner/co-owner → 403 (the auth gate above + // already returned 401 for a missing/invalid token). + if (!canManageRoom(room, accountId)) return c.body(null, 403) const body = (await c.req.parseBody().catch(() => ({}))) as Record const role = typeof body.role === 'string' ? Number.parseInt(body.role, 10) : Number.NaN - if (Number.isNaN(role)) { - return roomResult(c, { - Success: false, - ErrorId: 'Rooms.InvalidRole', - Error: 'You must provide a valid role!', - }) - } + if (Number.isNaN(role)) return roomEnvelope(c, null, 'You must provide a valid role!') const updated = await setRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room) // Notify the member whose role changed so their client refreshes the room // (and the permissions it grants them). await pushRoomUpdate(c, targetAccountId, updated) - return roomResult(c, { Success: true }) + return roomEnvelope(c, updated) + }) + + // Set a room's content warning: the `WarningMask` bit flags plus an optional + // free-text `CustomWarning`. Auth-gated (401) and owner/co-owner-only (403). Body is + // the `warningMask` form field (an integer) and an optional `customWarning` string + // (set when present — an empty value clears it). Returns the updated room in the + // `{ success, error, value }` envelope. + .put('/rooms/:roomId{[0-9]+}/warning', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const room = await getRoomById(c.env.DB, roomId) + if (!room) return roomEnvelope(c, null, 'This room does not exist!') + // A valid token but not the room's owner/co-owner → 403 (the auth gate above + // already returned 401 for a missing/invalid token). + if (!canManageRoom(room, accountId)) return c.body(null, 403) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const warningMask = + typeof body.warningMask === 'string' ? Number.parseInt(body.warningMask, 10) : Number.NaN + if (Number.isNaN(warningMask)) return roomEnvelope(c, null, 'You must provide a valid warning mask!') + + const patch: Record = { WarningMask: warningMask } + // Only touch CustomWarning when the field is present (an empty string clears it). + if (typeof body.customWarning === 'string') patch.CustomWarning = body.customWarning + + const updated = await updateRoomFields(c.env.DB, roomId, room, patch) + // Notify the owner so their client refreshes the room with the updated warning. + await pushRoomUpdate(c, accountId, updated) + return roomEnvelope(c, updated) + }) + + // Toggle whether a room may be cloned (`CloningAllowed`). Auth-gated (401) and + // owner/co-owner-only (403). Body is the `cloningAllowed` form field (`True`/`False`). + // Returns the updated room in the `{ success, error, value }` envelope. + .put('/rooms/:roomId{[0-9]+}/cloning', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const room = await getRoomById(c.env.DB, roomId) + if (!room) return roomEnvelope(c, null, 'This room does not exist!') + // A valid token but not the room's owner/co-owner → 403 (the auth gate above + // already returned 401 for a missing/invalid token). + if (!canManageRoom(room, accountId)) return c.body(null, 403) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + if (typeof body.cloningAllowed !== 'string') { + return roomEnvelope(c, null, 'You must provide cloningAllowed.') + } + const cloningAllowed = body.cloningAllowed.toLowerCase() === 'true' + + const updated = await updateRoomFields(c.env.DB, roomId, room, { CloningAllowed: cloningAllowed }) + await pushRoomUpdate(c, accountId, updated) + return roomEnvelope(c, updated) + }) + + // Set a room's platform/movement support flags (its `Supports*` restrictions). + // Auth-gated (401) and owner/co-owner-only (403). Body is a form of + // `supports*=True|False` fields (see RESTRICTION_FIELDS); only the fields present + // are changed. Returns the updated room in the `{ success, error, value }` envelope. + .put('/rooms/:roomId{[0-9]+}/restrictions', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const room = await getRoomById(c.env.DB, roomId) + if (!room) return roomEnvelope(c, null, 'This room does not exist!') + // A valid token but not the room's owner/co-owner → 403 (the auth gate above + // already returned 401 for a missing/invalid token). + if (!canManageRoom(room, accountId)) return c.body(null, 403) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const patch: Record = {} + for (const [key, value] of Object.entries(body)) { + const field = RESTRICTION_FIELDS[key.toLowerCase()] + if (field !== undefined && typeof value === 'string') { + patch[field] = value.toLowerCase() === 'true' + } + } + + const updated = await updateRoomFields(c.env.DB, roomId, room, patch) + await pushRoomUpdate(c, accountId, updated) + return roomEnvelope(c, updated) + }) + + // Add a load screen to a room (`LoadScreens[]` — the images shown while the room + // loads). Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName` + // form field plus optional `title`/`subtitle`. Appends one + // `{ ImageName, Title, Subtitle }` to the existing list and returns the updated + // room in the `{ success, error, value }` envelope. + .put('/rooms/:roomId{[0-9]+}/loadscreen', async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const room = await getRoomById(c.env.DB, roomId) + if (!room) return roomEnvelope(c, null, 'This room does not exist!') + // A valid token but not the room's owner/co-owner → 403 (the auth gate above + // already returned 401 for a missing/invalid token). + if (!canManageRoom(room, accountId)) return c.body(null, 403) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : '' + if (imageName === '') return roomEnvelope(c, null, 'You must provide an image!') + const title = typeof body.title === 'string' ? body.title : '' + const subtitle = typeof body.subtitle === 'string' ? body.subtitle : '' + + const existing = Array.isArray(room.LoadScreens) ? (room.LoadScreens as unknown[]) : [] + const loadScreens = [...existing, { ImageName: imageName, Title: title, Subtitle: subtitle }] + const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens }) + await pushRoomUpdate(c, accountId, updated) + return roomEnvelope(c, updated) }) // A subroom's data descriptor (the SubRoom object from the room's SubRooms @@ -643,13 +756,9 @@ const app = new Hono() Error: 'This room does not exist!', }) } - if (!canManageRoom(room, accountId)) { - return roomResult(c, { - Success: false, - ErrorId: 'Rooms.PermissionDenied', - Error: 'You are not the owner of this room!', - }) - } + // A valid token but not the room's owner/co-owner → 403 (the auth gate above + // already returned 401 for a missing/invalid token). + if (!canManageRoom(room, accountId)) return c.body(null, 403) const body = (await c.req.json().catch(() => ({}))) as { RoomData?: { Filename?: string } diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 135288b..69e4714 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -413,6 +413,7 @@ describe('rooms endpoints', () => { Name: string CreatorAccountId: number Tags?: Array<{ Tag: string }> + IsRRO: boolean Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }> } | null } @@ -428,6 +429,8 @@ describe('rooms endpoints', () => { // The clone starts fresh with no tags — none of the source's tags (including // the `base` template tag) carry over. expect(ok.value!.Tags).toEqual([]) + // IsRRO is cleared so the client doesn't render a virtual "RRO" tag on the clone. + expect(ok.value!.IsRRO).toBe(false) // Ownership is reset to the cloner: sole owner (Role 255), and none of the // source base room's roles (accounts 1/2) carry over. expect(ok.value!.Roles).toEqual([ @@ -507,7 +510,8 @@ describe('rooms endpoints', () => { body: new URLSearchParams(fields).toString(), }) - // Room-mutation envelope helper. + // Room-mutation envelope helper (PascalCase `{ Success, Value, ErrorId, Error }` — + // used by name/image/description). type RoomResult = { Success: boolean Value: unknown @@ -516,6 +520,11 @@ describe('rooms endpoints', () => { } const bodyOf = async (res: Response) => (await res.json()) as RoomResult + // Lowercase `{ success, error, value }` envelope — used by tags/clone and the + // roles/warning/cloning/restrictions room-settings mutations (value = updated room). + type RoomEnv = { success: boolean; error: string; value: Record | null } + const envOf = async (res: Response) => (await res.json()) as RoomEnv + it('PUT /rooms/:id/description is auth-gated, owner-only, and persists', async () => { // No token → 401 (auth gate). expect((await putForm('/rooms/2/description', { description: 'x' })).status).toBe(401) @@ -584,26 +593,27 @@ describe('rooms endpoints', () => { // No token → 401 (auth gate). expect((await putForm('/rooms/2/roles/5', { role: '20' })).status).toBe(401) // A valid token but no role on the room (RecCenter is owned by account 1, with - // account 2 as co-owner) → Success:false. - expect(await bodyOf(await putForm('/rooms/2/roles/5', { role: '20' }, '999'))).toMatchObject({ - Success: false, - ErrorId: 'Rooms.PermissionDenied', + // account 2 as co-owner) → 403. + expect((await putForm('/rooms/2/roles/5', { role: '20' }, '999')).status).toBe(403) + // Unknown room → failure envelope. + expect(await envOf(await putForm('/rooms/99999/roles/5', { role: '20' }, '1'))).toMatchObject({ + success: false, + error: 'This room does not exist!', }) - // Unknown room → Rooms.DoesntExist envelope. - expect(await bodyOf(await putForm('/rooms/99999/roles/5', { role: '20' }, '1'))).toMatchObject({ - Success: false, - ErrorId: 'Rooms.DoesntExist', - }) - // Non-numeric role → Success:false. - expect(await bodyOf(await putForm('/rooms/2/roles/5', { role: 'nope' }, '1'))).toMatchObject({ - Success: false, - ErrorId: 'Rooms.InvalidRole', + // Non-numeric role → failure envelope. + expect(await envOf(await putForm('/rooms/2/roles/5', { role: 'nope' }, '1'))).toMatchObject({ + success: false, }) - // Owner sets account 5's role to 20, adding a new Roles entry that persists. + // Owner sets account 5's role to 20, adding a new Roles entry that persists. The + // success envelope carries the updated room as `value`. const ok = await putForm('/rooms/2/roles/5', { role: '20' }, '1') expect(ok.status).toBe(200) - expect(await bodyOf(ok)).toMatchObject({ Success: true }) + const okBody = await envOf(ok) + expect(okBody).toMatchObject({ success: true, error: '' }) + expect((okBody.value?.Roles as Array<{ AccountId: number; Role: number }>)).toContainEqual( + expect.objectContaining({ AccountId: 5, Role: 20 }) + ) expect(await rolesOf()).toContainEqual(expect.objectContaining({ AccountId: 5, Role: 20 })) // The co-owner (account 2, Role 30) may also change it — updating the existing @@ -617,6 +627,173 @@ describe('rooms endpoints', () => { expect(roles).toContainEqual(expect.objectContaining({ AccountId: 2, Role: 30 })) }) + it('PUT /rooms/:id/warning is auth-gated, owner/co-owner-only, and persists', async () => { + // No token → 401 (auth gate). + expect((await putForm('/rooms/2/warning', { warningMask: '2' })).status).toBe(401) + // A valid token but no role on the room → 403. + expect((await putForm('/rooms/2/warning', { warningMask: '2' }, '999')).status).toBe(403) + // Unknown room → failure envelope. + expect( + await envOf(await putForm('/rooms/99999/warning', { warningMask: '2' }, '1')) + ).toMatchObject({ success: false, error: 'This room does not exist!' }) + // Non-numeric mask → failure envelope. + expect(await envOf(await putForm('/rooms/2/warning', { warningMask: 'x' }, '1'))).toMatchObject( + { success: false } + ) + + // Owner sets it, and it persists as an integer (not 2.0). The success envelope + // carries the updated room as `value`. + const ok = await putForm('/rooms/2/warning', { warningMask: '2' }, '1') + expect(ok.status).toBe(200) + const okBody = await envOf(ok) + expect(okBody).toMatchObject({ success: true, error: '' }) + expect(okBody.value?.WarningMask).toBe(2) + const raw = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1') + .bind(2) + .first<{ data: string }>() + expect(raw!.data).toContain('"WarningMask":2') + expect(raw!.data).not.toContain('"WarningMask":2.0') + const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { WarningMask: number } + expect(room.WarningMask).toBe(2) + + // The mask can carry an optional free-text CustomWarning alongside it. + const custom = await envOf( + await putForm('/rooms/2/warning', { warningMask: '63', customWarning: 'slfkjsdf' }, '1') + ) + expect(custom).toMatchObject({ success: true }) + expect(custom.value).toMatchObject({ WarningMask: 63, CustomWarning: 'slfkjsdf' }) + const withCustom = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { + WarningMask: number + CustomWarning: string + } + expect(withCustom).toMatchObject({ WarningMask: 63, CustomWarning: 'slfkjsdf' }) + + // Omitting customWarning leaves the existing text untouched (partial update). + await putForm('/rooms/2/warning', { warningMask: '7' }, '1') + const kept = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { CustomWarning: string } + expect(kept.CustomWarning).toBe('slfkjsdf') + + // The co-owner (account 2, Role 30) may also set it. + expect((await putForm('/rooms/2/warning', { warningMask: '4' }, '2')).status).toBe(200) + }) + + it('PUT /rooms/:id/cloning is auth-gated, owner/co-owner-only, and persists a JSON boolean', async () => { + // No token → 401. + expect((await putForm('/rooms/2/cloning', { cloningAllowed: 'False' })).status).toBe(401) + // A valid token but no role on the room → 403. + expect( + (await putForm('/rooms/2/cloning', { cloningAllowed: 'False' }, '999')).status + ).toBe(403) + // Unknown room → failure envelope. + expect( + await envOf(await putForm('/rooms/99999/cloning', { cloningAllowed: 'False' }, '1')) + ).toMatchObject({ success: false, error: 'This room does not exist!' }) + + // Owner disables cloning; it persists as a real JSON boolean (not 0/1). The + // success envelope carries the updated room as `value`. + const disabled = await envOf(await putForm('/rooms/2/cloning', { cloningAllowed: 'False' }, '1')) + expect(disabled).toMatchObject({ success: true, error: '' }) + expect(disabled.value?.CloningAllowed).toBe(false) + const raw = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1') + .bind(2) + .first<{ data: string }>() + expect(raw!.data).toContain('"CloningAllowed":false') + const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { CloningAllowed: boolean } + expect(room.CloningAllowed).toBe(false) + + // The co-owner (account 2) may re-enable it. + await putForm('/rooms/2/cloning', { cloningAllowed: 'True' }, '2') + const reenabled = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { + CloningAllowed: boolean + } + expect(reenabled.CloningAllowed).toBe(true) + }) + + it('PUT /rooms/:id/restrictions sets the Supports* flags present in the body', async () => { + // No token → 401; a valid token with no role → 403. + expect((await putForm('/rooms/2/restrictions', { supportsScreens: 'True' })).status).toBe(401) + expect( + (await putForm('/rooms/2/restrictions', { supportsScreens: 'True' }, '999')).status + ).toBe(403) + + // The exact client body: a mix of True/False across a subset of the flags. + const res = await SELF.fetch(`${ORIGIN}/rooms/2/restrictions`, { + method: 'PUT', + headers: { ...(await bearer('1')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'supportsScreens=True&supportsWalkVR=True&supportsTeleportVR=False&supportsJuniors=True', + }) + expect(res.status).toBe(200) + // The success envelope carries the updated room as `value`. + const env2 = await envOf(res) + expect(env2).toMatchObject({ success: true, error: '' }) + expect(env2.value).toMatchObject({ + SupportsScreens: true, + SupportsWalkVR: true, + SupportsTeleportVR: false, + SupportsJuniors: true, + }) + + const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { + SupportsScreens: boolean + SupportsWalkVR: boolean + SupportsTeleportVR: boolean + SupportsJuniors: boolean + SupportsMobile: boolean + } + expect(room.SupportsScreens).toBe(true) + expect(room.SupportsWalkVR).toBe(true) + expect(room.SupportsTeleportVR).toBe(false) + expect(room.SupportsJuniors).toBe(true) + // A flag not in the body is left unchanged (still a boolean, not dropped). + expect(typeof room.SupportsMobile).toBe('boolean') + }) + + it('PUT /rooms/:id/loadscreen appends a load screen (auth-gated, owner/co-owner-only)', async () => { + const screensOf = async (): Promise>> => { + const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { + LoadScreens?: Array> + } + return room.LoadScreens ?? [] + } + + // No token → 401; a valid token with no role → 403. + expect((await putForm('/rooms/2/loadscreen', { imageName: 'a.jpg' })).status).toBe(401) + expect((await putForm('/rooms/2/loadscreen', { imageName: 'a.jpg' }, '999')).status).toBe(403) + // Unknown room → failure envelope. + expect( + await envOf(await putForm('/rooms/99999/loadscreen', { imageName: 'a.jpg' }, '1')) + ).toMatchObject({ success: false, error: 'This room does not exist!' }) + // Missing image → failure envelope. + expect(await envOf(await putForm('/rooms/2/loadscreen', { title: 'x' }, '1'))).toMatchObject({ + success: false, + }) + + const before = (await screensOf()).length + + // Owner adds one (imageName + title + subtitle) — appended, and the success + // envelope carries the updated room. + const added = await envOf( + await putForm( + '/rooms/2/loadscreen', + { imageName: 'sharecamera/2026-07-15/abc.jpg', title: 'asdf', subtitle: 'sdf' }, + '1' + ) + ) + expect(added).toMatchObject({ success: true }) + expect(added.value?.LoadScreens as unknown[]).toContainEqual({ + ImageName: 'sharecamera/2026-07-15/abc.jpg', + Title: 'asdf', + Subtitle: 'sdf', + }) + expect(await screensOf()).toHaveLength(before + 1) + + // A second call appends rather than replacing; title/subtitle default to empty. + const co = await envOf(await putForm('/rooms/2/loadscreen', { imageName: 'second.jpg' }, '2')) + expect(co).toMatchObject({ success: true }) + expect(await screensOf()).toHaveLength(before + 2) + expect(await screensOf()).toContainEqual({ ImageName: 'second.jpg', Title: '', Subtitle: '' }) + }) + it('GET /rooms/:id/subrooms/:sid/data returns the subroom descriptor (404 when unknown)', async () => { // Room 2 has SubRoomId 2 in the seed. const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`) @@ -661,12 +838,9 @@ describe('rooms endpoints', () => { body: JSON.stringify(save), }) + // A valid token but no role on the room → 403. + expect((await authed(2, 2, '999')).status).toBe(403) // The response uses the PascalCase `{ Success, Value, ErrorId, Error }` envelope. - // Wrong owner (no role) → PermissionDenied. - expect(await bodyOf(await authed(2, 2, '999'))).toMatchObject({ - Success: false, - ErrorId: 'Rooms.PermissionDenied', - }) // Unknown room → DoesntExist. expect(await bodyOf(await authed(99999, 2, '1'))).toMatchObject({ Success: false, diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 0782870..c2456e4 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -80,7 +80,8 @@ export function canManageRoom(room: Room, accountId: number): boolean { * room's content (scene/subrooms/settings), assigning a fresh RoomId, the given * name, and the new owner. The clone starts with an empty tag set — the source's * tags (including the `base` template tag) do not carry over, so the owner tags the - * clone from scratch. Returns the new room, or null when the source isn't in D1 or + * clone from scratch — and `IsRRO` is cleared so the client doesn't render a virtual + * "RRO" tag on it. Returns the new room, or null when the source isn't in D1 or * disallows cloning. */ export async function cloneRoom( @@ -112,6 +113,9 @@ export async function cloneRoom( IsDorm: false, // Start fresh: drop every tag the source carried (including `base`). Tags: [], + // A user clone is not a Rec Room Original — clear the inherited flag, or the + // client renders a virtual "RRO" tag on the clone. + IsRRO: false, Roles: roles, CreatedAt: new Date().toISOString(), } @@ -148,6 +152,27 @@ export async function setRoomImage(db: D1Database, roomId: number, imageName: st .run() } +/** + * Merge a set of top-level fields into a room's JSON blob and write it back. Used by + * the room-settings mutations whose values include booleans (cloning, platform + * restrictions) — rewriting the whole blob preserves proper JSON booleans, whereas a + * `json_set` bind would store `true`/`false` as `1`/`0`. The caller supplies the + * already-loaded, permission-checked room. Returns the updated room. + */ +export async function updateRoomFields( + db: D1Database, + roomId: number, + room: Room, + patch: Record +): Promise { + const updated: Room = { ...room, ...patch } + await db + .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') + .bind(roomId, JSON.stringify(updated)) + .run() + return updated +} + /** * Set a target account's room `Role` — updating their existing `Roles` entry or * appending a new one — and stamp `LastChangedByAccountId` with the editor. The