mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[rooms] roles
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user