@openora/core 0.4.1-canary.113 → 0.4.1-canary.115

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/plugin.d.ts +11 -0
  3. package/dist/admin-console/plugin.d.ts.map +1 -1
  4. package/dist/analytics/plugin.d.ts +11 -0
  5. package/dist/analytics/plugin.d.ts.map +1 -1
  6. package/dist/audit/__tests__/map-event.test.js +34 -0
  7. package/dist/audit/__tests__/map-event.test.js.map +1 -1
  8. package/dist/audit/plugin.d.ts +11 -0
  9. package/dist/audit/plugin.d.ts.map +1 -1
  10. package/dist/audit/plugin.js +63 -7
  11. package/dist/audit/plugin.js.map +1 -1
  12. package/dist/casino/gaming/plugin.d.ts +11 -0
  13. package/dist/casino/gaming/plugin.d.ts.map +1 -1
  14. package/dist/casino/lobby/plugin.d.ts +11 -0
  15. package/dist/casino/lobby/plugin.d.ts.map +1 -1
  16. package/dist/cms/plugin.d.ts +11 -0
  17. package/dist/cms/plugin.d.ts.map +1 -1
  18. package/dist/compliance/plugin.d.ts +11 -0
  19. package/dist/compliance/plugin.d.ts.map +1 -1
  20. package/dist/contracts/adapters/admin-security.d.ts +27 -0
  21. package/dist/contracts/adapters/admin-security.d.ts.map +1 -0
  22. package/dist/contracts/adapters/admin-security.js +9 -0
  23. package/dist/contracts/adapters/admin-security.js.map +1 -0
  24. package/dist/contracts/adapters/index.d.ts +3 -1
  25. package/dist/contracts/adapters/index.d.ts.map +1 -1
  26. package/dist/contracts/adapters/index.js +1 -0
  27. package/dist/contracts/adapters/index.js.map +1 -1
  28. package/dist/contracts/adapters/realtime.d.ts +36 -3
  29. package/dist/contracts/adapters/realtime.d.ts.map +1 -1
  30. package/dist/contracts/adapters/realtime.js.map +1 -1
  31. package/dist/contracts/schemas/common.d.ts +3 -1
  32. package/dist/contracts/schemas/common.d.ts.map +1 -1
  33. package/dist/contracts/schemas/common.js +2 -0
  34. package/dist/contracts/schemas/common.js.map +1 -1
  35. package/dist/contracts/schemas/events.d.ts +114 -0
  36. package/dist/contracts/schemas/events.d.ts.map +1 -1
  37. package/dist/contracts/schemas/events.js +77 -0
  38. package/dist/contracts/schemas/events.js.map +1 -1
  39. package/dist/contracts/schemas/identity.d.ts +26 -0
  40. package/dist/contracts/schemas/identity.d.ts.map +1 -1
  41. package/dist/contracts/schemas/identity.js +32 -1
  42. package/dist/contracts/schemas/identity.js.map +1 -1
  43. package/dist/contracts/schemas/platform-config.d.ts +39 -0
  44. package/dist/contracts/schemas/platform-config.d.ts.map +1 -1
  45. package/dist/contracts/schemas/platform-config.js +43 -0
  46. package/dist/contracts/schemas/platform-config.js.map +1 -1
  47. package/dist/engagement/chat/__tests__/chat.router.test.d.ts +2 -0
  48. package/dist/engagement/chat/__tests__/chat.router.test.d.ts.map +1 -0
  49. package/dist/engagement/chat/__tests__/chat.router.test.js +44 -0
  50. package/dist/engagement/chat/__tests__/chat.router.test.js.map +1 -0
  51. package/dist/engagement/chat/__tests__/chat.service.int.test.js +590 -6
  52. package/dist/engagement/chat/__tests__/chat.service.int.test.js.map +1 -1
  53. package/dist/engagement/chat/contract/constants.d.ts +2 -0
  54. package/dist/engagement/chat/contract/constants.d.ts.map +1 -1
  55. package/dist/engagement/chat/contract/constants.js +7 -0
  56. package/dist/engagement/chat/contract/constants.js.map +1 -1
  57. package/dist/engagement/chat/contract/index.d.ts +39 -0
  58. package/dist/engagement/chat/contract/index.d.ts.map +1 -1
  59. package/dist/engagement/chat/contract/index.js +25 -1
  60. package/dist/engagement/chat/contract/index.js.map +1 -1
  61. package/dist/engagement/chat/drizzle/migrations/0009_massive_bill_hollister.sql +1 -0
  62. package/dist/engagement/chat/drizzle/migrations/0010_backfill_role_assigned_at.sql +9 -0
  63. package/dist/engagement/chat/drizzle/migrations/meta/0009_snapshot.json +1468 -0
  64. package/dist/engagement/chat/drizzle/migrations/meta/0010_snapshot.json +1468 -0
  65. package/dist/engagement/chat/drizzle/migrations/meta/_journal.json +14 -0
  66. package/dist/engagement/chat/plugin.d.ts +11 -0
  67. package/dist/engagement/chat/plugin.d.ts.map +1 -1
  68. package/dist/engagement/chat/router/index.d.ts +19 -0
  69. package/dist/engagement/chat/router/index.d.ts.map +1 -1
  70. package/dist/engagement/chat/router/index.js +31 -2
  71. package/dist/engagement/chat/router/index.js.map +1 -1
  72. package/dist/engagement/chat/schema/index.d.ts +17 -0
  73. package/dist/engagement/chat/schema/index.d.ts.map +1 -1
  74. package/dist/engagement/chat/schema/index.js +3 -0
  75. package/dist/engagement/chat/schema/index.js.map +1 -1
  76. package/dist/engagement/chat/service/chat-ban.service.d.ts.map +1 -1
  77. package/dist/engagement/chat/service/chat-ban.service.js +6 -6
  78. package/dist/engagement/chat/service/chat-ban.service.js.map +1 -1
  79. package/dist/engagement/chat/service/chat-room-ban.service.js +1 -1
  80. package/dist/engagement/chat/service/chat-room-ban.service.js.map +1 -1
  81. package/dist/engagement/chat/service/chat-room-membership.service.d.ts +15 -0
  82. package/dist/engagement/chat/service/chat-room-membership.service.d.ts.map +1 -1
  83. package/dist/engagement/chat/service/chat-room-membership.service.js +96 -4
  84. package/dist/engagement/chat/service/chat-room-membership.service.js.map +1 -1
  85. package/dist/engagement/chat/service/chat.service.d.ts +3 -2
  86. package/dist/engagement/chat/service/chat.service.d.ts.map +1 -1
  87. package/dist/engagement/chat/service/chat.service.js +73 -24
  88. package/dist/engagement/chat/service/chat.service.js.map +1 -1
  89. package/dist/engagement/chat/service/errors/chat-moderation.errors.d.ts +1 -0
  90. package/dist/engagement/chat/service/errors/chat-moderation.errors.d.ts.map +1 -1
  91. package/dist/engagement/chat/service/errors/chat-moderation.errors.js +1 -0
  92. package/dist/engagement/chat/service/errors/chat-moderation.errors.js.map +1 -1
  93. package/dist/engagement/chat-commands/plugin.d.ts +11 -0
  94. package/dist/engagement/chat-commands/plugin.d.ts.map +1 -1
  95. package/dist/engagement/notifications/plugin.d.ts +11 -0
  96. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  97. package/dist/engagement/social/plugin.d.ts +11 -0
  98. package/dist/engagement/social/plugin.d.ts.map +1 -1
  99. package/dist/iam/plugin.d.ts +11 -0
  100. package/dist/iam/plugin.d.ts.map +1 -1
  101. package/dist/pam/identity/__tests__/admin-security.int.test.d.ts +2 -0
  102. package/dist/pam/identity/__tests__/admin-security.int.test.d.ts.map +1 -0
  103. package/dist/pam/identity/__tests__/admin-security.int.test.js +137 -0
  104. package/dist/pam/identity/__tests__/admin-security.int.test.js.map +1 -0
  105. package/dist/pam/identity/__tests__/device-fingerprint.test.d.ts +2 -0
  106. package/dist/pam/identity/__tests__/device-fingerprint.test.d.ts.map +1 -0
  107. package/dist/pam/identity/__tests__/device-fingerprint.test.js +105 -0
  108. package/dist/pam/identity/__tests__/device-fingerprint.test.js.map +1 -0
  109. package/dist/pam/identity/__tests__/identity.rate-limit.int.test.js +3 -3
  110. package/dist/pam/identity/__tests__/identity.rate-limit.int.test.js.map +1 -1
  111. package/dist/pam/identity/__tests__/identity.service.int.test.js +221 -1
  112. package/dist/pam/identity/__tests__/identity.service.int.test.js.map +1 -1
  113. package/dist/pam/identity/contract/index.d.ts +144 -0
  114. package/dist/pam/identity/contract/index.d.ts.map +1 -1
  115. package/dist/pam/identity/contract/index.js +84 -1
  116. package/dist/pam/identity/contract/index.js.map +1 -1
  117. package/dist/pam/identity/drizzle/migrations/0008_pink_bucky.sql +23 -0
  118. package/dist/pam/identity/drizzle/migrations/0009_hard_sauron.sql +2 -0
  119. package/dist/pam/identity/drizzle/migrations/meta/0008_snapshot.json +913 -0
  120. package/dist/pam/identity/drizzle/migrations/meta/0009_snapshot.json +926 -0
  121. package/dist/pam/identity/drizzle/migrations/meta/_journal.json +14 -0
  122. package/dist/pam/identity/plugin.d.ts +11 -0
  123. package/dist/pam/identity/plugin.d.ts.map +1 -1
  124. package/dist/pam/identity/plugin.js +81 -26
  125. package/dist/pam/identity/plugin.js.map +1 -1
  126. package/dist/pam/identity/react/account.d.ts +5 -0
  127. package/dist/pam/identity/react/account.d.ts.map +1 -1
  128. package/dist/pam/identity/react/auth.d.ts +2 -0
  129. package/dist/pam/identity/react/auth.d.ts.map +1 -1
  130. package/dist/pam/identity/router/index.d.ts +105 -1
  131. package/dist/pam/identity/router/index.d.ts.map +1 -1
  132. package/dist/pam/identity/router/index.js +44 -3
  133. package/dist/pam/identity/router/index.js.map +1 -1
  134. package/dist/pam/identity/schema/index.d.ts +314 -0
  135. package/dist/pam/identity/schema/index.d.ts.map +1 -1
  136. package/dist/pam/identity/schema/index.js +38 -0
  137. package/dist/pam/identity/schema/index.js.map +1 -1
  138. package/dist/pam/identity/service/admin-security.service.d.ts +82 -0
  139. package/dist/pam/identity/service/admin-security.service.d.ts.map +1 -0
  140. package/dist/pam/identity/service/admin-security.service.js +277 -0
  141. package/dist/pam/identity/service/admin-security.service.js.map +1 -0
  142. package/dist/pam/identity/service/device-fingerprint.service.d.ts +32 -0
  143. package/dist/pam/identity/service/device-fingerprint.service.d.ts.map +1 -0
  144. package/dist/pam/identity/service/device-fingerprint.service.js +79 -0
  145. package/dist/pam/identity/service/device-fingerprint.service.js.map +1 -0
  146. package/dist/pam/identity/service/identity.service.d.ts +37 -2
  147. package/dist/pam/identity/service/identity.service.d.ts.map +1 -1
  148. package/dist/pam/identity/service/identity.service.js +233 -19
  149. package/dist/pam/identity/service/identity.service.js.map +1 -1
  150. package/dist/pam/identity/service/phone-login.service.d.ts +1 -0
  151. package/dist/pam/identity/service/phone-login.service.d.ts.map +1 -1
  152. package/dist/pam/identity/service/phone-login.service.js +2 -0
  153. package/dist/pam/identity/service/phone-login.service.js.map +1 -1
  154. package/dist/pam/identity/service/session.service.d.ts +45 -1
  155. package/dist/pam/identity/service/session.service.d.ts.map +1 -1
  156. package/dist/pam/identity/service/session.service.js +81 -12
  157. package/dist/pam/identity/service/session.service.js.map +1 -1
  158. package/dist/pam/identity/service/trusted-device.service.d.ts +54 -0
  159. package/dist/pam/identity/service/trusted-device.service.d.ts.map +1 -0
  160. package/dist/pam/identity/service/trusted-device.service.js +157 -0
  161. package/dist/pam/identity/service/trusted-device.service.js.map +1 -0
  162. package/dist/pam/identity/service/two-factor-lockout.service.d.ts +25 -0
  163. package/dist/pam/identity/service/two-factor-lockout.service.d.ts.map +1 -0
  164. package/dist/pam/identity/service/two-factor-lockout.service.js +121 -0
  165. package/dist/pam/identity/service/two-factor-lockout.service.js.map +1 -0
  166. package/dist/pam/player-management/plugin.d.ts +11 -0
  167. package/dist/pam/player-management/plugin.d.ts.map +1 -1
  168. package/dist/pam/player-note/plugin.d.ts +11 -0
  169. package/dist/pam/player-note/plugin.d.ts.map +1 -1
  170. package/dist/pam/profile/plugin.d.ts +11 -0
  171. package/dist/pam/profile/plugin.d.ts.map +1 -1
  172. package/dist/pam/tag/plugin.d.ts +11 -0
  173. package/dist/pam/tag/plugin.d.ts.map +1 -1
  174. package/dist/react/context/__tests__/realtime-client.test.d.ts +2 -0
  175. package/dist/react/context/__tests__/realtime-client.test.d.ts.map +1 -0
  176. package/dist/react/context/__tests__/realtime-client.test.js +75 -0
  177. package/dist/react/context/__tests__/realtime-client.test.js.map +1 -0
  178. package/dist/react/context/realtime-client.d.ts +14 -0
  179. package/dist/react/context/realtime-client.d.ts.map +1 -1
  180. package/dist/react/context/realtime-client.js.map +1 -1
  181. package/dist/server/auth/__tests__/admin-guard.int.test.js +3 -1
  182. package/dist/server/auth/__tests__/admin-guard.int.test.js.map +1 -1
  183. package/dist/server/auth/admin-guard.d.ts +3 -2
  184. package/dist/server/auth/admin-guard.d.ts.map +1 -1
  185. package/dist/server/auth/admin-guard.js +18 -2
  186. package/dist/server/auth/admin-guard.js.map +1 -1
  187. package/dist/server/auth/auth.d.ts +14 -0
  188. package/dist/server/auth/auth.d.ts.map +1 -1
  189. package/dist/server/auth/auth.js +18 -1
  190. package/dist/server/auth/auth.js.map +1 -1
  191. package/dist/server/kernel/realtime-transport.d.ts +11 -3
  192. package/dist/server/kernel/realtime-transport.d.ts.map +1 -1
  193. package/dist/server/kernel/realtime-transport.js +53 -10
  194. package/dist/server/kernel/realtime-transport.js.map +1 -1
  195. package/dist/server/runtime/core-token-catalog.d.ts +11 -0
  196. package/dist/server/runtime/core-token-catalog.d.ts.map +1 -1
  197. package/dist/server/runtime/core-token-catalog.js +2 -1
  198. package/dist/server/runtime/core-token-catalog.js.map +1 -1
  199. package/dist/server/runtime/create-app.d.ts.map +1 -1
  200. package/dist/server/runtime/create-app.js +2 -2
  201. package/dist/server/runtime/create-app.js.map +1 -1
  202. package/dist/testing/seed-player.d.ts +10 -2
  203. package/dist/testing/seed-player.d.ts.map +1 -1
  204. package/dist/wallet/plugin.d.ts +11 -0
  205. package/dist/wallet/plugin.d.ts.map +1 -1
  206. package/package.json +1 -1
@@ -7,7 +7,7 @@ import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity';
7
7
  import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile';
8
8
  import { NO_CLIENT_META, makeEventBus, makeIdentityReader, mock } from '../../../testing/mock.js';
9
9
  import { CHAT_ROOM_CATEGORIES } from '../contract/index.js';
10
- import { MAX_PRIVATE_ROOMS_PER_PLAYER } from '../contract/constants.js';
10
+ import { CHAT_MEMBER_ROLE_CHANGED_SIGNAL, MAX_PRIVATE_ROOMS_PER_PLAYER, } from '../contract/constants.js';
11
11
  import { migrate } from '../migrate.js';
12
12
  // import {
13
13
  // mock,
@@ -18,7 +18,7 @@ import { migrate } from '../migrate.js';
18
18
  // NO_CLIENT_META,
19
19
  // } from '../../../testing/mock.js';
20
20
  import { chatChannel } from '@openora/core/contracts';
21
- import { ChatService, ChatMessageBlockedError, ChatAttachmentRejectedError, ChatSelfBlockError, ChatSelfIgnoreError, ChatRoomSlugConflictError, ChatRoomJoinCodeNotFoundError, ChatRoomBannedError, ChatRoomLimitReachedError, ChatRoomLastModeratorError, ChatRoomNotMemberError, ChatRoomNotModeratorError, ChatRoomSelfModerationError, ChatRoomOwnershipError, } from '../service/chat.service.js';
21
+ import { ChatService, ChatMessageBlockedError, ChatAttachmentRejectedError, ChatSelfBlockError, ChatSelfIgnoreError, ChatRoomSlugConflictError, ChatRoomJoinCodeNotFoundError, ChatRoomBannedError, ChatRoomLimitReachedError, ChatRoomLastModeratorError, ChatRoomOwnerCannotLeaveError, ChatRoomNotMemberError, ChatRoomNotModeratorError, ChatRoomSelfModerationError, ChatRoomOwnershipError, } from '../service/chat.service.js';
22
22
  import { ChatModerationService, ChatRoomNotFoundError, ChatMessageNotFoundError, ChatPlayerMutedError, ChatPlayerBannedError, ChatAdminPrivateRoomModerationError, } from '../service/chat-moderation.service.js';
23
23
  import { chatMessage, chatRoom, chatRoomMember, chatRoomBan, chatRoomRule, chatRoomConfiguration, chatRoomMute, chatRoomRemove, chatUserBlock, chatUserIgnore, chatMute, chatPlatformBan, } from '../schema/index.js';
24
24
  import { ChatRoomMembershipService } from '../service/chat-room-membership.service.js';
@@ -35,8 +35,7 @@ function makeService(directory = mock({
35
35
  isActive: true,
36
36
  role: 'player',
37
37
  })),
38
- }), socialCommands, allowedAttachmentHosts = []) {
39
- const transport = new InProcessRealtimeTransport();
38
+ }), socialCommands, allowedAttachmentHosts = [], transport = new InProcessRealtimeTransport()) {
40
39
  const events = makeEventBus();
41
40
  const audit = mock({
42
41
  record: vi.fn().mockResolvedValue(undefined),
@@ -66,6 +65,7 @@ function makeService(directory = mock({
66
65
  adminJoinRoom: membership.adminJoinRoom.bind(membership),
67
66
  leaveRoom: membership.leaveRoom.bind(membership),
68
67
  removeMember: membership.removeMember.bind(membership),
68
+ setMemberRole: membership.setMemberRole.bind(membership),
69
69
  banMember: roomBan.banMember.bind(roomBan),
70
70
  unbanMember: roomBan.unbanMember.bind(roomBan),
71
71
  muteRoomMember: roomMute.muteRoomMember.bind(roomMute),
@@ -1164,6 +1164,93 @@ describe('ChatService admin rooms (real PG)', () => {
1164
1164
  after: { deletedAt: expect.any(String) },
1165
1165
  }));
1166
1166
  });
1167
+ it('cuts every member off the room channel once the room is deleted', async () => {
1168
+ const { svc, transport } = makeService();
1169
+ const ownerId = randomUUID();
1170
+ const memberId = randomUUID();
1171
+ const room = await svc.createPrivateRoom({
1172
+ userId: ownerId,
1173
+ name: 'End me',
1174
+ ...NO_CLIENT_META,
1175
+ });
1176
+ await svc.joinRoom({ userId: memberId, joinCode: room.joinCode, ...NO_CLIENT_META });
1177
+ const ownerDeliveries = [];
1178
+ const memberDeliveries = [];
1179
+ transport.subscribe(chatChannel(room.id), () => ownerDeliveries.push(true), ownerId);
1180
+ transport.subscribe(chatChannel(room.id), () => memberDeliveries.push(true), memberId);
1181
+ await svc.deletePrivateRoom({ roomId: room.id, userId: ownerId, ...NO_CLIENT_META });
1182
+ transport.publish(chatChannel(room.id), { type: 'chat.message.sent' });
1183
+ expect(ownerDeliveries).toEqual([]);
1184
+ expect(memberDeliveries).toEqual([]);
1185
+ });
1186
+ it('cuts every connection the same user holds, not just the first', async () => {
1187
+ const { svc, transport } = makeService();
1188
+ const ownerId = randomUUID();
1189
+ const memberId = randomUUID();
1190
+ const room = await svc.createPrivateRoom({
1191
+ userId: ownerId,
1192
+ name: 'End me',
1193
+ ...NO_CLIENT_META,
1194
+ });
1195
+ await svc.joinRoom({ userId: memberId, joinCode: room.joinCode, ...NO_CLIENT_META });
1196
+ const firstTab = [];
1197
+ const secondTab = [];
1198
+ transport.subscribe(chatChannel(room.id), () => firstTab.push(true), memberId);
1199
+ transport.subscribe(chatChannel(room.id), () => secondTab.push(true), memberId);
1200
+ await svc.deletePrivateRoom({ roomId: room.id, userId: ownerId, ...NO_CLIENT_META });
1201
+ transport.publish(chatChannel(room.id), { type: 'chat.message.sent' });
1202
+ expect(firstTab).toEqual([]);
1203
+ expect(secondTab).toEqual([]);
1204
+ });
1205
+ it('still records the deletion when the channel cleanup rejects', async () => {
1206
+ const transport = Object.assign(new InProcessRealtimeTransport(), {
1207
+ revokeUserFromChannel: vi.fn().mockRejectedValue(new Error('transport down')),
1208
+ });
1209
+ const { svc, events } = makeService(undefined, undefined, [], transport);
1210
+ const ownerId = randomUUID();
1211
+ const room = await svc.createPrivateRoom({
1212
+ userId: ownerId,
1213
+ name: 'End me',
1214
+ ...NO_CLIENT_META,
1215
+ });
1216
+ await expect(svc.deletePrivateRoom({ roomId: room.id, userId: ownerId, ...NO_CLIENT_META })).resolves.toEqual({ success: true });
1217
+ const [stored] = await db.drizzle.db.select().from(chatRoom).where(eq(chatRoom.id, room.id));
1218
+ expect(stored?.deletedAt).toBeInstanceOf(Date);
1219
+ expect(events.emit).toHaveBeenCalledWith('chat.private_room.deleted', expect.objectContaining({ roomId: room.id, creatorId: ownerId }));
1220
+ });
1221
+ it('lets only one of two concurrent deletes through, with one deletion event', async () => {
1222
+ const { svc, events } = makeService();
1223
+ const ownerId = randomUUID();
1224
+ const room = await svc.createPrivateRoom({
1225
+ userId: ownerId,
1226
+ name: 'End me',
1227
+ ...NO_CLIENT_META,
1228
+ });
1229
+ const results = await Promise.allSettled([
1230
+ svc.deletePrivateRoom({ roomId: room.id, userId: ownerId, ...NO_CLIENT_META }),
1231
+ svc.deletePrivateRoom({ roomId: room.id, userId: ownerId, ...NO_CLIENT_META }),
1232
+ ]);
1233
+ expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
1234
+ const [rejected] = results.filter((r) => r.status === 'rejected');
1235
+ expect(rejected.reason).toBeInstanceOf(ChatRoomNotFoundError);
1236
+ expect(events.emit.mock.calls.filter(([topic]) => topic === 'chat.private_room.deleted')).toHaveLength(1);
1237
+ });
1238
+ it('leaves the room channel intact when the delete is refused', async () => {
1239
+ const { svc, transport } = makeService();
1240
+ const ownerId = randomUUID();
1241
+ const memberId = randomUUID();
1242
+ const room = await svc.createPrivateRoom({
1243
+ userId: ownerId,
1244
+ name: 'Keep me',
1245
+ ...NO_CLIENT_META,
1246
+ });
1247
+ await svc.joinRoom({ userId: memberId, joinCode: room.joinCode, ...NO_CLIENT_META });
1248
+ const memberDeliveries = [];
1249
+ transport.subscribe(chatChannel(room.id), () => memberDeliveries.push(true), memberId);
1250
+ await expect(svc.deletePrivateRoom({ roomId: room.id, userId: memberId, ...NO_CLIENT_META })).rejects.toBeInstanceOf(ChatRoomOwnershipError);
1251
+ transport.publish(chatChannel(room.id), { type: 'chat.message.sent' });
1252
+ expect(memberDeliveries).toEqual([true]);
1253
+ });
1167
1254
  });
1168
1255
  describe('ChatService.createPrivateRoom (real PG)', () => {
1169
1256
  it('creates the room, joins the creator as moderator, and issues a join code', async () => {
@@ -1282,7 +1369,7 @@ describe('ChatService.joinRoom (real PG)', () => {
1282
1369
  });
1283
1370
  });
1284
1371
  describe('ChatService.leaveRoom (real PG)', () => {
1285
- it('refuses to let the sole moderator leave', async () => {
1372
+ it('refuses to let the owner leave', async () => {
1286
1373
  const { svc } = makeService();
1287
1374
  const creatorId = randomUUID();
1288
1375
  const room = await svc.createPrivateRoom({
@@ -1290,7 +1377,40 @@ describe('ChatService.leaveRoom (real PG)', () => {
1290
1377
  name: 'Room',
1291
1378
  ...NO_CLIENT_META,
1292
1379
  });
1293
- await expect(svc.leaveRoom({ userId: creatorId, roomId: room.id, ...NO_CLIENT_META })).rejects.toBeInstanceOf(ChatRoomLastModeratorError);
1380
+ await expect(svc.leaveRoom({ userId: creatorId, roomId: room.id, ...NO_CLIENT_META })).rejects.toBeInstanceOf(ChatRoomOwnerCannotLeaveError);
1381
+ });
1382
+ it('still refuses the owner once a moderator exists to cover the room', async () => {
1383
+ const { svc } = makeService();
1384
+ const ownerId = randomUUID();
1385
+ const room = await svc.createPrivateRoom({
1386
+ userId: ownerId,
1387
+ name: 'Room',
1388
+ ...NO_CLIENT_META,
1389
+ });
1390
+ const memberId = randomUUID();
1391
+ await svc.joinRoom({ userId: memberId, joinCode: room.joinCode, ...NO_CLIENT_META });
1392
+ await svc.setMemberRole({
1393
+ actorId: ownerId,
1394
+ roomId: room.id,
1395
+ userId: memberId,
1396
+ role: 'moderator',
1397
+ ...NO_CLIENT_META,
1398
+ });
1399
+ await expect(svc.leaveRoom({ userId: ownerId, roomId: room.id, ...NO_CLIENT_META })).rejects.toBeInstanceOf(ChatRoomOwnerCannotLeaveError);
1400
+ const [owner] = await db.drizzle.db
1401
+ .select({ userId: chatRoomMember.userId })
1402
+ .from(chatRoomMember)
1403
+ .where(and(eq(chatRoomMember.roomId, room.id), eq(chatRoomMember.userId, ownerId)));
1404
+ expect(owner).toBeDefined();
1405
+ });
1406
+ it('refuses to let the sole moderator of an ownerless room leave', async () => {
1407
+ const { svc } = makeService();
1408
+ const room = await seedRoom({ isPublic: false });
1409
+ const moderatorId = randomUUID();
1410
+ await db.drizzle.db
1411
+ .insert(chatRoomMember)
1412
+ .values({ roomId: room.id, userId: moderatorId, role: 'moderator' });
1413
+ await expect(svc.leaveRoom({ userId: moderatorId, roomId: room.id, ...NO_CLIENT_META })).rejects.toBeInstanceOf(ChatRoomLastModeratorError);
1294
1414
  });
1295
1415
  it('removes a plain member and emits left', async () => {
1296
1416
  const { svc, events } = makeService();
@@ -1310,6 +1430,405 @@ describe('ChatService.leaveRoom (real PG)', () => {
1310
1430
  expect(events.emit).toHaveBeenCalledWith('chat.room.member.left', expect.objectContaining({ roomId: room.id, userId: memberId }));
1311
1431
  });
1312
1432
  });
1433
+ describe('ChatService.setMemberRole (real PG)', () => {
1434
+ function transportWithSignal() {
1435
+ const signal = vi.fn();
1436
+ return { transport: Object.assign(new InProcessRealtimeTransport(), { signal }), signal };
1437
+ }
1438
+ async function roomWithMember(transport) {
1439
+ const { svc, events, audit, transport: bound, } = makeService(undefined, undefined, [], transport);
1440
+ const ownerId = randomUUID();
1441
+ const room = await svc.createPrivateRoom({
1442
+ userId: ownerId,
1443
+ name: 'Room',
1444
+ ...NO_CLIENT_META,
1445
+ });
1446
+ const memberId = randomUUID();
1447
+ await svc.joinRoom({ userId: memberId, joinCode: room.joinCode, ...NO_CLIENT_META });
1448
+ return { svc, events, audit, transport: bound, room, ownerId, memberId };
1449
+ }
1450
+ function readMember(roomId, userId) {
1451
+ return db.drizzle.db
1452
+ .select({ role: chatRoomMember.role, roleAssignedAt: chatRoomMember.roleAssignedAt })
1453
+ .from(chatRoomMember)
1454
+ .where(and(eq(chatRoomMember.roomId, roomId), eq(chatRoomMember.userId, userId)))
1455
+ .limit(1)
1456
+ .then(([row]) => row);
1457
+ }
1458
+ it('lets the owner promote a member, stamping the assignment time', async () => {
1459
+ const { svc, events, room, ownerId, memberId } = await roomWithMember();
1460
+ await expect(svc.setMemberRole({
1461
+ actorId: ownerId,
1462
+ roomId: room.id,
1463
+ userId: memberId,
1464
+ role: 'moderator',
1465
+ ...NO_CLIENT_META,
1466
+ })).resolves.toEqual({ success: true });
1467
+ const member = await readMember(room.id, memberId);
1468
+ expect(member.role).toBe('moderator');
1469
+ expect(member.roleAssignedAt).toBeInstanceOf(Date);
1470
+ expect(events.emit).toHaveBeenCalledWith('chat.room.member.role-changed', expect.objectContaining({
1471
+ roomId: room.id,
1472
+ userId: memberId,
1473
+ changedBy: ownerId,
1474
+ role: 'moderator',
1475
+ previousRole: 'member',
1476
+ }));
1477
+ });
1478
+ it('lets the owner revoke, clearing the assignment time', async () => {
1479
+ const { svc, room, ownerId, memberId } = await roomWithMember();
1480
+ await svc.setMemberRole({
1481
+ actorId: ownerId,
1482
+ roomId: room.id,
1483
+ userId: memberId,
1484
+ role: 'moderator',
1485
+ ...NO_CLIENT_META,
1486
+ });
1487
+ await svc.setMemberRole({
1488
+ actorId: ownerId,
1489
+ roomId: room.id,
1490
+ userId: memberId,
1491
+ role: 'member',
1492
+ ...NO_CLIENT_META,
1493
+ });
1494
+ expect(await readMember(room.id, memberId)).toMatchObject({
1495
+ role: 'member',
1496
+ roleAssignedAt: null,
1497
+ });
1498
+ });
1499
+ it('refuses a promotion by a moderator - granting is owner-only', async () => {
1500
+ const { svc, room, ownerId, memberId } = await roomWithMember();
1501
+ await svc.setMemberRole({
1502
+ actorId: ownerId,
1503
+ roomId: room.id,
1504
+ userId: memberId,
1505
+ role: 'moderator',
1506
+ ...NO_CLIENT_META,
1507
+ });
1508
+ const otherId = randomUUID();
1509
+ await svc.joinRoom({ userId: otherId, joinCode: room.joinCode, ...NO_CLIENT_META });
1510
+ await expect(svc.setMemberRole({
1511
+ actorId: memberId,
1512
+ roomId: room.id,
1513
+ userId: otherId,
1514
+ role: 'moderator',
1515
+ ...NO_CLIENT_META,
1516
+ })).rejects.toBeInstanceOf(ChatRoomNotModeratorError);
1517
+ });
1518
+ it('refuses a promotion by a plain member', async () => {
1519
+ const { svc, room, memberId } = await roomWithMember();
1520
+ const otherId = randomUUID();
1521
+ await svc.joinRoom({ userId: otherId, joinCode: room.joinCode, ...NO_CLIENT_META });
1522
+ await expect(svc.setMemberRole({
1523
+ actorId: memberId,
1524
+ roomId: room.id,
1525
+ userId: otherId,
1526
+ role: 'moderator',
1527
+ ...NO_CLIENT_META,
1528
+ })).rejects.toBeInstanceOf(ChatRoomNotModeratorError);
1529
+ });
1530
+ it('refuses a non-member actor', async () => {
1531
+ const { svc, room, memberId } = await roomWithMember();
1532
+ await expect(svc.setMemberRole({
1533
+ actorId: randomUUID(),
1534
+ roomId: room.id,
1535
+ userId: memberId,
1536
+ role: 'moderator',
1537
+ ...NO_CLIENT_META,
1538
+ })).rejects.toBeInstanceOf(ChatRoomNotMemberError);
1539
+ });
1540
+ it('refuses a target that is not a member of the room', async () => {
1541
+ const { svc, room, ownerId } = await roomWithMember();
1542
+ await expect(svc.setMemberRole({
1543
+ actorId: ownerId,
1544
+ roomId: room.id,
1545
+ userId: randomUUID(),
1546
+ role: 'moderator',
1547
+ ...NO_CLIENT_META,
1548
+ })).rejects.toBeInstanceOf(ChatRoomNotMemberError);
1549
+ });
1550
+ it('refuses an owner targeting themselves', async () => {
1551
+ const { svc, room, ownerId } = await roomWithMember();
1552
+ await expect(svc.setMemberRole({
1553
+ actorId: ownerId,
1554
+ roomId: room.id,
1555
+ userId: ownerId,
1556
+ role: 'member',
1557
+ ...NO_CLIENT_META,
1558
+ })).rejects.toBeInstanceOf(ChatRoomSelfModerationError);
1559
+ });
1560
+ it('refuses a target that is an owner', async () => {
1561
+ const { svc, room, ownerId } = await roomWithMember();
1562
+ const coOwnerId = randomUUID();
1563
+ await db.drizzle.db
1564
+ .insert(chatRoomMember)
1565
+ .values({ roomId: room.id, userId: coOwnerId, role: 'owner' });
1566
+ await expect(svc.setMemberRole({
1567
+ actorId: ownerId,
1568
+ roomId: room.id,
1569
+ userId: coOwnerId,
1570
+ role: 'member',
1571
+ ...NO_CLIENT_META,
1572
+ })).rejects.toBeInstanceOf(ChatRoomNotModeratorError);
1573
+ });
1574
+ it('refuses a soft-deleted room', async () => {
1575
+ const { svc, room, ownerId, memberId } = await roomWithMember();
1576
+ await db.drizzle.db
1577
+ .update(chatRoom)
1578
+ .set({ deletedAt: new Date() })
1579
+ .where(eq(chatRoom.id, room.id));
1580
+ await expect(svc.setMemberRole({
1581
+ actorId: ownerId,
1582
+ roomId: room.id,
1583
+ userId: memberId,
1584
+ role: 'moderator',
1585
+ ...NO_CLIENT_META,
1586
+ })).rejects.toBeInstanceOf(ChatRoomNotFoundError);
1587
+ });
1588
+ it('is idempotent on a repeated promote and a repeated revoke', async () => {
1589
+ const { svc, events, room, ownerId, memberId } = await roomWithMember();
1590
+ const roleChanges = () => events.emit.mock.calls.filter(([topic]) => topic === 'chat.room.member.role-changed').length;
1591
+ await svc.setMemberRole({
1592
+ actorId: ownerId,
1593
+ roomId: room.id,
1594
+ userId: memberId,
1595
+ role: 'moderator',
1596
+ ...NO_CLIENT_META,
1597
+ });
1598
+ await expect(svc.setMemberRole({
1599
+ actorId: ownerId,
1600
+ roomId: room.id,
1601
+ userId: memberId,
1602
+ role: 'moderator',
1603
+ ...NO_CLIENT_META,
1604
+ })).resolves.toEqual({ success: true });
1605
+ expect(await readMember(room.id, memberId)).toMatchObject({ role: 'moderator' });
1606
+ expect(roleChanges()).toBe(1);
1607
+ await svc.setMemberRole({
1608
+ actorId: ownerId,
1609
+ roomId: room.id,
1610
+ userId: memberId,
1611
+ role: 'member',
1612
+ ...NO_CLIENT_META,
1613
+ });
1614
+ await expect(svc.setMemberRole({
1615
+ actorId: ownerId,
1616
+ roomId: room.id,
1617
+ userId: memberId,
1618
+ role: 'member',
1619
+ ...NO_CLIENT_META,
1620
+ })).resolves.toEqual({ success: true });
1621
+ expect(await readMember(room.id, memberId)).toMatchObject({
1622
+ role: 'member',
1623
+ roleAssignedAt: null,
1624
+ });
1625
+ expect(roleChanges()).toBe(2);
1626
+ });
1627
+ it('allows revoking the only moderator - zero moderators is a valid end state', async () => {
1628
+ const { svc, room, ownerId, memberId } = await roomWithMember();
1629
+ await svc.setMemberRole({
1630
+ actorId: ownerId,
1631
+ roomId: room.id,
1632
+ userId: memberId,
1633
+ role: 'moderator',
1634
+ ...NO_CLIENT_META,
1635
+ });
1636
+ await expect(svc.setMemberRole({
1637
+ actorId: ownerId,
1638
+ roomId: room.id,
1639
+ userId: memberId,
1640
+ role: 'member',
1641
+ ...NO_CLIENT_META,
1642
+ })).resolves.toEqual({ success: true });
1643
+ });
1644
+ it('gives a promoted moderator power over plain members only', async () => {
1645
+ const { svc, room, ownerId, memberId } = await roomWithMember();
1646
+ await svc.setMemberRole({
1647
+ actorId: ownerId,
1648
+ roomId: room.id,
1649
+ userId: memberId,
1650
+ role: 'moderator',
1651
+ ...NO_CLIENT_META,
1652
+ });
1653
+ const plainId = randomUUID();
1654
+ const peerId = randomUUID();
1655
+ await svc.joinRoom({ userId: plainId, joinCode: room.joinCode, ...NO_CLIENT_META });
1656
+ await svc.joinRoom({ userId: peerId, joinCode: room.joinCode, ...NO_CLIENT_META });
1657
+ await svc.setMemberRole({
1658
+ actorId: ownerId,
1659
+ roomId: room.id,
1660
+ userId: peerId,
1661
+ role: 'moderator',
1662
+ ...NO_CLIENT_META,
1663
+ });
1664
+ await expect(svc.muteRoomMember({
1665
+ moderatorId: memberId,
1666
+ roomId: room.id,
1667
+ userId: plainId,
1668
+ ...NO_CLIENT_META,
1669
+ })).resolves.toEqual({ success: true });
1670
+ await expect(svc.removeMember({
1671
+ moderatorId: memberId,
1672
+ roomId: room.id,
1673
+ userId: plainId,
1674
+ ...NO_CLIENT_META,
1675
+ })).resolves.toEqual({ success: true });
1676
+ await expect(svc.removeMember({
1677
+ moderatorId: memberId,
1678
+ roomId: room.id,
1679
+ userId: peerId,
1680
+ ...NO_CLIENT_META,
1681
+ })).rejects.toBeInstanceOf(ChatRoomNotModeratorError);
1682
+ await expect(svc.removeMember({
1683
+ moderatorId: memberId,
1684
+ roomId: room.id,
1685
+ userId: ownerId,
1686
+ ...NO_CLIENT_META,
1687
+ })).rejects.toBeInstanceOf(ChatRoomNotModeratorError);
1688
+ });
1689
+ it('signals the room channel on both promotion and revoke', async () => {
1690
+ const { transport, signal } = transportWithSignal();
1691
+ const { svc, room, ownerId, memberId } = await roomWithMember(transport);
1692
+ await svc.setMemberRole({
1693
+ actorId: ownerId,
1694
+ roomId: room.id,
1695
+ userId: memberId,
1696
+ role: 'moderator',
1697
+ ...NO_CLIENT_META,
1698
+ });
1699
+ await svc.setMemberRole({
1700
+ actorId: ownerId,
1701
+ roomId: room.id,
1702
+ userId: memberId,
1703
+ role: 'member',
1704
+ ...NO_CLIENT_META,
1705
+ });
1706
+ expect(signal.mock.calls).toEqual([
1707
+ [
1708
+ chatChannel(room.id),
1709
+ CHAT_MEMBER_ROLE_CHANGED_SIGNAL,
1710
+ { roomId: room.id, userId: memberId, role: 'moderator' },
1711
+ ],
1712
+ [
1713
+ chatChannel(room.id),
1714
+ CHAT_MEMBER_ROLE_CHANGED_SIGNAL,
1715
+ { roomId: room.id, userId: memberId, role: 'member' },
1716
+ ],
1717
+ ]);
1718
+ });
1719
+ it('stays silent when the member already holds the requested role', async () => {
1720
+ const { transport, signal } = transportWithSignal();
1721
+ const { svc, room, ownerId, memberId } = await roomWithMember(transport);
1722
+ await svc.setMemberRole({
1723
+ actorId: ownerId,
1724
+ roomId: room.id,
1725
+ userId: memberId,
1726
+ role: 'member',
1727
+ ...NO_CLIENT_META,
1728
+ });
1729
+ expect(signal).not.toHaveBeenCalled();
1730
+ });
1731
+ it('stays silent when a non-owner is refused the change', async () => {
1732
+ const { transport, signal } = transportWithSignal();
1733
+ const { svc, room, memberId } = await roomWithMember(transport);
1734
+ const otherId = randomUUID();
1735
+ await svc.joinRoom({ userId: otherId, joinCode: room.joinCode, ...NO_CLIENT_META });
1736
+ await expect(svc.setMemberRole({
1737
+ actorId: memberId,
1738
+ roomId: room.id,
1739
+ userId: otherId,
1740
+ role: 'moderator',
1741
+ ...NO_CLIENT_META,
1742
+ })).rejects.toBeInstanceOf(ChatRoomNotModeratorError);
1743
+ expect(signal).not.toHaveBeenCalled();
1744
+ });
1745
+ it('succeeds on a transport that does not implement the signal capability', async () => {
1746
+ const transport = Object.assign(new InProcessRealtimeTransport(), {
1747
+ signal: undefined,
1748
+ });
1749
+ const { svc, room, ownerId, memberId } = await roomWithMember(transport);
1750
+ await expect(svc.setMemberRole({
1751
+ actorId: ownerId,
1752
+ roomId: room.id,
1753
+ userId: memberId,
1754
+ role: 'moderator',
1755
+ ...NO_CLIENT_META,
1756
+ })).resolves.toEqual({ success: true });
1757
+ });
1758
+ it('keeps a committed role change when the signal fan-out rejects', async () => {
1759
+ const transport = Object.assign(new InProcessRealtimeTransport(), {
1760
+ signal: vi.fn().mockRejectedValue(new Error('transport down')),
1761
+ });
1762
+ const { svc, events, room, ownerId, memberId } = await roomWithMember(transport);
1763
+ await expect(svc.setMemberRole({
1764
+ actorId: ownerId,
1765
+ roomId: room.id,
1766
+ userId: memberId,
1767
+ role: 'moderator',
1768
+ ...NO_CLIENT_META,
1769
+ })).resolves.toEqual({ success: true });
1770
+ expect(await readMember(room.id, memberId)).toMatchObject({ role: 'moderator' });
1771
+ expect(events.emit).toHaveBeenCalledWith('chat.room.member.role-changed', expect.objectContaining({ userId: memberId, role: 'moderator' }));
1772
+ });
1773
+ it('refuses to touch roles in a public room', async () => {
1774
+ const { svc } = makeService();
1775
+ const room = await seedRoom({ isPublic: true });
1776
+ const ownerId = randomUUID();
1777
+ const memberId = randomUUID();
1778
+ await db.drizzle.db.insert(chatRoomMember).values([
1779
+ { roomId: room.id, userId: ownerId, role: 'owner' },
1780
+ { roomId: room.id, userId: memberId, role: 'member' },
1781
+ ]);
1782
+ await expect(svc.setMemberRole({
1783
+ actorId: ownerId,
1784
+ roomId: room.id,
1785
+ userId: memberId,
1786
+ role: 'moderator',
1787
+ ...NO_CLIENT_META,
1788
+ })).rejects.toBeInstanceOf(ChatRoomNotFoundError);
1789
+ expect(await readMember(room.id, memberId)).toMatchObject({ role: 'member' });
1790
+ });
1791
+ it('cuts a signals-only subscriber when the member is removed', async () => {
1792
+ const { svc, transport, room, ownerId, memberId } = await roomWithMember();
1793
+ const signals = [];
1794
+ svc.subscribeSignals(room.id, (signal) => signals.push(signal), memberId);
1795
+ await svc.removeMember({
1796
+ moderatorId: ownerId,
1797
+ roomId: room.id,
1798
+ userId: memberId,
1799
+ ...NO_CLIENT_META,
1800
+ });
1801
+ transport.signal?.(chatChannel(room.id), CHAT_MEMBER_ROLE_CHANGED_SIGNAL, {
1802
+ roomId: room.id,
1803
+ userId: randomUUID(),
1804
+ role: 'moderator',
1805
+ });
1806
+ expect(signals).toEqual([]);
1807
+ });
1808
+ it('delivers the signal over the default transport, on its own lane', async () => {
1809
+ const { svc, transport, room, ownerId, memberId } = await roomWithMember();
1810
+ const signals = [];
1811
+ const messages = [];
1812
+ svc.subscribeSignals(room.id, (signal) => signals.push(signal), memberId);
1813
+ svc.subscribeMessages(room.id, (message) => messages.push(message), memberId);
1814
+ await svc.setMemberRole({
1815
+ actorId: ownerId,
1816
+ roomId: room.id,
1817
+ userId: memberId,
1818
+ role: 'moderator',
1819
+ ...NO_CLIENT_META,
1820
+ });
1821
+ expect(signals).toEqual([
1822
+ {
1823
+ name: CHAT_MEMBER_ROLE_CHANGED_SIGNAL,
1824
+ payload: { roomId: room.id, userId: memberId, role: 'moderator' },
1825
+ },
1826
+ ]);
1827
+ expect(messages).toEqual([]);
1828
+ transport.publish(chatChannel(room.id), { type: 'chat.message.sent' });
1829
+ expect(signals).toHaveLength(1);
1830
+ });
1831
+ });
1313
1832
  describe('ChatService moderation (real PG)', () => {
1314
1833
  async function roomWithMember() {
1315
1834
  const { svc, events, audit, moderation, transport } = makeService();
@@ -1668,4 +2187,69 @@ describe('ChatService.verifyRoomAccess and listings (real PG)', () => {
1668
2187
  expect(members).toEqual([expect.objectContaining({ userId: moderatorId, username: null })]);
1669
2188
  });
1670
2189
  });
2190
+ describe('ChatService staff visibility in a room roster (real PG)', () => {
2191
+ async function adminOwnedRoom() {
2192
+ const { svc } = makeService();
2193
+ const admin = await seedUser(db, { name: 'Room Admin', role: 'admin' });
2194
+ const player = await seedUser(db, { name: 'Player One', role: 'player' });
2195
+ const room = await svc.createPrivateRoom({
2196
+ userId: admin.id,
2197
+ name: 'Admin room',
2198
+ ...NO_CLIENT_META,
2199
+ });
2200
+ await svc.joinRoom({ userId: player.id, joinCode: room.joinCode, ...NO_CLIENT_META });
2201
+ return { svc, admin, player, room };
2202
+ }
2203
+ it('shows an admin who owns the room to the players in it', async () => {
2204
+ const { svc, admin, player, room } = await adminOwnedRoom();
2205
+ const asPlayer = await svc.listRoomMembers({ roomId: room.id, viewerId: player.id });
2206
+ expect(asPlayer.map((m) => ({ userId: m.userId, role: m.role }))).toEqual([
2207
+ { userId: admin.id, role: 'owner' },
2208
+ { userId: player.id, role: 'member' },
2209
+ ]);
2210
+ });
2211
+ it('still hides an admin who is only a member', async () => {
2212
+ const { svc, admin, player, room } = await adminOwnedRoom();
2213
+ const staffMember = await seedUser(db, { name: 'Support', role: 'super-admin' });
2214
+ await svc.joinRoom({ userId: staffMember.id, joinCode: room.joinCode, ...NO_CLIENT_META });
2215
+ const asPlayer = await svc.listRoomMembers({ roomId: room.id, viewerId: player.id });
2216
+ const asAdmin = await svc.listRoomMembers({ roomId: room.id, viewerId: admin.id });
2217
+ expect(asPlayer.map((m) => m.userId)).toEqual([admin.id, player.id]);
2218
+ expect(asAdmin.map((m) => m.userId)).toEqual([admin.id, player.id, staffMember.id]);
2219
+ });
2220
+ it('shows the owner in the moderator roster too', async () => {
2221
+ const { svc, admin, player, room } = await adminOwnedRoom();
2222
+ const staffMember = await seedUser(db, { name: 'Support', role: 'admin' });
2223
+ await svc.joinRoom({ userId: staffMember.id, joinCode: room.joinCode, ...NO_CLIENT_META });
2224
+ await svc.setMemberRole({
2225
+ actorId: admin.id,
2226
+ roomId: room.id,
2227
+ userId: player.id,
2228
+ role: 'moderator',
2229
+ ...NO_CLIENT_META,
2230
+ });
2231
+ const asModerator = await svc.listRoomUsers({
2232
+ roomId: room.id,
2233
+ actorId: player.id,
2234
+ status: 'all',
2235
+ });
2236
+ expect(asModerator.map((m) => m.userId)).toEqual([admin.id, player.id]);
2237
+ });
2238
+ it('keeps an owner-only filter free of the staff members it excludes', async () => {
2239
+ const { svc, admin, player, room } = await adminOwnedRoom();
2240
+ await svc.setMemberRole({
2241
+ actorId: admin.id,
2242
+ roomId: room.id,
2243
+ userId: player.id,
2244
+ role: 'moderator',
2245
+ ...NO_CLIENT_META,
2246
+ });
2247
+ const owners = await svc.listRoomUsers({
2248
+ roomId: room.id,
2249
+ actorId: player.id,
2250
+ status: 'owner',
2251
+ });
2252
+ expect(owners.map((m) => m.userId)).toEqual([admin.id]);
2253
+ });
2254
+ });
1671
2255
  //# sourceMappingURL=chat.service.int.test.js.map