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.
@@ -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.options.coreHttpUrl.replace(/\/+$/, "");
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(`${baseUrl}/api/v1/auth/challenge`, {
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
- const errBody = (await chalRes.json().catch(() => ({})));
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 verifyRes = await fetchFn(`${baseUrl}/api/v1/auth/verify`, {
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
- const errBody = (await verifyRes.json().catch(() => ({})));
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
- return this.options.coreWsUrl;
298
+ wsUrl = this.options.coreWsUrl;
196
299
  }
197
- const base = this.options.coreHttpUrl.replace(/\/+$/, "");
198
- if (base.startsWith("https://")) {
199
- return `wss://${base.slice("https://".length)}/ws`;
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 (base.startsWith("http://")) {
202
- return `ws://${base.slice("http://".length)}/ws`;
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 `${base}/ws`;
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
- reject(new Error(`WebSocket authentication failed: ${String(env.payload.code)} - ${String(env.payload.message)}`));
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 ?? 200;
351
- const exp = Math.min(5000, base * Math.pow(2, this.wsReconnectAttempt - 1));
352
- const jitter = Math.floor(Math.random() * 100);
353
- const delay = exp + jitter;
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.scheduleSignalingReconnect();
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 err = new Error(`${String(env.payload.code)}: ${String(env.payload.message)}`);
424
- err.code = String(env.payload.code);
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: String(env.payload.code),
428
- message: String(env.payload.message),
429
- retryable: Boolean(env.payload.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 && existingSession && existingSession.getState() !== "connected") {
516
- existingSession.setWaitingForPeer("peer_went_offline");
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 if (peer.online &&
519
- peer.deviceId !== this.identity?.deviceId &&
520
- (existingSession?.getState() === "waiting_for_peer" ||
521
- existingSession?.getState() === "reconnecting" ||
522
- ((this.options.autoConnectTrustedPeers ?? true) &&
523
- this.trustedPeers.has(peer.deviceId) &&
524
- (this.identity?.deviceId ?? "") < peer.deviceId))) {
525
- void this.connect(peer.deviceId, { spaceId }).catch(() => { });
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.options.coreHttpUrl.replace(/\/+$/, "");
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(),