@haiai/haiai 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +127 -0
  2. package/bin/haiai.cjs +70 -0
  3. package/dist/cjs/a2a.js +352 -0
  4. package/dist/cjs/a2a.js.map +1 -0
  5. package/dist/cjs/agent.js +236 -0
  6. package/dist/cjs/agent.js.map +1 -0
  7. package/dist/cjs/client.js +2168 -0
  8. package/dist/cjs/client.js.map +1 -0
  9. package/dist/cjs/config.js +176 -0
  10. package/dist/cjs/config.js.map +1 -0
  11. package/dist/cjs/errors.js +102 -0
  12. package/dist/cjs/errors.js.map +1 -0
  13. package/dist/cjs/hash.js +52 -0
  14. package/dist/cjs/hash.js.map +1 -0
  15. package/dist/cjs/index.js +84 -0
  16. package/dist/cjs/index.js.map +1 -0
  17. package/dist/cjs/integrations.js +193 -0
  18. package/dist/cjs/integrations.js.map +1 -0
  19. package/dist/cjs/jacs.js +66 -0
  20. package/dist/cjs/jacs.js.map +1 -0
  21. package/dist/cjs/mime.js +100 -0
  22. package/dist/cjs/mime.js.map +1 -0
  23. package/dist/cjs/package.json +3 -0
  24. package/dist/cjs/signing.js +190 -0
  25. package/dist/cjs/signing.js.map +1 -0
  26. package/dist/cjs/sse.js +76 -0
  27. package/dist/cjs/sse.js.map +1 -0
  28. package/dist/cjs/types.js +6 -0
  29. package/dist/cjs/types.js.map +1 -0
  30. package/dist/cjs/verify.js +76 -0
  31. package/dist/cjs/verify.js.map +1 -0
  32. package/dist/cjs/ws.js +206 -0
  33. package/dist/cjs/ws.js.map +1 -0
  34. package/dist/esm/a2a.js +305 -0
  35. package/dist/esm/a2a.js.map +1 -0
  36. package/dist/esm/agent.js +231 -0
  37. package/dist/esm/agent.js.map +1 -0
  38. package/dist/esm/client.js +2131 -0
  39. package/dist/esm/client.js.map +1 -0
  40. package/dist/esm/config.js +171 -0
  41. package/dist/esm/config.js.map +1 -0
  42. package/dist/esm/errors.js +88 -0
  43. package/dist/esm/errors.js.map +1 -0
  44. package/dist/esm/hash.js +49 -0
  45. package/dist/esm/hash.js.map +1 -0
  46. package/dist/esm/index.js +27 -0
  47. package/dist/esm/index.js.map +1 -0
  48. package/dist/esm/integrations.js +147 -0
  49. package/dist/esm/integrations.js.map +1 -0
  50. package/dist/esm/jacs.js +61 -0
  51. package/dist/esm/jacs.js.map +1 -0
  52. package/dist/esm/mime.js +97 -0
  53. package/dist/esm/mime.js.map +1 -0
  54. package/dist/esm/signing.js +183 -0
  55. package/dist/esm/signing.js.map +1 -0
  56. package/dist/esm/sse.js +73 -0
  57. package/dist/esm/sse.js.map +1 -0
  58. package/dist/esm/types.js +5 -0
  59. package/dist/esm/types.js.map +1 -0
  60. package/dist/esm/verify.js +72 -0
  61. package/dist/esm/verify.js.map +1 -0
  62. package/dist/esm/ws.js +168 -0
  63. package/dist/esm/ws.js.map +1 -0
  64. package/dist/types/a2a.d.ts +52 -0
  65. package/dist/types/a2a.d.ts.map +1 -0
  66. package/dist/types/agent.d.ts +202 -0
  67. package/dist/types/agent.d.ts.map +1 -0
  68. package/dist/types/client.d.ts +486 -0
  69. package/dist/types/client.d.ts.map +1 -0
  70. package/dist/types/config.d.ts +31 -0
  71. package/dist/types/config.d.ts.map +1 -0
  72. package/dist/types/errors.d.ts +50 -0
  73. package/dist/types/errors.d.ts.map +1 -0
  74. package/dist/types/hash.d.ts +32 -0
  75. package/dist/types/hash.d.ts.map +1 -0
  76. package/dist/types/index.d.ts +22 -0
  77. package/dist/types/index.d.ts.map +1 -0
  78. package/dist/types/integrations.d.ts +25 -0
  79. package/dist/types/integrations.d.ts.map +1 -0
  80. package/dist/types/jacs.d.ts +26 -0
  81. package/dist/types/jacs.d.ts.map +1 -0
  82. package/dist/types/mime.d.ts +39 -0
  83. package/dist/types/mime.d.ts.map +1 -0
  84. package/dist/types/signing.d.ts +58 -0
  85. package/dist/types/signing.d.ts.map +1 -0
  86. package/dist/types/sse.d.ts +8 -0
  87. package/dist/types/sse.d.ts.map +1 -0
  88. package/dist/types/types.d.ts +652 -0
  89. package/dist/types/types.d.ts.map +1 -0
  90. package/dist/types/verify.d.ts +20 -0
  91. package/dist/types/verify.d.ts.map +1 -0
  92. package/dist/types/ws.d.ts +30 -0
  93. package/dist/types/ws.d.ts.map +1 -0
  94. package/examples/a2a_quickstart.ts +138 -0
  95. package/examples/hai_quickstart.ts +111 -0
  96. package/examples/mcp_quickstart.ts +53 -0
  97. package/npm/@haiai/cli-darwin-arm64/package.json +16 -0
  98. package/npm/@haiai/cli-darwin-x64/package.json +16 -0
  99. package/npm/@haiai/cli-linux-arm64/package.json +16 -0
  100. package/npm/@haiai/cli-linux-x64/package.json +16 -0
  101. package/npm/@haiai/cli-win32-x64/package.json +16 -0
  102. package/package.json +68 -0
  103. package/scripts/build-platform-packages.js +132 -0
  104. package/scripts/smoke-package.cjs +114 -0
  105. package/scripts/write-cjs-package.cjs +9 -0
  106. package/src/a2a.ts +463 -0
  107. package/src/agent.ts +302 -0
  108. package/src/client.ts +2504 -0
  109. package/src/config.ts +204 -0
  110. package/src/errors.ts +99 -0
  111. package/src/hash.ts +66 -0
  112. package/src/index.ts +163 -0
  113. package/src/integrations.ts +210 -0
  114. package/src/jacs.ts +86 -0
  115. package/src/mime.ts +131 -0
  116. package/src/signing.ts +233 -0
  117. package/src/sse.ts +86 -0
  118. package/src/types.ts +773 -0
  119. package/src/verify.ts +89 -0
  120. package/src/ws.ts +198 -0
  121. package/tests/_debug_jacs.cjs +29 -0
  122. package/tests/a2a-contract.test.ts +271 -0
  123. package/tests/a2a-fixtures.test.ts +73 -0
  124. package/tests/a2a.test.ts +379 -0
  125. package/tests/binary.test.ts +90 -0
  126. package/tests/client-api-methods.test.ts +176 -0
  127. package/tests/client-path-escaping.test.ts +80 -0
  128. package/tests/client-register.test.ts +61 -0
  129. package/tests/config.test.ts +281 -0
  130. package/tests/contract.test.ts +360 -0
  131. package/tests/cross-lang-contract.test.ts +67 -0
  132. package/tests/email-conformance.test.ts +289 -0
  133. package/tests/email-integration.test.ts +217 -0
  134. package/tests/email.test.ts +767 -0
  135. package/tests/errors.test.ts +167 -0
  136. package/tests/init-contract.test.ts +129 -0
  137. package/tests/integrations.test.ts +132 -0
  138. package/tests/jacs-passthrough.test.ts +125 -0
  139. package/tests/key-cache.test.ts +201 -0
  140. package/tests/key-integration.test.ts +119 -0
  141. package/tests/key-lookups.test.ts +187 -0
  142. package/tests/key-rotation.test.ts +362 -0
  143. package/tests/mime.test.ts +127 -0
  144. package/tests/security.test.ts +109 -0
  145. package/tests/setup.ts +60 -0
  146. package/tests/signing.test.ts +142 -0
  147. package/tests/sse.test.ts +125 -0
  148. package/tests/types.test.ts +294 -0
  149. package/tests/verify-link.test.ts +81 -0
  150. package/tests/ws.test.ts +213 -0
  151. package/tsconfig.cjs.json +11 -0
  152. package/tsconfig.json +22 -0
  153. package/vitest.config.ts +11 -0
package/src/client.ts ADDED
@@ -0,0 +1,2504 @@
1
+ import type {
2
+ HaiClientOptions,
3
+ AgentConfig,
4
+ HaiEvent,
5
+ BenchmarkJob,
6
+ HelloWorldResult,
7
+ RegistrationResult,
8
+ RotateKeysOptions,
9
+ RotationResult,
10
+ FreeChaoticResult,
11
+ ProRunResult,
12
+ DnsCertifiedResult,
13
+ FullyCertifiedResult,
14
+ JobResponseResult,
15
+ VerifyAgentResult,
16
+ RegistrationEntry,
17
+ CheckUsernameResult,
18
+ ClaimUsernameResult,
19
+ UpdateUsernameResult,
20
+ DeleteUsernameResult,
21
+ TranscriptMessage,
22
+ ConnectionMode,
23
+ ConnectOptions,
24
+ OnBenchmarkJobOptions,
25
+ ProRunOptions,
26
+ DnsCertifiedRunOptions,
27
+ FreeChaoticRunOptions,
28
+ JobResponse,
29
+ SendEmailOptions,
30
+ SendEmailResult,
31
+ EmailMessage,
32
+ ListMessagesOptions,
33
+ SearchOptions,
34
+ EmailStatus,
35
+ Contact,
36
+ ForwardOptions,
37
+ PublicKeyInfo,
38
+ VerificationResult,
39
+ DocumentVerificationResult,
40
+ AdvancedVerificationResult,
41
+ VerifyAgentDocumentOnHaiOptions,
42
+ EmailVerificationResultV2,
43
+ FieldResult,
44
+ FieldStatus,
45
+ ChainEntry,
46
+ } from './types.js';
47
+ import {
48
+ HaiError,
49
+ AuthenticationError,
50
+ HaiConnectionError,
51
+ HaiApiError,
52
+ EmailNotActiveError,
53
+ RecipientNotFoundError,
54
+ RateLimitedError,
55
+ } from './errors.js';
56
+ import { signResponse, canonicalJson, getServerKeys, unwrapSignedEvent } from './signing.js';
57
+ import { loadConfig } from './config.js';
58
+ import { JacsAgent, createAgentSync, verifyDocumentStandalone, hashString } from '@hai.ai/jacs';
59
+ import { parseSseStream } from './sse.js';
60
+ import { openWebSocket, wsEventStream } from './ws.js';
61
+
62
+ function armorKeyData(raw: Buffer, blockType: string): string {
63
+ const base64 = raw.toString('base64');
64
+ const lines = base64.match(/.{1,64}/g) ?? [];
65
+ return `-----BEGIN ${blockType}-----\n${lines.join('\n')}\n-----END ${blockType}-----`;
66
+ }
67
+
68
+ function normalizeKeyText(raw: Buffer, blockType: string): string {
69
+ const text = raw.toString('utf-8').trim();
70
+ if (text.includes(`BEGIN ${blockType}`)) {
71
+ return text;
72
+ }
73
+ if (blockType === 'PUBLIC KEY' && text.includes('BEGIN RSA PUBLIC KEY')) {
74
+ return text;
75
+ }
76
+ return armorKeyData(raw, blockType);
77
+ }
78
+
79
+ /**
80
+ * HAI platform client.
81
+ *
82
+ * Zero-config: `new HaiClient()` auto-discovers jacs.config.json.
83
+ * All authentication uses JACS-signed headers (no API keys).
84
+ *
85
+ * @example
86
+ * ```typescript
87
+ * const hai = await HaiClient.create();
88
+ * const result = await hai.hello();
89
+ * console.log(result.message);
90
+ * ```
91
+ */
92
+ export class HaiClient {
93
+ private config!: AgentConfig;
94
+ /** JACS native agent for all cryptographic operations. */
95
+ private agent!: JacsAgent;
96
+ private baseUrl: string;
97
+ private timeout: number;
98
+ private maxRetries: number;
99
+ private _shouldDisconnect = false;
100
+ private _connected = false;
101
+ private _wsConnection: unknown = null;
102
+ private _lastEventId: string | null = null;
103
+ private serverPublicKeys: Record<string, string> = {};
104
+ /** HAI-assigned agent UUID, set after register(). Used for email URL paths. */
105
+ private _haiAgentId: string | null = null;
106
+ /** Agent's @hai.ai email address, set after claimUsername(). */
107
+ private agentEmail?: string;
108
+ /** Agent key cache: maps cache key -> { value, cachedAt (ms since epoch) }. */
109
+ private keyCache = new Map<string, { value: PublicKeyInfo; cachedAt: number }>();
110
+ /** Agent key cache TTL in milliseconds (5 minutes). */
111
+ private static readonly KEY_CACHE_TTL = 300_000;
112
+
113
+ private constructor(options?: HaiClientOptions) {
114
+ this.baseUrl = (options?.url ?? 'https://hai.ai').replace(/\/+$/, '');
115
+ this.timeout = options?.timeout ?? 30000;
116
+ this.maxRetries = options?.maxRetries ?? 3;
117
+ }
118
+
119
+ /**
120
+ * Create a HaiClient by loading JACS agent config.
121
+ *
122
+ * This is the primary constructor. Uses zero-config discovery:
123
+ * 1. options.configPath
124
+ * 2. JACS_CONFIG_PATH env var
125
+ * 3. ./jacs.config.json
126
+ */
127
+ static async create(options?: HaiClientOptions): Promise<HaiClient> {
128
+ const client = new HaiClient(options);
129
+ client.config = await loadConfig(options?.configPath);
130
+
131
+ const configPath = options?.configPath
132
+ ?? process.env.JACS_CONFIG_PATH
133
+ ?? './jacs.config.json';
134
+ const { resolve } = await import('node:path');
135
+ const resolvedConfigPath = resolve(configPath);
136
+
137
+ client.agent = new JacsAgent();
138
+ await client.agent.load(resolvedConfigPath);
139
+
140
+ return client;
141
+ }
142
+
143
+ /**
144
+ * Create a HaiClient directly from a JACS ID and PEM-encoded private key.
145
+ * Useful for testing or programmatic setup without config files.
146
+ *
147
+ * Creates a temporary JACS agent backed by on-disk key files so that
148
+ * all cryptographic operations delegate to JACS core.
149
+ */
150
+ static async fromCredentials(
151
+ jacsId: string,
152
+ privateKeyPem: string,
153
+ options?: Omit<HaiClientOptions, 'configPath'> & { privateKeyPassphrase?: string },
154
+ ): Promise<HaiClient> {
155
+ const client = new HaiClient(options);
156
+
157
+ // Store the caller's private key PEM for exportKeys/rotateKeys compatibility
158
+ (client as any)._privateKeyPem = privateKeyPem;
159
+ (client as any).privateKeyPem = privateKeyPem;
160
+ (client as any)._privateKeyPassphrase = options?.privateKeyPassphrase;
161
+
162
+ // Create an ephemeral JACS agent (in-memory keys, no disk I/O required).
163
+ // The ephemeral agent generates its own key pair — this is appropriate
164
+ // because fromCredentials is typically followed by register() which sends
165
+ // the new public key to the server.
166
+ client.agent = new JacsAgent();
167
+ const ephResult = JSON.parse(client.agent.ephemeralSync('pq2025'));
168
+
169
+ client.config = {
170
+ jacsAgentName: jacsId,
171
+ jacsAgentVersion: ephResult.version || '1.0.0',
172
+ jacsKeyDir: '',
173
+ jacsId,
174
+ };
175
+
176
+ return client;
177
+ }
178
+
179
+ /** The agent's JACS ID. */
180
+ get jacsId(): string {
181
+ return this.config.jacsId ?? this.config.jacsAgentName;
182
+ }
183
+
184
+ /** The agent name from config. */
185
+ get agentName(): string {
186
+ return this.config.jacsAgentName;
187
+ }
188
+
189
+ /** The HAI-assigned agent UUID (set after register()). Falls back to jacsId. */
190
+ get haiAgentId(): string {
191
+ return this._haiAgentId ?? this.jacsId;
192
+ }
193
+
194
+ /** Whether the client is currently connected to an event stream. */
195
+ get isConnected(): boolean {
196
+ return this._connected;
197
+ }
198
+
199
+ /** Get the agent's @hai.ai email address (set after claimUsername). */
200
+ getAgentEmail(): string | undefined {
201
+ return this.agentEmail;
202
+ }
203
+
204
+ /** Set the agent's @hai.ai email address manually. */
205
+ setAgentEmail(email: string): void {
206
+ this.agentEmail = email;
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Agent key cache
211
+ // ---------------------------------------------------------------------------
212
+
213
+ /** Get a cached key if it exists and hasn't expired. */
214
+ private getCachedKey(cacheKey: string): PublicKeyInfo | undefined {
215
+ const entry = this.keyCache.get(cacheKey);
216
+ if (!entry) return undefined;
217
+ if (Date.now() - entry.cachedAt >= HaiClient.KEY_CACHE_TTL) {
218
+ this.keyCache.delete(cacheKey);
219
+ return undefined;
220
+ }
221
+ return entry.value;
222
+ }
223
+
224
+ /** Store a key in the cache with the current timestamp. */
225
+ private setCachedKey(cacheKey: string, value: PublicKeyInfo): void {
226
+ this.keyCache.set(cacheKey, { value, cachedAt: Date.now() });
227
+ }
228
+
229
+ /** Clear the agent key cache, forcing subsequent fetches to hit the API. */
230
+ clearAgentKeyCache(): void {
231
+ this.keyCache.clear();
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Auth helpers
236
+ // ---------------------------------------------------------------------------
237
+
238
+ /**
239
+ * Build JACS Authorization header.
240
+ * Format: `JACS {jacsId}:{timestamp}:{signature_base64}`
241
+ */
242
+ private buildAuthHeaders(): Record<string, string> {
243
+ return {
244
+ 'Authorization': this.buildAuthHeader(),
245
+ 'Content-Type': 'application/json',
246
+ };
247
+ }
248
+
249
+ /** Sign a UTF-8 message with the agent's private key via JACS. Returns base64. */
250
+ signMessage(message: string): string {
251
+ return this.agent.signStringSync(message);
252
+ }
253
+
254
+ /** Build the JACS Authorization header value string. */
255
+ buildAuthHeader(): string {
256
+ // Prefer JACS binding delegation
257
+ if ('buildAuthHeaderSync' in this.agent && typeof (this.agent as unknown as Record<string, unknown>).buildAuthHeaderSync === 'function') {
258
+ return (this.agent as unknown as Record<string, unknown> & { buildAuthHeaderSync: () => string }).buildAuthHeaderSync();
259
+ }
260
+ // Fallback: local construction
261
+ const timestamp = Math.floor(Date.now() / 1000).toString();
262
+ const message = `${this.jacsId}:${timestamp}`;
263
+ const signature = this.agent.signStringSync(message);
264
+ return `JACS ${this.jacsId}:${timestamp}:${signature}`;
265
+ }
266
+
267
+ private makeUrl(path: string): string {
268
+ const cleanPath = path.startsWith('/') ? path : `/${path}`;
269
+ return `${this.baseUrl}${cleanPath}`;
270
+ }
271
+
272
+ private encodePathSegment(segment: string): string {
273
+ return encodeURIComponent(segment);
274
+ }
275
+
276
+ private usernameEndpoint(agentId: string): string {
277
+ const safeAgentId = this.encodePathSegment(agentId);
278
+ return this.makeUrl(`/api/v1/agents/${safeAgentId}/username`);
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // hello()
283
+ // ---------------------------------------------------------------------------
284
+
285
+ /**
286
+ * Perform a hello world exchange with HAI.
287
+ *
288
+ * Sends a JACS-signed request to the HAI hello endpoint. HAI responds
289
+ * with a signed ACK containing the caller's IP and a timestamp.
290
+ *
291
+ * @param includeTest - If true, request a test scenario preview
292
+ * @returns HelloWorldResult with HAI's signed acknowledgment
293
+ */
294
+ async hello(includeTest: boolean = false): Promise<HelloWorldResult> {
295
+ const url = this.makeUrl('/api/v1/agents/hello');
296
+ const payload: Record<string, unknown> = { agent_id: this.jacsId };
297
+ if (includeTest) {
298
+ payload.include_test = true;
299
+ }
300
+
301
+ const response = await this.fetchWithRetry(url, {
302
+ method: 'POST',
303
+ headers: this.buildAuthHeaders(),
304
+ body: JSON.stringify(payload),
305
+ });
306
+
307
+ const data = await response.json() as Record<string, unknown>;
308
+
309
+ // Verify HAI's signature on the ACK
310
+ let haiSigValid = false;
311
+ const haiSignedAck = data.hai_signed_ack as string | undefined;
312
+ if (haiSignedAck) {
313
+ const fingerprint = (data.hai_public_key_fingerprint as string) || '';
314
+ const serverKey = this.serverPublicKeys[fingerprint];
315
+ if (serverKey) {
316
+ haiSigValid = this.verifyHaiMessage(
317
+ JSON.stringify(data),
318
+ haiSignedAck,
319
+ serverKey,
320
+ );
321
+ }
322
+ }
323
+
324
+ return {
325
+ success: true,
326
+ timestamp: (data.timestamp as string) || '',
327
+ clientIp: (data.client_ip as string) || '',
328
+ haiPublicKeyFingerprint: (data.hai_public_key_fingerprint as string) || '',
329
+ message: (data.message as string) || '',
330
+ haiSignedAck: (data.hai_signed_ack as string) || '',
331
+ helloId: (data.hello_id as string) || '',
332
+ testScenario: data.test_scenario,
333
+ haiSignatureValid: haiSigValid,
334
+ rawResponse: data,
335
+ };
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // verifyHaiMessage()
340
+ // ---------------------------------------------------------------------------
341
+
342
+ /**
343
+ * Verify a message signed by HAI via JACS.
344
+ *
345
+ * @param message - The message string that was signed
346
+ * @param signature - The signature to verify (base64-encoded)
347
+ * @param haiPublicKey - HAI's public key (PEM)
348
+ * @returns true if signature is valid
349
+ */
350
+ verifyHaiMessage(message: string, signature: string, haiPublicKey: string = ''): boolean {
351
+ if (!signature || !message) return false;
352
+ if (!haiPublicKey) return false;
353
+ try {
354
+ return this.agent.verifyStringSync(
355
+ message,
356
+ signature,
357
+ Buffer.from(haiPublicKey, 'utf-8'),
358
+ 'pem',
359
+ );
360
+ } catch {
361
+ return false;
362
+ }
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // register()
367
+ // ---------------------------------------------------------------------------
368
+
369
+ /**
370
+ * Register this agent with HAI.
371
+ *
372
+ * Generates a JACS agent document with the agent's public key and
373
+ * POSTs to the registration endpoint.
374
+ *
375
+ * This is the haiai equivalent of JACS's `registerWithHai()`. Unlike
376
+ * the JACS version (which uses API-key Bearer auth), this method uses
377
+ * the self-signed agent document as authentication. See also {@link registerNewAgent}
378
+ * for a full generate-and-register workflow.
379
+ *
380
+ * @param options - Optional registration parameters
381
+ */
382
+ async register(options?: {
383
+ ownerEmail?: string;
384
+ description?: string;
385
+ domain?: string;
386
+ agentJson?: string;
387
+ publicKeyPem?: string;
388
+ }): Promise<RegistrationResult> {
389
+ const derived = this.exportKeys();
390
+ const publicKeyPem = options?.publicKeyPem ?? derived.publicKeyPem;
391
+ let agentJson = options?.agentJson;
392
+
393
+ if (!agentJson) {
394
+ // Build JACS agent document
395
+ const agentDoc: Record<string, unknown> = {
396
+ jacsId: this.jacsId,
397
+ jacsVersion: '1.0.0',
398
+ jacsSignature: {
399
+ agentID: this.jacsId,
400
+ date: new Date().toISOString(),
401
+ },
402
+ jacsPublicKey: publicKeyPem,
403
+ name: this.config.jacsAgentName,
404
+ description: options?.description ?? 'Agent registered via Node SDK',
405
+ capabilities: ['mediation'],
406
+ version: this.config.jacsAgentVersion,
407
+ };
408
+ if (options?.domain) {
409
+ agentDoc.domain = options.domain;
410
+ }
411
+
412
+ // Sign canonical JSON via JACS
413
+ const canonical = canonicalJson(agentDoc);
414
+ const signature = this.agent.signStringSync(canonical);
415
+ (agentDoc.jacsSignature as Record<string, string>).signature = signature;
416
+ agentJson = JSON.stringify(agentDoc);
417
+ }
418
+
419
+ const url = this.makeUrl('/api/v1/agents/register');
420
+ const publicKeyB64 = Buffer.from(publicKeyPem, 'utf-8').toString('base64');
421
+ const body: Record<string, unknown> = {
422
+ agent_json: agentJson,
423
+ public_key: publicKeyB64,
424
+ };
425
+ if (options?.ownerEmail) {
426
+ body.owner_email = options.ownerEmail;
427
+ }
428
+ if (options?.domain) {
429
+ body.domain = options.domain;
430
+ }
431
+ if (options?.description) {
432
+ body.description = options.description;
433
+ }
434
+
435
+ const response = await this.fetchWithRetry(url, {
436
+ method: 'POST',
437
+ // New-agent registration is self-authenticated by the signed agent document.
438
+ headers: { 'Content-Type': 'application/json' },
439
+ body: JSON.stringify(body),
440
+ });
441
+
442
+ const data = await response.json() as Record<string, unknown>;
443
+
444
+ // After successful registration, store the HAI-assigned agent_id (UUID).
445
+ // Email endpoints use this UUID in their URL paths while auth headers
446
+ // continue to use the original JACS ID string.
447
+ const assignedAgentId = (data.agent_id as string) || (data.agentId as string) || '';
448
+ if (assignedAgentId) {
449
+ this._haiAgentId = assignedAgentId;
450
+ }
451
+
452
+ return {
453
+ success: true,
454
+ agentId: assignedAgentId,
455
+ jacsId: (data.jacs_id as string) || (data.jacsId as string) || this.jacsId,
456
+ haiSignature: (data.hai_signature as string) || (data.haiSignature as string) || '',
457
+ registrationId: (data.registration_id as string) || (data.registrationId as string) || '',
458
+ registeredAt: (data.registered_at as string) || (data.registeredAt as string) || '',
459
+ rawResponse: data,
460
+ };
461
+ }
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // rotateKeys()
465
+ // ---------------------------------------------------------------------------
466
+
467
+ /**
468
+ * Rotate the agent's cryptographic keys.
469
+ *
470
+ * Archives old keys, generates a new keypair via JACS core, builds a new
471
+ * self-signed agent document, updates config, and optionally re-registers
472
+ * with HAI.
473
+ *
474
+ * @param options - Rotation options (registerWithHai, haiUrl).
475
+ * @returns RotationResult with old/new versions and registration status.
476
+ */
477
+ async rotateKeys(options?: RotateKeysOptions): Promise<RotationResult> {
478
+ const {
479
+ copyFile,
480
+ mkdtemp,
481
+ readFile: readF,
482
+ rename,
483
+ rm,
484
+ stat: fsStat,
485
+ writeFile,
486
+ } = await import('node:fs/promises');
487
+ const { randomUUID } = await import('node:crypto');
488
+ const { join, resolve } = await import('node:path');
489
+ const { tmpdir } = await import('node:os');
490
+
491
+ const registerWithHai = options?.registerWithHai ?? true;
492
+ const haiUrl = options?.haiUrl ?? this.baseUrl;
493
+
494
+ if (!this.config.jacsId) {
495
+ throw new AuthenticationError('Cannot rotate keys: no jacsId in config. Register first.');
496
+ }
497
+
498
+ const jacsId = this.config.jacsId;
499
+ const oldVersion = this.config.jacsAgentVersion;
500
+ const keyDir = this.config.jacsKeyDir;
501
+
502
+ // Build old-key auth header BEFORE rotation (chain of trust)
503
+ const oldAuthTimestamp = Math.floor(Date.now() / 1000).toString();
504
+ const oldAuthMessage = `${jacsId}:${oldVersion}:${oldAuthTimestamp}`;
505
+ const oldAuthSig = this.agent.signStringSync(oldAuthMessage);
506
+ const oldAgent = this.agent;
507
+
508
+ // Find existing private key file
509
+ const candidates = [
510
+ join(keyDir, 'agent_private_key.pem'),
511
+ join(keyDir, `${this.config.jacsAgentName}.private.pem`),
512
+ join(keyDir, 'private_key.pem'),
513
+ ];
514
+
515
+ let privKeyPath: string | null = null;
516
+ for (const candidate of candidates) {
517
+ try {
518
+ await fsStat(candidate);
519
+ privKeyPath = candidate;
520
+ break;
521
+ } catch {
522
+ // continue
523
+ }
524
+ }
525
+
526
+ if (!privKeyPath) {
527
+ throw new AuthenticationError(
528
+ `Cannot rotate keys: private key not found. Searched: ${candidates.join(', ')}`,
529
+ );
530
+ }
531
+
532
+ // Derive public key path
533
+ const pubKeyPath = privKeyPath.replace('private', 'public');
534
+
535
+ // 1. Archive old keys
536
+ const archivePriv = privKeyPath.replace('.pem', `.${oldVersion}.pem`);
537
+ const archivePub = pubKeyPath.replace('.pem', `.${oldVersion}.pem`);
538
+
539
+ await rename(privKeyPath, archivePriv);
540
+ try {
541
+ await fsStat(pubKeyPath);
542
+ await rename(pubKeyPath, archivePub);
543
+ } catch (err) {
544
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
545
+ console.warn('Failed to archive public key:', err);
546
+ }
547
+ }
548
+
549
+ // 2. Generate new JACS agent (keys + config) via JACS core
550
+ const passphrase = (this as any)._privateKeyPassphrase
551
+ ?? process.env.JACS_PRIVATE_KEY_PASSWORD
552
+ ?? '';
553
+ const newVersion = randomUUID();
554
+ const generatedKeyDir = await mkdtemp(join(tmpdir(), 'haiai-rotate-'));
555
+ let newPublicKeyPem: string;
556
+ try {
557
+ const resultJson = createAgentSync(
558
+ this.config.jacsAgentName,
559
+ passphrase,
560
+ 'pq2025',
561
+ null, // data dir
562
+ generatedKeyDir,
563
+ null, // config path (don't overwrite main config)
564
+ null, // agent type
565
+ (this.config as unknown as Record<string, unknown>).description as string
566
+ ?? 'Agent registered via Node SDK',
567
+ null, // domain
568
+ null, // default storage
569
+ );
570
+ const result = JSON.parse(resultJson);
571
+ const newPubKeyPath = result.public_key_path || join(keyDir, 'jacs.public.pem');
572
+ const newPrivKeyPath = result.private_key_path || join(keyDir, 'jacs.private.pem.enc');
573
+ const newPublicKeyRaw = await readF(newPubKeyPath);
574
+ newPublicKeyPem = normalizeKeyText(newPublicKeyRaw, 'PUBLIC KEY');
575
+
576
+ if (newPrivKeyPath !== privKeyPath) {
577
+ await copyFile(newPrivKeyPath, privKeyPath);
578
+ }
579
+ if (newPubKeyPath !== pubKeyPath) {
580
+ await writeFile(pubKeyPath, `${newPublicKeyPem}\n`);
581
+ } else {
582
+ await writeFile(pubKeyPath, `${newPublicKeyPem}\n`);
583
+ }
584
+ (this as any)._privateKeyPem = (await readF(privKeyPath)).toString('base64');
585
+ (this as any).privateKeyPem = (this as any)._privateKeyPem;
586
+ } catch (err) {
587
+ // Rollback: restore archived keys
588
+ await rename(archivePriv, privKeyPath).catch(() => {});
589
+ try { await rename(archivePub, pubKeyPath); } catch { /* noop */ }
590
+ throw new AuthenticationError(`Key generation failed: ${err}`);
591
+ } finally {
592
+ await rm(generatedKeyDir, { recursive: true, force: true }).catch(() => {});
593
+ }
594
+
595
+ // 3. Build new agent document
596
+ const agentDoc: Record<string, unknown> = {
597
+ jacsId,
598
+ jacsVersion: newVersion,
599
+ jacsPreviousVersion: oldVersion,
600
+ jacsPublicKey: newPublicKeyPem,
601
+ name: this.config.jacsAgentName,
602
+ description: (this.config as unknown as Record<string, unknown>).description
603
+ ?? (this.config as unknown as Record<string, unknown>).jacsAgentDescription
604
+ ?? `Agent registered via Node SDK`,
605
+ jacsSignature: {
606
+ agentID: jacsId,
607
+ date: new Date().toISOString(),
608
+ },
609
+ };
610
+
611
+ // Reload the agent with new keys for signing
612
+ const configPath = resolve(process.env.JACS_CONFIG_PATH ?? './jacs.config.json');
613
+ const reloadedAgent = new JacsAgent();
614
+ try {
615
+ await reloadedAgent.load(configPath);
616
+ this.agent = reloadedAgent;
617
+ } catch {
618
+ // Fall back to the currently loaded in-memory agent when the freshly
619
+ // generated on-disk config cannot be reloaded in this process.
620
+ this.agent = oldAgent;
621
+ }
622
+
623
+ const canonical = canonicalJson(agentDoc);
624
+ const signature = this.agent.signStringSync(canonical);
625
+ (agentDoc.jacsSignature as Record<string, string>).signature = signature;
626
+ const signedAgentJson = JSON.stringify(agentDoc, null, 2);
627
+
628
+ // 4. Compute new public key hash via JACS
629
+ const newPublicKeyHash = hashString(newPublicKeyPem);
630
+
631
+ // 5. Update in-memory state
632
+ this.config = {
633
+ ...this.config,
634
+ jacsAgentVersion: newVersion,
635
+ };
636
+
637
+ // 6. Update config file
638
+ try {
639
+ const raw = JSON.parse(await readF(configPath, 'utf-8')) as Record<string, unknown>;
640
+ raw.jacsAgentVersion = newVersion;
641
+ await writeFile(configPath, JSON.stringify(raw, null, 2) + '\n');
642
+ } catch {
643
+ // Config update failure is non-fatal for rotation
644
+ }
645
+
646
+ // 7. Optionally re-register with HAI using the OLD key for auth
647
+ let registeredWithHai = false;
648
+ if (registerWithHai && haiUrl) {
649
+ try {
650
+ const authHeader = `JACS ${jacsId}:${oldVersion}:${oldAuthTimestamp}:${oldAuthSig}`;
651
+
652
+ const url = this.makeUrl('/api/v1/agents/register');
653
+ const publicKeyB64 = Buffer.from(newPublicKeyPem, 'utf-8').toString('base64');
654
+ const body = JSON.stringify({
655
+ agent_json: signedAgentJson,
656
+ public_key: publicKeyB64,
657
+ });
658
+ const resp = await this.fetchWithRetry(url, {
659
+ method: 'POST',
660
+ headers: {
661
+ 'Authorization': authHeader,
662
+ 'Content-Type': 'application/json',
663
+ },
664
+ body,
665
+ });
666
+ if (resp.ok) {
667
+ registeredWithHai = true;
668
+ }
669
+ } catch {
670
+ // HAI failure is non-fatal — local rotation is preserved
671
+ }
672
+ }
673
+
674
+ return {
675
+ jacsId,
676
+ oldVersion,
677
+ newVersion,
678
+ newPublicKeyHash,
679
+ registeredWithHai,
680
+ signedAgentJson,
681
+ };
682
+ }
683
+
684
+ // ---------------------------------------------------------------------------
685
+ // verify()
686
+ // ---------------------------------------------------------------------------
687
+
688
+ /** Verify the agent's registration status. */
689
+ async verify(): Promise<VerifyAgentResult> {
690
+ const safeJacsId = this.encodePathSegment(this.jacsId);
691
+ const url = this.makeUrl(`/api/v1/agents/${safeJacsId}/verify`);
692
+
693
+ const response = await this.fetchWithRetry(url, {
694
+ method: 'GET',
695
+ headers: this.buildAuthHeaders(),
696
+ });
697
+
698
+ const data = await response.json() as Record<string, unknown>;
699
+
700
+ const rawRegistrations = (data.registrations as Array<Record<string, unknown>>) || [];
701
+ const registrations: RegistrationEntry[] = rawRegistrations.map((r) => ({
702
+ keyId: (r.key_id as string) || '',
703
+ algorithm: (r.algorithm as string) || '',
704
+ signatureJson: (r.signature_json as string) || '',
705
+ signedAt: (r.signed_at as string) || '',
706
+ }));
707
+
708
+ return {
709
+ jacsId: (data.jacs_id as string) || this.jacsId,
710
+ registered: (data.registered as boolean) ?? false,
711
+ registrations,
712
+ dnsVerified: (data.dns_verified as boolean) ?? false,
713
+ registeredAt: (data.registered_at as string) || '',
714
+ rawResponse: data,
715
+ };
716
+ }
717
+
718
+ /** @deprecated Use verify() instead. */
719
+ async status(): Promise<VerifyAgentResult> {
720
+ return this.verify();
721
+ }
722
+
723
+ // ---------------------------------------------------------------------------
724
+ // freeChaoticRun()
725
+ // ---------------------------------------------------------------------------
726
+
727
+ /**
728
+ * Run a free chaotic benchmark.
729
+ *
730
+ * No scoring, returns raw transcript with structural annotations.
731
+ * Rate limited to 3 runs per JACS keypair per 24 hours.
732
+ */
733
+ async freeChaoticRun(options?: FreeChaoticRunOptions): Promise<FreeChaoticResult> {
734
+ const url = this.makeUrl('/api/benchmark/run');
735
+ const payload = {
736
+ name: `Free Run - ${this.jacsId.slice(0, 8)}`,
737
+ tier: 'free',
738
+ transport: options?.transport ?? 'sse',
739
+ };
740
+
741
+ const response = await this.fetchWithRetry(url, {
742
+ method: 'POST',
743
+ headers: this.buildAuthHeaders(),
744
+ body: JSON.stringify(payload),
745
+ }, Math.max(this.timeout, 120000));
746
+
747
+ const data = await response.json() as Record<string, unknown>;
748
+
749
+ return {
750
+ success: true,
751
+ runId: (data.run_id as string) || (data.runId as string) || '',
752
+ transcript: this.parseTranscript((data.transcript as unknown[]) || []),
753
+ upsellMessage: (data.upsell_message as string) || (data.upsellMessage as string) || '',
754
+ rawResponse: data,
755
+ };
756
+ }
757
+
758
+ // ---------------------------------------------------------------------------
759
+ // proRun()
760
+ // ---------------------------------------------------------------------------
761
+
762
+ /**
763
+ * Run a pro tier benchmark ($20/month).
764
+ *
765
+ * Flow: create Stripe checkout -> poll for payment -> run benchmark.
766
+ */
767
+ async proRun(options?: ProRunOptions): Promise<ProRunResult> {
768
+ const pollIntervalMs = options?.pollIntervalMs ?? 2000;
769
+ const pollTimeoutMs = options?.pollTimeoutMs ?? 300000;
770
+
771
+ // Step 1: Create Stripe Checkout session
772
+ const purchaseUrl = this.makeUrl('/api/benchmark/purchase');
773
+ const purchasePayload = { tier: 'pro', agent_id: this.jacsId };
774
+
775
+ const purchaseResp = await this.fetchWithRetry(purchaseUrl, {
776
+ method: 'POST',
777
+ headers: this.buildAuthHeaders(),
778
+ body: JSON.stringify(purchasePayload),
779
+ });
780
+
781
+ const purchaseData = await purchaseResp.json() as Record<string, unknown>;
782
+ const checkoutUrl = (purchaseData.checkout_url as string) || '';
783
+ const paymentId = (purchaseData.payment_id as string) || '';
784
+
785
+ if (!checkoutUrl) {
786
+ throw new HaiError('No checkout URL returned from API');
787
+ }
788
+
789
+ // Step 2: Notify caller of checkout URL
790
+ if (options?.onCheckoutUrl) {
791
+ options.onCheckoutUrl(checkoutUrl);
792
+ }
793
+
794
+ // Step 3: Poll for payment confirmation
795
+ const paymentStatusUrl = this.makeUrl(
796
+ `/api/benchmark/payments/${this.encodePathSegment(paymentId)}/status`,
797
+ );
798
+ const startTime = Date.now();
799
+
800
+ while (Date.now() - startTime < pollTimeoutMs) {
801
+ try {
802
+ const statusResp = await this.fetchWithRetry(paymentStatusUrl, {
803
+ headers: this.buildAuthHeaders(),
804
+ });
805
+
806
+ if (statusResp.status === 200) {
807
+ const statusData = await statusResp.json() as Record<string, unknown>;
808
+ const paymentStatus = (statusData.status as string) || '';
809
+
810
+ if (paymentStatus === 'paid') break;
811
+ if (['failed', 'expired', 'cancelled'].includes(paymentStatus)) {
812
+ throw new HaiError(`Payment ${paymentStatus}: ${statusData.message || ''}`);
813
+ }
814
+ }
815
+ } catch (e) {
816
+ if (e instanceof HaiError) throw e;
817
+ // Ignore transient errors during polling
818
+ }
819
+
820
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
821
+ }
822
+
823
+ if (Date.now() - startTime >= pollTimeoutMs) {
824
+ throw new HaiError('Payment not confirmed within timeout. Complete payment and retry.');
825
+ }
826
+
827
+ // Step 4: Run the benchmark
828
+ const runUrl = this.makeUrl('/api/benchmark/run');
829
+ const runPayload = {
830
+ name: `Pro Run - ${this.jacsId.slice(0, 8)}`,
831
+ tier: 'pro',
832
+ payment_id: paymentId,
833
+ transport: options?.transport ?? 'sse',
834
+ };
835
+
836
+ const runResponse = await this.fetchWithRetry(runUrl, {
837
+ method: 'POST',
838
+ headers: this.buildAuthHeaders(),
839
+ body: JSON.stringify(runPayload),
840
+ }, Math.max(this.timeout, 300000));
841
+
842
+ const data = await runResponse.json() as Record<string, unknown>;
843
+
844
+ return {
845
+ success: true,
846
+ runId: (data.run_id as string) || (data.runId as string) || '',
847
+ score: Number(data.score) || 0,
848
+ transcript: this.parseTranscript((data.transcript as unknown[]) || []),
849
+ paymentId,
850
+ rawResponse: data,
851
+ };
852
+ }
853
+
854
+ /** @deprecated Use proRun instead. The tier was renamed from dns_certified to pro. */
855
+ async dnsCertifiedRun(options?: DnsCertifiedRunOptions): Promise<DnsCertifiedResult> {
856
+ return this.proRun(options);
857
+ }
858
+
859
+ // ---------------------------------------------------------------------------
860
+ // enterpriseRun()
861
+ // ---------------------------------------------------------------------------
862
+
863
+ /**
864
+ * Run an enterprise tier benchmark.
865
+ *
866
+ * The enterprise tier is coming soon.
867
+ * Contact support@hai.ai for early access.
868
+ */
869
+ async enterpriseRun(_options?: Record<string, unknown>): Promise<never> {
870
+ throw new Error(
871
+ 'The enterprise tier is coming soon. ' +
872
+ 'Contact support@hai.ai for early access.'
873
+ );
874
+ }
875
+
876
+ /** @deprecated Use enterpriseRun instead. The tier was renamed from fully_certified to enterprise. */
877
+ async certifiedRun(_options?: Record<string, unknown>): Promise<never> {
878
+ return this.enterpriseRun(_options);
879
+ }
880
+
881
+ // ---------------------------------------------------------------------------
882
+ // submitResponse()
883
+ // ---------------------------------------------------------------------------
884
+
885
+ /**
886
+ * Submit a mediation response for a benchmark job.
887
+ *
888
+ * @param jobId - The job/run ID from the benchmark_job event
889
+ * @param message - The mediator's response message
890
+ * @param options - Optional metadata and processingTimeMs
891
+ */
892
+ async submitResponse(
893
+ jobId: string,
894
+ message: string,
895
+ options?: {
896
+ metadata?: Record<string, unknown>;
897
+ processingTimeMs?: number;
898
+ },
899
+ ): Promise<JobResponseResult> {
900
+ const safeJobId = this.encodePathSegment(jobId);
901
+ const url = this.makeUrl(`/api/v1/agents/jobs/${safeJobId}/response`);
902
+
903
+ const body: JobResponse = {
904
+ response: {
905
+ message,
906
+ metadata: options?.metadata ?? null,
907
+ processing_time_ms: options?.processingTimeMs ?? 0,
908
+ },
909
+ };
910
+
911
+ // Sign the response as a JACS document via JACS
912
+ const signed = signResponse(body, this.agent, this.jacsId);
913
+
914
+ const response = await this.fetchWithRetry(url, {
915
+ method: 'POST',
916
+ headers: this.buildAuthHeaders(),
917
+ body: JSON.stringify(signed),
918
+ });
919
+
920
+ const data = await response.json() as Record<string, unknown>;
921
+
922
+ return {
923
+ success: (data.success as boolean) ?? true,
924
+ jobId: (data.job_id as string) || (data.jobId as string) || jobId,
925
+ message: (data.message as string) || 'Response accepted',
926
+ rawResponse: data,
927
+ };
928
+ }
929
+
930
+ // ---------------------------------------------------------------------------
931
+ // connect()
932
+ // ---------------------------------------------------------------------------
933
+
934
+ /**
935
+ * Connect to HAI event stream via SSE or WebSocket.
936
+ *
937
+ * Returns an async generator that yields HaiEvent objects.
938
+ * Supports automatic reconnection with exponential backoff.
939
+ */
940
+ async *connect(options?: ConnectOptions): AsyncGenerator<HaiEvent> {
941
+ const transport = options?.transport ?? 'sse';
942
+ const onEvent = options?.onEvent;
943
+
944
+ this._shouldDisconnect = false;
945
+ this._connected = false;
946
+
947
+ if (transport === 'ws') {
948
+ yield* this.connectWs(onEvent);
949
+ } else {
950
+ yield* this.connectSse(onEvent);
951
+ }
952
+ }
953
+
954
+ /**
955
+ * Disconnect from the event stream (SSE or WebSocket).
956
+ * Safe to call even if not connected.
957
+ */
958
+ disconnect(): void {
959
+ this._shouldDisconnect = true;
960
+
961
+ if (this._wsConnection) {
962
+ try {
963
+ (this._wsConnection as { close(): void }).close();
964
+ } catch { /* ignore */ }
965
+ this._wsConnection = null;
966
+ }
967
+
968
+ this._connected = false;
969
+ }
970
+
971
+ // ---------------------------------------------------------------------------
972
+ // onBenchmarkJob()
973
+ // ---------------------------------------------------------------------------
974
+
975
+ /**
976
+ * Convenience wrapper: connect and dispatch benchmark_job events.
977
+ *
978
+ * Runs until disconnect() is called.
979
+ */
980
+ async onBenchmarkJob(
981
+ handler: (job: BenchmarkJob) => Promise<void>,
982
+ options?: OnBenchmarkJobOptions,
983
+ ): Promise<void> {
984
+ for await (const event of this.connect({ transport: options?.transport })) {
985
+ if (event.eventType === 'benchmark_job') {
986
+ const data = (typeof event.data === 'object' && event.data !== null)
987
+ ? event.data as Record<string, unknown>
988
+ : {};
989
+
990
+ const job: BenchmarkJob = {
991
+ runId: (data.run_id as string) || (data.runId as string) || '',
992
+ scenario: data.scenario ?? data.prompt ?? data,
993
+ data,
994
+ };
995
+
996
+ await handler(job);
997
+ }
998
+ }
999
+ }
1000
+
1001
+ // ---------------------------------------------------------------------------
1002
+ // checkUsername()
1003
+ // ---------------------------------------------------------------------------
1004
+
1005
+ /**
1006
+ * Check if a username is available for claiming.
1007
+ * This is a public endpoint and does not require authentication.
1008
+ *
1009
+ * @param username - The username to check
1010
+ * @returns Availability result
1011
+ */
1012
+ async checkUsername(username: string): Promise<CheckUsernameResult> {
1013
+ const url = this.makeUrl(`/api/v1/agents/username/check?username=${encodeURIComponent(username)}`);
1014
+
1015
+ const response = await this.fetchWithRetry(url, {
1016
+ method: 'GET',
1017
+ headers: { 'Content-Type': 'application/json' },
1018
+ });
1019
+
1020
+ const data = await response.json() as Record<string, unknown>;
1021
+
1022
+ return {
1023
+ available: (data.available as boolean) ?? false,
1024
+ username: (data.username as string) || username,
1025
+ reason: (data.reason as string) || undefined,
1026
+ };
1027
+ }
1028
+
1029
+ // ---------------------------------------------------------------------------
1030
+ // claimUsername()
1031
+ // ---------------------------------------------------------------------------
1032
+
1033
+ /**
1034
+ * Claim a username for an agent. Requires JACS auth.
1035
+ *
1036
+ * @param agentId - The JACS ID of the agent to claim the username for
1037
+ * @param username - The username to claim
1038
+ * @returns Claim result with the assigned email
1039
+ */
1040
+ async claimUsername(agentId: string, username: string): Promise<ClaimUsernameResult> {
1041
+ const url = this.usernameEndpoint(agentId);
1042
+
1043
+ const response = await this.fetchWithRetry(url, {
1044
+ method: 'POST',
1045
+ headers: this.buildAuthHeaders(),
1046
+ body: JSON.stringify({ username }),
1047
+ });
1048
+
1049
+ const data = await response.json() as Record<string, unknown>;
1050
+
1051
+ this.agentEmail = (data.email as string) || '';
1052
+
1053
+ return {
1054
+ username: (data.username as string) || username,
1055
+ email: (data.email as string) || '',
1056
+ agentId: (data.agent_id as string) || (data.agentId as string) || agentId,
1057
+ };
1058
+ }
1059
+
1060
+ /**
1061
+ * Rename a claimed username for an agent. Requires JACS auth.
1062
+ *
1063
+ * @param agentId - The agent ID to update
1064
+ * @param username - The new username
1065
+ */
1066
+ async updateUsername(agentId: string, username: string): Promise<UpdateUsernameResult> {
1067
+ const url = this.usernameEndpoint(agentId);
1068
+
1069
+ const response = await this.fetchWithRetry(url, {
1070
+ method: 'PUT',
1071
+ headers: this.buildAuthHeaders(),
1072
+ body: JSON.stringify({ username }),
1073
+ });
1074
+
1075
+ const data = await response.json() as Record<string, unknown>;
1076
+ return {
1077
+ username: (data.username as string) || username,
1078
+ email: (data.email as string) || '',
1079
+ previousUsername: (data.previous_username as string) || '',
1080
+ };
1081
+ }
1082
+
1083
+ /**
1084
+ * Delete a claimed username for an agent. Requires JACS auth.
1085
+ *
1086
+ * @param agentId - The agent ID to update
1087
+ */
1088
+ async deleteUsername(agentId: string): Promise<DeleteUsernameResult> {
1089
+ const url = this.usernameEndpoint(agentId);
1090
+
1091
+ const response = await this.fetchWithRetry(url, {
1092
+ method: 'DELETE',
1093
+ headers: this.buildAuthHeaders(),
1094
+ });
1095
+
1096
+ const data = await response.json() as Record<string, unknown>;
1097
+ return {
1098
+ releasedUsername: (data.released_username as string) || '',
1099
+ cooldownUntil: (data.cooldown_until as string) || '',
1100
+ message: (data.message as string) || '',
1101
+ };
1102
+ }
1103
+
1104
+ // ---------------------------------------------------------------------------
1105
+ // verifyDocument()
1106
+ // ---------------------------------------------------------------------------
1107
+
1108
+ /**
1109
+ * Verify a signed JACS document via HAI's public verification endpoint.
1110
+ * This endpoint is public and does not require authentication.
1111
+ *
1112
+ * @param document - Signed JACS document JSON (object or string)
1113
+ */
1114
+ async verifyDocument(document: Record<string, unknown> | string): Promise<DocumentVerificationResult> {
1115
+ const url = this.makeUrl('/api/jacs/verify');
1116
+ const rawDocument = typeof document === 'string' ? document : JSON.stringify(document);
1117
+
1118
+ const response = await this.fetchWithRetry(url, {
1119
+ method: 'POST',
1120
+ headers: { 'Content-Type': 'application/json' },
1121
+ body: JSON.stringify({ document: rawDocument }),
1122
+ });
1123
+
1124
+ const data = await response.json() as Record<string, unknown>;
1125
+ return {
1126
+ valid: (data.valid as boolean) ?? false,
1127
+ verifiedAt: (data.verified_at as string) || '',
1128
+ documentType: (data.document_type as string) || '',
1129
+ issuerVerified: (data.issuer_verified as boolean) ?? false,
1130
+ signatureVerified: (data.signature_verified as boolean) ?? false,
1131
+ signerId: (data.signer_id as string) || '',
1132
+ signedAt: (data.signed_at as string) || '',
1133
+ error: (data.error as string) || undefined,
1134
+ };
1135
+ }
1136
+
1137
+ private parseAdvancedVerificationResult(
1138
+ data: Record<string, unknown>,
1139
+ fallbackAgentId: string = '',
1140
+ ): AdvancedVerificationResult {
1141
+ const verification = (data.verification as Record<string, unknown>) || {};
1142
+ return {
1143
+ agentId: (data.agent_id as string) || fallbackAgentId,
1144
+ verification: {
1145
+ jacsValid: (verification.jacs_valid as boolean) ?? false,
1146
+ dnsValid: (verification.dns_valid as boolean) ?? false,
1147
+ haiRegistered: (verification.hai_registered as boolean) ?? false,
1148
+ badge: (verification.badge as 'none' | 'basic' | 'domain' | 'attested') || 'none',
1149
+ },
1150
+ haiSignatures: ((data.hai_signatures as unknown[]) || []).map(String),
1151
+ verifiedAt: (data.verified_at as string) || '',
1152
+ errors: ((data.errors as unknown[]) || []).map(String),
1153
+ rawResponse: data,
1154
+ };
1155
+ }
1156
+
1157
+ /**
1158
+ * Get advanced 3-level verification status for an agent (public endpoint).
1159
+ *
1160
+ * GET /api/v1/agents/{agent_id}/verification
1161
+ */
1162
+ async getVerification(agentId: string): Promise<AdvancedVerificationResult> {
1163
+ const safeAgentId = this.encodePathSegment(agentId);
1164
+ const url = this.makeUrl(`/api/v1/agents/${safeAgentId}/verification`);
1165
+ const response = await this.fetchWithRetry(url, {
1166
+ method: 'GET',
1167
+ headers: { 'Content-Type': 'application/json' },
1168
+ });
1169
+
1170
+ const data = await response.json() as Record<string, unknown>;
1171
+ return this.parseAdvancedVerificationResult(data, agentId);
1172
+ }
1173
+
1174
+ /**
1175
+ * Verify an agent document via HAI's advanced verification endpoint (public).
1176
+ *
1177
+ * POST /api/v1/agents/verify
1178
+ */
1179
+ async verifyAgentDocumentOnHai(
1180
+ agentJson: Record<string, unknown> | string,
1181
+ options?: VerifyAgentDocumentOnHaiOptions,
1182
+ ): Promise<AdvancedVerificationResult> {
1183
+ const url = this.makeUrl('/api/v1/agents/verify');
1184
+ const payload: Record<string, unknown> = {
1185
+ agent_json: typeof agentJson === 'string' ? agentJson : JSON.stringify(agentJson),
1186
+ };
1187
+ if (options?.publicKey) {
1188
+ payload.public_key = options.publicKey;
1189
+ }
1190
+ if (options?.domain) {
1191
+ payload.domain = options.domain;
1192
+ }
1193
+
1194
+ const response = await this.fetchWithRetry(url, {
1195
+ method: 'POST',
1196
+ headers: { 'Content-Type': 'application/json' },
1197
+ body: JSON.stringify(payload),
1198
+ });
1199
+
1200
+ const data = await response.json() as Record<string, unknown>;
1201
+ return this.parseAdvancedVerificationResult(data);
1202
+ }
1203
+
1204
+ // ---------------------------------------------------------------------------
1205
+ // registerNewAgent()
1206
+ // ---------------------------------------------------------------------------
1207
+
1208
+ /**
1209
+ * Generate a fresh JACS agent and register it with HAI.
1210
+ *
1211
+ * Convenience method that combines key generation, document building,
1212
+ * signing, and registration in one call.
1213
+ *
1214
+ * @param agentName - Name for the new agent
1215
+ * @param options - Registration options
1216
+ * @returns Registration result
1217
+ */
1218
+ async registerNewAgent(agentName: string, options: {
1219
+ ownerEmail: string;
1220
+ domain?: string;
1221
+ description?: string;
1222
+ quiet?: boolean;
1223
+ }): Promise<RegistrationResult> {
1224
+ const { mkdtemp, readFile: readF } = await import('node:fs/promises');
1225
+ const { join } = await import('node:path');
1226
+ const { tmpdir } = await import('node:os');
1227
+
1228
+ // Generate a new JACS agent with keys via JACS core
1229
+ const tempDir = await mkdtemp(join(tmpdir(), 'haiai-register-'));
1230
+ const keyDir = join(tempDir, 'keys');
1231
+ const dataDir = join(tempDir, 'data');
1232
+ const passphrase = process.env.JACS_PRIVATE_KEY_PASSWORD ?? 'register-temp';
1233
+
1234
+ const resultJson = createAgentSync(
1235
+ agentName,
1236
+ passphrase,
1237
+ 'pq2025',
1238
+ dataDir,
1239
+ keyDir,
1240
+ join(tempDir, 'jacs.config.json'),
1241
+ null,
1242
+ options.description ?? 'Agent registered via Node SDK',
1243
+ options.domain ?? null,
1244
+ null,
1245
+ );
1246
+ const createResult = JSON.parse(resultJson);
1247
+
1248
+ const pubKeyPath = createResult.public_key_path || join(keyDir, 'jacs.public.pem');
1249
+ const publicKeyPem = normalizeKeyText(await readF(pubKeyPath), 'PUBLIC KEY');
1250
+
1251
+ // Load the new agent for signing
1252
+ const tempAgent = new JacsAgent();
1253
+ const tempConfigPath = createResult.config_path || join(tempDir, 'jacs.config.json');
1254
+ const { resolve } = await import('node:path');
1255
+ let signingAgent: JacsAgent = tempAgent;
1256
+ try {
1257
+ await tempAgent.load(resolve(tempConfigPath));
1258
+ } catch {
1259
+ tempAgent.ephemeralSync('pq2025');
1260
+ signingAgent = tempAgent;
1261
+ }
1262
+
1263
+ // Build minimal JACS agent document
1264
+ const agentDoc: Record<string, unknown> = {
1265
+ jacsId: agentName,
1266
+ jacsVersion: '1.0.0',
1267
+ jacsSignature: {
1268
+ agentID: agentName,
1269
+ date: new Date().toISOString(),
1270
+ },
1271
+ jacsPublicKey: publicKeyPem,
1272
+ name: agentName,
1273
+ description: options.description ?? 'Agent registered via Node SDK',
1274
+ capabilities: ['mediation'],
1275
+ version: '1.0.0',
1276
+ };
1277
+
1278
+ // Sign canonical JSON via JACS
1279
+ const canonical = canonicalJson(agentDoc);
1280
+ const signature = signingAgent.signStringSync(canonical);
1281
+ (agentDoc.jacsSignature as Record<string, string>).signature = signature;
1282
+
1283
+ const url = this.makeUrl('/api/v1/agents/register');
1284
+ const publicKeyB64 = Buffer.from(publicKeyPem, 'utf-8').toString('base64');
1285
+ const body: Record<string, unknown> = {
1286
+ agent_json: JSON.stringify(agentDoc),
1287
+ public_key: publicKeyB64,
1288
+ owner_email: options.ownerEmail,
1289
+ };
1290
+ if (options.domain) {
1291
+ body.domain = options.domain;
1292
+ }
1293
+ if (options.description) {
1294
+ body.description = options.description;
1295
+ }
1296
+
1297
+ const response = await this.fetchWithRetry(url, {
1298
+ method: 'POST',
1299
+ headers: { 'Content-Type': 'application/json' },
1300
+ body: JSON.stringify(body),
1301
+ });
1302
+
1303
+ const data = await response.json() as Record<string, unknown>;
1304
+
1305
+ if (!options.quiet) {
1306
+ console.log(`\nAgent created and submitted for registration!`);
1307
+ console.log(` -> Check your email (${options.ownerEmail}) for a verification link`);
1308
+ console.log(` -> Click the link and log into hai.ai to complete registration`);
1309
+ console.log(` -> After verification, claim a @hai.ai username with:`);
1310
+ console.log(` client.claimUsername('${(data.agent_id as string) || ''}', 'my-agent')`);
1311
+ console.log(` -> Save your config and private key to a secure, access-controlled location`);
1312
+
1313
+ if (options.domain) {
1314
+ const pubKeyHash = hashString(publicKeyPem);
1315
+ console.log(`\n--- DNS Setup Instructions ---`);
1316
+ console.log(`Add this TXT record to your domain '${options.domain}':`);
1317
+ console.log(` Name: _jacs.${options.domain}`);
1318
+ console.log(` Type: TXT`);
1319
+ console.log(` Value: sha256:${pubKeyHash}`);
1320
+ console.log(`DNS verification enables the pro tier.\n`);
1321
+ } else {
1322
+ console.log();
1323
+ }
1324
+ }
1325
+
1326
+ return {
1327
+ success: true,
1328
+ agentId: (data.agent_id as string) || (data.agentId as string) || '',
1329
+ jacsId: (data.jacs_id as string) || (data.jacsId as string) || (agentDoc.jacsId as string) || '',
1330
+ haiSignature: (data.hai_signature as string) || (data.haiSignature as string) || '',
1331
+ registrationId: (data.registration_id as string) || (data.registrationId as string) || '',
1332
+ registeredAt: (data.registered_at as string) || (data.registeredAt as string) || '',
1333
+ rawResponse: data,
1334
+ };
1335
+ }
1336
+
1337
+ // ---------------------------------------------------------------------------
1338
+ // testConnection()
1339
+ // ---------------------------------------------------------------------------
1340
+
1341
+ /**
1342
+ * Test connectivity to the HAI server.
1343
+ *
1344
+ * Tries multiple health endpoints and returns true if any respond with 2xx.
1345
+ * Does not require authentication.
1346
+ */
1347
+ async testConnection(): Promise<boolean> {
1348
+ const endpoints = ['/api/v1/health', '/health', '/api/health', '/'];
1349
+ const timeoutMs = Math.min(this.timeout, 10000);
1350
+
1351
+ for (const endpoint of endpoints) {
1352
+ try {
1353
+ const url = this.makeUrl(endpoint);
1354
+ const controller = new AbortController();
1355
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
1356
+
1357
+ const resp = await fetch(url, {
1358
+ signal: controller.signal,
1359
+ redirect: 'follow',
1360
+ });
1361
+
1362
+ clearTimeout(timeoutId);
1363
+
1364
+ if (resp.ok) {
1365
+ return true;
1366
+ }
1367
+ } catch {
1368
+ // Ignore errors and try next endpoint
1369
+ }
1370
+ }
1371
+ return false;
1372
+ }
1373
+
1374
+ // ---------------------------------------------------------------------------
1375
+ // Utility: export keys
1376
+ // ---------------------------------------------------------------------------
1377
+
1378
+ /**
1379
+ * Export the agent's public key.
1380
+ * Reads the public key from the JACS key directory.
1381
+ * Returns { publicKeyPem }.
1382
+ */
1383
+ exportKeys(): { publicKeyPem: string; privateKeyPem?: string } {
1384
+ const fs = require('node:fs');
1385
+ const path = require('node:path');
1386
+ const explicitPublicKeyPem = (this as any)._publicKeyPem;
1387
+ const explicitPrivateKeyPem = (this as any)._privateKeyPem;
1388
+ if (typeof explicitPublicKeyPem === 'string' && explicitPublicKeyPem.trim() !== '') {
1389
+ return {
1390
+ publicKeyPem: explicitPublicKeyPem.trim(),
1391
+ privateKeyPem: typeof explicitPrivateKeyPem === 'string' ? explicitPrivateKeyPem : undefined,
1392
+ };
1393
+ }
1394
+ const keyDir = this.config.jacsKeyDir;
1395
+
1396
+ const candidates = [
1397
+ path.join(keyDir, 'agent_public_key.pem'),
1398
+ path.join(keyDir, `${this.config.jacsAgentName}.public.pem`),
1399
+ path.join(keyDir, 'public_key.pem'),
1400
+ path.join(keyDir, 'jacs.public.pem'),
1401
+ ];
1402
+
1403
+ for (const candidate of candidates) {
1404
+ try {
1405
+ const content = fs.readFileSync(candidate);
1406
+ return {
1407
+ publicKeyPem: normalizeKeyText(content, 'PUBLIC KEY'),
1408
+ privateKeyPem: typeof explicitPrivateKeyPem === 'string' ? explicitPrivateKeyPem : undefined,
1409
+ };
1410
+ } catch {
1411
+ // try next
1412
+ }
1413
+ }
1414
+
1415
+ throw new AuthenticationError(
1416
+ `No public key found. Searched: ${candidates.join(', ')}`,
1417
+ );
1418
+ }
1419
+
1420
+ // ---------------------------------------------------------------------------
1421
+ // SSE transport (internal)
1422
+ // ---------------------------------------------------------------------------
1423
+
1424
+ private async *connectSse(
1425
+ onEvent?: (event: HaiEvent) => void,
1426
+ ): AsyncGenerator<HaiEvent> {
1427
+ const url = this.makeUrl('/api/v1/agents/connect');
1428
+ let reconnectDelay = 1000;
1429
+ const maxReconnectDelay = 60000;
1430
+
1431
+ while (!this._shouldDisconnect) {
1432
+ try {
1433
+ const headers: Record<string, string> = {
1434
+ ...this.buildAuthHeaders(),
1435
+ 'Accept': 'text/event-stream',
1436
+ 'Cache-Control': 'no-cache',
1437
+ };
1438
+ if (this._lastEventId) {
1439
+ headers['Last-Event-ID'] = this._lastEventId;
1440
+ }
1441
+
1442
+ const response = await fetch(url, { headers });
1443
+
1444
+ if (response.status === 401) {
1445
+ throw new AuthenticationError('JACS signature rejected by HAI', 401);
1446
+ }
1447
+ if (!response.ok) {
1448
+ throw new HaiConnectionError(`SSE connection failed with status ${response.status}`);
1449
+ }
1450
+ if (!response.body) {
1451
+ throw new HaiConnectionError('SSE response has no body');
1452
+ }
1453
+
1454
+ this._connected = true;
1455
+ reconnectDelay = 1000;
1456
+
1457
+ for await (const event of parseSseStream(response.body)) {
1458
+ if (this._shouldDisconnect) break;
1459
+ if (event.id) this._lastEventId = event.id;
1460
+
1461
+ // Unwrap signed events if we have server keys
1462
+ if (typeof event.data === 'object' && event.data !== null) {
1463
+ event.data = unwrapSignedEvent(
1464
+ event.data as Record<string, unknown>,
1465
+ this.serverPublicKeys,
1466
+ );
1467
+ }
1468
+
1469
+ if (onEvent) onEvent(event);
1470
+ yield event;
1471
+ }
1472
+ } catch (e) {
1473
+ this._connected = false;
1474
+ if (this._shouldDisconnect) break;
1475
+ if (e instanceof HaiError) throw e;
1476
+
1477
+ await new Promise(resolve => setTimeout(resolve, reconnectDelay));
1478
+ reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
1479
+ }
1480
+ }
1481
+
1482
+ this._connected = false;
1483
+ }
1484
+
1485
+ // ---------------------------------------------------------------------------
1486
+ // WebSocket transport (internal)
1487
+ // ---------------------------------------------------------------------------
1488
+
1489
+ private async *connectWs(
1490
+ onEvent?: (event: HaiEvent) => void,
1491
+ ): AsyncGenerator<HaiEvent> {
1492
+ const wsUrl = this.baseUrl
1493
+ .replace(/^https:/, 'wss:')
1494
+ .replace(/^http:/, 'ws:')
1495
+ + '/ws/agent/connect';
1496
+
1497
+ let reconnectDelay = 1000;
1498
+ const maxReconnectDelay = 60000;
1499
+
1500
+ while (!this._shouldDisconnect) {
1501
+ try {
1502
+ const headers: Record<string, string> = {
1503
+ Authorization: this.buildAuthHeader(),
1504
+ };
1505
+ if (this._lastEventId) {
1506
+ headers['Last-Event-ID'] = this._lastEventId;
1507
+ }
1508
+
1509
+ const ws = await openWebSocket(wsUrl, headers, this.timeout);
1510
+ this._wsConnection = ws;
1511
+
1512
+ try {
1513
+ this._connected = true;
1514
+ reconnectDelay = 1000;
1515
+
1516
+ // Yield connected event
1517
+ const connEvent: HaiEvent = {
1518
+ eventType: 'connected',
1519
+ data: null,
1520
+ raw: '',
1521
+ };
1522
+ if (onEvent) onEvent(connEvent);
1523
+ yield connEvent;
1524
+
1525
+ // Yield all subsequent messages
1526
+ for await (const event of wsEventStream(ws)) {
1527
+ if (this._shouldDisconnect) break;
1528
+ if (event.id) this._lastEventId = event.id;
1529
+
1530
+ // Auto-pong on heartbeat
1531
+ if (event.eventType === 'heartbeat') {
1532
+ const data = event.data as Record<string, unknown>;
1533
+ const timestamp = (data.timestamp as number) ?? Math.floor(Date.now() / 1000);
1534
+ ws.send(JSON.stringify({ type: 'pong', timestamp }));
1535
+ }
1536
+
1537
+ if (onEvent) onEvent(event);
1538
+ yield event;
1539
+ }
1540
+ } finally {
1541
+ try { ws.close(); } catch { /* ignore */ }
1542
+ this._wsConnection = null;
1543
+ }
1544
+ } catch (e) {
1545
+ this._connected = false;
1546
+ if (this._shouldDisconnect) break;
1547
+ if (e instanceof HaiError) throw e;
1548
+
1549
+ await new Promise(resolve => setTimeout(resolve, reconnectDelay));
1550
+ reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
1551
+ }
1552
+ }
1553
+
1554
+ this._connected = false;
1555
+ }
1556
+
1557
+ // ---------------------------------------------------------------------------
1558
+ // Fetch with retry and error handling
1559
+ // ---------------------------------------------------------------------------
1560
+
1561
+ private async fetchWithRetry(
1562
+ url: string,
1563
+ init: RequestInit,
1564
+ timeoutMs?: number,
1565
+ ): Promise<Response> {
1566
+ const effectiveTimeout = timeoutMs ?? this.timeout;
1567
+ let lastError: Error | null = null;
1568
+
1569
+ for (let attempt = 0; attempt < this.maxRetries; attempt++) {
1570
+ try {
1571
+ const controller = new AbortController();
1572
+ const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout);
1573
+
1574
+ const response = await fetch(url, {
1575
+ ...init,
1576
+ signal: controller.signal,
1577
+ });
1578
+
1579
+ clearTimeout(timeoutId);
1580
+
1581
+ if (response.status === 401) {
1582
+ throw new AuthenticationError('JACS signature rejected by HAI', 401);
1583
+ }
1584
+ if (response.status === 429) {
1585
+ throw new HaiError('Rate limited', 429);
1586
+ }
1587
+ if (response.ok) {
1588
+ return response;
1589
+ }
1590
+
1591
+ let msg = `Request failed with status ${response.status}`;
1592
+ try {
1593
+ const errBody = await response.json() as Record<string, unknown>;
1594
+ if (errBody.error) msg = String(errBody.error);
1595
+ } catch { /* empty */ }
1596
+ lastError = new HaiError(msg, response.status);
1597
+ } catch (e) {
1598
+ if (e instanceof HaiError) throw e;
1599
+ if (e instanceof Error && e.name === 'AbortError') {
1600
+ throw new HaiConnectionError(`Request timed out after ${effectiveTimeout}ms`);
1601
+ }
1602
+ lastError = e instanceof Error ? e : new Error(String(e));
1603
+ }
1604
+
1605
+ // Exponential backoff
1606
+ if (attempt < this.maxRetries - 1) {
1607
+ await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
1608
+ }
1609
+ }
1610
+
1611
+ throw lastError ?? new HaiError('Request failed after all retries');
1612
+ }
1613
+
1614
+ // ---------------------------------------------------------------------------
1615
+ // Transcript parsing
1616
+ // ---------------------------------------------------------------------------
1617
+
1618
+ private parseEmailMessage(m: Record<string, unknown>): EmailMessage {
1619
+ return {
1620
+ id: (m.id as string) || '',
1621
+ direction: (m.direction as string) || '',
1622
+ fromAddress: (m.from_address as string) || '',
1623
+ toAddress: (m.to_address as string) || '',
1624
+ subject: (m.subject as string) || '',
1625
+ bodyText: (m.body_text as string) || '',
1626
+ messageId: (m.message_id as string) || '',
1627
+ inReplyTo: (m.in_reply_to as string | null) ?? null,
1628
+ isRead: (m.is_read as boolean) ?? false,
1629
+ deliveryStatus: (m.delivery_status as string) || '',
1630
+ createdAt: (m.created_at as string) || '',
1631
+ readAt: (m.read_at as string | null) ?? null,
1632
+ jacsVerified: (m.jacs_verified as boolean) ?? false,
1633
+ ccAddresses: (m.cc_addresses as string[]) || [],
1634
+ labels: (m.labels as string[]) || [],
1635
+ folder: (m.folder as string) || 'inbox',
1636
+ };
1637
+ }
1638
+
1639
+ private parseTranscript(raw: unknown[]): TranscriptMessage[] {
1640
+ return (raw || []).map((msg: unknown) => {
1641
+ const m = msg as Record<string, unknown>;
1642
+ return {
1643
+ role: (m.role as string) || 'system',
1644
+ content: (m.content as string) || '',
1645
+ timestamp: (m.timestamp as string) || '',
1646
+ annotations: (m.annotations as string[]) || [],
1647
+ };
1648
+ });
1649
+ }
1650
+
1651
+ // ---------------------------------------------------------------------------
1652
+ // Server key management
1653
+ // ---------------------------------------------------------------------------
1654
+
1655
+ /** Fetch and cache server public keys for signature verification. */
1656
+ async fetchServerKeys(): Promise<void> {
1657
+ this.serverPublicKeys = await getServerKeys(this.baseUrl);
1658
+ }
1659
+
1660
+ // ---------------------------------------------------------------------------
1661
+ // getAgentAttestation()
1662
+ // ---------------------------------------------------------------------------
1663
+
1664
+ /**
1665
+ * Get attestation information for another agent.
1666
+ *
1667
+ * @param agentId - The JACS ID of the agent to query
1668
+ * @returns Attestation status including HAI signatures
1669
+ */
1670
+ async getAgentAttestation(agentId: string): Promise<VerifyAgentResult> {
1671
+ const safeAgentId = this.encodePathSegment(agentId);
1672
+ const url = this.makeUrl(`/api/v1/agents/${safeAgentId}/verify`);
1673
+
1674
+ const response = await this.fetchWithRetry(url, {
1675
+ method: 'GET',
1676
+ headers: this.buildAuthHeaders(),
1677
+ });
1678
+
1679
+ const data = await response.json() as Record<string, unknown>;
1680
+
1681
+ const rawRegistrations = (data.registrations as Array<Record<string, unknown>>) || [];
1682
+ const registrations: RegistrationEntry[] = rawRegistrations.map((r) => ({
1683
+ keyId: (r.key_id as string) || '',
1684
+ algorithm: (r.algorithm as string) || '',
1685
+ signatureJson: (r.signature_json as string) || '',
1686
+ signedAt: (r.signed_at as string) || '',
1687
+ }));
1688
+
1689
+ return {
1690
+ jacsId: (data.jacs_id as string) || agentId,
1691
+ registered: (data.registered as boolean) ?? false,
1692
+ registrations,
1693
+ dnsVerified: (data.dns_verified as boolean) ?? false,
1694
+ registeredAt: (data.registered_at as string) || '',
1695
+ rawResponse: data,
1696
+ };
1697
+ }
1698
+
1699
+ // ---------------------------------------------------------------------------
1700
+ // signBenchmarkResult()
1701
+ // ---------------------------------------------------------------------------
1702
+
1703
+ /**
1704
+ * Sign a benchmark result as a JACS document for independent verification.
1705
+ *
1706
+ * @param benchmarkResult - The benchmark result data to sign
1707
+ * @returns Signed JACS document envelope
1708
+ */
1709
+ signBenchmarkResult(benchmarkResult: Record<string, unknown>): { signed_document: string; agent_jacs_id: string } {
1710
+ return signResponse(
1711
+ benchmarkResult,
1712
+ this.agent,
1713
+ this.jacsId,
1714
+ );
1715
+ }
1716
+
1717
+ // ---------------------------------------------------------------------------
1718
+ // benchmark() -- legacy suite-based
1719
+ // ---------------------------------------------------------------------------
1720
+
1721
+ /**
1722
+ * Run a benchmark with specified name and tier.
1723
+ *
1724
+ * @param name - Benchmark run name
1725
+ * @param tier - Benchmark tier ("free", "pro", "enterprise"). Default: "free"
1726
+ * @returns Benchmark result with scores
1727
+ */
1728
+ async benchmark(name: string = 'mediation_basic', tier: string = 'free'): Promise<Record<string, unknown>> {
1729
+ const url = this.makeUrl('/api/benchmark/run');
1730
+
1731
+ const response = await this.fetchWithRetry(url, {
1732
+ method: 'POST',
1733
+ headers: this.buildAuthHeaders(),
1734
+ body: JSON.stringify({ name, tier }),
1735
+ });
1736
+
1737
+ const data = await response.json() as Record<string, unknown>;
1738
+ return data;
1739
+ }
1740
+
1741
+ // ---------------------------------------------------------------------------
1742
+ // Email CRUD
1743
+ // ---------------------------------------------------------------------------
1744
+
1745
+ /**
1746
+ * Send an email from the agent's @hai.ai address.
1747
+ *
1748
+ * @param options - Email send options (to, subject, body, optional inReplyTo)
1749
+ * @returns Send result with message ID and status
1750
+ */
1751
+ async sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
1752
+ const safeAgentId = this.encodePathSegment(this.haiAgentId);
1753
+ const url = this.makeUrl(`/api/agents/${safeAgentId}/email/send`);
1754
+
1755
+ if (!this.agentEmail) {
1756
+ throw new Error('agent email not set — call claimUsername first');
1757
+ }
1758
+
1759
+ // Server handles JACS attachment signing (TASK_014/018).
1760
+ // Client only sends content fields.
1761
+ const controller = new AbortController();
1762
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
1763
+
1764
+ let response: Response;
1765
+ try {
1766
+ const payload: Record<string, unknown> = {
1767
+ to: options.to,
1768
+ subject: options.subject,
1769
+ body: options.body,
1770
+ in_reply_to: options.inReplyTo,
1771
+ attachments: options.attachments?.map(a => ({
1772
+ filename: a.filename,
1773
+ content_type: a.contentType,
1774
+ data_base64: a.data.toString('base64'),
1775
+ })),
1776
+ };
1777
+ if (options.cc?.length) payload.cc = options.cc;
1778
+ if (options.bcc?.length) payload.bcc = options.bcc;
1779
+ if (options.labels?.length) payload.labels = options.labels;
1780
+
1781
+ response = await fetch(url, {
1782
+ method: 'POST',
1783
+ headers: this.buildAuthHeaders(),
1784
+ body: JSON.stringify(payload),
1785
+ signal: controller.signal,
1786
+ });
1787
+ } catch (e) {
1788
+ clearTimeout(timeoutId);
1789
+ if (e instanceof Error && e.name === 'AbortError') {
1790
+ throw new HaiConnectionError(`Request timed out after ${this.timeout}ms`);
1791
+ }
1792
+ throw e;
1793
+ }
1794
+ clearTimeout(timeoutId);
1795
+
1796
+ if (!response.ok) {
1797
+ const text = await response.text();
1798
+ let errCode = '';
1799
+ let errMsg = text;
1800
+ try {
1801
+ const errData = JSON.parse(text) as Record<string, unknown>;
1802
+ errCode = (errData.error_code as string) || '';
1803
+ errMsg = (errData.message as string) || (errData.error as string) || text;
1804
+ } catch { /* non-JSON body */ }
1805
+
1806
+ if (response.status === 401) {
1807
+ throw new AuthenticationError('JACS signature rejected by HAI', 401);
1808
+ }
1809
+ if (response.status === 403 && (errCode === 'EMAIL_NOT_ACTIVE' || text.toLowerCase().includes('allocated'))) {
1810
+ throw new EmailNotActiveError(errMsg, response.status, text);
1811
+ }
1812
+ if (response.status === 400 && (errCode === 'RECIPIENT_NOT_FOUND' || text.includes('Invalid recipient'))) {
1813
+ throw new RecipientNotFoundError(errMsg, response.status, text);
1814
+ }
1815
+ if (response.status === 429) {
1816
+ throw new RateLimitedError(errMsg, response.status, text);
1817
+ }
1818
+ throw new HaiApiError(errMsg, response.status, undefined, errCode, text);
1819
+ }
1820
+
1821
+ const data = await response.json() as Record<string, unknown>;
1822
+ return {
1823
+ messageId: (data.message_id as string) || '',
1824
+ status: (data.status as string) || '',
1825
+ };
1826
+ }
1827
+
1828
+ /**
1829
+ * Sign a raw RFC 5322 email with a JACS attachment via the HAI API.
1830
+ *
1831
+ * The server adds a `jacs-signature.json` MIME attachment containing
1832
+ * the detached JACS signature. The returned Buffer is the signed email.
1833
+ *
1834
+ * @param rawEmail - Raw RFC 5322 email as a Buffer or string.
1835
+ * @returns Signed email bytes with the JACS attachment added.
1836
+ */
1837
+ async signEmail(rawEmail: Buffer | string): Promise<Buffer> {
1838
+ const url = this.makeUrl('/api/v1/email/sign');
1839
+ const headers = this.buildAuthHeaders();
1840
+ headers['Content-Type'] = 'message/rfc822';
1841
+
1842
+ const body = typeof rawEmail === 'string' ? Buffer.from(rawEmail) : rawEmail;
1843
+
1844
+ const response = await this.fetchWithRetry(url, {
1845
+ method: 'POST',
1846
+ headers,
1847
+ body,
1848
+ });
1849
+
1850
+ if (!response.ok) {
1851
+ const text = await response.text();
1852
+ throw new HaiApiError(`Email sign failed: HTTP ${response.status}`, response.status, undefined, '', text);
1853
+ }
1854
+
1855
+ const arrayBuf = await response.arrayBuffer();
1856
+ return Buffer.from(arrayBuf);
1857
+ }
1858
+
1859
+ /**
1860
+ * Send an agent-signed email.
1861
+ *
1862
+ * @deprecated sendSignedEmail currently delegates to sendEmail. The previous
1863
+ * implementation called /api/v1/email/sign (HAI authority key) then POSTed
1864
+ * to send-signed, which rejects because the signer ID does not match the
1865
+ * authenticated agent. True agent-key local signing will be available when
1866
+ * the Rust SDK core (DevEx TASK_017) ships. Use sendEmail directly.
1867
+ *
1868
+ * @param options - Email options (to, subject, body, attachments, etc.)
1869
+ * @returns SendEmailResult with messageId and status.
1870
+ */
1871
+ async sendSignedEmail(options: SendEmailOptions): Promise<SendEmailResult> {
1872
+ // Deprecated: delegates to sendEmail until local agent-key signing
1873
+ // is available (DevEx TASK_017). Use sendEmail directly.
1874
+ return this.sendEmail(options);
1875
+ }
1876
+
1877
+ /**
1878
+ * Verify a JACS-signed email via the HAI API.
1879
+ *
1880
+ * The server extracts the `jacs-signature.json` attachment, validates
1881
+ * the cryptographic signature and content hashes, and returns a
1882
+ * detailed verification result.
1883
+ *
1884
+ * @param rawEmail - Raw RFC 5322 email as a Buffer or string.
1885
+ * @returns EmailVerificationResultV2 with field-level verification results.
1886
+ */
1887
+ async verifyEmail(rawEmail: Buffer | string): Promise<EmailVerificationResultV2> {
1888
+ const url = this.makeUrl('/api/v1/email/verify');
1889
+ const headers = this.buildAuthHeaders();
1890
+ headers['Content-Type'] = 'message/rfc822';
1891
+
1892
+ const body = typeof rawEmail === 'string' ? Buffer.from(rawEmail) : rawEmail;
1893
+
1894
+ const response = await this.fetchWithRetry(url, {
1895
+ method: 'POST',
1896
+ headers,
1897
+ body,
1898
+ });
1899
+
1900
+ if (!response.ok) {
1901
+ const text = await response.text();
1902
+ throw new HaiApiError(`Email verify failed: HTTP ${response.status}`, response.status, undefined, '', text);
1903
+ }
1904
+
1905
+ const data = await response.json() as Record<string, unknown>;
1906
+ return {
1907
+ valid: (data.valid as boolean) ?? false,
1908
+ jacsId: (data.jacs_id as string) ?? '',
1909
+ algorithm: (data.algorithm as string) ?? '',
1910
+ reputationTier: (data.reputation_tier as string) ?? '',
1911
+ dnsVerified: data.dns_verified as boolean | null | undefined,
1912
+ fieldResults: ((data.field_results as Array<Record<string, unknown>>) ?? []).map(fr => ({
1913
+ field: (fr.field as string) ?? '',
1914
+ status: (fr.status as FieldStatus) ?? 'unverifiable',
1915
+ originalHash: fr.original_hash as string | undefined,
1916
+ currentHash: fr.current_hash as string | undefined,
1917
+ originalValue: fr.original_value as string | undefined,
1918
+ currentValue: fr.current_value as string | undefined,
1919
+ })),
1920
+ chain: ((data.chain as Array<Record<string, unknown>>) ?? []).map(ce => ({
1921
+ signer: (ce.signer as string) ?? '',
1922
+ jacsId: (ce.jacs_id as string) ?? '',
1923
+ valid: (ce.valid as boolean) ?? false,
1924
+ forwarded: (ce.forwarded as boolean) ?? false,
1925
+ })),
1926
+ error: data.error as string | null | undefined,
1927
+ agentStatus: data.agent_status as string | null | undefined,
1928
+ benchmarksCompleted: (data.benchmarks_completed as string[]) ?? [],
1929
+ };
1930
+ }
1931
+
1932
+ /**
1933
+ * List email messages for this agent.
1934
+ *
1935
+ * @param options - Pagination and direction filter options
1936
+ * @returns Array of email messages
1937
+ */
1938
+ async listMessages(options?: ListMessagesOptions): Promise<EmailMessage[]> {
1939
+ const params = new URLSearchParams();
1940
+ if (options?.limit != null) params.set('limit', String(options.limit));
1941
+ if (options?.offset != null) params.set('offset', String(options.offset));
1942
+ if (options?.direction) params.set('direction', options.direction);
1943
+ if (options?.isRead != null) params.set('is_read', String(options.isRead));
1944
+ if (options?.folder) params.set('folder', options.folder);
1945
+ if (options?.label) params.set('label', options.label);
1946
+
1947
+ const qs = params.toString();
1948
+ const safeAgentId = this.encodePathSegment(this.haiAgentId);
1949
+ const url = this.makeUrl(`/api/agents/${safeAgentId}/email/messages${qs ? `?${qs}` : ''}`);
1950
+
1951
+ const response = await this.fetchWithRetry(url, {
1952
+ method: 'GET',
1953
+ headers: this.buildAuthHeaders(),
1954
+ });
1955
+
1956
+ const data = await response.json() as Record<string, unknown>;
1957
+ const messages = (data.messages as Array<Record<string, unknown>>) || [];
1958
+ return messages.map((m) => this.parseEmailMessage(m));
1959
+ }
1960
+
1961
+ /**
1962
+ * Mark an email message as read.
1963
+ *
1964
+ * @param messageId - The message ID to mark as read
1965
+ */
1966
+ async markRead(messageId: string): Promise<void> {
1967
+ const safeAgentId = this.encodePathSegment(this.haiAgentId);
1968
+ const safeMessageId = this.encodePathSegment(messageId);
1969
+ const url = this.makeUrl(`/api/agents/${safeAgentId}/email/messages/${safeMessageId}/read`);
1970
+ await this.fetchWithRetry(url, {
1971
+ method: 'POST',
1972
+ headers: this.buildAuthHeaders(),
1973
+ });
1974
+ }
1975
+
1976
+ /**
1977
+ * Get email rate limit and status info for this agent.
1978
+ *
1979
+ * @returns Email status with daily limits and usage
1980
+ */
1981
+ async getEmailStatus(): Promise<EmailStatus> {
1982
+ const safeAgentId = this.encodePathSegment(this.haiAgentId);
1983
+ const url = this.makeUrl(`/api/agents/${safeAgentId}/email/status`);
1984
+ const response = await this.fetchWithRetry(url, {
1985
+ method: 'GET',
1986
+ headers: this.buildAuthHeaders(),
1987
+ });
1988
+
1989
+ const data = await response.json() as Record<string, unknown>;
1990
+ const volumeRaw = data.volume as Record<string, unknown> | undefined;
1991
+ const deliveryRaw = data.delivery as Record<string, unknown> | undefined;
1992
+ const reputationRaw = data.reputation as Record<string, unknown> | undefined;
1993
+
1994
+ return {
1995
+ email: (data.email as string) || '',
1996
+ status: (data.status as string) || '',
1997
+ tier: (data.tier as string) || '',
1998
+ billingTier: (data.billing_tier as string) || '',
1999
+ messagesSent24h: (data.messages_sent_24h as number) || 0,
2000
+ dailyLimit: (data.daily_limit as number) || 0,
2001
+ dailyUsed: (data.daily_used as number) || 0,
2002
+ resetsAt: (data.resets_at as string) || '',
2003
+ messagesSentTotal: (data.messages_sent_total as number) || 0,
2004
+ externalEnabled: (data.external_enabled as boolean) || false,
2005
+ externalSendsToday: (data.external_sends_today as number) || 0,
2006
+ lastTierChange: (data.last_tier_change as string) || null,
2007
+ volume: volumeRaw ? {
2008
+ sentTotal: (volumeRaw.sent_total as number) || 0,
2009
+ receivedTotal: (volumeRaw.received_total as number) || 0,
2010
+ sent24h: (volumeRaw.sent_24h as number) || 0,
2011
+ } : null,
2012
+ delivery: deliveryRaw ? {
2013
+ bounceCount: (deliveryRaw.bounce_count as number) || 0,
2014
+ spamReportCount: (deliveryRaw.spam_report_count as number) || 0,
2015
+ deliveryRate: (deliveryRaw.delivery_rate as number) || 0,
2016
+ } : null,
2017
+ reputation: reputationRaw ? {
2018
+ score: (reputationRaw.score as number) || 0,
2019
+ tier: (reputationRaw.tier as string) || '',
2020
+ emailScore: (reputationRaw.email_score as number) || 0,
2021
+ haiScore: reputationRaw.hai_score != null ? (reputationRaw.hai_score as number) : null,
2022
+ } : null,
2023
+ };
2024
+ }
2025
+
2026
+ /**
2027
+ * Get a single email message by ID.
2028
+ *
2029
+ * @param messageId - The message ID to retrieve
2030
+ * @returns The email message
2031
+ */
2032
+ async getMessage(messageId: string): Promise<EmailMessage> {
2033
+ const safeAgentId = this.encodePathSegment(this.haiAgentId);
2034
+ const safeMessageId = this.encodePathSegment(messageId);
2035
+ const url = this.makeUrl(`/api/agents/${safeAgentId}/email/messages/${safeMessageId}`);
2036
+ const response = await this.fetchWithRetry(url, {
2037
+ method: 'GET',
2038
+ headers: this.buildAuthHeaders(),
2039
+ });
2040
+
2041
+ const m = await response.json() as Record<string, unknown>;
2042
+ return this.parseEmailMessage(m);
2043
+ }
2044
+
2045
+ /**
2046
+ * Delete an email message.
2047
+ *
2048
+ * @param messageId - The message ID to delete
2049
+ */
2050
+ async deleteMessage(messageId: string): Promise<void> {
2051
+ const safeAgentId = this.encodePathSegment(this.haiAgentId);
2052
+ const safeMessageId = this.encodePathSegment(messageId);
2053
+ const url = this.makeUrl(`/api/agents/${safeAgentId}/email/messages/${safeMessageId}`);
2054
+ await this.fetchWithRetry(url, {
2055
+ method: 'DELETE',
2056
+ headers: this.buildAuthHeaders(),
2057
+ });
2058
+ }
2059
+
2060
+ /**
2061
+ * Mark an email message as unread.
2062
+ *
2063
+ * @param messageId - The message ID to mark as unread
2064
+ */
2065
+ async markUnread(messageId: string): Promise<void> {
2066
+ const safeAgentId = this.encodePathSegment(this.haiAgentId);
2067
+ const safeMessageId = this.encodePathSegment(messageId);
2068
+ const url = this.makeUrl(`/api/agents/${safeAgentId}/email/messages/${safeMessageId}/unread`);
2069
+ await this.fetchWithRetry(url, {
2070
+ method: 'POST',
2071
+ headers: this.buildAuthHeaders(),
2072
+ });
2073
+ }
2074
+
2075
+ /**
2076
+ * Search email messages.
2077
+ *
2078
+ * @param options - Search query and pagination options
2079
+ * @returns Array of matching email messages
2080
+ */
2081
+ async searchMessages(options: SearchOptions): Promise<EmailMessage[]> {
2082
+ const params = new URLSearchParams();
2083
+ params.set('q', options.query);
2084
+ if (options.limit != null) params.set('limit', String(options.limit));
2085
+ if (options.offset != null) params.set('offset', String(options.offset));
2086
+ if (options.direction) params.set('direction', options.direction);
2087
+ if (options.fromAddress) params.set('from_address', options.fromAddress);
2088
+ if (options.toAddress) params.set('to_address', options.toAddress);
2089
+ if (options.isRead != null) params.set('is_read', String(options.isRead));
2090
+ if (options.jacsVerified != null) params.set('jacs_verified', String(options.jacsVerified));
2091
+ if (options.folder) params.set('folder', options.folder);
2092
+ if (options.label) params.set('label', options.label);
2093
+
2094
+ const safeAgentId = this.encodePathSegment(this.haiAgentId);
2095
+ const url = this.makeUrl(`/api/agents/${safeAgentId}/email/search?${params.toString()}`);
2096
+ const response = await this.fetchWithRetry(url, {
2097
+ method: 'GET',
2098
+ headers: this.buildAuthHeaders(),
2099
+ });
2100
+
2101
+ const data = await response.json() as Record<string, unknown>;
2102
+ const messages = (data.messages as Array<Record<string, unknown>>) || [];
2103
+ return messages.map((m) => this.parseEmailMessage(m));
2104
+ }
2105
+
2106
+ /**
2107
+ * Get the count of unread messages.
2108
+ *
2109
+ * @returns The number of unread messages
2110
+ */
2111
+ async getUnreadCount(): Promise<number> {
2112
+ const safeAgentId = this.encodePathSegment(this.haiAgentId);
2113
+ const url = this.makeUrl(`/api/agents/${safeAgentId}/email/unread-count`);
2114
+ const response = await this.fetchWithRetry(url, {
2115
+ method: 'GET',
2116
+ headers: this.buildAuthHeaders(),
2117
+ });
2118
+
2119
+ const data = await response.json() as Record<string, unknown>;
2120
+ return (data.count as number) || 0;
2121
+ }
2122
+
2123
+ /**
2124
+ * Reply to an email message.
2125
+ *
2126
+ * Convenience method that fetches the original message to get the sender
2127
+ * and subject, then sends a reply with proper threading.
2128
+ *
2129
+ * @param messageId - The message ID to reply to
2130
+ * @param body - Reply body text
2131
+ * @param subjectOverride - Optional subject override (defaults to "Re: <original subject>")
2132
+ * @returns Send result with message ID and status
2133
+ */
2134
+ async reply(messageId: string, body: string, subjectOverride?: string): Promise<SendEmailResult> {
2135
+ const original = await this.getMessage(messageId);
2136
+ const subject = subjectOverride ?? (original.subject?.startsWith('Re: ') ? original.subject : `Re: ${original.subject}`);
2137
+ return this.sendEmail({
2138
+ to: original.fromAddress,
2139
+ subject,
2140
+ body,
2141
+ inReplyTo: original.messageId ?? messageId,
2142
+ });
2143
+ }
2144
+
2145
+ /**
2146
+ * Forward an email message to another recipient.
2147
+ *
2148
+ * @param options - Forward options (messageId, to, optional comment)
2149
+ * @returns Send result with message ID and status
2150
+ */
2151
+ async forward(options: ForwardOptions): Promise<SendEmailResult> {
2152
+ const safeAgentId = this.encodePathSegment(this.haiAgentId);
2153
+ const url = this.makeUrl(`/api/agents/${safeAgentId}/email/forward`);
2154
+
2155
+ const payload: Record<string, unknown> = {
2156
+ message_id: options.messageId,
2157
+ to: options.to,
2158
+ };
2159
+ if (options.comment) payload.comment = options.comment;
2160
+
2161
+ const response = await this.fetchWithRetry(url, {
2162
+ method: 'POST',
2163
+ headers: this.buildAuthHeaders(),
2164
+ body: JSON.stringify(payload),
2165
+ });
2166
+
2167
+ const data = await response.json() as Record<string, unknown>;
2168
+ return {
2169
+ messageId: (data.message_id as string) || '',
2170
+ status: (data.status as string) || '',
2171
+ };
2172
+ }
2173
+
2174
+ /**
2175
+ * Archive an email message.
2176
+ *
2177
+ * @param messageId - The message ID to archive
2178
+ */
2179
+ async archive(messageId: string): Promise<void> {
2180
+ const safeAgentId = this.encodePathSegment(this.haiAgentId);
2181
+ const safeMessageId = this.encodePathSegment(messageId);
2182
+ const url = this.makeUrl(`/api/agents/${safeAgentId}/email/messages/${safeMessageId}/archive`);
2183
+ await this.fetchWithRetry(url, {
2184
+ method: 'POST',
2185
+ headers: this.buildAuthHeaders(),
2186
+ });
2187
+ }
2188
+
2189
+ /**
2190
+ * Unarchive (restore) an email message.
2191
+ *
2192
+ * @param messageId - The message ID to unarchive
2193
+ */
2194
+ async unarchive(messageId: string): Promise<void> {
2195
+ const safeAgentId = this.encodePathSegment(this.haiAgentId);
2196
+ const safeMessageId = this.encodePathSegment(messageId);
2197
+ const url = this.makeUrl(`/api/agents/${safeAgentId}/email/messages/${safeMessageId}/unarchive`);
2198
+ await this.fetchWithRetry(url, {
2199
+ method: 'POST',
2200
+ headers: this.buildAuthHeaders(),
2201
+ });
2202
+ }
2203
+
2204
+ /**
2205
+ * List contacts derived from email message history.
2206
+ *
2207
+ * @returns Array of Contact objects
2208
+ */
2209
+ async getContacts(): Promise<Contact[]> {
2210
+ const safeAgentId = this.encodePathSegment(this.haiAgentId);
2211
+ const url = this.makeUrl(`/api/agents/${safeAgentId}/email/contacts`);
2212
+ const response = await this.fetchWithRetry(url, {
2213
+ method: 'GET',
2214
+ headers: this.buildAuthHeaders(),
2215
+ });
2216
+
2217
+ const data = await response.json() as Record<string, unknown>;
2218
+ const items = Array.isArray(data) ? data : (data.contacts as Array<Record<string, unknown>>) || [];
2219
+ return items.map((c: Record<string, unknown>) => ({
2220
+ email: (c.email as string) || '',
2221
+ displayName: (c.display_name as string) || undefined,
2222
+ lastContact: (c.last_contact as string) || '',
2223
+ jacsVerified: (c.jacs_verified as boolean) ?? false,
2224
+ reputationTier: (c.reputation_tier as string) || undefined,
2225
+ }));
2226
+ }
2227
+
2228
+ // ---------------------------------------------------------------------------
2229
+ // fetchRemoteKey()
2230
+ // ---------------------------------------------------------------------------
2231
+
2232
+ /**
2233
+ * Look up another agent's public key from the HAI key directory.
2234
+ *
2235
+ * @param jacsId - The JACS ID of the agent to look up
2236
+ * @param version - Key version (default: "latest")
2237
+ * @returns Public key information
2238
+ */
2239
+ async fetchRemoteKey(jacsId: string, version: string = 'latest'): Promise<PublicKeyInfo> {
2240
+ const cacheKey = `remote:${jacsId}:${version}`;
2241
+ const cached = this.getCachedKey(cacheKey);
2242
+ if (cached) return cached;
2243
+
2244
+ const safeJacsId = this.encodePathSegment(jacsId);
2245
+ const safeVersion = this.encodePathSegment(version);
2246
+ const url = this.makeUrl(`/jacs/v1/agents/${safeJacsId}/keys/${safeVersion}`);
2247
+ const response = await this.fetchWithRetry(url, {
2248
+ method: 'GET',
2249
+ headers: { 'Content-Type': 'application/json' },
2250
+ });
2251
+
2252
+ const warning = response.headers.get('Warning');
2253
+ if (warning) {
2254
+ console.warn(`HAI key service: ${warning}`);
2255
+ }
2256
+
2257
+ const data = await response.json() as Record<string, unknown>;
2258
+ const result: PublicKeyInfo = {
2259
+ jacsId: (data.jacs_id as string) || '',
2260
+ version: (data.version as string) || '',
2261
+ publicKey: (data.public_key as string) || '',
2262
+ publicKeyRawB64: (data.public_key_raw_b64 as string) || '',
2263
+ algorithm: (data.algorithm as string) || '',
2264
+ publicKeyHash: (data.public_key_hash as string) || '',
2265
+ status: (data.status as string) || '',
2266
+ dnsVerified: (data.dns_verified as boolean) ?? false,
2267
+ createdAt: (data.created_at as string) || '',
2268
+ };
2269
+ this.setCachedKey(cacheKey, result);
2270
+ return result;
2271
+ }
2272
+
2273
+ // ---------------------------------------------------------------------------
2274
+ // fetchKeyByHash()
2275
+ // ---------------------------------------------------------------------------
2276
+
2277
+ /**
2278
+ * Look up an agent's public key by its SHA-256 hash.
2279
+ *
2280
+ * @param publicKeyHash - Hash in `sha256:<hex>` format
2281
+ * @returns Public key information
2282
+ */
2283
+ async fetchKeyByHash(publicKeyHash: string): Promise<PublicKeyInfo> {
2284
+ const cacheKey = `hash:${publicKeyHash}`;
2285
+ const cached = this.getCachedKey(cacheKey);
2286
+ if (cached) return cached;
2287
+
2288
+ const safeHash = this.encodePathSegment(publicKeyHash);
2289
+ const url = this.makeUrl(`/jacs/v1/keys/by-hash/${safeHash}`);
2290
+ const response = await this.fetchWithRetry(url, {
2291
+ method: 'GET',
2292
+ headers: { 'Content-Type': 'application/json' },
2293
+ });
2294
+
2295
+ const data = await response.json() as Record<string, unknown>;
2296
+ const result: PublicKeyInfo = {
2297
+ jacsId: (data.jacs_id as string) || '',
2298
+ version: (data.version as string) || '',
2299
+ publicKey: (data.public_key as string) || '',
2300
+ publicKeyRawB64: (data.public_key_raw_b64 as string) || '',
2301
+ algorithm: (data.algorithm as string) || '',
2302
+ publicKeyHash: (data.public_key_hash as string) || '',
2303
+ status: (data.status as string) || '',
2304
+ dnsVerified: (data.dns_verified as boolean) ?? false,
2305
+ createdAt: (data.created_at as string) || '',
2306
+ };
2307
+ this.setCachedKey(cacheKey, result);
2308
+ return result;
2309
+ }
2310
+
2311
+ // ---------------------------------------------------------------------------
2312
+ // fetchKeyByEmail()
2313
+ // ---------------------------------------------------------------------------
2314
+
2315
+ /**
2316
+ * Look up an agent's public key by their @hai.ai email address.
2317
+ *
2318
+ * @param email - The agent's email address (e.g., "alice@hai.ai")
2319
+ * @returns Public key information
2320
+ */
2321
+ async fetchKeyByEmail(email: string): Promise<PublicKeyInfo> {
2322
+ const cacheKey = `email:${email}`;
2323
+ const cached = this.getCachedKey(cacheKey);
2324
+ if (cached) return cached;
2325
+
2326
+ const safeEmail = this.encodePathSegment(email);
2327
+ const url = this.makeUrl(`/api/agents/keys/${safeEmail}`);
2328
+ const response = await this.fetchWithRetry(url, {
2329
+ method: 'GET',
2330
+ headers: { 'Content-Type': 'application/json' },
2331
+ });
2332
+
2333
+ const data = await response.json() as Record<string, unknown>;
2334
+ const result: PublicKeyInfo = {
2335
+ jacsId: (data.jacs_id as string) || '',
2336
+ version: (data.version as string) || '',
2337
+ publicKey: (data.public_key as string) || '',
2338
+ publicKeyRawB64: (data.public_key_raw_b64 as string) || '',
2339
+ algorithm: (data.algorithm as string) || '',
2340
+ publicKeyHash: (data.public_key_hash as string) || '',
2341
+ status: (data.status as string) || '',
2342
+ dnsVerified: (data.dns_verified as boolean) ?? false,
2343
+ createdAt: (data.created_at as string) || '',
2344
+ };
2345
+ this.setCachedKey(cacheKey, result);
2346
+ return result;
2347
+ }
2348
+
2349
+ // ---------------------------------------------------------------------------
2350
+ // fetchKeyByDomain()
2351
+ // ---------------------------------------------------------------------------
2352
+
2353
+ /**
2354
+ * Look up the latest DNS-verified agent key for a domain.
2355
+ *
2356
+ * @param domain - DNS domain (e.g., "example.com")
2357
+ * @returns Public key information
2358
+ */
2359
+ async fetchKeyByDomain(domain: string): Promise<PublicKeyInfo> {
2360
+ const cacheKey = `domain:${domain}`;
2361
+ const cached = this.getCachedKey(cacheKey);
2362
+ if (cached) return cached;
2363
+
2364
+ const safeDomain = this.encodePathSegment(domain);
2365
+ const url = this.makeUrl(`/jacs/v1/agents/by-domain/${safeDomain}`);
2366
+ const response = await this.fetchWithRetry(url, {
2367
+ method: 'GET',
2368
+ headers: { 'Content-Type': 'application/json' },
2369
+ });
2370
+
2371
+ const data = await response.json() as Record<string, unknown>;
2372
+ const result: PublicKeyInfo = {
2373
+ jacsId: (data.jacs_id as string) || '',
2374
+ version: (data.version as string) || '',
2375
+ publicKey: (data.public_key as string) || '',
2376
+ publicKeyRawB64: (data.public_key_raw_b64 as string) || '',
2377
+ algorithm: (data.algorithm as string) || '',
2378
+ publicKeyHash: (data.public_key_hash as string) || '',
2379
+ status: (data.status as string) || '',
2380
+ dnsVerified: (data.dns_verified as boolean) ?? false,
2381
+ createdAt: (data.created_at as string) || '',
2382
+ };
2383
+ this.setCachedKey(cacheKey, result);
2384
+ return result;
2385
+ }
2386
+
2387
+ // ---------------------------------------------------------------------------
2388
+ // fetchAllKeys()
2389
+ // ---------------------------------------------------------------------------
2390
+
2391
+ /**
2392
+ * Fetch all key versions for an agent, ordered by creation date descending.
2393
+ *
2394
+ * @param jacsId - The JACS ID of the agent to look up
2395
+ * @returns Object with jacs_id, keys array, and total count
2396
+ */
2397
+ async fetchAllKeys(jacsId: string): Promise<{ jacsId: string; keys: PublicKeyInfo[]; total: number }> {
2398
+ const safeJacsId = this.encodePathSegment(jacsId);
2399
+ const url = this.makeUrl(`/jacs/v1/agents/${safeJacsId}/keys`);
2400
+ const response = await this.fetchWithRetry(url, {
2401
+ method: 'GET',
2402
+ headers: { 'Content-Type': 'application/json' },
2403
+ });
2404
+
2405
+ const data = await response.json() as Record<string, unknown>;
2406
+ const rawKeys = (data.keys as Array<Record<string, unknown>>) || [];
2407
+ const keys = rawKeys.map((k) => ({
2408
+ jacsId: (k.jacs_id as string) || '',
2409
+ version: (k.version as string) || '',
2410
+ publicKey: (k.public_key as string) || '',
2411
+ publicKeyRawB64: (k.public_key_raw_b64 as string) || '',
2412
+ algorithm: (k.algorithm as string) || '',
2413
+ publicKeyHash: (k.public_key_hash as string) || '',
2414
+ status: (k.status as string) || '',
2415
+ dnsVerified: (k.dns_verified as boolean) ?? false,
2416
+ createdAt: (k.created_at as string) || '',
2417
+ }));
2418
+
2419
+ return {
2420
+ jacsId: (data.jacs_id as string) || '',
2421
+ keys,
2422
+ total: (data.total as number) || 0,
2423
+ };
2424
+ }
2425
+
2426
+ // ---------------------------------------------------------------------------
2427
+ // verifyAgent()
2428
+ // ---------------------------------------------------------------------------
2429
+
2430
+ /**
2431
+ * Verify another agent's JACS document.
2432
+ *
2433
+ * Performs three levels of verification:
2434
+ * 1. Local Ed25519 signature verification
2435
+ * 2. DNS verification (via server attestation)
2436
+ * 3. HAI registration attestation
2437
+ *
2438
+ * @param agentDocument - JACS agent document (object or JSON string)
2439
+ * @returns Verification result with signature validity and trust level
2440
+ */
2441
+ async verifyAgent(agentDocument: Record<string, unknown> | string): Promise<VerificationResult> {
2442
+ const doc = typeof agentDocument === 'string'
2443
+ ? JSON.parse(agentDocument) as Record<string, unknown>
2444
+ : agentDocument;
2445
+
2446
+ const result: VerificationResult = {
2447
+ signatureValid: false,
2448
+ dnsVerified: false,
2449
+ haiRegistered: false,
2450
+ badgeLevel: 'none',
2451
+ jacsId: (doc.jacsId as string) || '',
2452
+ version: (doc.jacsVersion as string) || '',
2453
+ errors: [],
2454
+ };
2455
+
2456
+ // Level 1: JACS signature verification
2457
+ try {
2458
+ const publicKeyPem = doc.jacsPublicKey as string | undefined;
2459
+ if (!publicKeyPem) {
2460
+ result.errors.push('No jacsPublicKey in document');
2461
+ return result;
2462
+ }
2463
+
2464
+ const sig = doc.jacsSignature as Record<string, unknown> | undefined;
2465
+ const signature = sig?.signature as string | undefined;
2466
+ if (!signature) {
2467
+ result.errors.push('No signature in jacsSignature');
2468
+ return result;
2469
+ }
2470
+
2471
+ // Remove signature, canonicalize, verify via JACS
2472
+ const verifyDoc = JSON.parse(JSON.stringify(doc)) as Record<string, unknown>;
2473
+ delete (verifyDoc.jacsSignature as Record<string, unknown>).signature;
2474
+ const canonical = canonicalJson(verifyDoc);
2475
+
2476
+ result.signatureValid = this.agent.verifyStringSync(
2477
+ canonical,
2478
+ signature,
2479
+ Buffer.from(publicKeyPem, 'utf-8'),
2480
+ 'pem',
2481
+ );
2482
+ } catch (e) {
2483
+ result.errors.push(`Signature verification failed: ${(e as Error).message}`);
2484
+ }
2485
+
2486
+ // Level 3: Server attestation
2487
+ try {
2488
+ const safeDocJacsId = this.encodePathSegment(String(doc.jacsId || ''));
2489
+ const attestUrl = this.makeUrl(`/api/v1/agents/${safeDocJacsId}/verify`);
2490
+ const resp = await this.fetchWithRetry(attestUrl, {
2491
+ method: 'GET',
2492
+ headers: { 'Content-Type': 'application/json' },
2493
+ });
2494
+ const data = await resp.json() as Record<string, unknown>;
2495
+ result.haiRegistered = (data.registered as boolean) ?? false;
2496
+ result.dnsVerified = (data.dns_verified as boolean) ?? false;
2497
+ result.badgeLevel = (data.badge_level as VerificationResult['badgeLevel']) || 'none';
2498
+ } catch (e) {
2499
+ result.errors.push(`Server attestation check failed: ${(e as Error).message}`);
2500
+ }
2501
+
2502
+ return result;
2503
+ }
2504
+ }