@comapeo/core 2.0.1 → 2.2.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/dist/blob-store/downloader.d.ts +43 -0
- package/dist/blob-store/downloader.d.ts.map +1 -0
- package/dist/blob-store/entries-stream.d.ts +13 -0
- package/dist/blob-store/entries-stream.d.ts.map +1 -0
- package/dist/blob-store/hyperdrive-index.d.ts +20 -0
- package/dist/blob-store/hyperdrive-index.d.ts.map +1 -0
- package/dist/blob-store/index.d.ts +34 -29
- package/dist/blob-store/index.d.ts.map +1 -1
- package/dist/blob-store/utils.d.ts +27 -0
- package/dist/blob-store/utils.d.ts.map +1 -0
- package/dist/constants.d.ts +2 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/core-manager/index.d.ts +11 -1
- package/dist/core-manager/index.d.ts.map +1 -1
- package/dist/core-ownership.d.ts.map +1 -1
- package/dist/datastore/index.d.ts +5 -4
- package/dist/datastore/index.d.ts.map +1 -1
- package/dist/datatype/index.d.ts +5 -1
- package/dist/discovery/local-discovery.d.ts.map +1 -1
- package/dist/errors.d.ts +6 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/fastify-plugins/blobs.d.ts.map +1 -1
- package/dist/fastify-plugins/maps.d.ts.map +1 -1
- package/dist/generated/extensions.d.ts +31 -0
- package/dist/generated/extensions.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/drizzle-helpers.d.ts +6 -0
- package/dist/lib/drizzle-helpers.d.ts.map +1 -0
- package/dist/lib/error.d.ts +51 -0
- package/dist/lib/error.d.ts.map +1 -0
- package/dist/lib/get-own.d.ts +9 -0
- package/dist/lib/get-own.d.ts.map +1 -0
- package/dist/lib/is-hostname-ip-address.d.ts +17 -0
- package/dist/lib/is-hostname-ip-address.d.ts.map +1 -0
- package/dist/lib/ws-core-replicator.d.ts +11 -0
- package/dist/lib/ws-core-replicator.d.ts.map +1 -0
- package/dist/mapeo-manager.d.ts +18 -22
- package/dist/mapeo-manager.d.ts.map +1 -1
- package/dist/mapeo-project.d.ts +459 -26
- package/dist/mapeo-project.d.ts.map +1 -1
- package/dist/member-api.d.ts +44 -1
- package/dist/member-api.d.ts.map +1 -1
- package/dist/roles.d.ts.map +1 -1
- package/dist/schema/client.d.ts +17 -5
- package/dist/schema/client.d.ts.map +1 -1
- package/dist/schema/project.d.ts +212 -2
- package/dist/schema/project.d.ts.map +1 -1
- package/dist/sync/core-sync-state.d.ts +20 -15
- package/dist/sync/core-sync-state.d.ts.map +1 -1
- package/dist/sync/namespace-sync-state.d.ts +13 -1
- package/dist/sync/namespace-sync-state.d.ts.map +1 -1
- package/dist/sync/peer-sync-controller.d.ts +1 -1
- package/dist/sync/peer-sync-controller.d.ts.map +1 -1
- package/dist/sync/sync-api.d.ts +47 -2
- package/dist/sync/sync-api.d.ts.map +1 -1
- package/dist/sync/sync-state.d.ts +12 -0
- package/dist/sync/sync-state.d.ts.map +1 -1
- package/dist/translation-api.d.ts +2 -2
- package/dist/translation-api.d.ts.map +1 -1
- package/dist/types.d.ts +10 -2
- package/dist/types.d.ts.map +1 -1
- package/drizzle/client/0001_chubby_cargill.sql +12 -0
- package/drizzle/client/meta/0001_snapshot.json +208 -0
- package/drizzle/client/meta/_journal.json +7 -0
- package/drizzle/project/0001_medical_wendell_rand.sql +22 -0
- package/drizzle/project/meta/0001_snapshot.json +1267 -0
- package/drizzle/project/meta/_journal.json +7 -0
- package/package.json +14 -5
- package/src/blob-store/downloader.js +130 -0
- package/src/blob-store/entries-stream.js +81 -0
- package/src/blob-store/hyperdrive-index.js +122 -0
- package/src/blob-store/index.js +59 -117
- package/src/blob-store/utils.js +54 -0
- package/src/constants.js +4 -1
- package/src/core-manager/index.js +60 -3
- package/src/core-ownership.js +2 -4
- package/src/datastore/README.md +1 -2
- package/src/datastore/index.js +8 -8
- package/src/datatype/index.d.ts +5 -1
- package/src/datatype/index.js +22 -9
- package/src/discovery/local-discovery.js +2 -1
- package/src/errors.js +11 -2
- package/src/fastify-plugins/blobs.js +17 -1
- package/src/fastify-plugins/maps.js +2 -1
- package/src/generated/extensions.d.ts +31 -0
- package/src/generated/extensions.js +150 -0
- package/src/generated/extensions.ts +181 -0
- package/src/index.js +10 -0
- package/src/invite-api.js +1 -1
- package/src/lib/drizzle-helpers.js +79 -0
- package/src/lib/error.js +71 -0
- package/src/lib/get-own.js +10 -0
- package/src/lib/is-hostname-ip-address.js +26 -0
- package/src/lib/ws-core-replicator.js +47 -0
- package/src/mapeo-manager.js +74 -45
- package/src/mapeo-project.js +238 -58
- package/src/member-api.js +295 -2
- package/src/roles.js +38 -32
- package/src/schema/client.js +4 -3
- package/src/schema/project.js +7 -0
- package/src/sync/core-sync-state.js +39 -23
- package/src/sync/namespace-sync-state.js +22 -0
- package/src/sync/peer-sync-controller.js +1 -0
- package/src/sync/sync-api.js +197 -3
- package/src/sync/sync-state.js +18 -0
- package/src/translation-api.js +5 -9
- package/src/types.ts +12 -3
- package/dist/blob-store/live-download.d.ts +0 -107
- package/dist/blob-store/live-download.d.ts.map +0 -1
- package/dist/lib/timing-safe-equal.d.ts +0 -15
- package/dist/lib/timing-safe-equal.d.ts.map +0 -1
- package/src/blob-store/live-download.js +0 -373
- package/src/lib/timing-safe-equal.js +0 -34
package/src/member-api.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import * as b4a from 'b4a'
|
|
1
2
|
import * as crypto from 'node:crypto'
|
|
3
|
+
import WebSocket from 'ws'
|
|
2
4
|
import { TypedEmitter } from 'tiny-typed-emitter'
|
|
3
5
|
import { pEvent } from 'p-event'
|
|
4
6
|
import { InviteResponse_Decision } from './generated/rpc.js'
|
|
@@ -8,11 +10,15 @@ import {
|
|
|
8
10
|
ExhaustivenessError,
|
|
9
11
|
projectKeyToId,
|
|
10
12
|
projectKeyToProjectInviteId,
|
|
13
|
+
projectKeyToPublicId,
|
|
11
14
|
} from './utils.js'
|
|
12
15
|
import { keyBy } from './lib/key-by.js'
|
|
13
16
|
import { abortSignalAny } from './lib/ponyfills.js'
|
|
14
|
-
import timingSafeEqual from '
|
|
15
|
-
import {
|
|
17
|
+
import timingSafeEqual from 'string-timing-safe-equal'
|
|
18
|
+
import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js'
|
|
19
|
+
import { ErrorWithCode, getErrorMessage } from './lib/error.js'
|
|
20
|
+
import { wsCoreReplicator } from './lib/ws-core-replicator.js'
|
|
21
|
+
import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js'
|
|
16
22
|
/**
|
|
17
23
|
* @import {
|
|
18
24
|
* DeviceInfo,
|
|
@@ -21,11 +27,13 @@ import { ROLES, isRoleIdForNewInvite } from './roles.js'
|
|
|
21
27
|
* ProjectSettingsValue
|
|
22
28
|
* } from '@comapeo/schema'
|
|
23
29
|
*/
|
|
30
|
+
/** @import { Promisable } from 'type-fest' */
|
|
24
31
|
/** @import { Invite, InviteResponse } from './generated/rpc.js' */
|
|
25
32
|
/** @import { DataType } from './datatype/index.js' */
|
|
26
33
|
/** @import { DataStore } from './datastore/index.js' */
|
|
27
34
|
/** @import { deviceInfoTable } from './schema/project.js' */
|
|
28
35
|
/** @import { projectSettingsTable } from './schema/client.js' */
|
|
36
|
+
/** @import { ReplicationStream } from './types.js' */
|
|
29
37
|
|
|
30
38
|
/** @typedef {DataType<DataStore<'config'>, typeof deviceInfoTable, "deviceInfo", DeviceInfo, DeviceInfoValue>} DeviceInfoDataType */
|
|
31
39
|
/** @typedef {DataType<DataStore<'config'>, typeof projectSettingsTable, "projectSettings", ProjectSettings, ProjectSettingsValue>} ProjectDataType */
|
|
@@ -36,6 +44,8 @@ import { ROLES, isRoleIdForNewInvite } from './roles.js'
|
|
|
36
44
|
* @prop {DeviceInfo['name']} [name]
|
|
37
45
|
* @prop {DeviceInfo['deviceType']} [deviceType]
|
|
38
46
|
* @prop {DeviceInfo['createdAt']} [joinedAt]
|
|
47
|
+
* @prop {object} [selfHostedServerDetails]
|
|
48
|
+
* @prop {string} selfHostedServerDetails.baseUrl
|
|
39
49
|
*/
|
|
40
50
|
|
|
41
51
|
export class MemberApi extends TypedEmitter {
|
|
@@ -43,8 +53,11 @@ export class MemberApi extends TypedEmitter {
|
|
|
43
53
|
#roles
|
|
44
54
|
#coreOwnership
|
|
45
55
|
#encryptionKeys
|
|
56
|
+
#getProjectName
|
|
46
57
|
#projectKey
|
|
47
58
|
#rpc
|
|
59
|
+
#getReplicationStream
|
|
60
|
+
#waitForInitialSyncWithPeer
|
|
48
61
|
#dataTypes
|
|
49
62
|
|
|
50
63
|
/** @type {Map<string, { abortController: AbortController }>} */
|
|
@@ -56,8 +69,11 @@ export class MemberApi extends TypedEmitter {
|
|
|
56
69
|
* @param {import('./roles.js').Roles} opts.roles
|
|
57
70
|
* @param {import('./core-ownership.js').CoreOwnership} opts.coreOwnership
|
|
58
71
|
* @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys
|
|
72
|
+
* @param {() => Promisable<undefined | string>} opts.getProjectName
|
|
59
73
|
* @param {Buffer} opts.projectKey
|
|
60
74
|
* @param {import('./local-peers.js').LocalPeers} opts.rpc
|
|
75
|
+
* @param {() => ReplicationStream} opts.getReplicationStream
|
|
76
|
+
* @param {(deviceId: string, abortSignal: AbortSignal) => Promise<void>} opts.waitForInitialSyncWithPeer
|
|
61
77
|
* @param {Object} opts.dataTypes
|
|
62
78
|
* @param {Pick<DeviceInfoDataType, 'getByDocId' | 'getMany'>} opts.dataTypes.deviceInfo
|
|
63
79
|
* @param {Pick<ProjectDataType, 'getByDocId'>} opts.dataTypes.project
|
|
@@ -67,8 +83,11 @@ export class MemberApi extends TypedEmitter {
|
|
|
67
83
|
roles,
|
|
68
84
|
coreOwnership,
|
|
69
85
|
encryptionKeys,
|
|
86
|
+
getProjectName,
|
|
70
87
|
projectKey,
|
|
71
88
|
rpc,
|
|
89
|
+
getReplicationStream,
|
|
90
|
+
waitForInitialSyncWithPeer,
|
|
72
91
|
dataTypes,
|
|
73
92
|
}) {
|
|
74
93
|
super()
|
|
@@ -76,8 +95,11 @@ export class MemberApi extends TypedEmitter {
|
|
|
76
95
|
this.#roles = roles
|
|
77
96
|
this.#coreOwnership = coreOwnership
|
|
78
97
|
this.#encryptionKeys = encryptionKeys
|
|
98
|
+
this.#getProjectName = getProjectName
|
|
79
99
|
this.#projectKey = projectKey
|
|
80
100
|
this.#rpc = rpc
|
|
101
|
+
this.#getReplicationStream = getReplicationStream
|
|
102
|
+
this.#waitForInitialSyncWithPeer = waitForInitialSyncWithPeer
|
|
81
103
|
this.#dataTypes = dataTypes
|
|
82
104
|
}
|
|
83
105
|
|
|
@@ -245,6 +267,160 @@ export class MemberApi extends TypedEmitter {
|
|
|
245
267
|
this.#outboundInvitesByDevice.get(deviceId)?.abortController.abort()
|
|
246
268
|
}
|
|
247
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Add a server peer.
|
|
272
|
+
*
|
|
273
|
+
* Can reject with any of the following error codes (accessed via `err.code`):
|
|
274
|
+
*
|
|
275
|
+
* - `INVALID_URL`: the base URL is invalid, likely due to user error.
|
|
276
|
+
* - `MISSING_DATA`: some required data is missing in order to add the server
|
|
277
|
+
* peer. For example, the project must have a name.
|
|
278
|
+
* - `NETWORK_ERROR`: there was an issue connecting to the server. Is the
|
|
279
|
+
* device online? Is the server online?
|
|
280
|
+
* - `SERVER_HAS_TOO_MANY_PROJECTS`: the server limits the number of projects
|
|
281
|
+
* it can have, and it's at the limit.
|
|
282
|
+
* - `PROJECT_NOT_IN_SERVER_ALLOWLIST`: the server only allows specific
|
|
283
|
+
* projects to be added and ours wasn't one of them.
|
|
284
|
+
* - `INVALID_SERVER_RESPONSE`: we connected to the server but it returned
|
|
285
|
+
* an unexpected response. Is the server running a compatible version of
|
|
286
|
+
* CoMapeo Cloud?
|
|
287
|
+
*
|
|
288
|
+
* If `err.code` is not specified, that indicates a bug in this module.
|
|
289
|
+
*
|
|
290
|
+
* @param {string} baseUrl
|
|
291
|
+
* @param {object} [options]
|
|
292
|
+
* @param {boolean} [options.dangerouslyAllowInsecureConnections] Allow insecure network connections. Should only be used in tests.
|
|
293
|
+
* @returns {Promise<void>}
|
|
294
|
+
*/
|
|
295
|
+
async addServerPeer(
|
|
296
|
+
baseUrl,
|
|
297
|
+
{ dangerouslyAllowInsecureConnections = false } = {}
|
|
298
|
+
) {
|
|
299
|
+
if (
|
|
300
|
+
!isValidServerBaseUrl(baseUrl, { dangerouslyAllowInsecureConnections })
|
|
301
|
+
) {
|
|
302
|
+
throw new ErrorWithCode('INVALID_URL', 'Server base URL is invalid')
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const { serverDeviceId } = await this.#addServerToProject(baseUrl)
|
|
306
|
+
|
|
307
|
+
await this.#roles.assignRole(serverDeviceId, MEMBER_ROLE_ID)
|
|
308
|
+
|
|
309
|
+
await this.#waitForInitialSyncWithServer({
|
|
310
|
+
baseUrl,
|
|
311
|
+
serverDeviceId,
|
|
312
|
+
dangerouslyAllowInsecureConnections,
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* @param {string} baseUrl Server base URL. Should already be validated.
|
|
318
|
+
* @returns {Promise<{ serverDeviceId: string }>}
|
|
319
|
+
*/
|
|
320
|
+
async #addServerToProject(baseUrl) {
|
|
321
|
+
const projectName = await this.#getProjectName()
|
|
322
|
+
if (!projectName) {
|
|
323
|
+
throw new ErrorWithCode(
|
|
324
|
+
'MISSING_DATA',
|
|
325
|
+
'Project must have name to add server peer'
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const requestUrl = new URL('projects', baseUrl)
|
|
330
|
+
const requestBody = {
|
|
331
|
+
projectName,
|
|
332
|
+
projectKey: encodeBufferForServer(this.#projectKey),
|
|
333
|
+
encryptionKeys: {
|
|
334
|
+
auth: encodeBufferForServer(this.#encryptionKeys.auth),
|
|
335
|
+
data: encodeBufferForServer(this.#encryptionKeys.data),
|
|
336
|
+
config: encodeBufferForServer(this.#encryptionKeys.config),
|
|
337
|
+
blobIndex: encodeBufferForServer(this.#encryptionKeys.blobIndex),
|
|
338
|
+
blob: encodeBufferForServer(this.#encryptionKeys.blob),
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** @type {Response} */ let response
|
|
343
|
+
try {
|
|
344
|
+
response = await fetch(requestUrl, {
|
|
345
|
+
method: 'PUT',
|
|
346
|
+
body: JSON.stringify(requestBody),
|
|
347
|
+
headers: { 'Content-Type': 'application/json' },
|
|
348
|
+
})
|
|
349
|
+
} catch (err) {
|
|
350
|
+
throw new ErrorWithCode(
|
|
351
|
+
'NETWORK_ERROR',
|
|
352
|
+
`Failed to add server peer due to network error: ${getErrorMessage(
|
|
353
|
+
err
|
|
354
|
+
)}`
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return await parseAddServerResponse(response)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* @param {object} options
|
|
363
|
+
* @param {string} options.baseUrl
|
|
364
|
+
* @param {string} options.serverDeviceId
|
|
365
|
+
* @param {boolean} options.dangerouslyAllowInsecureConnections
|
|
366
|
+
* @returns {Promise<void>}
|
|
367
|
+
*/
|
|
368
|
+
async #waitForInitialSyncWithServer({
|
|
369
|
+
baseUrl,
|
|
370
|
+
serverDeviceId,
|
|
371
|
+
dangerouslyAllowInsecureConnections,
|
|
372
|
+
}) {
|
|
373
|
+
const projectPublicId = projectKeyToPublicId(this.#projectKey)
|
|
374
|
+
const websocketUrl = new URL('sync/' + projectPublicId, baseUrl)
|
|
375
|
+
websocketUrl.protocol =
|
|
376
|
+
dangerouslyAllowInsecureConnections && websocketUrl.protocol === 'http:'
|
|
377
|
+
? 'ws:'
|
|
378
|
+
: 'wss:'
|
|
379
|
+
|
|
380
|
+
const websocket = new WebSocket(websocketUrl)
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
await pEvent(websocket, 'open', { rejectionEvents: ['error'] })
|
|
384
|
+
} catch (rejectionEvent) {
|
|
385
|
+
throw new ErrorWithCode(
|
|
386
|
+
// It's difficult for us to reliably disambiguate between "network error"
|
|
387
|
+
// and "invalid response from server" here, so we just say it was an
|
|
388
|
+
// invalid server response.
|
|
389
|
+
'INVALID_SERVER_RESPONSE',
|
|
390
|
+
'Failed to open the socket',
|
|
391
|
+
rejectionEvent &&
|
|
392
|
+
typeof rejectionEvent === 'object' &&
|
|
393
|
+
'error' in rejectionEvent
|
|
394
|
+
? { cause: rejectionEvent.error }
|
|
395
|
+
: { cause: rejectionEvent }
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const onErrorPromise = pEvent(websocket, 'error')
|
|
400
|
+
|
|
401
|
+
const replicationStream = this.#getReplicationStream()
|
|
402
|
+
wsCoreReplicator(websocket, replicationStream)
|
|
403
|
+
|
|
404
|
+
const syncAbortController = new AbortController()
|
|
405
|
+
const syncPromise = this.#waitForInitialSyncWithPeer(
|
|
406
|
+
serverDeviceId,
|
|
407
|
+
syncAbortController.signal
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
const errorEvent = await Promise.race([onErrorPromise, syncPromise])
|
|
411
|
+
|
|
412
|
+
if (errorEvent) {
|
|
413
|
+
syncAbortController.abort()
|
|
414
|
+
websocket.close()
|
|
415
|
+
throw errorEvent.error
|
|
416
|
+
} else {
|
|
417
|
+
const onClosePromise = pEvent(websocket, 'close')
|
|
418
|
+
onErrorPromise.cancel()
|
|
419
|
+
websocket.close()
|
|
420
|
+
await onClosePromise
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
248
424
|
/**
|
|
249
425
|
* @param {string} deviceId
|
|
250
426
|
* @returns {Promise<MemberInfo>}
|
|
@@ -304,6 +480,8 @@ export class MemberApi extends TypedEmitter {
|
|
|
304
480
|
memberInfo.name = deviceInfo?.name
|
|
305
481
|
memberInfo.deviceType = deviceInfo?.deviceType
|
|
306
482
|
memberInfo.joinedAt = deviceInfo?.createdAt
|
|
483
|
+
memberInfo.selfHostedServerDetails =
|
|
484
|
+
deviceInfo?.selfHostedServerDetails
|
|
307
485
|
} catch (err) {
|
|
308
486
|
// Attempting to get someone else may throw because sync hasn't occurred or completed
|
|
309
487
|
// Only throw if attempting to get themself since the relevant information should be available
|
|
@@ -324,3 +502,118 @@ export class MemberApi extends TypedEmitter {
|
|
|
324
502
|
return this.#roles.assignRole(deviceId, roleId)
|
|
325
503
|
}
|
|
326
504
|
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* @param {string} baseUrl
|
|
508
|
+
* @param {object} options
|
|
509
|
+
* @param {boolean} options.dangerouslyAllowInsecureConnections
|
|
510
|
+
* @returns {boolean}
|
|
511
|
+
*/
|
|
512
|
+
function isValidServerBaseUrl(
|
|
513
|
+
baseUrl,
|
|
514
|
+
{ dangerouslyAllowInsecureConnections }
|
|
515
|
+
) {
|
|
516
|
+
if (baseUrl.length > 2000) return false
|
|
517
|
+
|
|
518
|
+
/** @type {URL} */ let url
|
|
519
|
+
try {
|
|
520
|
+
url = new URL(baseUrl)
|
|
521
|
+
} catch (_err) {
|
|
522
|
+
return false
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const isProtocolValid =
|
|
526
|
+
url.protocol === 'https:' ||
|
|
527
|
+
(dangerouslyAllowInsecureConnections && url.protocol === 'http:')
|
|
528
|
+
if (!isProtocolValid) return false
|
|
529
|
+
|
|
530
|
+
if (url.username) return false
|
|
531
|
+
if (url.password) return false
|
|
532
|
+
if (url.search) return false
|
|
533
|
+
if (url.hash) return false
|
|
534
|
+
|
|
535
|
+
// We may want to support this someday. See <https://github.com/digidem/comapeo-core/issues/908>.
|
|
536
|
+
if (url.pathname !== '/') return false
|
|
537
|
+
|
|
538
|
+
if (
|
|
539
|
+
!isHostnameIpAddress(url.hostname) &&
|
|
540
|
+
!dangerouslyAllowInsecureConnections
|
|
541
|
+
) {
|
|
542
|
+
const parts = url.hostname.split('.')
|
|
543
|
+
const isDomainValid = parts.length >= 2 && parts.every(Boolean)
|
|
544
|
+
if (!isDomainValid) return false
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return true
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* @param {undefined | Uint8Array} buffer
|
|
552
|
+
* @returns {undefined | string}
|
|
553
|
+
*/
|
|
554
|
+
function encodeBufferForServer(buffer) {
|
|
555
|
+
return buffer ? b4a.toString(buffer, 'hex') : undefined
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* @param {Response} response
|
|
560
|
+
* @returns {Promise<{ serverDeviceId: string }>}
|
|
561
|
+
*/
|
|
562
|
+
async function parseAddServerResponse(response) {
|
|
563
|
+
if (response.status === 200) {
|
|
564
|
+
try {
|
|
565
|
+
const responseBody = await response.json()
|
|
566
|
+
assert(
|
|
567
|
+
responseBody &&
|
|
568
|
+
typeof responseBody === 'object' &&
|
|
569
|
+
'data' in responseBody &&
|
|
570
|
+
responseBody.data &&
|
|
571
|
+
typeof responseBody.data === 'object' &&
|
|
572
|
+
'deviceId' in responseBody.data &&
|
|
573
|
+
typeof responseBody.data.deviceId === 'string',
|
|
574
|
+
'Response body is valid'
|
|
575
|
+
)
|
|
576
|
+
return { serverDeviceId: responseBody.data.deviceId }
|
|
577
|
+
} catch (err) {
|
|
578
|
+
throw new ErrorWithCode(
|
|
579
|
+
'INVALID_SERVER_RESPONSE',
|
|
580
|
+
"Failed to add server peer because we couldn't parse the response"
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
let responseBody
|
|
586
|
+
try {
|
|
587
|
+
responseBody = await response.json()
|
|
588
|
+
} catch (_) {
|
|
589
|
+
responseBody = null
|
|
590
|
+
}
|
|
591
|
+
if (
|
|
592
|
+
responseBody &&
|
|
593
|
+
typeof responseBody === 'object' &&
|
|
594
|
+
'error' in responseBody &&
|
|
595
|
+
responseBody.error &&
|
|
596
|
+
typeof responseBody.error === 'object' &&
|
|
597
|
+
'code' in responseBody.error
|
|
598
|
+
) {
|
|
599
|
+
switch (responseBody.error.code) {
|
|
600
|
+
case 'PROJECT_NOT_IN_ALLOWLIST':
|
|
601
|
+
throw new ErrorWithCode(
|
|
602
|
+
'PROJECT_NOT_IN_SERVER_ALLOWLIST',
|
|
603
|
+
"The server only allows specific projects to be added, and this isn't one of them"
|
|
604
|
+
)
|
|
605
|
+
case 'TOO_MANY_PROJECTS':
|
|
606
|
+
throw new ErrorWithCode(
|
|
607
|
+
'SERVER_HAS_TOO_MANY_PROJECTS',
|
|
608
|
+
"The server limits the number of projects it can have and it's at the limit"
|
|
609
|
+
)
|
|
610
|
+
default:
|
|
611
|
+
break
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
throw new ErrorWithCode(
|
|
616
|
+
'INVALID_SERVER_RESPONSE',
|
|
617
|
+
`Failed to add server peer due to HTTP status code ${response.status}`
|
|
618
|
+
)
|
|
619
|
+
}
|
package/src/roles.js
CHANGED
|
@@ -2,6 +2,7 @@ import { currentSchemaVersions } from '@comapeo/schema'
|
|
|
2
2
|
import mapObject from 'map-obj'
|
|
3
3
|
import { kCreateWithDocId, kDataStore } from './datatype/index.js'
|
|
4
4
|
import { assert, setHas } from './utils.js'
|
|
5
|
+
import { nullIfNotFound } from './errors.js'
|
|
5
6
|
import { TypedEmitter } from 'tiny-typed-emitter'
|
|
6
7
|
/** @import { Namespace } from './types.js' */
|
|
7
8
|
|
|
@@ -98,6 +99,33 @@ export const CREATOR_ROLE = {
|
|
|
98
99
|
},
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/**
|
|
103
|
+
* @type {Role<typeof BLOCKED_ROLE_ID>}
|
|
104
|
+
*/
|
|
105
|
+
const BLOCKED_ROLE = {
|
|
106
|
+
roleId: BLOCKED_ROLE_ID,
|
|
107
|
+
name: 'Blocked',
|
|
108
|
+
docs: mapObject(currentSchemaVersions, (key) => {
|
|
109
|
+
return [
|
|
110
|
+
key,
|
|
111
|
+
{
|
|
112
|
+
readOwn: false,
|
|
113
|
+
writeOwn: false,
|
|
114
|
+
readOthers: false,
|
|
115
|
+
writeOthers: false,
|
|
116
|
+
},
|
|
117
|
+
]
|
|
118
|
+
}),
|
|
119
|
+
roleAssignment: [],
|
|
120
|
+
sync: {
|
|
121
|
+
auth: 'blocked',
|
|
122
|
+
config: 'blocked',
|
|
123
|
+
data: 'blocked',
|
|
124
|
+
blobIndex: 'blocked',
|
|
125
|
+
blob: 'blocked',
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
101
129
|
/**
|
|
102
130
|
* This is the role assumed for a device when no role record can be found. This
|
|
103
131
|
* can happen when an invited device did not manage to sync with the device that
|
|
@@ -166,29 +194,7 @@ export const ROLES = {
|
|
|
166
194
|
blob: 'allowed',
|
|
167
195
|
},
|
|
168
196
|
},
|
|
169
|
-
[BLOCKED_ROLE_ID]:
|
|
170
|
-
roleId: BLOCKED_ROLE_ID,
|
|
171
|
-
name: 'Blocked',
|
|
172
|
-
docs: mapObject(currentSchemaVersions, (key) => {
|
|
173
|
-
return [
|
|
174
|
-
key,
|
|
175
|
-
{
|
|
176
|
-
readOwn: false,
|
|
177
|
-
writeOwn: false,
|
|
178
|
-
readOthers: false,
|
|
179
|
-
writeOthers: false,
|
|
180
|
-
},
|
|
181
|
-
]
|
|
182
|
-
}),
|
|
183
|
-
roleAssignment: [],
|
|
184
|
-
sync: {
|
|
185
|
-
auth: 'blocked',
|
|
186
|
-
config: 'blocked',
|
|
187
|
-
data: 'blocked',
|
|
188
|
-
blobIndex: 'blocked',
|
|
189
|
-
blob: 'blocked',
|
|
190
|
-
},
|
|
191
|
-
},
|
|
197
|
+
[BLOCKED_ROLE_ID]: BLOCKED_ROLE,
|
|
192
198
|
[LEFT_ROLE_ID]: {
|
|
193
199
|
roleId: LEFT_ROLE_ID,
|
|
194
200
|
name: 'Left',
|
|
@@ -264,12 +270,10 @@ export class Roles extends TypedEmitter {
|
|
|
264
270
|
* @returns {Promise<Role>}
|
|
265
271
|
*/
|
|
266
272
|
async getRole(deviceId) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
roleId = roleAssignment.roleId
|
|
272
|
-
} catch (e) {
|
|
273
|
+
const roleRecord = await this.#dataType
|
|
274
|
+
.getByDocId(deviceId)
|
|
275
|
+
.catch(nullIfNotFound)
|
|
276
|
+
if (!roleRecord) {
|
|
273
277
|
// The project creator will have the creator role
|
|
274
278
|
const authCoreId = await this.#coreOwnership.getCoreId(deviceId, 'auth')
|
|
275
279
|
if (authCoreId === this.#projectCreatorAuthCoreId) {
|
|
@@ -280,8 +284,10 @@ export class Roles extends TypedEmitter {
|
|
|
280
284
|
return NO_ROLE
|
|
281
285
|
}
|
|
282
286
|
}
|
|
287
|
+
|
|
288
|
+
const { roleId } = roleRecord
|
|
283
289
|
if (!isRoleId(roleId)) {
|
|
284
|
-
return
|
|
290
|
+
return BLOCKED_ROLE
|
|
285
291
|
}
|
|
286
292
|
return ROLES[roleId]
|
|
287
293
|
}
|
|
@@ -383,7 +389,7 @@ export class Roles extends TypedEmitter {
|
|
|
383
389
|
|
|
384
390
|
const existingRoleDoc = await this.#dataType
|
|
385
391
|
.getByDocId(deviceId)
|
|
386
|
-
.catch(
|
|
392
|
+
.catch(nullIfNotFound)
|
|
387
393
|
|
|
388
394
|
if (existingRoleDoc) {
|
|
389
395
|
await this.#dataType.update(
|
|
@@ -403,7 +409,7 @@ export class Roles extends TypedEmitter {
|
|
|
403
409
|
}
|
|
404
410
|
}
|
|
405
411
|
|
|
406
|
-
|
|
412
|
+
#isProjectCreator() {
|
|
407
413
|
const ownAuthCoreId = this.#coreManager
|
|
408
414
|
.getWriterCore('auth')
|
|
409
415
|
.key.toString('hex')
|
package/src/schema/client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// These schemas are all in a "client" database. There is only one client
|
|
2
2
|
// database and it contains information that is shared across all projects on a
|
|
3
3
|
// device
|
|
4
|
-
import { blob, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|
4
|
+
import { blob, sqliteTable, text, int } from 'drizzle-orm/sqlite-core'
|
|
5
5
|
import { dereferencedDocSchemas as schemas } from '@comapeo/schema'
|
|
6
6
|
import { jsonSchemaToDrizzleColumns as toColumns } from './schema-to-drizzle.js'
|
|
7
7
|
import { backlinkTable, customJson } from './utils.js'
|
|
@@ -49,7 +49,8 @@ const deviceInfoColumn =
|
|
|
49
49
|
)
|
|
50
50
|
|
|
51
51
|
// This table only ever has one row in it.
|
|
52
|
-
export const
|
|
52
|
+
export const deviceSettingsTable = sqliteTable('deviceSettings', {
|
|
53
53
|
deviceId: text('deviceId').notNull().unique(),
|
|
54
|
-
deviceInfo: deviceInfoColumn('deviceInfo')
|
|
54
|
+
deviceInfo: deviceInfoColumn('deviceInfo'),
|
|
55
|
+
isArchiveDevice: int('isArchiveDevice', { mode: 'boolean' }),
|
|
55
56
|
})
|
package/src/schema/project.js
CHANGED
|
@@ -15,6 +15,10 @@ export const observationTable = sqliteTable(
|
|
|
15
15
|
toColumns(schemas.observation)
|
|
16
16
|
)
|
|
17
17
|
export const trackTable = sqliteTable('track', toColumns(schemas.track))
|
|
18
|
+
export const remoteDetectionAlertTable = sqliteTable(
|
|
19
|
+
'remoteDetectionAlert',
|
|
20
|
+
toColumns(schemas.remoteDetectionAlert)
|
|
21
|
+
)
|
|
18
22
|
export const presetTable = sqliteTable('preset', toColumns(schemas.preset))
|
|
19
23
|
export const fieldTable = sqliteTable('field', toColumns(schemas.field))
|
|
20
24
|
export const coreOwnershipTable = sqliteTable(
|
|
@@ -31,6 +35,9 @@ export const iconTable = sqliteTable('icon', toColumns(schemas.icon))
|
|
|
31
35
|
export const translationBacklinkTable = backlinkTable(translationTable)
|
|
32
36
|
export const observationBacklinkTable = backlinkTable(observationTable)
|
|
33
37
|
export const trackBacklinkTable = backlinkTable(trackTable)
|
|
38
|
+
export const remoteDetectionAlertBacklinkTable = backlinkTable(
|
|
39
|
+
remoteDetectionAlertTable
|
|
40
|
+
)
|
|
34
41
|
export const presetBacklinkTable = backlinkTable(presetTable)
|
|
35
42
|
export const fieldBacklinkTable = backlinkTable(fieldTable)
|
|
36
43
|
export const coreOwnershipBacklinkTable = backlinkTable(coreOwnershipTable)
|
|
@@ -182,13 +182,23 @@ export class CoreSyncState {
|
|
|
182
182
|
* blocks/ranges that are added here
|
|
183
183
|
*
|
|
184
184
|
* @param {PeerId} peerId
|
|
185
|
-
* @param {
|
|
185
|
+
* @param {number} start
|
|
186
|
+
* @param {number} length
|
|
187
|
+
* @returns {void}
|
|
186
188
|
*/
|
|
187
|
-
|
|
189
|
+
addWantRange(peerId, start, length) {
|
|
188
190
|
const peerState = this.#getOrCreatePeerState(peerId)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
191
|
+
peerState.addWantRange(start, length)
|
|
192
|
+
this.#update()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {PeerId} peerId
|
|
197
|
+
* @returns {void}
|
|
198
|
+
*/
|
|
199
|
+
clearWantRanges(peerId) {
|
|
200
|
+
const peerState = this.#getOrCreatePeerState(peerId)
|
|
201
|
+
peerState.clearWantRanges()
|
|
192
202
|
this.#update()
|
|
193
203
|
}
|
|
194
204
|
|
|
@@ -291,14 +301,13 @@ export class PeerState {
|
|
|
291
301
|
#preHaves = new RemoteBitfield()
|
|
292
302
|
/** @type {HypercoreRemoteBitfield | undefined} */
|
|
293
303
|
#haves
|
|
294
|
-
/**
|
|
295
|
-
|
|
304
|
+
/**
|
|
305
|
+
* What blocks do we want? If `null`, we want everything.
|
|
306
|
+
* @type {null | Bitfield}
|
|
307
|
+
*/
|
|
308
|
+
#wants = null
|
|
296
309
|
/** @type {PeerNamespaceState['status']} */
|
|
297
310
|
status = 'stopped'
|
|
298
|
-
#wantAll
|
|
299
|
-
constructor({ wantAll = true } = {}) {
|
|
300
|
-
this.#wantAll = wantAll
|
|
301
|
-
}
|
|
302
311
|
get preHavesBitfield() {
|
|
303
312
|
return this.#preHaves
|
|
304
313
|
}
|
|
@@ -316,18 +325,27 @@ export class PeerState {
|
|
|
316
325
|
this.#haves = bitfield
|
|
317
326
|
}
|
|
318
327
|
/**
|
|
319
|
-
*
|
|
328
|
+
* Add a range of blocks that a peer wants. This is not part of the Hypercore
|
|
320
329
|
* protocol, so we need our own extension messages that a peer can use to
|
|
321
330
|
* inform us which blocks they are interested in. For most cores peers always
|
|
322
|
-
* want all blocks, but for blob cores
|
|
331
|
+
* want all blocks, but for blob cores peers may only want preview or
|
|
323
332
|
* thumbnail versions of media
|
|
324
333
|
*
|
|
325
|
-
* @param {
|
|
334
|
+
* @param {number} start
|
|
335
|
+
* @param {number} length
|
|
336
|
+
* @returns {void}
|
|
326
337
|
*/
|
|
327
|
-
|
|
328
|
-
this.#
|
|
338
|
+
addWantRange(start, length) {
|
|
339
|
+
this.#wants ??= new RemoteBitfield()
|
|
329
340
|
this.#wants.setRange(start, length, true)
|
|
330
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* Set the range of blocks that this peer wants to the empty set. In other
|
|
344
|
+
* words, this peer wants nothing from this core.
|
|
345
|
+
*/
|
|
346
|
+
clearWantRanges() {
|
|
347
|
+
this.#wants = new RemoteBitfield()
|
|
348
|
+
}
|
|
331
349
|
/**
|
|
332
350
|
* Returns whether the peer has the block at `index`. If a pre-have bitfield
|
|
333
351
|
* has been passed, this is used if no connected peer bitfield is available.
|
|
@@ -355,8 +373,7 @@ export class PeerState {
|
|
|
355
373
|
* @param {number} index
|
|
356
374
|
*/
|
|
357
375
|
want(index) {
|
|
358
|
-
|
|
359
|
-
return this.#wants.get(index)
|
|
376
|
+
return this.#wants ? this.#wants.get(index) : true
|
|
360
377
|
}
|
|
361
378
|
/**
|
|
362
379
|
* Return the "wants" for the 32 blocks from `index`, as a 32-bit integer
|
|
@@ -366,11 +383,10 @@ export class PeerState {
|
|
|
366
383
|
* the 32 blocks from `index`
|
|
367
384
|
*/
|
|
368
385
|
wantWord(index) {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
return getBitfieldWord(this.#wants, index)
|
|
386
|
+
return this.#wants
|
|
387
|
+
? getBitfieldWord(this.#wants, index)
|
|
388
|
+
: // This is a 32-bit number with all bits set
|
|
389
|
+
2 ** 32 - 1
|
|
374
390
|
}
|
|
375
391
|
}
|
|
376
392
|
|
|
@@ -136,6 +136,28 @@ export class NamespaceSyncState {
|
|
|
136
136
|
this.#getCoreState(coreDiscoveryId).insertPreHaves(peerId, start, bitfield)
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* @param {string} peerId
|
|
141
|
+
* @param {number} start
|
|
142
|
+
* @param {number} length
|
|
143
|
+
* @returns {void}
|
|
144
|
+
*/
|
|
145
|
+
addWantRange(peerId, start, length) {
|
|
146
|
+
for (const coreState of this.#coreStates.values()) {
|
|
147
|
+
coreState.addWantRange(peerId, start, length)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} peerId
|
|
153
|
+
* @returns {void}
|
|
154
|
+
*/
|
|
155
|
+
clearWantRanges(peerId) {
|
|
156
|
+
for (const coreState of this.#coreStates.values()) {
|
|
157
|
+
coreState.clearWantRanges(peerId)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
139
161
|
/**
|
|
140
162
|
* @param {string} discoveryId
|
|
141
163
|
*/
|