@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/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/generated/rpc.d.ts +47 -0
- package/dist/generated/rpc.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/invite/invite-api.d.ts +4 -5
- package/dist/invite/invite-api.d.ts.map +1 -1
- package/dist/local-peers.d.ts +31 -0
- package/dist/local-peers.d.ts.map +1 -1
- package/dist/mapeo-manager.d.ts +30 -22
- package/dist/mapeo-manager.d.ts.map +1 -1
- package/dist/mapeo-project.d.ts +40 -2
- package/dist/mapeo-project.d.ts.map +1 -1
- package/dist/member-api.d.ts +15 -1
- package/dist/member-api.d.ts.map +1 -1
- package/dist/roles.d.ts +4 -3
- package/dist/roles.d.ts.map +1 -1
- package/dist/schema/client.d.ts +26 -3
- package/dist/schema/client.d.ts.map +1 -1
- package/dist/sync/sync-api.d.ts +4 -1
- package/dist/sync/sync-api.d.ts.map +1 -1
- package/drizzle/client/0002_brief_demogoblin.sql +2 -0
- package/drizzle/client/meta/0002_snapshot.json +220 -0
- package/drizzle/client/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/errors.js +32 -0
- package/src/generated/rpc.d.ts +47 -0
- package/src/generated/rpc.js +241 -3
- package/src/generated/rpc.ts +280 -1
- package/src/invite/invite-api.js +15 -3
- package/src/local-peers.js +199 -1
- package/src/mapeo-manager.js +60 -20
- package/src/mapeo-project.js +21 -3
- package/src/member-api.js +94 -14
- package/src/roles.js +37 -0
- package/src/schema/client.js +3 -2
- package/src/sync/sync-api.js +6 -2
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 {
|
|
20
|
+
import {
|
|
21
|
+
InviteAbortedError,
|
|
22
|
+
InviteInitialSyncFailError,
|
|
23
|
+
ProjectDetailsSendFailError,
|
|
24
|
+
} from './errors.js'
|
|
21
25
|
import { wsCoreReplicator } from './lib/ws-core-replicator.js'
|
|
22
|
-
import {
|
|
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
|
-
//
|
|
190
|
-
//
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 =
|
|
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([
|
|
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)) {
|
package/src/schema/client.js
CHANGED
|
@@ -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 {
|
|
12
|
-
* @prop {string} [name]
|
|
13
|
+
* @typedef {Pick<ProjectSettings, 'name' | 'projectColor' | 'projectDescription'>} ProjectInfo
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
const projectInfoColumn =
|
package/src/sync/sync-api.js
CHANGED
|
@@ -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 =
|
|
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', () => {
|