From 5010f623719be9eb8d9741e426c80a4878606697 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 3 Aug 2026 15:11:50 -0400 Subject: [PATCH] add subroom permissions --- .../migrations/0009_subroom_permissions.sql | 29 ++ apps/rooms/src/openapi.ts | 40 ++- apps/rooms/src/rooms.app.ts | 192 ++++++++++-- apps/rooms/src/test/integration/api.test.ts | 284 ++++++++++++++++-- 4 files changed, 493 insertions(+), 52 deletions(-) create mode 100644 apps/rooms/migrations/0009_subroom_permissions.sql diff --git a/apps/rooms/migrations/0009_subroom_permissions.sql b/apps/rooms/migrations/0009_subroom_permissions.sql new file mode 100644 index 0000000..7399fde --- /dev/null +++ b/apps/rooms/migrations/0009_subroom_permissions.sql @@ -0,0 +1,29 @@ +-- Per-subroom permission overrides. `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions` +-- is how a room's creator changes what a role may do in one subroom (spawn inventions, +-- invite, use the delete-all button, …). The client addresses an entry by the +-- (`Permission`, `Role`) pair and re-PUTs that pair to change it, so the pair is the +-- primary key: sending it again overwrites the stored row rather than appending a second. +-- +-- A row IS an override, so the client's `Override` flag is not a column. It's the checkbox +-- the client draws next to each permission — `Override: true` stores the value, and +-- `Override: false` means "fall back to the default", which deletes the row. Reads always +-- serve `Override: true`. +-- +-- Read on one path only — `GET /photon_access_token`, where a stored entry overwrites the +-- matching default in the permission table the client applies when it spawns. That's why +-- this is its own table rather than a field on the subroom's `data` blob: that blob is +-- served to the client verbatim inside the room, and nothing client-facing reads these. +-- +-- `value` is the client's string kept verbatim: usually `True`/`False`, but a permission +-- whose UI isn't a True/False picker carries something else, and we don't interpret it. +-- +-- Generated from packages/domain/src/rooms-db.ts (SUBROOM_SCHEMA_DDL) — keep in sync. + +CREATE TABLE IF NOT EXISTS subroom_permission ( + sub_room_id INTEGER NOT NULL, + permission TEXT NOT NULL, + role INTEGER NOT NULL, + type INTEGER NOT NULL DEFAULT 0, + value TEXT NOT NULL, + PRIMARY KEY (sub_room_id, permission, role) + ); diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 3d82d4b..a29adea 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -479,6 +479,35 @@ export const SubRoomAccessibilityRequest = z.object({ ), }) +/** + * `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions` — the entries to change, keyed by + * (`Permission`, `Role`). Only the pairs sent are touched. `Override` is the client's + * checkbox: true stores the entry, false clears it back to the default. + */ +export const SubRoomPermissionsRequest = z + .array( + z.object({ + Permission: z + .string() + .describe('e.g. `CAN_SAVE_INVENTIONS`, `CAN_INVITE`, `CAN_USE_DELETE_ALL_BUTTON`'), + Role: z.int().describe('The role tier the entry applies to (0 = everyone, 30 = co-owner)'), + Override: z + .boolean() + .describe( + 'The override checkbox, and a JSON boolean unlike `Value`: true stores this entry, ' + + 'false DELETES any stored one so the pair falls back to its default' + ), + Type: z.int().describe('Always 0 in what the client sends; stored verbatim'), + Value: z + .string() + .describe( + 'A STRING, not a boolean — usually `True` / `False`, but kept verbatim: not every ' + + 'permission’s UI is a True/False picker. Ignored when `Override` is false' + ), + }) + ) + .describe('An array — the client sends one even when changing a single permission') + /** * `POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save` — promotes one save to live. * Any id from the subroom's history works, so this is both publish and restore. @@ -543,11 +572,13 @@ export const SubRoomSavesPage = z.object({ /** One entry of the permission table the client applies when it spawns into a room. */ export const RoomPermissionDto = z.object({ - Override: z.boolean(), + Override: z.boolean().describe('Always true on an entry that came from a subroom’s overrides'), Permission: z.string().describe('e.g. `CAN_USE_MAKER_PEN`, `CAN_SAVE_INVENTIONS`'), Role: z.int().describe('The role tier the permission applies to (0 = everyone)'), Type: z.int(), - Value: z.string().describe('Always `True` — a permission is present or absent'), + Value: z + .string() + .describe('A STRING, not a boolean — `True` on the defaults, anything on an override'), }) /** @@ -556,6 +587,11 @@ export const RoomPermissionDto = z.object({ * `PhotonAccessToken` is deliberately empty: the reference server signs it with a * secret/algorithm we don't have, and our Photon setup accepts an empty token. The * global (Role 0) maker pen is granted only to the hardcoded dev accounts. + * + * `Permissions` is the default table with the overrides stored on the subroom the caller + * is standing in merged over it (see + * `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions`): an override replaces the + * default with the same (`Permission`, `Role`), and one naming a new pair is appended. */ export const PhotonAccessTokenDto = z.object({ Permissions: z.array(RoomPermissionDto), diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 4d51b74..3225c1c 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -25,6 +25,7 @@ import { getRoomsByCreator, getRoomsByIds, getSimilarRooms, + getSubRoomPermissions, getSubRoomSaves, getVisitedRooms, modifySubRoom, @@ -37,6 +38,7 @@ import { setRoomImage, setRoomName, setRoomRole, + setSubRoomPermissions, toggleCheer, toggleFavorite, toggleRoomTag, @@ -81,6 +83,7 @@ import { stringQuery, SubRoomAccessibilityRequest, subRoomIdParam, + SubRoomPermissionsRequest, SubRoomSavesPage, TagRequest, UNAUTHORIZED_EMPTY, @@ -90,6 +93,7 @@ import { } from './openapi' import type { Context } from 'hono' +import type { RoomPermission } from '@repo/domain' import type { App } from './context' /** @@ -131,9 +135,14 @@ const DEFAULT_MAX_ROOMS_PER_ACCOUNT = 10 * hardcoded moderator/dev accounts. */ const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3]) -/** The slice of the shared presence row we read — the caller's current room instance. */ +/** + * The slice of the shared presence row we read — the caller's current room instance. + * `subRoomId` is what scopes the stored permission overrides: they belong to the subroom + * the player is standing in, not to the room. + */ interface PresenceView { roomInstanceId?: number + subRoomId?: number } /** @@ -143,16 +152,26 @@ interface PresenceView { * they aren't in one). `PhotonAccessToken` stays empty — the reference server * signs it via `ClientSecurity`, whose secret/algorithm we don't have; our * Photon setup accepts an empty token. + * + * `overrides` are the permissions the room's creator saved on the subroom the caller is + * in (see `PUT …/subrooms/{subRoomId}/permissions`). They are matched against the + * defaults by (`Permission`, `Role`) — the same pair the client addresses an entry by — + * and win, so a subroom that revokes the Role 0 maker pen revokes it for a dev account + * standing in it as well. */ -function photonAccessToken(accountId: number, roomInstanceId: number | null) { - const perm = (Permission: string, Role: number, Override: boolean) => ({ +function photonAccessToken( + accountId: number, + roomInstanceId: number | null, + overrides: RoomPermission[] = [] +) { + const perm = (Permission: string, Role: number, Override: boolean): RoomPermission => ({ Override, Permission, Role, Type: 0, Value: 'True', }) - const permissions = [ + const permissions: RoomPermission[] = [ perm('CAN_USE_ROOM_RESET_BUTTON', 0, true), perm('CAN_USE_DELETE_ALL_BUTTON', 0, true), perm('CAN_SAVE_INVENTIONS', 0, true), @@ -165,9 +184,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) { perm('CAN_SPAWN_INVENTIONS', 30, true), perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true), ] + if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) { permissions.unshift(perm('CAN_USE_MAKER_PEN', 0, true)) } + + // The subroom's stored table wins, applied LAST and over the dev grant too: a + // (Permission, Role) the table already carries is replaced in place — so the order + // doesn't shift under the client, and no pair is ever listed twice with two values — + // and one it doesn't (e.g. CAN_INVITE) is appended. + for (const override of overrides) { + const i = permissions.findIndex( + (p) => p.Permission === override.Permission && p.Role === override.Role + ) + if (i === -1) permissions.push(override) + else permissions[i] = override + } return { Permissions: permissions, PhotonAccessToken: '', @@ -176,16 +208,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) { } /** - * Photon access-token handler (served bare and under `/roomserver`). Auth-gated: - * resolves the caller, reads their current room instance from the shared - * `presence` table (see @repo/domain), and returns the permissions + token. + * Photon access-token handler. Auth-gated: resolves the caller, reads their current + * room instance from the shared `presence` table (see @repo/domain), and returns the + * permissions + token. */ async function handlePhotonAccessToken(c: Context) { const accountId = await authedAccountId(c) if (accountId === null) return unauthorized(c) - const presence = await getPresence(c.env.DB, accountId) - const roomInstanceId = presence?.roomInstance?.roomInstanceId ?? null - return c.json(photonAccessToken(accountId, roomInstanceId)) + const instance = (await getPresence(c.env.DB, accountId))?.roomInstance + // The permission overrides are the ones saved on the subroom the caller is standing in. + // A player in no instance — sitting in the lobby, or an instance predating subroom + // tracking — gets the default table untouched. + const overrides = + typeof instance?.subRoomId === 'number' + ? await getSubRoomPermissions(c.env.DB, instance.subRoomId) + : [] + return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, overrides)) } /** The Bearer token's account id (`sub`), or null when there's no valid token. */ @@ -214,6 +252,59 @@ function parseAccessibility(value: unknown): number | undefined { return named ? (named[1] as number) : undefined } +/** Parse an integer from the number or numeric string a JSON body may carry. */ +function parseInt10(value: unknown): number | undefined { + if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : undefined + if (typeof value !== 'string') return undefined + const n = Number.parseInt(value.trim(), 10) + return Number.isNaN(n) ? undefined : n +} + +/** + * The client's `Value`, kept as the STRING it sends. Usually `"True"`/`"False"` — the + * True/False picker beside the override checkbox — but a permission whose UI is something + * else carries a different value, so nothing here interprets it. A JSON boolean or number + * is rendered the way the client would have written it. + */ +function permissionValue(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'boolean') return value ? 'True' : 'False' + if (typeof value === 'number') return String(value) + return '' +} + +/** + * Parse the subroom-permissions PUT body: a JSON ARRAY of + * `{ Permission, Role, Override, Type, Value }` entries. + * + * `Override` is the client's checkbox, not data — see {@link setSubRoomPermissions}: true + * stores `Value` for that (`Permission`, `Role`), false clears any stored entry so the + * pair falls back to the default. It is carried through as sent. + * + * Entries without a permission name or a usable role are dropped rather than rejected — + * the client ignores the response either way, so half a table applied beats none. + */ +function parseRoomPermissions(body: unknown): RoomPermission[] { + if (!Array.isArray(body)) return [] + const permissions: RoomPermission[] = [] + for (const entry of body) { + if (typeof entry !== 'object' || entry === null) continue + const e = entry as Record + const permission = typeof e.Permission === 'string' ? e.Permission.trim() : '' + const role = parseInt10(e.Role) + if (permission === '' || role === undefined) continue + permissions.push({ + Permission: permission, + Role: role, + // Sent as a JSON boolean, unlike `Value` — accept the string form regardless. + Override: e.Override === true || String(e.Override).toLowerCase() === 'true', + Type: parseInt10(e.Type) ?? 0, + Value: permissionValue(e.Value), + }) + } + return permissions +} + /** The notifications hub is a single global DO instance (see the `notify` worker). */ const HUB_INSTANCE = 'global' @@ -1820,6 +1911,67 @@ const app = new Hono() } ) + // Set a subroom's permission overrides — what each role may do in that subroom. The + // body is a JSON ARRAY of the entries to change, keyed by (Permission, Role): `Override` + // is the client's checkbox, so true stores the entry for that pair and false clears it + // back to the default. The stored table then overwrites the matching defaults in + // `GET /photon_access_token`. Auth-gated (401) and creator-only (403), like the other + // subroom mutations. Answers an EMPTY 200 — the client fires this and re-reads nothing, + // so there is no envelope to match. + .put( + '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/permissions', + describeRoute({ + tags: ['Subrooms'], + summary: 'Set a subroom’s permissions', + description: [ + 'Stores the permission entries a room’s creator changed for one subroom — who may', + 'save inventions, invite players, use the delete-all button, and so on. The body is a', + 'JSON ARRAY; each entry is addressed by its (`Permission`, `Role`) pair, so re-sending', + 'a pair overwrites the stored entry rather than adding a second, and pairs that were', + 'never sent are left alone.', + '', + '`Override` is the checkbox the client draws beside each permission, not data:', + '`true` stores `Value` for that pair, and `false` means “fall back to the default”, so', + 'it DELETES any stored entry. Nothing is stored with `Override: false`, and reads', + 'always serve `true`. `Value` is a string — usually `True`/`False`, but it is kept', + 'verbatim, since not every permission’s UI is a True/False picker.', + '', + 'What this feeds is `GET /photon_access_token`: a stored entry replaces the default', + 'with the same (`Permission`, `Role`) in the table the client applies when it spawns,', + 'and one naming a pair the defaults don’t carry (e.g. `CAN_INVITE`) is added to it.', + 'The overrides apply to the subroom the caller is standing in, resolved from presence.', + '', + 'Creator-only — co-owners may build in a room but not decide what a role may do.', + 'The response body is EMPTY: the client doesn’t read one.', + ].join('\n'), + security: AUTHED, + parameters: [roomIdParam, subRoomIdParam], + requestBody: jsonBody(SubRoomPermissionsRequest, 'The permission entries to set'), + responses: { + 200: { description: 'Stored (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: { description: 'No such room or subroom' }, + }, + }), + async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10) + + // Scoped through the room so a subroom id from another room can't be written. + const room = await getRoomById(c.env.DB, roomId) + if (!room || !findSubRoom(room, subRoomId)) return c.notFound() + if (room.CreatorAccountId !== accountId) return c.body(null, 403) + + const permissions = parseRoomPermissions(await c.req.json().catch(() => null)) + await setSubRoomPermissions(c.env.DB, subRoomId, permissions) + return c.body(null, 200) + } + ) + // Clone a subroom into a new subroom of the same room (fresh SubRoomId, same // scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and // returns the updated ROOM in the `{ success, error, value }` envelope — NOT the new @@ -2041,8 +2193,7 @@ const app = new Hono() } ) - // Photon access token + room permissions the client needs to spawn into a - // room. The client calls it on the rooms host both bare and under `/roomserver`. + // Photon access token + room permissions the client needs to spawn into a room. .get( '/photon_access_token', describeRoute({ @@ -2065,23 +2216,6 @@ const app = new Hono() }), handlePhotonAccessToken ) - .get( - '/roomserver/photon_access_token', - describeRoute({ - tags: ['Session'], - summary: 'Photon token + room permissions (legacy path)', - description: [ - 'Identical to `GET /photon_access_token` — the client calls it both bare and under the', - '`/roomserver` prefix, so both forms are registered.', - ].join(' '), - security: AUTHED, - responses: { - 200: json(PhotonAccessTokenDto, 'The permissions and (empty) token'), - 401: UNAUTHORIZED_RESPONSE, - }, - }), - handlePhotonAccessToken - ) // The generated spec. Documentation only — no request is validated against it (see // openapi.ts). `hide: true` keeps this route out of its own output. diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 7da49c6..f9a3fd5 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -1433,13 +1433,10 @@ describe('rooms endpoints', () => { }) it('GET /photon_access_token 401s without a token', async () => { - for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) { - const res = await SELF.fetch(`${ORIGIN}${path}`) - expect(res.status).toBe(401) - } + expect((await SELF.fetch(`${ORIGIN}/photon_access_token`)).status).toBe(401) }) - it('GET /photon_access_token (bare + /roomserver) returns permissions + presence instance', async () => { + it('GET /photon_access_token returns permissions + presence instance', async () => { // Seed the caller's presence so RoomInstanceId reflects their current instance. await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') .bind( @@ -1450,22 +1447,19 @@ describe('rooms endpoints', () => { }) ) .run() - const headers = await bearer('777') - for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) { - const res = await SELF.fetch(`${ORIGIN}${path}`, { headers }) - expect(res.status).toBe(200) - const body = (await res.json()) as { - Permissions: Array<{ Permission: string; Role: number }> - PhotonAccessToken: string - RoomInstanceId: number | null - } - expect(body.Permissions.length).toBe(11) - expect(body.RoomInstanceId).toBe(1000042) - // A non-dev account does NOT get the global (Role 0) maker pen. - expect( - body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0) - ).toBe(false) + const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer('777') }) + expect(res.status).toBe(200) + const body = (await res.json()) as { + Permissions: Array<{ Permission: string; Role: number }> + PhotonAccessToken: string + RoomInstanceId: number | null } + expect(body.Permissions.length).toBe(11) + expect(body.RoomInstanceId).toBe(1000042) + // A non-dev account does NOT get the global (Role 0) maker pen. + expect(body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toBe( + false + ) }) it('GET /photon_access_token returns null RoomInstanceId when the caller has no presence', async () => { @@ -1676,6 +1670,254 @@ describe('rooms endpoints', () => { expect(await accessibilityOf()).toBe(1) }) + // The permission table a room's creator saves on a subroom, and how it reaches the + // client: `PUT …/permissions` stores entries keyed by (Permission, Role), and + // `GET /photon_access_token` merges them over its defaults for whoever is standing in + // that subroom. Room 2 / subroom 2 is owned by account 1; account 743 is the visitor + // whose presence points at it. + describe('subroom permissions', () => { + type Permission = { Permission: string; Role: number; Override: boolean; Value: string } + + const putPermissions = async (path: string, body: unknown, sub?: string) => + SELF.fetch(`${ORIGIN}${path}`, { + method: 'PUT', + headers: { ...(sub ? await bearer(sub) : {}), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + // Put a player in an instance of the given subroom, then read the permission table + // the client would apply when it spawns there. + const permissionsIn = async (accountId: number, subRoomId: number): Promise => { + await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId, + roomInstance: { roomInstanceId: 1000900 + subRoomId, roomId: 2, subRoomId }, + expiresAt: Math.floor(Date.now() / 1000) + 900, + }) + ) + .run() + const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { + headers: await bearer(String(accountId)), + }) + expect(res.status).toBe(200) + return ((await res.json()) as { Permissions: Permission[] }).Permissions + } + + const entry = (list: Permission[], permission: string, role: number) => + list.find((p) => p.Permission === permission && p.Role === role) + + it('is auth-gated and creator-only', async () => { + const body = [ + { Permission: 'CAN_SAVE_INVENTIONS', Role: 30, Override: false, Type: 0, Value: 'True' }, + ] + // No token → 401. + expect((await putPermissions('/rooms/2/subrooms/2/permissions', body)).status).toBe(401) + // A valid token that isn't the room's creator → 403. + expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '999')).status).toBe( + 403 + ) + // Not even a co-owner: account 2 holds Role 30 on the seeded rooms. Co-owners may + // build in a room but don't decide what a role may do. + expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '2')).status).toBe(403) + // Unknown room / unknown subroom → 404. + expect((await putPermissions('/rooms/99999/subrooms/2/permissions', body, '1')).status).toBe( + 404 + ) + // A subroom id belonging to another room doesn't resolve either. + expect((await putPermissions('/rooms/2/subrooms/9999/permissions', body, '1')).status).toBe( + 404 + ) + }) + + it('answers an empty 200 — the client reads no body', async () => { + const res = await putPermissions( + '/rooms/2/subrooms/2/permissions', + [{ Permission: 'CAN_SPAWN_INVENTIONS', Role: 30, Override: true, Type: 0, Value: 'True' }], + '1' + ) + expect(res.status).toBe(200) + expect(await res.text()).toBe('') + }) + + it('a checked Override replaces the matching default in place', async () => { + const before = await permissionsIn(743, 2) + expect(before.length).toBe(11) + const at = before.findIndex((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 30) + // The default for this pair is an un-overridden grant. + expect(before[at]).toMatchObject({ Override: false, Value: 'True' }) + + expect( + ( + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [ + { + Permission: 'CAN_USE_MAKER_PEN', + Role: 30, + Override: true, + Type: 0, + Value: 'False', + }, + ], + '1' + ) + ).status + ).toBe(200) + + const after = await permissionsIn(743, 2) + // Replaced, not appended — and at the same index, so the table doesn't reshuffle. + expect(after.length).toBe(11) + expect(after[at]).toMatchObject({ + Permission: 'CAN_USE_MAKER_PEN', + Role: 30, + Override: true, + Value: 'False', + }) + + // Re-sending the same (Permission, Role) updates that entry rather than adding one. + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [{ Permission: 'CAN_USE_MAKER_PEN', Role: 30, Override: true, Type: 0, Value: 'True' }], + '1' + ) + const changed = await permissionsIn(743, 2) + expect(changed.length).toBe(11) + expect(changed[at]).toMatchObject({ Override: true, Value: 'True' }) + }) + + it('an unchecked Override erases the entry, back to the default', async () => { + const stored = async () => + (await env.DB.prepare( + `SELECT COUNT(*) AS n FROM subroom_permission + WHERE sub_room_id = 2 AND permission = 'CAN_USE_MAKER_PEN' AND role = 30` + ).first<{ n: number }>())!.n + + // The previous test left this pair overridden. + expect(await stored()).toBe(1) + + // `Override: false` means "fall back to the default" — the `Value` riding along is + // not stored, it's whatever the picker happened to show. + expect( + ( + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [ + { + Permission: 'CAN_USE_MAKER_PEN', + Role: 30, + Override: false, + Type: 0, + Value: 'True', + }, + ], + '1' + ) + ).status + ).toBe(200) + + // The row is gone, and the token serves the default for the pair again. + expect(await stored()).toBe(0) + const table = await permissionsIn(743, 2) + expect(table.length).toBe(11) + expect(entry(table, 'CAN_USE_MAKER_PEN', 30)).toMatchObject({ + Override: false, + Value: 'True', + }) + + // Clearing a pair that was never overridden is a no-op, not an insert. + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [{ Permission: 'CAN_INVITE', Role: 0, Override: false, Type: 0, Value: 'True' }], + '1' + ) + expect((await permissionsIn(743, 2)).length).toBe(11) + }) + + it('appends a permission the defaults do not carry, and scopes it to its subroom', async () => { + // CAN_INVITE is in none of the defaults, so it lands as a new entry. + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [{ Permission: 'CAN_INVITE', Role: 30, Override: true, Type: 0, Value: 'False' }], + '1' + ) + const inSubRoom2 = await permissionsIn(744, 2) + expect(inSubRoom2.length).toBe(12) + expect(entry(inSubRoom2, 'CAN_INVITE', 30)).toMatchObject({ + Override: true, + Value: 'False', + }) + + // A different subroom is untouched — the table is per-subroom, not per-room. + expect((await permissionsIn(744, 3)).length).toBe(11) + // And so is a player in no instance at all. + await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(744).run() + const lobby = await SELF.fetch(`${ORIGIN}/photon_access_token`, { + headers: await bearer('744'), + }) + expect(((await lobby.json()) as { Permissions: Permission[] }).Permissions.length).toBe(11) + }) + + it('keeps a Value that isn’t True/False verbatim', async () => { + // Not every permission's UI is the True/False picker, so nothing interprets the + // string — it goes to the client exactly as the creator set it. + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [{ Permission: 'MAX_SPAWNED_INVENTIONS', Role: 0, Override: true, Type: 0, Value: '25' }], + '1' + ) + expect(entry(await permissionsIn(747, 2), 'MAX_SPAWNED_INVENTIONS', 0)).toMatchObject({ + Override: true, + Value: '25', + }) + }) + + it('applies over the dev accounts’ global maker pen, without listing a pair twice', async () => { + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [ + { Permission: 'CAN_USE_MAKER_PEN', Role: 0, Override: true, Type: 0, Value: 'False' }, + // The third sample body — a Role 0 grant the defaults already carry. + { + Permission: 'CAN_USE_DELETE_ALL_BUTTON', + Role: 0, + Override: true, + Type: 0, + Value: 'True', + }, + ], + '1' + ) + // Account 3 is one of the hardcoded dev accounts, so it gets the global (Role 0) + // maker pen prepended — which this subroom then revokes. The merge runs last and + // replaces it in place, so the pair appears exactly ONCE: a table listing it twice + // with two values would leave which one applies up to the client. + const devTable = await permissionsIn(3, 2) + expect(devTable.filter((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toEqual([ + { Override: true, Permission: 'CAN_USE_MAKER_PEN', Role: 0, Type: 0, Value: 'False' }, + ]) + expect(entry(devTable, 'CAN_USE_DELETE_ALL_BUTTON', 0)).toMatchObject({ Value: 'True' }) + + // A normal player in the same subroom sees the same revocation. + expect(entry(await permissionsIn(745, 2), 'CAN_USE_MAKER_PEN', 0)).toMatchObject({ + Value: 'False', + }) + }) + + it('a cloned subroom inherits the source’s permission table', async () => { + const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, { + method: 'POST', + headers: await bearer('1'), + }) + const room = (await res.json()) as { value: { SubRooms: Array<{ SubRoomId: number }> } } + const cloneId = Math.max(...room.value.SubRooms.map((s) => s.SubRoomId)) + + const inClone = await permissionsIn(746, cloneId) + expect(entry(inClone, 'CAN_INVITE', 30)).toMatchObject({ Value: 'False' }) + expect(entry(inClone, 'CAN_USE_MAKER_PEN', 0)).toMatchObject({ Value: 'False' }) + }) + }) + it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => { const clone = async (roomId: number, subRoomId: number, sub?: string) => SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, { @@ -1944,7 +2186,6 @@ describe('rooms endpoints', () => { 'GET /rooms/{roomId}/playerdata/me', 'GET /rooms/{roomId}/similar', 'GET /rooms/{roomId}/subrooms/{subRoomId}/saves', - 'GET /roomserver/photon_access_token', 'GET /roomserver/rooms/createdby/me', 'POST /rooms/{roomId}/clone', 'POST /rooms/{roomId}/subrooms', @@ -1963,6 +2204,7 @@ describe('rooms endpoints', () => { 'PUT /rooms/{roomId}/roles/{accountId}', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify', + 'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions', 'PUT /rooms/{roomId}/tags', 'PUT /rooms/{roomId}/warning', ])