@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 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
- 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
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 declare const BROWSER_NODE_SDK_VERSION = "0.7.71-browser";
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.71-browser";
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/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,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: "webllm",
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
  {
@@ -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.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",