@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.
- package/dist/contracts/system/mesh-metrics-types.d.ts +45 -5
- package/dist/data/authority-write-checks.js +8 -0
- package/dist/data/devices.d.ts +97 -0
- package/dist/data/devices.js +100 -1
- package/dist/device/connection.d.ts +6 -1
- package/dist/device/connection.js +11 -4
- package/dist/device/socket-io-binary-peer.d.ts +1 -0
- package/dist/device/socket-io-binary-peer.js +21 -5
- package/dist/types/peer-device.d.ts +5 -1
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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);
|
package/dist/data/devices.d.ts
CHANGED
|
@@ -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>;
|
package/dist/data/devices.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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;
|