@zooid/transport-matrix 0.9.0 → 0.9.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zooid/transport-matrix",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Matrix Application Service transport for zooid. Routes inbound Matrix messages to ACP agents and posts replies plus approval custom events back to threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,8 +28,8 @@
28
28
  "marked": "^18.0.4",
29
29
  "sanitize-html": "^2.17.4",
30
30
  "yaml": "^2.5.0",
31
- "@zooid/core": "^0.9.0",
32
- "@zooid/acp-client": "^0.9.0"
31
+ "@zooid/acp-client": "^0.9.1",
32
+ "@zooid/core": "^0.9.1"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@hono/node-server": "^1.13.0",
@@ -248,8 +248,14 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
248
248
  ],
249
249
  )
250
250
  await pool.bootstrap({ spaceRoomId: '!space:zoon.local', asUserId: '@zooid:zoon.local' })
251
+ // Created BY the AS sender bot (room owner/admin), never by the agent —
252
+ // agents join as plain members, never room admins.
251
253
  expect(createRoom).toHaveBeenCalledWith(
252
- expect.objectContaining({ roomAliasName: 'design', restrictedToSpaceId: '!space:zoon.local' }),
254
+ expect.objectContaining({
255
+ roomAliasName: 'design',
256
+ restrictedToSpaceId: '!space:zoon.local',
257
+ senderUserId: '@zooid:zoon.local',
258
+ }),
253
259
  )
254
260
  })
255
261
 
package/src/bot-pool.ts CHANGED
@@ -82,7 +82,13 @@ export class BotPool {
82
82
  } else {
83
83
  const colon = room.indexOf(':')
84
84
  const aliasLocalpart = colon > 1 ? room.slice(1, colon) : room.slice(1)
85
- const sender = opts.adminUserId ?? a.userId
85
+ // Workstation-created rooms are owned by the AS sender bot (PL
86
+ // 100, already seeded in the power override below). Agents join
87
+ // as plain members and must never be room admins. The human
88
+ // operator is invited + seeded at PL 100, but can't be the
89
+ // creator under a workstation-scoped namespace — the AS token
90
+ // can't impersonate a non-namespaced human.
91
+ const sender = opts.asUserId ?? opts.adminUserId ?? a.userId
86
92
  const userPowerLevels = buildUserPowerLevels(
87
93
  opts.asUserId,
88
94
  opts.adminUserIds,
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ isValidWorkstation,
4
+ isValidAgentKey,
5
+ agentMxid,
6
+ splitAgentLocalpart,
7
+ workstationUserNamespace,
8
+ } from './identity.js'
9
+
10
+ describe('workstation / agent grammar', () => {
11
+ it('accepts dot-free lowercase-alnum-hyphen', () => {
12
+ expect(isValidWorkstation('laptop')).toBe(true)
13
+ expect(isValidWorkstation('ec2-prod')).toBe(true)
14
+ expect(isValidAgentKey('docs')).toBe(true)
15
+ })
16
+ it('rejects dots (the workstation/agent separator) and bad chars', () => {
17
+ expect(isValidWorkstation('lap.top')).toBe(false) // dot is the boundary, never inside
18
+ expect(isValidWorkstation('Laptop')).toBe(false) // strict ASCII lowercase
19
+ expect(isValidWorkstation('lap_top')).toBe(false)
20
+ expect(isValidWorkstation('')).toBe(false)
21
+ expect(isValidAgentKey('a.b')).toBe(false)
22
+ })
23
+ })
24
+
25
+ describe('agentMxid', () => {
26
+ it('builds @{workstation}.{agent}:server', () => {
27
+ expect(agentMxid('laptop', 'assistant', 'zoon.eco')).toBe('@laptop.assistant:zoon.eco')
28
+ })
29
+ it('throws on invalid parts', () => {
30
+ expect(() => agentMxid('lap.top', 'assistant', 'zoon.eco')).toThrow()
31
+ expect(() => agentMxid('laptop', 'a.b', 'zoon.eco')).toThrow()
32
+ })
33
+ })
34
+
35
+ describe('splitAgentLocalpart — split on FIRST dot', () => {
36
+ it('recovers (workstation, agent)', () => {
37
+ expect(splitAgentLocalpart('laptop.assistant')).toEqual({
38
+ workstation: 'laptop',
39
+ agent: 'assistant',
40
+ })
41
+ })
42
+ it('throws when there is no separator', () => {
43
+ expect(() => splitAgentLocalpart('zooid')).toThrow()
44
+ })
45
+ })
46
+
47
+ describe('workstationUserNamespace', () => {
48
+ it('emits an escaped-dot exclusive regex for the workstation', () => {
49
+ // The literal dot must be escaped so @{workstation}.* does not also match @{workstation}X*
50
+ expect(workstationUserNamespace('laptop', 'zoon.eco')).toBe('@laptop\\..*:zoon.eco')
51
+ })
52
+ })
@@ -0,0 +1,27 @@
1
+ // A workstation id (and an agent key) must be "slug-shaped": kebab-case,
2
+ // MXID/alias-safe. SLUG_RE names the *format*; the domain noun is workstation.
3
+ export const SLUG_RE = /^[a-z0-9-]+$/
4
+ export const AGENT_KEY_RE = /^[a-z0-9-]+$/
5
+
6
+ export const isValidWorkstation = (s: string): boolean => SLUG_RE.test(s)
7
+ export const isValidAgentKey = (s: string): boolean => AGENT_KEY_RE.test(s)
8
+
9
+ export function agentMxid(workstation: string, agent: string, serverName: string): string {
10
+ if (!isValidWorkstation(workstation))
11
+ throw new Error(`invalid workstation: ${JSON.stringify(workstation)}`)
12
+ if (!isValidAgentKey(agent)) throw new Error(`invalid agent key: ${JSON.stringify(agent)}`)
13
+ return `@${workstation}.${agent}:${serverName}`
14
+ }
15
+
16
+ export function splitAgentLocalpart(localpart: string): { workstation: string; agent: string } {
17
+ const i = localpart.indexOf('.') // first dot is the boundary; workstation/agent are dot-free
18
+ if (i <= 0 || i === localpart.length - 1)
19
+ throw new Error(`not a workstation.agent localpart: ${localpart}`)
20
+ return { workstation: localpart.slice(0, i), agent: localpart.slice(i + 1) }
21
+ }
22
+
23
+ export function workstationUserNamespace(workstation: string, serverName: string): string {
24
+ if (!isValidWorkstation(workstation))
25
+ throw new Error(`invalid workstation: ${JSON.stringify(workstation)}`)
26
+ return `@${workstation}\\..*:${serverName}` // escaped dot — exclusive to this workstation's agents
27
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,15 @@ export { MatrixContextProvider } from './context-provider.js'
4
4
  export type { MatrixContextProviderOpts } from './context-provider.js'
5
5
  export { renderRegistration } from './registration.js'
6
6
  export type { MatrixTransportConfig } from './registration.js'
7
+ export {
8
+ isValidWorkstation,
9
+ isValidAgentKey,
10
+ agentMxid,
11
+ splitAgentLocalpart,
12
+ workstationUserNamespace,
13
+ SLUG_RE,
14
+ AGENT_KEY_RE,
15
+ } from './identity.js'
7
16
  export { extractMentions } from './mentions.js'
8
17
  export type { MaybeMessage } from './mentions.js'
9
18
  export { route, isMediaMsgtype, MEDIA_MSGTYPES } from './router.js'
@@ -11,6 +20,8 @@ export type { AgentBinding, RouteMatch } from './router.js'
11
20
  export { BotPool } from './bot-pool.js'
12
21
  export { createMatrixTransport } from './transport.js'
13
22
  export type { CreateMatrixTransportOptions, MediaClientLike } from './transport.js'
23
+ export { SyncLoop } from './sync-loop.js'
24
+ export type { SyncLoopOptions, SyncResponse, SyncClient } from './sync-loop.js'
14
25
  export { ensureDefaultChannel, ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
15
26
  export type { EnsureDefaultChannelOpts, EnsureSpaceOpts } from './space-provisioner.js'
16
27
  export {
@@ -478,6 +478,29 @@ describe('MatrixClient.leaveRoom', () => {
478
478
  })
479
479
  })
480
480
 
481
+ describe('MatrixClient.sync (impersonated)', () => {
482
+ it('GETs /sync with ?user_id=, as_token bearer, since + timeout', async () => {
483
+ const fetch = vi.fn(async () =>
484
+ new Response(JSON.stringify({ next_batch: 's2', rooms: { join: {} } }), { status: 200 }),
485
+ )
486
+ const c = new MatrixClient({
487
+ homeserver: 'https://zoon.eco',
488
+ asToken: 'as-tok',
489
+ fetch: fetch as unknown as typeof globalThis.fetch,
490
+ })
491
+ const out = await c.sync({ asUserId: '@laptop.docs:zoon.eco', since: 's1', timeoutMs: 30000 })
492
+
493
+ expect(out.next_batch).toBe('s2')
494
+ const url = new URL(fetch.mock.calls[0]![0] as string)
495
+ expect(url.pathname).toBe('/_matrix/client/v3/sync')
496
+ expect(url.searchParams.get('user_id')).toBe('@laptop.docs:zoon.eco')
497
+ expect(url.searchParams.get('since')).toBe('s1')
498
+ expect(url.searchParams.get('timeout')).toBe('30000')
499
+ const headers = (fetch.mock.calls[0]![1] as RequestInit).headers as Record<string, string>
500
+ expect(headers.Authorization).toBe('Bearer as-tok')
501
+ })
502
+ })
503
+
481
504
  describe('MatrixClient.createRoom restricted', () => {
482
505
  it('injects a restricted join rule referencing the space when restrictedToSpaceId is set', async () => {
483
506
  const fetch = fakeFetch(async ({ url, init }) => {
@@ -420,6 +420,38 @@ export class MatrixClient {
420
420
  return (await r.json()) as { joined: Record<string, { display_name?: string }> }
421
421
  }
422
422
 
423
+ async sync(opts: {
424
+ asUserId: string
425
+ since?: string | null
426
+ timeoutMs?: number
427
+ }): Promise<{
428
+ next_batch: string
429
+ rooms: {
430
+ join: Record<string, {
431
+ timeline: { events: Record<string, unknown>[]; prev_batch?: string; limited?: boolean }
432
+ }>
433
+ }
434
+ }> {
435
+ const params = new URLSearchParams({
436
+ user_id: opts.asUserId,
437
+ timeout: String(opts.timeoutMs ?? 30_000),
438
+ })
439
+ if (opts.since) params.set('since', opts.since)
440
+ const url = `${this.homeserver}/_matrix/client/v3/sync?${params.toString()}`
441
+ const r = await this.fetch(url, {
442
+ headers: { Authorization: `Bearer ${this.asToken}` },
443
+ })
444
+ if (!r.ok) throw new Error(`sync(${opts.asUserId}) failed: ${r.status}`)
445
+ return r.json() as Promise<{
446
+ next_batch: string
447
+ rooms: {
448
+ join: Record<string, {
449
+ timeline: { events: Record<string, unknown>[]; prev_batch?: string; limited?: boolean }
450
+ }>
451
+ }
452
+ }>
453
+ }
454
+
423
455
  async fetchRoomName(roomId: string, asUserId: string): Promise<string | null> {
424
456
  const url =
425
457
  `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}` +
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import { parse } from 'yaml'
2
3
  import { renderRegistration, type MatrixTransportConfig } from './registration.js'
3
4
 
4
5
  const baseConfig: MatrixTransportConfig = {
@@ -39,3 +40,22 @@ describe('renderRegistration', () => {
39
40
  expect(yaml).toContain('rate_limited: false')
40
41
  })
41
42
  })
43
+
44
+ it('renders a workstation-scoped EXCLUSIVE registration', () => {
45
+ const y = parse(
46
+ renderRegistration({
47
+ id: 'laptop',
48
+ url: 'http://10.0.1.5:9099',
49
+ homeserver: 'https://zoon.eco',
50
+ asToken: 'as-x',
51
+ hsToken: 'hs-x',
52
+ senderLocalpart: 'laptop',
53
+ userNamespace: '@laptop\\..*:zoon.eco',
54
+ exclusive: true,
55
+ }),
56
+ )
57
+ expect(y.sender_localpart).toBe('laptop')
58
+ expect(y.url).toBe('http://10.0.1.5:9099')
59
+ expect(y.namespaces.users[0]).toEqual({ exclusive: true, regex: '@laptop\\..*:zoon.eco' })
60
+ expect(y.namespaces.rooms).toEqual([]) // never fence by room id
61
+ })
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { SyncLoop } from './sync-loop.js'
3
+
4
+ const evt = (id: string, roomId: string) => ({
5
+ type: 'm.room.message',
6
+ event_id: id,
7
+ sender: '@me:zoon.eco',
8
+ room_id: roomId,
9
+ content: { msgtype: 'm.text', body: 'hi' },
10
+ })
11
+
12
+ function fakeClient(pages: unknown[]) {
13
+ let i = 0
14
+ return {
15
+ sync: vi.fn(async () => pages[Math.min(i++, pages.length - 1)]),
16
+ }
17
+ }
18
+
19
+ describe('SyncLoop', () => {
20
+ it('dispatches each timeline event once and persists next_batch', async () => {
21
+ const store = new Map<string, string>()
22
+ const dispatched: string[] = []
23
+ const client = fakeClient([
24
+ { next_batch: 's1', rooms: { join: { '!r': { timeline: { events: [evt('a', '!r')] } } } } },
25
+ { next_batch: 's2', rooms: { join: { '!r': { timeline: { events: [evt('b', '!r')] } } } } },
26
+ { next_batch: 's2', rooms: { join: {} } },
27
+ ])
28
+ const loop = new SyncLoop({
29
+ client: client as never,
30
+ asUserId: '@laptop.docs:zoon.eco',
31
+ loadSince: () => store.get('docs') ?? null,
32
+ saveSince: (s) => store.set('docs', s),
33
+ onEvent: (e) => dispatched.push(e.event_id as string),
34
+ })
35
+ await loop.tick()
36
+ await loop.tick()
37
+ expect(dispatched).toEqual(['a', 'b'])
38
+ expect(store.get('docs')).toBe('s2')
39
+ })
40
+
41
+ it('resumes from the persisted since (no reprocessing across restart)', async () => {
42
+ const store = new Map<string, string>([['docs', 's-persisted']])
43
+ const client = fakeClient([{ next_batch: 's3', rooms: { join: {} } }])
44
+ const loop = new SyncLoop({
45
+ client: client as never,
46
+ asUserId: '@laptop.docs:zoon.eco',
47
+ loadSince: () => store.get('docs') ?? null,
48
+ saveSince: (s) => store.set('docs', s),
49
+ onEvent: () => {},
50
+ })
51
+ await loop.tick()
52
+ expect((client.sync as ReturnType<typeof vi.fn>).mock.calls[0][0].since).toBe('s-persisted')
53
+ })
54
+
55
+ it('tolerates an idle sync with no `rooms` key (real homeservers omit it)', async () => {
56
+ const store = new Map<string, string>()
57
+ const dispatched: string[] = []
58
+ // Real Tuwunel returns just { next_batch } on an idle incremental sync.
59
+ const client = fakeClient([{ next_batch: 's-idle' }])
60
+ const loop = new SyncLoop({
61
+ client: client as never,
62
+ asUserId: '@laptop.docs:zoon.eco',
63
+ loadSince: () => store.get('docs') ?? null,
64
+ saveSince: (s) => store.set('docs', s),
65
+ onEvent: (e) => dispatched.push(e.event_id as string),
66
+ })
67
+ await expect(loop.tick()).resolves.toBeUndefined()
68
+ expect(dispatched).toEqual([])
69
+ expect(store.get('docs')).toBe('s-idle') // cursor still advances
70
+ })
71
+
72
+ it('tolerates a joined room with no `timeline` (state/ephemeral-only update)', async () => {
73
+ const store = new Map<string, string>()
74
+ const dispatched: string[] = []
75
+ // A room can appear in rooms.join carrying only state — no timeline key.
76
+ const client = fakeClient([
77
+ { next_batch: 's-state', rooms: { join: { '!r': { state: { events: [] } } } } },
78
+ ])
79
+ const loop = new SyncLoop({
80
+ client: client as never,
81
+ asUserId: '@laptop.docs:zoon.eco',
82
+ loadSince: () => store.get('docs') ?? null,
83
+ saveSince: (s) => store.set('docs', s),
84
+ onEvent: (e) => dispatched.push(e.event_id as string),
85
+ })
86
+ await expect(loop.tick()).resolves.toBeUndefined()
87
+ expect(dispatched).toEqual([])
88
+ expect(store.get('docs')).toBe('s-state')
89
+ })
90
+
91
+ it('run() survives a throwing tick and keeps going (no daemon crash)', async () => {
92
+ let calls = 0
93
+ let loop!: SyncLoop
94
+ const client = {
95
+ sync: vi.fn(async () => {
96
+ calls++
97
+ if (calls === 1) throw new Error('transient /sync 502')
98
+ loop.stop() // recovered on the 2nd tick — end the loop deterministically
99
+ return { next_batch: 's-ok' }
100
+ }),
101
+ }
102
+ loop = new SyncLoop({
103
+ client: client as never,
104
+ asUserId: '@laptop.docs:zoon.eco',
105
+ loadSince: () => null,
106
+ saveSince: () => {},
107
+ onEvent: () => {},
108
+ retryDelayMs: 1,
109
+ })
110
+ await loop.run() // tick 1 throws → backoff → tick 2 recovers → stop → exits
111
+ expect(calls).toBe(2)
112
+ })
113
+ })
@@ -0,0 +1,74 @@
1
+ export interface SyncResponse {
2
+ next_batch: string
3
+ // Real homeservers omit `rooms` (and `rooms.join`) entirely on an idle
4
+ // incremental sync — both are optional.
5
+ rooms?: {
6
+ join?: Record<string, {
7
+ // A joined room may carry only state/ephemeral/account_data on a given
8
+ // sync, with no `timeline` (or a timeline with no `events`).
9
+ timeline?: {
10
+ events?: Record<string, unknown>[]
11
+ prev_batch?: string
12
+ limited?: boolean
13
+ }
14
+ }>
15
+ }
16
+ }
17
+
18
+ export interface SyncClient {
19
+ sync(opts: { asUserId: string; since: string | null; timeoutMs: number }): Promise<SyncResponse>
20
+ }
21
+
22
+ export interface SyncLoopOptions {
23
+ client: SyncClient
24
+ asUserId: string
25
+ loadSince: () => string | null
26
+ saveSince: (since: string) => void
27
+ onEvent: (evt: Record<string, unknown>) => void | Promise<void>
28
+ timeoutMs?: number
29
+ /** Backoff after a failed tick before retrying. Default 5000ms. */
30
+ retryDelayMs?: number
31
+ }
32
+
33
+ export class SyncLoop {
34
+ private readonly opts: SyncLoopOptions
35
+ private running = false
36
+
37
+ constructor(opts: SyncLoopOptions) {
38
+ this.opts = opts
39
+ }
40
+
41
+ async tick(): Promise<void> {
42
+ const since = this.opts.loadSince()
43
+ const res = await this.opts.client.sync({
44
+ asUserId: this.opts.asUserId,
45
+ since,
46
+ timeoutMs: this.opts.timeoutMs ?? 30_000,
47
+ })
48
+ for (const [roomId, roomState] of Object.entries(res.rooms?.join ?? {})) {
49
+ for (const baseEvt of roomState.timeline?.events ?? []) {
50
+ await this.opts.onEvent({ ...(baseEvt as Record<string, unknown>), room_id: roomId })
51
+ }
52
+ }
53
+ this.opts.saveSince(res.next_batch)
54
+ }
55
+
56
+ async run(): Promise<void> {
57
+ this.running = true
58
+ while (this.running) {
59
+ try {
60
+ await this.tick()
61
+ } catch (err) {
62
+ // A transient /sync failure (network blip, sleep/wake, 5xx) must not
63
+ // kill the loop — log and back off, then resume from the same `since`.
64
+ if (!this.running) break
65
+ console.warn(`[sync-loop] ${this.opts.asUserId} tick failed, retrying:`, err)
66
+ await new Promise((r) => setTimeout(r, this.opts.retryDelayMs ?? 5_000))
67
+ }
68
+ }
69
+ }
70
+
71
+ stop(): void {
72
+ this.running = false
73
+ }
74
+ }