beam-protocol-sdk 0.2.2 → 0.3.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.
- package/package.json +3 -23
- package/src/client.ts +383 -0
- package/src/directory.ts +91 -0
- package/src/frames.ts +161 -0
- package/src/identity.ts +85 -0
- package/src/index.ts +25 -0
- package/src/types.ts +71 -0
- package/tests/client.test.ts +217 -0
- package/tests/directory.test.ts +202 -0
- package/tests/frames.test.ts +213 -0
- package/tests/identity.test.ts +130 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +8 -0
- package/README.md +0 -177
- package/dist/client.d.ts +0 -96
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -293
- package/dist/client.js.map +0 -1
- package/dist/directory.d.ts +0 -15
- package/dist/directory.d.ts.map +0 -1
- package/dist/directory.js +0 -82
- package/dist/directory.js.map +0 -1
- package/dist/frames.d.ts +0 -29
- package/dist/frames.d.ts.map +0 -1
- package/dist/frames.js +0 -133
- package/dist/frames.js.map +0 -1
- package/dist/identity.d.ts +0 -19
- package/dist/identity.d.ts.map +0 -1
- package/dist/identity.js +0 -65
- package/dist/identity.js.map +0 -1
- package/dist/index.d.ts +0 -6
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -5
- package/dist/index.js.map +0 -1
- package/dist/types.d.ts +0 -60
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beam-protocol-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "TypeScript SDK for the Beam Protocol \u2014 Agent-to-Agent communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -29,25 +29,5 @@
|
|
|
29
29
|
"engines": {
|
|
30
30
|
"node": ">=18.0.0"
|
|
31
31
|
},
|
|
32
|
-
"license": "Apache-2.0"
|
|
33
|
-
|
|
34
|
-
"dist",
|
|
35
|
-
"README.md",
|
|
36
|
-
"LICENSE"
|
|
37
|
-
],
|
|
38
|
-
"repository": {
|
|
39
|
-
"type": "git",
|
|
40
|
-
"url": "https://github.com/Beam-directory/beam-protocol.git",
|
|
41
|
-
"directory": "packages/sdk-typescript"
|
|
42
|
-
},
|
|
43
|
-
"homepage": "https://beam.directory",
|
|
44
|
-
"keywords": [
|
|
45
|
-
"beam",
|
|
46
|
-
"protocol",
|
|
47
|
-
"agent",
|
|
48
|
-
"a2a",
|
|
49
|
-
"ai",
|
|
50
|
-
"communication",
|
|
51
|
-
"ed25519"
|
|
52
|
-
]
|
|
53
|
-
}
|
|
32
|
+
"license": "Apache-2.0"
|
|
33
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { BeamIdentity } from './identity.js'
|
|
2
|
+
import { BeamDirectory } from './directory.js'
|
|
3
|
+
import { createIntentFrame, createResultFrame, signFrame, validateIntentFrame } from './frames.js'
|
|
4
|
+
import type {
|
|
5
|
+
AgentRecord,
|
|
6
|
+
BeamClientConfig,
|
|
7
|
+
BeamIdString,
|
|
8
|
+
IntentFrame,
|
|
9
|
+
ResultFrame,
|
|
10
|
+
} from './types.js'
|
|
11
|
+
|
|
12
|
+
interface WebSocketLike {
|
|
13
|
+
readyState: number
|
|
14
|
+
readonly OPEN: number
|
|
15
|
+
send(data: string): void
|
|
16
|
+
close(): void
|
|
17
|
+
onopen: ((event: unknown) => void) | null
|
|
18
|
+
onclose: ((event: unknown) => void) | null
|
|
19
|
+
onerror: ((event: unknown) => void) | null
|
|
20
|
+
onmessage: ((event: { data: string }) => void) | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type IntentHandler = (
|
|
24
|
+
frame: IntentFrame,
|
|
25
|
+
respond: (options: {
|
|
26
|
+
success: boolean
|
|
27
|
+
payload?: Record<string, unknown>
|
|
28
|
+
error?: string
|
|
29
|
+
errorCode?: string
|
|
30
|
+
latency?: number
|
|
31
|
+
}) => ResultFrame
|
|
32
|
+
) => void | Promise<void>
|
|
33
|
+
|
|
34
|
+
interface PendingResult {
|
|
35
|
+
resolve: (frame: ResultFrame) => void
|
|
36
|
+
reject: (err: Error) => void
|
|
37
|
+
timer: ReturnType<typeof setTimeout>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const INITIAL_RECONNECT_DELAY_MS = 1_000
|
|
41
|
+
const MAX_RECONNECT_DELAY_MS = 30_000
|
|
42
|
+
const MAX_RECONNECT_ATTEMPTS = 10
|
|
43
|
+
const RECONNECT_FACTOR = 2
|
|
44
|
+
|
|
45
|
+
async function openWebSocket(url: string): Promise<WebSocketLike> {
|
|
46
|
+
if (typeof globalThis.WebSocket !== 'undefined') {
|
|
47
|
+
return new (globalThis.WebSocket as new (url: string) => WebSocketLike)(url)
|
|
48
|
+
}
|
|
49
|
+
const { default: WS } = await import('ws')
|
|
50
|
+
return new WS(url) as unknown as WebSocketLike
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class BeamClient {
|
|
54
|
+
private readonly _identity: BeamIdentity
|
|
55
|
+
private readonly _directory: BeamDirectory
|
|
56
|
+
private readonly _directoryUrl: string
|
|
57
|
+
private readonly _autoReconnect: boolean
|
|
58
|
+
private readonly _onDisconnect?: () => void
|
|
59
|
+
private readonly _onReconnect?: () => void
|
|
60
|
+
private _ws: WebSocketLike | null = null
|
|
61
|
+
private _wsConnected = false
|
|
62
|
+
private _isConnecting = false
|
|
63
|
+
private _manualDisconnect = false
|
|
64
|
+
private _reconnectAttempts = 0
|
|
65
|
+
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
66
|
+
private readonly _pendingResults = new Map<string, PendingResult>()
|
|
67
|
+
private readonly _intentHandlers = new Map<string, IntentHandler>()
|
|
68
|
+
|
|
69
|
+
constructor(config: BeamClientConfig) {
|
|
70
|
+
this._identity = BeamIdentity.fromData(config.identity)
|
|
71
|
+
this._directoryUrl = config.directoryUrl
|
|
72
|
+
this._directory = new BeamDirectory({ baseUrl: config.directoryUrl })
|
|
73
|
+
this._autoReconnect = config.autoReconnect ?? true
|
|
74
|
+
this._onDisconnect = config.onDisconnect
|
|
75
|
+
this._onReconnect = config.onReconnect
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get beamId(): BeamIdString {
|
|
79
|
+
return this._identity.beamId
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get directory(): BeamDirectory {
|
|
83
|
+
return this._directory
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async register(displayName: string, capabilities: string[]): Promise<AgentRecord> {
|
|
87
|
+
const parsed = BeamIdentity.parseBeamId(this._identity.beamId)
|
|
88
|
+
if (!parsed) throw new Error('Invalid beam ID on identity')
|
|
89
|
+
|
|
90
|
+
return this._directory.register({
|
|
91
|
+
beamId: this._identity.beamId,
|
|
92
|
+
displayName,
|
|
93
|
+
capabilities,
|
|
94
|
+
publicKey: this._identity.publicKeyBase64,
|
|
95
|
+
org: parsed.org,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async connect(): Promise<void> {
|
|
100
|
+
if (this._ws && this._wsConnected) return
|
|
101
|
+
if (this._isConnecting) {
|
|
102
|
+
return new Promise<void>((resolve, reject) => {
|
|
103
|
+
const startedAt = Date.now()
|
|
104
|
+
const poll = () => {
|
|
105
|
+
if (this._wsConnected) {
|
|
106
|
+
resolve()
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (!this._isConnecting) {
|
|
110
|
+
reject(new Error('WebSocket connection failed'))
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (Date.now() - startedAt > 30_000) {
|
|
114
|
+
reject(new Error('Timed out waiting for WebSocket connection'))
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
setTimeout(poll, 50)
|
|
118
|
+
}
|
|
119
|
+
poll()
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this._manualDisconnect = false
|
|
124
|
+
this._clearReconnectTimer()
|
|
125
|
+
await this._openConnection(false)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private _getWebSocketUrl(): string {
|
|
129
|
+
return this._directoryUrl
|
|
130
|
+
.replace(/^http:\/\//, 'ws://')
|
|
131
|
+
.replace(/^https:\/\//, 'wss://')
|
|
132
|
+
.replace(/\/$/, '') + `/ws?beamId=${encodeURIComponent(this._identity.beamId)}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private async _openConnection(isReconnect: boolean): Promise<void> {
|
|
136
|
+
this._isConnecting = true
|
|
137
|
+
const wsUrl = this._getWebSocketUrl()
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const ws = await openWebSocket(wsUrl)
|
|
141
|
+
await new Promise<void>((resolve, reject) => {
|
|
142
|
+
let settled = false
|
|
143
|
+
const previousHandleMessage = this._handleMessage.bind(this)
|
|
144
|
+
|
|
145
|
+
const finishResolve = () => {
|
|
146
|
+
if (settled) return
|
|
147
|
+
settled = true
|
|
148
|
+
this._handleMessage = previousHandleMessage
|
|
149
|
+
this._isConnecting = false
|
|
150
|
+
this._wsConnected = true
|
|
151
|
+
this._reconnectAttempts = 0
|
|
152
|
+
resolve()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const finishReject = (error: Error) => {
|
|
156
|
+
if (settled) return
|
|
157
|
+
settled = true
|
|
158
|
+
this._handleMessage = previousHandleMessage
|
|
159
|
+
this._isConnecting = false
|
|
160
|
+
this._wsConnected = false
|
|
161
|
+
this._ws = null
|
|
162
|
+
reject(error)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this._ws = ws
|
|
166
|
+
|
|
167
|
+
ws.onopen = () => {
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
ws.onmessage = (event) => {
|
|
171
|
+
this._handleMessage(event.data)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
ws.onclose = () => {
|
|
175
|
+
const wasConnected = this._wsConnected || settled
|
|
176
|
+
this._handleSocketClose(wasConnected)
|
|
177
|
+
if (!settled) {
|
|
178
|
+
finishReject(new Error('WebSocket connection closed before handshake completed'))
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
ws.onerror = (event) => {
|
|
183
|
+
if (!settled) {
|
|
184
|
+
finishReject(new Error(`WebSocket connection error: ${String(event)}`))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this._handleMessage = (data: string) => {
|
|
189
|
+
try {
|
|
190
|
+
const msg = JSON.parse(data) as { type: string }
|
|
191
|
+
if (msg.type === 'connected') {
|
|
192
|
+
finishResolve()
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
}
|
|
197
|
+
previousHandleMessage(data)
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
if (isReconnect) {
|
|
202
|
+
this._onReconnect?.()
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
this._isConnecting = false
|
|
206
|
+
this._wsConnected = false
|
|
207
|
+
this._ws = null
|
|
208
|
+
if (isReconnect) {
|
|
209
|
+
this._scheduleReconnect()
|
|
210
|
+
}
|
|
211
|
+
throw error
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private _handleSocketClose(wasConnected: boolean): void {
|
|
216
|
+
this._wsConnected = false
|
|
217
|
+
this._isConnecting = false
|
|
218
|
+
this._ws = null
|
|
219
|
+
|
|
220
|
+
for (const [nonce, pending] of this._pendingResults) {
|
|
221
|
+
clearTimeout(pending.timer)
|
|
222
|
+
pending.reject(new Error('WebSocket connection closed'))
|
|
223
|
+
this._pendingResults.delete(nonce)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (wasConnected) {
|
|
227
|
+
this._onDisconnect?.()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!this._manualDisconnect && this._autoReconnect) {
|
|
231
|
+
this._scheduleReconnect()
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private _scheduleReconnect(): void {
|
|
236
|
+
if (this._reconnectTimer || this._isConnecting || this._manualDisconnect || !this._autoReconnect) {
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const delay = Math.min(
|
|
244
|
+
INITIAL_RECONNECT_DELAY_MS * (RECONNECT_FACTOR ** this._reconnectAttempts),
|
|
245
|
+
MAX_RECONNECT_DELAY_MS
|
|
246
|
+
)
|
|
247
|
+
this._reconnectAttempts += 1
|
|
248
|
+
|
|
249
|
+
this._reconnectTimer = setTimeout(() => {
|
|
250
|
+
this._reconnectTimer = null
|
|
251
|
+
void this._openConnection(true).catch(() => {
|
|
252
|
+
this._scheduleReconnect()
|
|
253
|
+
})
|
|
254
|
+
}, delay)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private _clearReconnectTimer(): void {
|
|
258
|
+
if (this._reconnectTimer) {
|
|
259
|
+
clearTimeout(this._reconnectTimer)
|
|
260
|
+
this._reconnectTimer = null
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private _handleMessage(data: string): void {
|
|
265
|
+
let msg: Record<string, unknown>
|
|
266
|
+
try {
|
|
267
|
+
msg = JSON.parse(data) as Record<string, unknown>
|
|
268
|
+
} catch {
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (msg['type'] === 'result') {
|
|
273
|
+
const frame = msg['frame'] as ResultFrame | undefined
|
|
274
|
+
if (!frame) return
|
|
275
|
+
const pending = this._pendingResults.get(frame.nonce)
|
|
276
|
+
if (pending) {
|
|
277
|
+
clearTimeout(pending.timer)
|
|
278
|
+
this._pendingResults.delete(frame.nonce)
|
|
279
|
+
pending.resolve(frame)
|
|
280
|
+
}
|
|
281
|
+
} else if (msg['type'] === 'intent') {
|
|
282
|
+
const frame = msg['frame'] as IntentFrame | undefined
|
|
283
|
+
const senderPublicKey = msg['senderPublicKey'] as string | undefined
|
|
284
|
+
if (!frame || !senderPublicKey) return
|
|
285
|
+
|
|
286
|
+
const validation = validateIntentFrame(frame, senderPublicKey)
|
|
287
|
+
if (!validation.valid) return
|
|
288
|
+
|
|
289
|
+
const handler = this._intentHandlers.get(frame.intent) ?? this._intentHandlers.get('*')
|
|
290
|
+
if (!handler) return
|
|
291
|
+
|
|
292
|
+
const startTime = Date.now()
|
|
293
|
+
const respond = (options: {
|
|
294
|
+
success: boolean
|
|
295
|
+
payload?: Record<string, unknown>
|
|
296
|
+
error?: string
|
|
297
|
+
errorCode?: string
|
|
298
|
+
latency?: number
|
|
299
|
+
}): ResultFrame => {
|
|
300
|
+
const latency = options.latency ?? (Date.now() - startTime)
|
|
301
|
+
const resultFrame = createResultFrame(
|
|
302
|
+
{ ...options, nonce: frame.nonce, latency },
|
|
303
|
+
this._identity
|
|
304
|
+
)
|
|
305
|
+
if (this._ws && this._wsConnected) {
|
|
306
|
+
this._ws.send(JSON.stringify({ type: 'result', frame: resultFrame }))
|
|
307
|
+
}
|
|
308
|
+
return resultFrame
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
Promise.resolve(handler(frame, respond)).catch(() => {
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async send(
|
|
317
|
+
to: BeamIdString,
|
|
318
|
+
intent: string,
|
|
319
|
+
payload?: Record<string, unknown>,
|
|
320
|
+
timeoutMs = 30_000
|
|
321
|
+
): Promise<ResultFrame> {
|
|
322
|
+
const frame = createIntentFrame({ intent, from: this._identity.beamId, to, payload }, this._identity)
|
|
323
|
+
signFrame(frame, this._identity.export().privateKeyBase64)
|
|
324
|
+
|
|
325
|
+
if (this._ws && this._wsConnected) {
|
|
326
|
+
return this._sendViaWebSocket(frame, timeoutMs)
|
|
327
|
+
}
|
|
328
|
+
return this._sendViaHttp(frame)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private _sendViaWebSocket(frame: IntentFrame, timeoutMs: number): Promise<ResultFrame> {
|
|
332
|
+
return new Promise<ResultFrame>((resolve, reject) => {
|
|
333
|
+
const timer = setTimeout(() => {
|
|
334
|
+
this._pendingResults.delete(frame.nonce)
|
|
335
|
+
reject(new Error(`Intent "${frame.intent}" timed out after ${timeoutMs}ms`))
|
|
336
|
+
}, timeoutMs)
|
|
337
|
+
|
|
338
|
+
this._pendingResults.set(frame.nonce, { resolve, reject, timer })
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
this._ws!.send(JSON.stringify({ type: 'intent', frame }))
|
|
342
|
+
} catch (err) {
|
|
343
|
+
clearTimeout(timer)
|
|
344
|
+
this._pendingResults.delete(frame.nonce)
|
|
345
|
+
reject(err instanceof Error ? err : new Error(String(err)))
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private async _sendViaHttp(frame: IntentFrame): Promise<ResultFrame> {
|
|
351
|
+
const baseUrl = this._directoryUrl.replace(/\/$/, '')
|
|
352
|
+
const res = await fetch(`${baseUrl}/intents`, {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: { 'Content-Type': 'application/json' },
|
|
355
|
+
body: JSON.stringify(frame),
|
|
356
|
+
})
|
|
357
|
+
if (!res.ok) {
|
|
358
|
+
throw new Error(`HTTP intent delivery failed: ${res.status} ${res.statusText}`)
|
|
359
|
+
}
|
|
360
|
+
return res.json() as Promise<ResultFrame>
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
on(intent: string, handler: IntentHandler): this {
|
|
364
|
+
this._intentHandlers.set(intent, handler)
|
|
365
|
+
return this
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
disconnect(): void {
|
|
369
|
+
this._manualDisconnect = true
|
|
370
|
+
this._clearReconnectTimer()
|
|
371
|
+
|
|
372
|
+
if (this._ws) {
|
|
373
|
+
this._wsConnected = false
|
|
374
|
+
for (const [nonce, pending] of this._pendingResults) {
|
|
375
|
+
clearTimeout(pending.timer)
|
|
376
|
+
pending.reject(new Error('Client disconnected'))
|
|
377
|
+
this._pendingResults.delete(nonce)
|
|
378
|
+
}
|
|
379
|
+
this._ws.close()
|
|
380
|
+
this._ws = null
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
package/src/directory.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentRecord,
|
|
3
|
+
AgentRegistration,
|
|
4
|
+
AgentSearchQuery,
|
|
5
|
+
BeamIdString,
|
|
6
|
+
DirectoryConfig
|
|
7
|
+
} from './types.js'
|
|
8
|
+
|
|
9
|
+
/** Normalize snake_case server response to camelCase AgentRecord */
|
|
10
|
+
function normalizeAgent(raw: Record<string, unknown>): AgentRecord {
|
|
11
|
+
return {
|
|
12
|
+
beamId: (raw.beamId ?? raw.beam_id) as AgentRecord['beamId'],
|
|
13
|
+
displayName: (raw.displayName ?? raw.display_name ?? '') as string,
|
|
14
|
+
capabilities: (raw.capabilities ?? []) as string[],
|
|
15
|
+
publicKey: (raw.publicKey ?? raw.public_key ?? '') as string,
|
|
16
|
+
org: (raw.org ?? '') as string,
|
|
17
|
+
trustScore: (raw.trustScore ?? raw.trust_score ?? 0) as number,
|
|
18
|
+
verified: (raw.verified ?? false) as boolean,
|
|
19
|
+
createdAt: (raw.createdAt ?? raw.created_at ?? '') as string,
|
|
20
|
+
lastSeen: (raw.lastSeen ?? raw.last_seen ?? '') as string,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class BeamDirectoryError extends Error {
|
|
25
|
+
constructor(
|
|
26
|
+
message: string,
|
|
27
|
+
public readonly statusCode: number
|
|
28
|
+
) {
|
|
29
|
+
super(message)
|
|
30
|
+
this.name = 'BeamDirectoryError'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class BeamDirectory {
|
|
35
|
+
private readonly baseUrl: string
|
|
36
|
+
private readonly headers: Record<string, string>
|
|
37
|
+
|
|
38
|
+
constructor(config: DirectoryConfig) {
|
|
39
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, '')
|
|
40
|
+
this.headers = {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
...(config.apiKey && { Authorization: `Bearer ${config.apiKey}` })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async register(registration: AgentRegistration): Promise<AgentRecord> {
|
|
47
|
+
const res = await fetch(`${this.baseUrl}/agents/register`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: this.headers,
|
|
50
|
+
body: JSON.stringify(registration)
|
|
51
|
+
})
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
const body = await res.json().catch(() => ({ error: res.statusText })) as { error: string }
|
|
54
|
+
throw new BeamDirectoryError(`Registration failed: ${body.error}`, res.status)
|
|
55
|
+
}
|
|
56
|
+
return res.json() as Promise<AgentRecord>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async lookup(beamId: BeamIdString): Promise<AgentRecord | null> {
|
|
60
|
+
const res = await fetch(`${this.baseUrl}/agents/${encodeURIComponent(beamId)}`, {
|
|
61
|
+
headers: this.headers
|
|
62
|
+
})
|
|
63
|
+
if (res.status === 404) return null
|
|
64
|
+
if (!res.ok) throw new BeamDirectoryError(`Lookup failed: ${res.statusText}`, res.status)
|
|
65
|
+
return res.json() as Promise<AgentRecord>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async search(query: AgentSearchQuery): Promise<AgentRecord[]> {
|
|
69
|
+
const params = new URLSearchParams()
|
|
70
|
+
if (query.org) params.set('org', query.org)
|
|
71
|
+
if (query.capabilities?.length) params.set('capabilities', query.capabilities.join(','))
|
|
72
|
+
if (query.minTrustScore !== undefined) params.set('minTrustScore', String(query.minTrustScore))
|
|
73
|
+
if (query.limit !== undefined) params.set('limit', String(query.limit))
|
|
74
|
+
|
|
75
|
+
const res = await fetch(`${this.baseUrl}/agents/search?${params}`, { headers: this.headers })
|
|
76
|
+
if (!res.ok) throw new BeamDirectoryError(`Search failed: ${res.statusText}`, res.status)
|
|
77
|
+
const body = await res.json() as { agents?: Record<string, unknown>[] } | Record<string, unknown>[]
|
|
78
|
+
const raw = Array.isArray(body) ? body : (body?.agents ?? [])
|
|
79
|
+
return raw.map(normalizeAgent)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async heartbeat(beamId: BeamIdString): Promise<void> {
|
|
83
|
+
const res = await fetch(`${this.baseUrl}/agents/${encodeURIComponent(beamId)}/heartbeat`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: this.headers
|
|
86
|
+
})
|
|
87
|
+
if (!res.ok && res.status !== 404) {
|
|
88
|
+
throw new BeamDirectoryError(`Heartbeat failed: ${res.statusText}`, res.status)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/frames.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { randomUUID, createPrivateKey, sign } from 'node:crypto'
|
|
2
|
+
import type { IntentFrame, ResultFrame, BeamIdString } from './types.js'
|
|
3
|
+
import { BeamIdentity } from './identity.js'
|
|
4
|
+
|
|
5
|
+
export const MAX_FRAME_SIZE = 4 * 1024 // 4KB hard limit
|
|
6
|
+
export const REPLAY_WINDOW_MS = 5 * 60 * 1000 // 5 minutes
|
|
7
|
+
|
|
8
|
+
export function createIntentFrame(
|
|
9
|
+
options: {
|
|
10
|
+
intent: string
|
|
11
|
+
from: BeamIdString
|
|
12
|
+
to: BeamIdString
|
|
13
|
+
payload?: Record<string, unknown>
|
|
14
|
+
},
|
|
15
|
+
identity: BeamIdentity
|
|
16
|
+
): IntentFrame {
|
|
17
|
+
const frame: IntentFrame = {
|
|
18
|
+
v: '1',
|
|
19
|
+
intent: options.intent,
|
|
20
|
+
from: options.from,
|
|
21
|
+
to: options.to,
|
|
22
|
+
payload: options.payload ?? {},
|
|
23
|
+
nonce: randomUUID(),
|
|
24
|
+
timestamp: new Date().toISOString()
|
|
25
|
+
}
|
|
26
|
+
return signFrame(frame, identity.export().privateKeyBase64)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function signFrame(frame: IntentFrame, privateKeyBase64: string): IntentFrame {
|
|
30
|
+
const signedPayload = JSON.stringify({
|
|
31
|
+
type: 'intent',
|
|
32
|
+
from: frame.from,
|
|
33
|
+
to: frame.to,
|
|
34
|
+
intent: frame.intent,
|
|
35
|
+
payload: frame.payload,
|
|
36
|
+
timestamp: frame.timestamp,
|
|
37
|
+
nonce: frame.nonce,
|
|
38
|
+
})
|
|
39
|
+
const privateKey = createPrivateKey({
|
|
40
|
+
key: Buffer.from(privateKeyBase64, 'base64'),
|
|
41
|
+
format: 'der',
|
|
42
|
+
type: 'pkcs8',
|
|
43
|
+
})
|
|
44
|
+
frame.signature = sign(null, Buffer.from(signedPayload, 'utf8'), privateKey).toString('base64')
|
|
45
|
+
return frame
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createResultFrame(
|
|
49
|
+
options: {
|
|
50
|
+
nonce: string
|
|
51
|
+
success: boolean
|
|
52
|
+
payload?: Record<string, unknown>
|
|
53
|
+
error?: string
|
|
54
|
+
errorCode?: string
|
|
55
|
+
latency?: number
|
|
56
|
+
},
|
|
57
|
+
identity: BeamIdentity
|
|
58
|
+
): ResultFrame {
|
|
59
|
+
const frame: ResultFrame = {
|
|
60
|
+
v: '1',
|
|
61
|
+
success: options.success,
|
|
62
|
+
nonce: options.nonce,
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
...(options.payload !== undefined && { payload: options.payload }),
|
|
65
|
+
...(options.error !== undefined && { error: options.error }),
|
|
66
|
+
...(options.errorCode !== undefined && { errorCode: options.errorCode }),
|
|
67
|
+
...(options.latency !== undefined && { latency: options.latency })
|
|
68
|
+
}
|
|
69
|
+
frame.signature = identity.sign(canonicalizeFrame(frame as unknown as Record<string, unknown>))
|
|
70
|
+
return frame
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function validateIntentFrame(
|
|
74
|
+
frame: unknown,
|
|
75
|
+
senderPublicKey: string
|
|
76
|
+
): { valid: boolean; error?: string } {
|
|
77
|
+
if (!frame || typeof frame !== 'object') {
|
|
78
|
+
return { valid: false, error: 'Frame must be an object' }
|
|
79
|
+
}
|
|
80
|
+
const f = frame as Record<string, unknown>
|
|
81
|
+
|
|
82
|
+
if (f['v'] !== '1') return { valid: false, error: 'Invalid protocol version' }
|
|
83
|
+
if (typeof f['intent'] !== 'string' || !f['intent']) return { valid: false, error: 'Missing or empty intent' }
|
|
84
|
+
if (typeof f['from'] !== 'string' || !BeamIdentity.parseBeamId(f['from'])) {
|
|
85
|
+
return { valid: false, error: 'Invalid from Beam ID' }
|
|
86
|
+
}
|
|
87
|
+
if (typeof f['to'] !== 'string' || !BeamIdentity.parseBeamId(f['to'])) {
|
|
88
|
+
return { valid: false, error: 'Invalid to Beam ID' }
|
|
89
|
+
}
|
|
90
|
+
if (typeof f['nonce'] !== 'string' || !f['nonce']) return { valid: false, error: 'Missing nonce' }
|
|
91
|
+
if (typeof f['timestamp'] !== 'string') return { valid: false, error: 'Missing timestamp' }
|
|
92
|
+
if (!f['payload'] || typeof f['payload'] !== 'object' || Array.isArray(f['payload'])) {
|
|
93
|
+
return { valid: false, error: 'Payload must be an object' }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const size = Buffer.byteLength(JSON.stringify(frame), 'utf8')
|
|
97
|
+
if (size > MAX_FRAME_SIZE) {
|
|
98
|
+
return { valid: false, error: `Frame size ${size} exceeds limit of ${MAX_FRAME_SIZE} bytes` }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const frameTime = new Date(f['timestamp'] as string).getTime()
|
|
102
|
+
if (isNaN(frameTime)) return { valid: false, error: 'Invalid timestamp format' }
|
|
103
|
+
if (Math.abs(Date.now() - frameTime) > REPLAY_WINDOW_MS) {
|
|
104
|
+
return { valid: false, error: 'Frame timestamp outside replay window (±5 minutes)' }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (typeof f['signature'] !== 'string') return { valid: false, error: 'Missing signature' }
|
|
108
|
+
const signedPayload = JSON.stringify({
|
|
109
|
+
type: 'intent',
|
|
110
|
+
from: f['from'],
|
|
111
|
+
to: f['to'],
|
|
112
|
+
intent: f['intent'],
|
|
113
|
+
payload: f['payload'],
|
|
114
|
+
timestamp: f['timestamp'],
|
|
115
|
+
nonce: f['nonce'],
|
|
116
|
+
})
|
|
117
|
+
if (!BeamIdentity.verify(signedPayload, f['signature'] as string, senderPublicKey)) {
|
|
118
|
+
return { valid: false, error: 'Signature verification failed' }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { valid: true }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function validateResultFrame(
|
|
125
|
+
frame: unknown,
|
|
126
|
+
senderPublicKey: string
|
|
127
|
+
): { valid: boolean; error?: string } {
|
|
128
|
+
if (!frame || typeof frame !== 'object') {
|
|
129
|
+
return { valid: false, error: 'Frame must be an object' }
|
|
130
|
+
}
|
|
131
|
+
const f = frame as Record<string, unknown>
|
|
132
|
+
|
|
133
|
+
if (f['v'] !== '1') return { valid: false, error: 'Invalid protocol version' }
|
|
134
|
+
if (typeof f['success'] !== 'boolean') return { valid: false, error: 'Missing success boolean' }
|
|
135
|
+
if (typeof f['nonce'] !== 'string' || !f['nonce']) return { valid: false, error: 'Missing nonce' }
|
|
136
|
+
if (typeof f['timestamp'] !== 'string') return { valid: false, error: 'Missing timestamp' }
|
|
137
|
+
if (typeof f['signature'] !== 'string') return { valid: false, error: 'Missing signature' }
|
|
138
|
+
|
|
139
|
+
const { signature, ...unsigned } = f
|
|
140
|
+
if (!BeamIdentity.verify(canonicalizeFrame(unsigned), signature as string, senderPublicKey)) {
|
|
141
|
+
return { valid: false, error: 'Signature verification failed' }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { valid: true }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function canonicalizeFrame(frame: Record<string, unknown>): string {
|
|
148
|
+
return JSON.stringify(deepSortKeys(frame))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function deepSortKeys(value: unknown): unknown {
|
|
152
|
+
if (Array.isArray(value)) return value.map(deepSortKeys)
|
|
153
|
+
if (value !== null && typeof value === 'object') {
|
|
154
|
+
const sorted: Record<string, unknown> = {}
|
|
155
|
+
for (const key of Object.keys(value as object).sort()) {
|
|
156
|
+
sorted[key] = deepSortKeys((value as Record<string, unknown>)[key])
|
|
157
|
+
}
|
|
158
|
+
return sorted
|
|
159
|
+
}
|
|
160
|
+
return value
|
|
161
|
+
}
|