fix some custom room authoring issues

This commit is contained in:
Devin Zuczek
2026-07-15 16:12:24 -04:00
parent 0061dc046b
commit 489ac04574
8 changed files with 569 additions and 70 deletions
+75
View File
@@ -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<Save
return image
}
/**
* Recompute an image's `CheerCount` from the `image_interaction` rows and write it
* back into the blob (nothing reads a generated column for it, but the client-facing
* blob must stay accurate). CAST to INTEGER: D1 binds a JS number as a SQLite REAL,
* which json_set would otherwise store as `"CheerCount":3.0`. Returns the fresh count.
*/
async function syncImageCheerCount(db: D1Database, savedImageId: number): Promise<number> {
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<void> {
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<Set<number>> {
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<SavedImage | null> {
const row = await db
+27 -4
View File
@@ -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<App>({ 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) })))
})
+76 -10
View File
@@ -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<number> => {
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 () => {
@@ -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);
+12
View File
@@ -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). */
+143 -34
View File
@@ -29,6 +29,7 @@ import {
setRoomImage,
setRoomName,
setRoomRole,
updateRoomFields,
toggleCheer,
toggleFavorite,
toggleRoomTag,
@@ -139,6 +140,20 @@ function unauthorized(c: Context<App>) {
/** 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<string, string> = {
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<App>()
})
// 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<App>()
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<string, unknown>
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<string, unknown>
const warningMask =
typeof body.warningMask === 'string' ? Number.parseInt(body.warningMask, 10) : Number.NaN
if (Number.isNaN(warningMask)) return roomEnvelope(c, null, 'You must provide a valid warning mask!')
const patch: Record<string, unknown> = { WarningMask: warningMask }
// Only touch CustomWarning when the field is present (an empty string clears it).
if (typeof body.customWarning === 'string') patch.CustomWarning = body.customWarning
const 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<string, unknown>
if (typeof body.cloningAllowed !== 'string') {
return roomEnvelope(c, null, 'You must provide cloningAllowed.')
}
const cloningAllowed = body.cloningAllowed.toLowerCase() === 'true'
const updated = await updateRoomFields(c.env.DB, roomId, room, { CloningAllowed: cloningAllowed })
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
})
// 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<string, unknown>
const patch: Record<string, boolean> = {}
for (const [key, value] of Object.entries(body)) {
const field = RESTRICTION_FIELDS[key.toLowerCase()]
if (field !== undefined && typeof value === 'string') {
patch[field] = value.toLowerCase() === 'true'
}
}
const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
})
// 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<string, unknown>
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
if (imageName === '') return roomEnvelope(c, null, 'You must provide an image!')
const title = typeof body.title === 'string' ? body.title : ''
const subtitle = typeof body.subtitle === 'string' ? body.subtitle : ''
const existing = Array.isArray(room.LoadScreens) ? (room.LoadScreens as unknown[]) : []
const loadScreens = [...existing, { ImageName: imageName, Title: title, Subtitle: subtitle }]
const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens })
await pushRoomUpdate(c, accountId, updated)
return roomEnvelope(c, updated)
})
// A subroom's data descriptor (the SubRoom object from the room's SubRooms
@@ -643,13 +756,9 @@ const app = new Hono<App>()
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 }
+195 -21
View File
@@ -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<string, unknown> | 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 roomRooms.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 rolefailure 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<Array<Record<string, unknown>>> => {
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
LoadScreens?: Array<Record<string, unknown>>
}
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,