@zooid/transport-matrix 0.7.1 → 0.7.3

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.
@@ -5,6 +5,7 @@ import type { AgentBinding } from './router.js'
5
5
  function fakeClient() {
6
6
  return {
7
7
  registerBot: vi.fn(async () => undefined),
8
+ invite: vi.fn(async () => undefined),
8
9
  joinRoom: vi.fn(async () => undefined),
9
10
  resolveAlias: vi.fn(async (_a: string) => '!existing:example.com' as string | null),
10
11
  createRoom: vi.fn(async () => '!new:example.com'),
@@ -18,13 +19,13 @@ const agents: AgentBinding[] = [
18
19
  {
19
20
  name: 'architect',
20
21
  userId: '@architect:example.com',
21
- rooms: ['!r1:example.com', '!r2:example.com'],
22
+ rooms: [{ alias: '!r1:example.com' }, { alias: '!r2:example.com' }],
22
23
  trigger: 'mention',
23
24
  },
24
25
  {
25
26
  name: 'monitor',
26
27
  userId: '@monitor:example.com',
27
- rooms: ['!alerts:example.com'],
28
+ rooms: [{ alias: '!alerts:example.com' }],
28
29
  trigger: 'any',
29
30
  },
30
31
  ]
@@ -90,7 +91,12 @@ describe('BotPool.bootstrap — create-if-missing rooms', () => {
90
91
  },
91
92
  }
92
93
  const pool = new BotPool(client as never, [
93
- { name: 'echo', userId: '@echo:localhost', rooms: ['#welcome:localhost'], trigger: 'mention' },
94
+ {
95
+ name: 'echo',
96
+ userId: '@echo:localhost',
97
+ rooms: [{ alias: '#welcome:localhost' }],
98
+ trigger: 'mention',
99
+ },
94
100
  ])
95
101
  await pool.bootstrap({ adminUserId: '@admin:localhost' })
96
102
 
@@ -115,13 +121,18 @@ describe('BotPool.bootstrap — create-if-missing rooms', () => {
115
121
  },
116
122
  }
117
123
  const pool = new BotPool(client as never, [
118
- { name: 'echo', userId: '@echo:localhost', rooms: ['#welcome:localhost'], trigger: 'mention' },
124
+ {
125
+ name: 'echo',
126
+ userId: '@echo:localhost',
127
+ rooms: [{ alias: '#welcome:localhost' }],
128
+ trigger: 'mention',
129
+ },
119
130
  ])
120
131
  await pool.bootstrap({ adminUserId: '@admin:localhost' })
121
132
  expect(calls).toEqual(['join:@echo:localhost->!existing:localhost'])
122
133
  })
123
134
 
124
- it('rewrites the binding\'s rooms array with resolved IDs so the router can match', async () => {
135
+ it("rewrites the binding's rooms array with resolved IDs so the router can match", async () => {
125
136
  const client = {
126
137
  registerBot: async () => {},
127
138
  setDisplayName: async () => {},
@@ -132,12 +143,12 @@ describe('BotPool.bootstrap — create-if-missing rooms', () => {
132
143
  const binding: AgentBinding = {
133
144
  name: 'echo',
134
145
  userId: '@echo:localhost',
135
- rooms: ['#welcome:localhost'],
146
+ rooms: [{ alias: '#welcome:localhost' }],
136
147
  trigger: 'mention',
137
148
  }
138
149
  const pool = new BotPool(client as never, [binding])
139
150
  await pool.bootstrap({ adminUserId: '@admin:localhost' })
140
- expect(binding.rooms).toEqual(['!resolved:localhost'])
151
+ expect(binding.rooms).toEqual([{ alias: '!resolved:localhost' }])
141
152
  })
142
153
 
143
154
  it('passes through room IDs (starting with !) without resolving or creating', async () => {
@@ -156,7 +167,12 @@ describe('BotPool.bootstrap — create-if-missing rooms', () => {
156
167
  },
157
168
  }
158
169
  const pool = new BotPool(client as never, [
159
- { name: 'echo', userId: '@echo:localhost', rooms: ['!abc:localhost'], trigger: 'mention' },
170
+ {
171
+ name: 'echo',
172
+ userId: '@echo:localhost',
173
+ rooms: [{ alias: '!abc:localhost' }],
174
+ trigger: 'mention',
175
+ },
160
176
  ])
161
177
  await pool.bootstrap({ adminUserId: '@admin:localhost' })
162
178
  expect(calls).toEqual(['join:@echo:localhost->!abc:localhost'])
@@ -176,6 +192,7 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
176
192
  const pool = new BotPool(
177
193
  {
178
194
  registerBot,
195
+ invite: vi.fn(async () => undefined),
179
196
  joinRoom,
180
197
  resolveAlias,
181
198
  createRoom,
@@ -186,7 +203,7 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
186
203
  {
187
204
  name: 'planner',
188
205
  userId: '@planner:zoon.local',
189
- rooms: ['#welcome:zoon.local'],
206
+ rooms: [{ alias: '#welcome:zoon.local' }],
190
207
  trigger: 'mention',
191
208
  },
192
209
  ],
@@ -209,11 +226,39 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
209
226
  )
210
227
  })
211
228
 
229
+ it('creates agent rooms restricted to the workforce space when spaceRoomId is set', async () => {
230
+ const createRoom = vi.fn(async () => '!created:zoon.local')
231
+ const pool = new BotPool(
232
+ {
233
+ registerBot: vi.fn(async () => undefined),
234
+ invite: vi.fn(async () => undefined),
235
+ joinRoom: vi.fn(async () => undefined),
236
+ resolveAlias: vi.fn(async () => null), // alias unknown → must create
237
+ createRoom,
238
+ sendStateEvent: vi.fn(async () => ({ event_id: '$ev' })),
239
+ setDisplayName: vi.fn(async () => undefined),
240
+ } as never,
241
+ [
242
+ {
243
+ name: 'planner',
244
+ userId: '@planner:zoon.local',
245
+ rooms: [{ alias: '#design:zoon.local' }],
246
+ trigger: 'mention',
247
+ },
248
+ ],
249
+ )
250
+ await pool.bootstrap({ spaceRoomId: '!space:zoon.local', asUserId: '@zooid:zoon.local' })
251
+ expect(createRoom).toHaveBeenCalledWith(
252
+ expect.objectContaining({ roomAliasName: 'design', restrictedToSpaceId: '!space:zoon.local' }),
253
+ )
254
+ })
255
+
212
256
  it('does nothing extra when spaceRoomId is omitted', async () => {
213
257
  const sendStateEvent = vi.fn(async () => ({ event_id: '$ev' }))
214
258
  const pool = new BotPool(
215
259
  {
216
260
  registerBot: vi.fn(async () => undefined),
261
+ invite: vi.fn(async () => undefined),
217
262
  joinRoom: vi.fn(async () => undefined),
218
263
  resolveAlias: vi.fn(async () => '!r:zoon.local'),
219
264
  createRoom: vi.fn(async () => '!r:zoon.local'),
@@ -224,7 +269,7 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
224
269
  {
225
270
  name: 'planner',
226
271
  userId: '@planner:zoon.local',
227
- rooms: ['#r:zoon.local'],
272
+ rooms: [{ alias: '#r:zoon.local' }],
228
273
  trigger: 'mention',
229
274
  },
230
275
  ],
@@ -238,6 +283,7 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
238
283
  const pool = new BotPool(
239
284
  {
240
285
  registerBot: vi.fn(async () => undefined),
286
+ invite: vi.fn(async () => undefined),
241
287
  joinRoom: vi.fn(async () => undefined),
242
288
  resolveAlias: vi.fn(async () => '!shared:zoon.local'),
243
289
  createRoom: vi.fn(async () => '!shared:zoon.local'),
@@ -245,8 +291,8 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
245
291
  setDisplayName: vi.fn(async () => undefined),
246
292
  } as never,
247
293
  [
248
- { name: 'a', userId: '@a:zoon.local', rooms: ['#shared:zoon.local'], trigger: 'any' },
249
- { name: 'b', userId: '@b:zoon.local', rooms: ['#shared:zoon.local'], trigger: 'any' },
294
+ { name: 'a', userId: '@a:zoon.local', rooms: [{ alias: '#shared:zoon.local' }], trigger: 'any' },
295
+ { name: 'b', userId: '@b:zoon.local', rooms: [{ alias: '#shared:zoon.local' }], trigger: 'any' },
250
296
  ],
251
297
  )
252
298
  await pool.bootstrap({ spaceRoomId: '!space:zoon.local', asUserId: '@zooid:zoon.local' })
@@ -257,6 +303,142 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
257
303
  })
258
304
  })
259
305
 
306
+ describe('BotPool.bootstrap agent space membership', () => {
307
+ it('invites the agent to the space (via the AS bot) then joins it as the agent', async () => {
308
+ const invite = vi.fn(async () => undefined)
309
+ const joinRoom = vi.fn(async () => undefined)
310
+ const pool = new BotPool(
311
+ {
312
+ registerBot: vi.fn(async () => undefined),
313
+ invite,
314
+ joinRoom,
315
+ resolveAlias: vi.fn(async () => '!r:zoon.local'),
316
+ createRoom: vi.fn(async () => '!r:zoon.local'),
317
+ sendStateEvent: vi.fn(async () => ({ event_id: '$ev' })),
318
+ setDisplayName: vi.fn(async () => undefined),
319
+ } as never,
320
+ [
321
+ {
322
+ name: 'planner',
323
+ userId: '@planner:zoon.local',
324
+ rooms: [{ alias: '#r:zoon.local' }],
325
+ trigger: 'mention',
326
+ },
327
+ ],
328
+ )
329
+ await pool.bootstrap({
330
+ spaceRoomId: '!space:zoon.local',
331
+ asUserId: '@zooid:zoon.local',
332
+ })
333
+ expect(invite).toHaveBeenCalledWith({
334
+ roomId: '!space:zoon.local',
335
+ asUserId: '@zooid:zoon.local',
336
+ targetUserId: '@planner:zoon.local',
337
+ })
338
+ expect(joinRoom).toHaveBeenCalledWith('!space:zoon.local', '@planner:zoon.local')
339
+ })
340
+
341
+ it('skips space invite+join when spaceRoomId is not provided', async () => {
342
+ const invite = vi.fn(async () => undefined)
343
+ const pool = new BotPool(
344
+ {
345
+ registerBot: vi.fn(async () => undefined),
346
+ invite,
347
+ joinRoom: vi.fn(async () => undefined),
348
+ resolveAlias: vi.fn(async () => '!r:zoon.local'),
349
+ createRoom: vi.fn(async () => '!r:zoon.local'),
350
+ sendStateEvent: vi.fn(async () => ({ event_id: '$ev' })),
351
+ setDisplayName: vi.fn(async () => undefined),
352
+ } as never,
353
+ [
354
+ {
355
+ name: 'planner',
356
+ userId: '@planner:zoon.local',
357
+ rooms: [{ alias: '#r:zoon.local' }],
358
+ trigger: 'mention',
359
+ },
360
+ ],
361
+ )
362
+ await pool.bootstrap({})
363
+ expect(invite).not.toHaveBeenCalled()
364
+ })
365
+ })
366
+
367
+ describe('BotPool.bootstrap creation-time power levels', () => {
368
+ it('merges admin (PL 100) and per-agent declared PLs into createRoom userPowerLevels', async () => {
369
+ const createRoom = vi.fn(async () => '!created:zoon.local')
370
+ const pool = new BotPool(
371
+ {
372
+ registerBot: vi.fn(async () => undefined),
373
+ invite: vi.fn(async () => undefined),
374
+ joinRoom: vi.fn(async () => undefined),
375
+ resolveAlias: vi.fn(async () => null), // force create
376
+ createRoom,
377
+ sendStateEvent: vi.fn(async () => ({ event_id: '$ev' })),
378
+ setDisplayName: vi.fn(async () => undefined),
379
+ } as never,
380
+ [
381
+ {
382
+ name: 'planner',
383
+ userId: '@planner:zoon.local',
384
+ rooms: [{ alias: '#design:zoon.local' }], // default PL
385
+ trigger: 'mention',
386
+ },
387
+ {
388
+ name: 'mod',
389
+ userId: '@mod:zoon.local',
390
+ rooms: [{ alias: '#design:zoon.local', powerLevel: 50 }], // moderator
391
+ trigger: 'mention',
392
+ },
393
+ ],
394
+ )
395
+ await pool.bootstrap({
396
+ asUserId: '@zooid:zoon.local',
397
+ spaceRoomId: '!space:zoon.local',
398
+ adminUserIds: ['@admin:zoon.local'],
399
+ })
400
+
401
+ // First createRoom carries the merged map:
402
+ // bot @ 100, admin @ 100, mod @ 50 (planner has no declared PL → absent)
403
+ const firstCall = createRoom.mock.calls[0]?.[0] as {
404
+ userPowerLevels?: Record<string, number>
405
+ }
406
+ expect(firstCall.userPowerLevels).toEqual({
407
+ '@zooid:zoon.local': 100,
408
+ '@admin:zoon.local': 100,
409
+ '@mod:zoon.local': 50,
410
+ })
411
+ })
412
+
413
+ it('omits userPowerLevels when no admin and no agent declares a PL for the room', async () => {
414
+ const createRoom = vi.fn(async () => '!r:zoon.local')
415
+ const pool = new BotPool(
416
+ {
417
+ registerBot: vi.fn(async () => undefined),
418
+ invite: vi.fn(async () => undefined),
419
+ joinRoom: vi.fn(async () => undefined),
420
+ resolveAlias: vi.fn(async () => null),
421
+ createRoom,
422
+ sendStateEvent: vi.fn(async () => ({ event_id: '$ev' })),
423
+ setDisplayName: vi.fn(async () => undefined),
424
+ } as never,
425
+ [
426
+ {
427
+ name: 'plain',
428
+ userId: '@plain:zoon.local',
429
+ rooms: [{ alias: '#plain:zoon.local' }],
430
+ trigger: 'mention',
431
+ },
432
+ ],
433
+ )
434
+ await pool.bootstrap({ spaceRoomId: '!space:zoon.local' })
435
+ const opts = createRoom.mock.calls[0]?.[0] as {
436
+ userPowerLevels?: Record<string, number>
437
+ }
438
+ expect(opts.userPowerLevels).toBeUndefined()
439
+ })
440
+ })
441
+
260
442
  describe('BotPool.bootstrap — display name', () => {
261
443
  it('falls back to the user_id localpart when no displayName is supplied', async () => {
262
444
  const client = fakeClient()
@@ -297,7 +479,12 @@ describe('BotPool.bootstrap — display name', () => {
297
479
  setDisplayName: vi.fn(async () => undefined),
298
480
  }
299
481
  const pool = new BotPool(client as never, [
300
- { name: 'echo', userId: '@echo:localhost', rooms: ['#welcome:localhost'], trigger: 'mention' },
482
+ {
483
+ name: 'echo',
484
+ userId: '@echo:localhost',
485
+ rooms: [{ alias: '#welcome:localhost' }],
486
+ trigger: 'mention',
487
+ },
301
488
  ])
302
489
  await pool.bootstrap({ adminUserId: '@admin:localhost' })
303
490
  expect(createRoom).toHaveBeenCalledWith(
package/src/bot-pool.ts CHANGED
@@ -9,13 +9,25 @@ export interface BootstrapOpts {
9
9
  spaceRoomId?: string
10
10
  /** AS bot user ID. Required when spaceRoomId is set; sender of the m.space.child write. */
11
11
  asUserId?: string
12
+ /**
13
+ * Operator MXIDs seeded at PL 100 in every agent room this pool creates.
14
+ * Applied via `power_level_content_override.users` at room creation only —
15
+ * never reconciled. Empty/absent = no operator entries.
16
+ */
17
+ adminUserIds?: string[]
12
18
  }
13
19
 
14
20
  export class BotPool {
15
21
  constructor(
16
22
  private readonly client: Pick<
17
23
  MatrixClient,
18
- 'registerBot' | 'joinRoom' | 'resolveAlias' | 'createRoom' | 'sendStateEvent' | 'setDisplayName'
24
+ | 'registerBot'
25
+ | 'invite'
26
+ | 'joinRoom'
27
+ | 'resolveAlias'
28
+ | 'createRoom'
29
+ | 'sendStateEvent'
30
+ | 'setDisplayName'
19
31
  >,
20
32
  private readonly agents: AgentBinding[],
21
33
  ) {}
@@ -35,8 +47,28 @@ export class BotPool {
35
47
  } catch (err) {
36
48
  console.warn(`[matrix] setDisplayName(${a.userId}) failed: ${(err as Error).message}`)
37
49
  }
50
+ // Make each agent a member of the workforce space. That covers two
51
+ // things at once: the eco.zoon.workforce roster is now backed by
52
+ // actual space membership (so the Zoon client's member autocomplete
53
+ // works across rooms), and every restricted child room's allow rule
54
+ // is satisfied without per-room invites.
55
+ if (opts.spaceRoomId && opts.asUserId) {
56
+ try {
57
+ await this.client.invite({
58
+ roomId: opts.spaceRoomId,
59
+ asUserId: opts.asUserId,
60
+ targetUserId: a.userId,
61
+ })
62
+ await this.client.joinRoom(opts.spaceRoomId, a.userId)
63
+ } catch (err) {
64
+ console.warn(
65
+ `[matrix] space membership for ${a.userId} failed: ${(err as Error).message}`,
66
+ )
67
+ }
68
+ }
38
69
  for (let i = 0; i < a.rooms.length; i++) {
39
- const room = a.rooms[i]
70
+ const binding = a.rooms[i]!
71
+ const room = binding.alias
40
72
  try {
41
73
  let resolved = room
42
74
  if (room.startsWith('#')) {
@@ -51,19 +83,29 @@ export class BotPool {
51
83
  const colon = room.indexOf(':')
52
84
  const aliasLocalpart = colon > 1 ? room.slice(1, colon) : room.slice(1)
53
85
  const sender = opts.adminUserId ?? a.userId
86
+ const userPowerLevels = buildUserPowerLevels(
87
+ opts.asUserId,
88
+ opts.adminUserIds,
89
+ this.agents,
90
+ room,
91
+ )
54
92
  resolved = await this.client.createRoom({
55
93
  roomAliasName: aliasLocalpart,
56
94
  invite: opts.adminUserId ? [opts.adminUserId] : [],
57
95
  senderUserId: sender,
58
96
  name: aliasLocalpart,
97
+ ...(opts.spaceRoomId ? { restrictedToSpaceId: opts.spaceRoomId } : {}),
98
+ ...(userPowerLevels ? { userPowerLevels } : {}),
59
99
  })
60
100
  }
61
101
  aliasToId.set(room, resolved)
62
102
  }
63
103
  }
64
- // Store the canonical room_id on the binding so the router (which
65
- // matches on event.room_id) sees a hit when Tuwunel pushes events.
66
- a.rooms[i] = resolved
104
+ // Rewrite the binding's alias to the canonical room ID so the
105
+ // router (which matches on event.room_id) sees a hit when Tuwunel
106
+ // pushes events. The declared powerLevel was a creation-time
107
+ // seed — it has no role after bootstrap.
108
+ binding.alias = resolved
67
109
  await this.client.joinRoom(resolved, a.userId)
68
110
 
69
111
  if (
@@ -110,3 +152,29 @@ function localpart(userId: string): string {
110
152
  if (!m) throw new Error(`bad user id: ${userId}`)
111
153
  return m[1]
112
154
  }
155
+
156
+ /**
157
+ * Build the `userPowerLevels` map for a room about to be created. Always
158
+ * seeds the AS bot (when known) and any operator admins at PL 100; layers
159
+ * per-agent declared PLs on top so agents land at moderator (or whatever
160
+ * they declared) without a follow-up state write. Returns undefined when
161
+ * the map would be empty — `createRoom` then omits the override entirely
162
+ * and the preset's defaults apply.
163
+ */
164
+ function buildUserPowerLevels(
165
+ asUserId: string | undefined,
166
+ admins: string[] | undefined,
167
+ agents: AgentBinding[],
168
+ roomAlias: string,
169
+ ): Record<string, number> | undefined {
170
+ const users: Record<string, number> = {}
171
+ if (asUserId) users[asUserId] = 100
172
+ if (admins) for (const a of admins) users[a] = 100
173
+ for (const a of agents) {
174
+ for (const r of a.rooms) {
175
+ if (r.alias !== roomAlias) continue
176
+ if (r.powerLevel !== undefined) users[a.userId] = r.powerLevel
177
+ }
178
+ }
179
+ return Object.keys(users).length > 0 ? users : undefined
180
+ }
package/src/index.ts CHANGED
@@ -11,8 +11,8 @@ export type { AgentBinding, RouteMatch } from './router.js'
11
11
  export { BotPool } from './bot-pool.js'
12
12
  export { createMatrixTransport } from './transport.js'
13
13
  export type { CreateMatrixTransportOptions } from './transport.js'
14
- export { ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
15
- export type { EnsureSpaceOpts } from './space-provisioner.js'
14
+ export { ensureDefaultChannel, ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
15
+ export type { EnsureDefaultChannelOpts, EnsureSpaceOpts } from './space-provisioner.js'
16
16
  export {
17
17
  buildWorkforceRoster,
18
18
  publishWorkforce,
@@ -305,3 +305,187 @@ describe('MatrixClient', () => {
305
305
  })
306
306
  })
307
307
  })
308
+
309
+ describe('MatrixClient.invite', () => {
310
+ it('POSTs to /invite with the user_id payload, impersonating the inviter', async () => {
311
+ const fetch = fakeFetch(async ({ url, init }) => {
312
+ expect(url).toBe(
313
+ 'https://hs.example.com/_matrix/client/v3/rooms/!space%3Aexample.com/invite' +
314
+ '?user_id=%40zooid%3Aexample.com',
315
+ )
316
+ expect(init.method).toBe('POST')
317
+ expect(JSON.parse(init.body as string)).toEqual({ user_id: '@planner:example.com' })
318
+ return new Response('{}', { status: 200 })
319
+ })
320
+ const client = new MatrixClient({
321
+ homeserver: 'https://hs.example.com',
322
+ asToken: 'as',
323
+ fetch: fetch as unknown as typeof globalThis.fetch,
324
+ })
325
+ await client.invite({
326
+ roomId: '!space:example.com',
327
+ asUserId: '@zooid:example.com',
328
+ targetUserId: '@planner:example.com',
329
+ })
330
+ })
331
+
332
+ it('silently swallows Tuwunel\'s "cannot invite user that is joined or banned" 403', async () => {
333
+ const fetch = fakeFetch(async () =>
334
+ new Response(
335
+ JSON.stringify({
336
+ errcode: 'M_FORBIDDEN',
337
+ error: 'Auth check failed: cannot invite user that is joined or banned',
338
+ }),
339
+ { status: 403 },
340
+ ),
341
+ )
342
+ const client = new MatrixClient({
343
+ homeserver: 'https://hs.example.com',
344
+ asToken: 'as',
345
+ fetch: fetch as unknown as typeof globalThis.fetch,
346
+ })
347
+ await expect(
348
+ client.invite({
349
+ roomId: '!r:example.com',
350
+ asUserId: '@zooid:example.com',
351
+ targetUserId: '@planner:example.com',
352
+ }),
353
+ ).resolves.toBeUndefined()
354
+ })
355
+
356
+ it('silently swallows the idempotent "already in the room" 403', async () => {
357
+ const fetch = fakeFetch(async () =>
358
+ new Response(
359
+ JSON.stringify({ errcode: 'M_FORBIDDEN', error: 'User is already in the room' }),
360
+ { status: 403 },
361
+ ),
362
+ )
363
+ const client = new MatrixClient({
364
+ homeserver: 'https://hs.example.com',
365
+ asToken: 'as',
366
+ fetch: fetch as unknown as typeof globalThis.fetch,
367
+ })
368
+ await expect(
369
+ client.invite({
370
+ roomId: '!r:example.com',
371
+ asUserId: '@zooid:example.com',
372
+ targetUserId: '@planner:example.com',
373
+ }),
374
+ ).resolves.toBeUndefined()
375
+ })
376
+
377
+ it('rethrows a real permission 403 (no idempotency phrase in body)', async () => {
378
+ const fetch = fakeFetch(async () =>
379
+ new Response(
380
+ JSON.stringify({ errcode: 'M_FORBIDDEN', error: 'You do not have power to invite' }),
381
+ { status: 403 },
382
+ ),
383
+ )
384
+ const client = new MatrixClient({
385
+ homeserver: 'https://hs.example.com',
386
+ asToken: 'as',
387
+ fetch: fetch as unknown as typeof globalThis.fetch,
388
+ })
389
+ await expect(
390
+ client.invite({
391
+ roomId: '!r:example.com',
392
+ asUserId: '@zooid:example.com',
393
+ targetUserId: '@planner:example.com',
394
+ }),
395
+ ).rejects.toThrow(/invite/)
396
+ })
397
+ })
398
+
399
+ describe('MatrixClient.createRoom userPowerLevels', () => {
400
+ it('forwards userPowerLevels into power_level_content_override.users', async () => {
401
+ const fetch = fakeFetch(async ({ init }) => {
402
+ const body = JSON.parse(init.body as string)
403
+ expect(body.power_level_content_override).toEqual({
404
+ users: {
405
+ '@zooid:example.com': 100,
406
+ '@admin:example.com': 100,
407
+ '@mod:example.com': 50,
408
+ },
409
+ })
410
+ return new Response(JSON.stringify({ room_id: '!r:example.com' }), { status: 200 })
411
+ })
412
+ const client = new MatrixClient({
413
+ homeserver: 'https://hs.example.com',
414
+ asToken: 'as',
415
+ fetch: fetch as unknown as typeof globalThis.fetch,
416
+ })
417
+ await client.createRoom({
418
+ roomAliasName: 'x',
419
+ invite: [],
420
+ senderUserId: '@zooid:example.com',
421
+ userPowerLevels: {
422
+ '@zooid:example.com': 100,
423
+ '@admin:example.com': 100,
424
+ '@mod:example.com': 50,
425
+ },
426
+ })
427
+ })
428
+
429
+ it('omits power_level_content_override when userPowerLevels is empty or absent', async () => {
430
+ const fetch = fakeFetch(async ({ init }) => {
431
+ const body = JSON.parse(init.body as string)
432
+ expect(body.power_level_content_override).toBeUndefined()
433
+ return new Response(JSON.stringify({ room_id: '!r:example.com' }), { status: 200 })
434
+ })
435
+ const client = new MatrixClient({
436
+ homeserver: 'https://hs.example.com',
437
+ asToken: 'as',
438
+ fetch: fetch as unknown as typeof globalThis.fetch,
439
+ })
440
+ await client.createRoom({
441
+ roomAliasName: 'x',
442
+ invite: [],
443
+ senderUserId: '@zooid:example.com',
444
+ })
445
+ })
446
+ })
447
+
448
+ describe('MatrixClient.createRoom restricted', () => {
449
+ it('injects a restricted join rule referencing the space when restrictedToSpaceId is set', async () => {
450
+ const fetch = fakeFetch(async ({ url, init }) => {
451
+ expect(url).toContain('/_matrix/client/v3/createRoom')
452
+ const body = JSON.parse(init.body as string)
453
+ expect(body.room_alias_name).toBe('design')
454
+ expect(body.initial_state).toContainEqual({
455
+ type: 'm.room.join_rules',
456
+ state_key: '',
457
+ content: {
458
+ join_rule: 'restricted',
459
+ allow: [{ type: 'm.room_membership', room_id: '!space:example.com' }],
460
+ },
461
+ })
462
+ return new Response(JSON.stringify({ room_id: '!new:example.com' }), { status: 200 })
463
+ })
464
+ const client = new MatrixClient({
465
+ homeserver: 'https://hs.example.com',
466
+ asToken: 'as-secret',
467
+ fetch: fetch as unknown as typeof globalThis.fetch,
468
+ })
469
+ const id = await client.createRoom({
470
+ roomAliasName: 'design',
471
+ invite: [],
472
+ senderUserId: '@architect:example.com',
473
+ restrictedToSpaceId: '!space:example.com',
474
+ })
475
+ expect(id).toBe('!new:example.com')
476
+ })
477
+
478
+ it('omits the restricted join rule when no space is given (public room, unchanged)', async () => {
479
+ const fetch = fakeFetch(async ({ init }) => {
480
+ const body = JSON.parse(init.body as string)
481
+ expect(body.initial_state).toBeUndefined()
482
+ return new Response(JSON.stringify({ room_id: '!r:example.com' }), { status: 200 })
483
+ })
484
+ const client = new MatrixClient({
485
+ homeserver: 'https://hs.example.com',
486
+ asToken: 'as-secret',
487
+ fetch: fetch as unknown as typeof globalThis.fetch,
488
+ })
489
+ await client.createRoom({ roomAliasName: 'x', invite: [], senderUserId: '@a:example.com' })
490
+ })
491
+ })