@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.
Files changed (114) hide show
  1. package/dist/blob-store/downloader.d.ts +43 -0
  2. package/dist/blob-store/downloader.d.ts.map +1 -0
  3. package/dist/blob-store/entries-stream.d.ts +13 -0
  4. package/dist/blob-store/entries-stream.d.ts.map +1 -0
  5. package/dist/blob-store/hyperdrive-index.d.ts +20 -0
  6. package/dist/blob-store/hyperdrive-index.d.ts.map +1 -0
  7. package/dist/blob-store/index.d.ts +34 -29
  8. package/dist/blob-store/index.d.ts.map +1 -1
  9. package/dist/blob-store/utils.d.ts +27 -0
  10. package/dist/blob-store/utils.d.ts.map +1 -0
  11. package/dist/constants.d.ts +2 -1
  12. package/dist/constants.d.ts.map +1 -1
  13. package/dist/core-manager/index.d.ts +11 -1
  14. package/dist/core-manager/index.d.ts.map +1 -1
  15. package/dist/core-ownership.d.ts.map +1 -1
  16. package/dist/datastore/index.d.ts +5 -4
  17. package/dist/datastore/index.d.ts.map +1 -1
  18. package/dist/datatype/index.d.ts +5 -1
  19. package/dist/discovery/local-discovery.d.ts.map +1 -1
  20. package/dist/errors.d.ts +6 -1
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/fastify-plugins/blobs.d.ts.map +1 -1
  23. package/dist/fastify-plugins/maps.d.ts.map +1 -1
  24. package/dist/generated/extensions.d.ts +31 -0
  25. package/dist/generated/extensions.d.ts.map +1 -1
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/lib/drizzle-helpers.d.ts +6 -0
  29. package/dist/lib/drizzle-helpers.d.ts.map +1 -0
  30. package/dist/lib/error.d.ts +51 -0
  31. package/dist/lib/error.d.ts.map +1 -0
  32. package/dist/lib/get-own.d.ts +9 -0
  33. package/dist/lib/get-own.d.ts.map +1 -0
  34. package/dist/lib/is-hostname-ip-address.d.ts +17 -0
  35. package/dist/lib/is-hostname-ip-address.d.ts.map +1 -0
  36. package/dist/lib/ws-core-replicator.d.ts +11 -0
  37. package/dist/lib/ws-core-replicator.d.ts.map +1 -0
  38. package/dist/mapeo-manager.d.ts +18 -22
  39. package/dist/mapeo-manager.d.ts.map +1 -1
  40. package/dist/mapeo-project.d.ts +459 -26
  41. package/dist/mapeo-project.d.ts.map +1 -1
  42. package/dist/member-api.d.ts +44 -1
  43. package/dist/member-api.d.ts.map +1 -1
  44. package/dist/roles.d.ts.map +1 -1
  45. package/dist/schema/client.d.ts +17 -5
  46. package/dist/schema/client.d.ts.map +1 -1
  47. package/dist/schema/project.d.ts +212 -2
  48. package/dist/schema/project.d.ts.map +1 -1
  49. package/dist/sync/core-sync-state.d.ts +20 -15
  50. package/dist/sync/core-sync-state.d.ts.map +1 -1
  51. package/dist/sync/namespace-sync-state.d.ts +13 -1
  52. package/dist/sync/namespace-sync-state.d.ts.map +1 -1
  53. package/dist/sync/peer-sync-controller.d.ts +1 -1
  54. package/dist/sync/peer-sync-controller.d.ts.map +1 -1
  55. package/dist/sync/sync-api.d.ts +47 -2
  56. package/dist/sync/sync-api.d.ts.map +1 -1
  57. package/dist/sync/sync-state.d.ts +12 -0
  58. package/dist/sync/sync-state.d.ts.map +1 -1
  59. package/dist/translation-api.d.ts +2 -2
  60. package/dist/translation-api.d.ts.map +1 -1
  61. package/dist/types.d.ts +10 -2
  62. package/dist/types.d.ts.map +1 -1
  63. package/drizzle/client/0001_chubby_cargill.sql +12 -0
  64. package/drizzle/client/meta/0001_snapshot.json +208 -0
  65. package/drizzle/client/meta/_journal.json +7 -0
  66. package/drizzle/project/0001_medical_wendell_rand.sql +22 -0
  67. package/drizzle/project/meta/0001_snapshot.json +1267 -0
  68. package/drizzle/project/meta/_journal.json +7 -0
  69. package/package.json +14 -5
  70. package/src/blob-store/downloader.js +130 -0
  71. package/src/blob-store/entries-stream.js +81 -0
  72. package/src/blob-store/hyperdrive-index.js +122 -0
  73. package/src/blob-store/index.js +59 -117
  74. package/src/blob-store/utils.js +54 -0
  75. package/src/constants.js +4 -1
  76. package/src/core-manager/index.js +60 -3
  77. package/src/core-ownership.js +2 -4
  78. package/src/datastore/README.md +1 -2
  79. package/src/datastore/index.js +8 -8
  80. package/src/datatype/index.d.ts +5 -1
  81. package/src/datatype/index.js +22 -9
  82. package/src/discovery/local-discovery.js +2 -1
  83. package/src/errors.js +11 -2
  84. package/src/fastify-plugins/blobs.js +17 -1
  85. package/src/fastify-plugins/maps.js +2 -1
  86. package/src/generated/extensions.d.ts +31 -0
  87. package/src/generated/extensions.js +150 -0
  88. package/src/generated/extensions.ts +181 -0
  89. package/src/index.js +10 -0
  90. package/src/invite-api.js +1 -1
  91. package/src/lib/drizzle-helpers.js +79 -0
  92. package/src/lib/error.js +71 -0
  93. package/src/lib/get-own.js +10 -0
  94. package/src/lib/is-hostname-ip-address.js +26 -0
  95. package/src/lib/ws-core-replicator.js +47 -0
  96. package/src/mapeo-manager.js +74 -45
  97. package/src/mapeo-project.js +238 -58
  98. package/src/member-api.js +295 -2
  99. package/src/roles.js +38 -32
  100. package/src/schema/client.js +4 -3
  101. package/src/schema/project.js +7 -0
  102. package/src/sync/core-sync-state.js +39 -23
  103. package/src/sync/namespace-sync-state.js +22 -0
  104. package/src/sync/peer-sync-controller.js +1 -0
  105. package/src/sync/sync-api.js +197 -3
  106. package/src/sync/sync-state.js +18 -0
  107. package/src/translation-api.js +5 -9
  108. package/src/types.ts +12 -3
  109. package/dist/blob-store/live-download.d.ts +0 -107
  110. package/dist/blob-store/live-download.d.ts.map +0 -1
  111. package/dist/lib/timing-safe-equal.d.ts +0 -15
  112. package/dist/lib/timing-safe-equal.d.ts.map +0 -1
  113. package/src/blob-store/live-download.js +0 -373
  114. 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 './lib/timing-safe-equal.js'
15
- import { ROLES, isRoleIdForNewInvite } from './roles.js'
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
- /** @type {string} */
268
- let roleId
269
- try {
270
- const roleAssignment = await this.#dataType.getByDocId(deviceId)
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 ROLES[BLOCKED_ROLE_ID]
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(() => null)
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
- async #isProjectCreator() {
412
+ #isProjectCreator() {
407
413
  const ownAuthCoreId = this.#coreManager
408
414
  .getWriterCore('auth')
409
415
  .key.toString('hex')
@@ -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 localDeviceInfoTable = sqliteTable('localDeviceInfo', {
52
+ export const deviceSettingsTable = sqliteTable('deviceSettings', {
53
53
  deviceId: text('deviceId').notNull().unique(),
54
- deviceInfo: deviceInfoColumn('deviceInfo').notNull(),
54
+ deviceInfo: deviceInfoColumn('deviceInfo'),
55
+ isArchiveDevice: int('isArchiveDevice', { mode: 'boolean' }),
55
56
  })
@@ -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 {Array<{ start: number, length: number }>} ranges
185
+ * @param {number} start
186
+ * @param {number} length
187
+ * @returns {void}
186
188
  */
187
- setPeerWants(peerId, ranges) {
189
+ addWantRange(peerId, start, length) {
188
190
  const peerState = this.#getOrCreatePeerState(peerId)
189
- for (const { start, length } of ranges) {
190
- peerState.setWantRange({ start, length })
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
- /** @type {Bitfield} */
295
- #wants = new RemoteBitfield()
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
- * Set a range of blocks that a peer wants. This is not part of the Hypercore
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 often peers only want preview or
331
+ * want all blocks, but for blob cores peers may only want preview or
323
332
  * thumbnail versions of media
324
333
  *
325
- * @param {{ start: number, length: number }} range
334
+ * @param {number} start
335
+ * @param {number} length
336
+ * @returns {void}
326
337
  */
327
- setWantRange({ start, length }) {
328
- this.#wantAll = false
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
- if (this.#wantAll) return true
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
- if (this.#wantAll) {
370
- // This is a 32-bit number with all bits set
371
- return 2 ** 32 - 1
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
  */
@@ -152,6 +152,7 @@ export class PeerSyncController {
152
152
 
153
153
  if (didUpdate.auth) {
154
154
  try {
155
+ this.#log('reading role for %h', this.peerId)
155
156
  const cap = await this.#roles.getRole(this.peerId)
156
157
  this.#syncCapability = cap.sync
157
158
  } catch (e) {