working room saves

This commit is contained in:
Devin Zuczek
2026-07-05 22:55:56 -04:00
parent 5a2e3f6e1a
commit 3b9d59239e
10 changed files with 469 additions and 3 deletions
+35
View File
@@ -574,6 +574,41 @@ const app = new Hono<App>({ strict: false })
})
)
// Verify the caller holds at least `role` in a room. Params come from the form
// body (falling back to the query string). Returns a bare `true`/`false`: the
// room creator always passes; otherwise the caller needs a Roles entry with
// `Role >= role`. Any failure (no token, unknown room, insufficient role) is
// `false`. The `context` field (e.g. MakerPen) is accepted and ignored.
.post('/api/rooms/v1/verifyRole', async (c) => {
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const param = (name: string): string => {
const form = body[name]
if (typeof form === 'string' && form !== '') return form
return c.req.query(name) ?? ''
}
const roomId = Number.parseInt(param('roomId'), 10)
const role = Number.parseInt(param('role'), 10)
const accountId = await authedId(c)
if (accountId === null || Number.isNaN(roomId)) return c.json(false)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return c.json(false)
// The creator always passes.
if (room.CreatorAccountId === accountId) return c.json(true)
// Otherwise the caller needs a room role at least as high as requested.
const roles = Array.isArray(room.Roles)
? (room.Roles as Array<Record<string, unknown>>)
: []
const hasRole = roles.some(
(r) =>
r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0)
)
return c.json(hasRole)
})
// ---- Room server ----------------------------------------------------------
// Room data is read from the shared `recflare` D1 (owned by the rooms worker).
// Register specific paths before the `/:id` param route.
+40
View File
@@ -32,6 +32,15 @@ const TEST_ROOMS = [
CreatorAccountId: 1,
SubRooms: [{ SubRoomId: 2 }],
},
{
// Owned by account 1; account 42 holds Role 30 (a co-owner) for verifyRole tests.
RoomId: 3,
Name: 'RoleRoom',
IsDorm: false,
CreatorAccountId: 1,
SubRooms: [{ SubRoomId: 3 }],
Roles: [{ AccountId: 42, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
},
]
beforeAll(async () => {
@@ -315,6 +324,37 @@ describe('room server', () => {
expect(rooms.map((r) => r.Name)).toEqual(['RecCenter'])
})
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
const verify = async (
fields: Record<string, string>,
sub?: string
): Promise<boolean> => {
const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/verifyRole`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(sub ? await bearer(sub) : {}),
},
body: new URLSearchParams(fields).toString(),
})
expect(res.status).toBe(200)
return (await res.json()) as boolean
}
// No token → false.
expect(await verify({ roomId: '2', role: '255' })).toBe(false)
// Creator (account 1 owns room 2) → true regardless of role.
expect(await verify({ roomId: '2', role: '255', context: 'MakerPen' }, '1')).toBe(true)
// Non-creator with no role in the room → false.
expect(await verify({ roomId: '2', role: '30' }, '42')).toBe(false)
// Account 42 holds Role 30 in room 3 → passes when requesting ≤ 30…
expect(await verify({ roomId: '3', role: '30' }, '42')).toBe(true)
// …but not a higher role.
expect(await verify({ roomId: '3', role: '255' }, '42')).toBe(false)
// Unknown room → false.
expect(await verify({ roomId: '99999', role: '0' }, '42')).toBe(false)
})
test('GET /roomserver/photon_access_token returns permissions + instance id', async () => {
const res = await exports.default.fetch(`${ORIGIN}/roomserver/photon_access_token`)
expect(res.status).toBe(200)
+6
View File
@@ -1,5 +1,8 @@
import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
// Type-only import (erased at build) of the DO class owned by the `notify`
// worker, so the cross-worker RPC stub is fully typed.
import type { NotificationsHub } from '../../notify/src/notifications-hub'
export type Env = SharedHonoEnv & {
// D1 database holding rooms (JSON blob + generated columns). See rooms-db.ts.
@@ -8,6 +11,9 @@ export type Env = SharedHonoEnv & {
// the caller's current room instance for the photon access token — the
// equivalent of the reference server's HeartbeatDB.GetPlayerHeartbeat.
RECFLARE_MATCH_PRESENCE: KVNamespace
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
// push RoomUpdate notifications when a room is mutated.
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
}
/** Variables can be extended */
+67
View File
@@ -117,6 +117,73 @@ export async function setRoomName(db: D1Database, roomId: number, name: string):
.run()
}
/** Set a room's ImageName in place (the caller is responsible for the owner check). */
export async function setRoomImage(db: D1Database, roomId: number, imageName: string): Promise<void> {
await db
.prepare("UPDATE rooms SET data = json_set(data, '$.ImageName', ?2) WHERE room_id = ?1")
.bind(roomId, imageName)
.run()
}
/** Find a subroom (by SubRoomId) inside a room's `SubRooms` array, or undefined. */
export function findSubRoom(room: Room, subRoomId: number): Record<string, unknown> | undefined {
const subRooms = Array.isArray(room.SubRooms)
? (room.SubRooms as Array<Record<string, unknown>>)
: []
return subRooms.find((s) => s.SubRoomId === subRoomId)
}
/** Fields from the client's room-save POST body. */
export interface SaveSubRoomDataInput {
/** Uploaded blob key for this subroom's scene data (becomes the subroom's DataBlob). */
subRoomDataFilename?: string
/** Uploaded blob key for the room-level data. */
roomDataFilename?: string
description?: string
persistenceVersion?: number
inventionUsage?: string
}
/**
* Persist a room-save against a specific subroom: point the subroom at its newly
* uploaded data blob (what the loader later downloads) and record the room-level
* fields from the save. Returns the updated room, or null when the room or
* subroom doesn't exist. The whole room JSON is rewritten (subrooms live in it).
*/
export async function saveSubRoomData(
db: D1Database,
roomId: number,
subRoomId: number,
accountId: number,
input: SaveSubRoomDataInput
): Promise<Room | null> {
const room = await getRoomById(db, roomId)
if (!room) return null
const sub = findSubRoom(room, subRoomId)
if (!sub) return null
// 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.
if (sub.CreatorAccountId == null) sub.CreatorAccountId = accountId
// Point the subroom at the newly-uploaded data blobs and stamp the save.
if (input.subRoomDataFilename) sub.DataBlob = input.subRoomDataFilename
if (input.roomDataFilename) sub.RoomDataBlob = input.roomDataFilename
sub.DataSavedAt = new Date().toISOString()
if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion
// Room-level fields carried by the save.
if (typeof input.description === 'string') room.Description = input.description
if (input.persistenceVersion !== undefined) room.PersistenceVersion = input.persistenceVersion
if (input.inventionUsage !== undefined) room.InventionUsage = input.inventionUsage
await db
.prepare('UPDATE rooms SET data = ?2 WHERE room_id = ?1')
.bind(roomId, JSON.stringify(room))
.run()
return room
}
interface RoomRow {
data: string
}
+160 -1
View File
@@ -1,11 +1,12 @@
import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from './jwt'
import {
cloneRoom,
findSubRoom,
getBaseRooms,
getFavoritedRooms,
getHotRooms,
@@ -20,8 +21,10 @@ import {
removeCheer,
removeFavorite,
getVisitedRooms,
saveSubRoomData,
searchRooms,
setRoomDescription,
setRoomImage,
setRoomName,
toggleCheer,
toggleFavorite,
@@ -138,6 +141,53 @@ function unauthorized(c: Context<App>) {
return c.json({ error: 'Unauthorized' }, 401)
}
/**
* Room role values the reference treats as edit-capable: Creator (255) and
* CoOwner. (CoOwner's numeric value is a best guess from the seed data — base
* rooms give the co-owner account Role 30.)
*/
const EDIT_ROLES = new Set([255, 30])
/**
* Whether an account may edit a room's data — its creator, or a holder of a
* Creator/CoOwner role. Mirrors the reference's SetRoomData permission check.
*/
function canEditRoomData(room: Record<string, unknown>, accountId: number): boolean {
if (room.CreatorAccountId === accountId) return true
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Record<string, unknown>>) : []
return roles.some(
(r) => r.AccountId === accountId && typeof r.Role === 'number' && EDIT_ROLES.has(r.Role)
)
}
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/**
* Push a RoomUpdate notification to a player after their room changes, mirroring
* the reference server's `HubSendToPlayer(playerId, NotifFrame("RoomUpdate", room))`.
* Hub failures are logged and swallowed — the room write has already committed,
* so a hub hiccup must not fail the request.
*/
async function pushRoomUpdate(
c: Context<App>,
playerId: number,
room: Record<string, unknown>
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
'RoomUpdate',
room
)
} catch (err) {
logger.error('failed to push RoomUpdate notification', {
playerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Room-mutation result envelope: `{ Success, Value, ErrorId, Error }`, always
* HTTP 200 (the client reads `Success`). `ErrorId`/`Error` are null on success.
@@ -453,6 +503,115 @@ const app = new Hono<App>()
return roomResult(c, { Success: true })
})
// Set a room's image. Auth-gated (401) and owner-only. Body is the `imageName`
// form field (a key from the storage/image upload). Business results use the
// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
.put('/rooms/:roomId{[0-9]+}/image', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
if (room.CreatorAccountId !== accountId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
Error: 'You are not the owner of this room!',
})
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
if (imageName === '') {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.InvalidImage',
Error: 'You must provide an image!',
})
}
await setRoomImage(c.env.DB, roomId, imageName)
// Notify the owner so their client refreshes the room (RoomUpdate carries the
// updated room). The reference sends the post-update room, so merge the change.
await pushRoomUpdate(c, accountId, { ...room, ImageName: imageName })
return roomResult(c, { Success: true })
})
// A subroom's data descriptor (the SubRoom object from the room's SubRooms
// array). Public — the client fetches it while loading the room. 404 when the
// room or subroom is unknown.
.get('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data', async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
const sub = room ? findSubRoom(room, subRoomId) : undefined
return sub ? c.json(sub) : c.notFound()
})
// Save a subroom's data (room save). Auth-gated (401 with empty body). Editable
// by the room creator or a Creator/CoOwner role holder. Points the subroom at
// the uploaded data blobs and records the room-level save fields, notifies the
// owner, and returns the updated ROOM in the lowercase `{ success, error, value }`
// envelope the reference's SetRoomData uses.
.post('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return c.body(null, 401)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
if (!canEditRoomData(room, accountId)) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.PermissionDenied',
Error: 'You are not the owner of this room!',
})
}
const body = (await c.req.json().catch(() => ({}))) as {
RoomData?: { Filename?: string }
SubRoomData?: { Filename?: string }
Description?: string
PersistenceVersion?: number
InventionUsage?: string
}
const updated = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, {
subRoomDataFilename: body.SubRoomData?.Filename,
roomDataFilename: body.RoomData?.Filename,
description: typeof body.Description === 'string' ? body.Description : undefined,
persistenceVersion:
typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined,
})
if (!updated) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
// RoomUpdate carries the full room, but the HTTP response is the saved SUBROOM
// itself — no envelope. The client deserializes the body directly as the subroom.
await pushRoomUpdate(c, accountId, updated)
return c.json(findSubRoom(updated, subRoomId) ?? {})
})
// Rooms similar to the given room (sharing tags). Paginated via skip/take (take
// defaults to 100). Returns `{ Results, TotalResults }`; empty when the room is
// unknown/untagged.
+121
View File
@@ -519,6 +519,127 @@ describe('rooms endpoints', () => {
expect(room.Description).toBe('blah blah blah')
})
it('PUT /rooms/:id/image is auth-gated, owner-only, and persists', async () => {
const imageName = '644064b03bd64a8291cde284629e9ca9.jpg'
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/image', { imageName })).status).toBe(401)
// Not the owner (RecCenter is owned by account 1) → 200 envelope, Success:false.
expect(await bodyOf(await putForm('/rooms/2/image', { imageName }, '999'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.NotOwner',
})
// Unknown room → Rooms.DoesntExist envelope.
expect(await bodyOf(await putForm('/rooms/99999/image', { imageName }, '1'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.DoesntExist',
})
// Empty image → Success:false.
expect(await bodyOf(await putForm('/rooms/2/image', { imageName: ' ' }, '1'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.InvalidImage',
})
// Owner sets it, and it persists.
const ok = await putForm('/rooms/2/image', { imageName }, '1')
expect(ok.status).toBe(200)
expect(await bodyOf(ok)).toMatchObject({ Success: true })
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { ImageName: string }
expect(room.ImageName).toBe(imageName)
})
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`)
expect(res.status).toBe(200)
expect((await res.json()) as { SubRoomId: number }).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 () => {
const save = {
UnityAssetId: null,
RoomData: { Filename: '5c618c920f6247efb8327e327d0b4417', Hash: null, OwnershipProof: null },
SubRoomData: {
Filename: 'a84167b16796452ab70ee8a6a5b1dc5f',
Hash: null,
OwnershipProof: null,
},
InventionUsage: 'CAE=',
PersistenceVersion: 41,
Description: 'mydescription here',
AutoPublish: true,
}
// No token → 401.
expect(
(
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(save),
})
).status
).toBe(401)
const authed = async (roomId: number, subRoomId: number, sub = '1') =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/data`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await bearer(sub)) },
body: JSON.stringify(save),
})
// 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,
ErrorId: 'Rooms.DoesntExist',
})
// Owner saves → 200 with the saved SUBROOM as the bare body (no envelope),
// carrying the new blobs and populated creator.
const ok = await authed(2, 2, '1')
expect(ok.status).toBe(200)
expect(await bodyOf(ok)).toMatchObject({
SubRoomId: 2,
DataBlob: 'a84167b16796452ab70ee8a6a5b1dc5f',
RoomDataBlob: '5c618c920f6247efb8327e327d0b4417',
CreatorAccountId: 1,
PersistenceVersion: 41,
})
// 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',
CreatorAccountId: 1,
})
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
Description: string
PersistenceVersion: number
}
expect(room.Description).toBe('mydescription here')
expect(room.PersistenceVersion).toBe(41)
// 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).
const coOwner = await authed(2, 2, '2')
expect(coOwner.status).toBe(200)
expect(await bodyOf(coOwner)).toMatchObject({ SubRoomId: 2, CreatorAccountId: 1 })
})
it('PUT /rooms/:id/name is auth-gated, owner-only, unique, and persists', async () => {
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/name', { name: 'Whatever' })).status).toBe(401)
+22
View File
@@ -9,6 +9,28 @@ export default defineConfig({
bindings: {
ENVIRONMENT: 'VITEST',
},
// The worker's RECFLARE_NOTIFICATIONS_HUB binding points at the `notify`
// worker's DO (script_name: "notify"). That worker isn't part of this
// isolated test, so provide a minimal stub service exposing the same
// NotificationsHub RPC surface — enough for the runtime to start and for
// notification sends to no-op.
workers: [
{
name: 'notify',
modules: true,
compatibilityDate: '2025-09-20',
compatibilityFlags: ['nodejs_compat'],
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
async notifyPlayer() { return { delivered: 0, queued: true } }
async broadcast() { return { delivered: 0 } }
}
export default { fetch() { return new Response('ok') } }
`,
},
],
},
}),
],
+11
View File
@@ -27,6 +27,17 @@
"id": "local"
}
],
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
// the `notify` worker). We only invoke its RPC methods; no migration here.
"durable_objects": {
"bindings": [
{
"name": "RECFLARE_NOTIFICATIONS_HUB",
"class_name": "NotificationsHub",
"script_name": "notify"
}
]
},
"upload_source_maps": true,
"observability": {
"logs": {
+5 -1
View File
@@ -19,9 +19,13 @@ import type { App } from './context'
* stored under. `Unknown` (0) is intentionally absent: like the reference
* server's `makeUploadName`, an unrecognized type has no destination and is
* rejected rather than stored.
*
* RoomSave (1) lands under `room/` (not `roomsave/`) so the `cdn` worker's
* `GET /room/:dataBlob` route serves the blob back — both bind the same
* `recflare-cdn` bucket, so the key prefixes must match.
*/
const UPLOAD_SUBFOLDER: Record<number, string> = {
1: 'roomsave',
1: 'room',
2: 'holotar',
3: 'image',
4: 'video',
@@ -79,7 +79,8 @@ it('POST /upload stores a RoomMetadata (FileType 6) file under roommetadata/ and
it('POST /upload folders each FileType under its own subfolder', async () => {
const headers = await bearer()
const cases: Array<[string, string]> = [
['1', 'roomsave'],
// RoomSave lands under `room/` so the cdn worker's /room/:dataBlob serves it.
['1', 'room'],
['3', 'image'],
['5', 'invention'],
]