@peers-app/peers-sdk 0.30.3 → 0.30.5

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,20 +5,38 @@ 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";
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
- /** Optional observational metadata passed through a dial without affecting its result. */
13
+ /** Local dial metadata and cancellation; never serialized into device messages. */
12
14
  export interface IMeshDial {
15
+ /** Cancels pending transport work; an admitted winner is retained. */
16
+ signal?: AbortSignal;
17
+ /** Scheduler priority: bootstrap, coverage, repair, then redundancy. */
18
+ priority?: 1 | 2 | 3 | 4;
13
19
  attemptId?: string;
14
20
  reason?: MeshDialReason;
15
21
  targetDeviceId?: string;
22
+ /** One-based retry number; absent for the initial dial. */
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;
16
30
  }
17
31
  /** One dial or inbound connection. Missing stages have not been observed. */
18
32
  export interface IMeshAttempt {
19
33
  attemptId: string;
20
34
  targetDeviceId?: string;
21
35
  reason: MeshDialReason;
36
+ /** One-based retry number; absent for initial and incoming attempts. */
37
+ retry?: number;
38
+ /** Scheduler priority when this was planned locally. */
39
+ priority?: 1 | 2 | 3 | 4;
22
40
  direction: "outgoing" | "incoming";
23
41
  path: MeshPath;
24
42
  address?: string;
@@ -33,7 +51,7 @@ export interface IMeshAttempt {
33
51
  };
34
52
  ready: Record<string, number>;
35
53
  firstSync: Record<string, number>;
36
- outcome: "pending" | "admitted" | "failed" | "unfinished";
54
+ outcome: "pending" | "admitted" | "failed" | "cancelled" | "unfinished";
37
55
  endReason?: "buffer-evicted" | "disposed";
38
56
  endedAt?: number;
39
57
  durationMs?: number;
@@ -60,6 +78,10 @@ export interface IMeshContextCoverage {
60
78
  possibleSourceDeviceIds: string[];
61
79
  usableDeviceIds: string[];
62
80
  excluded: boolean;
81
+ /** True when an unmet context cannot currently fit at the connection cap. */
82
+ limitedByCapacity?: boolean;
83
+ /** Planner target; zero means no possible source. */
84
+ target?: number;
63
85
  }
64
86
  /** One connectivity episode, independent of any other episode in flight. */
65
87
  export interface IMeshEpisode {
@@ -69,8 +91,8 @@ export interface IMeshEpisode {
69
91
  startedAt: number;
70
92
  endedAt?: number;
71
93
  durationMs?: number;
72
- outcome: "active" | "covered" | "unfinished";
73
- endReason?: "covered" | "deadline" | "buffer-evicted" | "disposed";
94
+ outcome: "active" | "covered" | "unfinished" | "not-measured";
95
+ endReason?: "covered" | "deadline" | "buffer-evicted" | "disposed" | "page-hidden" | "still-covered";
74
96
  metrics: IMeshDurations;
75
97
  contexts: IMeshContextCoverage[];
76
98
  }
@@ -121,6 +143,22 @@ export interface IMeshStats {
121
143
  probes: number;
122
144
  connected: number;
123
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
+ };
157
+ /** Lifetime episodes excluded from latency percentiles, by reason. */
158
+ unmeasuredEpisodes?: {
159
+ pageHidden: number;
160
+ stillCovered: number;
161
+ };
124
162
  unfinishedAttempts: number;
125
163
  doubleDials: number;
126
164
  offlineDials: number;
@@ -137,6 +175,8 @@ export interface IMeshStats {
137
175
  };
138
176
  recentAttempts: IMeshAttempt[];
139
177
  recentBroadcasts: IMeshBroadcast[];
178
+ /** Latest planner coverage, including zero targets and capacity limitations. */
179
+ coverage?: IMeshContextCoverage[];
140
180
  }
141
181
  /** A discovery broadcast and its observed reply, bounded with the attempt history. */
142
182
  export interface IMeshBroadcast {
@@ -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,
@@ -42,6 +42,8 @@ export declare class Connection {
42
42
  private pendingSendArm;
43
43
  /** Bumped by every {@link Connection.reset}, so a handshake can tell it was cut short. */
44
44
  private handshakeGeneration;
45
+ /** Local close intent, retained through reset so retry owners do not penalize orderly shedding. */
46
+ closeIntent?: "remote" | "policy";
45
47
  constructor(socket: ISocket, localDevice: Device, localDeviceServerAddresses?: string[] | undefined, getTrustLevel?: GetTrustLevel);
46
48
  /** True once the remote identity has been authenticated by the handshake. */
47
49
  get verified(): boolean;
@@ -151,5 +153,8 @@ export declare class Connection {
151
153
  */
152
154
  close(): Promise<void>;
153
155
  }
154
- /** Normalizes a peer address for transport and security comparisons. */
156
+ /** Lowercases a peer address and removes a trailing slash; preserves its scheme. */
155
157
  export declare function normalizeAddress(address: string): string;
158
+ /** Local handshake refusal. Its existing Error text remains compatible with older peers. */
159
+ export declare class ConnectionTrustError extends Error {
160
+ }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Connection = void 0;
3
+ exports.ConnectionTrustError = exports.Connection = void 0;
4
4
  exports.normalizeAddress = normalizeAddress;
5
5
  const user_context_singleton_1 = require("../context/user-context-singleton");
6
6
  const data_1 = require("../data");
@@ -62,6 +62,8 @@ class Connection {
62
62
  pendingSendArm;
63
63
  /** Bumped by every {@link Connection.reset}, so a handshake can tell it was cut short. */
64
64
  handshakeGeneration = 0;
65
+ /** Local close intent, retained through reset so retry owners do not penalize orderly shedding. */
66
+ closeIntent;
65
67
  constructor(socket, localDevice, localDeviceServerAddresses, getTrustLevel = () => Promise.resolve(socket_type_1.TrustLevel.Unknown)) {
66
68
  this.localDevice = localDevice;
67
69
  this.localDeviceServerAddresses = localDeviceServerAddresses;
@@ -76,6 +78,7 @@ class Connection {
76
78
  socket.setPlaintextStreamsAllowed?.(false);
77
79
  // Only the server side of a connection should expose these methods
78
80
  this.exposeRPC("close", () => {
81
+ this.closeIntent ??= "remote";
79
82
  (0, utils_1.sleep)(100).then(() => {
80
83
  this.closeLocal();
81
84
  });
@@ -404,7 +407,7 @@ class Connection {
404
407
  this.assertHandshakeCurrent(generation);
405
408
  if (trustLevel < socket_type_1.TrustLevel.Unknown) {
406
409
  this.resetRemote();
407
- throw new Error("Untrusted connection");
410
+ throw new ConnectionTrustError("Untrusted connection");
408
411
  }
409
412
  // The client seals everything after it reads our response, so receive
410
413
  // can arm now; send arms after the response frame goes out.
@@ -466,7 +469,7 @@ class Connection {
466
469
  this.trustLevel = trustLevel;
467
470
  if (this.trustLevel < socket_type_1.TrustLevel.Unknown) {
468
471
  this.resetRemote();
469
- throw new Error("Untrusted connection");
472
+ throw new ConnectionTrustError("Untrusted connection");
470
473
  }
471
474
  return handshakeResponse;
472
475
  }
@@ -613,7 +616,11 @@ async function noteHandshakeContest(contest) {
613
616
  console.warn("Could not apply a key contest from the handshake", err);
614
617
  }
615
618
  }
616
- /** Normalizes a peer address for transport and security comparisons. */
619
+ /** Lowercases a peer address and removes a trailing slash; preserves its scheme. */
617
620
  function normalizeAddress(address) {
618
621
  return address.toLowerCase().replace(/\/$/, "");
619
622
  }
623
+ /** Local handshake refusal. Its existing Error text remains compatible with older peers. */
624
+ class ConnectionTrustError extends Error {
625
+ }
626
+ exports.ConnectionTrustError = ConnectionTrustError;
@@ -14,6 +14,7 @@ import type { IBinaryPeer } from "./binary-peer-connection-v2";
14
14
  interface ISocketIO {
15
15
  id?: string;
16
16
  on(event: string, handler: (...args: any[]) => void): void;
17
+ off(event: string, handler: (...args: any[]) => void): void;
17
18
  emit(event: string, ...args: any[]): void;
18
19
  removeAllListeners(event?: string): void;
19
20
  disconnect(): void;
@@ -22,8 +22,11 @@ function wrapSocketIOAsBinaryPeer(socket) {
22
22
  const closeHandlers = [];
23
23
  const drainHandlers = [];
24
24
  let pendingBytes = 0;
25
+ let closed = false;
25
26
  // Listen for binary data
26
- socket.on(BINARY_EVENT, (data) => {
27
+ const onData = (data) => {
28
+ if (closed)
29
+ return;
27
30
  // Ensure we have a proper Uint8Array
28
31
  const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
29
32
  dataHandlers.forEach((handler) => {
@@ -34,9 +37,16 @@ function wrapSocketIOAsBinaryPeer(socket) {
34
37
  console.error("[socket-io-binary] Error in data handler:", err);
35
38
  }
36
39
  });
37
- });
40
+ };
38
41
  // Listen for disconnect
39
- socket.on("disconnect", () => {
42
+ const onDisconnect = () => {
43
+ if (closed)
44
+ return;
45
+ closed = true;
46
+ // This adapter belongs to one connection incarnation. A delayed close from it
47
+ // must never disconnect a reconnected Socket.IO socket or remove its listeners.
48
+ socket.off(BINARY_EVENT, onData);
49
+ socket.off("disconnect", onDisconnect);
40
50
  closeHandlers.forEach((handler) => {
41
51
  try {
42
52
  handler();
@@ -45,7 +55,9 @@ function wrapSocketIOAsBinaryPeer(socket) {
45
55
  console.error("[socket-io-binary] Error in close handler:", err);
46
56
  }
47
57
  });
48
- });
58
+ };
59
+ socket.on(BINARY_EVENT, onData);
60
+ socket.on("disconnect", onDisconnect);
49
61
  return {
50
62
  on(event, handler) {
51
63
  if (event === "data") {
@@ -59,6 +71,8 @@ function wrapSocketIOAsBinaryPeer(socket) {
59
71
  }
60
72
  },
61
73
  send(data) {
74
+ if (closed)
75
+ return;
62
76
  const bytes = typeof data === "string"
63
77
  ? Buffer.from(data)
64
78
  : data instanceof Uint8Array
@@ -79,7 +93,9 @@ function wrapSocketIOAsBinaryPeer(socket) {
79
93
  });
80
94
  },
81
95
  destroy() {
82
- socket.removeAllListeners(BINARY_EVENT);
96
+ if (closed)
97
+ return;
98
+ onDisconnect();
83
99
  socket.disconnect();
84
100
  },
85
101
  getBufferedAmount() {
@@ -26,7 +26,11 @@ export interface IDeviceMessage<T = any> {
26
26
  deviceMessageId: string;
27
27
  /** Device that originated the message. */
28
28
  fromDeviceId: string;
29
- /** Signed source identity used to bootstrap fresh same-user devices. */
29
+ /**
30
+ * Signed source identity. Lets a receiver accept a device it hasn't met yet when the device
31
+ * belongs to the receiver's own user (personal context) or to a counted member of the
32
+ * message's group.
33
+ */
30
34
  fromDeviceInfo?: ISignedObject<IDeviceInfo>;
31
35
  /** Intended destination device. */
32
36
  toDeviceId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-sdk",
3
- "version": "0.30.3",
3
+ "version": "0.30.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-sdk.git"