mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[rooms] fix photon access
This commit is contained in:
+105
-53
@@ -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
|
||||||
// The subroom's stored table wins, applied LAST and over the dev grant too: a
|
.filter((o) => o.Permission === permission && o.Role <= role)
|
||||||
// (Permission, Role) the table already carries is replaced in place — so the order
|
.reduce<RoomPermission | undefined>(
|
||||||
// doesn't shift under the client, and no pair is ever listed twice with two values —
|
(best, o) => (best && best.Role > o.Role ? best : o),
|
||||||
// and one it doesn't (e.g. CAN_INVITE) is appended.
|
undefined
|
||||||
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 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 {
|
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: {
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user