@iicp/web-node 0.1.0 → 0.2.1
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 +18 -2
- package/dist/browserNodeProvider.d.ts +21 -2
- package/dist/browserNodeProvider.js +120 -32
- package/dist/iicpConsumer.d.ts +138 -17
- package/dist/iicpConsumer.js +327 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,6 +26,11 @@ const reply = await client.chat(
|
|
|
26
26
|
);
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
The client prefers short-lived dispatch tickets, falls back only when an older directory
|
|
30
|
+
does not support them, and can enforce strict region and signed-policy-manifest constraints
|
|
31
|
+
before a prompt is sent. Declared prohibited or high-risk public-mesh intents are refused
|
|
32
|
+
locally. Successful routing receipts exclude prompt, response, token, and endpoint content.
|
|
33
|
+
|
|
29
34
|
When the chosen node advertises an encryption key (`nodeCxKey(node)`), the payload is
|
|
30
35
|
**sealed end-to-end** — the directory, relays, and network see only ciphertext. There is
|
|
31
36
|
no opt-out. A node that does not advertise `cx_public_key`/`public_key` is refused before
|
|
@@ -42,6 +47,17 @@ Browser providers advertise a generated `cx_public_key`, decrypt incoming `iicp_
|
|
|
42
47
|
payloads locally, report task success/failure/latency in heartbeats, and use a relay path
|
|
43
48
|
because browser tabs cannot accept raw inbound TCP.
|
|
44
49
|
|
|
50
|
+
Startup is intentionally ticketed: the provider registers, requests a short-lived relay
|
|
51
|
+
bind ticket scoped to its worker and selected relay, and presents that ticket when it binds.
|
|
52
|
+
Authentication or validation failures stop serving and clean up the temporary directory
|
|
53
|
+
registration instead of silently falling back to an unsigned bind. Compatibility fallback
|
|
54
|
+
is limited to directories that explicitly report that the ticket service is unavailable.
|
|
55
|
+
|
|
56
|
+
Provider state includes deterministic recovery diagnostics (`stable`, `tunnel_starting`,
|
|
57
|
+
`route_mismatch`, `operator_action_needed`, or `unavailable`) so a host page can explain
|
|
58
|
+
whether it is directory-listed and relay-bound. Browser lifetime still matters: closing or
|
|
59
|
+
suspending the tab stops the in-browser provider.
|
|
60
|
+
|
|
45
61
|
Serving requires WebLLM — add it alongside this package:
|
|
46
62
|
|
|
47
63
|
```
|
|
@@ -57,8 +73,8 @@ TypeScript, and Rust IICP clients and the provider/adapter that decrypts it.
|
|
|
57
73
|
|
|
58
74
|
## CORS reality
|
|
59
75
|
|
|
60
|
-
|
|
61
|
-
sends CORS headers. Routing a task to a
|
|
76
|
+
Public discovery and ticketed dispatch work from approved browser origins because
|
|
77
|
+
`iicp.network` sends CORS headers. Routing a task to a selected node is subject to the browser's CORS
|
|
62
78
|
and mixed-content policy: an `https://` page cannot reach an `http://localhost` model, a
|
|
63
79
|
node without CORS headers, or an IPv6-firewalled node. In **Node** there is no CORS
|
|
64
80
|
restriction and everything works.
|
|
@@ -8,6 +8,8 @@ export interface BrowserProviderRuntime {
|
|
|
8
8
|
export interface BrowserProviderConfig {
|
|
9
9
|
/** Relay node base URL, e.g. "http://127.0.0.1:9484". Required. */
|
|
10
10
|
relayUrl: string;
|
|
11
|
+
/** Auto-discovered relay node id. Used to audience-scope relay bind tickets. */
|
|
12
|
+
relayNodeId?: string;
|
|
11
13
|
/** Directory API base. Default: "https://iicp.network/api". */
|
|
12
14
|
directoryUrl?: string;
|
|
13
15
|
/** Model name advertised to the directory (the loaded WebLLM model id). */
|
|
@@ -17,9 +19,19 @@ export interface BrowserProviderConfig {
|
|
|
17
19
|
/** Called after each served task with the running total. */
|
|
18
20
|
onTaskServed?: (total: number) => void;
|
|
19
21
|
onStateChange?: (state: BrowserProviderState) => void;
|
|
22
|
+
onRecoveryChange?: (diagnostic: BrowserProviderDiagnostic) => void;
|
|
20
23
|
}
|
|
21
24
|
export type BrowserProviderState = "stopped" | "starting" | "serving" | "error";
|
|
22
|
-
export
|
|
25
|
+
export type BrowserProviderRecoveryState = "stable" | "tunnel_starting" | "route_mismatch" | "operator_action_needed" | "unavailable";
|
|
26
|
+
export type BrowserProviderRecoveryAction = "none" | "reregister" | "wait_cooldown" | "operator_endpoint_needed";
|
|
27
|
+
export interface BrowserProviderDiagnostic {
|
|
28
|
+
recovery_state: BrowserProviderRecoveryState;
|
|
29
|
+
recovery_action: BrowserProviderRecoveryAction;
|
|
30
|
+
directory_listed: boolean;
|
|
31
|
+
relay_bound: boolean;
|
|
32
|
+
tasks_served: number;
|
|
33
|
+
}
|
|
34
|
+
export declare const BROWSER_NODE_SDK_VERSION = "0.7.86-browser";
|
|
23
35
|
/**
|
|
24
36
|
* Coarse region autodetect from the browser's timezone (no network, no
|
|
25
37
|
* geolocation permission). Matches the mesh's region convention
|
|
@@ -53,19 +65,26 @@ export declare class BrowserNodeProvider {
|
|
|
53
65
|
private readonly _cx;
|
|
54
66
|
private _heartbeatTimer;
|
|
55
67
|
private _stopRequested;
|
|
68
|
+
private _recoveryState;
|
|
69
|
+
private _recoveryAction;
|
|
56
70
|
/** True when the directory accepted the registration (mesh-discoverable). */
|
|
57
71
|
directoryListed: boolean;
|
|
58
72
|
constructor(runtime: BrowserProviderRuntime, cfg: BrowserProviderConfig);
|
|
59
73
|
get state(): BrowserProviderState;
|
|
60
74
|
get tasksServed(): number;
|
|
75
|
+
get diagnostic(): BrowserProviderDiagnostic;
|
|
61
76
|
private get directoryBase();
|
|
62
77
|
private get relayBase();
|
|
63
78
|
private log;
|
|
64
79
|
private setState;
|
|
65
|
-
|
|
80
|
+
private setRecovery;
|
|
81
|
+
/** Register → obtain bind ticket → bind → serve. */
|
|
66
82
|
start(): Promise<void>;
|
|
83
|
+
private register;
|
|
84
|
+
private fetchBindTicket;
|
|
67
85
|
/** Unbind from the relay and deregister from the directory. */
|
|
68
86
|
stop(): Promise<void>;
|
|
87
|
+
private deregisterQuietly;
|
|
69
88
|
private unbindQuietly;
|
|
70
89
|
private heartbeat;
|
|
71
90
|
private pollLoop;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { maskTunnelUrl } from "./iicpConsumer.js";
|
|
20
20
|
import { createCxKeyPair, decryptPayload } from "./cxConfidentiality.js";
|
|
21
21
|
const CHAT_INTENT = "urn:iicp:intent:llm:chat:v1";
|
|
22
|
-
export const BROWSER_NODE_SDK_VERSION = "0.7.
|
|
22
|
+
export const BROWSER_NODE_SDK_VERSION = "0.7.86-browser";
|
|
23
23
|
/**
|
|
24
24
|
* Coarse region autodetect from the browser's timezone (no network, no
|
|
25
25
|
* geolocation permission). Matches the mesh's region convention
|
|
@@ -88,11 +88,18 @@ const MIN_RELAY_REPUTATION = 0.1; // hard floor — drop actively-demoted nodes
|
|
|
88
88
|
export async function discoverRelay(directoryUrl = "https://iicp.network/api") {
|
|
89
89
|
try {
|
|
90
90
|
const base = directoryUrl.replace(/\/$/, "");
|
|
91
|
-
const resp = await fetch(`${base}/v1/
|
|
91
|
+
const resp = await fetch(`${base}/v1/dispatch/ticket`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
|
94
|
+
body: JSON.stringify({ intent: CHAT_INTENT, relay_capable: true, limit: 1 }),
|
|
95
|
+
});
|
|
92
96
|
if (!resp.ok)
|
|
93
97
|
return null;
|
|
94
98
|
const data = await resp.json();
|
|
95
|
-
const
|
|
99
|
+
const route = data?.route;
|
|
100
|
+
const nodes = route && typeof route === "object"
|
|
101
|
+
? [{ ...route, node_id: data?.node_id, relay_capable: true }]
|
|
102
|
+
: [];
|
|
96
103
|
const isLoopback = (ep) => /^http:\/\/(localhost|127\.0\.0\.1)[:/]/.test(ep);
|
|
97
104
|
const usable = nodes
|
|
98
105
|
.filter((n) => n.relay_capable === true &&
|
|
@@ -144,6 +151,8 @@ export class BrowserNodeProvider {
|
|
|
144
151
|
_cx = createCxKeyPair();
|
|
145
152
|
_heartbeatTimer = null;
|
|
146
153
|
_stopRequested = false;
|
|
154
|
+
_recoveryState = "unavailable";
|
|
155
|
+
_recoveryAction = "operator_endpoint_needed";
|
|
147
156
|
/** True when the directory accepted the registration (mesh-discoverable). */
|
|
148
157
|
directoryListed = false;
|
|
149
158
|
constructor(runtime, cfg) {
|
|
@@ -157,6 +166,15 @@ export class BrowserNodeProvider {
|
|
|
157
166
|
get tasksServed() {
|
|
158
167
|
return this._tasksServed;
|
|
159
168
|
}
|
|
169
|
+
get diagnostic() {
|
|
170
|
+
return {
|
|
171
|
+
recovery_state: this._recoveryState,
|
|
172
|
+
recovery_action: this._recoveryAction,
|
|
173
|
+
directory_listed: this.directoryListed,
|
|
174
|
+
relay_bound: Boolean(this._sessionToken),
|
|
175
|
+
tasks_served: this._tasksServed,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
160
178
|
get directoryBase() {
|
|
161
179
|
return (this.cfg.directoryUrl ?? "https://iicp.network/api").replace(/\/$/, "");
|
|
162
180
|
}
|
|
@@ -170,14 +188,30 @@ export class BrowserNodeProvider {
|
|
|
170
188
|
this._state = s;
|
|
171
189
|
this.cfg.onStateChange?.(s);
|
|
172
190
|
}
|
|
173
|
-
|
|
191
|
+
setRecovery(state, action) {
|
|
192
|
+
this._recoveryState = state;
|
|
193
|
+
this._recoveryAction = action;
|
|
194
|
+
this.cfg.onRecoveryChange?.(this.diagnostic);
|
|
195
|
+
}
|
|
196
|
+
/** Register → obtain bind ticket → bind → serve. */
|
|
174
197
|
async start() {
|
|
175
198
|
if (this._state === "serving" || this._state === "starting")
|
|
176
199
|
return;
|
|
177
200
|
this._stopRequested = false;
|
|
178
201
|
this.setState("starting");
|
|
179
|
-
|
|
180
|
-
|
|
202
|
+
this.setRecovery("tunnel_starting", "wait_cooldown");
|
|
203
|
+
const endpoint = `${this.relayBase}/v1/relay-for/${this.nodeId}`;
|
|
204
|
+
// 1. Register first. Strict relays require a directory-issued bind ticket,
|
|
205
|
+
// which in turn requires the worker's node token. A failed later bind is
|
|
206
|
+
// immediately cleaned up so this ordering does not leave stale listings.
|
|
207
|
+
await this.register(endpoint);
|
|
208
|
+
// 2. Obtain a short-lived worker/audience-scoped bind ticket. Only an
|
|
209
|
+
// explicit older-directory response may use the legacy soft-bind path.
|
|
210
|
+
let bindTicket = "";
|
|
211
|
+
if (this._nodeToken) {
|
|
212
|
+
bindTicket = await this.fetchBindTicket();
|
|
213
|
+
}
|
|
214
|
+
// 3. Bind to the relay, presenting the ticket whenever one was issued.
|
|
181
215
|
let bindResp;
|
|
182
216
|
try {
|
|
183
217
|
bindResp = await fetch(`${this.relayBase}/v1/relay/bind`, {
|
|
@@ -187,23 +221,37 @@ export class BrowserNodeProvider {
|
|
|
187
221
|
worker_id: this.nodeId,
|
|
188
222
|
intent: CHAT_INTENT,
|
|
189
223
|
models: [this.cfg.model],
|
|
224
|
+
...(bindTicket ? { bind_ticket: bindTicket } : {}),
|
|
190
225
|
}),
|
|
191
226
|
});
|
|
192
227
|
}
|
|
193
228
|
catch (err) {
|
|
229
|
+
this.setRecovery("operator_action_needed", "operator_endpoint_needed");
|
|
194
230
|
this.setState("error");
|
|
231
|
+
await this.deregisterQuietly();
|
|
195
232
|
throw new BrowserProviderError(`relay unreachable at ${maskTunnelUrl(this.relayBase)}: ${err instanceof Error ? err.message : String(err)}`, "bind");
|
|
196
233
|
}
|
|
197
234
|
if (!bindResp.ok) {
|
|
198
235
|
this.setState("error");
|
|
199
236
|
const detail = await bindResp.text().catch(() => "");
|
|
237
|
+
await this.deregisterQuietly();
|
|
200
238
|
throw new BrowserProviderError(`relay bind failed: HTTP ${bindResp.status} ${detail.slice(0, 200)}`, "bind");
|
|
201
239
|
}
|
|
202
240
|
const bind = await bindResp.json();
|
|
203
241
|
this._sessionToken = bind.session_token;
|
|
242
|
+
this.setRecovery("route_mismatch", "reregister");
|
|
204
243
|
this.log(`relay bound — worker ${this.nodeId}`);
|
|
205
|
-
|
|
206
|
-
|
|
244
|
+
if (this.directoryListed)
|
|
245
|
+
this.setRecovery("stable", "none");
|
|
246
|
+
// 4. Heartbeat (only when directory-listed) + poll loop.
|
|
247
|
+
if (this.directoryListed) {
|
|
248
|
+
void this.heartbeat();
|
|
249
|
+
this._heartbeatTimer = setInterval(() => void this.heartbeat(), HEARTBEAT_MS);
|
|
250
|
+
}
|
|
251
|
+
this.setState("serving");
|
|
252
|
+
void this.pollLoop();
|
|
253
|
+
}
|
|
254
|
+
async register(endpoint) {
|
|
207
255
|
try {
|
|
208
256
|
const regResp = await fetch(`${this.directoryBase}/v1/register`, {
|
|
209
257
|
method: "POST",
|
|
@@ -229,7 +277,11 @@ export class BrowserNodeProvider {
|
|
|
229
277
|
},
|
|
230
278
|
sdk_language: "browser",
|
|
231
279
|
sdk_version: BROWSER_NODE_SDK_VERSION,
|
|
232
|
-
backend
|
|
280
|
+
// The current directory contract accepts native backend identifiers
|
|
281
|
+
// plus "custom". Browser/WebLLM providers are non-native providers,
|
|
282
|
+
// so advertise them as custom until the protocol taxonomy grows a
|
|
283
|
+
// dedicated browser backend token.
|
|
284
|
+
backend: "custom",
|
|
233
285
|
// IICP-CX S.16: browser providers are privacy-ready too. The relay
|
|
234
286
|
// still sees metadata, but not the task payload.
|
|
235
287
|
cx_public_key: this._cx.publicKey,
|
|
@@ -252,18 +304,44 @@ export class BrowserNodeProvider {
|
|
|
252
304
|
// node is not mesh-discoverable, but consumers that know the relay
|
|
253
305
|
// endpoint can still dispatch — keep serving and say so plainly.
|
|
254
306
|
this.directoryListed = false;
|
|
307
|
+
this.setRecovery("route_mismatch", "reregister");
|
|
255
308
|
this.log(`directory registration rejected — serving relay-only (not discoverable): ${err instanceof Error ? err.message : String(err)}`);
|
|
256
309
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
310
|
+
}
|
|
311
|
+
async fetchBindTicket() {
|
|
312
|
+
let resp;
|
|
313
|
+
try {
|
|
314
|
+
resp = await fetch(`${this.directoryBase}/v1/relay/ticket`, {
|
|
315
|
+
method: "POST",
|
|
316
|
+
headers: {
|
|
317
|
+
Authorization: `Bearer ${this._nodeToken}`,
|
|
318
|
+
"Content-Type": "application/json",
|
|
319
|
+
},
|
|
320
|
+
body: JSON.stringify({ relay_node_id: this.cfg.relayNodeId ?? "*" }),
|
|
321
|
+
});
|
|
264
322
|
}
|
|
265
|
-
|
|
266
|
-
|
|
323
|
+
catch (err) {
|
|
324
|
+
await this.deregisterQuietly();
|
|
325
|
+
this.setState("error");
|
|
326
|
+
throw new BrowserProviderError(`relay bind ticket request failed: ${err instanceof Error ? err.message : String(err)}`, "bind");
|
|
327
|
+
}
|
|
328
|
+
if (resp.ok) {
|
|
329
|
+
const body = await resp.json();
|
|
330
|
+
if (typeof body.ticket !== "string" || !body.ticket) {
|
|
331
|
+
await this.deregisterQuietly();
|
|
332
|
+
throw new BrowserProviderError("directory returned no relay bind ticket", "bind");
|
|
333
|
+
}
|
|
334
|
+
return body.ticket;
|
|
335
|
+
}
|
|
336
|
+
const detail = await resp.text().catch(() => "");
|
|
337
|
+
const legacy = resp.status === 404 || (resp.status === 503 && detail.includes("not_configured"));
|
|
338
|
+
if (legacy) {
|
|
339
|
+
this.log("directory has no relay bind-ticket service — trying legacy soft bind");
|
|
340
|
+
return "";
|
|
341
|
+
}
|
|
342
|
+
await this.deregisterQuietly();
|
|
343
|
+
this.setState("error");
|
|
344
|
+
throw new BrowserProviderError(`relay bind ticket refused: HTTP ${resp.status} ${detail.slice(0, 200)}`, "bind");
|
|
267
345
|
}
|
|
268
346
|
/** Unbind from the relay and deregister from the directory. */
|
|
269
347
|
async stop() {
|
|
@@ -273,22 +351,26 @@ export class BrowserNodeProvider {
|
|
|
273
351
|
this._heartbeatTimer = null;
|
|
274
352
|
}
|
|
275
353
|
await this.unbindQuietly();
|
|
276
|
-
|
|
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
|
-
}
|
|
354
|
+
await this.deregisterQuietly();
|
|
290
355
|
this.setState("stopped");
|
|
291
356
|
}
|
|
357
|
+
async deregisterQuietly() {
|
|
358
|
+
if (!this._nodeToken)
|
|
359
|
+
return;
|
|
360
|
+
try {
|
|
361
|
+
await fetch(`${this.directoryBase}/v1/register`, {
|
|
362
|
+
method: "DELETE",
|
|
363
|
+
headers: { Authorization: `Bearer ${this._nodeToken}` },
|
|
364
|
+
keepalive: true,
|
|
365
|
+
});
|
|
366
|
+
this.log("deregistered from directory");
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// best-effort — heartbeat expiry cleans up server-side
|
|
370
|
+
}
|
|
371
|
+
this._nodeToken = "";
|
|
372
|
+
this.directoryListed = false;
|
|
373
|
+
}
|
|
292
374
|
async unbindQuietly() {
|
|
293
375
|
if (!this._sessionToken)
|
|
294
376
|
return;
|
|
@@ -307,6 +389,8 @@ export class BrowserNodeProvider {
|
|
|
307
389
|
// best-effort — relay liveness window GCs the session
|
|
308
390
|
}
|
|
309
391
|
this._sessionToken = "";
|
|
392
|
+
if (!this._stopRequested)
|
|
393
|
+
this.setRecovery("operator_action_needed", "operator_endpoint_needed");
|
|
310
394
|
}
|
|
311
395
|
async heartbeat() {
|
|
312
396
|
if (!this._nodeToken)
|
|
@@ -351,10 +435,12 @@ export class BrowserNodeProvider {
|
|
|
351
435
|
this.log(`keep-alive ✓ (model loaded${ok || fail ? `, +${ok} ok / +${fail} fail` : ""})`);
|
|
352
436
|
}
|
|
353
437
|
else {
|
|
438
|
+
this.setRecovery("route_mismatch", "reregister");
|
|
354
439
|
this.log(`keep-alive → HTTP ${resp.status}`);
|
|
355
440
|
}
|
|
356
441
|
}
|
|
357
442
|
catch {
|
|
443
|
+
this.setRecovery("route_mismatch", "reregister");
|
|
358
444
|
this.log("keep-alive failed (transient)");
|
|
359
445
|
}
|
|
360
446
|
}
|
|
@@ -376,6 +462,7 @@ export class BrowserNodeProvider {
|
|
|
376
462
|
continue; // idle window — poll again
|
|
377
463
|
if (resp.status === 401) {
|
|
378
464
|
// Session displaced or GC'd — stop serving rather than fight over the id.
|
|
465
|
+
this.setRecovery("operator_action_needed", "operator_endpoint_needed");
|
|
379
466
|
this.log("relay session expired — stopping");
|
|
380
467
|
await this.stop();
|
|
381
468
|
this.setState("error");
|
|
@@ -408,6 +495,7 @@ export class BrowserNodeProvider {
|
|
|
408
495
|
});
|
|
409
496
|
// OpenAI-shape choices — exactly what SDK consumers' chat() parses.
|
|
410
497
|
result = {
|
|
498
|
+
generated_by_ai: true,
|
|
411
499
|
result: {
|
|
412
500
|
choices: [
|
|
413
501
|
{
|
package/dist/iicpConsumer.d.ts
CHANGED
|
@@ -5,6 +5,21 @@ export interface ClientConfig {
|
|
|
5
5
|
directory_url?: string;
|
|
6
6
|
/** Per-request timeout (ms). Default 10000. */
|
|
7
7
|
timeout_ms?: number;
|
|
8
|
+
/**
|
|
9
|
+
* Optional fail-closed transfer/region policy. When set, discovery and
|
|
10
|
+
* dispatch keep only nodes whose directory-safe `region` is in this list.
|
|
11
|
+
* Empty/omitted means normal open-mesh routing.
|
|
12
|
+
*/
|
|
13
|
+
allowed_regions?: string[];
|
|
14
|
+
/**
|
|
15
|
+
* Optional fail-closed node-policy requirement. When set, discovery and
|
|
16
|
+
* dispatch keep only nodes whose directory-computed policy manifest identity
|
|
17
|
+
* level is at least this strong. This is a technical accountability signal,
|
|
18
|
+
* not legal/DPA compliance proof.
|
|
19
|
+
*/
|
|
20
|
+
required_manifest_identity_level?: RequiredManifestIdentityLevel;
|
|
21
|
+
/** Ticketed route migration mode. Default: auto. */
|
|
22
|
+
route_discovery_mode?: "auto" | "ticketed" | "legacy";
|
|
8
23
|
}
|
|
9
24
|
export interface DiscoverOptions {
|
|
10
25
|
region?: string;
|
|
@@ -13,7 +28,12 @@ export interface DiscoverOptions {
|
|
|
13
28
|
limit?: number;
|
|
14
29
|
/** Browser pages should keep only HTTPS/loopback endpoints. Default: true. */
|
|
15
30
|
browser_usable_only?: boolean;
|
|
31
|
+
/** Optional per-discovery strict region allowlist; overrides client default. */
|
|
32
|
+
allowed_regions?: string[];
|
|
33
|
+
/** Optional per-discovery policy-manifest identity requirement; overrides client default. */
|
|
34
|
+
required_manifest_identity_level?: RequiredManifestIdentityLevel;
|
|
16
35
|
}
|
|
36
|
+
export type RequiredManifestIdentityLevel = "signed_valid" | "operator_bound" | "known_operator";
|
|
17
37
|
/** A discoverable provider node (public discovery view — no tokens/endpoints private). */
|
|
18
38
|
export interface Node {
|
|
19
39
|
node_id: string;
|
|
@@ -26,18 +46,24 @@ export interface Node {
|
|
|
26
46
|
route_evidence?: string;
|
|
27
47
|
routing_hint?: string;
|
|
28
48
|
browser_usable?: boolean;
|
|
49
|
+
node_policy_manifest?: {
|
|
50
|
+
manifest_identity_level?: string | null;
|
|
51
|
+
verification?: {
|
|
52
|
+
status?: string | null;
|
|
53
|
+
};
|
|
54
|
+
} | null;
|
|
55
|
+
dispatch_ticket_id_prefix?: string;
|
|
29
56
|
[k: string]: unknown;
|
|
30
57
|
}
|
|
31
58
|
export interface ChatMessage {
|
|
32
59
|
role: "system" | "user" | "assistant";
|
|
33
60
|
content: string;
|
|
34
61
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
/** CIP consumer task envelope — extracted for testability (KAT, parity with @iicp/client). */
|
|
62
|
+
/**
|
|
63
|
+
* Compatibility helper for callers that need to inspect or test the canonical
|
|
64
|
+
* plaintext task shape. Production `chat()` remains fail-closed and sends only
|
|
65
|
+
* an encrypted `iicp_conf` envelope.
|
|
66
|
+
*/
|
|
41
67
|
export interface TaskEnvelope {
|
|
42
68
|
task_id: string;
|
|
43
69
|
intent: string;
|
|
@@ -47,28 +73,104 @@ export interface TaskEnvelope {
|
|
|
47
73
|
model?: string;
|
|
48
74
|
};
|
|
49
75
|
}
|
|
50
|
-
/**
|
|
51
|
-
* Build the CIP consumer task envelope. Pure, deterministic (pass task_id for KAT).
|
|
52
|
-
* Wire-protocol parity with the Node SDK: `POST {endpoint}/v1/task` when the caller
|
|
53
|
-
* deliberately builds a plaintext test envelope. Production chat() below is fail-closed
|
|
54
|
-
* and sends an `iicp_conf` envelope only.
|
|
55
|
-
*/
|
|
56
76
|
export declare function cipConsumerEnvelope(messages: ChatMessage[], opts?: {
|
|
57
77
|
intent?: string;
|
|
58
78
|
model?: string;
|
|
59
79
|
task_id?: string;
|
|
60
80
|
}): TaskEnvelope;
|
|
81
|
+
export interface RoutingReceipt {
|
|
82
|
+
receipt_version: "iicp-routing-receipt-v1";
|
|
83
|
+
trace_id: string;
|
|
84
|
+
task_id: string;
|
|
85
|
+
issued_at: string;
|
|
86
|
+
status: "attempted" | "ok";
|
|
87
|
+
intent: string;
|
|
88
|
+
generated_by_ai: true;
|
|
89
|
+
dispatch_ticket_id_prefix?: string;
|
|
90
|
+
selected_node: {
|
|
91
|
+
node_id_prefix?: string;
|
|
92
|
+
region?: string;
|
|
93
|
+
reputation_tier?: string;
|
|
94
|
+
reputation_score?: number;
|
|
95
|
+
model?: string;
|
|
96
|
+
};
|
|
97
|
+
transport: {
|
|
98
|
+
endpoint_kind: "https_public" | "https_tunnel" | "http_loopback" | "other" | "unknown";
|
|
99
|
+
relay_used: boolean;
|
|
100
|
+
};
|
|
101
|
+
policy: {
|
|
102
|
+
cx_required: true;
|
|
103
|
+
cx_key_status: "present";
|
|
104
|
+
cx_key_id?: string;
|
|
105
|
+
plaintext_allowed: false;
|
|
106
|
+
directory_prompt_free: true;
|
|
107
|
+
region_policy: "not_configured" | "allowed_regions_strict";
|
|
108
|
+
allowed_regions?: string[];
|
|
109
|
+
selected_region_allowed?: boolean;
|
|
110
|
+
region_policy_decision?: RegionPolicyDecision["reason"];
|
|
111
|
+
manifest_identity_policy: "not_configured" | "minimum_required";
|
|
112
|
+
required_manifest_identity_level?: RequiredManifestIdentityLevel;
|
|
113
|
+
selected_manifest_identity_level?: string;
|
|
114
|
+
};
|
|
115
|
+
redaction: {
|
|
116
|
+
prompt_content: "excluded";
|
|
117
|
+
response_content: "excluded";
|
|
118
|
+
node_token: "excluded";
|
|
119
|
+
endpoint_url: "not_recorded";
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export interface RegionPolicyDecision {
|
|
123
|
+
active: boolean;
|
|
124
|
+
allowed_regions?: string[];
|
|
125
|
+
selected_region?: string;
|
|
126
|
+
selected_region_allowed?: boolean;
|
|
127
|
+
reason: "not_configured" | "allowed" | "missing_region" | "region_not_allowed";
|
|
128
|
+
}
|
|
129
|
+
export interface ManifestIdentityPolicyDecision {
|
|
130
|
+
active: boolean;
|
|
131
|
+
required_level?: RequiredManifestIdentityLevel;
|
|
132
|
+
selected_level?: string;
|
|
133
|
+
selected_level_allowed?: boolean;
|
|
134
|
+
reason: "not_configured" | "allowed" | "missing_manifest" | "insufficient_level" | "revoked_or_rotated";
|
|
135
|
+
}
|
|
136
|
+
export declare class IicpError extends Error {
|
|
137
|
+
readonly code: string;
|
|
138
|
+
readonly status?: number | undefined;
|
|
139
|
+
constructor(message: string, code: string, status?: number | undefined);
|
|
140
|
+
}
|
|
61
141
|
/**
|
|
62
142
|
* Build the discover URL. Extracted so the query construction is unit-testable
|
|
63
143
|
* (same discipline as the federation event-log URL regression test).
|
|
64
144
|
*/
|
|
65
145
|
export declare function discoverUrl(directoryUrl: string, intent: string, opts?: DiscoverOptions): string;
|
|
146
|
+
export declare function normalizeAllowedRegions(regions?: readonly string[] | null): string[];
|
|
147
|
+
export declare function regionPolicyDecision(node: Node | undefined, allowedRegions?: readonly string[] | null): RegionPolicyDecision;
|
|
148
|
+
export declare function filterNodesByAllowedRegions(nodes: Node[], allowedRegions?: readonly string[] | null): Node[];
|
|
149
|
+
export declare function manifestIdentityPolicyDecision(node: Node | undefined, requiredLevel?: RequiredManifestIdentityLevel | null): ManifestIdentityPolicyDecision;
|
|
150
|
+
export declare function filterNodesByManifestIdentity(nodes: Node[], requiredLevel?: RequiredManifestIdentityLevel | null): Node[];
|
|
151
|
+
export declare function createRedactedRoutingReceipt(args: {
|
|
152
|
+
taskId: string;
|
|
153
|
+
intent: string;
|
|
154
|
+
endpoint?: string;
|
|
155
|
+
node?: Node;
|
|
156
|
+
model?: string;
|
|
157
|
+
cxPublicKey: CxPublicKey;
|
|
158
|
+
status?: RoutingReceipt["status"];
|
|
159
|
+
allowedRegions?: readonly string[] | null;
|
|
160
|
+
requiredManifestIdentityLevel?: RequiredManifestIdentityLevel | null;
|
|
161
|
+
}): RoutingReceipt;
|
|
66
162
|
/** Browser-native, consumer-only IICP client. */
|
|
67
163
|
export declare class IicpBrowserClient {
|
|
68
164
|
private readonly directory;
|
|
69
165
|
private readonly timeout;
|
|
166
|
+
private readonly allowedRegions;
|
|
167
|
+
private readonly requiredManifestIdentityLevel?;
|
|
168
|
+
private readonly routeDiscoveryMode;
|
|
70
169
|
constructor(cfg?: ClientConfig);
|
|
170
|
+
private effectiveAllowedRegions;
|
|
171
|
+
private effectiveManifestIdentityLevel;
|
|
71
172
|
private getJson;
|
|
173
|
+
private ticketedDiscover;
|
|
72
174
|
/** Discover nodes capable of an intent (GET /v1/discover — CORS-enabled on iicp.network). */
|
|
73
175
|
discover(intent: string, opts?: DiscoverOptions): Promise<Node[]>;
|
|
74
176
|
/** Directory mesh stats (GET /v1/stats), incl. mesh_health + active_nodes. */
|
|
@@ -85,11 +187,11 @@ export declare class IicpBrowserClient {
|
|
|
85
187
|
* (`transport_endpoint: iicp://…`) — more efficient, used by the full SDKs. A browser
|
|
86
188
|
* can't open raw TCP, so this client uses the HTTP transport (the discover `endpoint`).
|
|
87
189
|
*
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
190
|
+
* Privacy: IICP-CX is fail-closed in the browser too. A discovered node must
|
|
191
|
+
* advertise `cx_public_key` (or the temporary `public_key` alias) before this
|
|
192
|
+
* helper will send it a task.
|
|
193
|
+
*
|
|
194
|
+
* ⚠ Reachability: from an https:// page the browser reaches iicp.network (discover) but
|
|
93
195
|
* NOT http://localhost LLMs (mixed-content; Chrome 129+ flag only), nor a node without
|
|
94
196
|
* CORS, nor an IPv6-firewalled node (today's live nodes are `ipv6_direct_firewall_required`).
|
|
95
197
|
* Pass a reachable `endpoint`. In Node there is no CORS restriction. Returns the node JSON.
|
|
@@ -99,6 +201,25 @@ export declare class IicpBrowserClient {
|
|
|
99
201
|
intent?: string;
|
|
100
202
|
model?: string;
|
|
101
203
|
cxPublicKey?: CxPublicKey | null;
|
|
204
|
+
node?: Node;
|
|
205
|
+
allowed_regions?: string[];
|
|
206
|
+
required_manifest_identity_level?: RequiredManifestIdentityLevel | null;
|
|
102
207
|
}): Promise<Record<string, unknown>>;
|
|
208
|
+
/**
|
|
209
|
+
* Same dispatch as `chat()`, but returns a redacted routing receipt. The receipt
|
|
210
|
+
* deliberately excludes prompt text, response text, node tokens and endpoint URLs.
|
|
211
|
+
*/
|
|
212
|
+
chatWithReceipt(messages: ChatMessage[], opts: {
|
|
213
|
+
endpoint: string;
|
|
214
|
+
intent?: string;
|
|
215
|
+
model?: string;
|
|
216
|
+
cxPublicKey?: CxPublicKey | null;
|
|
217
|
+
node?: Node;
|
|
218
|
+
allowed_regions?: string[];
|
|
219
|
+
required_manifest_identity_level?: RequiredManifestIdentityLevel | null;
|
|
220
|
+
}): Promise<{
|
|
221
|
+
response: Record<string, unknown>;
|
|
222
|
+
receipt: RoutingReceipt;
|
|
223
|
+
}>;
|
|
103
224
|
}
|
|
104
225
|
export declare function maskTunnelUrl(url: string): string;
|
package/dist/iicpConsumer.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Browser-safe consumer used by @iicp/web-node and vendored by iicp.network.
|
|
3
|
+
// Keep both copies aligned when publishing this package.
|
|
2
4
|
//
|
|
3
|
-
//
|
|
5
|
+
// @iicp/web-node — browser-native IICP consumer module.
|
|
4
6
|
//
|
|
5
7
|
// A tiny (zero-runtime-dependency) TypeScript client for the IICP discovery mesh,
|
|
6
8
|
// built on the browser's native fetch / TextEncoder / SubtleCrypto so it embeds
|
|
@@ -14,9 +16,36 @@
|
|
|
14
16
|
//
|
|
15
17
|
// Epic: #446 · Dev: #447 · Research: research/wasm/WASM-1-feasibility.md (#292).
|
|
16
18
|
import { encryptPayload } from "./cxConfidentiality.js";
|
|
19
|
+
const REFUSED_INTENT_RULES = [
|
|
20
|
+
{ category: "prohibited", rule_id: "eu-ai-act-social-scoring", label: "social scoring", fragments: ["social-scoring", "social_scoring", "social:scoring"] },
|
|
21
|
+
{ category: "prohibited", rule_id: "eu-ai-act-criminal-risk", label: "individual criminal risk prediction", fragments: ["criminal-risk", "criminal_risk", "criminal:risk", "predict-crime"] },
|
|
22
|
+
{ category: "prohibited", rule_id: "eu-ai-act-workplace-education-emotion", label: "workplace or education emotion recognition", fragments: ["emotion:workplace", "emotion:education", "workplace-monitoring", "education-monitoring", "worker-monitoring"] },
|
|
23
|
+
{ category: "prohibited", rule_id: "eu-ai-act-protected-trait-biometric", label: "biometric protected-trait classification", fragments: ["protected-trait", "protected_trait", "biometric:protected"] },
|
|
24
|
+
{ category: "prohibited", rule_id: "eu-ai-act-untargeted-face-scraping", label: "untargeted facial image scraping", fragments: ["untargeted-scraping", "untargeted_scraping", "face-scraping", "facial-scraping"] },
|
|
25
|
+
{ category: "prohibited", rule_id: "eu-ai-act-realtime-remote-biometric-id", label: "real-time remote biometric identification", fragments: ["remote-biometric:realtime", "realtime-remote-biometric", "real-time-remote-biometric"] },
|
|
26
|
+
{ category: "prohibited", rule_id: "eu-ai-act-nonconsensual-sexual-deepfake", label: "non-consensual sexual deepfake or CSAM generation", fragments: ["nonconsensual-sexual", "non-consensual-sexual", "child-sexual-abuse", "csam"] },
|
|
27
|
+
{ category: "high_risk", rule_id: "eu-ai-act-employment-workforce", label: "employment or workforce decision", fragments: ["employment:hiring", "employment:screen", "employment:rank", "recruitment:decision", "workforce:decision", "worker-management", "worker:performance", "worker:discipline"] },
|
|
28
|
+
{ category: "high_risk", rule_id: "eu-ai-act-education-admission-grading", label: "education admission or grading decision", fragments: ["education:admission", "education:grading", "education:grade", "student:admission", "student:assess", "exam-grading"] },
|
|
29
|
+
{ category: "high_risk", rule_id: "eu-ai-act-credit-essential-services", label: "credit or essential-services decision", fragments: ["credit-scoring", "credit:score", "credit:decision", "essential-services", "benefits:eligibility", "public-benefit:eligibility"] },
|
|
30
|
+
{ category: "high_risk", rule_id: "eu-ai-act-law-enforcement-border-justice", label: "law enforcement, border, justice or democratic-process decision", fragments: ["law-enforcement", "law_enforcement", "migration:decision", "asylum:decision", "border-control", "justice:decision", "democratic-process", "election:decision"] },
|
|
31
|
+
{ category: "high_risk", rule_id: "eu-ai-act-healthcare-critical-infrastructure", label: "healthcare or critical-infrastructure safety decision", fragments: ["healthcare:decision", "medical:diagnosis", "medical:triage", "clinical:decision", "critical-infrastructure", "grid:stabilize", "hospital:surge-capacity"] },
|
|
32
|
+
{ category: "high_risk", rule_id: "eu-ai-act-physical-world-control", label: "physical-world control", fragments: ["robotics:control", "robotics:fleet", "drone:control", "drone:search", "iot:actuate", "physical-world", "system_control"] },
|
|
33
|
+
];
|
|
17
34
|
/** Intent URN shape — parity with @iicp/client (SDK-02). */
|
|
18
35
|
const INTENT_RE = /^urn:iicp:intent:[a-z0-9_:/-]+$/;
|
|
19
36
|
export const DEFAULT_DIRECTORY_URL = "https://iicp.network";
|
|
37
|
+
export function cipConsumerEnvelope(messages, opts = {}) {
|
|
38
|
+
const intent = opts.intent ?? "urn:iicp:intent:llm:chat:v1";
|
|
39
|
+
validateIntent(intent);
|
|
40
|
+
const taskId = opts.task_id
|
|
41
|
+
?? (globalThis.crypto?.randomUUID?.() ?? `task-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
42
|
+
return {
|
|
43
|
+
task_id: taskId,
|
|
44
|
+
intent,
|
|
45
|
+
constraints: {},
|
|
46
|
+
payload: { messages, model: opts.model },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
20
49
|
export class IicpError extends Error {
|
|
21
50
|
code;
|
|
22
51
|
status;
|
|
@@ -27,28 +56,17 @@ export class IicpError extends Error {
|
|
|
27
56
|
this.name = "IicpError";
|
|
28
57
|
}
|
|
29
58
|
}
|
|
59
|
+
class LegacyDiscoveryRequired extends Error {
|
|
60
|
+
}
|
|
30
61
|
function validateIntent(intent) {
|
|
31
62
|
if (!INTENT_RE.test(intent)) {
|
|
32
63
|
throw new IicpError(`invalid intent URN: ${intent}`, "invalid_intent");
|
|
33
64
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
* and sends an `iicp_conf` envelope only.
|
|
40
|
-
*/
|
|
41
|
-
export function cipConsumerEnvelope(messages, opts = {}) {
|
|
42
|
-
const intent = opts.intent ?? "urn:iicp:intent:llm:chat:v1";
|
|
43
|
-
validateIntent(intent);
|
|
44
|
-
const taskId = opts.task_id ??
|
|
45
|
-
(globalThis.crypto?.randomUUID?.() ?? `task-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
46
|
-
return {
|
|
47
|
-
task_id: taskId,
|
|
48
|
-
intent,
|
|
49
|
-
constraints: {},
|
|
50
|
-
payload: { messages, model: opts.model },
|
|
51
|
-
};
|
|
65
|
+
const normalized = intent.trim().toLowerCase();
|
|
66
|
+
const rule = REFUSED_INTENT_RULES.find((candidate) => candidate.fragments.some((fragment) => normalized.includes(fragment)));
|
|
67
|
+
if (rule) {
|
|
68
|
+
throw new IicpError(`intent refused by public-mesh policy: ${rule.label} (${rule.rule_id}) [${rule.category}]`, "intent_policy_refused");
|
|
69
|
+
}
|
|
52
70
|
}
|
|
53
71
|
/**
|
|
54
72
|
* Build the discover URL. Extracted so the query construction is unit-testable
|
|
@@ -80,13 +98,197 @@ function isBrowserUsableEndpoint(endpoint) {
|
|
|
80
98
|
return false;
|
|
81
99
|
}
|
|
82
100
|
}
|
|
101
|
+
function safeNodePrefix(nodeId) {
|
|
102
|
+
return typeof nodeId === "string" && nodeId.length > 0 ? `${nodeId.slice(0, 8)}…` : undefined;
|
|
103
|
+
}
|
|
104
|
+
function endpointKind(endpoint) {
|
|
105
|
+
if (!endpoint)
|
|
106
|
+
return "unknown";
|
|
107
|
+
try {
|
|
108
|
+
const url = new URL(endpoint);
|
|
109
|
+
const hostname = url.hostname.toLowerCase();
|
|
110
|
+
if (url.protocol === "https:" && TUNNEL_SUFFIXES.some((suffix) => hostname.endsWith(suffix)))
|
|
111
|
+
return "https_tunnel";
|
|
112
|
+
if (url.protocol === "https:")
|
|
113
|
+
return "https_public";
|
|
114
|
+
if (url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(hostname))
|
|
115
|
+
return "http_loopback";
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return "unknown";
|
|
119
|
+
}
|
|
120
|
+
return "other";
|
|
121
|
+
}
|
|
122
|
+
export function normalizeAllowedRegions(regions) {
|
|
123
|
+
if (!regions)
|
|
124
|
+
return [];
|
|
125
|
+
return Array.from(new Set(regions
|
|
126
|
+
.map((region) => region.trim().toLowerCase())
|
|
127
|
+
.filter((region) => region.length > 0))).sort();
|
|
128
|
+
}
|
|
129
|
+
function normalizeNodeRegion(node) {
|
|
130
|
+
const region = typeof node?.region === "string" ? node.region.trim().toLowerCase() : "";
|
|
131
|
+
return region.length > 0 ? region : undefined;
|
|
132
|
+
}
|
|
133
|
+
export function regionPolicyDecision(node, allowedRegions) {
|
|
134
|
+
const allowed = normalizeAllowedRegions(allowedRegions);
|
|
135
|
+
if (allowed.length === 0) {
|
|
136
|
+
return { active: false, reason: "not_configured" };
|
|
137
|
+
}
|
|
138
|
+
const selected = normalizeNodeRegion(node);
|
|
139
|
+
if (!selected) {
|
|
140
|
+
return {
|
|
141
|
+
active: true,
|
|
142
|
+
allowed_regions: allowed,
|
|
143
|
+
selected_region_allowed: false,
|
|
144
|
+
reason: "missing_region",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const ok = allowed.includes(selected);
|
|
148
|
+
return {
|
|
149
|
+
active: true,
|
|
150
|
+
allowed_regions: allowed,
|
|
151
|
+
selected_region: selected,
|
|
152
|
+
selected_region_allowed: ok,
|
|
153
|
+
reason: ok ? "allowed" : "region_not_allowed",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export function filterNodesByAllowedRegions(nodes, allowedRegions) {
|
|
157
|
+
const allowed = normalizeAllowedRegions(allowedRegions);
|
|
158
|
+
if (allowed.length === 0)
|
|
159
|
+
return nodes;
|
|
160
|
+
return nodes.filter((node) => regionPolicyDecision(node, allowed).selected_region_allowed === true);
|
|
161
|
+
}
|
|
162
|
+
const MANIFEST_IDENTITY_RANK = {
|
|
163
|
+
self_attested: 0,
|
|
164
|
+
signed_valid: 1,
|
|
165
|
+
operator_bound: 2,
|
|
166
|
+
known_operator: 3,
|
|
167
|
+
rotated: -1,
|
|
168
|
+
revoked: -1,
|
|
169
|
+
};
|
|
170
|
+
function manifestIdentityLevel(node) {
|
|
171
|
+
const level = node?.node_policy_manifest?.manifest_identity_level;
|
|
172
|
+
return typeof level === "string" && level.length > 0 ? level : undefined;
|
|
173
|
+
}
|
|
174
|
+
export function manifestIdentityPolicyDecision(node, requiredLevel) {
|
|
175
|
+
if (!requiredLevel) {
|
|
176
|
+
return { active: false, reason: "not_configured" };
|
|
177
|
+
}
|
|
178
|
+
const selected = manifestIdentityLevel(node);
|
|
179
|
+
if (!selected) {
|
|
180
|
+
return {
|
|
181
|
+
active: true,
|
|
182
|
+
required_level: requiredLevel,
|
|
183
|
+
selected_level_allowed: false,
|
|
184
|
+
reason: "missing_manifest",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (selected === "revoked" || selected === "rotated") {
|
|
188
|
+
return {
|
|
189
|
+
active: true,
|
|
190
|
+
required_level: requiredLevel,
|
|
191
|
+
selected_level: selected,
|
|
192
|
+
selected_level_allowed: false,
|
|
193
|
+
reason: "revoked_or_rotated",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const ok = (MANIFEST_IDENTITY_RANK[selected] ?? -1) >= MANIFEST_IDENTITY_RANK[requiredLevel];
|
|
197
|
+
return {
|
|
198
|
+
active: true,
|
|
199
|
+
required_level: requiredLevel,
|
|
200
|
+
selected_level: selected,
|
|
201
|
+
selected_level_allowed: ok,
|
|
202
|
+
reason: ok ? "allowed" : "insufficient_level",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
export function filterNodesByManifestIdentity(nodes, requiredLevel) {
|
|
206
|
+
if (!requiredLevel)
|
|
207
|
+
return nodes;
|
|
208
|
+
return nodes.filter((node) => manifestIdentityPolicyDecision(node, requiredLevel).selected_level_allowed === true);
|
|
209
|
+
}
|
|
210
|
+
function assertAllowedRegionForNode(node, allowedRegions) {
|
|
211
|
+
const decision = regionPolicyDecision(node, allowedRegions);
|
|
212
|
+
if (decision.active && decision.selected_region_allowed !== true) {
|
|
213
|
+
throw new IicpError(decision.reason === "missing_region"
|
|
214
|
+
? "IICP strict region policy refused node before task dispatch: node region is missing"
|
|
215
|
+
: `IICP strict region policy refused node before task dispatch: region ${decision.selected_region ?? "unknown"} is not allowed`, "region_not_allowed");
|
|
216
|
+
}
|
|
217
|
+
return decision;
|
|
218
|
+
}
|
|
219
|
+
function assertManifestIdentityForNode(node, requiredLevel) {
|
|
220
|
+
const decision = manifestIdentityPolicyDecision(node, requiredLevel);
|
|
221
|
+
if (decision.active && decision.selected_level_allowed !== true) {
|
|
222
|
+
throw new IicpError(decision.reason === "missing_manifest"
|
|
223
|
+
? `IICP strict policy refused node before task dispatch: missing policy manifest identity level ${requiredLevel}`
|
|
224
|
+
: `IICP strict policy refused node before task dispatch: manifest identity ${decision.selected_level ?? "unknown"} is below ${requiredLevel}`, "manifest_identity_not_allowed");
|
|
225
|
+
}
|
|
226
|
+
return decision;
|
|
227
|
+
}
|
|
228
|
+
export function createRedactedRoutingReceipt(args) {
|
|
229
|
+
const regionDecision = regionPolicyDecision(args.node, args.allowedRegions);
|
|
230
|
+
const manifestDecision = manifestIdentityPolicyDecision(args.node, args.requiredManifestIdentityLevel);
|
|
231
|
+
return {
|
|
232
|
+
receipt_version: "iicp-routing-receipt-v1",
|
|
233
|
+
trace_id: args.taskId,
|
|
234
|
+
task_id: args.taskId,
|
|
235
|
+
issued_at: new Date().toISOString(),
|
|
236
|
+
status: args.status ?? "attempted",
|
|
237
|
+
intent: args.intent,
|
|
238
|
+
generated_by_ai: true,
|
|
239
|
+
dispatch_ticket_id_prefix: args.node?.dispatch_ticket_id_prefix,
|
|
240
|
+
selected_node: {
|
|
241
|
+
node_id_prefix: safeNodePrefix(args.node?.node_id),
|
|
242
|
+
region: args.node?.region,
|
|
243
|
+
reputation_tier: typeof args.node?.reputation_tier === "string" ? args.node.reputation_tier : undefined,
|
|
244
|
+
reputation_score: typeof args.node?.reputation_score === "number" ? args.node.reputation_score : undefined,
|
|
245
|
+
model: args.model,
|
|
246
|
+
},
|
|
247
|
+
transport: {
|
|
248
|
+
endpoint_kind: endpointKind(args.endpoint),
|
|
249
|
+
relay_used: args.node?.relay_capable === true || args.node?.routing_hint === "relay",
|
|
250
|
+
},
|
|
251
|
+
policy: {
|
|
252
|
+
cx_required: true,
|
|
253
|
+
cx_key_status: "present",
|
|
254
|
+
cx_key_id: args.cxPublicKey.key_id,
|
|
255
|
+
plaintext_allowed: false,
|
|
256
|
+
directory_prompt_free: true,
|
|
257
|
+
region_policy: regionDecision.active ? "allowed_regions_strict" : "not_configured",
|
|
258
|
+
allowed_regions: regionDecision.allowed_regions,
|
|
259
|
+
selected_region_allowed: regionDecision.active ? regionDecision.selected_region_allowed : undefined,
|
|
260
|
+
region_policy_decision: regionDecision.active ? regionDecision.reason : undefined,
|
|
261
|
+
manifest_identity_policy: manifestDecision.active ? "minimum_required" : "not_configured",
|
|
262
|
+
required_manifest_identity_level: manifestDecision.required_level,
|
|
263
|
+
selected_manifest_identity_level: manifestDecision.selected_level,
|
|
264
|
+
},
|
|
265
|
+
redaction: {
|
|
266
|
+
prompt_content: "excluded",
|
|
267
|
+
response_content: "excluded",
|
|
268
|
+
node_token: "excluded",
|
|
269
|
+
endpoint_url: "not_recorded",
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
83
273
|
/** Browser-native, consumer-only IICP client. */
|
|
84
274
|
export class IicpBrowserClient {
|
|
85
275
|
directory;
|
|
86
276
|
timeout;
|
|
277
|
+
allowedRegions;
|
|
278
|
+
requiredManifestIdentityLevel;
|
|
279
|
+
routeDiscoveryMode;
|
|
87
280
|
constructor(cfg = {}) {
|
|
88
281
|
this.directory = (cfg.directory_url ?? DEFAULT_DIRECTORY_URL).replace(/\/+$/, "");
|
|
89
282
|
this.timeout = cfg.timeout_ms ?? 10_000;
|
|
283
|
+
this.allowedRegions = normalizeAllowedRegions(cfg.allowed_regions);
|
|
284
|
+
this.requiredManifestIdentityLevel = cfg.required_manifest_identity_level;
|
|
285
|
+
this.routeDiscoveryMode = cfg.route_discovery_mode ?? "auto";
|
|
286
|
+
}
|
|
287
|
+
effectiveAllowedRegions(override) {
|
|
288
|
+
return override !== undefined ? normalizeAllowedRegions(override) : this.allowedRegions;
|
|
289
|
+
}
|
|
290
|
+
effectiveManifestIdentityLevel(override) {
|
|
291
|
+
return override !== undefined ? (override ?? undefined) : this.requiredManifestIdentityLevel;
|
|
90
292
|
}
|
|
91
293
|
async getJson(url) {
|
|
92
294
|
const ctrl = new AbortController();
|
|
@@ -102,18 +304,87 @@ export class IicpBrowserClient {
|
|
|
102
304
|
clearTimeout(t);
|
|
103
305
|
}
|
|
104
306
|
}
|
|
307
|
+
async ticketedDiscover(intent, opts) {
|
|
308
|
+
const url = `${this.directory}/api/v1/dispatch/ticket`;
|
|
309
|
+
const excluded = [];
|
|
310
|
+
const nodes = [];
|
|
311
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 4, 10));
|
|
312
|
+
for (let attempt = 0; attempt < limit; attempt += 1) {
|
|
313
|
+
const ctrl = new AbortController();
|
|
314
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeout);
|
|
315
|
+
let response;
|
|
316
|
+
try {
|
|
317
|
+
response = await fetch(url, {
|
|
318
|
+
method: "POST",
|
|
319
|
+
signal: ctrl.signal,
|
|
320
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
321
|
+
body: JSON.stringify({
|
|
322
|
+
intent,
|
|
323
|
+
region: opts.region,
|
|
324
|
+
min_reputation: opts.min_reputation,
|
|
325
|
+
limit,
|
|
326
|
+
exclude_node_id_prefixes: excluded,
|
|
327
|
+
}),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
clearTimeout(timer);
|
|
332
|
+
}
|
|
333
|
+
const body = await response.json().catch(() => ({}));
|
|
334
|
+
const error = body.error;
|
|
335
|
+
const errorCode = typeof error?.code === "string" ? error.code : undefined;
|
|
336
|
+
if (response.status === 201) {
|
|
337
|
+
const route = body.route;
|
|
338
|
+
if (!route || typeof body.node_id !== "string") {
|
|
339
|
+
throw new IicpError("ticketed route response is malformed", "ticket_malformed");
|
|
340
|
+
}
|
|
341
|
+
const node = {
|
|
342
|
+
...route,
|
|
343
|
+
node_id: body.node_id,
|
|
344
|
+
dispatch_ticket_id_prefix: typeof body.ticket_id_prefix === "string" ? body.ticket_id_prefix : undefined,
|
|
345
|
+
};
|
|
346
|
+
nodes.push(node);
|
|
347
|
+
excluded.push(body.node_id.slice(0, 8));
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (response.status === 404 && errorCode === "no_route_available")
|
|
351
|
+
break;
|
|
352
|
+
if ([404, 405, 501].includes(response.status) || (response.status === 503 && errorCode === "not_configured")) {
|
|
353
|
+
throw new LegacyDiscoveryRequired();
|
|
354
|
+
}
|
|
355
|
+
throw new IicpError(`ticketed route request refused (${errorCode ?? response.status})`, "ticket_refused", response.status);
|
|
356
|
+
}
|
|
357
|
+
return nodes;
|
|
358
|
+
}
|
|
105
359
|
/** Discover nodes capable of an intent (GET /v1/discover — CORS-enabled on iicp.network). */
|
|
106
360
|
async discover(intent, opts = {}) {
|
|
107
361
|
validateIntent(intent);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
362
|
+
let nodes;
|
|
363
|
+
if (this.routeDiscoveryMode === "legacy") {
|
|
364
|
+
const body = await this.getJson(discoverUrl(this.directory, intent, opts));
|
|
365
|
+
nodes = Array.isArray(body) ? body : (body.nodes ?? []);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
try {
|
|
369
|
+
nodes = await this.ticketedDiscover(intent, opts);
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
if (!(error instanceof LegacyDiscoveryRequired))
|
|
373
|
+
throw error;
|
|
374
|
+
if (this.routeDiscoveryMode === "ticketed") {
|
|
375
|
+
throw new IicpError("directory does not support ticketed dispatch", "ticket_unavailable");
|
|
376
|
+
}
|
|
377
|
+
const body = await this.getJson(discoverUrl(this.directory, intent, opts));
|
|
378
|
+
nodes = Array.isArray(body) ? body : (body.nodes ?? []);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const browserUsableNodes = opts.browser_usable_only === false ? nodes : nodes.filter((node) => {
|
|
113
382
|
if (typeof node.browser_usable === "boolean")
|
|
114
383
|
return node.browser_usable;
|
|
115
384
|
return isBrowserUsableEndpoint(String(node.endpoint ?? ""));
|
|
116
385
|
});
|
|
386
|
+
const regionFiltered = filterNodesByAllowedRegions(browserUsableNodes, this.effectiveAllowedRegions(opts.allowed_regions));
|
|
387
|
+
return filterNodesByManifestIdentity(regionFiltered, this.effectiveManifestIdentityLevel(opts.required_manifest_identity_level));
|
|
117
388
|
}
|
|
118
389
|
/** Directory mesh stats (GET /v1/stats), incl. mesh_health + active_nodes. */
|
|
119
390
|
async stats() {
|
|
@@ -133,16 +404,24 @@ export class IicpBrowserClient {
|
|
|
133
404
|
* (`transport_endpoint: iicp://…`) — more efficient, used by the full SDKs. A browser
|
|
134
405
|
* can't open raw TCP, so this client uses the HTTP transport (the discover `endpoint`).
|
|
135
406
|
*
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
407
|
+
* Privacy: IICP-CX is fail-closed in the browser too. A discovered node must
|
|
408
|
+
* advertise `cx_public_key` (or the temporary `public_key` alias) before this
|
|
409
|
+
* helper will send it a task.
|
|
410
|
+
*
|
|
411
|
+
* ⚠ Reachability: from an https:// page the browser reaches iicp.network (discover) but
|
|
141
412
|
* NOT http://localhost LLMs (mixed-content; Chrome 129+ flag only), nor a node without
|
|
142
413
|
* CORS, nor an IPv6-firewalled node (today's live nodes are `ipv6_direct_firewall_required`).
|
|
143
414
|
* Pass a reachable `endpoint`. In Node there is no CORS restriction. Returns the node JSON.
|
|
144
415
|
*/
|
|
145
416
|
async chat(messages, opts) {
|
|
417
|
+
const routed = await this.chatWithReceipt(messages, opts);
|
|
418
|
+
return routed.response;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Same dispatch as `chat()`, but returns a redacted routing receipt. The receipt
|
|
422
|
+
* deliberately excludes prompt text, response text, node tokens and endpoint URLs.
|
|
423
|
+
*/
|
|
424
|
+
async chatWithReceipt(messages, opts) {
|
|
146
425
|
const intent = opts.intent ?? "urn:iicp:intent:llm:chat:v1";
|
|
147
426
|
validateIntent(intent);
|
|
148
427
|
const taskId = globalThis.crypto?.randomUUID?.() ?? `task-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
@@ -152,6 +431,21 @@ export class IicpBrowserClient {
|
|
|
152
431
|
if (!opts.cxPublicKey) {
|
|
153
432
|
throw new IicpError("IICP-CX confidentiality required: node advertises no cx_public_key/public_key", "cx_required");
|
|
154
433
|
}
|
|
434
|
+
const allowedRegions = this.effectiveAllowedRegions(opts.allowed_regions);
|
|
435
|
+
const requiredManifestIdentityLevel = this.effectiveManifestIdentityLevel(opts.required_manifest_identity_level);
|
|
436
|
+
assertAllowedRegionForNode(opts.node, allowedRegions);
|
|
437
|
+
assertManifestIdentityForNode(opts.node, requiredManifestIdentityLevel);
|
|
438
|
+
const receipt = createRedactedRoutingReceipt({
|
|
439
|
+
taskId,
|
|
440
|
+
intent,
|
|
441
|
+
endpoint: opts.endpoint,
|
|
442
|
+
node: opts.node,
|
|
443
|
+
model: opts.model,
|
|
444
|
+
cxPublicKey: opts.cxPublicKey,
|
|
445
|
+
status: "attempted",
|
|
446
|
+
allowedRegions,
|
|
447
|
+
requiredManifestIdentityLevel,
|
|
448
|
+
});
|
|
155
449
|
const body = {
|
|
156
450
|
task_id: taskId,
|
|
157
451
|
intent,
|
|
@@ -170,7 +464,10 @@ export class IicpBrowserClient {
|
|
|
170
464
|
if (!resp.ok) {
|
|
171
465
|
throw new IicpError(`task → ${resp.status}`, "node_error", resp.status);
|
|
172
466
|
}
|
|
173
|
-
return
|
|
467
|
+
return {
|
|
468
|
+
response: (await resp.json()),
|
|
469
|
+
receipt: { ...receipt, status: "ok", issued_at: new Date().toISOString() },
|
|
470
|
+
};
|
|
174
471
|
}
|
|
175
472
|
finally {
|
|
176
473
|
clearTimeout(t);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iicp/web-node",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Browser-native IICP node (consume + serve): discovery-mesh client with mandatory E2E encryption (IICP-CX) + WebLLM provider. Zero-config, ESM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|