@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.
- package/README.md +127 -0
- package/bin/haiai.cjs +70 -0
- package/dist/cjs/a2a.js +352 -0
- package/dist/cjs/a2a.js.map +1 -0
- package/dist/cjs/agent.js +236 -0
- package/dist/cjs/agent.js.map +1 -0
- package/dist/cjs/client.js +2168 -0
- package/dist/cjs/client.js.map +1 -0
- package/dist/cjs/config.js +176 -0
- package/dist/cjs/config.js.map +1 -0
- package/dist/cjs/errors.js +102 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/hash.js +52 -0
- package/dist/cjs/hash.js.map +1 -0
- package/dist/cjs/index.js +84 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/integrations.js +193 -0
- package/dist/cjs/integrations.js.map +1 -0
- package/dist/cjs/jacs.js +66 -0
- package/dist/cjs/jacs.js.map +1 -0
- package/dist/cjs/mime.js +100 -0
- package/dist/cjs/mime.js.map +1 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/signing.js +190 -0
- package/dist/cjs/signing.js.map +1 -0
- package/dist/cjs/sse.js +76 -0
- package/dist/cjs/sse.js.map +1 -0
- package/dist/cjs/types.js +6 -0
- package/dist/cjs/types.js.map +1 -0
- package/dist/cjs/verify.js +76 -0
- package/dist/cjs/verify.js.map +1 -0
- package/dist/cjs/ws.js +206 -0
- package/dist/cjs/ws.js.map +1 -0
- package/dist/esm/a2a.js +305 -0
- package/dist/esm/a2a.js.map +1 -0
- package/dist/esm/agent.js +231 -0
- package/dist/esm/agent.js.map +1 -0
- package/dist/esm/client.js +2131 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/config.js +171 -0
- package/dist/esm/config.js.map +1 -0
- package/dist/esm/errors.js +88 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/hash.js +49 -0
- package/dist/esm/hash.js.map +1 -0
- package/dist/esm/index.js +27 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/integrations.js +147 -0
- package/dist/esm/integrations.js.map +1 -0
- package/dist/esm/jacs.js +61 -0
- package/dist/esm/jacs.js.map +1 -0
- package/dist/esm/mime.js +97 -0
- package/dist/esm/mime.js.map +1 -0
- package/dist/esm/signing.js +183 -0
- package/dist/esm/signing.js.map +1 -0
- package/dist/esm/sse.js +73 -0
- package/dist/esm/sse.js.map +1 -0
- package/dist/esm/types.js +5 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/verify.js +72 -0
- package/dist/esm/verify.js.map +1 -0
- package/dist/esm/ws.js +168 -0
- package/dist/esm/ws.js.map +1 -0
- package/dist/types/a2a.d.ts +52 -0
- package/dist/types/a2a.d.ts.map +1 -0
- package/dist/types/agent.d.ts +202 -0
- package/dist/types/agent.d.ts.map +1 -0
- package/dist/types/client.d.ts +486 -0
- package/dist/types/client.d.ts.map +1 -0
- package/dist/types/config.d.ts +31 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/errors.d.ts +50 -0
- package/dist/types/errors.d.ts.map +1 -0
- package/dist/types/hash.d.ts +32 -0
- package/dist/types/hash.d.ts.map +1 -0
- package/dist/types/index.d.ts +22 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/integrations.d.ts +25 -0
- package/dist/types/integrations.d.ts.map +1 -0
- package/dist/types/jacs.d.ts +26 -0
- package/dist/types/jacs.d.ts.map +1 -0
- package/dist/types/mime.d.ts +39 -0
- package/dist/types/mime.d.ts.map +1 -0
- package/dist/types/signing.d.ts +58 -0
- package/dist/types/signing.d.ts.map +1 -0
- package/dist/types/sse.d.ts +8 -0
- package/dist/types/sse.d.ts.map +1 -0
- package/dist/types/types.d.ts +652 -0
- package/dist/types/types.d.ts.map +1 -0
- package/dist/types/verify.d.ts +20 -0
- package/dist/types/verify.d.ts.map +1 -0
- package/dist/types/ws.d.ts +30 -0
- package/dist/types/ws.d.ts.map +1 -0
- package/examples/a2a_quickstart.ts +138 -0
- package/examples/hai_quickstart.ts +111 -0
- package/examples/mcp_quickstart.ts +53 -0
- package/npm/@haiai/cli-darwin-arm64/package.json +16 -0
- package/npm/@haiai/cli-darwin-x64/package.json +16 -0
- package/npm/@haiai/cli-linux-arm64/package.json +16 -0
- package/npm/@haiai/cli-linux-x64/package.json +16 -0
- package/npm/@haiai/cli-win32-x64/package.json +16 -0
- package/package.json +68 -0
- package/scripts/build-platform-packages.js +132 -0
- package/scripts/smoke-package.cjs +114 -0
- package/scripts/write-cjs-package.cjs +9 -0
- package/src/a2a.ts +463 -0
- package/src/agent.ts +302 -0
- package/src/client.ts +2504 -0
- package/src/config.ts +204 -0
- package/src/errors.ts +99 -0
- package/src/hash.ts +66 -0
- package/src/index.ts +163 -0
- package/src/integrations.ts +210 -0
- package/src/jacs.ts +86 -0
- package/src/mime.ts +131 -0
- package/src/signing.ts +233 -0
- package/src/sse.ts +86 -0
- package/src/types.ts +773 -0
- package/src/verify.ts +89 -0
- package/src/ws.ts +198 -0
- package/tests/_debug_jacs.cjs +29 -0
- package/tests/a2a-contract.test.ts +271 -0
- package/tests/a2a-fixtures.test.ts +73 -0
- package/tests/a2a.test.ts +379 -0
- package/tests/binary.test.ts +90 -0
- package/tests/client-api-methods.test.ts +176 -0
- package/tests/client-path-escaping.test.ts +80 -0
- package/tests/client-register.test.ts +61 -0
- package/tests/config.test.ts +281 -0
- package/tests/contract.test.ts +360 -0
- package/tests/cross-lang-contract.test.ts +67 -0
- package/tests/email-conformance.test.ts +289 -0
- package/tests/email-integration.test.ts +217 -0
- package/tests/email.test.ts +767 -0
- package/tests/errors.test.ts +167 -0
- package/tests/init-contract.test.ts +129 -0
- package/tests/integrations.test.ts +132 -0
- package/tests/jacs-passthrough.test.ts +125 -0
- package/tests/key-cache.test.ts +201 -0
- package/tests/key-integration.test.ts +119 -0
- package/tests/key-lookups.test.ts +187 -0
- package/tests/key-rotation.test.ts +362 -0
- package/tests/mime.test.ts +127 -0
- package/tests/security.test.ts +109 -0
- package/tests/setup.ts +60 -0
- package/tests/signing.test.ts +142 -0
- package/tests/sse.test.ts +125 -0
- package/tests/types.test.ts +294 -0
- package/tests/verify-link.test.ts +81 -0
- package/tests/ws.test.ts +213 -0
- package/tsconfig.cjs.json +11 -0
- package/tsconfig.json +22 -0
- 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
|
+
}
|