agent-messenger 2.21.0 → 2.23.0

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 (134) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -0
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/webex/client.d.ts +25 -0
  5. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  6. package/dist/src/platforms/webex/client.js +115 -5
  7. package/dist/src/platforms/webex/client.js.map +1 -1
  8. package/dist/src/platforms/webex/commands/auth.d.ts +9 -1
  9. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  10. package/dist/src/platforms/webex/commands/auth.js +141 -25
  11. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  12. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
  13. package/dist/src/platforms/webex/credential-manager.js +8 -4
  14. package/dist/src/platforms/webex/credential-manager.js.map +1 -1
  15. package/dist/src/platforms/webex/id-normalizer.d.ts +19 -0
  16. package/dist/src/platforms/webex/id-normalizer.d.ts.map +1 -0
  17. package/dist/src/platforms/webex/id-normalizer.js +60 -0
  18. package/dist/src/platforms/webex/id-normalizer.js.map +1 -0
  19. package/dist/src/platforms/webex/index.d.ts +4 -0
  20. package/dist/src/platforms/webex/index.d.ts.map +1 -1
  21. package/dist/src/platforms/webex/index.js +2 -0
  22. package/dist/src/platforms/webex/index.js.map +1 -1
  23. package/dist/src/platforms/webex/listener.d.ts +61 -0
  24. package/dist/src/platforms/webex/listener.d.ts.map +1 -0
  25. package/dist/src/platforms/webex/listener.js +222 -0
  26. package/dist/src/platforms/webex/listener.js.map +1 -0
  27. package/dist/src/platforms/webex/password-login.d.ts +18 -0
  28. package/dist/src/platforms/webex/password-login.d.ts.map +1 -0
  29. package/dist/src/platforms/webex/password-login.js +259 -0
  30. package/dist/src/platforms/webex/password-login.js.map +1 -0
  31. package/dist/src/platforms/webex/types.d.ts +2 -1
  32. package/dist/src/platforms/webex/types.d.ts.map +1 -1
  33. package/dist/src/platforms/webex/types.js +1 -1
  34. package/dist/src/platforms/webex/types.js.map +1 -1
  35. package/dist/src/platforms/webex/wdm-discovery.d.ts.map +1 -0
  36. package/dist/src/platforms/{webexbot → webex}/wdm-discovery.js +3 -3
  37. package/dist/src/platforms/webex/wdm-discovery.js.map +1 -0
  38. package/dist/src/platforms/webexbot/cli.d.ts.map +1 -1
  39. package/dist/src/platforms/webexbot/cli.js +4 -1
  40. package/dist/src/platforms/webexbot/cli.js.map +1 -1
  41. package/dist/src/platforms/webexbot/client.d.ts +24 -0
  42. package/dist/src/platforms/webexbot/client.d.ts.map +1 -1
  43. package/dist/src/platforms/webexbot/client.js +81 -5
  44. package/dist/src/platforms/webexbot/client.js.map +1 -1
  45. package/dist/src/platforms/webexbot/commands/file.d.ts +22 -0
  46. package/dist/src/platforms/webexbot/commands/file.d.ts.map +1 -0
  47. package/dist/src/platforms/webexbot/commands/file.js +64 -0
  48. package/dist/src/platforms/webexbot/commands/file.js.map +1 -0
  49. package/dist/src/platforms/webexbot/commands/index.d.ts +3 -0
  50. package/dist/src/platforms/webexbot/commands/index.d.ts.map +1 -1
  51. package/dist/src/platforms/webexbot/commands/index.js +3 -0
  52. package/dist/src/platforms/webexbot/commands/index.js.map +1 -1
  53. package/dist/src/platforms/webexbot/commands/message.d.ts +7 -0
  54. package/dist/src/platforms/webexbot/commands/message.d.ts.map +1 -1
  55. package/dist/src/platforms/webexbot/commands/message.js +52 -1
  56. package/dist/src/platforms/webexbot/commands/message.js.map +1 -1
  57. package/dist/src/platforms/webexbot/commands/snapshot.d.ts +24 -0
  58. package/dist/src/platforms/webexbot/commands/snapshot.d.ts.map +1 -0
  59. package/dist/src/platforms/webexbot/commands/snapshot.js +37 -0
  60. package/dist/src/platforms/webexbot/commands/snapshot.js.map +1 -0
  61. package/dist/src/platforms/webexbot/commands/user.d.ts +30 -0
  62. package/dist/src/platforms/webexbot/commands/user.d.ts.map +1 -0
  63. package/dist/src/platforms/webexbot/commands/user.js +66 -0
  64. package/dist/src/platforms/webexbot/commands/user.js.map +1 -0
  65. package/dist/src/platforms/webexbot/index.d.ts +2 -0
  66. package/dist/src/platforms/webexbot/index.d.ts.map +1 -1
  67. package/dist/src/platforms/webexbot/index.js +1 -0
  68. package/dist/src/platforms/webexbot/index.js.map +1 -1
  69. package/dist/src/platforms/webexbot/listener.d.ts +3 -41
  70. package/dist/src/platforms/webexbot/listener.d.ts.map +1 -1
  71. package/dist/src/platforms/webexbot/listener.js +13 -208
  72. package/dist/src/platforms/webexbot/listener.js.map +1 -1
  73. package/dist/src/platforms/webexbot/types.d.ts +1 -18
  74. package/dist/src/platforms/webexbot/types.d.ts.map +1 -1
  75. package/dist/src/platforms/webexbot/types.js.map +1 -1
  76. package/docs/content/docs/cli/webex.mdx +38 -12
  77. package/docs/content/docs/cli/webexbot.mdx +2 -0
  78. package/docs/content/docs/sdk/webexbot.mdx +18 -0
  79. package/package.json +1 -1
  80. package/skills/agent-channeltalk/SKILL.md +1 -1
  81. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  82. package/skills/agent-discord/SKILL.md +1 -1
  83. package/skills/agent-discordbot/SKILL.md +1 -1
  84. package/skills/agent-instagram/SKILL.md +1 -1
  85. package/skills/agent-kakaotalk/SKILL.md +1 -1
  86. package/skills/agent-line/SKILL.md +1 -1
  87. package/skills/agent-slack/SKILL.md +1 -1
  88. package/skills/agent-slackbot/SKILL.md +1 -1
  89. package/skills/agent-teams/SKILL.md +1 -1
  90. package/skills/agent-telegram/SKILL.md +1 -1
  91. package/skills/agent-telegrambot/SKILL.md +1 -1
  92. package/skills/agent-webex/SKILL.md +76 -22
  93. package/skills/agent-webex/references/authentication.md +55 -14
  94. package/skills/agent-webex/references/common-patterns.md +5 -2
  95. package/skills/agent-webexbot/SKILL.md +60 -5
  96. package/skills/agent-webexbot/references/common-patterns.md +118 -0
  97. package/skills/agent-wechatbot/SKILL.md +1 -1
  98. package/skills/agent-whatsapp/SKILL.md +1 -1
  99. package/skills/agent-whatsappbot/SKILL.md +1 -1
  100. package/src/platforms/webex/cli.test.ts +31 -1
  101. package/src/platforms/webex/client.test.ts +67 -0
  102. package/src/platforms/webex/client.ts +136 -7
  103. package/src/platforms/webex/commands/auth.test.ts +189 -28
  104. package/src/platforms/webex/commands/auth.ts +194 -35
  105. package/src/platforms/webex/credential-manager.test.ts +40 -0
  106. package/src/platforms/webex/credential-manager.ts +7 -4
  107. package/src/platforms/webex/id-normalizer.test.ts +207 -0
  108. package/src/platforms/webex/id-normalizer.ts +76 -0
  109. package/src/platforms/webex/index.test.ts +6 -0
  110. package/src/platforms/webex/index.ts +4 -0
  111. package/src/platforms/webex/listener.test.ts +243 -0
  112. package/src/platforms/webex/listener.ts +285 -0
  113. package/src/platforms/webex/password-login.test.ts +193 -0
  114. package/src/platforms/webex/password-login.ts +332 -0
  115. package/src/platforms/webex/types.test.ts +16 -0
  116. package/src/platforms/webex/types.ts +2 -2
  117. package/src/platforms/{webexbot → webex}/wdm-discovery.ts +3 -3
  118. package/src/platforms/webexbot/cli.ts +6 -0
  119. package/src/platforms/webexbot/client.test.ts +322 -0
  120. package/src/platforms/webexbot/client.ts +104 -7
  121. package/src/platforms/webexbot/commands/file.ts +104 -0
  122. package/src/platforms/webexbot/commands/index.ts +3 -0
  123. package/src/platforms/webexbot/commands/message.ts +68 -2
  124. package/src/platforms/webexbot/commands/snapshot.ts +60 -0
  125. package/src/platforms/webexbot/commands/user.test.ts +77 -0
  126. package/src/platforms/webexbot/commands/user.ts +98 -0
  127. package/src/platforms/webexbot/index.ts +2 -0
  128. package/src/platforms/webexbot/listener.test.ts +37 -224
  129. package/src/platforms/webexbot/listener.ts +18 -250
  130. package/src/platforms/webexbot/types.ts +2 -23
  131. package/dist/src/platforms/webexbot/wdm-discovery.d.ts.map +0 -1
  132. package/dist/src/platforms/webexbot/wdm-discovery.js.map +0 -1
  133. /package/dist/src/platforms/{webexbot → webex}/wdm-discovery.d.ts +0 -0
  134. /package/src/platforms/{webexbot → webex}/wdm-discovery.test.ts +0 -0
@@ -0,0 +1,285 @@
1
+ import { EventEmitter } from 'events'
2
+
3
+ import { WebexMessageHandler } from 'webex-message-handler'
4
+ import type {
5
+ AttachmentAction,
6
+ DecryptedMessage,
7
+ DeletedMessage,
8
+ HandlerStatus,
9
+ InjectedWebSocket,
10
+ MembershipActivity,
11
+ RoomActivity,
12
+ WebexMessageHandlerConfig,
13
+ WebexMessageHandlerEvents,
14
+ } from 'webex-message-handler'
15
+ import WebSocket from 'ws'
16
+
17
+ import {
18
+ normalizeAttachmentAction,
19
+ normalizeDeletedMessage,
20
+ normalizeMembership,
21
+ normalizeMessage,
22
+ normalizeRoomActivity,
23
+ } from './id-normalizer'
24
+ import { createWdmRewriteFetch, discoverWdmDevicesUrl } from './wdm-discovery'
25
+
26
+ type EventKey = keyof WebexListenerEventMap
27
+
28
+ export interface WebexListenerClient {
29
+ getToken(): string
30
+ }
31
+
32
+ interface WebexMessageHandlerLike {
33
+ connect(): Promise<void>
34
+ disconnect(): Promise<void>
35
+ status(): HandlerStatus
36
+ get connected(): boolean
37
+ on<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this
38
+ off<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this
39
+ once<K extends keyof WebexMessageHandlerEvents>(event: K, listener: WebexMessageHandlerEvents[K]): this
40
+ }
41
+
42
+ export interface WebexListenerOptions {
43
+ ignoreSelfMessages?: boolean
44
+ pingInterval?: number
45
+ pongTimeout?: number
46
+ reconnectBackoffMax?: number
47
+ maxReconnectAttempts?: number
48
+ _handlerFactory?: (config: WebexMessageHandlerConfig) => WebexMessageHandlerLike
49
+ }
50
+
51
+ export interface WebexListenerEventMap {
52
+ message_created: [event: DecryptedMessage]
53
+ message_updated: [event: DecryptedMessage]
54
+ message_deleted: [event: DeletedMessage]
55
+ membership_created: [event: MembershipActivity]
56
+ attachment_action: [event: AttachmentAction]
57
+ room_created: [event: RoomActivity]
58
+ room_updated: [event: RoomActivity]
59
+ webex_event: [event: DecryptedMessage | DeletedMessage | MembershipActivity | AttachmentAction | RoomActivity]
60
+ connected: [info: { connected: boolean; status: HandlerStatus }]
61
+ reconnecting: [attempt: number]
62
+ disconnected: [reason: string]
63
+ error: [error: Error]
64
+ }
65
+
66
+ export class WebexListener {
67
+ private client: WebexListenerClient
68
+ private options: WebexListenerOptions
69
+ private running = false
70
+ private emitter = new EventEmitter()
71
+ private handler: WebexMessageHandlerLike | null = null
72
+ private generation = 0
73
+ private detachHandler: (() => void) | null = null
74
+ private startPromise: Promise<void> | null = null
75
+
76
+ constructor(client: WebexListenerClient, options: WebexListenerOptions = {}) {
77
+ this.client = client
78
+ this.options = options
79
+ }
80
+
81
+ async start(): Promise<void> {
82
+ if (this.startPromise) return this.startPromise
83
+ if (this.running) return
84
+
85
+ this.running = true
86
+ const generation = ++this.generation
87
+
88
+ let startPromise!: Promise<void>
89
+ startPromise = (async () => {
90
+ let handler: WebexMessageHandlerLike | null = null
91
+ try {
92
+ handler = await this.createHandler()
93
+
94
+ if (!this.running || this.generation !== generation) {
95
+ await handler.disconnect().catch(() => undefined)
96
+ return
97
+ }
98
+ this.handler = handler
99
+ this.detachHandler = this.wireHandler(handler, generation)
100
+
101
+ await handler.connect()
102
+
103
+ if (!this.running || this.handler !== handler || this.generation !== generation) {
104
+ await handler.disconnect().catch(() => undefined)
105
+ return
106
+ }
107
+ } catch (error) {
108
+ if (this.handler === handler && this.generation === generation) {
109
+ this.detachHandler?.()
110
+ this.detachHandler = null
111
+ this.handler = null
112
+ }
113
+ if (this.generation === generation) {
114
+ this.running = false
115
+ }
116
+ await handler?.disconnect().catch(() => undefined)
117
+ throw error
118
+ } finally {
119
+ if (this.startPromise === startPromise) {
120
+ this.startPromise = null
121
+ }
122
+ }
123
+ })()
124
+
125
+ this.startPromise = startPromise
126
+ return startPromise
127
+ }
128
+
129
+ async stop(): Promise<void> {
130
+ const pendingStart = this.startPromise
131
+ this.startPromise = null
132
+
133
+ this.running = false
134
+ this.generation++
135
+
136
+ const detach = this.detachHandler
137
+ this.detachHandler = null
138
+ detach?.()
139
+
140
+ const handler = this.handler
141
+ this.handler = null
142
+ if (handler) {
143
+ await handler.disconnect().catch(() => undefined)
144
+ }
145
+
146
+ if (pendingStart) {
147
+ await pendingStart.catch(() => undefined)
148
+ }
149
+ }
150
+
151
+ on<K extends EventKey>(event: K, listener: (...args: WebexListenerEventMap[K]) => void): this {
152
+ this.emitter.on(event, listener as (...args: unknown[]) => void)
153
+ return this
154
+ }
155
+
156
+ off<K extends EventKey>(event: K, listener: (...args: WebexListenerEventMap[K]) => void): this {
157
+ this.emitter.off(event, listener as (...args: unknown[]) => void)
158
+ return this
159
+ }
160
+
161
+ once<K extends EventKey>(event: K, listener: (...args: WebexListenerEventMap[K]) => void): this {
162
+ this.emitter.once(event, listener as (...args: unknown[]) => void)
163
+ return this
164
+ }
165
+
166
+ private isCurrent(handler: WebexMessageHandlerLike, generation: number): boolean {
167
+ return this.running && this.handler === handler && this.generation === generation
168
+ }
169
+
170
+ // Node's EventEmitter throws synchronously if an 'error' event is emitted with
171
+ // no registered listener. Guard it so SDK consumers that only subscribe to
172
+ // message events are never crashed by a transient connection error.
173
+ private emitError(error: Error): void {
174
+ if (this.emitter.listenerCount('error') > 0) {
175
+ this.emitter.emit('error', error)
176
+ }
177
+ }
178
+
179
+ private async createHandler(): Promise<WebexMessageHandlerLike> {
180
+ const token = this.client.getToken()
181
+ const config: WebexMessageHandlerConfig = { token }
182
+ if (this.options.ignoreSelfMessages !== undefined) config.ignoreSelfMessages = this.options.ignoreSelfMessages
183
+ if (this.options.pingInterval !== undefined) config.pingInterval = this.options.pingInterval
184
+ if (this.options.pongTimeout !== undefined) config.pongTimeout = this.options.pongTimeout
185
+ if (this.options.reconnectBackoffMax !== undefined) config.reconnectBackoffMax = this.options.reconnectBackoffMax
186
+ if (this.options.maxReconnectAttempts !== undefined) config.maxReconnectAttempts = this.options.maxReconnectAttempts
187
+
188
+ if (this.options._handlerFactory) {
189
+ return this.options._handlerFactory(config)
190
+ }
191
+
192
+ const wdmDevicesUrl = await discoverWdmDevicesUrl(token)
193
+ config.mode = 'injected'
194
+ config.fetch = createWdmRewriteFetch(wdmDevicesUrl)
195
+ config.webSocketFactory = (url: string) => new WebSocket(url) as unknown as InjectedWebSocket
196
+ return new WebexMessageHandler(config)
197
+ }
198
+
199
+ private wireHandler(handler: WebexMessageHandlerLike, generation: number): () => void {
200
+ const onMessageCreated = (event: DecryptedMessage) => {
201
+ if (!this.isCurrent(handler, generation)) return
202
+ const normalized = normalizeMessage(event)
203
+ this.emitter.emit('message_created', normalized)
204
+ this.emitter.emit('webex_event', normalized)
205
+ }
206
+ const onMessageUpdated = (event: DecryptedMessage) => {
207
+ if (!this.isCurrent(handler, generation)) return
208
+ const normalized = normalizeMessage(event)
209
+ this.emitter.emit('message_updated', normalized)
210
+ this.emitter.emit('webex_event', normalized)
211
+ }
212
+ const onMessageDeleted = (event: DeletedMessage) => {
213
+ if (!this.isCurrent(handler, generation)) return
214
+ const normalized = normalizeDeletedMessage(event)
215
+ this.emitter.emit('message_deleted', normalized)
216
+ this.emitter.emit('webex_event', normalized)
217
+ }
218
+ const onMembershipCreated = (event: MembershipActivity) => {
219
+ if (!this.isCurrent(handler, generation)) return
220
+ const normalized = normalizeMembership(event)
221
+ this.emitter.emit('membership_created', normalized)
222
+ this.emitter.emit('webex_event', normalized)
223
+ }
224
+ const onAttachmentAction = (event: AttachmentAction) => {
225
+ if (!this.isCurrent(handler, generation)) return
226
+ const normalized = normalizeAttachmentAction(event)
227
+ this.emitter.emit('attachment_action', normalized)
228
+ this.emitter.emit('webex_event', normalized)
229
+ }
230
+ const onRoomCreated = (event: RoomActivity) => {
231
+ if (!this.isCurrent(handler, generation)) return
232
+ const normalized = normalizeRoomActivity(event)
233
+ this.emitter.emit('room_created', normalized)
234
+ this.emitter.emit('webex_event', normalized)
235
+ }
236
+ const onRoomUpdated = (event: RoomActivity) => {
237
+ if (!this.isCurrent(handler, generation)) return
238
+ const normalized = normalizeRoomActivity(event)
239
+ this.emitter.emit('room_updated', normalized)
240
+ this.emitter.emit('webex_event', normalized)
241
+ }
242
+ const onConnected = () => {
243
+ if (!this.isCurrent(handler, generation)) return
244
+ this.emitter.emit('connected', { connected: handler.connected, status: handler.status() })
245
+ }
246
+ const onReconnecting = (attempt: number) => {
247
+ if (!this.isCurrent(handler, generation)) return
248
+ this.emitter.emit('reconnecting', attempt)
249
+ }
250
+ const onDisconnected = (reason: string) => {
251
+ if (!this.isCurrent(handler, generation)) return
252
+ this.emitter.emit('disconnected', reason)
253
+ }
254
+ const onError = (error: Error) => {
255
+ if (!this.isCurrent(handler, generation)) return
256
+ this.emitError(error)
257
+ }
258
+
259
+ handler.on('message:created', onMessageCreated)
260
+ handler.on('message:updated', onMessageUpdated)
261
+ handler.on('message:deleted', onMessageDeleted)
262
+ handler.on('membership:created', onMembershipCreated)
263
+ handler.on('attachmentAction:created', onAttachmentAction)
264
+ handler.on('room:created', onRoomCreated)
265
+ handler.on('room:updated', onRoomUpdated)
266
+ handler.on('connected', onConnected)
267
+ handler.on('reconnecting', onReconnecting)
268
+ handler.on('disconnected', onDisconnected)
269
+ handler.on('error', onError)
270
+
271
+ return () => {
272
+ handler.off('message:created', onMessageCreated)
273
+ handler.off('message:updated', onMessageUpdated)
274
+ handler.off('message:deleted', onMessageDeleted)
275
+ handler.off('membership:created', onMembershipCreated)
276
+ handler.off('attachmentAction:created', onAttachmentAction)
277
+ handler.off('room:created', onRoomCreated)
278
+ handler.off('room:updated', onRoomUpdated)
279
+ handler.off('connected', onConnected)
280
+ handler.off('reconnecting', onReconnecting)
281
+ handler.off('disconnected', onDisconnected)
282
+ handler.off('error', onError)
283
+ }
284
+ }
285
+ }
@@ -0,0 +1,193 @@
1
+ import { afterEach, describe, expect, it, mock } from 'bun:test'
2
+ import { createHash } from 'node:crypto'
3
+
4
+ import { createPkcePair, loginWithPassword } from './password-login'
5
+ import { WebexError } from './types'
6
+
7
+ const realFetch = globalThis.fetch
8
+
9
+ describe('loginWithPassword', () => {
10
+ afterEach(() => {
11
+ globalThis.fetch = realFetch
12
+ })
13
+
14
+ it('exchanges email and password for tokens and registers a device', async () => {
15
+ const postedBodies: string[] = []
16
+
17
+ globalThis.fetch = mock((input: RequestInfo | URL, init?: RequestInit) => {
18
+ const url = toUrl(input)
19
+ if (typeof init?.body === 'string') postedBodies.push(init.body)
20
+
21
+ if (url.startsWith('https://u2c.svc.webex.com/')) {
22
+ return Promise.resolve(
23
+ jsonResponse({
24
+ serviceLinks: { idbroker: 'https://idbroker-test.webex.com', wdm: 'https://wdm-test.wbx2.com' },
25
+ }),
26
+ )
27
+ }
28
+
29
+ if (url.startsWith('https://idbroker-test.webex.com/idb/oauth2/v1/authorize')) {
30
+ return Promise.resolve(
31
+ new Response(
32
+ '<form><input type="hidden" name="SunQueryParamsString" value="one&amp;two=&#x40;"><input name="goto" value="https://web.webex.com"><input name="encoded" value="true"><input name="gx_charset" value="UTF-8"><input name="webAuthnEnabledFlow" value="false"><input name="webAuthnResponse" value=""><input name="isAudioCaptcha" value="false"></form>',
33
+ { status: 200, headers: { 'set-cookie': 'sid=abc; Path=/' } },
34
+ ),
35
+ )
36
+ }
37
+
38
+ if (url === 'https://idbroker-test.webex.com/idb/UI/Login') {
39
+ return Promise.resolve(
40
+ new Response('', { status: 302, headers: { location: 'https://web.webex.com/?code=TESTCODE' } }),
41
+ )
42
+ }
43
+
44
+ if (url === 'https://idbroker-test.webex.com/idb/oauth2/v1/access_token') {
45
+ return Promise.resolve(
46
+ jsonResponse({
47
+ access_token: 'fake-access-token',
48
+ refresh_token: 'fake-refresh-token',
49
+ expires_in: 3600,
50
+ refresh_token_expires_in: 7200,
51
+ scope: 'spark:kms',
52
+ token_type: 'Bearer',
53
+ }),
54
+ )
55
+ }
56
+
57
+ if (url === 'https://wdm-test.wbx2.com/wdm/api/v1/devices') {
58
+ return Promise.resolve(
59
+ jsonResponse({ url: 'https://wdm-test.wbx2.com/wdm/api/v1/devices/device-1', userId: 'user-1' }),
60
+ )
61
+ }
62
+
63
+ return Promise.resolve(new Response('', { status: 404 }))
64
+ }) as typeof fetch
65
+
66
+ const result = await loginWithPassword('user@example.com', 'test-password')
67
+ const loginBody = new URLSearchParams(postedBodies.find((body) => body.includes('IDToken2')))
68
+
69
+ expect(result.accessToken).toBe('fake-access-token')
70
+ expect(result.refreshToken).toBe('fake-refresh-token')
71
+ expect(result.expiresAt).toBeGreaterThan(Date.now())
72
+ expect(result.deviceUrl).toBe('https://wdm-test.wbx2.com/wdm/api/v1/devices/device-1')
73
+ expect(result.userId).toBe('user-1')
74
+ expect(loginBody.get('IDToken1')).toBe('user@example.com')
75
+ expect(loginBody.get('IDToken2')).toBe('test-password')
76
+ expect(loginBody.get('SunQueryParamsString')).toBe('one&two=@')
77
+ })
78
+
79
+ it('creates an S256 PKCE challenge from the verifier', () => {
80
+ const verifier = 'test-verifier'
81
+ const expectedChallenge = createHash('sha256')
82
+ .update(verifier)
83
+ .digest('base64')
84
+ .replace(/\+/g, '-')
85
+ .replace(/\//g, '_')
86
+ .replace(/=+$/, '')
87
+
88
+ expect(createPkcePair(verifier)).toEqual({ verifier, challenge: expectedChallenge })
89
+ })
90
+
91
+ it('rejects without persisting when WDM device registration fails', async () => {
92
+ globalThis.fetch = mock((input: RequestInfo | URL) => {
93
+ const url = toUrl(input)
94
+
95
+ if (url.startsWith('https://u2c.svc.webex.com/')) {
96
+ return Promise.resolve(
97
+ jsonResponse({
98
+ serviceLinks: { idbroker: 'https://idbroker-test.webex.com', wdm: 'https://wdm-test.wbx2.com' },
99
+ }),
100
+ )
101
+ }
102
+ if (url.startsWith('https://idbroker-test.webex.com/idb/oauth2/v1/authorize')) {
103
+ return Promise.resolve(
104
+ new Response('<input name="SunQueryParamsString" value="x">', {
105
+ status: 200,
106
+ headers: { 'set-cookie': 'sid=abc; Path=/' },
107
+ }),
108
+ )
109
+ }
110
+ if (url === 'https://idbroker-test.webex.com/idb/UI/Login') {
111
+ return Promise.resolve(
112
+ new Response('', { status: 302, headers: { location: 'https://web.webex.com/?code=TESTCODE' } }),
113
+ )
114
+ }
115
+ if (url === 'https://idbroker-test.webex.com/idb/oauth2/v1/access_token') {
116
+ return Promise.resolve(
117
+ jsonResponse({ access_token: 'fake-access-token', refresh_token: 'fake-refresh-token', expires_in: 3600 }),
118
+ )
119
+ }
120
+ if (url === 'https://wdm-test.wbx2.com/wdm/api/v1/devices') {
121
+ return Promise.resolve(new Response('', { status: 500 }))
122
+ }
123
+ return Promise.resolve(new Response('', { status: 404 }))
124
+ }) as typeof fetch
125
+
126
+ await expect(loginWithPassword('user@example.com', 'test-password')).rejects.toMatchObject({
127
+ name: 'WebexError',
128
+ code: 'device_registration_failed',
129
+ } satisfies Partial<WebexError>)
130
+ })
131
+
132
+ it('rejects as sso_required without posting credentials when authorize redirects to an external IdP', async () => {
133
+ const postedBodies: string[] = []
134
+ globalThis.fetch = mock((input: RequestInfo | URL, init?: RequestInit) => {
135
+ const url = toUrl(input)
136
+ if (typeof init?.body === 'string') postedBodies.push(init.body)
137
+
138
+ if (url.startsWith('https://u2c.svc.webex.com/')) {
139
+ return Promise.resolve(jsonResponse({ serviceLinks: { idbroker: 'https://idbroker-test.webex.com' } }))
140
+ }
141
+ if (url.startsWith('https://idbroker-test.webex.com/idb/oauth2/v1/authorize')) {
142
+ return Promise.resolve(
143
+ new Response('', { status: 302, headers: { location: 'https://idp.example.com/login' } }),
144
+ )
145
+ }
146
+ if (url.startsWith('https://idp.example.com/')) {
147
+ return Promise.resolve(new Response('<input name="IDToken1"><input name="IDToken2">', { status: 200 }))
148
+ }
149
+ return Promise.resolve(new Response('', { status: 404 }))
150
+ }) as typeof fetch
151
+
152
+ await expect(loginWithPassword('user@example.com', 'test-password')).rejects.toMatchObject({
153
+ name: 'WebexError',
154
+ code: 'sso_required',
155
+ } satisfies Partial<WebexError>)
156
+ expect(postedBodies.some((body) => body.includes('test-password'))).toBe(false)
157
+ })
158
+
159
+ it('throws mfa_required when login lands on an OTP step', async () => {
160
+ globalThis.fetch = mock((input: RequestInfo | URL) => {
161
+ const url = toUrl(input)
162
+
163
+ if (url.startsWith('https://u2c.svc.webex.com/')) {
164
+ return Promise.resolve(jsonResponse({ serviceLinks: { idbroker: 'https://idbroker-test.webex.com' } }))
165
+ }
166
+
167
+ if (url.startsWith('https://idbroker-test.webex.com/idb/oauth2/v1/authorize')) {
168
+ return Promise.resolve(new Response('<input name="SunQueryParamsString" value="x">', { status: 200 }))
169
+ }
170
+
171
+ if (url === 'https://idbroker-test.webex.com/idb/UI/Login') {
172
+ return Promise.resolve(new Response('<input name="IDToken1"><p>verification code</p>', { status: 200 }))
173
+ }
174
+
175
+ return Promise.resolve(new Response('', { status: 404 }))
176
+ }) as typeof fetch
177
+
178
+ await expect(loginWithPassword('user@example.com', 'test-password')).rejects.toMatchObject({
179
+ name: 'WebexError',
180
+ code: 'mfa_required',
181
+ } satisfies Partial<WebexError>)
182
+ })
183
+ })
184
+
185
+ function jsonResponse(body: unknown): Response {
186
+ return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } })
187
+ }
188
+
189
+ function toUrl(input: RequestInfo | URL): string {
190
+ if (typeof input === 'string') return input
191
+ if (input instanceof URL) return input.toString()
192
+ return input.url
193
+ }