diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index 30117ad..acea956 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -574,6 +574,41 @@ const app = new Hono({ 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 + 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>) + : [] + 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. diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 55a8178..b91a925 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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, + sub?: string + ): Promise => { + 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) diff --git a/apps/rooms/src/context.ts b/apps/rooms/src/context.ts index 782c04b..30c0dad 100644 --- a/apps/rooms/src/context.ts +++ b/apps/rooms/src/context.ts @@ -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 } /** Variables can be extended */ diff --git a/apps/rooms/src/rooms-db.ts b/apps/rooms/src/rooms-db.ts index 5183fec..59d8ebb 100644 --- a/apps/rooms/src/rooms-db.ts +++ b/apps/rooms/src/rooms-db.ts @@ -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 { + 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 | undefined { + const subRooms = Array.isArray(room.SubRooms) + ? (room.SubRooms as Array>) + : [] + 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 { + 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 } diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 24772f6..111d9fa 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -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) { 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, accountId: number): boolean { + if (room.CreatorAccountId === accountId) return true + const roles = Array.isArray(room.Roles) ? (room.Roles as Array>) : [] + 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, + playerId: number, + room: Record +): Promise { + 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() 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 + 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. diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index b105d19..83c47cb 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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) diff --git a/apps/rooms/vitest.config.ts b/apps/rooms/vitest.config.ts index de0d903..b549955 100644 --- a/apps/rooms/vitest.config.ts +++ b/apps/rooms/vitest.config.ts @@ -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') } } + `, + }, + ], }, }), ], diff --git a/apps/rooms/wrangler.jsonc b/apps/rooms/wrangler.jsonc index 9841a60..0c6fe74 100644 --- a/apps/rooms/wrangler.jsonc +++ b/apps/rooms/wrangler.jsonc @@ -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": { diff --git a/apps/storage/src/storage.app.ts b/apps/storage/src/storage.app.ts index 378d2b3..5289158 100644 --- a/apps/storage/src/storage.app.ts +++ b/apps/storage/src/storage.app.ts @@ -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 = { - 1: 'roomsave', + 1: 'room', 2: 'holotar', 3: 'image', 4: 'video', diff --git a/apps/storage/src/test/integration/api.test.ts b/apps/storage/src/test/integration/api.test.ts index 177b0a1..65c877a 100644 --- a/apps/storage/src/test/integration/api.test.ts +++ b/apps/storage/src/test/integration/api.test.ts @@ -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'], ]