diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index e856c8a..7005051 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -180,7 +180,7 @@ import { } from './openapi' import type { Context } from 'hono' -import type { RoomBan, RoomBanRecord, RoomPermission } from '@repo/domain' +import type { Room, RoomBan, RoomBanRecord, RoomPermission } from '@repo/domain' import type { MessageReceivedPayload } from '../../notify/src/notification-payloads' import type { App, Env } from './context' @@ -257,59 +257,100 @@ interface PresenceView { } /** - * Room permissions + Photon token the client needs to spawn into a room. The - * global (Role 0) maker pen is added only for the hardcoded dev accounts, and - * `RoomInstanceId` is the caller's current instance from presence (null when - * 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. + * The permissions the token table carries for every role, in the order the client has been + * seen receiving them. A room's creator may override any of them per subroom and per role + * (`PUT …/subrooms/{subRoomId}/permissions`); the table may also carry pairs the creator + * added that aren't listed here (`CAN_INVITE`, `MAX_SPAWNED_INVENTIONS`). + */ +const BUILD_PERMISSIONS = [ + 'CAN_USE_MAKER_PEN', + 'CAN_USE_ROOM_RESET_BUTTON', + 'CAN_USE_DELETE_ALL_BUTTON', + 'CAN_SAVE_INVENTIONS', + 'CAN_SPAWN_INVENTIONS', + 'CAN_USE_PLAY_GIZMOS_TOGGLE', +] as const + +/** + * Whether a role may build by default — the value every permission above takes for it when + * the creator has stored nothing. Only the room's MANAGERS (co-owner and up) may: a visitor, + * a host or a moderator in a room whose creator never opened the permissions screen must not + * be able to reset the room, delete everything in it or spawn into it. Serving `True` at + * Role 0 to everyone (as this once did) handed every visitor the delete-all button. + */ +const buildsByDefault = (role: number): boolean => role >= Role.CoOwner + +/** + * The caller's role in a room: Creator for its `CreatorAccountId`, otherwise the highest + * tier `Roles` names them at, otherwise None — a visitor. + */ +function roleIn(room: Room, accountId: number): number { + if (room.CreatorAccountId === accountId) return Role.Creator + return roomRoles(room) + .filter((r) => r.AccountId === accountId) + .reduce((best, r) => Math.max(best, r.Role), Role.None as number) +} + +/** + * Room permissions + Photon token the client needs to spawn into a room. `RoomInstanceId` + * is the caller's current instance from presence (null when 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. + * The table is the CALLER'S: it is built for the role they hold in the room they are + * standing in (`role`), from the subroom's stored overrides (`overrides`, see + * `PUT …/subrooms/{subRoomId}/permissions`) over the role's defaults. A permission's value + * for the caller is the override stored at their own role, else the one stored at the + * highest lower role (a grant to "everyone" reaches a host too), else the default — managers + * may build, nobody else may. Pairs the creator stored under a role the caller doesn't hold + * are not theirs and are left out, so a visitor never sees — or gets — a co-owner's grants. + * + * Every value is served at Role 0 AND, for a role holder, again at their role, and every row + * says `Override: true`: the client has been seen applying Role 0 rows to a visitor, and + * which rows it reads for a role holder is not known, so both readings land on the same + * values. The global maker pen stays with the hardcoded dev accounts, whatever the room says. */ function photonAccessToken( accountId: number, roomInstanceId: number | null, + role: number = Role.None, overrides: RoomPermission[] = [] ) { - const perm = (Permission: string, Role: number, Override: boolean): RoomPermission => ({ - Override, + const perm = (Permission: string, Role: number, Value: string): RoomPermission => ({ + Override: true, Permission, Role, Type: 0, - Value: 'True', + Value, }) - 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), - perm('CAN_SPAWN_INVENTIONS', 0, true), - perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 0, true), - perm('CAN_USE_MAKER_PEN', 30, false), - perm('CAN_USE_ROOM_RESET_BUTTON', 30, true), - perm('CAN_USE_DELETE_ALL_BUTTON', 30, true), - perm('CAN_SAVE_INVENTIONS', 30, true), - 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 override that reaches the caller for a permission: their own role's, else the + // highest role below theirs that has one. + const reaching = (permission: string): RoomPermission | undefined => + overrides + .filter((o) => o.Permission === permission && o.Role <= role) + .reduce( + (best, o) => (best && best.Role > o.Role ? best : o), + undefined + ) + + const names = [...BUILD_PERMISSIONS, ...overrides.map((o) => o.Permission)].filter( + (name, i, all) => all.indexOf(name) === i + ) + const values = new Map() + for (const name of names) { + const stored = reaching(name) + if (stored !== undefined) values.set(name, stored.Value) + else if (BUILD_PERMISSIONS.includes(name as (typeof BUILD_PERMISSIONS)[number])) { + values.set(name, buildsByDefault(role) ? 'True' : 'False') + } + // A creator-added pair stored only under a higher role than the caller's is not theirs. } + if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) values.set('CAN_USE_MAKER_PEN', '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 + const permissions: RoomPermission[] = [] + for (const roleRow of role === Role.None ? [Role.None] : [Role.None, role]) { + for (const [name, value] of values) permissions.push(perm(name, roleRow, value)) } return { Permissions: permissions, @@ -319,22 +360,24 @@ function photonAccessToken( } /** - * 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. + * Photon access-token handler. Auth-gated: resolves the caller, reads their current room + * instance from the shared `presence` table (see @repo/domain), works out their role in that + * room and the overrides stored on the subroom they are standing in, and returns the + * permissions + token. A player in no instance — sitting in the lobby, or an instance + * predating subroom tracking — is a visitor with the defaults. */ async function handlePhotonAccessToken(c: Context) { const accountId = await authedAccountId(c) if (accountId === null) return unauthorized(c) 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 room = + typeof instance?.roomId === 'number' ? await getRoomById(c.env.DB, instance.roomId) : null + const role = room ? roleIn(room, accountId) : Role.None const overrides = typeof instance?.subRoomId === 'number' ? await getSubRoomPermissions(c.env.DB, instance.subRoomId) : [] - return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, overrides)) + return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, role, overrides)) } /** @@ -4041,10 +4084,11 @@ const app = new Hono() '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.', + 'What this feeds is `GET /photon_access_token`, which builds each player’s table from', + 'the entries that reach THEIR role in the room: an entry at their role, else one at a', + 'lower role (a Role 0 grant reaches everyone), over the role’s defaults. One naming a', + 'pair the defaults don’t carry (e.g. `CAN_INVITE`) is served too. 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.', @@ -4575,9 +4619,17 @@ const app = new Hono() '`RoomInstanceId` is the caller’s current instance, read from the shared `presence`', 'table (null when they’re in none).', '', + 'The table is built for the CALLER: their role in the room they are standing in', + '(Creator, co-owner, moderator, host, or a visitor) decides each permission’s value —', + 'the entry the room’s creator stored on that subroom at their role', + '(`PUT …/subrooms/{subRoomId}/permissions`), else one stored at a lower role, else the', + 'default: managers (co-owner and up) may build, nobody else may. Entries stored under', + 'a role the caller does not hold are left out. Every value is served at Role 0 and,', + 'for a role holder, again at their role, all with `Override: true`.', + '', '`PhotonAccessToken` is always 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.', + 'global maker pen is granted only to the hardcoded dev accounts, whatever the table says.', ].join('\n'), security: AUTHED, responses: { diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index c8ad8a8..22c9ebe 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -4394,13 +4394,13 @@ describe('rooms endpoints', () => { expect((await SELF.fetch(`${ORIGIN}/photon_access_token`)).status).toBe(401) }) - it('GET /photon_access_token returns permissions + presence instance', async () => { + it('GET /photon_access_token gives a VISITOR no build permissions, with their 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( JSON.stringify({ accountId: 777, - roomInstance: { roomInstanceId: 1000042 }, + roomInstance: { roomInstanceId: 1000042, roomId: 2, subRoomId: 2 }, expiresAt: Math.floor(Date.now() / 1000) + 900, }) ) @@ -4408,38 +4408,91 @@ describe('rooms endpoints', () => { 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 }> + Permissions: Array<{ Permission: string; Role: number; Override: boolean; Value: string }> 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 - ) + expect(body.PhotonAccessToken).toBe('') + // 777 holds no role in room 2: one Role 0 row per permission, every one of them False. + // Serving True here handed every visitor the delete-all button. + expect(body.Permissions.map((p) => p.Permission)).toEqual([ + 'CAN_USE_MAKER_PEN', + 'CAN_USE_ROOM_RESET_BUTTON', + 'CAN_USE_DELETE_ALL_BUTTON', + 'CAN_SAVE_INVENTIONS', + 'CAN_SPAWN_INVENTIONS', + 'CAN_USE_PLAY_GIZMOS_TOGGLE', + ]) + for (const p of body.Permissions) { + expect(p).toMatchObject({ Role: 0, Override: true, Type: 0, Value: 'False' }) + } }) it('GET /photon_access_token returns null RoomInstanceId when the caller has no presence', async () => { const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer('888') }) expect(res.status).toBe(200) - expect(((await res.json()) as { RoomInstanceId: number | null }).RoomInstanceId).toBeNull() + const body = (await res.json()) as { + RoomInstanceId: number | null + Permissions: Array<{ Value: string }> + } + expect(body.RoomInstanceId).toBeNull() + // In no room, they are nobody's manager. + expect(body.Permissions.every((p) => p.Value === 'False')).toBe(true) }) - it('GET /photon_access_token grants the global maker pen to dev accounts (1/2/3)', async () => { + it('GET /photon_access_token gives the room’s MANAGERS every build permission', async () => { + // Room 2 is account 1's, with account 2 a co-owner (Role 30). + const tableFor = async (accountId: number) => { + await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId, + roomInstance: { roomInstanceId: 1000043, roomId: 2, subRoomId: 2 }, + expiresAt: Math.floor(Date.now() / 1000) + 900, + }) + ) + .run() + const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { + headers: await bearer(String(accountId)), + }) + return ((await res.json()) as { Permissions: Array<{ Role: number; Value: string }> }) + .Permissions + } + // The creator's values are served at Role 0 and again at Role 255, all True; the + // co-owner's at 0 and 30. Both readings of the table land on the same values. + const creator = await tableFor(1) + expect(creator.length).toBe(12) + expect(creator.map((p) => p.Role)).toEqual([ + ...Array(6).fill(0), + ...Array(6).fill(255), + ]) + expect(creator.every((p) => p.Value === 'True')).toBe(true) + const coOwner = await tableFor(2) + expect(coOwner.map((p) => p.Role)).toEqual([ + ...Array(6).fill(0), + ...Array(6).fill(30), + ]) + expect(coOwner.every((p) => p.Value === 'True')).toBe(true) + await env.DB.prepare('DELETE FROM presence WHERE account_id IN (1, 2)').run() + }) + + it('GET /photon_access_token grants the global maker pen to dev accounts (1/2/3), nothing else', async () => { for (const sub of ['1', '2', '3']) { + // Out of any room: a visitor with the dev maker pen. const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer(sub) }) expect(res.status).toBe(200) const body = (await res.json()) as { - Permissions: Array<{ Permission: string; Role: number; Override: boolean }> + Permissions: Array<{ Permission: string; Role: number; Override: boolean; Value: string }> } - // The global maker pen is prepended → first entry, Role 0, Override true. - expect(body.Permissions[0]).toMatchObject({ + expect(body.Permissions[0]).toEqual({ + Override: true, Permission: 'CAN_USE_MAKER_PEN', Role: 0, - Override: true, + Type: 0, + Value: 'True', }) - expect(body.Permissions.length).toBe(12) + expect(body.Permissions.slice(1).every((p) => p.Value === 'False')).toBe(true) } }) @@ -4709,9 +4762,10 @@ describe('rooms endpoints', () => { // 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. + // `GET /photon_access_token` builds the table for whoever is standing in that subroom, + // from the entries that reach THEIR role over the role's defaults. Room 2 / subroom 2 is + // owned by account 1, with account 2 a co-owner; 743–747 are visitors whose presence + // points at it. describe('subroom permissions', () => { type Permission = { Permission: string; Role: number; Override: boolean; Value: string } @@ -4741,8 +4795,28 @@ describe('rooms endpoints', () => { 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) + /** The value a table serves for a permission (every row of a table agrees). */ + const valueOf = (list: Permission[], permission: string) => { + const values = new Set(list.filter((p) => p.Permission === permission).map((p) => p.Value)) + expect(values.size).toBeLessThanOrEqual(1) + return [...values][0] + } + // Room 2's seeded co-owner is account 2 — one of the hardcoded dev accounts, whose + // global maker pen no table takes away — so the role-holder cases use 748 (a Host) + // and 749 (a co-owner), given their roles here. + const setRoles = async () => { + await env.DB.prepare( + `UPDATE room SET data = json_set(data, '$.Roles', json(?1)) WHERE room_id = 2` + ) + .bind( + JSON.stringify([ + { AccountId: 2, Role: 30 }, + { AccountId: 748, Role: 10 }, + { AccountId: 749, Role: 30 }, + ]) + ) + .run() + } it('is auth-gated and creator-only', async () => { const body = [ @@ -4777,50 +4851,91 @@ describe('rooms endpoints', () => { 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' }) + it('a Role 0 grant reaches a visitor; a Role 30 grant does not', async () => { + // Nothing stored for everyone: the visitor may do nothing. + expect(valueOf(await permissionsIn(743, 2), 'CAN_SPAWN_INVENTIONS')).toBe('False') + // The creator lets everyone spawn inventions in this subroom. expect( ( await putPermissions( '/rooms/2/subrooms/2/permissions', [ { - Permission: 'CAN_USE_MAKER_PEN', - Role: 30, + Permission: 'CAN_SPAWN_INVENTIONS', + Role: 0, Override: true, Type: 0, - Value: 'False', + Value: 'True', }, ], '1' ) ).status ).toBe(200) + const granted = await permissionsIn(743, 2) + expect(valueOf(granted, 'CAN_SPAWN_INVENTIONS')).toBe('True') + // Only that one: the rest of the visitor's table is still False, and one row each. + expect(valueOf(granted, 'CAN_USE_DELETE_ALL_BUTTON')).toBe('False') + expect(granted.length).toBe(6) - 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. + // A grant stored for co-owners is not the visitor's — and nor is it listed for them. await putPermissions( '/rooms/2/subrooms/2/permissions', - [{ Permission: 'CAN_USE_MAKER_PEN', Role: 30, Override: true, Type: 0, Value: 'True' }], + [{ Permission: 'CAN_INVITE', 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' }) + const after = await permissionsIn(743, 2) + expect(after.find((p) => p.Permission === 'CAN_INVITE')).toBeUndefined() + expect(after.length).toBe(6) + // The co-owner gets it, at Role 0 and at 30, beside their default grants. + await setRoles() + const coOwner = await permissionsIn(749, 2) + expect(valueOf(coOwner, 'CAN_INVITE')).toBe('True') + expect(coOwner.filter((p) => p.Permission === 'CAN_INVITE').map((p) => p.Role)).toEqual([ + 0, 30, + ]) + }) + + it('a stored entry at the caller’s role beats one below it, and revokes a default', async () => { + // Take the maker pen away from co-owners, while everyone may spawn inventions. + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [{ Permission: 'CAN_USE_MAKER_PEN', Role: 30, Override: true, Type: 0, Value: 'False' }], + '1' + ) + const coOwner = await permissionsIn(749, 2) + expect(valueOf(coOwner, 'CAN_USE_MAKER_PEN')).toBe('False') + expect(valueOf(coOwner, 'CAN_SPAWN_INVENTIONS')).toBe('True') + // The creator is above Role 30: that entry doesn't reach them. + expect(valueOf(await permissionsIn(1, 2), 'CAN_USE_MAKER_PEN')).toBe('True') + + // A Host (Role 10) sits between: the Role 0 grant reaches them, the Role 30 + // revocation does not, and their own default is no maker pen either way. + const host = await permissionsIn(748, 2) + expect(host.map((p) => p.Role).filter((r) => r === 10).length).toBe(6) + expect(valueOf(host, 'CAN_SPAWN_INVENTIONS')).toBe('True') + expect(valueOf(host, 'CAN_USE_MAKER_PEN')).toBe('False') + expect(valueOf(host, 'CAN_USE_DELETE_ALL_BUTTON')).toBe('False') + // Now the creator opens the delete-all button to hosts and up. + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [ + { + Permission: 'CAN_USE_DELETE_ALL_BUTTON', + Role: 10, + Override: true, + Type: 0, + Value: 'True', + }, + ], + '1' + ) + expect(valueOf(await permissionsIn(748, 2), 'CAN_USE_DELETE_ALL_BUTTON')).toBe('True') + // Which reaches a co-owner (above 10) but not a visitor (below it). + expect(valueOf(await permissionsIn(749, 2), 'CAN_USE_DELETE_ALL_BUTTON')).toBe('True') + expect(valueOf(await permissionsIn(743, 2), 'CAN_USE_DELETE_ALL_BUTTON')).toBe('False') + await env.DB.prepare('DELETE FROM presence WHERE account_id = 1').run() }) it('an unchecked Override erases the entry, back to the default', async () => { @@ -4845,7 +4960,7 @@ describe('rooms endpoints', () => { Role: 30, Override: false, Type: 0, - Value: 'True', + Value: 'False', }, ], '1' @@ -4853,14 +4968,9 @@ describe('rooms endpoints', () => { ).status ).toBe(200) - // The row is gone, and the token serves the default for the pair again. + // The row is gone, and the co-owner has their default maker pen 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', - }) + expect(valueOf(await permissionsIn(749, 2), 'CAN_USE_MAKER_PEN')).toBe('True') // Clearing a pair that was never overridden is a no-op, not an insert. await putPermissions( @@ -4868,31 +4978,21 @@ describe('rooms endpoints', () => { [{ Permission: 'CAN_INVITE', Role: 0, Override: false, Type: 0, Value: 'True' }], '1' ) - expect((await permissionsIn(743, 2)).length).toBe(11) + expect((await permissionsIn(743, 2)).length).toBe(6) }) - 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. + it('scopes the table to its subroom', async () => { + // Subroom 2 lets everyone spawn inventions (set above); subroom 3 does not. + expect(valueOf(await permissionsIn(744, 2), 'CAN_SPAWN_INVENTIONS')).toBe('True') + expect(valueOf(await permissionsIn(744, 3), 'CAN_SPAWN_INVENTIONS')).toBe('False') + // And a player in no instance at all is a visitor with the defaults. 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) + const table = ((await lobby.json()) as { Permissions: Permission[] }).Permissions + expect(table.length).toBe(6) + expect(table.every((p) => p.Value === 'False')).toBe(true) }) it('keeps a Value that isn’t True/False verbatim', async () => { @@ -4903,42 +5003,23 @@ describe('rooms endpoints', () => { [{ 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', - }) + expect(valueOf(await permissionsIn(747, 2), 'MAX_SPAWNED_INVENTIONS')).toBe('25') }) - it('applies over the dev accounts’ global maker pen, without listing a pair twice', async () => { + it('applies over the dev accounts’ global maker pen', async () => { + // Account 3 is one of the hardcoded dev accounts, so it holds the global maker pen + // in every room — the one grant a subroom's table doesn't take away. 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', - }, - ], + [{ Permission: 'CAN_USE_MAKER_PEN', Role: 0, Override: true, Type: 0, Value: 'False' }], '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(devTable.filter((p) => p.Permission === 'CAN_USE_MAKER_PEN')).toEqual([ + { Override: true, Permission: 'CAN_USE_MAKER_PEN', Role: 0, Type: 0, Value: 'True' }, ]) - 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', - }) + // A normal player in the same subroom has none, as before. + expect(valueOf(await permissionsIn(745, 2), 'CAN_USE_MAKER_PEN')).toBe('False') }) it('a cloned subroom inherits the source’s permission table', async () => { @@ -4950,8 +5031,8 @@ describe('rooms endpoints', () => { 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' }) + expect(valueOf(inClone, 'CAN_SPAWN_INVENTIONS')).toBe('True') + expect(valueOf(inClone, 'MAX_SPAWNED_INVENTIONS')).toBe('25') }) })