beam-protocol-sdk 0.2.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beam-protocol-sdk",
3
- "version": "0.2.1",
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
- "files": [
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
+ }
@@ -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
+ }