dropspace 0.1.0 → 0.1.1
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/automerge/index.d.ts +6 -1
- package/dist/automerge/index.d.ts.map +1 -1
- package/dist/automerge/index.js +57 -14
- package/dist/automerge/index.js.map +1 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +372 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/channel.d.ts +23 -1
- package/dist/client/channel.d.ts.map +1 -1
- package/dist/client/channel.js +172 -34
- package/dist/client/channel.js.map +1 -1
- package/dist/client/client.d.ts +67 -1
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +287 -42
- package/dist/client/client.js.map +1 -1
- package/dist/client/peer-connection.d.ts +8 -0
- package/dist/client/peer-connection.d.ts.map +1 -1
- package/dist/client/peer-connection.js +72 -18
- package/dist/client/peer-connection.js.map +1 -1
- package/dist/files/index.d.ts +4 -0
- package/dist/files/index.d.ts.map +1 -1
- package/dist/files/index.js +61 -11
- package/dist/files/index.js.map +1 -1
- package/dist/files/sha256.d.ts.map +1 -1
- package/dist/files/sha256.js +22 -8
- package/dist/files/sha256.js.map +1 -1
- package/dist/protocol/envelope.d.ts +1 -1
- package/dist/protocol/envelope.d.ts.map +1 -1
- package/dist/protocol/envelope.js +8 -0
- package/dist/protocol/envelope.js.map +1 -1
- package/dist/protocol/framing.d.ts +15 -2
- package/dist/protocol/framing.d.ts.map +1 -1
- package/dist/protocol/framing.js +66 -14
- package/dist/protocol/framing.js.map +1 -1
- package/package.json +10 -2
package/dist/client/client.js
CHANGED
|
@@ -3,6 +3,28 @@ import { loadOrCreateDeviceIdentity, formatCanonicalChallenge, } from "./identit
|
|
|
3
3
|
import { TypedEventEmitter } from "./events.js";
|
|
4
4
|
import { SmartPeerSelector, } from "./peer-selection.js";
|
|
5
5
|
import { ManagedPeerSession, } from "./peer-connection.js";
|
|
6
|
+
export class DropspaceError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
retryable;
|
|
9
|
+
retryAfterMs;
|
|
10
|
+
status;
|
|
11
|
+
constructor(code, message, opts) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "DropspaceError";
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.retryable = opts?.retryable ?? false;
|
|
16
|
+
this.retryAfterMs = opts?.retryAfterMs;
|
|
17
|
+
this.status = opts?.status;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const PERMANENT_ERROR_CODES = new Set([
|
|
21
|
+
"APP_DISABLED",
|
|
22
|
+
"ORIGIN_NOT_ALLOWED",
|
|
23
|
+
"DEVICE_REVOKED",
|
|
24
|
+
"AUTH_FAILED",
|
|
25
|
+
"AUTH_REQUIRED",
|
|
26
|
+
"SPACE_EXPIRED",
|
|
27
|
+
]);
|
|
6
28
|
let reqCounter = 0;
|
|
7
29
|
function nextRequestId() {
|
|
8
30
|
reqCounter = (reqCounter + 1) % 1_000_000_000;
|
|
@@ -44,7 +66,9 @@ export class DropspaceClient {
|
|
|
44
66
|
hubBySpace = new Map();
|
|
45
67
|
trustedPeers = new Map();
|
|
46
68
|
peerSessions = new Map();
|
|
69
|
+
peersWentOffline = new Set();
|
|
47
70
|
peerQuality = new Map();
|
|
71
|
+
routeByPeer = new Map();
|
|
48
72
|
pendingRequests = new Map();
|
|
49
73
|
hubSeenRelayIds = new Map();
|
|
50
74
|
debugLogs = [];
|
|
@@ -91,12 +115,37 @@ export class DropspaceClient {
|
|
|
91
115
|
getDebugLogs() {
|
|
92
116
|
return this.debugLogs;
|
|
93
117
|
}
|
|
118
|
+
redactDebugValue(val) {
|
|
119
|
+
if (typeof val === "string") {
|
|
120
|
+
return val
|
|
121
|
+
.replace(/dinv_[A-Za-z0-9_-]+/g, "[REDACTED_INVITE_TOKEN]")
|
|
122
|
+
.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [REDACTED]");
|
|
123
|
+
}
|
|
124
|
+
if (Array.isArray(val)) {
|
|
125
|
+
return val.map((item) => this.redactDebugValue(item));
|
|
126
|
+
}
|
|
127
|
+
if (val && typeof val === "object") {
|
|
128
|
+
const out = {};
|
|
129
|
+
for (const [k, v] of Object.entries(val)) {
|
|
130
|
+
if (/token|secret|password|credential|privateKey|sdp/i.test(k)) {
|
|
131
|
+
out[k] = "[REDACTED]";
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
out[k] = this.redactDebugValue(v);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
return val;
|
|
140
|
+
}
|
|
94
141
|
recordDebug(category, summary, details) {
|
|
95
142
|
const entry = {
|
|
96
143
|
timestamp: Date.now(),
|
|
97
144
|
category,
|
|
98
|
-
summary,
|
|
99
|
-
details
|
|
145
|
+
summary: String(this.redactDebugValue(summary)),
|
|
146
|
+
details: details
|
|
147
|
+
? this.redactDebugValue(details)
|
|
148
|
+
: undefined,
|
|
100
149
|
};
|
|
101
150
|
this.debugLogs.push(entry);
|
|
102
151
|
if (this.debugLogs.length > 250) {
|
|
@@ -137,12 +186,60 @@ export class DropspaceClient {
|
|
|
137
186
|
}
|
|
138
187
|
return f.bind(globalThis);
|
|
139
188
|
}
|
|
189
|
+
getCoreHttpUrl() {
|
|
190
|
+
const raw = this.options.coreHttpUrl ?? this.options.baseUrl ?? "";
|
|
191
|
+
if (!raw) {
|
|
192
|
+
throw new DropspaceError("INVALID_ENVELOPE", "DropspaceClient requires coreHttpUrl or baseUrl");
|
|
193
|
+
}
|
|
194
|
+
return raw.replace(/\/+$/, "");
|
|
195
|
+
}
|
|
196
|
+
async getCapabilities(signal) {
|
|
197
|
+
const fetchFn = this.getFetch();
|
|
198
|
+
const baseUrl = this.getCoreHttpUrl();
|
|
199
|
+
const url = this.options.appId
|
|
200
|
+
? `${baseUrl}/api/v1/apps/${encodeURIComponent(this.options.appId)}/capabilities`
|
|
201
|
+
: `${baseUrl}/api/v1/capabilities`;
|
|
202
|
+
const res = await fetchFn(url, { method: "GET", signal });
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
const errBody = (await res.json().catch(() => ({})));
|
|
205
|
+
throw new DropspaceError(errBody.code ?? "INTERNAL_ERROR", errBody.message ?? `Capabilities request failed (${res.status})`, { status: res.status, retryAfterMs: errBody.retryAfterMs });
|
|
206
|
+
}
|
|
207
|
+
return (await res.json());
|
|
208
|
+
}
|
|
209
|
+
async parseHttpError(res, fallbackPrefix) {
|
|
210
|
+
const errBody = (await res.json().catch(() => ({})));
|
|
211
|
+
const retryHeader = res.headers.get("retry-after");
|
|
212
|
+
const retryAfterMs = errBody.retryAfterMs ??
|
|
213
|
+
(retryHeader && /^\d+$/.test(retryHeader) ? Number(retryHeader) * 1000 : undefined);
|
|
214
|
+
const code = errBody.code ?? "INTERNAL_ERROR";
|
|
215
|
+
const retryable = code === "RATE_LIMITED" ||
|
|
216
|
+
code === "SERVICE_OVERLOADED" ||
|
|
217
|
+
res.status === 429 ||
|
|
218
|
+
res.status === 503;
|
|
219
|
+
return new DropspaceError(code, errBody.message ?? `${fallbackPrefix} (${res.status})`, { retryable, retryAfterMs, status: res.status });
|
|
220
|
+
}
|
|
221
|
+
isPermanentStopError(err) {
|
|
222
|
+
if (err instanceof DropspaceError) {
|
|
223
|
+
if (PERMANENT_ERROR_CODES.has(err.code))
|
|
224
|
+
return true;
|
|
225
|
+
if (err.code === "TICKET_EXPIRED" && !this.options.getAccessTicket)
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
140
230
|
async authenticateHttpSession(signal) {
|
|
141
231
|
const id = this.getIdentity();
|
|
142
232
|
const fetchFn = this.getFetch();
|
|
143
|
-
const baseUrl = this.
|
|
233
|
+
const baseUrl = this.getCoreHttpUrl();
|
|
234
|
+
const appId = this.options.appId;
|
|
235
|
+
const accessTicket = this.options.getAccessTicket
|
|
236
|
+
? await this.options.getAccessTicket()
|
|
237
|
+
: undefined;
|
|
238
|
+
const chalUrl = appId
|
|
239
|
+
? `${baseUrl}/api/v1/apps/${encodeURIComponent(appId)}/auth/challenge`
|
|
240
|
+
: `${baseUrl}/api/v1/auth/challenge`;
|
|
144
241
|
// 1. Request short-lived challenge
|
|
145
|
-
const chalRes = await fetchFn(
|
|
242
|
+
const chalRes = await fetchFn(chalUrl, {
|
|
146
243
|
method: "POST",
|
|
147
244
|
headers: { "content-type": "application/json" },
|
|
148
245
|
body: JSON.stringify({
|
|
@@ -151,57 +248,72 @@ export class DropspaceClient {
|
|
|
151
248
|
algorithm: id.algorithm,
|
|
152
249
|
deviceLabel: id.deviceLabel,
|
|
153
250
|
purpose: "session_auth",
|
|
251
|
+
...(appId ? { appId } : {}),
|
|
252
|
+
...(accessTicket ? { accessTicket } : {}),
|
|
154
253
|
}),
|
|
155
254
|
signal,
|
|
156
255
|
});
|
|
157
256
|
if (!chalRes.ok) {
|
|
158
|
-
|
|
159
|
-
throw new Error(`Challenge request failed (${chalRes.status}): ${errBody.code ?? ""} ${errBody.message ?? ""}`);
|
|
257
|
+
throw await this.parseHttpError(chalRes, "Challenge request failed");
|
|
160
258
|
}
|
|
161
259
|
const chalData = (await chalRes.json());
|
|
162
260
|
// 2. Sign canonical challenge with private key
|
|
163
261
|
const canonical = formatCanonicalChallenge(chalData);
|
|
164
262
|
const signature = await id.sign(canonical);
|
|
165
263
|
// 3. Submit signature to obtain sessionToken
|
|
166
|
-
const
|
|
264
|
+
const verifyUrl = appId
|
|
265
|
+
? `${baseUrl}/api/v1/apps/${encodeURIComponent(appId)}/auth/verify`
|
|
266
|
+
: `${baseUrl}/api/v1/auth/verify`;
|
|
267
|
+
const verifyRes = await fetchFn(verifyUrl, {
|
|
167
268
|
method: "POST",
|
|
168
269
|
headers: { "content-type": "application/json" },
|
|
169
270
|
body: JSON.stringify({
|
|
170
271
|
challengeId: chalData.challengeId,
|
|
171
272
|
deviceId: id.deviceId,
|
|
172
273
|
signature,
|
|
274
|
+
...(appId ? { appId } : {}),
|
|
173
275
|
}),
|
|
174
276
|
signal,
|
|
175
277
|
});
|
|
176
278
|
if (!verifyRes.ok) {
|
|
177
|
-
|
|
178
|
-
throw new Error(`Challenge verification failed (${verifyRes.status}): ${errBody.code ?? ""} ${errBody.message ?? ""}`);
|
|
279
|
+
throw await this.parseHttpError(verifyRes, "Challenge verification failed");
|
|
179
280
|
}
|
|
180
281
|
const verifyData = (await verifyRes.json());
|
|
181
282
|
this.sessionToken = verifyData.sessionToken;
|
|
182
283
|
this.sessionId = verifyData.sessionId;
|
|
183
284
|
this.sessionExpiresAt = verifyData.expiresAt;
|
|
184
285
|
this.defaultSpaceId = verifyData.defaultSpaceId;
|
|
185
|
-
this.turnCredentials = verifyData.turnCredentials;
|
|
286
|
+
this.turnCredentials = verifyData.turnCredentials ?? null;
|
|
186
287
|
this.subscribedSpaces.add(verifyData.defaultSpaceId);
|
|
187
288
|
this.recordDebug("security", "Authenticated HTTP challenge-response session", {
|
|
289
|
+
appId: verifyData.appId ?? appId,
|
|
188
290
|
deviceId: id.deviceId,
|
|
189
291
|
sessionId: verifyData.sessionId,
|
|
190
292
|
securityLevel: id.securityLevel,
|
|
191
293
|
});
|
|
192
294
|
}
|
|
193
295
|
getWsUrl() {
|
|
296
|
+
let wsUrl;
|
|
194
297
|
if (this.options.coreWsUrl) {
|
|
195
|
-
|
|
298
|
+
wsUrl = this.options.coreWsUrl;
|
|
196
299
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
300
|
+
else {
|
|
301
|
+
const base = this.getCoreHttpUrl();
|
|
302
|
+
if (base.startsWith("https://")) {
|
|
303
|
+
wsUrl = `wss://${base.slice("https://".length)}/ws`;
|
|
304
|
+
}
|
|
305
|
+
else if (base.startsWith("http://")) {
|
|
306
|
+
wsUrl = `ws://${base.slice("http://".length)}/ws`;
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
wsUrl = `${base}/ws`;
|
|
310
|
+
}
|
|
200
311
|
}
|
|
201
|
-
if (
|
|
202
|
-
|
|
312
|
+
if (this.options.appId && !wsUrl.includes("appId=")) {
|
|
313
|
+
const sep = wsUrl.includes("?") ? "&" : "?";
|
|
314
|
+
wsUrl = `${wsUrl}${sep}appId=${encodeURIComponent(this.options.appId)}`;
|
|
203
315
|
}
|
|
204
|
-
return
|
|
316
|
+
return wsUrl;
|
|
205
317
|
}
|
|
206
318
|
transitionSignalingState(next, reason) {
|
|
207
319
|
if (this.signalingState === next)
|
|
@@ -304,7 +416,17 @@ export class DropspaceClient {
|
|
|
304
416
|
}
|
|
305
417
|
if (env.type === "error" && !settled && this.signalingState === "authenticating") {
|
|
306
418
|
settled = true;
|
|
307
|
-
|
|
419
|
+
const errCode = String(env.payload.code ?? "AUTH_FAILED");
|
|
420
|
+
const errMsg = String(env.payload.message ?? "WebSocket authentication failed");
|
|
421
|
+
const retryAfterMs = typeof env.payload.retryAfterMs === "number" ? env.payload.retryAfterMs : undefined;
|
|
422
|
+
const err = new DropspaceError(errCode, errMsg, {
|
|
423
|
+
retryable: Boolean(env.payload.retryable),
|
|
424
|
+
retryAfterMs,
|
|
425
|
+
});
|
|
426
|
+
if (this.isPermanentStopError(err)) {
|
|
427
|
+
this.manuallyClosed = true;
|
|
428
|
+
}
|
|
429
|
+
reject(err);
|
|
308
430
|
return;
|
|
309
431
|
}
|
|
310
432
|
this.handleIncomingSignalingEnvelope(env);
|
|
@@ -343,25 +465,46 @@ export class DropspaceClient {
|
|
|
343
465
|
}
|
|
344
466
|
});
|
|
345
467
|
}
|
|
346
|
-
scheduleSignalingReconnect() {
|
|
468
|
+
scheduleSignalingReconnect(serverRetryAfterMs) {
|
|
347
469
|
if (this.manuallyClosed || this.wsReconnectTimer)
|
|
348
470
|
return;
|
|
471
|
+
const maxAttempts = this.options.maxReconnectAttempts ?? 20;
|
|
472
|
+
if (this.wsReconnectAttempt >= maxAttempts) {
|
|
473
|
+
this.transitionSignalingState("closed", "max_reconnect_attempts_reached");
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
349
476
|
this.wsReconnectAttempt += 1;
|
|
350
|
-
const base = this.options.baseBackoffMs ??
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
|
|
477
|
+
const base = this.options.baseBackoffMs ?? 100;
|
|
478
|
+
const cap = 30_000;
|
|
479
|
+
const expCeiling = Math.min(cap, base * Math.pow(2, this.wsReconnectAttempt - 1));
|
|
480
|
+
// Full jitter prevents reconnect storms after outages
|
|
481
|
+
const fullJitterDelay = Math.floor(Math.random() * expCeiling);
|
|
482
|
+
const delay = typeof serverRetryAfterMs === "number" && serverRetryAfterMs > fullJitterDelay
|
|
483
|
+
? serverRetryAfterMs
|
|
484
|
+
: fullJitterDelay;
|
|
354
485
|
this.wsReconnectTimer = setTimeout(async () => {
|
|
355
486
|
this.wsReconnectTimer = null;
|
|
356
487
|
if (this.manuallyClosed)
|
|
357
488
|
return;
|
|
358
489
|
try {
|
|
359
|
-
// Refresh HTTP session if expired or rejected
|
|
360
490
|
await this.authenticateHttpSession();
|
|
361
491
|
await this.connectSignalingWebSocket();
|
|
362
492
|
}
|
|
363
|
-
catch {
|
|
364
|
-
this.
|
|
493
|
+
catch (err) {
|
|
494
|
+
if (this.isPermanentStopError(err)) {
|
|
495
|
+
this.manuallyClosed = true;
|
|
496
|
+
this.transitionSignalingState("closed", "permanent_auth_or_policy_error");
|
|
497
|
+
if (err instanceof DropspaceError) {
|
|
498
|
+
this.events.emit("error", {
|
|
499
|
+
code: err.code,
|
|
500
|
+
message: err.message,
|
|
501
|
+
retryable: false,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const retryMs = err instanceof DropspaceError ? err.retryAfterMs : undefined;
|
|
507
|
+
this.scheduleSignalingReconnect(retryMs);
|
|
365
508
|
}
|
|
366
509
|
}, delay);
|
|
367
510
|
}
|
|
@@ -420,13 +563,20 @@ export class DropspaceClient {
|
|
|
420
563
|
clearTimeout(pend.timer);
|
|
421
564
|
this.pendingRequests.delete(env.requestId);
|
|
422
565
|
if (env.type === "error") {
|
|
423
|
-
const
|
|
424
|
-
|
|
566
|
+
const code = String(env.payload.code ?? "UNKNOWN_ERROR");
|
|
567
|
+
const message = String(env.payload.message ?? "Signaling error");
|
|
568
|
+
const retryable = Boolean(env.payload.retryable);
|
|
569
|
+
const retryAfterMs = typeof env.payload.retryAfterMs === "number" ? env.payload.retryAfterMs : undefined;
|
|
570
|
+
const err = new DropspaceError(code, `${code}: ${message}`, {
|
|
571
|
+
retryable,
|
|
572
|
+
retryAfterMs,
|
|
573
|
+
});
|
|
425
574
|
pend.reject(err);
|
|
426
575
|
this.events.emit("error", {
|
|
427
|
-
code
|
|
428
|
-
message
|
|
429
|
-
retryable
|
|
576
|
+
code,
|
|
577
|
+
message,
|
|
578
|
+
retryable,
|
|
579
|
+
retryAfterMs,
|
|
430
580
|
});
|
|
431
581
|
return;
|
|
432
582
|
}
|
|
@@ -438,6 +588,7 @@ export class DropspaceClient {
|
|
|
438
588
|
code: String(env.payload.code),
|
|
439
589
|
message: String(env.payload.message),
|
|
440
590
|
retryable: Boolean(env.payload.retryable),
|
|
591
|
+
retryAfterMs: typeof env.payload.retryAfterMs === "number" ? env.payload.retryAfterMs : undefined,
|
|
441
592
|
});
|
|
442
593
|
break;
|
|
443
594
|
}
|
|
@@ -512,17 +663,35 @@ export class DropspaceClient {
|
|
|
512
663
|
});
|
|
513
664
|
}
|
|
514
665
|
const existingSession = this.peerSessions.get(peer.deviceId);
|
|
515
|
-
if (!peer.online
|
|
516
|
-
|
|
666
|
+
if (!peer.online) {
|
|
667
|
+
this.peersWentOffline.add(peer.deviceId);
|
|
668
|
+
if (existingSession &&
|
|
669
|
+
existingSession.getState() !== "connected" &&
|
|
670
|
+
!existingSession.isManuallyClosed()) {
|
|
671
|
+
existingSession.setWaitingForPeer("peer_went_offline");
|
|
672
|
+
}
|
|
517
673
|
}
|
|
518
|
-
else
|
|
519
|
-
|
|
520
|
-
(
|
|
521
|
-
existingSession
|
|
522
|
-
(
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
674
|
+
else {
|
|
675
|
+
const returnedFromOffline = this.peersWentOffline.delete(peer.deviceId);
|
|
676
|
+
if (returnedFromOffline &&
|
|
677
|
+
existingSession &&
|
|
678
|
+
existingSession.getState() === "connected" &&
|
|
679
|
+
!existingSession.isManuallyClosed() &&
|
|
680
|
+
(this.options.autoConnectTrustedPeers ?? true) &&
|
|
681
|
+
this.trustedPeers.has(peer.deviceId) &&
|
|
682
|
+
(this.identity?.deviceId ?? "") < peer.deviceId) {
|
|
683
|
+
void existingSession.forceReconnect("peer_returned_online").catch(() => { });
|
|
684
|
+
}
|
|
685
|
+
else if (peer.deviceId !== this.identity?.deviceId &&
|
|
686
|
+
!existingSession?.isManuallyClosed() &&
|
|
687
|
+
(existingSession?.getState() === "waiting_for_peer" ||
|
|
688
|
+
existingSession?.getState() === "reconnecting" ||
|
|
689
|
+
existingSession?.getState() === "failed" ||
|
|
690
|
+
((this.options.autoConnectTrustedPeers ?? true) &&
|
|
691
|
+
this.trustedPeers.has(peer.deviceId) &&
|
|
692
|
+
(this.identity?.deviceId ?? "") < peer.deviceId))) {
|
|
693
|
+
void this.connect(peer.deviceId, { spaceId }).catch(() => { });
|
|
694
|
+
}
|
|
526
695
|
}
|
|
527
696
|
break;
|
|
528
697
|
}
|
|
@@ -602,12 +771,19 @@ export class DropspaceClient {
|
|
|
602
771
|
this.trustedPeers.delete(rev.revokedDeviceId);
|
|
603
772
|
// Immediately terminate active P2P session if the revoked device is the remote peer OR us!
|
|
604
773
|
if (rev.revokedDeviceId === this.identity?.deviceId) {
|
|
774
|
+
this.peersBySpace.delete(rev.spaceId);
|
|
775
|
+
for (const [key, trusted] of this.trustedPeers.entries()) {
|
|
776
|
+
if (trusted.spaceId === rev.spaceId) {
|
|
777
|
+
this.trustedPeers.delete(key);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
605
780
|
for (const [peerId, sess] of this.peerSessions.entries()) {
|
|
606
781
|
sess.close(false);
|
|
607
782
|
this.peerSessions.delete(peerId);
|
|
608
783
|
}
|
|
609
784
|
}
|
|
610
785
|
else {
|
|
786
|
+
this.peersBySpace.get(rev.spaceId)?.delete(rev.revokedDeviceId);
|
|
611
787
|
const targetSess = this.peerSessions.get(rev.revokedDeviceId);
|
|
612
788
|
if (targetSess) {
|
|
613
789
|
targetSess.close(false);
|
|
@@ -810,7 +986,7 @@ export class DropspaceClient {
|
|
|
810
986
|
if (!this.sessionToken)
|
|
811
987
|
return Array.from(this.trustedPeers.values());
|
|
812
988
|
const fetchFn = this.getFetch();
|
|
813
|
-
const baseUrl = this.
|
|
989
|
+
const baseUrl = this.getCoreHttpUrl();
|
|
814
990
|
const res = await fetchFn(`${baseUrl}/api/v1/devices/trusted`, {
|
|
815
991
|
method: "GET",
|
|
816
992
|
headers: {
|
|
@@ -860,6 +1036,11 @@ export class DropspaceClient {
|
|
|
860
1036
|
credential: this.turnCredentials.credential,
|
|
861
1037
|
});
|
|
862
1038
|
}
|
|
1039
|
+
if (this.options.byoIceServers && this.options.byoIceServers.length > 0) {
|
|
1040
|
+
for (const s of this.options.byoIceServers) {
|
|
1041
|
+
servers.push(s);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
863
1044
|
return servers;
|
|
864
1045
|
}
|
|
865
1046
|
getOrCreateManagedPeerSession(remoteDeviceId, spaceId) {
|
|
@@ -922,18 +1103,35 @@ export class DropspaceClient {
|
|
|
922
1103
|
qual.rttMs = ev.rttMs;
|
|
923
1104
|
}
|
|
924
1105
|
this.peerQuality.set(remoteDeviceId, qual);
|
|
1106
|
+
this.routeByPeer.set(remoteDeviceId, {
|
|
1107
|
+
candidateType: ev.candidateType,
|
|
1108
|
+
protocol: ev.protocol,
|
|
1109
|
+
rttMs: ev.rttMs,
|
|
1110
|
+
});
|
|
925
1111
|
this.recordDebug("webrtc", `Selected ${ev.candidateType} route for ${remoteDeviceId}`, {
|
|
926
1112
|
candidateType: ev.candidateType,
|
|
927
1113
|
rttMs: ev.rttMs,
|
|
928
1114
|
});
|
|
929
1115
|
});
|
|
930
1116
|
session.multiplexer.events.on("message", (msg) => {
|
|
1117
|
+
if (!this.hasPeerPermission(remoteDeviceId, "messaging", spaceId)) {
|
|
1118
|
+
this.recordDebug("security", `Dropped incoming message from unauthorized peer ${remoteDeviceId}`);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
931
1121
|
this.events.emit("message", msg);
|
|
932
1122
|
});
|
|
933
1123
|
session.multiplexer.events.on("transferFrame", (frame) => {
|
|
1124
|
+
if (!this.hasPeerPermission(remoteDeviceId, "files", spaceId)) {
|
|
1125
|
+
this.recordDebug("security", `Dropped incoming transferFrame from unauthorized peer ${remoteDeviceId}`);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
934
1128
|
this.events.emit("transferFrame", { peerDeviceId: remoteDeviceId, frame });
|
|
935
1129
|
});
|
|
936
1130
|
session.multiplexer.events.on("crdtFrame", (frame) => {
|
|
1131
|
+
if (!this.hasPeerPermission(remoteDeviceId, "crdt", spaceId)) {
|
|
1132
|
+
this.recordDebug("security", `Dropped incoming crdtFrame from unauthorized peer ${remoteDeviceId}`);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
937
1135
|
this.events.emit("crdtFrame", { peerDeviceId: remoteDeviceId, frame });
|
|
938
1136
|
});
|
|
939
1137
|
session.multiplexer.events.on("hubRelayFrame", (frame) => {
|
|
@@ -1062,6 +1260,15 @@ export class DropspaceClient {
|
|
|
1062
1260
|
getPeerSession(peerDeviceId) {
|
|
1063
1261
|
return this.peerSessions.get(peerDeviceId);
|
|
1064
1262
|
}
|
|
1263
|
+
getConnectedPeerIds() {
|
|
1264
|
+
const ids = [];
|
|
1265
|
+
for (const [peerId, sess] of this.peerSessions.entries()) {
|
|
1266
|
+
if (sess.getState() === "connected") {
|
|
1267
|
+
ids.push(peerId);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return ids;
|
|
1271
|
+
}
|
|
1065
1272
|
async sendBinaryMessage(peerDeviceId, payload, options = {}) {
|
|
1066
1273
|
if (options.viaHubDeviceId) {
|
|
1067
1274
|
const hubSess = this.peerSessions.get(options.viaHubDeviceId);
|
|
@@ -1148,8 +1355,10 @@ export class DropspaceClient {
|
|
|
1148
1355
|
this.pendingRequests.clear();
|
|
1149
1356
|
for (const sess of this.peerSessions.values()) {
|
|
1150
1357
|
sess.close(false);
|
|
1358
|
+
sess.multiplexer.dispose();
|
|
1151
1359
|
}
|
|
1152
1360
|
this.peerSessions.clear();
|
|
1361
|
+
this.routeByPeer.clear();
|
|
1153
1362
|
if (this.ws) {
|
|
1154
1363
|
try {
|
|
1155
1364
|
this.ws.close(1000, "client_closed");
|
|
@@ -1160,6 +1369,42 @@ export class DropspaceClient {
|
|
|
1160
1369
|
this.transitionSignalingState("closed", "manual_close");
|
|
1161
1370
|
this.events.removeAllListeners();
|
|
1162
1371
|
}
|
|
1372
|
+
hasPeerPermission(peerDeviceId, permission, spaceId) {
|
|
1373
|
+
if (spaceId) {
|
|
1374
|
+
if (this.revokedPeersBySpace.get(spaceId)?.has(peerDeviceId)) {
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1377
|
+
const presence = this.peersBySpace.get(spaceId)?.get(peerDeviceId);
|
|
1378
|
+
if (presence) {
|
|
1379
|
+
return presence.permissions.includes(permission);
|
|
1380
|
+
}
|
|
1381
|
+
for (const trusted of this.trustedPeers.values()) {
|
|
1382
|
+
if (trusted.peerDeviceId === peerDeviceId && trusted.spaceId === spaceId) {
|
|
1383
|
+
return trusted.permissions.includes(permission);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
return false;
|
|
1387
|
+
}
|
|
1388
|
+
for (const [spId, spacePeers] of this.peersBySpace.entries()) {
|
|
1389
|
+
if (this.revokedPeersBySpace.get(spId)?.has(peerDeviceId))
|
|
1390
|
+
continue;
|
|
1391
|
+
const presence = spacePeers.get(peerDeviceId);
|
|
1392
|
+
if (presence && presence.permissions.includes(permission)) {
|
|
1393
|
+
return true;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
for (const trusted of this.trustedPeers.values()) {
|
|
1397
|
+
if (trusted.peerDeviceId === peerDeviceId &&
|
|
1398
|
+
!this.revokedPeersBySpace.get(trusted.spaceId)?.has(peerDeviceId) &&
|
|
1399
|
+
trusted.permissions.includes(permission)) {
|
|
1400
|
+
return true;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
return false;
|
|
1404
|
+
}
|
|
1405
|
+
getPeerRouteInfo(peerDeviceId) {
|
|
1406
|
+
return this.routeByPeer.get(peerDeviceId) ?? null;
|
|
1407
|
+
}
|
|
1163
1408
|
getActiveResourceCounts() {
|
|
1164
1409
|
return {
|
|
1165
1410
|
listenerCount: this.events.listenerCount(),
|