[rooms] fix photon access

This commit is contained in:
Devin Zuczek
2026-10-06 16:42:38 -04:00
parent 525e6ee24e
commit e1391b59b3
2 changed files with 286 additions and 153 deletions
+105 -53
View File
@@ -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 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
// 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<RoomPermission | undefined>(
(best, o) => (best && best.Role > o.Role ? best : o),
undefined
)
if (i === -1) permissions.push(override)
else permissions[i] = override
const names = [...BUILD_PERMISSIONS, ...overrides.map((o) => o.Permission)].filter(
(name, i, all) => all.indexOf(name) === i
)
const values = new Map<string, string>()
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')
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<App>) {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const instance = (await getPresence<PresenceView>(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<App>()
'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<App>()
'`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: {
+182 -101
View File
@@ -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<number>(6).fill(0),
...Array<number>(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<number>(6).fill(0),
...Array<number>(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')
})
})