@peers-app/peers-sdk 0.30.4 → 0.30.6

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.
@@ -5,7 +5,9 @@ export type MeshDialReason = "own-device" | "group-network-info" | "broadcast-an
5
5
  /** Bounded path categories used for lifetime counters; attempts also retain the address. */
6
6
  export type MeshPath = "websocket" | "webrtc" | "cloud" | "discovery";
7
7
  /** Failure categories, independent of free-form transport error messages. */
8
- export type MeshFailureReason = "ttl0" | "no-route" | "signaling-timeout" | "socket-error" | "socket-timeout" | "handshake" | "rejected" | "closed" | "superseded" | "unsupported" | "sender-not-known" | "trust-refused" | "identity-mismatch" | "cancelled" | "capacity";
8
+ export type MeshFailureReason = "ttl0" | "no-route" | "signaling-timeout" | "socket-error" | "socket-timeout" | "handshake" | "rejected" | "closed" | "superseded" | "unsupported" | "sender-not-known"
9
+ /** This device can't encrypt to the target yet: no owner-signed row or profile for it here. */
10
+ | "recipient-unknown" | "trust-refused" | "identity-mismatch" | "cancelled" | "capacity";
9
11
  /** Observed stage; timestamps are offsets in milliseconds from the attempt's queued time. */
10
12
  export type MeshStage = "queued" | "started" | "authenticated" | "admitted" | "ready" | "firstSync";
11
13
  /** Local dial metadata and cancellation; never serialized into device messages. */
@@ -19,6 +21,12 @@ export interface IMeshDial {
19
21
  targetDeviceId?: string;
20
22
  /** One-based retry number; absent for the initial dial. */
21
23
  retry?: number;
24
+ /**
25
+ * Metrics only: the logical dial this transport races alongside (a published address tried
26
+ * next to the device-id path). It's recorded as an attempt of its own but counted as part of
27
+ * that dial, and its failures don't end the dial.
28
+ */
29
+ siblingOf?: string;
22
30
  }
23
31
  /** One dial or inbound connection. Missing stages have not been observed. */
24
32
  export interface IMeshAttempt {
@@ -135,6 +143,17 @@ export interface IMeshStats {
135
143
  probes: number;
136
144
  connected: number;
137
145
  };
146
+ /**
147
+ * Dials helped by what devices publish: dials that tried a device's published addresses,
148
+ * those an address won, and the relayed protocol queries made and skipped because the
149
+ * device's protocols were known. Absent from devices older than these counters.
150
+ */
151
+ dialHints?: {
152
+ addressDials: number;
153
+ addressWins: number;
154
+ protocolQueries: number;
155
+ protocolQueriesSkipped: number;
156
+ };
138
157
  /** Lifetime episodes excluded from latency percentiles, by reason. */
139
158
  unmeasuredEpisodes?: {
140
159
  pageHidden: number;
@@ -49,11 +49,42 @@ export declare class ToolCatalog {
49
49
  private subscription?;
50
50
  private disposed;
51
51
  private reconciliationQueued;
52
+ /**
53
+ * This context's Tools rows, read once by {@link ready} and kept current from the table's
54
+ * change events (local writes and synced changes alike). Publication checks against it:
55
+ * every start publishes every provider in every context, and reading rows for each one
56
+ * cost thousands of queries per start on a phone.
57
+ */
58
+ private rows?;
59
+ /** Rows changed since they were last reconciled. */
60
+ private readonly changed;
61
+ private readonly pendingRows;
62
+ private readonly revisions;
52
63
  /** Bind to the registry shared by host and isolated providers in this context. */
53
64
  constructor(dataContext: DataContext, registry: ContractRegistry);
54
65
  private enqueue;
55
66
  /** Await successful backfill and system publication. Failed initialization can be retried. */
56
67
  ready(): Promise<void>;
68
+ private initialized;
69
+ /** True while change events keep {@link rows} current. */
70
+ private live;
71
+ /**
72
+ * Read this context's rows into memory, after subscribing to the table's changes so none
73
+ * is missed while the read is in flight. A row that changes during the read keeps the
74
+ * changed version. Where the table can't report changes (a client process whose table
75
+ * events are off), the rows are read again before each use instead.
76
+ */
77
+ private load;
78
+ /** Read partial notifications in batches, retrying only rows changed during the read. */
79
+ private loadPendingRows;
80
+ /** Stop watching the table and forget its rows (after a failed start, or on dispose). */
81
+ private forget;
82
+ /**
83
+ * Record silent writes, but never replace a newer event with an older write result.
84
+ */
85
+ private write;
86
+ private get rowMap();
87
+ private queueReconciliation;
57
88
  private assertLive;
58
89
  private validate;
59
90
  /**
@@ -80,6 +111,7 @@ export declare class ToolCatalog {
80
111
  * leaves every other column, including concurrently synced policy, untouched.
81
112
  */
82
113
  private refresh;
114
+ /** Re-derive catalog state for these rows (all of them at startup, then those that change). */
83
115
  private reconcile;
84
116
  /**
85
117
  * Patch supplemental metadata of existing system members, preserving identity,
@@ -6,6 +6,7 @@ exports.retainToolCatalogFields = retainToolCatalogFields;
6
6
  exports.validateToolCatalogWrite = validateToolCatalogWrite;
7
7
  const lodash_1 = require("lodash");
8
8
  const tools_1 = require("../data/tools");
9
+ const rpc_types_1 = require("../rpc-types");
9
10
  const registry_1 = require("./registry");
10
11
  const system_contracts_1 = require("./system-contracts");
11
12
  const tool_catalog_identity_1 = require("./tool-catalog-identity");
@@ -238,6 +239,14 @@ function resolvedFields(target, toolId) {
238
239
  catalogReason: undefined,
239
240
  };
240
241
  }
242
+ /** Table writes normally report full rows; incomplete notifications need a storage read. */
243
+ function isCompleteRow(row) {
244
+ return (row.name !== undefined &&
245
+ row.usageDescription !== undefined &&
246
+ row.inputSchema !== undefined &&
247
+ row.outputSchema !== undefined &&
248
+ row.code !== undefined);
249
+ }
241
250
  /**
242
251
  * Per-context runtime owner of permanent Tools identities. Construction is inert;
243
252
  * ready() explicitly initializes storage after UserContext construction completes.
@@ -251,6 +260,17 @@ class ToolCatalog {
251
260
  subscription;
252
261
  disposed = false;
253
262
  reconciliationQueued = false;
263
+ /**
264
+ * This context's Tools rows, read once by {@link ready} and kept current from the table's
265
+ * change events (local writes and synced changes alike). Publication checks against it:
266
+ * every start publishes every provider in every context, and reading rows for each one
267
+ * cost thousands of queries per start on a phone.
268
+ */
269
+ rows;
270
+ /** Rows changed since they were last reconciled. */
271
+ changed = new Set();
272
+ pendingRows = new Set();
273
+ revisions = new Map();
254
274
  /** Bind to the registry shared by host and isolated providers in this context. */
255
275
  constructor(dataContext, registry) {
256
276
  this.dataContext = dataContext;
@@ -274,7 +294,8 @@ class ToolCatalog {
274
294
  return Promise.reject(new Error("Tool catalog disposed"));
275
295
  if (!this.initialization) {
276
296
  this.initialization = this.enqueue(async () => {
277
- await this.reconcile();
297
+ await this.load();
298
+ await this.reconcile([...this.rowMap.keys()]);
278
299
  const groups = new Map();
279
300
  for (const entry of (0, system_contracts_1.listSystemContracts)()) {
280
301
  const entries = groups.get(entry.providerPackageId) ?? [];
@@ -295,25 +316,148 @@ class ToolCatalog {
295
316
  this.registry.replaceBatch(provider, registrations);
296
317
  }
297
318
  });
298
- // dispose() may have run while initialization awaited storage; it has
299
- // already unsubscribed, so a late subscription would never be released.
319
+ // dispose() may have run while initialization awaited storage.
300
320
  this.assertLive();
301
- this.subscription ??= (0, tools_1.Tools)(this.dataContext).dataChanged.subscribe(() => {
302
- if (this.reconciliationQueued || this.disposed)
303
- return;
304
- this.reconciliationQueued = true;
305
- void this.enqueue(async () => {
306
- this.reconciliationQueued = false;
307
- await this.reconcile();
308
- }).catch((error) => console.error("Tool catalog reconciliation failed", error));
309
- });
321
+ this.initialized = true;
322
+ this.queueReconciliation();
310
323
  }).catch((error) => {
311
324
  this.initialization = undefined;
325
+ this.forget();
312
326
  throw error;
313
327
  });
314
328
  }
315
329
  return this.initialization;
316
330
  }
331
+ initialized = false;
332
+ /** True while change events keep {@link rows} current. */
333
+ live = false;
334
+ /**
335
+ * Read this context's rows into memory, after subscribing to the table's changes so none
336
+ * is missed while the read is in flight. A row that changes during the read keeps the
337
+ * changed version. Where the table can't report changes (a client process whose table
338
+ * events are off), the rows are read again before each use instead.
339
+ */
340
+ async load() {
341
+ // Work queued before dispose() would otherwise subscribe again, and nothing would release it.
342
+ this.assertLive();
343
+ if (this.rows && this.live)
344
+ return this.loadPendingRows();
345
+ const table = (0, tools_1.Tools)(this.dataContext);
346
+ const rows = new Map();
347
+ const seen = new Set();
348
+ if (!this.subscription && table.dataChanged && !(rpc_types_1.isClient && !(0, rpc_types_1.isSingleProcessClient)())) {
349
+ this.subscription = table.dataChanged.subscribe((event) => {
350
+ const toolId = event.dataObject.toolId;
351
+ this.revisions.set(toolId, (this.revisions.get(toolId) ?? 0) + 1);
352
+ if (!this.rows)
353
+ seen.add(toolId);
354
+ const target = this.rows ?? rows;
355
+ if (event.op === "delete")
356
+ target.delete(toolId);
357
+ // Full replacements clear omitted optional fields in SQL. Merging would retain
358
+ // removed provenance, descriptions or quarantine evidence indefinitely.
359
+ else if (isCompleteRow(event.dataObject))
360
+ target.set(toolId, (0, lodash_1.cloneDeep)(event.dataObject));
361
+ else
362
+ this.pendingRows.add(toolId);
363
+ this.changed.add(toolId);
364
+ if (this.initialized)
365
+ this.queueReconciliation();
366
+ });
367
+ this.live = true;
368
+ }
369
+ for (const row of await table.list()) {
370
+ if (!seen.has(row.toolId))
371
+ rows.set(row.toolId, row);
372
+ }
373
+ this.assertLive();
374
+ this.rows = rows;
375
+ if (this.pendingRows.size)
376
+ await this.loadPendingRows();
377
+ }
378
+ /** Read partial notifications in batches, retrying only rows changed during the read. */
379
+ async loadPendingRows() {
380
+ while (this.pendingRows.size) {
381
+ const ids = [...this.pendingRows].slice(0, 500);
382
+ const revisions = ids.map((id) => this.revisions.get(id));
383
+ const rows = new Map((await (0, tools_1.Tools)(this.dataContext).list({ toolId: { $in: ids } })).map((row) => [
384
+ row.toolId,
385
+ row,
386
+ ]));
387
+ this.assertLive();
388
+ ids.forEach((id, index) => {
389
+ if (this.revisions.get(id) !== revisions[index])
390
+ return;
391
+ const row = rows.get(id);
392
+ if (row)
393
+ this.rowMap.set(id, row);
394
+ else
395
+ this.rowMap.delete(id);
396
+ this.pendingRows.delete(id);
397
+ });
398
+ }
399
+ }
400
+ /** Stop watching the table and forget its rows (after a failed start, or on dispose). */
401
+ forget() {
402
+ this.subscription?.unsubscribe();
403
+ this.subscription = undefined;
404
+ this.live = false;
405
+ this.rows = undefined;
406
+ this.changed.clear();
407
+ this.pendingRows.clear();
408
+ this.revisions.clear();
409
+ this.initialized = false;
410
+ }
411
+ /**
412
+ * Record silent writes, but never replace a newer event with an older write result.
413
+ */
414
+ async write(toolId, save) {
415
+ const revision = this.revisions.get(toolId);
416
+ let row;
417
+ try {
418
+ row = await save();
419
+ }
420
+ catch (error) {
421
+ // The row write can succeed before history persistence fails and suppresses its event.
422
+ if (this.rows)
423
+ this.pendingRows.add(toolId);
424
+ throw error;
425
+ }
426
+ if (this.rows && this.revisions.get(toolId) === revision) {
427
+ // Table returns and event payloads can still be held and edited by their caller.
428
+ if (isCompleteRow(row))
429
+ this.rows.set(toolId, (0, lodash_1.cloneDeep)(row));
430
+ else
431
+ this.pendingRows.add(toolId);
432
+ }
433
+ return row;
434
+ }
435
+ get rowMap() {
436
+ if (!this.rows)
437
+ throw new Error("Tool catalog is not ready");
438
+ return this.rows;
439
+ }
440
+ queueReconciliation() {
441
+ if (this.reconciliationQueued || this.disposed || !this.changed.size)
442
+ return;
443
+ this.reconciliationQueued = true;
444
+ void this.enqueue(async () => {
445
+ this.reconciliationQueued = false;
446
+ const ids = [...this.changed];
447
+ this.changed.clear();
448
+ try {
449
+ if (this.pendingRows.size)
450
+ await this.loadPendingRows();
451
+ await this.reconcile(ids);
452
+ }
453
+ catch (error) {
454
+ // Keep failed and unvisited rows for the next event, like the old full pass.
455
+ for (const id of ids)
456
+ this.changed.add(id);
457
+ throw error;
458
+ }
459
+ }).catch((error) => console.error("Tool catalog reconciliation failed", error));
460
+ }
317
461
  assertLive() {
318
462
  if (this.disposed)
319
463
  throw new Error("Tool catalog disposed");
@@ -349,6 +493,7 @@ class ToolCatalog {
349
493
  }
350
494
  await this.ready();
351
495
  return this.enqueue(async () => {
496
+ await this.load();
352
497
  this.validate(provider, registrations);
353
498
  await this.materialize(registrations, false);
354
499
  await prepare();
@@ -356,7 +501,9 @@ class ToolCatalog {
356
501
  await this.materialize(registrations, true, provider);
357
502
  for (const metadata of toolMetadata) {
358
503
  for (const toolId of (0, tool_catalog_migrations_1.toolCatalogIds)(metadata)) {
359
- const stored = await (0, tools_1.Tools)(this.dataContext).get(toolId);
504
+ if (this.pendingRows.size)
505
+ await this.loadPendingRows();
506
+ const stored = this.rowMap.get(toolId);
360
507
  if (!stored || stored.catalogState !== "resolved")
361
508
  throw new Error(`Missing or quarantined tool identity: ${toolId}`);
362
509
  await this.refresh({ ...metadata, toolId }, stored, tools_1.toolCatalogSupplementalFields);
@@ -392,6 +539,7 @@ class ToolCatalog {
392
539
  if (!expected.has(toolId))
393
540
  return;
394
541
  try {
542
+ // Validate the payload as written: a partial row that omits identity fails closed.
395
543
  validateRow(toolId, event.op === "delete" ? undefined : event.dataObject);
396
544
  }
397
545
  catch (error) {
@@ -405,8 +553,10 @@ class ToolCatalog {
405
553
  this.validate(provider, registrations);
406
554
  };
407
555
  try {
556
+ // Clients without table events need the same final storage check as before caching.
557
+ await this.load();
408
558
  for (const toolId of expected.keys())
409
- validateRow(toolId, await table.get(toolId));
559
+ validateRow(toolId, this.rowMap.get(toolId));
410
560
  assertCatalogCurrent();
411
561
  return await activate(assertCatalogCurrent);
412
562
  }
@@ -443,8 +593,9 @@ class ToolCatalog {
443
593
  * definition's name, usage description and IO schemas.
444
594
  */
445
595
  async materialize(registrations, refresh, provider) {
596
+ await this.load();
446
597
  const table = (0, tools_1.Tools)(this.dataContext);
447
- const existing = new Map((await table.list()).map((row) => [row.toolId, row]));
598
+ const existing = this.rowMap;
448
599
  const identities = new Map();
449
600
  // Reserve the entire reviewed inventory, even on an empty database.
450
601
  for (const entry of tool_catalog_migrations_1.toolCatalogMigrations)
@@ -486,23 +637,32 @@ class ToolCatalog {
486
637
  // Validate the entire batch before the first write. Catalog metadata never
487
638
  // overwrites provider-specific policy or another provider's descriptions.
488
639
  for (const row of candidates.values()) {
640
+ if (this.pendingRows.size)
641
+ await this.loadPendingRows();
489
642
  const previous = existing.get(row.toolId);
643
+ let restored;
490
644
  // Restore missing identity columns only after validating the entire batch.
491
645
  // This marked patch leaves concurrent policy and historical fields intact.
492
646
  if (previous &&
493
647
  (canRestoreBareDeterministicRow(previous, row) || canResolveMissingTarget(previous, row))) {
494
- const restored = await table.update((0, tools_1.toolCatalogPatch)({
648
+ restored = await this.write(row.toolId, () => table.update((0, tools_1.toolCatalogPatch)({
495
649
  toolId: row.toolId,
496
650
  ...resolvedFields(toolCatalogTarget(row), row.toolId),
497
- }));
651
+ })));
498
652
  validateToolCatalogWrite(row, restored);
499
653
  if (restored.catalogState === "quarantined")
500
654
  throw new Error(`Quarantined tool identity: ${row.toolId}`);
501
655
  }
502
- // Older runtimes allowed provider removal to leave tombstones. Only a
503
- // validated public target may restore one; insert-only still preserves any
504
- // row that arrived concurrently, and the returned mapping is checked below.
505
- const stored = await table.save(row, { insertOnly: true, restoreIfDeleted: true });
656
+ // A row already in memory needs no insert-only save, which would only read it back:
657
+ // every start would cost a query per tool per context. A row that changes later is
658
+ // caught by guardActivation, which checks again under a subscription before anything
659
+ // activates. Older runtimes allowed provider removal to leave tombstones, which aren't
660
+ // in memory: only a validated public target may restore one, and insert-only still
661
+ // preserves a row that arrived concurrently.
662
+ let stored = restored ?? previous;
663
+ if (!stored) {
664
+ stored = await this.write(row.toolId, () => table.save(row, { insertOnly: true, restoreIfDeleted: true }));
665
+ }
506
666
  validateToolCatalogWrite(row, stored);
507
667
  if (!stored || stored.catalogState === "quarantined")
508
668
  throw new Error(`Catalog insert did not succeed: ${row.toolId}`);
@@ -521,14 +681,20 @@ class ToolCatalog {
521
681
  const patch = { toolId: desired.toolId };
522
682
  for (const field of changed)
523
683
  Object.assign(patch, { [field]: desired[field] });
524
- const refreshed = await (0, tools_1.Tools)(this.dataContext).update((0, tools_1.toolCatalogPatch)(patch));
684
+ const refreshed = await this.write(desired.toolId, () => (0, tools_1.Tools)(this.dataContext).update((0, tools_1.toolCatalogPatch)(patch)));
525
685
  validateToolCatalogWrite(stored, refreshed);
526
686
  if (refreshed.catalogState === "quarantined")
527
687
  throw new Error(`Quarantined tool identity: ${desired.toolId}`);
528
688
  }
529
- async reconcile() {
689
+ /** Re-derive catalog state for these rows (all of them at startup, then those that change). */
690
+ async reconcile(toolIds) {
530
691
  const table = (0, tools_1.Tools)(this.dataContext);
531
- for (const row of await table.list()) {
692
+ for (const toolId of toolIds) {
693
+ if (this.pendingRows.size)
694
+ await this.loadPendingRows();
695
+ const row = this.rowMap.get(toolId);
696
+ if (!row)
697
+ continue;
532
698
  let fields;
533
699
  // Quarantine is sticky: conflicting evidence is never silently rehabilitated. The one
534
700
  // exception is a row quarantined for its missing target that has since received the
@@ -540,7 +706,10 @@ class ToolCatalog {
540
706
  const resolved = { ...row, ...resolvedFields(target, row.toolId) };
541
707
  if (!canResolveMissingTarget(row, resolved))
542
708
  continue;
543
- await table.update((0, tools_1.toolCatalogPatch)({ toolId: row.toolId, ...resolvedFields(target, row.toolId) }));
709
+ await this.write(row.toolId, () => table.update((0, tools_1.toolCatalogPatch)({
710
+ toolId: row.toolId,
711
+ ...resolvedFields(target, row.toolId),
712
+ })));
544
713
  continue;
545
714
  }
546
715
  try {
@@ -554,7 +723,7 @@ class ToolCatalog {
554
723
  if (Object.entries(fields).some(([key, value]) => row[key] !== value)) {
555
724
  // Update only catalog-owned fields. A sync write may have changed legacy
556
725
  // metadata since list() returned; writing the snapshot back would lose it.
557
- await table.update((0, tools_1.toolCatalogPatch)({ toolId: row.toolId, ...fields }));
726
+ await this.write(row.toolId, () => table.update((0, tools_1.toolCatalogPatch)({ toolId: row.toolId, ...fields })));
558
727
  }
559
728
  }
560
729
  }
@@ -566,6 +735,7 @@ class ToolCatalog {
566
735
  async saveToolMetadata(entries) {
567
736
  await this.ready();
568
737
  await this.enqueue(async () => {
738
+ await this.load();
569
739
  const members = (0, system_contracts_1.listSystemContracts)().flatMap((entry) => {
570
740
  const definition = entry.getDefinition();
571
741
  return definition.tools.map((tool) => ({
@@ -578,7 +748,7 @@ class ToolCatalog {
578
748
  for (const entry of entries) {
579
749
  const target = (0, tool_catalog_migrations_1.resolveLegacyToolId)(entry.toolId) ??
580
750
  members.find((member) => (0, tool_catalog_migrations_1.toolCatalogIds)(member).includes(entry.toolId));
581
- const stored = await (0, tools_1.Tools)(this.dataContext).get(entry.toolId);
751
+ const stored = this.rowMap.get(entry.toolId);
582
752
  if (!target ||
583
753
  !members.some((member) => (0, tool_catalog_identity_1.toolCatalogTargetKey)(member) === (0, tool_catalog_identity_1.toolCatalogTargetKey)(target)) ||
584
754
  !stored ||
@@ -626,7 +796,7 @@ class ToolCatalog {
626
796
  /** Stop subscriptions and queued lifecycle work; permanent rows are retained. */
627
797
  async dispose() {
628
798
  this.disposed = true;
629
- this.subscription?.unsubscribe();
799
+ this.forget();
630
800
  await this.tail;
631
801
  }
632
802
  }
@@ -312,6 +312,14 @@ async function checkDevice(existing, candidate, context) {
312
312
  !signed(candidate)) {
313
313
  throw (0, write_checks_1.writeNotAuthorized)("Devices", "the ownership claim doesn't verify");
314
314
  }
315
+ // Only the owner publishes how to dial its device. Sync keeps the stored block when a writer
316
+ // drops it or holds an older one (retainDeviceReachability), so this refuses only a forgery.
317
+ if (candidate?.reachability &&
318
+ (candidate.userId !== existing?.userId ||
319
+ !(0, devices_1.sameDeviceReachability)(candidate.reachability, existing?.reachability)) &&
320
+ !(0, devices_1.isDeviceReachabilitySigned)(candidate, users.get(ownerId ?? candidate.userId)?.publicKey)) {
321
+ throw (0, write_checks_1.writeNotAuthorized)("Devices", "reachability isn't signed by the device's owner");
322
+ }
315
323
  }
316
324
  (0, write_checks_1.registerTableWriteCheck)("Users", checkUser);
317
325
  (0, write_checks_1.registerTableWriteCheck)("Devices", checkDevice);
@@ -5,6 +5,34 @@ import { TrustLevel } from "../device/socket.type";
5
5
  export declare const DISPLAY_NAME_MAX_LENGTH = 128;
6
6
  /** Shared schema for bounded user and device display names. */
7
7
  export declare const displayNameSchema: z.ZodString;
8
+ /** Most origins one device publishes: every LAN interface plus a configured public origin. */
9
+ export declare const DEVICE_REACHABILITY_MAX_ADDRESSES = 16;
10
+ /** Most protocol names one device publishes. */
11
+ export declare const DEVICE_REACHABILITY_MAX_PROTOCOLS = 8;
12
+ /**
13
+ * How a device can be dialed, published by its owner on its own Devices row: the origins its peer
14
+ * listener accepts connections on, and the protocols it supports. Only the owner writes it, signed
15
+ * with the owner's key, and the newest signed `updatedAt` wins. Addresses are a fast path; every
16
+ * dial still checks that the expected device answers.
17
+ */
18
+ export declare const deviceReachabilitySchema: z.ZodObject<{
19
+ addresses: z.ZodArray<z.ZodString, "many">;
20
+ protocols: z.ZodArray<z.ZodString, "many">;
21
+ updatedAt: z.ZodNumber;
22
+ signature: z.ZodString;
23
+ }, "strip", z.ZodTypeAny, {
24
+ signature: string;
25
+ updatedAt: number;
26
+ addresses: string[];
27
+ protocols: string[];
28
+ }, {
29
+ signature: string;
30
+ updatedAt: number;
31
+ addresses: string[];
32
+ protocols: string[];
33
+ }>;
34
+ /** How a device can be dialed, as its owner published it (see {@link deviceReachabilitySchema}). */
35
+ export type IDeviceReachability = z.infer<typeof deviceReachabilitySchema>;
8
36
  /** Schema for a persisted, context-scoped device record. */
9
37
  export declare const deviceSchema: z.ZodObject<{
10
38
  deviceId: z.ZodEffects<z.ZodString, string, string>;
@@ -16,6 +44,22 @@ export declare const deviceSchema: z.ZodObject<{
16
44
  serverUrl: z.ZodOptional<z.ZodString>;
17
45
  trustLevel: z.ZodNativeEnum<typeof TrustLevel>;
18
46
  ownerSignature: z.ZodOptional<z.ZodString>;
47
+ reachability: z.ZodOptional<z.ZodObject<{
48
+ addresses: z.ZodArray<z.ZodString, "many">;
49
+ protocols: z.ZodArray<z.ZodString, "many">;
50
+ updatedAt: z.ZodNumber;
51
+ signature: z.ZodString;
52
+ }, "strip", z.ZodTypeAny, {
53
+ signature: string;
54
+ updatedAt: number;
55
+ addresses: string[];
56
+ protocols: string[];
57
+ }, {
58
+ signature: string;
59
+ updatedAt: number;
60
+ addresses: string[];
61
+ protocols: string[];
62
+ }>>;
19
63
  }, "strip", z.ZodTypeAny, {
20
64
  userId: string;
21
65
  deviceId: string;
@@ -26,6 +70,12 @@ export declare const deviceSchema: z.ZodObject<{
26
70
  reportedName?: string | undefined;
27
71
  serverUrl?: string | undefined;
28
72
  ownerSignature?: string | undefined;
73
+ reachability?: {
74
+ signature: string;
75
+ updatedAt: number;
76
+ addresses: string[];
77
+ protocols: string[];
78
+ } | undefined;
29
79
  }, {
30
80
  userId: string;
31
81
  deviceId: string;
@@ -36,6 +86,12 @@ export declare const deviceSchema: z.ZodObject<{
36
86
  reportedName?: string | undefined;
37
87
  serverUrl?: string | undefined;
38
88
  ownerSignature?: string | undefined;
89
+ reachability?: {
90
+ signature: string;
91
+ updatedAt: number;
92
+ addresses: string[];
93
+ protocols: string[];
94
+ } | undefined;
39
95
  }>;
40
96
  /** A context-scoped device record containing local state and owner-reported metadata. */
41
97
  export type IDevice = z.infer<typeof deviceSchema>;
@@ -58,6 +114,41 @@ export declare function deviceOwnerStatement(device: Pick<IDevice, "deviceId" |
58
114
  * @param ownerPublicKey - The signing key of `device.userId`, as this device knows it.
59
115
  */
60
116
  export declare function isDeviceOwnerSigned(device: Pick<IDevice, "deviceId" | "userId" | "ownerSignature">, ownerPublicKey: string | undefined): boolean;
117
+ /**
118
+ * What a device's owner signs to publish how to dial it: the contents of
119
+ * `reachability.signature`. Arrays are signed in the order given, so publishers sort them.
120
+ *
121
+ * @param deviceId - The device the reachability is for.
122
+ * @param reachability - Its addresses, protocols and publishing time.
123
+ */
124
+ export declare function deviceReachabilityStatement(deviceId: string, reachability: Pick<IDeviceReachability, "addresses" | "protocols" | "updatedAt">): {
125
+ purpose: string;
126
+ deviceId: string;
127
+ addresses: string[];
128
+ protocols: string[];
129
+ updatedAt: number;
130
+ };
131
+ /**
132
+ * Whether `device.reachability` carries `ownerPublicKey`'s signature over
133
+ * {@link deviceReachabilityStatement}: the device's owner published it, not whoever wrote the row.
134
+ *
135
+ * @param device - A Devices row.
136
+ * @param ownerPublicKey - The signing key of the device's owner, as this device knows it.
137
+ */
138
+ export declare function isDeviceReachabilitySigned(device: Pick<IDevice, "deviceId" | "reachability">, ownerPublicKey: string | undefined): boolean;
139
+ /** Whether two reachability blocks are the same publication. */
140
+ export declare function sameDeviceReachability(a: IDeviceReachability | undefined, b: IDeviceReachability | undefined): boolean;
141
+ /**
142
+ * The row to store when `candidate` replaces `existing`: the candidate, keeping the stored
143
+ * `reachability` when the candidate has none or an older one (a tie keeps the stored one too).
144
+ * Writers that don't know the field (older builds) or hold a stale copy never roll it back;
145
+ * only a newer publication replaces it. Signatures are checked separately, by the group write
146
+ * check and by whoever dials.
147
+ *
148
+ * @param existing - The row as stored now, if any.
149
+ * @param candidate - The row about to be stored.
150
+ */
151
+ export declare function retainDeviceReachability<T extends Partial<IDevice>>(existing: Partial<IDevice> | undefined, candidate: T): T;
61
152
  /** Schema for the signed identity and bounded presentation hints exchanged by devices. */
62
153
  export declare const deviceInfoSchema: z.ZodObject<{
63
154
  userId: z.ZodEffects<z.ZodString, string, string>;
@@ -237,6 +328,12 @@ export declare function Devices(dataContext?: DataContext): import("..").Table<{
237
328
  reportedName?: string | undefined;
238
329
  serverUrl?: string | undefined;
239
330
  ownerSignature?: string | undefined;
331
+ reachability?: {
332
+ signature: string;
333
+ updatedAt: number;
334
+ addresses: string[];
335
+ protocols: string[];
336
+ } | undefined;
240
337
  }>;
241
338
  /** Persistent identifier for the current physical/runtime device. */
242
339
  export declare const thisDeviceId: import("./persistent-vars").PersistentVar<string | undefined>;
@@ -1,8 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.thisDeviceId = exports.deviceHandshakeSchema = exports.handshakeFrameCryptoSchema = exports.deviceInfoSchema = exports.deviceSchema = exports.displayNameSchema = exports.DISPLAY_NAME_MAX_LENGTH = void 0;
3
+ exports.thisDeviceId = exports.deviceHandshakeSchema = exports.handshakeFrameCryptoSchema = exports.deviceInfoSchema = exports.deviceSchema = exports.deviceReachabilitySchema = exports.DEVICE_REACHABILITY_MAX_PROTOCOLS = exports.DEVICE_REACHABILITY_MAX_ADDRESSES = exports.displayNameSchema = exports.DISPLAY_NAME_MAX_LENGTH = void 0;
4
4
  exports.deviceOwnerStatement = deviceOwnerStatement;
5
5
  exports.isDeviceOwnerSigned = isDeviceOwnerSigned;
6
+ exports.deviceReachabilityStatement = deviceReachabilityStatement;
7
+ exports.isDeviceReachabilitySigned = isDeviceReachabilitySigned;
8
+ exports.sameDeviceReachability = sameDeviceReachability;
9
+ exports.retainDeviceReachability = retainDeviceReachability;
6
10
  exports.Devices = Devices;
7
11
  const zod_1 = require("zod");
8
12
  const user_context_singleton_1 = require("../context/user-context-singleton");
@@ -17,6 +21,30 @@ const persistent_vars_1 = require("./persistent-vars");
17
21
  exports.DISPLAY_NAME_MAX_LENGTH = 128;
18
22
  /** Shared schema for bounded user and device display names. */
19
23
  exports.displayNameSchema = zod_1.z.string().max(exports.DISPLAY_NAME_MAX_LENGTH);
24
+ /** Most origins one device publishes: every LAN interface plus a configured public origin. */
25
+ exports.DEVICE_REACHABILITY_MAX_ADDRESSES = 16;
26
+ /** Most protocol names one device publishes. */
27
+ exports.DEVICE_REACHABILITY_MAX_PROTOCOLS = 8;
28
+ /**
29
+ * How a device can be dialed, published by its owner on its own Devices row: the origins its peer
30
+ * listener accepts connections on, and the protocols it supports. Only the owner writes it, signed
31
+ * with the owner's key, and the newest signed `updatedAt` wins. Addresses are a fast path; every
32
+ * dial still checks that the expected device answers.
33
+ */
34
+ exports.deviceReachabilitySchema = zod_1.z.object({
35
+ addresses: zod_1.z
36
+ .array(zod_1.z.string().max(256))
37
+ .max(exports.DEVICE_REACHABILITY_MAX_ADDRESSES)
38
+ .describe("Origins the device's peer listener accepts connections on, in a stable order"),
39
+ protocols: zod_1.z
40
+ .array(zod_1.z.string().max(32))
41
+ .max(exports.DEVICE_REACHABILITY_MAX_PROTOCOLS)
42
+ .describe("Protocols the device can be dialed with, such as ws and wrtc"),
43
+ updatedAt: zod_1.z.number().describe("When the owner published this, in ms; the newest one wins"),
44
+ signature: zod_1.z
45
+ .string()
46
+ .describe("The owner's signature over deviceReachabilityStatement, without the key prefix"),
47
+ });
20
48
  /** Schema for a persisted, context-scoped device record. */
21
49
  exports.deviceSchema = zod_1.z.object({
22
50
  deviceId: zod_types_1.zodPeerId,
@@ -31,6 +59,9 @@ exports.deviceSchema = zod_1.z.object({
31
59
  .string()
32
60
  .optional()
33
61
  .describe("The owner's signature claiming this device for userId (see deviceOwnerStatement). In a group, only a row with a valid one says who a device belongs to, and it stays theirs"),
62
+ reachability: exports.deviceReachabilitySchema
63
+ .optional()
64
+ .describe("How to dial this device, published and signed by its owner. Sync keeps the newest signed copy, so a writer without it never clears it"),
34
65
  });
35
66
  /**
36
67
  * What a device's owner signs to claim it: the contents of `ownerSignature`. Each device signs
@@ -56,6 +87,74 @@ function isDeviceOwnerSigned(device, ownerPublicKey) {
56
87
  signature: `${ownerPublicKey}:${device.ownerSignature}`,
57
88
  }) === ownerPublicKey);
58
89
  }
90
+ /**
91
+ * What a device's owner signs to publish how to dial it: the contents of
92
+ * `reachability.signature`. Arrays are signed in the order given, so publishers sort them.
93
+ *
94
+ * @param deviceId - The device the reachability is for.
95
+ * @param reachability - Its addresses, protocols and publishing time.
96
+ */
97
+ function deviceReachabilityStatement(deviceId, reachability) {
98
+ return {
99
+ purpose: "peers-device-reachability",
100
+ deviceId,
101
+ addresses: reachability.addresses,
102
+ protocols: reachability.protocols,
103
+ updatedAt: reachability.updatedAt,
104
+ };
105
+ }
106
+ /**
107
+ * Whether `device.reachability` carries `ownerPublicKey`'s signature over
108
+ * {@link deviceReachabilityStatement}: the device's owner published it, not whoever wrote the row.
109
+ *
110
+ * @param device - A Devices row.
111
+ * @param ownerPublicKey - The signing key of the device's owner, as this device knows it.
112
+ */
113
+ function isDeviceReachabilitySigned(device, ownerPublicKey) {
114
+ const reachability = device.reachability;
115
+ if (!reachability?.signature || !ownerPublicKey)
116
+ return false;
117
+ return ((0, keys_1.signedByKey)({
118
+ ...deviceReachabilityStatement(device.deviceId, reachability),
119
+ signature: `${ownerPublicKey}:${reachability.signature}`,
120
+ }) === ownerPublicKey);
121
+ }
122
+ /** Whether two reachability blocks are the same publication. */
123
+ function sameDeviceReachability(a, b) {
124
+ if (!a || !b)
125
+ return a === b;
126
+ return (a.updatedAt === b.updatedAt &&
127
+ a.signature === b.signature &&
128
+ a.addresses.length === b.addresses.length &&
129
+ a.addresses.every((address, i) => address === b.addresses[i]) &&
130
+ a.protocols.length === b.protocols.length &&
131
+ a.protocols.every((protocol, i) => protocol === b.protocols[i]));
132
+ }
133
+ /**
134
+ * The row to store when `candidate` replaces `existing`: the candidate, keeping the stored
135
+ * `reachability` when the candidate has none or an older one (a tie keeps the stored one too).
136
+ * Writers that don't know the field (older builds) or hold a stale copy never roll it back;
137
+ * only a newer publication replaces it. Signatures are checked separately, by the group write
138
+ * check and by whoever dials.
139
+ *
140
+ * @param existing - The row as stored now, if any.
141
+ * @param candidate - The row about to be stored.
142
+ */
143
+ function retainDeviceReachability(existing, candidate) {
144
+ const stored = existing?.reachability;
145
+ if (!stored)
146
+ return candidate;
147
+ // An unclaimed discovery row can acquire a different owner. Its former owner's
148
+ // publication must neither follow that claim nor prevent the new owner's publication.
149
+ if (existing.userId && candidate.userId && existing.userId !== candidate.userId)
150
+ return candidate;
151
+ const incoming = candidate.reachability;
152
+ if (incoming && incoming.updatedAt > stored.updatedAt)
153
+ return candidate;
154
+ if (incoming && sameDeviceReachability(incoming, stored))
155
+ return candidate;
156
+ return { ...candidate, reachability: stored };
157
+ }
59
158
  /** Schema for the signed identity and bounded presentation hints exchanged by devices. */
60
159
  exports.deviceInfoSchema = zod_1.z.object({
61
160
  userId: zod_types_1.zodPeerId,
@@ -21,6 +21,8 @@ export declare class Table<T extends {
21
21
  protected readonly dataChangedEmitter: Emitter<IDataChangedEvent<T>>;
22
22
  readonly dataChanged: Event<IDataChangedEvent<T>>;
23
23
  private readonly localOperationCache;
24
+ /** Rows committed by an in-progress local write, for {@link reportCommittedLocalWrite}. */
25
+ private readonly committedLocalEvents;
24
26
  readonly dataSource: IDataSource<T>;
25
27
  readonly metaData: ITableMetaData;
26
28
  readonly groupId?: string;
@@ -28,6 +30,12 @@ export declare class Table<T extends {
28
30
  private getRecordId;
29
31
  private setLocalOperation;
30
32
  private clearLocalOperation;
33
+ /**
34
+ * A local write failed after its row was committed (for example, a tracked table's history
35
+ * write failed). Tell subscribers about the committed row, as a successful write would, so
36
+ * a cache of this table (the Tools catalog's) doesn't miss it.
37
+ */
38
+ private reportCommittedLocalWrite;
31
39
  private cachedRecords;
32
40
  get(id: string, opts?: {
33
41
  useCache?: boolean;
@@ -28,6 +28,8 @@ class Table {
28
28
  dataChangedEmitter;
29
29
  dataChanged;
30
30
  localOperationCache = new Map();
31
+ /** Rows committed by an in-progress local write, for {@link reportCommittedLocalWrite}. */
32
+ committedLocalEvents = new Map();
31
33
  dataSource;
32
34
  metaData;
33
35
  groupId;
@@ -79,6 +81,12 @@ class Table {
79
81
  source: "remote",
80
82
  });
81
83
  }
84
+ else {
85
+ // A local write's row is committed, but the write can still fail after it (a
86
+ // tracked table's history write). Keep the event, so a failed write still reports
87
+ // the committed row; a successful one emits its own event instead.
88
+ this.committedLocalEvents.set(recordId, event);
89
+ }
82
90
  });
83
91
  }
84
92
  }
@@ -92,6 +100,17 @@ class Table {
92
100
  clearLocalOperation(recordId, operation) {
93
101
  const cacheKey = `${recordId}-${operation}`;
94
102
  this.localOperationCache.delete(cacheKey);
103
+ this.committedLocalEvents.delete(recordId);
104
+ }
105
+ /**
106
+ * A local write failed after its row was committed (for example, a tracked table's history
107
+ * write failed). Tell subscribers about the committed row, as a successful write would, so
108
+ * a cache of this table (the Tools catalog's) doesn't miss it.
109
+ */
110
+ reportCommittedLocalWrite(recordId) {
111
+ const event = this.committedLocalEvents.get(recordId);
112
+ if (event)
113
+ this.dataChangedEmitter.emit({ ...event, source: "local" });
95
114
  }
96
115
  cachedRecords = {};
97
116
  async get(id, opts = {}) {
@@ -161,6 +180,10 @@ class Table {
161
180
  // TODO: try to avoid the double-read, probably by passing the just-read result as `opts.dbData`
162
181
  result = await this.dataSource.save(record, opts);
163
182
  }
183
+ catch (error) {
184
+ this.reportCommittedLocalWrite(recordId);
185
+ throw error;
186
+ }
164
187
  finally {
165
188
  this.clearLocalOperation(recordId, operation);
166
189
  }
@@ -178,6 +201,10 @@ class Table {
178
201
  try {
179
202
  result = await this.dataSource.insert(record);
180
203
  }
204
+ catch (error) {
205
+ this.reportCommittedLocalWrite(recordId);
206
+ throw error;
207
+ }
181
208
  finally {
182
209
  this.clearLocalOperation(recordId, "insert");
183
210
  }
@@ -196,6 +223,10 @@ class Table {
196
223
  try {
197
224
  result = await this.dataSource.update(record);
198
225
  }
226
+ catch (error) {
227
+ this.reportCommittedLocalWrite(recordId);
228
+ throw error;
229
+ }
199
230
  finally {
200
231
  this.clearLocalOperation(recordId, "update");
201
232
  }
@@ -217,6 +248,10 @@ class Table {
217
248
  try {
218
249
  await this.dataSource.delete(idOrRecord);
219
250
  }
251
+ catch (error) {
252
+ this.reportCommittedLocalWrite(primaryKey);
253
+ throw error;
254
+ }
220
255
  finally {
221
256
  this.clearLocalOperation(primaryKey, "delete");
222
257
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-sdk",
3
- "version": "0.30.4",
3
+ "version": "0.30.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-sdk.git"