@comapeo/core 3.0.0 → 3.1.0

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/src/member-api.js CHANGED
@@ -19,7 +19,12 @@ import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js'
19
19
  import { ErrorWithCode, getErrorMessage } from './lib/error.js'
20
20
  import { InviteAbortedError } from './errors.js'
21
21
  import { wsCoreReplicator } from './lib/ws-core-replicator.js'
22
- import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js'
22
+ import {
23
+ BLOCKED_ROLE_ID,
24
+ MEMBER_ROLE_ID,
25
+ ROLES,
26
+ isRoleIdForNewInvite,
27
+ } from './roles.js'
23
28
  /**
24
29
  * @import {
25
30
  * DeviceInfo,
@@ -57,6 +62,7 @@ export class MemberApi extends TypedEmitter {
57
62
  #getProjectName
58
63
  #projectKey
59
64
  #rpc
65
+ #makeWebsocket
60
66
  #getReplicationStream
61
67
  #waitForInitialSyncWithPeer
62
68
  #dataTypes
@@ -73,6 +79,7 @@ export class MemberApi extends TypedEmitter {
73
79
  * @param {() => Promisable<undefined | string>} opts.getProjectName
74
80
  * @param {Buffer} opts.projectKey
75
81
  * @param {import('./local-peers.js').LocalPeers} opts.rpc
82
+ * @param {(url: string) => WebSocket} [opts.makeWebsocket]
76
83
  * @param {() => ReplicationStream} opts.getReplicationStream
77
84
  * @param {(deviceId: string, abortSignal: AbortSignal) => Promise<void>} opts.waitForInitialSyncWithPeer
78
85
  * @param {Object} opts.dataTypes
@@ -87,6 +94,7 @@ export class MemberApi extends TypedEmitter {
87
94
  getProjectName,
88
95
  projectKey,
89
96
  rpc,
97
+ makeWebsocket = (url) => new WebSocket(url),
90
98
  getReplicationStream,
91
99
  waitForInitialSyncWithPeer,
92
100
  dataTypes,
@@ -99,6 +107,7 @@ export class MemberApi extends TypedEmitter {
99
107
  this.#getProjectName = getProjectName
100
108
  this.#projectKey = projectKey
101
109
  this.#rpc = rpc
110
+ this.#makeWebsocket = makeWebsocket
102
111
  this.#getReplicationStream = getReplicationStream
103
112
  this.#waitForInitialSyncWithPeer = waitForInitialSyncWithPeer
104
113
  this.#dataTypes = dataTypes
@@ -157,12 +166,17 @@ export class MemberApi extends TypedEmitter {
157
166
  const projectName = project.name
158
167
  assert(projectName, 'Project must have a name to invite people')
159
168
 
169
+ const projectColor = project.projectColor
170
+ const projectDescription = project.projectDescription
171
+
160
172
  abortSignal.throwIfAborted()
161
173
 
162
174
  const invite = {
163
175
  inviteId,
164
176
  projectInviteId,
165
177
  projectName,
178
+ projectColor,
179
+ projectDescription,
166
180
  roleName,
167
181
  roleDescription,
168
182
  invitorName,
@@ -186,18 +200,16 @@ export class MemberApi extends TypedEmitter {
186
200
  case InviteResponse_Decision.DECISION_UNSPECIFIED:
187
201
  return InviteResponse_Decision.REJECT
188
202
  case InviteResponse_Decision.ACCEPT:
189
- // We should assign the role locally *before* sharing the project details
190
- // so that they're part of the project even if they don't receive the
191
- // project details message.
192
-
193
- await this.#roles.assignRole(deviceId, roleId)
194
-
195
203
  await this.#rpc.sendProjectJoinDetails(deviceId, {
196
204
  inviteId,
197
205
  projectKey: this.#projectKey,
198
206
  encryptionKeys: this.#encryptionKeys,
199
207
  })
200
208
 
209
+ // Only add after we know they got the details
210
+ // Otherwise the joiner will be stuck unable to join
211
+ await this.#roles.assignRole(deviceId, roleId)
212
+
201
213
  return inviteResponse.decision
202
214
  default:
203
215
  throw new ExhaustivenessError(inviteResponse.decision)
@@ -314,6 +326,46 @@ export class MemberApi extends TypedEmitter {
314
326
  })
315
327
  }
316
328
 
329
+ /**
330
+ * Remove a server peer. Only works when the peer is reachable
331
+ *
332
+ * @param {string} serverDeviceId
333
+ * @param {object} [options]
334
+ * @param {boolean} [options.dangerouslyAllowInsecureConnections] Allow insecure network connections. Should only be used in tests.
335
+ * @returns {Promise<void>}
336
+ */
337
+ async removeServerPeer(
338
+ serverDeviceId,
339
+ { dangerouslyAllowInsecureConnections = false } = {}
340
+ ) {
341
+ // Get device ID for URL
342
+ // Parse through URL to ensure end pathname if missing
343
+ const member = await this.getById(serverDeviceId)
344
+
345
+ if (!member.selfHostedServerDetails) {
346
+ throw new ErrorWithCode(
347
+ 'DEVICE_ID_NOT_FOR_SERVER',
348
+ 'DeviceId is not for a server peer'
349
+ )
350
+ }
351
+
352
+ if (member.role.roleId === BLOCKED_ROLE_ID) {
353
+ throw new ErrorWithCode('ALREADY_BLOCKED', 'Server peer already blocked')
354
+ }
355
+
356
+ const { baseUrl } = member.selfHostedServerDetails
357
+
358
+ // Add blocked role to project
359
+ await this.#roles.assignRole(serverDeviceId, BLOCKED_ROLE_ID)
360
+
361
+ // TODO: Catch fail and sync with server after
362
+ await this.#waitForInitialSyncWithServer({
363
+ baseUrl,
364
+ serverDeviceId,
365
+ dangerouslyAllowInsecureConnections,
366
+ })
367
+ }
368
+
317
369
  /**
318
370
  * @param {string} baseUrl Server base URL. Should already be validated.
319
371
  * @returns {Promise<{ serverDeviceId: string }>}
@@ -378,7 +430,7 @@ export class MemberApi extends TypedEmitter {
378
430
  ? 'ws:'
379
431
  : 'wss:'
380
432
 
381
- const websocket = new WebSocket(websocketUrl)
433
+ const websocket = this.#makeWebsocket(websocketUrl.href)
382
434
 
383
435
  try {
384
436
  await pEvent(websocket, 'open', { rejectionEvents: ['error'] })
@@ -400,7 +452,7 @@ export class MemberApi extends TypedEmitter {
400
452
  const onErrorPromise = pEvent(websocket, 'error')
401
453
 
402
454
  const replicationStream = this.#getReplicationStream()
403
- wsCoreReplicator(websocket, replicationStream)
455
+ const streamPromise = wsCoreReplicator(websocket, replicationStream)
404
456
 
405
457
  const syncAbortController = new AbortController()
406
458
  const syncPromise = this.#waitForInitialSyncWithPeer(
@@ -408,7 +460,11 @@ export class MemberApi extends TypedEmitter {
408
460
  syncAbortController.signal
409
461
  )
410
462
 
411
- const errorEvent = await Promise.race([onErrorPromise, syncPromise])
463
+ const errorEvent = await Promise.race([
464
+ onErrorPromise,
465
+ syncPromise,
466
+ streamPromise,
467
+ ])
412
468
 
413
469
  if (errorEvent) {
414
470
  syncAbortController.abort()
@@ -445,6 +501,7 @@ export class MemberApi extends TypedEmitter {
445
501
  result.name = deviceInfo.name
446
502
  result.deviceType = deviceInfo.deviceType
447
503
  result.joinedAt = deviceInfo.createdAt
504
+ result.selfHostedServerDetails = deviceInfo.selfHostedServerDetails
448
505
  } catch (err) {
449
506
  // Attempting to get someone else may throw because sync hasn't occurred or completed
450
507
  // Only throw if attempting to get themself since the relevant information should be available
@@ -7,9 +7,10 @@ import { jsonSchemaToDrizzleColumns as toColumns } from './schema-to-drizzle.js'
7
7
  import { backlinkTable, customJson } from './utils.js'
8
8
 
9
9
  /**
10
+ * @import { ProjectSettings } from '@comapeo/schema'
11
+ *
10
12
  * @internal
11
- * @typedef {object} ProjectInfo
12
- * @prop {string} [name]
13
+ * @typedef {Pick<ProjectSettings, 'name' | 'projectColor' | 'projectDescription'>} ProjectInfo
13
14
  */
14
15
 
15
16
  const projectInfoColumn =
@@ -99,12 +99,14 @@ export class SyncApi extends TypedEmitter {
99
99
  #getReplicationStream
100
100
  /** @type {Map<string, WebSocket>} */
101
101
  #serverWebsockets = new Map()
102
+ #makeWebsocket
102
103
 
103
104
  /**
104
105
  * @param {object} opts
105
106
  * @param {import('../core-manager/index.js').CoreManager} opts.coreManager
106
107
  * @param {CoreOwnership} opts.coreOwnership
107
108
  * @param {import('../roles.js').Roles} opts.roles
109
+ * @param {(url: string) => WebSocket} [opts.makeWebsocket]
108
110
  * @param {() => Promise<Iterable<string>>} opts.getServerWebsocketUrls
109
111
  * @param {() => ReplicationStream} opts.getReplicationStream
110
112
  * @param {import('../blob-store/index.js').BlobStore} opts.blobStore
@@ -115,6 +117,7 @@ export class SyncApi extends TypedEmitter {
115
117
  coreManager,
116
118
  throttleMs = 200,
117
119
  roles,
120
+ makeWebsocket = (url) => new WebSocket(url),
118
121
  getServerWebsocketUrls,
119
122
  getReplicationStream,
120
123
  logger,
@@ -126,6 +129,7 @@ export class SyncApi extends TypedEmitter {
126
129
  this.#coreManager = coreManager
127
130
  this.#coreOwnership = coreOwnership
128
131
  this.#roles = roles
132
+ this.#makeWebsocket = makeWebsocket
129
133
  this.#getServerWebsocketUrls = getServerWebsocketUrls
130
134
  this.#getReplicationStream = getReplicationStream
131
135
  this[kSyncState] = new SyncState({
@@ -332,7 +336,7 @@ export class SyncApi extends TypedEmitter {
332
336
  continue
333
337
  }
334
338
 
335
- const websocket = new WebSocket(url)
339
+ const websocket = this.#makeWebsocket(url)
336
340
 
337
341
  /** @param {Error} err */
338
342
  const onWebsocketError = (err) => {
@@ -354,7 +358,7 @@ export class SyncApi extends TypedEmitter {
354
358
  websocket.on('unexpected-response', onWebsocketUnexpectedResponse)
355
359
 
356
360
  const replicationStream = this.#getReplicationStream()
357
- wsCoreReplicator(websocket, replicationStream)
361
+ wsCoreReplicator(websocket, replicationStream).catch(noop)
358
362
 
359
363
  this.#serverWebsockets.set(url, websocket)
360
364
  websocket.once('close', () => {