@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 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
- Discovery (`GET /api/v1/discover`) works from any `https://` page because `iicp.network`
61
- sends CORS headers. Routing a task to a discovered node is subject to the browser's CORS
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 declare const BROWSER_NODE_SDK_VERSION = "0.7.71-browser";
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
- /** Register → bind → start serving. Throws BrowserProviderError on failure. */
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.71-browser";
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/discover?intent=${CHAT_INTENT}&relay_capable=true&limit=50`);
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 nodes = data?.nodes ?? [];
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
- /** Register → bind → start serving. Throws BrowserProviderError on failure. */
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
- // 1. Bind to the relay FIRST — if no relay is reachable there is no point
180
- // holding a directory registration that consumers can't route to.
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
- // 2. Register with the directory, advertising the path-scoped relay endpoint.
206
- const endpoint = `${this.relayBase}/v1/relay-for/${this.nodeId}`;
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: "webllm",
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
- // 3. Heartbeat (only when directory-listed) + poll loop. Fire one
258
- // immediately so the directory sees us as available without waiting a
259
- // full interval, then keep alive every 30 s while the tab is open and the
260
- // model stays loaded.
261
- if (this.directoryListed) {
262
- void this.heartbeat();
263
- this._heartbeatTimer = setInterval(() => void this.heartbeat(), HEARTBEAT_MS);
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
- this.setState("serving");
266
- void this.pollLoop();
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
- if (this._nodeToken) {
277
- try {
278
- await fetch(`${this.directoryBase}/v1/register`, {
279
- method: "DELETE",
280
- headers: { Authorization: `Bearer ${this._nodeToken}` },
281
- keepalive: true,
282
- });
283
- this.log("deregistered from directory");
284
- }
285
- catch {
286
- // best-effort — heartbeat expiry cleans up server-side
287
- }
288
- this._nodeToken = "";
289
- }
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
  {
@@ -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
- export declare class IicpError extends Error {
36
- readonly code: string;
37
- readonly status?: number | undefined;
38
- constructor(message: string, code: string, status?: number | undefined);
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
- * Privacy: IICP-CX is fail-closed in the browser too. A discovered node must
89
- * advertise `cx_public_key` (or the temporary `public_key` alias) before this
90
- * helper will send it a task.
91
- *
92
- * ⚠ Reachability: from an https:// page the browser reaches iicp.network (discover) but
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;
@@ -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
- // iicpConsumer — the CONSUMER module of @iicp/web-node (discover + encrypted submit).
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
- * Build the CIP consumer task envelope. Pure, deterministic (pass task_id for KAT).
37
- * Wire-protocol parity with the Node SDK: `POST {endpoint}/v1/task` when the caller
38
- * deliberately builds a plaintext test envelope. Production chat() below is fail-closed
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
- const body = await this.getJson(discoverUrl(this.directory, intent, opts));
109
- const nodes = Array.isArray(body) ? body : (body.nodes ?? []);
110
- if (opts.browser_usable_only === false)
111
- return nodes;
112
- return nodes.filter((node) => {
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
- * Privacy: IICP-CX is fail-closed in the browser too. A discovered node must
137
- * advertise `cx_public_key` (or the temporary `public_key` alias) before this
138
- * helper will send it a task.
139
- *
140
- * ⚠ Reachability: from an https:// page the browser reaches iicp.network (discover) but
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 (await resp.json());
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.0",
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",