[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
+104 -52
View File
@@ -180,7 +180,7 @@ import {
} from './openapi' } from './openapi'
import type { Context } from 'hono' 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 { MessageReceivedPayload } from '../../notify/src/notification-payloads'
import type { App, Env } from './context' 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 * The permissions the token table carries for every role, in the order the client has been
* global (Role 0) maker pen is added only for the hardcoded dev accounts, and * seen receiving them. A room's creator may override any of them per subroom and per role
* `RoomInstanceId` is the caller's current instance from presence (null when * (`PUT …/subrooms/{subRoomId}/permissions`); the table may also carry pairs the creator
* they aren't in one). `PhotonAccessToken` stays empty — the reference server * added that aren't listed here (`CAN_INVITE`, `MAX_SPAWNED_INVENTIONS`).
* signs it via `ClientSecurity`, whose secret/algorithm we don't have; our */
* Photon setup accepts an empty token. 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 * The table is the CALLER'S: it is built for the role they hold in the room they are
* in (see `PUT …/subrooms/{subRoomId}/permissions`). They are matched against the * standing in (`role`), from the subroom's stored overrides (`overrides`, see
* defaults by (`Permission`, `Role`) — the same pair the client addresses an entry by — * `PUT …/subrooms/{subRoomId}/permissions`) over the role's defaults. A permission's value
* and win, so a subroom that revokes the Role 0 maker pen revokes it for a dev account * for the caller is the override stored at their own role, else the one stored at the
* standing in it as well. * 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( function photonAccessToken(
accountId: number, accountId: number,
roomInstanceId: number | null, roomInstanceId: number | null,
role: number = Role.None,
overrides: RoomPermission[] = [] overrides: RoomPermission[] = []
) { ) {
const perm = (Permission: string, Role: number, Override: boolean): RoomPermission => ({ const perm = (Permission: string, Role: number, Value: string): RoomPermission => ({
Override, Override: true,
Permission, Permission,
Role, Role,
Type: 0, 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)) { // The override that reaches the caller for a permission: their own role's, else the
permissions.unshift(perm('CAN_USE_MAKER_PEN', 0, true)) // 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
)
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')
// The subroom's stored table wins, applied LAST and over the dev grant too: a const permissions: RoomPermission[] = []
// (Permission, Role) the table already carries is replaced in place — so the order for (const roleRow of role === Role.None ? [Role.None] : [Role.None, role]) {
// doesn't shift under the client, and no pair is ever listed twice with two values — for (const [name, value] of values) permissions.push(perm(name, roleRow, value))
// 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 { return {
Permissions: permissions, Permissions: permissions,
@@ -319,22 +360,24 @@ function photonAccessToken(
} }
/** /**
* Photon access-token handler. Auth-gated: resolves the caller, reads their current * Photon access-token handler. Auth-gated: resolves the caller, reads their current room
* room instance from the shared `presence` table (see @repo/domain), and returns the * instance from the shared `presence` table (see @repo/domain), works out their role in that
* permissions + token. * 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>) { async function handlePhotonAccessToken(c: Context<App>) {
const accountId = await authedAccountId(c) const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c) if (accountId === null) return unauthorized(c)
const instance = (await getPresence<PresenceView>(c.env.DB, accountId))?.roomInstance 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. const room =
// A player in no instance — sitting in the lobby, or an instance predating subroom typeof instance?.roomId === 'number' ? await getRoomById(c.env.DB, instance.roomId) : null
// tracking — gets the default table untouched. const role = room ? roleIn(room, accountId) : Role.None
const overrides = const overrides =
typeof instance?.subRoomId === 'number' typeof instance?.subRoomId === 'number'
? await getSubRoomPermissions(c.env.DB, instance.subRoomId) ? 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', '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.', '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', 'What this feeds is `GET /photon_access_token`, which builds each player’s table from',
'with the same (`Permission`, `Role`) in the table the client applies when it spawns,', 'the entries that reach THEIR role in the room: an entry at their role, else one at a',
'and one naming a pair the defaults don’t carry (e.g. `CAN_INVITE`) is added to it.', 'lower role (a Role 0 grant reaches everyone), over the role’s defaults. One naming a',
'The overrides apply to the subroom the caller is standing in, resolved from presence.', '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.', '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.', '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`', '`RoomInstanceId` is the caller’s current instance, read from the shared `presence`',
'table (null when they’re in none).', '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', '`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', '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'), ].join('\n'),
security: AUTHED, security: AUTHED,
responses: { responses: {
+182 -101
View File
@@ -4394,13 +4394,13 @@ describe('rooms endpoints', () => {
expect((await SELF.fetch(`${ORIGIN}/photon_access_token`)).status).toBe(401) 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. // Seed the caller's presence so RoomInstanceId reflects their current instance.
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind( .bind(
JSON.stringify({ JSON.stringify({
accountId: 777, accountId: 777,
roomInstance: { roomInstanceId: 1000042 }, roomInstance: { roomInstanceId: 1000042, roomId: 2, subRoomId: 2 },
expiresAt: Math.floor(Date.now() / 1000) + 900, 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') }) const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer('777') })
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = (await res.json()) as { const body = (await res.json()) as {
Permissions: Array<{ Permission: string; Role: number }> Permissions: Array<{ Permission: string; Role: number; Override: boolean; Value: string }>
PhotonAccessToken: string PhotonAccessToken: string
RoomInstanceId: number | null RoomInstanceId: number | null
} }
expect(body.Permissions.length).toBe(11)
expect(body.RoomInstanceId).toBe(1000042) expect(body.RoomInstanceId).toBe(1000042)
// A non-dev account does NOT get the global (Role 0) maker pen. expect(body.PhotonAccessToken).toBe('')
expect(body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toBe( // 777 holds no role in room 2: one Role 0 row per permission, every one of them False.
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 () => { 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') }) const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer('888') })
expect(res.status).toBe(200) 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']) { 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) }) const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer(sub) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = (await res.json()) as { 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]).toEqual({
expect(body.Permissions[0]).toMatchObject({ Override: true,
Permission: 'CAN_USE_MAKER_PEN', Permission: 'CAN_USE_MAKER_PEN',
Role: 0, 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 // 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 // client: `PUT …/permissions` stores entries keyed by (Permission, Role), and
// `GET /photon_access_token` merges them over its defaults for whoever is standing in // `GET /photon_access_token` builds the table for whoever is standing in that subroom,
// that subroom. Room 2 / subroom 2 is owned by account 1; account 743 is the visitor // from the entries that reach THEIR role over the role's defaults. Room 2 / subroom 2 is
// whose presence points at it. // owned by account 1, with account 2 a co-owner; 743–747 are visitors whose presence
// points at it.
describe('subroom permissions', () => { describe('subroom permissions', () => {
type Permission = { Permission: string; Role: number; Override: boolean; Value: string } 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 return ((await res.json()) as { Permissions: Permission[] }).Permissions
} }
const entry = (list: Permission[], permission: string, role: number) => /** The value a table serves for a permission (every row of a table agrees). */
list.find((p) => p.Permission === permission && p.Role === role) 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 () => { it('is auth-gated and creator-only', async () => {
const body = [ const body = [
@@ -4777,50 +4851,91 @@ describe('rooms endpoints', () => {
expect(await res.text()).toBe('') expect(await res.text()).toBe('')
}) })
it('a checked Override replaces the matching default in place', async () => { it('a Role 0 grant reaches a visitor; a Role 30 grant does not', async () => {
const before = await permissionsIn(743, 2) // Nothing stored for everyone: the visitor may do nothing.
expect(before.length).toBe(11) expect(valueOf(await permissionsIn(743, 2), 'CAN_SPAWN_INVENTIONS')).toBe('False')
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' })
// The creator lets everyone spawn inventions in this subroom.
expect( expect(
( (
await putPermissions( await putPermissions(
'/rooms/2/subrooms/2/permissions', '/rooms/2/subrooms/2/permissions',
[ [
{ {
Permission: 'CAN_USE_MAKER_PEN', Permission: 'CAN_SPAWN_INVENTIONS',
Role: 30, Role: 0,
Override: true, Override: true,
Type: 0, Type: 0,
Value: 'False', Value: 'True',
}, },
], ],
'1' '1'
) )
).status ).status
).toBe(200) ).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) // A grant stored for co-owners is not the visitor's — and nor is it listed for them.
// 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( await putPermissions(
'/rooms/2/subrooms/2/permissions', '/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' '1'
) )
const changed = await permissionsIn(743, 2) const after = await permissionsIn(743, 2)
expect(changed.length).toBe(11) expect(after.find((p) => p.Permission === 'CAN_INVITE')).toBeUndefined()
expect(changed[at]).toMatchObject({ Override: true, Value: 'True' }) 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 () => { it('an unchecked Override erases the entry, back to the default', async () => {
@@ -4845,7 +4960,7 @@ describe('rooms endpoints', () => {
Role: 30, Role: 30,
Override: false, Override: false,
Type: 0, Type: 0,
Value: 'True', Value: 'False',
}, },
], ],
'1' '1'
@@ -4853,14 +4968,9 @@ describe('rooms endpoints', () => {
).status ).status
).toBe(200) ).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) expect(await stored()).toBe(0)
const table = await permissionsIn(743, 2) expect(valueOf(await permissionsIn(749, 2), 'CAN_USE_MAKER_PEN')).toBe('True')
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. // Clearing a pair that was never overridden is a no-op, not an insert.
await putPermissions( await putPermissions(
@@ -4868,31 +4978,21 @@ describe('rooms endpoints', () => {
[{ Permission: 'CAN_INVITE', Role: 0, Override: false, Type: 0, Value: 'True' }], [{ Permission: 'CAN_INVITE', Role: 0, Override: false, Type: 0, Value: 'True' }],
'1' '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 () => { it('scopes the table to its subroom', async () => {
// CAN_INVITE is in none of the defaults, so it lands as a new entry. // Subroom 2 lets everyone spawn inventions (set above); subroom 3 does not.
await putPermissions( expect(valueOf(await permissionsIn(744, 2), 'CAN_SPAWN_INVENTIONS')).toBe('True')
'/rooms/2/subrooms/2/permissions', expect(valueOf(await permissionsIn(744, 3), 'CAN_SPAWN_INVENTIONS')).toBe('False')
[{ Permission: 'CAN_INVITE', Role: 30, Override: true, Type: 0, Value: 'False' }], // And a player in no instance at all is a visitor with the defaults.
'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() await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(744).run()
const lobby = await SELF.fetch(`${ORIGIN}/photon_access_token`, { const lobby = await SELF.fetch(`${ORIGIN}/photon_access_token`, {
headers: await bearer('744'), 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 () => { 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' }], [{ Permission: 'MAX_SPAWNED_INVENTIONS', Role: 0, Override: true, Type: 0, Value: '25' }],
'1' '1'
) )
expect(entry(await permissionsIn(747, 2), 'MAX_SPAWNED_INVENTIONS', 0)).toMatchObject({ expect(valueOf(await permissionsIn(747, 2), 'MAX_SPAWNED_INVENTIONS')).toBe('25')
Override: true,
Value: '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( await putPermissions(
'/rooms/2/subrooms/2/permissions', '/rooms/2/subrooms/2/permissions',
[ [{ Permission: 'CAN_USE_MAKER_PEN', Role: 0, Override: true, Type: 0, Value: 'False' }],
{ 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' '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) const devTable = await permissionsIn(3, 2)
expect(devTable.filter((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toEqual([ expect(devTable.filter((p) => p.Permission === 'CAN_USE_MAKER_PEN')).toEqual([
{ Override: true, Permission: 'CAN_USE_MAKER_PEN', Role: 0, Type: 0, Value: 'False' }, { 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 has none, as before.
expect(valueOf(await permissionsIn(745, 2), 'CAN_USE_MAKER_PEN')).toBe('False')
// 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 () => { 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 cloneId = Math.max(...room.value.SubRooms.map((s) => s.SubRoomId))
const inClone = await permissionsIn(746, cloneId) const inClone = await permissionsIn(746, cloneId)
expect(entry(inClone, 'CAN_INVITE', 30)).toMatchObject({ Value: 'False' }) expect(valueOf(inClone, 'CAN_SPAWN_INVENTIONS')).toBe('True')
expect(entry(inClone, 'CAN_USE_MAKER_PEN', 0)).toMatchObject({ Value: 'False' }) expect(valueOf(inClone, 'MAX_SPAWNED_INVENTIONS')).toBe('25')
}) })
}) })