@iicp/web-node 0.2.0 → 0.2.2

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
@@ -47,6 +47,12 @@ Browser providers advertise a generated `cx_public_key`, decrypt incoming `iicp_
47
47
  payloads locally, report task success/failure/latency in heartbeats, and use a relay path
48
48
  because browser tabs cannot accept raw inbound TCP.
49
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
+
50
56
  Provider state includes deterministic recovery diagnostics (`stable`, `tunnel_starting`,
51
57
  `route_mismatch`, `operator_action_needed`, or `unavailable`) so a host page can explain
52
58
  whether it is directory-listed and relay-bound. Browser lifetime still matters: closing or
@@ -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). */
@@ -29,7 +31,7 @@ export interface BrowserProviderDiagnostic {
29
31
  relay_bound: boolean;
30
32
  tasks_served: number;
31
33
  }
32
- export declare const BROWSER_NODE_SDK_VERSION = "0.7.84-browser";
34
+ export declare const BROWSER_NODE_SDK_VERSION = "0.7.86-browser";
33
35
  /**
34
36
  * Coarse region autodetect from the browser's timezone (no network, no
35
37
  * geolocation permission). Matches the mesh's region convention
@@ -76,10 +78,13 @@ export declare class BrowserNodeProvider {
76
78
  private log;
77
79
  private setState;
78
80
  private setRecovery;
79
- /** Register → bind → start serving. Throws BrowserProviderError on failure. */
81
+ /** Register → obtain bind ticket bind serve. */
80
82
  start(): Promise<void>;
83
+ private register;
84
+ private fetchBindTicket;
81
85
  /** Unbind from the relay and deregister from the directory. */
82
86
  stop(): Promise<void>;
87
+ private deregisterQuietly;
83
88
  private unbindQuietly;
84
89
  private heartbeat;
85
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.84-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
@@ -193,15 +193,25 @@ export class BrowserNodeProvider {
193
193
  this._recoveryAction = action;
194
194
  this.cfg.onRecoveryChange?.(this.diagnostic);
195
195
  }
196
- /** Register → bind → start serving. Throws BrowserProviderError on failure. */
196
+ /** Register → obtain bind ticket bind serve. */
197
197
  async start() {
198
198
  if (this._state === "serving" || this._state === "starting")
199
199
  return;
200
200
  this._stopRequested = false;
201
201
  this.setState("starting");
202
202
  this.setRecovery("tunnel_starting", "wait_cooldown");
203
- // 1. Bind to the relay FIRST — if no relay is reachable there is no point
204
- // holding a directory registration that consumers can't route to.
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.
205
215
  let bindResp;
206
216
  try {
207
217
  bindResp = await fetch(`${this.relayBase}/v1/relay/bind`, {
@@ -211,25 +221,37 @@ export class BrowserNodeProvider {
211
221
  worker_id: this.nodeId,
212
222
  intent: CHAT_INTENT,
213
223
  models: [this.cfg.model],
224
+ ...(bindTicket ? { bind_ticket: bindTicket } : {}),
214
225
  }),
215
226
  });
216
227
  }
217
228
  catch (err) {
218
229
  this.setRecovery("operator_action_needed", "operator_endpoint_needed");
219
230
  this.setState("error");
231
+ await this.deregisterQuietly();
220
232
  throw new BrowserProviderError(`relay unreachable at ${maskTunnelUrl(this.relayBase)}: ${err instanceof Error ? err.message : String(err)}`, "bind");
221
233
  }
222
234
  if (!bindResp.ok) {
223
235
  this.setState("error");
224
236
  const detail = await bindResp.text().catch(() => "");
237
+ await this.deregisterQuietly();
225
238
  throw new BrowserProviderError(`relay bind failed: HTTP ${bindResp.status} ${detail.slice(0, 200)}`, "bind");
226
239
  }
227
240
  const bind = await bindResp.json();
228
241
  this._sessionToken = bind.session_token;
229
242
  this.setRecovery("route_mismatch", "reregister");
230
243
  this.log(`relay bound — worker ${this.nodeId}`);
231
- // 2. Register with the directory, advertising the path-scoped relay endpoint.
232
- 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) {
233
255
  try {
234
256
  const regResp = await fetch(`${this.directoryBase}/v1/register`, {
235
257
  method: "POST",
@@ -274,7 +296,6 @@ export class BrowserNodeProvider {
274
296
  if (!this._nodeToken)
275
297
  throw new Error("directory returned no node_token");
276
298
  this.directoryListed = true;
277
- this.setRecovery("stable", "none");
278
299
  this.log(`registered with directory as ${this.nodeId}`);
279
300
  }
280
301
  catch (err) {
@@ -286,16 +307,45 @@ export class BrowserNodeProvider {
286
307
  this.setRecovery("route_mismatch", "reregister");
287
308
  this.log(`directory registration rejected — serving relay-only (not discoverable): ${err instanceof Error ? err.message : String(err)}`);
288
309
  }
289
- // 3. Heartbeat (only when directory-listed) + poll loop. Fire one
290
- // immediately so the directory sees us as available without waiting a
291
- // full interval, then keep alive every 30 s while the tab is open and the
292
- // model stays loaded.
293
- if (this.directoryListed) {
294
- void this.heartbeat();
295
- 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
+ // Opaque node tokens need an explicit subject hint; JWT-backed
320
+ // clients can be resolved from their claims, but browser providers
321
+ // intentionally use the portable opaque registration credential.
322
+ "X-Node-Id": this.nodeId,
323
+ },
324
+ body: JSON.stringify({ relay_node_id: this.cfg.relayNodeId ?? "*" }),
325
+ });
296
326
  }
297
- this.setState("serving");
298
- void this.pollLoop();
327
+ catch (err) {
328
+ await this.deregisterQuietly();
329
+ this.setState("error");
330
+ throw new BrowserProviderError(`relay bind ticket request failed: ${err instanceof Error ? err.message : String(err)}`, "bind");
331
+ }
332
+ if (resp.ok) {
333
+ const body = await resp.json();
334
+ if (typeof body.ticket !== "string" || !body.ticket) {
335
+ await this.deregisterQuietly();
336
+ throw new BrowserProviderError("directory returned no relay bind ticket", "bind");
337
+ }
338
+ return body.ticket;
339
+ }
340
+ const detail = await resp.text().catch(() => "");
341
+ const legacy = resp.status === 404 || (resp.status === 503 && detail.includes("not_configured"));
342
+ if (legacy) {
343
+ this.log("directory has no relay bind-ticket service — trying legacy soft bind");
344
+ return "";
345
+ }
346
+ await this.deregisterQuietly();
347
+ this.setState("error");
348
+ throw new BrowserProviderError(`relay bind ticket refused: HTTP ${resp.status} ${detail.slice(0, 200)}`, "bind");
299
349
  }
300
350
  /** Unbind from the relay and deregister from the directory. */
301
351
  async stop() {
@@ -305,22 +355,30 @@ export class BrowserNodeProvider {
305
355
  this._heartbeatTimer = null;
306
356
  }
307
357
  await this.unbindQuietly();
308
- if (this._nodeToken) {
309
- try {
310
- await fetch(`${this.directoryBase}/v1/register`, {
311
- method: "DELETE",
312
- headers: { Authorization: `Bearer ${this._nodeToken}` },
313
- keepalive: true,
314
- });
315
- this.log("deregistered from directory");
316
- }
317
- catch {
318
- // best-effort — heartbeat expiry cleans up server-side
319
- }
320
- this._nodeToken = "";
321
- }
358
+ await this.deregisterQuietly();
322
359
  this.setState("stopped");
323
360
  }
361
+ async deregisterQuietly() {
362
+ if (!this._nodeToken)
363
+ return;
364
+ try {
365
+ await fetch(`${this.directoryBase}/v1/register`, {
366
+ method: "DELETE",
367
+ headers: {
368
+ Authorization: `Bearer ${this._nodeToken}`,
369
+ // Opaque registration credentials carry no subject claim.
370
+ "X-Node-Id": this.nodeId,
371
+ },
372
+ keepalive: true,
373
+ });
374
+ this.log("deregistered from directory");
375
+ }
376
+ catch {
377
+ // best-effort — heartbeat expiry cleans up server-side
378
+ }
379
+ this._nodeToken = "";
380
+ this.directoryListed = false;
381
+ }
324
382
  async unbindQuietly() {
325
383
  if (!this._sessionToken)
326
384
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iicp/web-node",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",