@comapeo/core 3.0.0 → 3.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/src/member-api.js CHANGED
@@ -17,9 +17,19 @@ import { abortSignalAny } from './lib/ponyfills.js'
17
17
  import timingSafeEqual from 'string-timing-safe-equal'
18
18
  import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js'
19
19
  import { ErrorWithCode, getErrorMessage } from './lib/error.js'
20
- import { InviteAbortedError } from './errors.js'
20
+ import {
21
+ InviteAbortedError,
22
+ InviteInitialSyncFailError,
23
+ ProjectDetailsSendFailError,
24
+ } from './errors.js'
21
25
  import { wsCoreReplicator } from './lib/ws-core-replicator.js'
22
- import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js'
26
+ import {
27
+ BLOCKED_ROLE_ID,
28
+ FAILED_ROLE_ID,
29
+ MEMBER_ROLE_ID,
30
+ ROLES,
31
+ isRoleIdForNewInvite,
32
+ } from './roles.js'
23
33
  /**
24
34
  * @import {
25
35
  * DeviceInfo,
@@ -57,6 +67,7 @@ export class MemberApi extends TypedEmitter {
57
67
  #getProjectName
58
68
  #projectKey
59
69
  #rpc
70
+ #makeWebsocket
60
71
  #getReplicationStream
61
72
  #waitForInitialSyncWithPeer
62
73
  #dataTypes
@@ -73,6 +84,7 @@ export class MemberApi extends TypedEmitter {
73
84
  * @param {() => Promisable<undefined | string>} opts.getProjectName
74
85
  * @param {Buffer} opts.projectKey
75
86
  * @param {import('./local-peers.js').LocalPeers} opts.rpc
87
+ * @param {(url: string) => WebSocket} [opts.makeWebsocket]
76
88
  * @param {() => ReplicationStream} opts.getReplicationStream
77
89
  * @param {(deviceId: string, abortSignal: AbortSignal) => Promise<void>} opts.waitForInitialSyncWithPeer
78
90
  * @param {Object} opts.dataTypes
@@ -87,6 +99,7 @@ export class MemberApi extends TypedEmitter {
87
99
  getProjectName,
88
100
  projectKey,
89
101
  rpc,
102
+ makeWebsocket = (url) => new WebSocket(url),
90
103
  getReplicationStream,
91
104
  waitForInitialSyncWithPeer,
92
105
  dataTypes,
@@ -99,6 +112,7 @@ export class MemberApi extends TypedEmitter {
99
112
  this.#getProjectName = getProjectName
100
113
  this.#projectKey = projectKey
101
114
  this.#rpc = rpc
115
+ this.#makeWebsocket = makeWebsocket
102
116
  this.#getReplicationStream = getReplicationStream
103
117
  this.#waitForInitialSyncWithPeer = waitForInitialSyncWithPeer
104
118
  this.#dataTypes = dataTypes
@@ -157,12 +171,17 @@ export class MemberApi extends TypedEmitter {
157
171
  const projectName = project.name
158
172
  assert(projectName, 'Project must have a name to invite people')
159
173
 
174
+ const projectColor = project.projectColor
175
+ const projectDescription = project.projectDescription
176
+
160
177
  abortSignal.throwIfAborted()
161
178
 
162
179
  const invite = {
163
180
  inviteId,
164
181
  projectInviteId,
165
182
  projectName,
183
+ projectColor,
184
+ projectDescription,
166
185
  roleName,
167
186
  roleDescription,
168
187
  invitorName,
@@ -186,17 +205,33 @@ export class MemberApi extends TypedEmitter {
186
205
  case InviteResponse_Decision.DECISION_UNSPECIFIED:
187
206
  return InviteResponse_Decision.REJECT
188
207
  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
-
208
+ // Technically we can assign after sending project details
209
+ // This lets us test role removal
193
210
  await this.#roles.assignRole(deviceId, roleId)
194
211
 
195
- await this.#rpc.sendProjectJoinDetails(deviceId, {
196
- inviteId,
197
- projectKey: this.#projectKey,
198
- encryptionKeys: this.#encryptionKeys,
199
- })
212
+ try {
213
+ await this.#rpc.sendProjectJoinDetails(deviceId, {
214
+ inviteId,
215
+ projectKey: this.#projectKey,
216
+ encryptionKeys: this.#encryptionKeys,
217
+ })
218
+ } catch {
219
+ try {
220
+ // Mark them as "failed" so we can retry the flow
221
+ await this.#roles.assignRole(deviceId, FAILED_ROLE_ID)
222
+ } catch (e) {
223
+ console.error(e)
224
+ }
225
+ throw new ProjectDetailsSendFailError()
226
+ }
227
+
228
+ try {
229
+ await this.#waitForInitialSyncWithPeer(deviceId, abortSignal)
230
+ } catch {
231
+ // Mark them as "failed" so we can retry the flow
232
+ await this.#roles.assignRole(deviceId, FAILED_ROLE_ID)
233
+ throw new InviteInitialSyncFailError()
234
+ }
200
235
 
201
236
  return inviteResponse.decision
202
237
  default:
@@ -314,6 +349,46 @@ export class MemberApi extends TypedEmitter {
314
349
  })
315
350
  }
316
351
 
352
+ /**
353
+ * Remove a server peer. Only works when the peer is reachable
354
+ *
355
+ * @param {string} serverDeviceId
356
+ * @param {object} [options]
357
+ * @param {boolean} [options.dangerouslyAllowInsecureConnections] Allow insecure network connections. Should only be used in tests.
358
+ * @returns {Promise<void>}
359
+ */
360
+ async removeServerPeer(
361
+ serverDeviceId,
362
+ { dangerouslyAllowInsecureConnections = false } = {}
363
+ ) {
364
+ // Get device ID for URL
365
+ // Parse through URL to ensure end pathname if missing
366
+ const member = await this.getById(serverDeviceId)
367
+
368
+ if (!member.selfHostedServerDetails) {
369
+ throw new ErrorWithCode(
370
+ 'DEVICE_ID_NOT_FOR_SERVER',
371
+ 'DeviceId is not for a server peer'
372
+ )
373
+ }
374
+
375
+ if (member.role.roleId === BLOCKED_ROLE_ID) {
376
+ throw new ErrorWithCode('ALREADY_BLOCKED', 'Server peer already blocked')
377
+ }
378
+
379
+ const { baseUrl } = member.selfHostedServerDetails
380
+
381
+ // Add blocked role to project
382
+ await this.#roles.assignRole(serverDeviceId, BLOCKED_ROLE_ID)
383
+
384
+ // TODO: Catch fail and sync with server after
385
+ await this.#waitForInitialSyncWithServer({
386
+ baseUrl,
387
+ serverDeviceId,
388
+ dangerouslyAllowInsecureConnections,
389
+ })
390
+ }
391
+
317
392
  /**
318
393
  * @param {string} baseUrl Server base URL. Should already be validated.
319
394
  * @returns {Promise<{ serverDeviceId: string }>}
@@ -378,7 +453,7 @@ export class MemberApi extends TypedEmitter {
378
453
  ? 'ws:'
379
454
  : 'wss:'
380
455
 
381
- const websocket = new WebSocket(websocketUrl)
456
+ const websocket = this.#makeWebsocket(websocketUrl.href)
382
457
 
383
458
  try {
384
459
  await pEvent(websocket, 'open', { rejectionEvents: ['error'] })
@@ -400,7 +475,7 @@ export class MemberApi extends TypedEmitter {
400
475
  const onErrorPromise = pEvent(websocket, 'error')
401
476
 
402
477
  const replicationStream = this.#getReplicationStream()
403
- wsCoreReplicator(websocket, replicationStream)
478
+ const streamPromise = wsCoreReplicator(websocket, replicationStream)
404
479
 
405
480
  const syncAbortController = new AbortController()
406
481
  const syncPromise = this.#waitForInitialSyncWithPeer(
@@ -408,7 +483,11 @@ export class MemberApi extends TypedEmitter {
408
483
  syncAbortController.signal
409
484
  )
410
485
 
411
- const errorEvent = await Promise.race([onErrorPromise, syncPromise])
486
+ const errorEvent = await Promise.race([
487
+ onErrorPromise,
488
+ syncPromise,
489
+ streamPromise,
490
+ ])
412
491
 
413
492
  if (errorEvent) {
414
493
  syncAbortController.abort()
@@ -445,6 +524,7 @@ export class MemberApi extends TypedEmitter {
445
524
  result.name = deviceInfo.name
446
525
  result.deviceType = deviceInfo.deviceType
447
526
  result.joinedAt = deviceInfo.createdAt
527
+ result.selfHostedServerDetails = deviceInfo.selfHostedServerDetails
448
528
  } catch (err) {
449
529
  // Attempting to get someone else may throw because sync hasn't occurred or completed
450
530
  // Only throw if attempting to get themself since the relevant information should be available
package/src/roles.js CHANGED
@@ -12,6 +12,7 @@ export const COORDINATOR_ROLE_ID = 'f7c150f5a3a9a855'
12
12
  export const MEMBER_ROLE_ID = '012fd2d431c0bf60'
13
13
  export const BLOCKED_ROLE_ID = '9e6d29263cba36c9'
14
14
  export const LEFT_ROLE_ID = '8ced989b1904606b'
15
+ export const FAILED_ROLE_ID = 'a24eaca65ab5d5d0'
15
16
  export const NO_ROLE_ID = '08e4251e36f6e7ed'
16
17
 
17
18
  /**
@@ -27,6 +28,7 @@ const ROLE_IDS = new Set(
27
28
  MEMBER_ROLE_ID,
28
29
  BLOCKED_ROLE_ID,
29
30
  LEFT_ROLE_ID,
31
+ FAILED_ROLE_ID,
30
32
  NO_ROLE_ID,
31
33
  ])
32
34
  )
@@ -51,6 +53,7 @@ const ROLE_IDS_ASSIGNABLE_TO_ANYONE = new Set(
51
53
  MEMBER_ROLE_ID,
52
54
  BLOCKED_ROLE_ID,
53
55
  LEFT_ROLE_ID,
56
+ FAILED_ROLE_ID,
54
57
  ])
55
58
  )
56
59
  const isRoleIdAssignableToAnyone = setHas(ROLE_IDS_ASSIGNABLE_TO_ANYONE)
@@ -126,6 +129,34 @@ const BLOCKED_ROLE = {
126
129
  },
127
130
  }
128
131
 
132
+ /**
133
+ * This role is used for devices that failed to sync after accepting an invite and being added
134
+ * @type {Role<typeof FAILED_ROLE_ID>}
135
+ */
136
+ const FAILED_ROLE = {
137
+ roleId: FAILED_ROLE_ID,
138
+ name: 'Failed',
139
+ docs: mapObject(currentSchemaVersions, (key) => {
140
+ return [
141
+ key,
142
+ {
143
+ readOwn: false,
144
+ writeOwn: false,
145
+ readOthers: false,
146
+ writeOthers: false,
147
+ },
148
+ ]
149
+ }),
150
+ roleAssignment: [],
151
+ sync: {
152
+ auth: 'blocked',
153
+ config: 'blocked',
154
+ data: 'blocked',
155
+ blobIndex: 'blocked',
156
+ blob: 'blocked',
157
+ },
158
+ }
159
+
129
160
  /**
130
161
  * This is the role assumed for a device when no role record can be found. This
131
162
  * can happen when an invited device did not manage to sync with the device that
@@ -219,6 +250,7 @@ export const ROLES = {
219
250
  },
220
251
  },
221
252
  [NO_ROLE_ID]: NO_ROLE,
253
+ [FAILED_ROLE_ID]: FAILED_ROLE,
222
254
  }
223
255
 
224
256
  /**
@@ -380,6 +412,11 @@ export class Roles extends TypedEmitter {
380
412
  if (deviceId !== this.#ownDeviceId) {
381
413
  throw new Error('Cannot assign LEFT role to another device')
382
414
  }
415
+ } else if (roleId === FAILED_ROLE_ID) {
416
+ const ownRole = await this.getRole(this.#ownDeviceId)
417
+ if (!ownRole.roleAssignment.includes(COORDINATOR_ROLE_ID)) {
418
+ throw new Error('Lacks permission to assign role ' + roleId)
419
+ }
383
420
  } else {
384
421
  const ownRole = await this.getRole(this.#ownDeviceId)
385
422
  if (!ownRole.roleAssignment.includes(roleId)) {
@@ -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', () => {