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/src/identity.ts
ADDED
|
@@ -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
|
+
})
|