agent-messenger 2.20.2 → 2.20.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.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bun.lock +41 -0
  3. package/dist/package.json +2 -1
  4. package/dist/src/platforms/webex/client.d.ts +2 -0
  5. package/dist/src/platforms/webex/client.d.ts.map +1 -1
  6. package/dist/src/platforms/webex/client.js +29 -5
  7. package/dist/src/platforms/webex/client.js.map +1 -1
  8. package/dist/src/platforms/webex/commands/message.d.ts.map +1 -1
  9. package/dist/src/platforms/webex/commands/message.js +17 -13
  10. package/dist/src/platforms/webex/commands/message.js.map +1 -1
  11. package/dist/src/platforms/webex/encryption.d.ts +7 -0
  12. package/dist/src/platforms/webex/encryption.d.ts.map +1 -1
  13. package/dist/src/platforms/webex/encryption.js +12 -1
  14. package/dist/src/platforms/webex/encryption.js.map +1 -1
  15. package/dist/src/platforms/webex/kms-key-provider.d.ts +20 -0
  16. package/dist/src/platforms/webex/kms-key-provider.d.ts.map +1 -0
  17. package/dist/src/platforms/webex/kms-key-provider.js +78 -0
  18. package/dist/src/platforms/webex/kms-key-provider.js.map +1 -0
  19. package/docs/content/docs/cli/webex.mdx +1 -1
  20. package/package.json +2 -1
  21. package/skills/agent-channeltalk/SKILL.md +1 -1
  22. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  23. package/skills/agent-discord/SKILL.md +1 -1
  24. package/skills/agent-discordbot/SKILL.md +1 -1
  25. package/skills/agent-instagram/SKILL.md +1 -1
  26. package/skills/agent-kakaotalk/SKILL.md +1 -1
  27. package/skills/agent-line/SKILL.md +1 -1
  28. package/skills/agent-slack/SKILL.md +1 -1
  29. package/skills/agent-slackbot/SKILL.md +1 -1
  30. package/skills/agent-teams/SKILL.md +1 -1
  31. package/skills/agent-telegram/SKILL.md +1 -1
  32. package/skills/agent-telegrambot/SKILL.md +1 -1
  33. package/skills/agent-webex/SKILL.md +1 -1
  34. package/skills/agent-webex/references/authentication.md +1 -1
  35. package/skills/agent-wechatbot/SKILL.md +1 -1
  36. package/skills/agent-whatsapp/SKILL.md +1 -1
  37. package/skills/agent-whatsappbot/SKILL.md +1 -1
  38. package/src/platforms/webex/client.ts +35 -6
  39. package/src/platforms/webex/commands/message.test.ts +12 -0
  40. package/src/platforms/webex/commands/message.ts +22 -14
  41. package/src/platforms/webex/encryption.test.ts +58 -3
  42. package/src/platforms/webex/encryption.ts +19 -1
  43. package/src/platforms/webex/kms-key-provider.ts +99 -0
  44. package/src/platforms/webex/typings/webex-message-handler.d.ts +45 -0
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-channeltalkbot
3
3
  description: Interact with Channel Talk workspaces using API credentials - send messages, read chats, manage groups and bots
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-channeltalkbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-discord
3
3
  description: Interact with Discord servers - send messages, read channels, manage reactions
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-discord:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-discordbot
3
3
  description: Interact with Discord servers using bot tokens - send messages, read channels, manage reactions
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-discordbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-instagram
3
3
  description: Interact with Instagram DMs - send messages, read conversations, manage accounts
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-instagram:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-kakaotalk
3
3
  description: Interact with KakaoTalk - send messages, read chats, manage conversations
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-kakaotalk:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-line
3
3
  description: Interact with LINE - send messages, read chats, manage conversations
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-line:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-slack
3
3
  description: Interact with Slack workspaces - send messages, read channels, manage reactions
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-slack:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-slackbot
3
3
  description: Interact with Slack workspaces using bot tokens - send messages, read channels, manage reactions
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-slackbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-teams
3
3
  description: Interact with Microsoft Teams - send messages, read channels, manage reactions
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-teams:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegram
3
3
  description: Interact with Telegram through TDLib - authenticate, inspect chats, and send messages
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-telegram:*)
6
6
  ---
7
7
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-telegrambot
3
3
  description: Interact with Telegram using bot tokens - send messages, read chats, manage reactions
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-telegrambot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-webex
3
3
  description: Interact with Cisco Webex - send messages, read spaces, manage memberships
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-webex:*)
6
6
  metadata:
7
7
  openclaw:
@@ -19,7 +19,7 @@ Extracts your first-party Webex session token from a Chromium-based browser wher
19
19
  - **Supported browsers**: Chrome, Chrome Canary, Edge, Arc, Brave, Vivaldi, Chromium
20
20
  - **Token lifetime**: Depends on Webex session policy (typically hours to days). Re-extract when expired.
21
21
  - **Auto-extraction**: The CLI attempts browser extraction automatically when no valid token is stored, so you often don't need to run `auth extract` manually.
22
- - **End-to-end encryption**: When encryption keys are found in the browser's cache, messages are encrypted client-side (JWE with AES-256-GCM) before sending via the internal conversation API. This ensures messages appear as encrypted in the Webex client. If no keys are found (e.g., the conversation hasn't been opened in the browser), messages fall back to plaintext.
22
+ - **End-to-end encryption**: Messages are encrypted client-side (JWE with AES-256-GCM) before sending via the internal conversation API, so they appear as encrypted in the Webex client. Keys cached from the browser are used directly; for an end-to-end encrypted conversation whose key was not cached at extract time, the key is fetched on demand over the Mercury websocket + KMS flow and persisted for reuse. Only non-encrypted conversations are sent as plaintext.
23
23
  - **Best for**: Interactive use, sending messages as yourself without the "via" label
24
24
 
25
25
  ```bash
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-wechatbot
3
3
  description: Interact with WeChat Official Account using API credentials - send messages, manage templates, list followers
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-wechatbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsapp
3
3
  description: Interact with WhatsApp - send messages, read chats, manage conversations
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-whatsapp:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: agent-whatsappbot
3
3
  description: Interact with WhatsApp using Cloud API credentials - send messages, manage templates
4
- version: 2.20.2
4
+ version: 2.20.3
5
5
  allowed-tools: Bash(agent-whatsappbot:*)
6
6
  metadata:
7
7
  openclaw:
@@ -1,7 +1,8 @@
1
1
  import { WebexCredentialManager } from './credential-manager'
2
2
  import { WebexEncryptionService } from './encryption'
3
+ import { KmsKeyProvider } from './kms-key-provider'
3
4
  import { escapeHtml, markdownToHtml, stripMarkdown } from './markdown-to-html'
4
- import type { WebexMembership, WebexMessage, WebexPerson, WebexSpace } from './types'
5
+ import type { WebexConfig, WebexMembership, WebexMessage, WebexPerson, WebexSpace } from './types'
5
6
  import { WebexError } from './types'
6
7
 
7
8
  const BASE_URL = 'https://webexapis.com/v1'
@@ -44,16 +45,40 @@ export class WebexClient {
44
45
  this.tokenType = config?.tokenType ?? null
45
46
  await this.login({ token })
46
47
 
47
- if (this.tokenType === 'extracted' && config?.encryptionKeys) {
48
- const keysMap = new Map(Object.entries(config.encryptionKeys))
49
- if (keysMap.size > 0) {
50
- this.encryption = new WebexEncryptionService(keysMap)
51
- }
48
+ if (this.tokenType === 'extracted') {
49
+ const keysMap = new Map(Object.entries(config?.encryptionKeys ?? {}))
50
+ this.encryption = new WebexEncryptionService(keysMap)
51
+ const kmsProvider = new KmsKeyProvider({ token })
52
+ this.encryption.setKeyProvider({
53
+ fetchKey: async (keyUri: string) => {
54
+ const serializedKey = await kmsProvider.fetchKey(keyUri)
55
+ if (serializedKey) {
56
+ await this.persistEncryptionKey(credManager, keyUri, serializedKey)
57
+ }
58
+ return serializedKey
59
+ },
60
+ close: () => kmsProvider.close(),
61
+ })
52
62
  }
53
63
 
54
64
  return this
55
65
  }
56
66
 
67
+ async dispose(): Promise<void> {
68
+ await this.encryption?.close()
69
+ }
70
+
71
+ private async persistEncryptionKey(
72
+ credManager: WebexCredentialManager,
73
+ keyUri: string,
74
+ serializedKey: string,
75
+ ): Promise<void> {
76
+ const latestConfig = await credManager.loadConfig()
77
+ if (!latestConfig) return
78
+ const encryptionKeys = { ...latestConfig.encryptionKeys, [keyUri]: serializedKey }
79
+ await credManager.saveConfig({ ...latestConfig, encryptionKeys } satisfies WebexConfig)
80
+ }
81
+
57
82
  private ensureAuth(): string {
58
83
  if (this.token === null) {
59
84
  throw new WebexError('Not authenticated. Call .login() first.', 'not_authenticated')
@@ -287,6 +312,9 @@ export class WebexClient {
287
312
  if (keyUri) {
288
313
  const encryptedDisplayName = await this.encryption.encryptText(keyUri, displayName)
289
314
  const encryptedContent = content ? await this.encryption.encryptText(keyUri, content) : undefined
315
+ if (content && !encryptedContent) {
316
+ throw new WebexError('Cannot encrypt message for Webex E2E conversation', 'encryption_failed')
317
+ }
290
318
  if (encryptedDisplayName) {
291
319
  const object: Record<string, string> = {
292
320
  objectType: 'comment',
@@ -297,6 +325,7 @@ export class WebexClient {
297
325
  }
298
326
  return { object, encryptionKeyUrl: keyUri }
299
327
  }
328
+ throw new WebexError('Cannot encrypt message for Webex E2E conversation', 'encryption_failed')
300
329
  }
301
330
  }
302
331
 
@@ -32,6 +32,7 @@ let mockGetMessage: ReturnType<typeof spyOn>
32
32
  let mockDeleteMessage: ReturnType<typeof spyOn>
33
33
  let mockEditMessage: ReturnType<typeof spyOn>
34
34
  let mockLogin: ReturnType<typeof spyOn>
35
+ let mockDispose: ReturnType<typeof spyOn>
35
36
  let consoleLogSpy: ReturnType<typeof spyOn>
36
37
  let consoleErrorSpy: ReturnType<typeof spyOn>
37
38
  let processExitSpy: ReturnType<typeof spyOn>
@@ -47,6 +48,7 @@ beforeEach(() => {
47
48
  mockLogin = protoSpy('login').mockImplementation(async function (this: WebexClient) {
48
49
  return this
49
50
  })
51
+ mockDispose = protoSpy('dispose').mockResolvedValue(undefined)
50
52
  mockSendMessage = protoSpy('sendMessage').mockResolvedValue(mockMessage)
51
53
  mockSendDirectMessage = protoSpy('sendDirectMessage').mockResolvedValue(mockMessage)
52
54
  mockListMessages = protoSpy('listMessages').mockResolvedValue([mockMessage, mockMessage2])
@@ -78,6 +80,7 @@ it('calls sendMessage with correct args and outputs result', async () => {
78
80
  expect(output).toContain('msg_123')
79
81
  expect(output).toContain('space_456')
80
82
  expect(output).toContain('user@example.com')
83
+ expect(mockDispose).toHaveBeenCalled()
81
84
  })
82
85
 
83
86
  it('passes markdown option when --markdown flag is set on send', async () => {
@@ -86,6 +89,14 @@ it('passes markdown option when --markdown flag is set on send', async () => {
86
89
  expect(mockSendMessage).toHaveBeenCalledWith('space_456', '**bold**', { markdown: true })
87
90
  })
88
91
 
92
+ it('disposes the client when send fails', async () => {
93
+ mockSendMessage.mockRejectedValue(new WebexError('Send failed', 'send_failed'))
94
+
95
+ await sendAction('space_456', 'Hello world', { pretty: false })
96
+
97
+ expect(mockDispose).toHaveBeenCalled()
98
+ })
99
+
89
100
  it('exits with code 1 when not authenticated on send', async () => {
90
101
  mockLogin.mockRejectedValue(new WebexError('No Webex credentials found.', 'no_credentials'))
91
102
 
@@ -166,6 +177,7 @@ it('calls editMessage with roomId in args and outputs result', async () => {
166
177
  const output = consoleLogSpy.mock.calls[0][0]
167
178
  expect(output).toContain('msg_123')
168
179
  expect(output).toContain('Updated message')
180
+ expect(mockDispose).toHaveBeenCalled()
169
181
  })
170
182
 
171
183
  it('passes markdown option to editMessage when --markdown flag is set', async () => {
@@ -6,14 +6,23 @@ import { formatOutput } from '@/shared/utils/output'
6
6
  import { WebexClient } from '../client'
7
7
  import type { WebexMessage } from '../types'
8
8
 
9
+ async function withWebexClient<T>(run: (client: WebexClient) => Promise<T>): Promise<T> {
10
+ const client = new WebexClient()
11
+ try {
12
+ await client.login()
13
+ return await run(client)
14
+ } finally {
15
+ await client.dispose()
16
+ }
17
+ }
18
+
9
19
  export async function sendAction(
10
20
  spaceId: string,
11
21
  text: string,
12
22
  options: { markdown?: boolean; pretty?: boolean },
13
23
  ): Promise<void> {
14
24
  try {
15
- const client = await new WebexClient().login()
16
- const message = await client.sendMessage(spaceId, text, { markdown: options.markdown })
25
+ const message = await withWebexClient((client) => client.sendMessage(spaceId, text, { markdown: options.markdown }))
17
26
 
18
27
  const output = {
19
28
  id: message.id,
@@ -31,9 +40,8 @@ export async function sendAction(
31
40
 
32
41
  export async function listAction(spaceId: string, options: { limit?: number; pretty?: boolean }): Promise<void> {
33
42
  try {
34
- const client = await new WebexClient().login()
35
43
  const limit = options.limit ?? 50
36
- const messages = await client.listMessages(spaceId, { max: limit })
44
+ const messages = await withWebexClient((client) => client.listMessages(spaceId, { max: limit }))
37
45
 
38
46
  const output = messages.map((msg: WebexMessage) => ({
39
47
  id: msg.id,
@@ -51,8 +59,7 @@ export async function listAction(spaceId: string, options: { limit?: number; pre
51
59
 
52
60
  export async function getAction(messageId: string, options: { pretty?: boolean }): Promise<void> {
53
61
  try {
54
- const client = await new WebexClient().login()
55
- const message = await client.getMessage(messageId)
62
+ const message = await withWebexClient((client) => client.getMessage(messageId))
56
63
 
57
64
  const output = {
58
65
  id: message.id,
@@ -75,8 +82,7 @@ export async function deleteAction(messageId: string, options: { force?: boolean
75
82
  return process.exit(0)
76
83
  }
77
84
 
78
- const client = await new WebexClient().login()
79
- await client.deleteMessage(messageId)
85
+ await withWebexClient((client) => client.deleteMessage(messageId))
80
86
 
81
87
  console.log(formatOutput({ deleted: messageId }, options.pretty))
82
88
  } catch (error) {
@@ -91,10 +97,11 @@ export async function editAction(
91
97
  options: { markdown?: boolean; pretty?: boolean },
92
98
  ): Promise<void> {
93
99
  try {
94
- const client = await new WebexClient().login()
95
- const message = await client.editMessage(messageId, spaceId, text, {
96
- markdown: options.markdown,
97
- })
100
+ const message = await withWebexClient((client) =>
101
+ client.editMessage(messageId, spaceId, text, {
102
+ markdown: options.markdown,
103
+ }),
104
+ )
98
105
 
99
106
  const output = {
100
107
  id: message.id,
@@ -116,8 +123,9 @@ export async function dmAction(
116
123
  options: { markdown?: boolean; pretty?: boolean },
117
124
  ): Promise<void> {
118
125
  try {
119
- const client = await new WebexClient().login()
120
- const message = await client.sendDirectMessage(email, text, { markdown: options.markdown })
126
+ const message = await withWebexClient((client) =>
127
+ client.sendDirectMessage(email, text, { markdown: options.markdown }),
128
+ )
121
129
 
122
130
  const output = {
123
131
  id: message.id,
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'bun:test'
1
+ import { describe, expect, it, mock } from 'bun:test'
2
2
 
3
3
  import * as jose from 'node-jose'
4
4
 
@@ -11,12 +11,16 @@ const decodeJweHeader = (jwe: string): Record<string, unknown> => {
11
11
  return JSON.parse(json) as Record<string, unknown>
12
12
  }
13
13
 
14
- const createKeyring = async (keyUri: string) => {
14
+ const createSerializedKey = async (keyUri: string) => {
15
15
  const keystore = jose.JWK.createKeyStore()
16
16
  const key = await keystore.generate('oct', 256, { alg: 'A256GCM' })
17
17
  const jwk = key.toJSON(true)
18
+ return JSON.stringify({ uri: keyUri, jwk })
19
+ }
20
+
21
+ const createKeyring = async (keyUri: string) => {
18
22
  const rawKeys = new Map<string, string>()
19
- rawKeys.set(keyUri, JSON.stringify({ jwk }))
23
+ rawKeys.set(keyUri, await createSerializedKey(keyUri))
20
24
  return new WebexEncryptionService(rawKeys)
21
25
  }
22
26
 
@@ -51,4 +55,55 @@ describe('WebexEncryptionService', () => {
51
55
 
52
56
  expect(plaintext).toBe('round trip')
53
57
  })
58
+
59
+ it('getKey returns cached key without calling provider when key is present', async () => {
60
+ const service = await createKeyring(keyUri)
61
+ const provider = { fetchKey: mock(async () => null as string | null) }
62
+ service.setKeyProvider(provider)
63
+
64
+ const key = await service.getKey(keyUri)
65
+
66
+ expect(key).not.toBeNull()
67
+ expect(provider.fetchKey).not.toHaveBeenCalled()
68
+ })
69
+
70
+ it('getKey calls provider and returns key when key is missing', async () => {
71
+ const missingKeyUri = 'kms://kms-aore.wbx2.com/keys/0d7a0dfb-0464-40ce-8f3d-e65a33b61561'
72
+ const serializedKey = await createSerializedKey(missingKeyUri)
73
+ const service = new WebexEncryptionService(new Map())
74
+ const provider = { fetchKey: mock(async () => serializedKey) }
75
+ service.setKeyProvider(provider)
76
+
77
+ const key = await service.getKey(missingKeyUri)
78
+
79
+ expect(key).not.toBeNull()
80
+ expect(provider.fetchKey).toHaveBeenCalledWith(missingKeyUri)
81
+ })
82
+
83
+ it('getKey returns null when provider returns null', async () => {
84
+ const missingKeyUri = 'kms://kms-aore.wbx2.com/keys/13d6256d-f7f1-4b98-8102-4d3d87b2834a'
85
+ const service = new WebexEncryptionService(new Map())
86
+ const provider = { fetchKey: mock(async () => null as string | null) }
87
+ service.setKeyProvider(provider)
88
+
89
+ const key = await service.getKey(missingKeyUri)
90
+
91
+ expect(key).toBeNull()
92
+ expect(provider.fetchKey).toHaveBeenCalledWith(missingKeyUri)
93
+ })
94
+
95
+ it('getKey reuses provider result from raw keys after first fetch', async () => {
96
+ const missingKeyUri = 'kms://kms-aore.wbx2.com/keys/84afb005-5ba5-49c8-bd46-0c5d7ddf1c30'
97
+ const serializedKey = await createSerializedKey(missingKeyUri)
98
+ const service = new WebexEncryptionService(new Map())
99
+ const provider = { fetchKey: mock(async () => serializedKey) }
100
+ service.setKeyProvider(provider)
101
+
102
+ await service.getKey(missingKeyUri)
103
+ ;(service as unknown as { keyCache: Map<string, jose.JWK.Key> }).keyCache.clear()
104
+ const key = await service.getKey(missingKeyUri)
105
+
106
+ expect(key).not.toBeNull()
107
+ expect(provider.fetchKey).toHaveBeenCalledTimes(1)
108
+ })
54
109
  })
@@ -1,23 +1,41 @@
1
1
  import * as jose from 'node-jose'
2
2
 
3
+ export interface WebexKeyProvider {
4
+ fetchKey(keyUri: string): Promise<string | null>
5
+ close?(): Promise<void>
6
+ }
7
+
3
8
  export class WebexEncryptionService {
4
9
  private rawKeys: Map<string, string>
5
10
  private keyCache: Map<string, jose.JWK.Key> = new Map()
11
+ private keyProvider: WebexKeyProvider | null = null
6
12
 
7
13
  constructor(serializedKeys: Map<string, string>) {
8
14
  this.rawKeys = serializedKeys
9
15
  }
10
16
 
17
+ setKeyProvider(provider: WebexKeyProvider): void {
18
+ this.keyProvider = provider
19
+ }
20
+
21
+ async close(): Promise<void> {
22
+ await this.keyProvider?.close?.()
23
+ }
24
+
11
25
  async getKey(keyUri: string): Promise<jose.JWK.Key | null> {
12
26
  const cached = this.keyCache.get(keyUri)
13
27
  if (cached) return cached
14
28
 
15
- const raw = this.rawKeys.get(keyUri)
29
+ let raw = this.rawKeys.get(keyUri)
30
+ if (!raw && this.keyProvider) {
31
+ raw = (await this.keyProvider.fetchKey(keyUri)) ?? undefined
32
+ }
16
33
  if (!raw) return null
17
34
 
18
35
  try {
19
36
  const parsed = JSON.parse(raw) as { jwk: object }
20
37
  const joseKey = await jose.JWK.asKey(parsed.jwk)
38
+ this.rawKeys.set(keyUri, raw)
21
39
  this.keyCache.set(keyUri, joseKey)
22
40
  return joseKey
23
41
  } catch {
@@ -0,0 +1,99 @@
1
+ import { DeviceManager, KmsClient, MercurySocket, noopLogger } from 'webex-message-handler'
2
+ import WebSocket from 'ws'
3
+
4
+ interface KmsKeyProviderOptions {
5
+ token: string
6
+ logger?: { debug(message: string): void }
7
+ }
8
+
9
+ interface Registration {
10
+ webSocketUrl: string
11
+ deviceUrl: string
12
+ userId: string
13
+ encryptionServiceUrl: string
14
+ }
15
+
16
+ type HttpRequest = {
17
+ url: string
18
+ method: string
19
+ headers: Record<string, string>
20
+ body?: string
21
+ }
22
+
23
+ export class KmsKeyProvider {
24
+ private token: string
25
+ private logger?: { debug(message: string): void }
26
+ private mercury: MercurySocket | null = null
27
+ private kms: KmsClient | null = null
28
+ private readyPromise: Promise<void> | null = null
29
+
30
+ constructor(options: KmsKeyProviderOptions) {
31
+ this.token = options.token
32
+ this.logger = options.logger
33
+ }
34
+
35
+ async fetchKey(keyUri: string): Promise<string | null> {
36
+ try {
37
+ await this.ensureReady()
38
+ const key = await this.kms?.getKey(keyUri)
39
+ if (!key) return null
40
+ return JSON.stringify({ uri: keyUri, jwk: key.toJSON(true) })
41
+ } catch (error) {
42
+ this.logger?.debug(`Webex KMS key fetch failed: ${error instanceof Error ? error.message : String(error)}`)
43
+ await this.close()
44
+ this.readyPromise = null
45
+ return null
46
+ }
47
+ }
48
+
49
+ async close(): Promise<void> {
50
+ await this.mercury?.disconnect().catch(() => undefined)
51
+ this.mercury = null
52
+ this.kms = null
53
+ this.readyPromise = null
54
+ }
55
+
56
+ private async ensureReady(): Promise<void> {
57
+ this.readyPromise ??= this.initialize()
58
+ await this.readyPromise
59
+ }
60
+
61
+ private async initialize(): Promise<void> {
62
+ const httpDo = async (req: HttpRequest) => {
63
+ const res = await fetch(req.url, {
64
+ method: req.method,
65
+ headers: req.headers,
66
+ body: req.body,
67
+ })
68
+ return {
69
+ status: res.status,
70
+ ok: res.ok,
71
+ json: () => res.json(),
72
+ text: () => res.text(),
73
+ }
74
+ }
75
+ const wsFactory = (url: string) => new WebSocket(url) as never
76
+ const dm = new DeviceManager({ logger: noopLogger, httpDo })
77
+ const reg = (await dm.register(this.token)) as Registration
78
+ const mercury = new MercurySocket({ logger: noopLogger, wsFactory })
79
+ const kms = new KmsClient({
80
+ token: this.token,
81
+ deviceUrl: reg.deviceUrl,
82
+ userId: reg.userId,
83
+ encryptionServiceUrl: reg.encryptionServiceUrl,
84
+ logger: noopLogger,
85
+ httpDo,
86
+ })
87
+ mercury.on('kms:response', (data: unknown) => kms.handleKmsMessage(data))
88
+ await mercury.connect(reg.webSocketUrl, this.token)
89
+ this.mercury = mercury
90
+ try {
91
+ await kms.initialize()
92
+ } catch (error) {
93
+ await mercury.disconnect().catch(() => undefined)
94
+ this.mercury = null
95
+ throw error
96
+ }
97
+ this.kms = kms
98
+ }
99
+ }
@@ -0,0 +1,45 @@
1
+ export {}
2
+
3
+ declare module 'webex-message-handler' {
4
+ import type * as jose from 'node-jose'
5
+
6
+ type Logger = Record<string, (...args: unknown[]) => void>
7
+ type HttpRequest = { url: string; method: string; headers: Record<string, string>; body?: string }
8
+ type HttpResponse = { status: number; ok: boolean; json(): Promise<unknown>; text(): Promise<string> }
9
+ type HttpDo = (req: HttpRequest) => Promise<HttpResponse>
10
+
11
+ export const noopLogger: Logger
12
+ export const consoleLogger: Logger
13
+
14
+ export class DeviceManager {
15
+ constructor(options: { logger: Logger; httpDo: HttpDo })
16
+ register(token: string): Promise<{
17
+ webSocketUrl: string
18
+ deviceUrl: string
19
+ userId: string
20
+ services: unknown
21
+ encryptionServiceUrl: string
22
+ }>
23
+ }
24
+
25
+ export class MercurySocket {
26
+ constructor(options: { logger: Logger; wsFactory: (url: string) => unknown })
27
+ on(event: 'kms:response', handler: (data: unknown) => void): void
28
+ connect(webSocketUrl: string, token: string): Promise<void>
29
+ disconnect(): Promise<void>
30
+ }
31
+
32
+ export class KmsClient {
33
+ constructor(options: {
34
+ token: string
35
+ deviceUrl: string
36
+ userId: string
37
+ encryptionServiceUrl: string
38
+ logger: Logger
39
+ httpDo: HttpDo
40
+ })
41
+ initialize(): Promise<void>
42
+ getKey(keyUri: string): Promise<jose.JWK.Key | null>
43
+ handleKmsMessage(data: unknown): void
44
+ }
45
+ }