add subroom permissions

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