@iicp/web-node 0.1.0 → 0.2.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/README.md +12 -2
- package/dist/browserNodeProvider.d.ts +15 -1
- package/dist/browserNodeProvider.js +42 -4
- 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,11 @@ 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
|
+
Provider state includes deterministic recovery diagnostics (`stable`, `tunnel_starting`,
|
|
51
|
+
`route_mismatch`, `operator_action_needed`, or `unavailable`) so a host page can explain
|
|
52
|
+
whether it is directory-listed and relay-bound. Browser lifetime still matters: closing or
|
|
53
|
+
suspending the tab stops the in-browser provider.
|
|
54
|
+
|
|
45
55
|
Serving requires WebLLM — add it alongside this package:
|
|
46
56
|
|
|
47
57
|
```
|
|
@@ -57,8 +67,8 @@ TypeScript, and Rust IICP clients and the provider/adapter that decrypts it.
|
|
|
57
67
|
|
|
58
68
|
## CORS reality
|
|
59
69
|
|
|
60
|
-
|
|
61
|
-
sends CORS headers. Routing a task to a
|
|
70
|
+
Public discovery and ticketed dispatch work from approved browser origins because
|
|
71
|
+
`iicp.network` sends CORS headers. Routing a task to a selected node is subject to the browser's CORS
|
|
62
72
|
and mixed-content policy: an `https://` page cannot reach an `http://localhost` model, a
|
|
63
73
|
node without CORS headers, or an IPv6-firewalled node. In **Node** there is no CORS
|
|
64
74
|
restriction and everything works.
|
|
@@ -17,9 +17,19 @@ export interface BrowserProviderConfig {
|
|
|
17
17
|
/** Called after each served task with the running total. */
|
|
18
18
|
onTaskServed?: (total: number) => void;
|
|
19
19
|
onStateChange?: (state: BrowserProviderState) => void;
|
|
20
|
+
onRecoveryChange?: (diagnostic: BrowserProviderDiagnostic) => void;
|
|
20
21
|
}
|
|
21
22
|
export type BrowserProviderState = "stopped" | "starting" | "serving" | "error";
|
|
22
|
-
export
|
|
23
|
+
export type BrowserProviderRecoveryState = "stable" | "tunnel_starting" | "route_mismatch" | "operator_action_needed" | "unavailable";
|
|
24
|
+
export type BrowserProviderRecoveryAction = "none" | "reregister" | "wait_cooldown" | "operator_endpoint_needed";
|
|
25
|
+
export interface BrowserProviderDiagnostic {
|
|
26
|
+
recovery_state: BrowserProviderRecoveryState;
|
|
27
|
+
recovery_action: BrowserProviderRecoveryAction;
|
|
28
|
+
directory_listed: boolean;
|
|
29
|
+
relay_bound: boolean;
|
|
30
|
+
tasks_served: number;
|
|
31
|
+
}
|
|
32
|
+
export declare const BROWSER_NODE_SDK_VERSION = "0.7.84-browser";
|
|
23
33
|
/**
|
|
24
34
|
* Coarse region autodetect from the browser's timezone (no network, no
|
|
25
35
|
* geolocation permission). Matches the mesh's region convention
|
|
@@ -53,15 +63,19 @@ export declare class BrowserNodeProvider {
|
|
|
53
63
|
private readonly _cx;
|
|
54
64
|
private _heartbeatTimer;
|
|
55
65
|
private _stopRequested;
|
|
66
|
+
private _recoveryState;
|
|
67
|
+
private _recoveryAction;
|
|
56
68
|
/** True when the directory accepted the registration (mesh-discoverable). */
|
|
57
69
|
directoryListed: boolean;
|
|
58
70
|
constructor(runtime: BrowserProviderRuntime, cfg: BrowserProviderConfig);
|
|
59
71
|
get state(): BrowserProviderState;
|
|
60
72
|
get tasksServed(): number;
|
|
73
|
+
get diagnostic(): BrowserProviderDiagnostic;
|
|
61
74
|
private get directoryBase();
|
|
62
75
|
private get relayBase();
|
|
63
76
|
private log;
|
|
64
77
|
private setState;
|
|
78
|
+
private setRecovery;
|
|
65
79
|
/** Register → bind → start serving. Throws BrowserProviderError on failure. */
|
|
66
80
|
start(): Promise<void>;
|
|
67
81
|
/** Unbind from the relay and deregister from the directory. */
|
|
@@ -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.84-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,12 +188,18 @@ export class BrowserNodeProvider {
|
|
|
170
188
|
this._state = s;
|
|
171
189
|
this.cfg.onStateChange?.(s);
|
|
172
190
|
}
|
|
191
|
+
setRecovery(state, action) {
|
|
192
|
+
this._recoveryState = state;
|
|
193
|
+
this._recoveryAction = action;
|
|
194
|
+
this.cfg.onRecoveryChange?.(this.diagnostic);
|
|
195
|
+
}
|
|
173
196
|
/** Register → bind → start serving. Throws BrowserProviderError on failure. */
|
|
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");
|
|
202
|
+
this.setRecovery("tunnel_starting", "wait_cooldown");
|
|
179
203
|
// 1. Bind to the relay FIRST — if no relay is reachable there is no point
|
|
180
204
|
// holding a directory registration that consumers can't route to.
|
|
181
205
|
let bindResp;
|
|
@@ -191,6 +215,7 @@ export class BrowserNodeProvider {
|
|
|
191
215
|
});
|
|
192
216
|
}
|
|
193
217
|
catch (err) {
|
|
218
|
+
this.setRecovery("operator_action_needed", "operator_endpoint_needed");
|
|
194
219
|
this.setState("error");
|
|
195
220
|
throw new BrowserProviderError(`relay unreachable at ${maskTunnelUrl(this.relayBase)}: ${err instanceof Error ? err.message : String(err)}`, "bind");
|
|
196
221
|
}
|
|
@@ -201,6 +226,7 @@ export class BrowserNodeProvider {
|
|
|
201
226
|
}
|
|
202
227
|
const bind = await bindResp.json();
|
|
203
228
|
this._sessionToken = bind.session_token;
|
|
229
|
+
this.setRecovery("route_mismatch", "reregister");
|
|
204
230
|
this.log(`relay bound — worker ${this.nodeId}`);
|
|
205
231
|
// 2. Register with the directory, advertising the path-scoped relay endpoint.
|
|
206
232
|
const endpoint = `${this.relayBase}/v1/relay-for/${this.nodeId}`;
|
|
@@ -229,7 +255,11 @@ export class BrowserNodeProvider {
|
|
|
229
255
|
},
|
|
230
256
|
sdk_language: "browser",
|
|
231
257
|
sdk_version: BROWSER_NODE_SDK_VERSION,
|
|
232
|
-
backend
|
|
258
|
+
// The current directory contract accepts native backend identifiers
|
|
259
|
+
// plus "custom". Browser/WebLLM providers are non-native providers,
|
|
260
|
+
// so advertise them as custom until the protocol taxonomy grows a
|
|
261
|
+
// dedicated browser backend token.
|
|
262
|
+
backend: "custom",
|
|
233
263
|
// IICP-CX S.16: browser providers are privacy-ready too. The relay
|
|
234
264
|
// still sees metadata, but not the task payload.
|
|
235
265
|
cx_public_key: this._cx.publicKey,
|
|
@@ -244,6 +274,7 @@ export class BrowserNodeProvider {
|
|
|
244
274
|
if (!this._nodeToken)
|
|
245
275
|
throw new Error("directory returned no node_token");
|
|
246
276
|
this.directoryListed = true;
|
|
277
|
+
this.setRecovery("stable", "none");
|
|
247
278
|
this.log(`registered with directory as ${this.nodeId}`);
|
|
248
279
|
}
|
|
249
280
|
catch (err) {
|
|
@@ -252,6 +283,7 @@ export class BrowserNodeProvider {
|
|
|
252
283
|
// node is not mesh-discoverable, but consumers that know the relay
|
|
253
284
|
// endpoint can still dispatch — keep serving and say so plainly.
|
|
254
285
|
this.directoryListed = false;
|
|
286
|
+
this.setRecovery("route_mismatch", "reregister");
|
|
255
287
|
this.log(`directory registration rejected — serving relay-only (not discoverable): ${err instanceof Error ? err.message : String(err)}`);
|
|
256
288
|
}
|
|
257
289
|
// 3. Heartbeat (only when directory-listed) + poll loop. Fire one
|
|
@@ -307,6 +339,8 @@ export class BrowserNodeProvider {
|
|
|
307
339
|
// best-effort — relay liveness window GCs the session
|
|
308
340
|
}
|
|
309
341
|
this._sessionToken = "";
|
|
342
|
+
if (!this._stopRequested)
|
|
343
|
+
this.setRecovery("operator_action_needed", "operator_endpoint_needed");
|
|
310
344
|
}
|
|
311
345
|
async heartbeat() {
|
|
312
346
|
if (!this._nodeToken)
|
|
@@ -351,10 +385,12 @@ export class BrowserNodeProvider {
|
|
|
351
385
|
this.log(`keep-alive ✓ (model loaded${ok || fail ? `, +${ok} ok / +${fail} fail` : ""})`);
|
|
352
386
|
}
|
|
353
387
|
else {
|
|
388
|
+
this.setRecovery("route_mismatch", "reregister");
|
|
354
389
|
this.log(`keep-alive → HTTP ${resp.status}`);
|
|
355
390
|
}
|
|
356
391
|
}
|
|
357
392
|
catch {
|
|
393
|
+
this.setRecovery("route_mismatch", "reregister");
|
|
358
394
|
this.log("keep-alive failed (transient)");
|
|
359
395
|
}
|
|
360
396
|
}
|
|
@@ -376,6 +412,7 @@ export class BrowserNodeProvider {
|
|
|
376
412
|
continue; // idle window — poll again
|
|
377
413
|
if (resp.status === 401) {
|
|
378
414
|
// Session displaced or GC'd — stop serving rather than fight over the id.
|
|
415
|
+
this.setRecovery("operator_action_needed", "operator_endpoint_needed");
|
|
379
416
|
this.log("relay session expired — stopping");
|
|
380
417
|
await this.stop();
|
|
381
418
|
this.setState("error");
|
|
@@ -408,6 +445,7 @@ export class BrowserNodeProvider {
|
|
|
408
445
|
});
|
|
409
446
|
// OpenAI-shape choices — exactly what SDK consumers' chat() parses.
|
|
410
447
|
result = {
|
|
448
|
+
generated_by_ai: true,
|
|
411
449
|
result: {
|
|
412
450
|
choices: [
|
|
413
451
|
{
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|