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.
@@ -0,0 +1,85 @@
1
+ import {
2
+ generateKeyPairSync,
3
+ sign,
4
+ verify,
5
+ createPrivateKey,
6
+ createPublicKey,
7
+ randomUUID,
8
+ type KeyObject
9
+ } from 'node:crypto'
10
+ import type { BeamIdString, BeamIdentityConfig, BeamIdentityData } from './types.js'
11
+
12
+ export class BeamIdentity {
13
+ readonly beamId: BeamIdString
14
+ readonly publicKeyBase64: string
15
+ private readonly _privateKey: KeyObject
16
+ private readonly _publicKey: KeyObject
17
+
18
+ private constructor(beamId: BeamIdString, privateKey: KeyObject, publicKey: KeyObject) {
19
+ this.beamId = beamId
20
+ this._privateKey = privateKey
21
+ this._publicKey = publicKey
22
+ this.publicKeyBase64 = (publicKey.export({ type: 'spki', format: 'der' }) as Buffer).toString('base64')
23
+ }
24
+
25
+ static generate(config: BeamIdentityConfig): BeamIdentity {
26
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519')
27
+ const beamId = `${config.agentName}@${config.orgName}.beam.directory` as BeamIdString
28
+ return new BeamIdentity(beamId, privateKey, publicKey)
29
+ }
30
+
31
+ static fromData(data: BeamIdentityData): BeamIdentity {
32
+ const privateKey = createPrivateKey({
33
+ key: Buffer.from(data.privateKeyBase64, 'base64'),
34
+ format: 'der',
35
+ type: 'pkcs8'
36
+ })
37
+ const publicKey = createPublicKey({
38
+ key: Buffer.from(data.publicKeyBase64, 'base64'),
39
+ format: 'der',
40
+ type: 'spki'
41
+ })
42
+ return new BeamIdentity(data.beamId, privateKey, publicKey)
43
+ }
44
+
45
+ export(): BeamIdentityData {
46
+ return {
47
+ beamId: this.beamId,
48
+ publicKeyBase64: this.publicKeyBase64,
49
+ privateKeyBase64: (this._privateKey.export({ type: 'pkcs8', format: 'der' }) as Buffer).toString('base64')
50
+ }
51
+ }
52
+
53
+ sign(data: string): string {
54
+ const signature = sign(null, Buffer.from(data, 'utf8'), this._privateKey)
55
+ return (signature as Buffer).toString('base64')
56
+ }
57
+
58
+ static verify(data: string, signatureBase64: string, publicKeyBase64: string): boolean {
59
+ try {
60
+ const publicKey = createPublicKey({
61
+ key: Buffer.from(publicKeyBase64, 'base64'),
62
+ format: 'der',
63
+ type: 'spki'
64
+ })
65
+ return verify(
66
+ null,
67
+ Buffer.from(data, 'utf8'),
68
+ publicKey,
69
+ Buffer.from(signatureBase64, 'base64')
70
+ )
71
+ } catch {
72
+ return false
73
+ }
74
+ }
75
+
76
+ static parseBeamId(beamId: string): { agent: string; org: string } | null {
77
+ const match = beamId.match(/^([a-z0-9_-]+)@([a-z0-9_-]+)\.beam\.directory$/)
78
+ if (!match) return null
79
+ return { agent: match[1], org: match[2] }
80
+ }
81
+
82
+ static generateNonce(): string {
83
+ return randomUUID()
84
+ }
85
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ export { BeamIdentity } from './identity.js'
2
+ export { BeamDirectory, BeamDirectoryError } from './directory.js'
3
+ export { BeamClient } from './client.js'
4
+ export {
5
+ createIntentFrame,
6
+ createResultFrame,
7
+ signFrame,
8
+ validateIntentFrame,
9
+ validateResultFrame,
10
+ canonicalizeFrame,
11
+ MAX_FRAME_SIZE,
12
+ REPLAY_WINDOW_MS
13
+ } from './frames.js'
14
+ export type {
15
+ BeamIdString,
16
+ BeamIdentityConfig,
17
+ BeamIdentityData,
18
+ IntentFrame,
19
+ ResultFrame,
20
+ AgentRegistration,
21
+ AgentRecord,
22
+ DirectoryConfig,
23
+ BeamClientConfig,
24
+ AgentSearchQuery
25
+ } from './types.js'
package/src/types.ts ADDED
@@ -0,0 +1,71 @@
1
+ /** Beam ID format: agent@org.beam.directory */
2
+ export type BeamIdString = `${string}@${string}.beam.directory`
3
+
4
+ export interface BeamIdentityConfig {
5
+ agentName: string
6
+ orgName: string
7
+ }
8
+
9
+ export interface BeamIdentityData {
10
+ beamId: BeamIdString
11
+ publicKeyBase64: string
12
+ privateKeyBase64: string
13
+ }
14
+
15
+ export interface IntentFrame {
16
+ v: '1'
17
+ intent: string
18
+ from: BeamIdString
19
+ to: BeamIdString
20
+ payload: Record<string, unknown>
21
+ nonce: string
22
+ timestamp: string
23
+ signature?: string
24
+ }
25
+
26
+ export interface ResultFrame {
27
+ v: '1'
28
+ success: boolean
29
+ payload?: Record<string, unknown>
30
+ error?: string
31
+ errorCode?: string
32
+ nonce: string
33
+ timestamp: string
34
+ latency?: number
35
+ signature?: string
36
+ }
37
+
38
+ export interface AgentRegistration {
39
+ beamId: BeamIdString
40
+ displayName: string
41
+ capabilities: string[]
42
+ publicKey: string
43
+ org: string
44
+ }
45
+
46
+ export interface AgentRecord extends AgentRegistration {
47
+ trustScore: number
48
+ verified: boolean
49
+ createdAt: string
50
+ lastSeen: string
51
+ }
52
+
53
+ export interface DirectoryConfig {
54
+ baseUrl: string
55
+ apiKey?: string
56
+ }
57
+
58
+ export interface BeamClientConfig {
59
+ identity: BeamIdentityData
60
+ directoryUrl: string
61
+ autoReconnect?: boolean
62
+ onDisconnect?: () => void
63
+ onReconnect?: () => void
64
+ }
65
+
66
+ export interface AgentSearchQuery {
67
+ org?: string
68
+ capabilities?: string[]
69
+ minTrustScore?: number
70
+ limit?: number
71
+ }
@@ -0,0 +1,217 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { BeamClient } from '../src/client.js'
3
+ import { createIntentFrame, validateResultFrame } from '../src/frames.js'
4
+ import { BeamIdentity } from '../src/identity.js'
5
+
6
+ class MockWebSocket {
7
+ static instances: MockWebSocket[] = []
8
+
9
+ readonly OPEN = 1
10
+ readonly url: string
11
+ readyState = 0
12
+ sent: string[] = []
13
+ onopen: ((event: unknown) => void) | null = null
14
+ onclose: ((event: unknown) => void) | null = null
15
+ onerror: ((event: unknown) => void) | null = null
16
+ onmessage: ((event: { data: string }) => void) | null = null
17
+
18
+ constructor(url: string) {
19
+ this.url = url
20
+ MockWebSocket.instances.push(this)
21
+ }
22
+
23
+ send(data: string): void {
24
+ if (this.readyState !== this.OPEN) {
25
+ throw new Error('Socket not open')
26
+ }
27
+ this.sent.push(data)
28
+ }
29
+
30
+ close(): void {
31
+ this.readyState = 3
32
+ this.onclose?.({ type: 'close' })
33
+ }
34
+
35
+ emitOpen(): void {
36
+ this.readyState = this.OPEN
37
+ this.onopen?.({ type: 'open' })
38
+ }
39
+
40
+ emitMessage(message: unknown): void {
41
+ const data = typeof message === 'string' ? message : JSON.stringify(message)
42
+ this.onmessage?.({ data })
43
+ }
44
+ }
45
+
46
+ function getLastSocket(): MockWebSocket {
47
+ const socket = MockWebSocket.instances.at(-1)
48
+ if (!socket) {
49
+ throw new Error('Expected a mock WebSocket instance')
50
+ }
51
+ return socket
52
+ }
53
+
54
+ async function createConnectedClient() {
55
+ const identity = BeamIdentity.generate({ agentName: 'receiver', orgName: 'acme' })
56
+ const client = new BeamClient({ identity: identity.export(), directoryUrl: 'http://directory.test/' })
57
+ const connectPromise = client.connect()
58
+ const socket = getLastSocket()
59
+
60
+ await Promise.resolve()
61
+ socket.emitOpen()
62
+ socket.emitMessage({ type: 'connected', beamId: identity.beamId })
63
+ await connectPromise
64
+
65
+ return { client, socket, identity }
66
+ }
67
+
68
+ describe('BeamClient', () => {
69
+ const fetchMock = vi.fn()
70
+
71
+ beforeEach(() => {
72
+ MockWebSocket.instances = []
73
+ fetchMock.mockReset()
74
+ vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket)
75
+ vi.stubGlobal('fetch', fetchMock)
76
+ })
77
+
78
+ afterEach(() => {
79
+ vi.useRealTimers()
80
+ vi.unstubAllGlobals()
81
+ })
82
+
83
+ it('connect() opens the WebSocket and waits for the connected message', async () => {
84
+ const identity = BeamIdentity.generate({ agentName: 'alice', orgName: 'acme' })
85
+ const client = new BeamClient({ identity: identity.export(), directoryUrl: 'https://directory.test/' })
86
+
87
+ const connectPromise = client.connect()
88
+ const socket = getLastSocket()
89
+ expect(socket.url).toBe('wss://directory.test/ws?beamId=alice%40acme.beam.directory')
90
+
91
+ let resolved = false
92
+ void connectPromise.then(() => {
93
+ resolved = true
94
+ })
95
+
96
+ await Promise.resolve()
97
+ expect(resolved).toBe(false)
98
+
99
+ socket.emitOpen()
100
+ socket.emitMessage({ type: 'connected', beamId: identity.beamId })
101
+ await expect(connectPromise).resolves.toBeUndefined()
102
+ expect(resolved).toBe(true)
103
+ })
104
+
105
+ it('send() delivers intents over WebSocket and resolves matching results', async () => {
106
+ const { client, socket } = await createConnectedClient()
107
+
108
+ const sendPromise = client.send('target@acme.beam.directory', 'agent.ping', { message: 'hello' }, 5_000)
109
+
110
+ const outbound = JSON.parse(socket.sent[0]) as { type: string; frame: { nonce: string; intent: string } }
111
+ expect(outbound.type).toBe('intent')
112
+ expect(outbound.frame.intent).toBe('agent.ping')
113
+
114
+ socket.emitMessage({
115
+ type: 'result',
116
+ frame: {
117
+ v: '1',
118
+ success: true,
119
+ nonce: outbound.frame.nonce,
120
+ timestamp: new Date().toISOString(),
121
+ signature: 'not-validated-on-receive',
122
+ payload: { status: 'ok' },
123
+ },
124
+ })
125
+
126
+ await expect(sendPromise).resolves.toEqual(
127
+ expect.objectContaining({ success: true, nonce: outbound.frame.nonce, payload: { status: 'ok' } }),
128
+ )
129
+ })
130
+
131
+ it('on() invokes intent handlers and sends signed result frames', async () => {
132
+ const { client, socket, identity } = await createConnectedClient()
133
+ const sender = BeamIdentity.generate({ agentName: 'sender', orgName: 'acme' })
134
+ const handled = vi.fn()
135
+
136
+ client.on('task.delegate', async (frame, respond) => {
137
+ handled(frame)
138
+ respond({ success: true, payload: { accepted: true } })
139
+ })
140
+
141
+ const incomingFrame = createIntentFrame(
142
+ {
143
+ intent: 'task.delegate',
144
+ from: sender.beamId,
145
+ to: identity.beamId,
146
+ payload: { task: 'Prepare brief', priority: 'medium' },
147
+ },
148
+ sender,
149
+ )
150
+
151
+ socket.emitMessage({
152
+ type: 'intent',
153
+ frame: incomingFrame,
154
+ senderPublicKey: sender.publicKeyBase64,
155
+ })
156
+ await Promise.resolve()
157
+ await Promise.resolve()
158
+
159
+ expect(handled).toHaveBeenCalledWith(expect.objectContaining({ intent: 'task.delegate' }))
160
+ const outbound = JSON.parse(socket.sent[0]) as { type: string; frame: Record<string, unknown> }
161
+ expect(outbound.type).toBe('result')
162
+ expect(outbound.frame.nonce).toBe(incomingFrame.nonce)
163
+ expect(validateResultFrame(outbound.frame, identity.publicKeyBase64)).toEqual({ valid: true })
164
+ })
165
+
166
+ it('send() falls back to HTTP when WebSocket is not connected', async () => {
167
+ const identity = BeamIdentity.generate({ agentName: 'alice', orgName: 'acme' })
168
+ const client = new BeamClient({ identity: identity.export(), directoryUrl: 'http://directory.test/' })
169
+
170
+ fetchMock.mockResolvedValue({
171
+ ok: true,
172
+ status: 200,
173
+ statusText: 'OK',
174
+ json: vi.fn().mockResolvedValue({
175
+ v: '1',
176
+ success: true,
177
+ nonce: 'http-nonce',
178
+ timestamp: new Date().toISOString(),
179
+ signature: 'server-signature',
180
+ }),
181
+ })
182
+
183
+ const result = await client.send('target@acme.beam.directory', 'agent.ping', { message: 'fallback' })
184
+ expect(result).toEqual(expect.objectContaining({ success: true, nonce: 'http-nonce' }))
185
+ expect(fetchMock).toHaveBeenCalledWith(
186
+ 'http://directory.test/intents',
187
+ expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' } }),
188
+ )
189
+ const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body as string) as { intent: string; from: string; to: string }
190
+ expect(requestBody.intent).toBe('agent.ping')
191
+ expect(requestBody.from).toBe(identity.beamId)
192
+ expect(requestBody.to).toBe('target@acme.beam.directory')
193
+ })
194
+
195
+ it('send() rejects when a WebSocket intent times out', async () => {
196
+ vi.useFakeTimers()
197
+ const { client } = await createConnectedClient()
198
+
199
+ const pending = client.send('target@acme.beam.directory', 'agent.ping', { message: 'timeout' }, 250)
200
+ const assertion = expect(pending).rejects.toThrow('Intent "agent.ping" timed out after 250ms')
201
+
202
+ await vi.advanceTimersByTimeAsync(251)
203
+ await assertion
204
+ })
205
+
206
+ it('disconnect() closes the socket and rejects pending requests', async () => {
207
+ const { client, socket } = await createConnectedClient()
208
+
209
+ const pending = client.send('target@acme.beam.directory', 'agent.ping', { message: 'bye' }, 5_000)
210
+ expect(socket.sent).toHaveLength(1)
211
+
212
+ client.disconnect()
213
+
214
+ await expect(pending).rejects.toThrow('Client disconnected')
215
+ expect(socket.readyState).toBe(3)
216
+ })
217
+ })
@@ -0,0 +1,202 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { BeamDirectory, BeamDirectoryError } from '../src/directory.js'
3
+ import type { AgentRecord } from '../src/types.js'
4
+
5
+ interface MockResponseInit {
6
+ body?: unknown
7
+ ok?: boolean
8
+ status?: number
9
+ statusText?: string
10
+ }
11
+
12
+ function createResponse(init: MockResponseInit) {
13
+ return {
14
+ ok: init.ok ?? true,
15
+ status: init.status ?? 200,
16
+ statusText: init.statusText ?? 'OK',
17
+ json: vi.fn().mockResolvedValue(init.body),
18
+ }
19
+ }
20
+
21
+ describe('BeamDirectory', () => {
22
+ const fetchMock = vi.fn()
23
+ const directory = new BeamDirectory({ baseUrl: 'https://directory.example/', apiKey: 'secret-key' })
24
+
25
+ beforeEach(() => {
26
+ fetchMock.mockReset()
27
+ vi.stubGlobal('fetch', fetchMock)
28
+ })
29
+
30
+ afterEach(() => {
31
+ vi.unstubAllGlobals()
32
+ })
33
+
34
+ it('register() posts agent registration and returns the created agent', async () => {
35
+ const agent: AgentRecord = {
36
+ beamId: 'alice@acme.beam.directory',
37
+ displayName: 'Alice',
38
+ capabilities: ['agent.ping'],
39
+ publicKey: 'pub-key',
40
+ org: 'acme',
41
+ trustScore: 0.7,
42
+ verified: true,
43
+ createdAt: '2026-03-08T10:00:00.000Z',
44
+ lastSeen: '2026-03-08T10:00:00.000Z',
45
+ }
46
+ fetchMock.mockResolvedValue(createResponse({ body: agent, status: 201 }))
47
+
48
+ const result = await directory.register({
49
+ beamId: agent.beamId,
50
+ displayName: agent.displayName,
51
+ capabilities: agent.capabilities,
52
+ publicKey: agent.publicKey,
53
+ org: agent.org,
54
+ })
55
+
56
+ expect(result).toEqual(agent)
57
+ expect(fetchMock).toHaveBeenCalledWith(
58
+ 'https://directory.example/agents/register',
59
+ expect.objectContaining({
60
+ method: 'POST',
61
+ headers: expect.objectContaining({
62
+ 'Content-Type': 'application/json',
63
+ Authorization: 'Bearer secret-key',
64
+ }),
65
+ }),
66
+ )
67
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body as string)).toEqual({
68
+ beamId: agent.beamId,
69
+ displayName: agent.displayName,
70
+ capabilities: agent.capabilities,
71
+ publicKey: agent.publicKey,
72
+ org: agent.org,
73
+ })
74
+ })
75
+
76
+ it('register() wraps API failures in BeamDirectoryError', async () => {
77
+ fetchMock.mockResolvedValue(
78
+ createResponse({
79
+ ok: false,
80
+ status: 400,
81
+ statusText: 'Bad Request',
82
+ body: { error: 'Invalid registration' },
83
+ }),
84
+ )
85
+
86
+ await expect(
87
+ directory.register({
88
+ beamId: 'alice@acme.beam.directory',
89
+ displayName: 'Alice',
90
+ capabilities: [],
91
+ publicKey: 'pub-key',
92
+ org: 'acme',
93
+ }),
94
+ ).rejects.toEqual(expect.objectContaining<Partial<BeamDirectoryError>>({
95
+ name: 'BeamDirectoryError',
96
+ message: 'Registration failed: Invalid registration',
97
+ statusCode: 400,
98
+ }))
99
+ })
100
+
101
+ it('lookup() returns an agent when found', async () => {
102
+ const agent = {
103
+ beamId: 'bob@acme.beam.directory',
104
+ displayName: 'Bob',
105
+ capabilities: ['task.delegate'],
106
+ publicKey: 'pub-key',
107
+ org: 'acme',
108
+ trustScore: 0.6,
109
+ verified: false,
110
+ createdAt: '2026-03-08T10:00:00.000Z',
111
+ lastSeen: '2026-03-08T10:10:00.000Z',
112
+ }
113
+ fetchMock.mockResolvedValue(createResponse({ body: agent }))
114
+
115
+ await expect(directory.lookup(agent.beamId)).resolves.toEqual(agent)
116
+ expect(fetchMock).toHaveBeenCalledWith(
117
+ 'https://directory.example/agents/bob%40acme.beam.directory',
118
+ expect.objectContaining({ headers: expect.any(Object) }),
119
+ )
120
+ })
121
+
122
+ it('lookup() returns null for 404 responses', async () => {
123
+ fetchMock.mockResolvedValue(createResponse({ ok: false, status: 404, statusText: 'Not Found' }))
124
+
125
+ await expect(directory.lookup('missing@acme.beam.directory')).resolves.toBeNull()
126
+ })
127
+
128
+ it('search() sends filters and normalizes snake_case records', async () => {
129
+ fetchMock.mockResolvedValue(
130
+ createResponse({
131
+ body: {
132
+ agents: [
133
+ {
134
+ beam_id: 'clara@acme.beam.directory',
135
+ display_name: 'Clara',
136
+ capabilities: ['sales.pipeline_summary', 'agent.ping'],
137
+ public_key: 'pub-key',
138
+ org: 'acme',
139
+ trust_score: 0.9,
140
+ verified: true,
141
+ created_at: '2026-03-01T10:00:00.000Z',
142
+ last_seen: '2026-03-08T09:00:00.000Z',
143
+ },
144
+ ],
145
+ total: 1,
146
+ },
147
+ }),
148
+ )
149
+
150
+ const result = await directory.search({
151
+ org: 'acme',
152
+ capabilities: ['sales.pipeline_summary', 'agent.ping'],
153
+ minTrustScore: 0.8,
154
+ limit: 5,
155
+ })
156
+
157
+ expect(result).toEqual([
158
+ {
159
+ beamId: 'clara@acme.beam.directory',
160
+ displayName: 'Clara',
161
+ capabilities: ['sales.pipeline_summary', 'agent.ping'],
162
+ publicKey: 'pub-key',
163
+ org: 'acme',
164
+ trustScore: 0.9,
165
+ verified: true,
166
+ createdAt: '2026-03-01T10:00:00.000Z',
167
+ lastSeen: '2026-03-08T09:00:00.000Z',
168
+ },
169
+ ])
170
+
171
+ const [url] = fetchMock.mock.calls[0] as [string]
172
+ const parsedUrl = new URL(url)
173
+ expect(parsedUrl.searchParams.get('org')).toBe('acme')
174
+ expect(parsedUrl.searchParams.get('capabilities')).toBe('sales.pipeline_summary,agent.ping')
175
+ expect(parsedUrl.searchParams.get('minTrustScore')).toBe('0.8')
176
+ expect(parsedUrl.searchParams.get('limit')).toBe('5')
177
+ })
178
+
179
+ it('heartbeat() posts to the heartbeat endpoint', async () => {
180
+ fetchMock.mockResolvedValue(createResponse({ body: { ok: true } }))
181
+
182
+ await expect(directory.heartbeat('alice@acme.beam.directory')).resolves.toBeUndefined()
183
+ expect(fetchMock).toHaveBeenCalledWith(
184
+ 'https://directory.example/agents/alice%40acme.beam.directory/heartbeat',
185
+ expect.objectContaining({ method: 'POST' }),
186
+ )
187
+ })
188
+
189
+ it('heartbeat() ignores 404 responses but rejects other failures', async () => {
190
+ fetchMock.mockResolvedValueOnce(createResponse({ ok: false, status: 404, statusText: 'Not Found' }))
191
+ fetchMock.mockResolvedValueOnce(createResponse({ ok: false, status: 500, statusText: 'Server Error' }))
192
+
193
+ await expect(directory.heartbeat('missing@acme.beam.directory')).resolves.toBeUndefined()
194
+ await expect(directory.heartbeat('broken@acme.beam.directory')).rejects.toEqual(
195
+ expect.objectContaining<Partial<BeamDirectoryError>>({
196
+ name: 'BeamDirectoryError',
197
+ message: 'Heartbeat failed: Server Error',
198
+ statusCode: 500,
199
+ }),
200
+ )
201
+ })
202
+ })