@iicp/web-node 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +180 -0
- package/README.md +68 -0
- package/dist/browserNodeProvider.d.ts +73 -0
- package/dist/browserNodeProvider.js +455 -0
- package/dist/cxConfidentiality.d.ts +27 -0
- package/dist/cxConfidentiality.js +117 -0
- package/dist/iicpConsumer.d.ts +104 -0
- package/dist/iicpConsumer.js +201 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +11 -0
- package/dist/webllmRuntime.d.ts +140 -0
- package/dist/webllmRuntime.js +259 -0
- package/package.json +60 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// Browser node provider glue — issue #452 (the "serve into the mesh" half
|
|
4
|
+
// of the hybrid browser node #449).
|
|
5
|
+
//
|
|
6
|
+
// Pairs the WebLLM runtime (webllmRuntime.ts, #451) with the HTTP long-poll
|
|
7
|
+
// relay transport (#450, iicp-client >= 0.7.56 relay nodes):
|
|
8
|
+
//
|
|
9
|
+
// 1. Register with the directory (CORS is open on /api/v1/*) advertising
|
|
10
|
+
// endpoint = {relay}/v1/relay-for/{node_id} (transport_method=turn_relay)
|
|
11
|
+
// 2. Bind to the relay: POST /v1/relay/bind → bearer session token
|
|
12
|
+
// 3. Poll loop: GET /v1/relay/pull (long-poll) → run WebLLM chat() →
|
|
13
|
+
// POST /v1/relay/result (OpenAI-shape choices, same as SDK nodes)
|
|
14
|
+
// 4. Heartbeat the directory every 30 s; unbind + deregister on stop.
|
|
15
|
+
//
|
|
16
|
+
// Wire contracts mirror the Python SDK reference (node.py register()/
|
|
17
|
+
// _heartbeat payloads, client.py chat_async() result parsing) so PUBLISHED
|
|
18
|
+
// consumers route to a browser worker with zero client changes.
|
|
19
|
+
import { maskTunnelUrl } from "./iicpConsumer.js";
|
|
20
|
+
import { createCxKeyPair, decryptPayload } from "./cxConfidentiality.js";
|
|
21
|
+
const CHAT_INTENT = "urn:iicp:intent:llm:chat:v1";
|
|
22
|
+
export const BROWSER_NODE_SDK_VERSION = "0.7.71-browser";
|
|
23
|
+
/**
|
|
24
|
+
* Coarse region autodetect from the browser's timezone (no network, no
|
|
25
|
+
* geolocation permission). Matches the mesh's region convention
|
|
26
|
+
* (eu-central / us-east / …) as a SCORING HINT — deliberately coarse;
|
|
27
|
+
* operators can override via config. "(Browser)" is shown separately on
|
|
28
|
+
* the nodes page via sdk_language.
|
|
29
|
+
*/
|
|
30
|
+
export function detectRegion() {
|
|
31
|
+
try {
|
|
32
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
|
|
33
|
+
const continent = tz.split("/")[0];
|
|
34
|
+
const offset = -new Date().getTimezoneOffset() / 60; // hours east of UTC
|
|
35
|
+
switch (continent) {
|
|
36
|
+
case "Europe":
|
|
37
|
+
return offset <= 0.5 ? "eu-west" : "eu-central";
|
|
38
|
+
case "America":
|
|
39
|
+
if (offset <= -7)
|
|
40
|
+
return "us-west";
|
|
41
|
+
if (offset <= -5.5)
|
|
42
|
+
return "us-central";
|
|
43
|
+
if (offset <= -3.5)
|
|
44
|
+
return "us-east";
|
|
45
|
+
return "sa-east";
|
|
46
|
+
case "Asia":
|
|
47
|
+
if (offset >= 8.5)
|
|
48
|
+
return "ap-northeast";
|
|
49
|
+
if (offset >= 6.5)
|
|
50
|
+
return "ap-east";
|
|
51
|
+
return "ap-south";
|
|
52
|
+
case "Australia":
|
|
53
|
+
case "Pacific":
|
|
54
|
+
return "ap-southeast";
|
|
55
|
+
case "Africa":
|
|
56
|
+
return "af-central";
|
|
57
|
+
default:
|
|
58
|
+
return "unknown";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return "unknown";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const HEARTBEAT_MS = 30_000;
|
|
66
|
+
const POLL_ERROR_BACKOFF_MS = 2_000;
|
|
67
|
+
/**
|
|
68
|
+
* Auto-discover a browser-usable relay from the live directory (WQ-087 — the
|
|
69
|
+
* web client's "automagical" rung; tabs can't spawn tunnels, so relays carry
|
|
70
|
+
* them). Client-side filtering remains a hardening guard (not a dependency):
|
|
71
|
+
* the directory filter is server-side and authoritative when enabled, and query
|
|
72
|
+
* width is kept at 50 to avoid default-window clipping of relays.
|
|
73
|
+
* Browser-usable = https endpoint (or loopback
|
|
74
|
+
* http for local testing — loopback is a trustworthy origin).
|
|
75
|
+
*
|
|
76
|
+
* Red-team F3: a malicious relay sees & can inject the tasks a browser serves
|
|
77
|
+
* through it. Full trust needs relay attestation; as an interim mitigation we
|
|
78
|
+
* (a) drop relays below a hard floor (actively demoted / bad actors), (b)
|
|
79
|
+
* PREFER the highest-reputation, non-probationary relay, and (c) return the
|
|
80
|
+
* relay's node_id + trust state so the UI shows exactly what's being trusted.
|
|
81
|
+
*
|
|
82
|
+
* We do NOT hard-block probation: a brand-new relay is always on probation, so
|
|
83
|
+
* blocking it would mean no relay can ever bootstrap (it can't earn reputation
|
|
84
|
+
* without being used). Probationary relays sort LAST and are clearly labelled;
|
|
85
|
+
* the operator consents by clicking "Start serving".
|
|
86
|
+
*/
|
|
87
|
+
const MIN_RELAY_REPUTATION = 0.1; // hard floor — drop actively-demoted nodes only
|
|
88
|
+
export async function discoverRelay(directoryUrl = "https://iicp.network/api") {
|
|
89
|
+
try {
|
|
90
|
+
const base = directoryUrl.replace(/\/$/, "");
|
|
91
|
+
const resp = await fetch(`${base}/v1/discover?intent=${CHAT_INTENT}&relay_capable=true&limit=50`);
|
|
92
|
+
if (!resp.ok)
|
|
93
|
+
return null;
|
|
94
|
+
const data = await resp.json();
|
|
95
|
+
const nodes = data?.nodes ?? [];
|
|
96
|
+
const isLoopback = (ep) => /^http:\/\/(localhost|127\.0\.0\.1)[:/]/.test(ep);
|
|
97
|
+
const usable = nodes
|
|
98
|
+
.filter((n) => n.relay_capable === true &&
|
|
99
|
+
typeof n.endpoint === "string" &&
|
|
100
|
+
(n.browser_usable === true || n.endpoint.startsWith("https://") || isLoopback(n.endpoint)))
|
|
101
|
+
.map((n) => ({
|
|
102
|
+
endpoint: n.endpoint.replace(/\/$/, ""),
|
|
103
|
+
nodeId: n.node_id ?? "relay",
|
|
104
|
+
reputation: typeof n.reputation_score === "number" ? n.reputation_score : 0,
|
|
105
|
+
probation: n.probation === true,
|
|
106
|
+
loopback: isLoopback(n.endpoint),
|
|
107
|
+
}))
|
|
108
|
+
// F3: drop only below-the-hard-floor (demoted) relays; keep loopback
|
|
109
|
+
// (local operator testing, trusted by definition).
|
|
110
|
+
.filter((r) => r.loopback || r.reputation >= MIN_RELAY_REPUTATION)
|
|
111
|
+
// Prefer loopback (local) → non-probationary → higher reputation.
|
|
112
|
+
.sort((a, b) => (b.loopback ? 1 : 0) - (a.loopback ? 1 : 0) ||
|
|
113
|
+
(a.probation ? 1 : 0) - (b.probation ? 1 : 0) ||
|
|
114
|
+
b.reputation - a.reputation);
|
|
115
|
+
const top = usable[0];
|
|
116
|
+
return top
|
|
117
|
+
? { endpoint: top.endpoint, nodeId: top.nodeId, reputation: top.reputation, probation: top.probation }
|
|
118
|
+
: null;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export class BrowserProviderError extends Error {
|
|
125
|
+
stage;
|
|
126
|
+
constructor(message, stage) {
|
|
127
|
+
super(message);
|
|
128
|
+
this.stage = stage;
|
|
129
|
+
this.name = "BrowserProviderError";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export class BrowserNodeProvider {
|
|
133
|
+
runtime;
|
|
134
|
+
cfg;
|
|
135
|
+
nodeId;
|
|
136
|
+
_state = "stopped";
|
|
137
|
+
_nodeToken = "";
|
|
138
|
+
_sessionToken = "";
|
|
139
|
+
_tasksServed = 0;
|
|
140
|
+
// Success/fail since the last heartbeat (reset each beat — mirrors the SDK).
|
|
141
|
+
_okSinceBeat = 0;
|
|
142
|
+
_failSinceBeat = 0;
|
|
143
|
+
_latencyMsSinceBeat = 0;
|
|
144
|
+
_cx = createCxKeyPair();
|
|
145
|
+
_heartbeatTimer = null;
|
|
146
|
+
_stopRequested = false;
|
|
147
|
+
/** True when the directory accepted the registration (mesh-discoverable). */
|
|
148
|
+
directoryListed = false;
|
|
149
|
+
constructor(runtime, cfg) {
|
|
150
|
+
this.runtime = runtime;
|
|
151
|
+
this.cfg = cfg;
|
|
152
|
+
this.nodeId = `browser-${crypto.randomUUID().slice(0, 8)}`;
|
|
153
|
+
}
|
|
154
|
+
get state() {
|
|
155
|
+
return this._state;
|
|
156
|
+
}
|
|
157
|
+
get tasksServed() {
|
|
158
|
+
return this._tasksServed;
|
|
159
|
+
}
|
|
160
|
+
get directoryBase() {
|
|
161
|
+
return (this.cfg.directoryUrl ?? "https://iicp.network/api").replace(/\/$/, "");
|
|
162
|
+
}
|
|
163
|
+
get relayBase() {
|
|
164
|
+
return this.cfg.relayUrl.replace(/\/$/, "");
|
|
165
|
+
}
|
|
166
|
+
log(line) {
|
|
167
|
+
this.cfg.onLog?.(line);
|
|
168
|
+
}
|
|
169
|
+
setState(s) {
|
|
170
|
+
this._state = s;
|
|
171
|
+
this.cfg.onStateChange?.(s);
|
|
172
|
+
}
|
|
173
|
+
/** Register → bind → start serving. Throws BrowserProviderError on failure. */
|
|
174
|
+
async start() {
|
|
175
|
+
if (this._state === "serving" || this._state === "starting")
|
|
176
|
+
return;
|
|
177
|
+
this._stopRequested = false;
|
|
178
|
+
this.setState("starting");
|
|
179
|
+
// 1. Bind to the relay FIRST — if no relay is reachable there is no point
|
|
180
|
+
// holding a directory registration that consumers can't route to.
|
|
181
|
+
let bindResp;
|
|
182
|
+
try {
|
|
183
|
+
bindResp = await fetch(`${this.relayBase}/v1/relay/bind`, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { "Content-Type": "application/json" },
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
worker_id: this.nodeId,
|
|
188
|
+
intent: CHAT_INTENT,
|
|
189
|
+
models: [this.cfg.model],
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
this.setState("error");
|
|
195
|
+
throw new BrowserProviderError(`relay unreachable at ${maskTunnelUrl(this.relayBase)}: ${err instanceof Error ? err.message : String(err)}`, "bind");
|
|
196
|
+
}
|
|
197
|
+
if (!bindResp.ok) {
|
|
198
|
+
this.setState("error");
|
|
199
|
+
const detail = await bindResp.text().catch(() => "");
|
|
200
|
+
throw new BrowserProviderError(`relay bind failed: HTTP ${bindResp.status} ${detail.slice(0, 200)}`, "bind");
|
|
201
|
+
}
|
|
202
|
+
const bind = await bindResp.json();
|
|
203
|
+
this._sessionToken = bind.session_token;
|
|
204
|
+
this.log(`relay bound — worker ${this.nodeId}`);
|
|
205
|
+
// 2. Register with the directory, advertising the path-scoped relay endpoint.
|
|
206
|
+
const endpoint = `${this.relayBase}/v1/relay-for/${this.nodeId}`;
|
|
207
|
+
try {
|
|
208
|
+
const regResp = await fetch(`${this.directoryBase}/v1/register`, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: { "Content-Type": "application/json" },
|
|
211
|
+
body: JSON.stringify({
|
|
212
|
+
node_id: this.nodeId,
|
|
213
|
+
endpoint,
|
|
214
|
+
region: this.cfg.region ?? detectRegion(),
|
|
215
|
+
capabilities: [
|
|
216
|
+
{
|
|
217
|
+
intent: CHAT_INTENT,
|
|
218
|
+
models: [this.cfg.model],
|
|
219
|
+
max_tokens: 1024,
|
|
220
|
+
input_modalities: ["text"],
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
limits: { max_concurrent: 1, tokens_per_min: 6000 },
|
|
224
|
+
transport_method: "turn_relay",
|
|
225
|
+
exposure_mode: "relay_required",
|
|
226
|
+
transport_metadata: {
|
|
227
|
+
relay_for: this.nodeId,
|
|
228
|
+
relay_transport: "http-poll",
|
|
229
|
+
},
|
|
230
|
+
sdk_language: "browser",
|
|
231
|
+
sdk_version: BROWSER_NODE_SDK_VERSION,
|
|
232
|
+
backend: "webllm",
|
|
233
|
+
// IICP-CX S.16: browser providers are privacy-ready too. The relay
|
|
234
|
+
// still sees metadata, but not the task payload.
|
|
235
|
+
cx_public_key: this._cx.publicKey,
|
|
236
|
+
}),
|
|
237
|
+
});
|
|
238
|
+
if (!regResp.ok) {
|
|
239
|
+
const detail = await regResp.text().catch(() => "");
|
|
240
|
+
throw new Error(`HTTP ${regResp.status} ${detail.slice(0, 200)}`);
|
|
241
|
+
}
|
|
242
|
+
const reg = await regResp.json();
|
|
243
|
+
this._nodeToken = reg.node_token ?? reg.token ?? "";
|
|
244
|
+
if (!this._nodeToken)
|
|
245
|
+
throw new Error("directory returned no node_token");
|
|
246
|
+
this.directoryListed = true;
|
|
247
|
+
this.log(`registered with directory as ${this.nodeId}`);
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
// Relay-only degradation: a rejected registration (e.g. IICP-E035
|
|
251
|
+
// loopback-endpoint validation against a local test relay) means the
|
|
252
|
+
// node is not mesh-discoverable, but consumers that know the relay
|
|
253
|
+
// endpoint can still dispatch — keep serving and say so plainly.
|
|
254
|
+
this.directoryListed = false;
|
|
255
|
+
this.log(`directory registration rejected — serving relay-only (not discoverable): ${err instanceof Error ? err.message : String(err)}`);
|
|
256
|
+
}
|
|
257
|
+
// 3. Heartbeat (only when directory-listed) + poll loop. Fire one
|
|
258
|
+
// immediately so the directory sees us as available without waiting a
|
|
259
|
+
// full interval, then keep alive every 30 s while the tab is open and the
|
|
260
|
+
// model stays loaded.
|
|
261
|
+
if (this.directoryListed) {
|
|
262
|
+
void this.heartbeat();
|
|
263
|
+
this._heartbeatTimer = setInterval(() => void this.heartbeat(), HEARTBEAT_MS);
|
|
264
|
+
}
|
|
265
|
+
this.setState("serving");
|
|
266
|
+
void this.pollLoop();
|
|
267
|
+
}
|
|
268
|
+
/** Unbind from the relay and deregister from the directory. */
|
|
269
|
+
async stop() {
|
|
270
|
+
this._stopRequested = true;
|
|
271
|
+
if (this._heartbeatTimer) {
|
|
272
|
+
clearInterval(this._heartbeatTimer);
|
|
273
|
+
this._heartbeatTimer = null;
|
|
274
|
+
}
|
|
275
|
+
await this.unbindQuietly();
|
|
276
|
+
if (this._nodeToken) {
|
|
277
|
+
try {
|
|
278
|
+
await fetch(`${this.directoryBase}/v1/register`, {
|
|
279
|
+
method: "DELETE",
|
|
280
|
+
headers: { Authorization: `Bearer ${this._nodeToken}` },
|
|
281
|
+
keepalive: true,
|
|
282
|
+
});
|
|
283
|
+
this.log("deregistered from directory");
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// best-effort — heartbeat expiry cleans up server-side
|
|
287
|
+
}
|
|
288
|
+
this._nodeToken = "";
|
|
289
|
+
}
|
|
290
|
+
this.setState("stopped");
|
|
291
|
+
}
|
|
292
|
+
async unbindQuietly() {
|
|
293
|
+
if (!this._sessionToken)
|
|
294
|
+
return;
|
|
295
|
+
try {
|
|
296
|
+
await fetch(`${this.relayBase}/v1/relay/unbind`, {
|
|
297
|
+
method: "POST",
|
|
298
|
+
headers: {
|
|
299
|
+
Authorization: `Bearer ${this._sessionToken}`,
|
|
300
|
+
"Content-Type": "application/json",
|
|
301
|
+
},
|
|
302
|
+
body: "{}",
|
|
303
|
+
keepalive: true,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// best-effort — relay liveness window GCs the session
|
|
308
|
+
}
|
|
309
|
+
this._sessionToken = "";
|
|
310
|
+
}
|
|
311
|
+
async heartbeat() {
|
|
312
|
+
if (!this._nodeToken)
|
|
313
|
+
return;
|
|
314
|
+
const ok = this._okSinceBeat;
|
|
315
|
+
const fail = this._failSinceBeat;
|
|
316
|
+
const latencyMs = this._latencyMsSinceBeat;
|
|
317
|
+
this._okSinceBeat = 0;
|
|
318
|
+
this._failSinceBeat = 0;
|
|
319
|
+
this._latencyMsSinceBeat = 0;
|
|
320
|
+
const payload = {
|
|
321
|
+
node_id: this.nodeId,
|
|
322
|
+
node_token: this._nodeToken,
|
|
323
|
+
status: "available",
|
|
324
|
+
available: true,
|
|
325
|
+
max_concurrent: 1,
|
|
326
|
+
// Liveness: the model is loaded and the tab is open. health_models is the
|
|
327
|
+
// directory's signal that the advertised model is actually servable
|
|
328
|
+
// (empty here would demote us in discover) — see #494.
|
|
329
|
+
health_models: [this.cfg.model],
|
|
330
|
+
};
|
|
331
|
+
if (ok > 0 || fail > 0) {
|
|
332
|
+
const total = ok + fail;
|
|
333
|
+
payload.metrics = {
|
|
334
|
+
tasks_success: ok,
|
|
335
|
+
tasks_failed: fail,
|
|
336
|
+
...(latencyMs > 0 && total > 0
|
|
337
|
+
? { avg_latency_ms: Math.round((latencyMs / total) * 100) / 100 }
|
|
338
|
+
: {}),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
const resp = await fetch(`${this.directoryBase}/v1/heartbeat`, {
|
|
343
|
+
method: "POST",
|
|
344
|
+
headers: {
|
|
345
|
+
"Content-Type": "application/json",
|
|
346
|
+
Authorization: `Bearer ${this._nodeToken}`,
|
|
347
|
+
},
|
|
348
|
+
body: JSON.stringify(payload),
|
|
349
|
+
});
|
|
350
|
+
if (resp.ok) {
|
|
351
|
+
this.log(`keep-alive ✓ (model loaded${ok || fail ? `, +${ok} ok / +${fail} fail` : ""})`);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
this.log(`keep-alive → HTTP ${resp.status}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
this.log("keep-alive failed (transient)");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async pollLoop() {
|
|
362
|
+
while (!this._stopRequested && this._sessionToken) {
|
|
363
|
+
let resp;
|
|
364
|
+
try {
|
|
365
|
+
resp = await fetch(`${this.relayBase}/v1/relay/pull`, {
|
|
366
|
+
headers: { Authorization: `Bearer ${this._sessionToken}` },
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
if (this._stopRequested)
|
|
371
|
+
return;
|
|
372
|
+
await new Promise((r) => setTimeout(r, POLL_ERROR_BACKOFF_MS));
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (resp.status === 204)
|
|
376
|
+
continue; // idle window — poll again
|
|
377
|
+
if (resp.status === 401) {
|
|
378
|
+
// Session displaced or GC'd — stop serving rather than fight over the id.
|
|
379
|
+
this.log("relay session expired — stopping");
|
|
380
|
+
await this.stop();
|
|
381
|
+
this.setState("error");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (!resp.ok) {
|
|
385
|
+
await new Promise((r) => setTimeout(r, POLL_ERROR_BACKOFF_MS));
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const call = await resp.json();
|
|
389
|
+
void this.serveCall(call);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async serveCall(call) {
|
|
393
|
+
const task = call.task ?? {};
|
|
394
|
+
const taskId = String(task.task_id ?? call.call_id);
|
|
395
|
+
const intent = typeof task.intent === "string" ? task.intent : CHAT_INTENT;
|
|
396
|
+
let result;
|
|
397
|
+
const started = performance.now();
|
|
398
|
+
try {
|
|
399
|
+
let taskPayload = task.payload ?? {};
|
|
400
|
+
if (task.iicp_conf && typeof task.iicp_conf === "object") {
|
|
401
|
+
taskPayload = await decryptPayload(task.iicp_conf, this._cx.secretKey, taskId, intent);
|
|
402
|
+
}
|
|
403
|
+
const payload = (taskPayload ?? {});
|
|
404
|
+
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
|
405
|
+
const text = await this.runtime.chat(messages, {
|
|
406
|
+
...(payload.temperature !== undefined && { temperature: payload.temperature }),
|
|
407
|
+
...(payload.max_tokens !== undefined && { max_tokens: payload.max_tokens }),
|
|
408
|
+
});
|
|
409
|
+
// OpenAI-shape choices — exactly what SDK consumers' chat() parses.
|
|
410
|
+
result = {
|
|
411
|
+
result: {
|
|
412
|
+
choices: [
|
|
413
|
+
{
|
|
414
|
+
message: { role: "assistant", content: text },
|
|
415
|
+
finish_reason: "stop",
|
|
416
|
+
},
|
|
417
|
+
],
|
|
418
|
+
model: this.cfg.model,
|
|
419
|
+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
this._tasksServed += 1;
|
|
423
|
+
this._okSinceBeat += 1;
|
|
424
|
+
this._latencyMsSinceBeat += Math.max(1, Math.round(performance.now() - started));
|
|
425
|
+
this.cfg.onTaskServed?.(this._tasksServed);
|
|
426
|
+
this.log(`served task ${String(task.task_id ?? call.call_id)}`);
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
this._failSinceBeat += 1;
|
|
430
|
+
this._latencyMsSinceBeat += Math.max(1, Math.round(performance.now() - started));
|
|
431
|
+
result = {
|
|
432
|
+
result: {
|
|
433
|
+
error: {
|
|
434
|
+
code: "IICP-E020",
|
|
435
|
+
message: `browser inference failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
this.log(`task failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
await fetch(`${this.relayBase}/v1/relay/result`, {
|
|
443
|
+
method: "POST",
|
|
444
|
+
headers: {
|
|
445
|
+
Authorization: `Bearer ${this._sessionToken}`,
|
|
446
|
+
"Content-Type": "application/json",
|
|
447
|
+
},
|
|
448
|
+
body: JSON.stringify({ call_id: call.call_id, result }),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
this.log("result delivery failed — consumer will see a timeout");
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface CxPublicKey {
|
|
2
|
+
algorithm: string;
|
|
3
|
+
encoding?: string;
|
|
4
|
+
key: string;
|
|
5
|
+
key_id: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CxKeyPair {
|
|
8
|
+
publicKey: CxPublicKey;
|
|
9
|
+
secretKey: Uint8Array;
|
|
10
|
+
}
|
|
11
|
+
/** Generate a browser-provider X25519 key pair suitable for directory advertisement. */
|
|
12
|
+
export declare function createCxKeyPair(prefix?: string): CxKeyPair;
|
|
13
|
+
/**
|
|
14
|
+
* Encrypt a task payload to the node's advertised X25519 key, producing an `iicp_conf`
|
|
15
|
+
* envelope identical to what the SDKs send and the adapter decrypts (IICP-CX §5):
|
|
16
|
+
* ephemeral X25519 → HKDF-SHA256(salt=nonce, info="IICP-CX-v1"+task_id+intent) →
|
|
17
|
+
* AES-256-GCM(aad=task_id+"|"+intent). The caller omits `payload` when `iicp_conf` is present.
|
|
18
|
+
*/
|
|
19
|
+
export declare function encryptPayload(payload: unknown, cx: CxPublicKey, taskId: string, intent: string): Promise<Record<string, unknown>>;
|
|
20
|
+
/**
|
|
21
|
+
* Decrypt an IICP-CX envelope for a browser provider node. This is the mirror
|
|
22
|
+
* of encryptPayload(), used only after the tab explicitly starts serving via a
|
|
23
|
+
* relay and has advertised its generated cx_public_key to the directory.
|
|
24
|
+
*/
|
|
25
|
+
export declare function decryptPayload(envelope: Record<string, unknown>, secretKey: Uint8Array, taskId: string, intent: string): Promise<unknown>;
|
|
26
|
+
/** Read a node's canonical `cx_public_key`; accept deprecated `public_key` alias during migration. */
|
|
27
|
+
export declare function nodeCxKey(node: Record<string, unknown>): CxPublicKey | null;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// IICP-CX S.16 Tier-1 confidentiality — BROWSER CX-Consumer (mandatory, no opt-out).
|
|
2
|
+
// Byte-compatible with the node/adapter decrypt: X25519 (@noble/curves) +
|
|
3
|
+
// HKDF-SHA256 (@noble/hashes) + AES-256-GCM (WebCrypto, universally available).
|
|
4
|
+
// The browser SDK can't use Node-crypto; this is the WebCrypto/noble equivalent.
|
|
5
|
+
import { x25519 } from "@noble/curves/ed25519.js";
|
|
6
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
7
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
8
|
+
function b64urlEncode(b) {
|
|
9
|
+
let bin = "";
|
|
10
|
+
for (let i = 0; i < b.length; i++)
|
|
11
|
+
bin += String.fromCharCode(b[i]);
|
|
12
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
13
|
+
}
|
|
14
|
+
function b64urlDecode(s) {
|
|
15
|
+
let t = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
16
|
+
while (t.length % 4)
|
|
17
|
+
t += "=";
|
|
18
|
+
const bin = atob(t);
|
|
19
|
+
const out = new Uint8Array(bin.length);
|
|
20
|
+
for (let i = 0; i < bin.length; i++)
|
|
21
|
+
out[i] = bin.charCodeAt(i);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
function constantTimeEqual(a, b) {
|
|
25
|
+
if (a.length !== b.length)
|
|
26
|
+
return false;
|
|
27
|
+
let diff = 0;
|
|
28
|
+
for (let i = 0; i < a.length; i++)
|
|
29
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
30
|
+
return diff === 0;
|
|
31
|
+
}
|
|
32
|
+
function cxInfo(taskId, intent) {
|
|
33
|
+
return new TextEncoder().encode(`IICP-CX-v1${taskId}${intent}`);
|
|
34
|
+
}
|
|
35
|
+
function cxAad(taskId, intent) {
|
|
36
|
+
return new TextEncoder().encode(`${taskId}|${intent}`);
|
|
37
|
+
}
|
|
38
|
+
/** Generate a browser-provider X25519 key pair suitable for directory advertisement. */
|
|
39
|
+
export function createCxKeyPair(prefix = "cx-browser") {
|
|
40
|
+
const secretKey = x25519.utils.randomSecretKey();
|
|
41
|
+
const publicKeyBytes = x25519.getPublicKey(secretKey);
|
|
42
|
+
const fingerprint = b64urlEncode(sha256(publicKeyBytes)).slice(0, 12);
|
|
43
|
+
return {
|
|
44
|
+
secretKey,
|
|
45
|
+
publicKey: {
|
|
46
|
+
algorithm: "X25519",
|
|
47
|
+
encoding: "base64url",
|
|
48
|
+
key: b64urlEncode(publicKeyBytes),
|
|
49
|
+
key_id: `${prefix}-${fingerprint}`,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Encrypt a task payload to the node's advertised X25519 key, producing an `iicp_conf`
|
|
55
|
+
* envelope identical to what the SDKs send and the adapter decrypts (IICP-CX §5):
|
|
56
|
+
* ephemeral X25519 → HKDF-SHA256(salt=nonce, info="IICP-CX-v1"+task_id+intent) →
|
|
57
|
+
* AES-256-GCM(aad=task_id+"|"+intent). The caller omits `payload` when `iicp_conf` is present.
|
|
58
|
+
*/
|
|
59
|
+
export async function encryptPayload(payload, cx, taskId, intent) {
|
|
60
|
+
if (cx.algorithm !== "X25519") {
|
|
61
|
+
throw new Error(`Unsupported cx_public_key algorithm: ${cx.algorithm}`);
|
|
62
|
+
}
|
|
63
|
+
const nodePub = b64urlDecode(cx.key);
|
|
64
|
+
const ephemPriv = x25519.utils.randomSecretKey();
|
|
65
|
+
const ephemPub = x25519.getPublicKey(ephemPriv);
|
|
66
|
+
const shared = x25519.getSharedSecret(ephemPriv, nodePub);
|
|
67
|
+
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
68
|
+
const info = cxInfo(taskId, intent);
|
|
69
|
+
const keyBytes = hkdf(sha256, shared, nonce, info, 32);
|
|
70
|
+
const aad = cxAad(taskId, intent);
|
|
71
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
72
|
+
const key = await crypto.subtle.importKey("raw", new Uint8Array(keyBytes), "AES-GCM", false, ["encrypt"]);
|
|
73
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: new Uint8Array(nonce), additionalData: new Uint8Array(aad) }, key, new Uint8Array(plaintext)));
|
|
74
|
+
return {
|
|
75
|
+
version: 1,
|
|
76
|
+
recipient_key_id: cx.key_id,
|
|
77
|
+
kem_ciphertext: b64urlEncode(ephemPub),
|
|
78
|
+
encrypted_body: b64urlEncode(ct), // WebCrypto AES-GCM output = ciphertext || 16-byte tag
|
|
79
|
+
nonce: b64urlEncode(nonce),
|
|
80
|
+
aad: b64urlEncode(aad),
|
|
81
|
+
plaintext_size: plaintext.length,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Decrypt an IICP-CX envelope for a browser provider node. This is the mirror
|
|
86
|
+
* of encryptPayload(), used only after the tab explicitly starts serving via a
|
|
87
|
+
* relay and has advertised its generated cx_public_key to the directory.
|
|
88
|
+
*/
|
|
89
|
+
export async function decryptPayload(envelope, secretKey, taskId, intent) {
|
|
90
|
+
if (envelope.version !== 1) {
|
|
91
|
+
throw new Error(`Unsupported iicp_conf version: ${String(envelope.version)}`);
|
|
92
|
+
}
|
|
93
|
+
const kem = envelope.kem_ciphertext;
|
|
94
|
+
const encryptedBody = envelope.encrypted_body;
|
|
95
|
+
const nonceRaw = envelope.nonce;
|
|
96
|
+
if (typeof kem !== "string" || typeof encryptedBody !== "string" || typeof nonceRaw !== "string") {
|
|
97
|
+
throw new Error("Malformed iicp_conf envelope");
|
|
98
|
+
}
|
|
99
|
+
const aad = cxAad(taskId, intent);
|
|
100
|
+
if (typeof envelope.aad === "string" && !constantTimeEqual(envelope.aad, b64urlEncode(aad))) {
|
|
101
|
+
throw new Error("iicp_conf AAD does not match task identity");
|
|
102
|
+
}
|
|
103
|
+
const shared = x25519.getSharedSecret(secretKey, b64urlDecode(kem));
|
|
104
|
+
const nonce = b64urlDecode(nonceRaw);
|
|
105
|
+
const keyBytes = hkdf(sha256, shared, nonce, cxInfo(taskId, intent), 32);
|
|
106
|
+
const key = await crypto.subtle.importKey("raw", new Uint8Array(keyBytes), "AES-GCM", false, ["decrypt"]);
|
|
107
|
+
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: new Uint8Array(nonce), additionalData: new Uint8Array(aad) }, key, new Uint8Array(b64urlDecode(encryptedBody)));
|
|
108
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
109
|
+
}
|
|
110
|
+
/** Read a node's canonical `cx_public_key`; accept deprecated `public_key` alias during migration. */
|
|
111
|
+
export function nodeCxKey(node) {
|
|
112
|
+
const raw = (node["cx_public_key"] ?? node["public_key"]);
|
|
113
|
+
if (raw && typeof raw === "object" && raw["algorithm"] === "X25519" && typeof raw["key"] === "string") {
|
|
114
|
+
return { algorithm: "X25519", key: String(raw["key"]), key_id: String(raw["key_id"] ?? "cx-1") };
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|