[rooms] roles

This commit is contained in:
Devin Zuczek
2026-09-14 13:05:08 -04:00
parent 7add875223
commit 4b4db04069
20 changed files with 1516 additions and 181 deletions
+22 -2
View File
@@ -400,6 +400,15 @@ export const MissingLookupParam = z
.string()
.describe("`\"Either 'id' or 'name' query parameter is required\"`")
/**
* A bare `{ success: true }` — the acknowledgement a write answers with when the client
* has nothing to re-render from it. NOT the `{ success, error, value }` room envelope: no
* `error` key and no `value`, so don't reach for this where the client redraws the room.
*/
export const SuccessEnvelope = z.object({
success: z.literal(true),
})
/**
* `GET /Room_server/rooms/{roomId}/bans/{playerId}/isBanned` — the ban check's envelope.
*
@@ -571,9 +580,20 @@ export const ImageRequest = z.object({
imageName: z.string().describe('A key from the storage upload, stored un-prefixed'),
})
/** `PUT /rooms/{roomId}/roles/{accountId}`. */
/** `PUT /rooms/{roomId}/roles/{accountId}` — a grant, or the invited player's answer. */
export const RoleRequest = z.object({
role: z.string().describe('The role tier: 10 Host, 20 Moderator, 30 CoOwner, 255 Creator'),
role: z
.string()
.describe(
'The role tier: 10 Host, 20 Moderator. 30 CoOwner and 255 Creator are refused on a ' +
'grant — co-ownership is invited. Answering your own invite, it must equal the ' +
'standing `InvitedRole`, or be `0` to decline'
),
})
/** `PUT /rooms/{roomId}/roles/{accountId}/invite`. */
export const InviteRoleRequest = z.object({
role: z.string().describe('The role tier offered: 10 Host, 20 Moderator, 30 CoOwner'),
})
/** `POST /rooms/{roomId}/bans` — the player to ban from the room. */
+266 -20
View File
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
Accessibility,
answerRoomRoleInvite,
applyRoomTagEdit,
areFriends,
autocompleteRoomSearch,
@@ -12,6 +13,7 @@ import {
cloneRoom,
cloneSubRoom,
countRoomsByCreator,
createNotification,
createSubRoom,
deleteRoom,
deleteRoomLeaderboard,
@@ -24,6 +26,7 @@ import {
getHotRooms,
getInteraction,
getOrCreateDormRoom,
getPlayerIdsInRoom,
getPresence,
getPublicRoomsByCreator,
getRecommendedRooms,
@@ -38,12 +41,17 @@ import {
getSubRoomSaves,
getTrendingRooms,
getVisitedRooms,
inviteRoomRole,
isPlayerBannedFromRoom,
isRoomOwner,
MessageType,
modifySubRoom,
publishSubRoomSave,
removeCheer,
removeFavorite,
Role,
roomNameRejection,
roomRoles,
saveSubRoomData,
searchRooms,
setRoomDescription,
@@ -88,6 +96,7 @@ import {
ImageRequest,
InteractionDto,
intQuery,
InviteRoleRequest,
IsBannedEnvelope,
IsBannedPascalEnvelope,
json,
@@ -118,6 +127,7 @@ import {
roomIdParam,
RoomLookup,
RoomResultEnvelope,
RoomRoleDto,
RoomSaveEnvelope,
saveIdParam,
SaveSubRoomDataRequest,
@@ -131,6 +141,7 @@ import {
SubRoomPermissionsRequest,
SubRoomSavesNoUnityAssetsPage,
SubRoomSavesPage,
SuccessEnvelope,
TagRequest,
TooManyLookupIds,
UNAUTHORIZED_EMPTY,
@@ -141,6 +152,7 @@ import {
import type { Context } from 'hono'
import type { RoomBan, RoomPermission } from '@repo/domain'
import type { MessageReceivedPayload } from '../../notify/src/notification-payloads'
import type { App } from './context'
/**
@@ -501,6 +513,95 @@ async function pushRoomUpdate(
}
}
/**
* Push a `RoomUpdate` to everyone standing in a room right now, plus anyone in `alsoNotify`.
*
* The fan-out for a change to the ROOM rather than to one player's relationship with it — a
* role changing alters what the room lets people do, and every client in it is rendering that
* room. Read from live presence across all of the room's instances, so it is whoever is
* actually there; a room nobody is in pushes to nobody.
*
* `alsoNotify` is for the player the change is ABOUT, who may not be standing in the room at
* all — a role granted to someone across the map still changes what they can do when they
* arrive. De-duplicated against the occupants, so being both doesn't earn two pushes.
*
* Each push is independent and failures are swallowed by {@link pushRoomUpdate}, so one
* unreachable player doesn't cost the rest theirs.
*/
async function pushRoomUpdateToRoom(
c: Context<App>,
roomId: number,
room: Record<string, unknown>,
alsoNotify: number[] = []
): Promise<void> {
const playerIds = new Set(await getPlayerIdsInRoom(c.env.DB, roomId))
for (const playerId of alsoNotify) playerIds.add(playerId)
await Promise.all([...playerIds].map((playerId) => pushRoomUpdate(c, playerId, room)))
}
/**
* Store and deliver a room-role invite — a Message of type 62 (`RoomCoOwnerInvited`), which
* is what the invited player accepts or declines from. The `Roles` entry alone is silent:
* it records the offer, this delivers it.
*
* STORED first, then pushed from the stored record, so the frame carries the row's `Id` and
* the invite survives a failed or unheard push: the invited player reads it from
* `GET /api/messages/v2/get` on their next login instead of losing it to the hub's
* best-effort queue. The frame is typed as the hub's {@link MessageReceivedPayload} so a
* renamed key fails the build rather than vanishing on the wire.
*
* `Data` is the ROLE being offered, as a string (a Message's `Data` is always a string on
* the wire). It is what the invited player's client reads the offer off, and what it must
* send back as `role` when it accepts — the accept refuses anything that isn't the role
* standing on their entry, so an invite the client can't read the tier of is one it can't
* answer. `PlayerEventId` is null; the room is in `RoomId`. The hub drops null keys when it
* builds the frame, so it reaches the client absent rather than as null, which its decoder
* reads the same way.
*
* Best-effort on the PUSH only: the role entry and the message row have both committed, so
* a hub hiccup must not fail the request.
*/
async function pushRoleInvite(
c: Context<App>,
roomId: number,
fromAccountId: number,
toAccountId: number,
role: number
): Promise<void> {
const stored = await createNotification(c.env.DB, {
FromPlayerId: fromAccountId,
ToPlayerId: toAccountId,
Type: MessageType.RoomCoOwnerInvited,
// The role being offered, as a string — a Message's `Data` is a string on the wire.
Data: String(role),
RoomId: roomId,
})
const message: MessageReceivedPayload = {
Id: stored.Id,
FromPlayerId: stored.FromPlayerId,
SentTime: stored.SentTime,
Type: stored.Type,
Data: stored.Data,
RoomId: stored.RoomId,
PlayerEventId: stored.PlayerEventId,
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toAccountId,
NotificationType.MessageReceived,
{ ...message, ToPlayerId: stored.ToPlayerId }
)
} catch (err) {
logger.error('failed to push RoomCoOwnerInvited MessageReceived notification', {
messageId: stored.Id,
roomId,
toAccountId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* `reportCategory` on a moderation frame. -1 is "Moderator" — the category for an
* action a person took rather than one the system inferred, which is what a room ban
@@ -1942,23 +2043,83 @@ const app = new Hono<App>()
}
)
// Set a member's role in a room (`Roles[].Role`). Auth-gated (401) and gated to
// the room creator or a co-owner (403 otherwise) — the same owner/co-owner check
// the other room-admin actions use. Body is the `role` form field (an integer role
// tier). Updates the target account's existing role entry or adds one, notifies the
// affected member so their client refreshes permissions, and returns the updated
// room in the lowercase `{ success, error, value }` envelope.
// A room's role list — the same `Roles` the room DTO already carries, served on its own
// so the client can refresh the member list without re-reading the whole room. Public,
// like the room itself, and a BARE array rather than the room-write envelope. An unknown
// room is an empty list.
.get(
'/rooms/:roomId{[0-9]+}/roles',
describeRoute({
tags: ['Room settings'],
summary: 'A room’s roles',
description: [
'Everyone with a role in the room — its creator plus whoever has been granted Host,',
'Moderator or CoOwner — as whole `RoomRole` records. This is the same array the',
'room DTO serves under `Roles`, on its own endpoint, so it is public exactly like the',
'room is: a role list says who runs a room, which the room page already shows.',
'',
'A bare array, NOT the `{ success, error, value }` envelope the role WRITE answers. An',
'unknown room is an empty list rather than an error — it reads the same as a room',
'nobody holds a role in.',
].join('\n'),
parameters: [roomIdParam],
responses: {
200: json(RoomRoleDto.array(), 'The room’s role assignments'),
},
}),
async (c) => {
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
return c.json(room ? roomRoles(room) : [])
}
)
// Set a player's role in a room (`Roles[].Role`). ONE path, two callers:
//
// - the room's owner/co-owner GRANTING a role to somebody else, which takes effect
// immediately — the helper tiers are theirs to hand out;
// - the invited player ANSWERING their own standing co-owner invite, accepting it by
// naming the offered tier or declining with `role=0`.
//
// Which one it is comes from whether the path account IS the caller. CoOwner is the one
// tier a grant cannot reach: it has to be offered (`…/invite`) and accepted, so the
// second path exists at all. Creator is refused for the same reason — it is strictly
// more than the tier that needs an invite.
//
// Auth-gated (401), and a 403 for a valid token with no standing to do either. Everyone
// in the room gets a `RoomUpdate` push, plus the affected player wherever they are.
.put(
'/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}',
describeRoute({
tags: ['Room settings'],
summary: 'Set a member’s role in a room',
summary: 'Set a player’s role in a room',
description: [
'Updates the target account’s entry in the room’s `Roles` (or adds one). Gated to the',
'room’s creator or a co-owner — a valid token from anyone else is a 403. The affected',
'MEMBER gets the `RoomUpdate` push, not the caller, so their client refreshes the',
'permissions it just gained or lost.',
].join(' '),
'Two callers share this path, told apart by whether the `accountId` in it is the',
'caller’s own.',
'',
'**The room’s owner or a co-owner, granting a role to someone else.** Takes effect',
'immediately, updating the target’s entry in `Roles` or adding one. `Role` 30',
'(CoOwner) is refused: co-ownership is offered with `PUT …/roles/{accountId}/invite`',
'and accepted by the invited player, which is what the other half of this endpoint',
'is for. `Role` 255 (Creator) is refused too — it is strictly more than the tier',
'that needs an invite, so it cannot be the one grant that skips the ceremony.',
'Any pending invite on the entry is left standing.',
'',
'**The invited player, answering their own co-owner invite.** Accepting promotes',
'their entry from its pending `InvitedRole` to the real `Role` and clears the',
'pending one — an accepted entry reads `Role: 30, InvitedRole: 0`, so an invite',
'cannot be answered twice. The `role` body field must equal the `InvitedRole` the',
'owner offered: without that check the invited player could answer an invite to',
'Host by asking for Creator and award themselves the room. Declining is `role=0`',
'and drops the entry outright, leaving `Roles` as it was before the offer — note',
'that this takes any role they already held with it. No entry, no invite standing,',
'and the wrong role are all the same rejection, so probing tells the caller nothing.',
'',
'Everyone standing in the room right now gets the `RoomUpdate` push, and so does the',
'affected player wherever they are — a role change alters what the room lets people',
'do, and every client showing it re-renders from the room they are pushed. The',
'caller reads the same room out of the envelope.',
].join('\n'),
security: AUTHED,
parameters: [
roomIdParam,
@@ -1966,11 +2127,11 @@ const app = new Hono<App>()
name: 'accountId',
in: 'path',
required: true,
description: 'The member whose role changes',
description: 'The player whose role changes — the caller’s own id to answer an invite',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
requestBody: form(RoleRequest, 'The role tier to grant'),
requestBody: form(RoleRequest, 'The role tier, or 0 to decline an invite'),
responses: {
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
401: UNAUTHORIZED_RESPONSE,
@@ -1985,22 +2146,107 @@ const app = new Hono<App>()
const targetAccountId = Number.parseInt(c.req.param('accountId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const role = typeof body.role === 'string' ? Number.parseInt(body.role, 10) : Number.NaN
if (Number.isNaN(role)) return roomEnvelope(c, null, 'You must provide a valid role!')
// Acting on your OWN entry is answering an invite — the only thing a player with
// no standing in the room may do here, and the only way a co-owner role is ever
// taken. An owner targeting themselves lands here too, and has no invite to answer.
if (targetAccountId === accountId) {
const answered = await answerRoomRoleInvite(c.env.DB, roomId, accountId, role, room)
if (!answered) return roomEnvelope(c, null, 'You have no such invite to this room!')
await pushRoomUpdateToRoom(c, roomId, answered)
return roomEnvelope(c, answered)
}
// Otherwise it is a grant, and the room's owner/co-owner gate applies.
if (!canManageRoom(room, accountId)) return c.body(null, 403)
// `role=0` is the invited player's decline, not an owner-side revoke — there is no
// revoke yet, and letting one in here would let a co-owner strip the other.
if (role === Role.None) return roomEnvelope(c, null, 'You must provide a valid role!')
// Null means the tier has to be ACCEPTED rather than handed over: nobody force-adds
// a co-owner. `setRoomRole` refuses it itself — the rule belongs to the room's
// `Roles`, not to this route — so there is no check here to forget to copy.
const updated = await setRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
// Notify the member whose role changed so their client refreshes the room
// (and the permissions it grants them).
await pushRoomUpdate(c, targetAccountId, updated)
if (!updated) return roomEnvelope(c, null, 'Co-ownership must be invited, not granted!')
await pushRoomUpdateToRoom(c, roomId, updated, [targetAccountId])
return roomEnvelope(c, updated)
}
)
// Invite a player to a room role (`Roles[].InvitedRole`). Auth-gated (401) and gated to
// the room's OWNER alone (403 otherwise) — narrower than the role WRITE above, which a
// co-owner may also use. Body is the `role` form field. Records the pending offer on the
// target's role entry (adding one at `Role` 0 when they have none), pushes the
// `RoomCoOwnerInvited` message that raises the invite on their client, and answers a
// bare `{ success: true }` — the inviter's client re-renders nothing from it.
.put(
'/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}/invite',
describeRoute({
tags: ['Room settings'],
summary: 'Invite a player to a room role',
description: [
'Sets `InvitedRole` on the target’s entry in the room’s `Roles` (adding an entry at',
'`Role` 0 when they have none) — the PENDING half of a role entry, held until they',
'accept, at which point the role proper is granted. Someone who already holds a role',
'keeps it while a higher one is pending.',
'',
'Gated to the room’s OWNER — its creator, or the holder of the Creator role. Narrower',
'than setting a role outright, which a co-owner may also do: otherwise a co-owner',
'could quietly grow the set of people who can change the room. A valid token from',
'anyone else is a 403.',
'',
'The INVITED player gets a durable `MessageReceived` frame carrying a Message of type',
'62 (`RoomCoOwnerInvited`), naming the inviter, the room, and the offered role as its',
'`Data` — that message, not the `Roles` entry, is what their client raises the invite',
'from, and the role in it is what they send back to accept.',
'',
'Answers a bare `{ success: true }`, NOT the `{ success, error, value }` room',
'envelope: nothing on the inviter’s screen re-renders from an invite.',
].join('\n'),
security: AUTHED,
parameters: [
roomIdParam,
{
name: 'accountId',
in: 'path',
required: true,
description: 'The player being invited',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
requestBody: form(InviteRoleRequest, 'The role tier being offered'),
responses: {
200: json(SuccessEnvelope, 'The invite was recorded and pushed'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const targetAccountId = Number.parseInt(c.req.param('accountId'), 10)
const room = await getRoomById(c.env.DB, roomId)
// An unknown room and a room somebody else owns answer the same 403: the gate is
// the owner check, and there is no envelope here to carry a message in.
if (!room || !isRoomOwner(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const role = typeof body.role === 'string' ? Number.parseInt(body.role, 10) : Number.NaN
if (Number.isNaN(role)) return c.body(null, 400)
await inviteRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
await pushRoleInvite(c, roomId, accountId, targetAccountId, role)
return c.json({ success: true })
}
)
// A room's ban list — the owner's view of who they've banned. Same gate as issuing a
// ban: a ban list says who a room's owner has had trouble with, so it isn't public.
// Answers a BARE array (not the room-write envelope), newest ban first.
+361 -30
View File
@@ -6,6 +6,8 @@ import '../../rooms.app'
import {
createRoomInstance,
getRoomInstance,
MessageType,
NOTIFICATION_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
@@ -99,6 +101,9 @@ beforeAll(async () => {
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (read by the photon access-token handler).
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Message store (owned by the api worker) — a room-role invite is written there before
// it is pushed, so the invited player can read it back from their inbox.
for (const stmt of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
@@ -1629,49 +1634,373 @@ describe('rooms endpoints', () => {
expect(interactions!.n).toBe(0)
})
it('PUT /rooms/:id/roles/:accountId is auth-gated, owner/co-owner-only, and persists', async () => {
const rolesOf = async (): Promise<Array<{ AccountId: number; Role: number }>> => {
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
Roles?: Array<{ AccountId: number; Role: number }>
}
return room.Roles ?? []
it('PUT /rooms/:id/roles/:accountId grants a helper role outright, but never co-owner', async () => {
type Role = {
AccountId: number
Role: number
InvitedRole: number
LastChangedByAccountId: number | null
}
const roleOf = async (accountId: number): Promise<Role | undefined> =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]).find(
(r) => r.AccountId === accountId
)
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
// No token → 401; a valid token with no standing in the room → 403.
expect((await putForm('/rooms/2/roles/860', { role: '20' })).status).toBe(401)
expect((await putForm('/rooms/2/roles/860', { role: '20' }, '999')).status).toBe(403)
// The owner grants Moderator, which takes effect immediately — no invite, no accept.
const ok = await putForm('/rooms/2/roles/860', { role: '20' }, '1')
expect(ok.status).toBe(200)
expect(await envOf(ok)).toMatchObject({ success: true, error: '' })
expect(await roleOf(860)).toMatchObject({
Role: 20,
InvitedRole: 0,
LastChangedByAccountId: 1,
})
// A co-owner may hand out the helper tiers too, updating the entry in place rather
// than adding a second.
expect((await putForm('/rooms/2/roles/860', { role: '10' }, '2')).status).toBe(200)
const roles = (await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]
expect(roles.filter((r) => r.AccountId === 860)).toHaveLength(1)
expect(await roleOf(860)).toMatchObject({ Role: 10, LastChangedByAccountId: 2 })
// The two tiers a grant may never reach — co-ownership has to be accepted, and
// Creator is more than co-ownership. Neither writes anything.
for (const role of ['30', '255']) {
expect(await envOf(await putForm('/rooms/2/roles/860', { role }, '1'))).toMatchObject({
success: false,
error: 'Co-ownership must be invited, not granted!',
})
}
// `role=0` is the invited player's decline, not an owner-side revoke.
expect(await envOf(await putForm('/rooms/2/roles/860', { role: '0' }, '1'))).toMatchObject({
success: false,
})
expect(await roleOf(860)).toMatchObject({ Role: 10 })
// A grant leaves a pending co-owner invite standing — the two are answered separately.
expect((await putForm('/rooms/2/roles/861/invite', { role: '30' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/861', { role: '20' }, '1')).status).toBe(200)
expect(await roleOf(861)).toMatchObject({ Role: 20, InvitedRole: 30 })
})
it('a grant pushes a RoomUpdate to the room and to the affected player', async () => {
type Sent = { playerId: number; notificationType: string | number; data: { RoomId: number } }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
// One bystander in room 4, and the player being granted the role standing somewhere
// else entirely — a role they can use when they arrive, so they hear about it too.
await putInRoom(870, 4)
await putInRoom(871, 2)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await putForm('/rooms/4/roles/871', { role: '20' }, '1')).status).toBe(200)
const pushed = await sent()
expect(pushed.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([870, 871])
expect(
pushed.every((n) => n.notificationType === NotificationType.SubscriptionUpdateRoom)
).toBe(true)
expect(pushed.every((n) => n.data.RoomId === 4)).toBe(true)
for (const id of [870, 871]) await clearPresence(id)
})
it('PUT /rooms/:id/roles/:accountId accepts a standing invite, and only that one', async () => {
type Role = {
AccountId: number
Role: number
InvitedRole: number
LastChangedByAccountId: number | null
}
const roleOf = async (accountId: number): Promise<Role | undefined> =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]).find(
(r) => r.AccountId === accountId
)
// RecCenter (room 2) is owned by account 1. The owner invites 850 to co-ownership,
// leaving a pending offer and no role yet.
expect((await putForm('/rooms/2/roles/850/invite', { role: '30' }, '1')).status).toBe(200)
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/roles/5', { role: '20' })).status).toBe(401)
// A valid token but no role on the room (RecCenter is owned by account 1, with
// account 2 as co-owner) → 403.
expect((await putForm('/rooms/2/roles/5', { role: '20' }, '999')).status).toBe(403)
expect((await putForm('/rooms/2/roles/850', { role: '30' })).status).toBe(401)
// Nobody can accept on 850's behalf. A stranger has no standing here at all (403);
// the owner and the co-owner fall through to the GRANT path, where co-ownership is
// exactly the tier they may not hand over.
expect((await putForm('/rooms/2/roles/850', { role: '30' }, '999')).status).toBe(403)
for (const caller of ['1', '2']) {
expect(
await envOf(await putForm('/rooms/2/roles/850', { role: '30' }, caller))
).toMatchObject({ success: false, error: 'Co-ownership must be invited, not granted!' })
}
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// Unknown room → failure envelope.
expect(await envOf(await putForm('/rooms/99999/roles/5', { role: '20' }, '1'))).toMatchObject({
success: false,
error: 'This room does not exist!',
})
expect(
await envOf(await putForm('/rooms/99999/roles/850', { role: '30' }, '850'))
).toMatchObject({ success: false, error: 'This room does not exist!' })
// Non-numeric role → failure envelope.
expect(await envOf(await putForm('/rooms/2/roles/5', { role: 'nope' }, '1'))).toMatchObject({
expect(await envOf(await putForm('/rooms/2/roles/850', { role: 'nope' }, '850'))).toMatchObject(
{
success: false,
}
)
// Asking for a HIGHER role than the one offered is the attack this guards against:
// refused, and the entry is untouched — still no role, invite still standing.
expect(await envOf(await putForm('/rooms/2/roles/850', { role: '255' }, '850'))).toMatchObject({
success: false,
})
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// Owner sets account 5's role to 20, adding a new Roles entry that persists. The
// success envelope carries the updated room as `value`.
const ok = await putForm('/rooms/2/roles/5', { role: '20' }, '1')
// A player with no entry in the room at all is the same rejection — probing tells
// them nothing.
expect(await envOf(await putForm('/rooms/2/roles/833', { role: '30' }, '833'))).toMatchObject({
success: false,
})
expect(await roleOf(833)).toBeUndefined()
// Accepting the role actually offered promotes the entry and consumes the invite.
const ok = await putForm('/rooms/2/roles/850', { role: '30' }, '850')
expect(ok.status).toBe(200)
const okBody = await envOf(ok)
expect(okBody).toMatchObject({ success: true, error: '' })
expect(okBody.value?.Roles as Array<{ AccountId: number; Role: number }>).toContainEqual(
expect.objectContaining({ AccountId: 5, Role: 20 })
expect(okBody.value?.Roles as Role[]).toContainEqual(
expect.objectContaining({ AccountId: 850, Role: 30, InvitedRole: 0 })
)
expect(await rolesOf()).toContainEqual(expect.objectContaining({ AccountId: 5, Role: 20 }))
expect(await roleOf(850)).toMatchObject({
Role: 30,
InvitedRole: 0,
// Left as the OWNER who offered the role, not restamped with the accepter.
LastChangedByAccountId: 1,
})
// The co-owner (account 2, Role 30) may also change it — updating the existing
// entry in place rather than adding a duplicate.
const byCoOwner = await putForm('/rooms/2/roles/5', { role: '10' }, '2')
expect(byCoOwner.status).toBe(200)
const roles = await rolesOf()
expect(roles.filter((r) => r.AccountId === 5)).toHaveLength(1)
expect(roles).toContainEqual(expect.objectContaining({ AccountId: 5, Role: 10 }))
// The seeded co-owner (account 2) is left intact.
expect(roles).toContainEqual(expect.objectContaining({ AccountId: 2, Role: 30 }))
// The invite is spent: the same call again is refused, so it can't be replayed.
expect(await envOf(await putForm('/rooms/2/roles/850', { role: '30' }, '850'))).toMatchObject({
success: false,
})
expect(await roleOf(850)).toMatchObject({ Role: 30, InvitedRole: 0 })
})
it('PUT /rooms/:id/roles/:accountId with role=0 declines, dropping the entry', async () => {
type Role = { AccountId: number; Role: number; InvitedRole: number }
const roleOf = async (accountId: number): Promise<Role | undefined> =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2/roles`)).json()) as Role[]).find(
(r) => r.AccountId === accountId
)
// Declining with nothing standing is the same rejection as any other unanswerable
// invite — it can't be used to drop a role nobody offered to change.
expect(await envOf(await putForm('/rooms/2/roles/851', { role: '0' }, '851'))).toMatchObject({
success: false,
})
// Invited, then declined: the whole entry goes, leaving `Roles` as it was before the
// offer rather than an entry with nothing pending on it.
expect((await putForm('/rooms/2/roles/851/invite', { role: '30' }, '1')).status).toBe(200)
expect(await roleOf(851)).toMatchObject({ Role: 0, InvitedRole: 30 })
const declined = await putForm('/rooms/2/roles/851', { role: '0' }, '851')
expect(declined.status).toBe(200)
expect(await envOf(declined)).toMatchObject({ success: true, error: '' })
expect(await roleOf(851)).toBeUndefined()
// And the decline can't be replayed either — there is no entry left to answer.
expect(await envOf(await putForm('/rooms/2/roles/851', { role: '0' }, '851'))).toMatchObject({
success: false,
})
// Declining takes a role the player ALREADY held with it: the invite is answered as a
// whole, so a re-invited member who declines is left with nothing.
expect((await putForm('/rooms/2/roles/852/invite', { role: '10' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/852', { role: '10' }, '852')).status).toBe(200)
expect(await roleOf(852)).toMatchObject({ Role: 10, InvitedRole: 0 })
expect((await putForm('/rooms/2/roles/852/invite', { role: '30' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/852', { role: '0' }, '852')).status).toBe(200)
expect(await roleOf(852)).toBeUndefined()
})
it('accepting an invite pushes a RoomUpdate to everyone in the room', async () => {
type Sent = {
playerId: number
notificationType: string | number
data: { RoomId: number; Roles: Array<{ AccountId: number; Role: number }> }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
// Room 3's owner invites 840, who is standing in the room with two others. A fourth
// player is in a DIFFERENT room, and a fifth's presence has lapsed — neither is there.
expect((await putForm('/rooms/3/roles/840/invite', { role: '20' }, '1')).status).toBe(200)
await putInRoom(840, 3)
await putInRoom(841, 3)
await putInRoom(842, 2)
await putInRoom(843, 3, { expired: true })
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await putForm('/rooms/3/roles/840', { role: '20' }, '840')).status).toBe(200)
// A role change alters what the room lets people do, so everyone standing in it
// re-renders — including the accepter, who is one of them.
const pushed = await sent()
expect(pushed.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([840, 841])
expect(
pushed.every((n) => n.notificationType === NotificationType.SubscriptionUpdateRoom)
).toBe(true)
// Each carries the room as it now stands, with the accepted role on it.
expect(pushed[0].data).toMatchObject({ RoomId: 3 })
expect(pushed[0].data.Roles).toContainEqual(
expect.objectContaining({ AccountId: 840, Role: 20, InvitedRole: 0 })
)
for (const id of [840, 841, 842, 843]) await clearPresence(id)
})
it('GET /rooms/:id/roles serves the room’s roles as a bare array', async () => {
// RecCenter (room 2) is seeded with its creator (255) and a co-owner (30). Public,
// like the room itself — no token.
const res = await SELF.fetch(`${ORIGIN}/rooms/2/roles`)
expect(res.status).toBe(200)
// Containment, not equality: the role-write test above grants account 5 a role on
// this same room, and the two tests share a database.
expect(await res.json()).toEqual(
expect.arrayContaining([
{ AccountId: 1, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 2, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 },
])
)
// An unknown room reads the same as a room nobody holds a role in.
expect((await SELF.fetch(`${ORIGIN}/rooms/99999/roles`)).status).toBe(200)
expect(await (await SELF.fetch(`${ORIGIN}/rooms/99999/roles`)).json()).toEqual([])
// A role written without `LastChangedByAccountId`/`InvitedRole` (the older rooms) is
// still served as a whole record — the client's parser wants every key.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: 30601,
Name: 'PartialRoles',
CreatorAccountId: 830,
SubRooms: [],
Roles: [{ AccountId: 830, Role: 255 }],
})
)
.run()
expect(await (await SELF.fetch(`${ORIGIN}/rooms/30601/roles`)).json()).toEqual([
{ AccountId: 830, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
])
// A room with no `Roles` key at all is an empty list, not a failure.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(JSON.stringify({ RoomId: 30602, Name: 'NoRoles', CreatorAccountId: 830, SubRooms: [] }))
.run()
expect(await (await SELF.fetch(`${ORIGIN}/rooms/30602/roles`)).json()).toEqual([])
})
it('PUT /rooms/:id/roles/:accountId/invite records the offer and pushes the invite', async () => {
type Sent = {
playerId: number
notificationType: string | number
data: Record<string, unknown>
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
const rolesOf = async (roomId: number) =>
(await (await SELF.fetch(`${ORIGIN}/rooms/${roomId}/roles`)).json()) as Array<{
AccountId: number
Role: number
InvitedRole: number
LastChangedByAccountId: number | null
}>
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/roles/187/invite', { role: '30' })).status).toBe(401)
// A valid token with no standing on the room → 403...
expect((await putForm('/rooms/2/roles/187/invite', { role: '30' }, '999')).status).toBe(403)
// ...and so does the CO-OWNER (account 2, Role 30), who may set roles outright but
// may not hand out co-ownership. This is the gate that is narrower than the write.
expect((await putForm('/rooms/2/roles/187/invite', { role: '30' }, '2')).status).toBe(403)
// An unknown room answers the same 403 rather than leaking that it doesn't exist.
expect((await putForm('/rooms/99999/roles/187/invite', { role: '30' }, '1')).status).toBe(403)
// A non-numeric role is a bad request — there is no envelope here to carry a message.
expect((await putForm('/rooms/2/roles/187/invite', { role: 'nope' }, '1')).status).toBe(400)
// None of the refusals wrote a role entry.
expect((await rolesOf(2)).find((r) => r.AccountId === 187)).toBeUndefined()
// The owner invites 187 to co-ownership.
await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await putForm('/rooms/2/roles/187/invite', { role: '30' }, '1')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
// A new entry, holding no role yet — only the pending offer, stamped with the inviter.
expect(await rolesOf(2)).toContainEqual({
AccountId: 187,
Role: 0,
InvitedRole: 30,
LastChangedByAccountId: 1,
})
// One MessageReceived to the INVITED player carrying a type 62 RoomCoOwnerInvited —
// the entry alone is silent, this is what raises the invite on their client.
const pushed = await sent()
expect(pushed).toHaveLength(1)
expect(pushed[0].playerId).toBe(187)
expect(pushed[0].notificationType).toBe(NotificationType.MessageReceived)
expect(pushed[0].data).toMatchObject({
FromPlayerId: 1,
Type: MessageType.RoomCoOwnerInvited,
// The offered role, as a string — what the client reads the tier off, and sends
// back as `role` to accept.
Data: '30',
RoomId: 2,
PlayerEventId: null,
})
// The invite is STORED before it is pushed, and the frame carries the row's id and
// timestamp — so the invited player reads the same invite from their inbox
// (`GET /api/messages/v2/get`) even if the push never reached them.
const row = await env.DB.prepare(
'SELECT notification_id, from_player_id, type, data, room_id, sent_time FROM notification WHERE to_player_id = 187'
).first<{
notification_id: number
from_player_id: number
type: number
data: string | null
room_id: number
sent_time: string
}>()
expect(row).toMatchObject({
from_player_id: 1,
type: MessageType.RoomCoOwnerInvited,
data: '30',
room_id: 2,
})
expect(pushed[0].data.Id).toBe(row!.notification_id)
expect(pushed[0].data.SentTime).toBe(row!.sent_time)
expect(Date.parse(row!.sent_time)).not.toBeNaN()
// Re-inviting someone who already has an entry sets the pending role in place rather
// than adding a second entry — and leaves the role they already hold alone. 187
// accepts the invite above first, so they hold a real role to be re-invited over.
expect((await putForm('/rooms/2/roles/187', { role: '30' }, '187')).status).toBe(200)
await putForm('/rooms/2/roles/187/invite', { role: '20' }, '1')
const after = await rolesOf(2)
expect(after.filter((r) => r.AccountId === 187)).toHaveLength(1)
expect(after).toContainEqual({
AccountId: 187,
Role: 30,
InvitedRole: 20,
LastChangedByAccountId: 1,
})
})
it('POST /rooms/:id/bans is gated to the room’s owners or staff, and persists', async () => {
@@ -4100,6 +4429,7 @@ describe('rooms endpoints', () => {
'GET /rooms/{roomId}/experience/player',
'GET /rooms/{roomId}/interactionby/me',
'GET /rooms/{roomId}/playerdata/me',
'GET /rooms/{roomId}/roles',
'GET /rooms/{roomId}/similar',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/no_unity_assets',
@@ -4124,6 +4454,7 @@ describe('rooms endpoints', () => {
'PUT /rooms/{roomId}/name',
'PUT /rooms/{roomId}/restrictions',
'PUT /rooms/{roomId}/roles/{accountId}',
'PUT /rooms/{roomId}/roles/{accountId}/invite',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions',