@comapeo/core 1.0.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/LICENSE.md +9 -0
- package/README.md +31 -0
- package/dist/blob-api.d.ts +92 -0
- package/dist/blob-api.d.ts.map +1 -0
- package/dist/blob-store/index.d.ts +163 -0
- package/dist/blob-store/index.d.ts.map +1 -0
- package/dist/blob-store/live-download.d.ts +107 -0
- package/dist/blob-store/live-download.d.ts.map +1 -0
- package/dist/config-import.d.ts +74 -0
- package/dist/config-import.d.ts.map +1 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/core-manager/bitfield-rle.d.ts +25 -0
- package/dist/core-manager/bitfield-rle.d.ts.map +1 -0
- package/dist/core-manager/core-index.d.ts +56 -0
- package/dist/core-manager/core-index.d.ts.map +1 -0
- package/dist/core-manager/index.d.ts +125 -0
- package/dist/core-manager/index.d.ts.map +1 -0
- package/dist/core-manager/random-access-file-pool.d.ts +17 -0
- package/dist/core-manager/random-access-file-pool.d.ts.map +1 -0
- package/dist/core-manager/remote-bitfield.d.ts +146 -0
- package/dist/core-manager/remote-bitfield.d.ts.map +1 -0
- package/dist/core-ownership.d.ts +112 -0
- package/dist/core-ownership.d.ts.map +1 -0
- package/dist/datastore/index.d.ts +91 -0
- package/dist/datastore/index.d.ts.map +1 -0
- package/dist/datatype/index.d.ts +108 -0
- package/dist/discovery/local-discovery.d.ts +64 -0
- package/dist/discovery/local-discovery.d.ts.map +1 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/fastify-controller.d.ts +27 -0
- package/dist/fastify-controller.d.ts.map +1 -0
- package/dist/fastify-plugins/blobs.d.ts +6 -0
- package/dist/fastify-plugins/blobs.d.ts.map +1 -0
- package/dist/fastify-plugins/constants.d.ts +3 -0
- package/dist/fastify-plugins/constants.d.ts.map +1 -0
- package/dist/fastify-plugins/icons.d.ts +6 -0
- package/dist/fastify-plugins/icons.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/index.d.ts +11 -0
- package/dist/fastify-plugins/maps/index.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/offline-fallback-map.d.ts +12 -0
- package/dist/fastify-plugins/maps/offline-fallback-map.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/static-maps.d.ts +11 -0
- package/dist/fastify-plugins/maps/static-maps.d.ts.map +1 -0
- package/dist/fastify-plugins/utils.d.ts +23 -0
- package/dist/fastify-plugins/utils.d.ts.map +1 -0
- package/dist/generated/extensions.d.ts +44 -0
- package/dist/generated/extensions.d.ts.map +1 -0
- package/dist/generated/keys.d.ts +36 -0
- package/dist/generated/keys.d.ts.map +1 -0
- package/dist/generated/rpc.d.ts +87 -0
- package/dist/generated/rpc.d.ts.map +1 -0
- package/dist/icon-api.d.ts +109 -0
- package/dist/icon-api.d.ts.map +1 -0
- package/dist/index-writer/index.d.ts +51 -0
- package/dist/index-writer/index.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/invite-api.d.ts +70 -0
- package/dist/invite-api.d.ts.map +1 -0
- package/dist/lib/hashmap.d.ts +62 -0
- package/dist/lib/hashmap.d.ts.map +1 -0
- package/dist/lib/hypercore-helpers.d.ts +6 -0
- package/dist/lib/hypercore-helpers.d.ts.map +1 -0
- package/dist/lib/noise-secret-stream-helpers.d.ts +45 -0
- package/dist/lib/noise-secret-stream-helpers.d.ts.map +1 -0
- package/dist/lib/ponyfills.d.ts +10 -0
- package/dist/lib/ponyfills.d.ts.map +1 -0
- package/dist/lib/string.d.ts +2 -0
- package/dist/lib/string.d.ts.map +1 -0
- package/dist/lib/timing-safe-equal.d.ts +15 -0
- package/dist/lib/timing-safe-equal.d.ts.map +1 -0
- package/dist/local-peers.d.ts +151 -0
- package/dist/local-peers.d.ts.map +1 -0
- package/dist/logger.d.ts +32 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/mapeo-manager.d.ts +178 -0
- package/dist/mapeo-manager.d.ts.map +1 -0
- package/dist/mapeo-project.d.ts +3233 -0
- package/dist/mapeo-project.d.ts.map +1 -0
- package/dist/member-api.d.ts +114 -0
- package/dist/member-api.d.ts.map +1 -0
- package/dist/roles.d.ts +157 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/schema/client.d.ts +284 -0
- package/dist/schema/client.d.ts.map +1 -0
- package/dist/schema/project.d.ts +1812 -0
- package/dist/schema/project.d.ts.map +1 -0
- package/dist/schema/schema-to-drizzle.d.ts +20 -0
- package/dist/schema/schema-to-drizzle.d.ts.map +1 -0
- package/dist/schema/types.d.ts +98 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/schema/utils.d.ts +55 -0
- package/dist/schema/utils.d.ts.map +1 -0
- package/dist/sync/core-sync-state.d.ts +252 -0
- package/dist/sync/core-sync-state.d.ts.map +1 -0
- package/dist/sync/namespace-sync-state.d.ts +47 -0
- package/dist/sync/namespace-sync-state.d.ts.map +1 -0
- package/dist/sync/peer-sync-controller.d.ts +44 -0
- package/dist/sync/peer-sync-controller.d.ts.map +1 -0
- package/dist/sync/sync-api.d.ts +158 -0
- package/dist/sync/sync-api.d.ts.map +1 -0
- package/dist/sync/sync-state.d.ts +40 -0
- package/dist/sync/sync-state.d.ts.map +1 -0
- package/dist/translation-api.d.ts +288 -0
- package/dist/translation-api.d.ts.map +1 -0
- package/dist/types.d.ts +115 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils.d.ts +115 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils_types.d.ts +14 -0
- package/drizzle/client/0000_bumpy_carnage.sql +33 -0
- package/drizzle/client/meta/0000_snapshot.json +199 -0
- package/drizzle/client/meta/_journal.json +13 -0
- package/drizzle/project/0000_spooky_lady_ursula.sql +192 -0
- package/drizzle/project/meta/0000_snapshot.json +1137 -0
- package/drizzle/project/meta/_journal.json +13 -0
- package/package.json +202 -0
- package/src/blob-api.js +139 -0
- package/src/blob-store/index.js +325 -0
- package/src/blob-store/live-download.js +373 -0
- package/src/config-import.js +604 -0
- package/src/constants.js +34 -0
- package/src/core-manager/bitfield-rle.js +235 -0
- package/src/core-manager/core-index.js +87 -0
- package/src/core-manager/index.js +504 -0
- package/src/core-manager/random-access-file-pool.js +30 -0
- package/src/core-manager/remote-bitfield.js +416 -0
- package/src/core-ownership.js +235 -0
- package/src/datastore/README.md +46 -0
- package/src/datastore/index.js +234 -0
- package/src/datatype/README.md +33 -0
- package/src/datatype/index.d.ts +108 -0
- package/src/datatype/index.js +358 -0
- package/src/discovery/local-discovery.js +303 -0
- package/src/errors.js +5 -0
- package/src/fastify-controller.js +84 -0
- package/src/fastify-plugins/blobs.js +139 -0
- package/src/fastify-plugins/constants.js +5 -0
- package/src/fastify-plugins/icons.js +158 -0
- package/src/fastify-plugins/maps/index.js +173 -0
- package/src/fastify-plugins/maps/offline-fallback-map.js +114 -0
- package/src/fastify-plugins/maps/static-maps.js +271 -0
- package/src/fastify-plugins/utils.js +52 -0
- package/src/generated/README.md +3 -0
- package/src/generated/extensions.d.ts +44 -0
- package/src/generated/extensions.js +196 -0
- package/src/generated/extensions.ts +237 -0
- package/src/generated/keys.d.ts +36 -0
- package/src/generated/keys.js +148 -0
- package/src/generated/keys.ts +185 -0
- package/src/generated/rpc.d.ts +87 -0
- package/src/generated/rpc.js +389 -0
- package/src/generated/rpc.ts +463 -0
- package/src/icon-api.js +282 -0
- package/src/index-writer/README.md +38 -0
- package/src/index-writer/index.js +124 -0
- package/src/index.js +16 -0
- package/src/invite-api.js +450 -0
- package/src/lib/hashmap.js +91 -0
- package/src/lib/hypercore-helpers.js +18 -0
- package/src/lib/noise-secret-stream-helpers.js +37 -0
- package/src/lib/ponyfills.js +25 -0
- package/src/lib/string.js +7 -0
- package/src/lib/timing-safe-equal.js +34 -0
- package/src/local-peers.js +737 -0
- package/src/logger.js +99 -0
- package/src/mapeo-manager.js +914 -0
- package/src/mapeo-project.js +980 -0
- package/src/member-api.js +319 -0
- package/src/roles.js +412 -0
- package/src/schema/client.js +55 -0
- package/src/schema/project.js +44 -0
- package/src/schema/schema-to-drizzle.js +118 -0
- package/src/schema/types.ts +153 -0
- package/src/schema/utils.js +51 -0
- package/src/sync/core-sync-state.js +440 -0
- package/src/sync/namespace-sync-state.js +193 -0
- package/src/sync/peer-sync-controller.js +332 -0
- package/src/sync/sync-api.js +588 -0
- package/src/sync/sync-state.js +63 -0
- package/src/translation-api.js +141 -0
- package/src/types.ts +149 -0
- package/src/utils.js +210 -0
- package/src/utils_types.d.ts +14 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { TypedEmitter } from 'tiny-typed-emitter'
|
|
2
|
+
import { pEvent } from 'p-event'
|
|
3
|
+
import { InviteResponse_Decision } from './generated/rpc.js'
|
|
4
|
+
import { assert, keyToId, noop } from './utils.js'
|
|
5
|
+
import HashMap from './lib/hashmap.js'
|
|
6
|
+
import timingSafeEqual from './lib/timing-safe-equal.js'
|
|
7
|
+
import { Logger } from './logger.js'
|
|
8
|
+
/** @import { MapBuffers } from './types.js' */
|
|
9
|
+
/**
|
|
10
|
+
* @import {
|
|
11
|
+
* Invite as InviteRpcMessage,
|
|
12
|
+
* InviteCancel,
|
|
13
|
+
* ProjectJoinDetails
|
|
14
|
+
* } from './generated/rpc.js'
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// There are three slightly different invite types:
|
|
18
|
+
//
|
|
19
|
+
// - InviteRpcMessage comes from the protobuf.
|
|
20
|
+
// - InviteInternal adds a locally-generated receive timestamp.
|
|
21
|
+
// - Invite is the externally-facing type.
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @internal
|
|
25
|
+
* @typedef {InviteRpcMessage & { receivedAt: number }} InviteInternal
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @typedef {MapBuffers<InviteInternal>} Invite */
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {(
|
|
32
|
+
* 'accepted' |
|
|
33
|
+
* 'rejected' |
|
|
34
|
+
* 'canceled' |
|
|
35
|
+
* 'accepted other' |
|
|
36
|
+
* 'connection error' |
|
|
37
|
+
* 'internal error'
|
|
38
|
+
* )} InviteRemovalReason
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Manage pending invite state.
|
|
43
|
+
*/
|
|
44
|
+
class PendingInvites {
|
|
45
|
+
/**
|
|
46
|
+
* @internal
|
|
47
|
+
* @typedef {object} PendingInvite
|
|
48
|
+
* @prop {string} peerId
|
|
49
|
+
* @prop {InviteInternal} invite
|
|
50
|
+
* @prop {boolean} isAccepting
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/** @type {HashMap<Buffer, PendingInvite>} */
|
|
54
|
+
#byInviteId = new HashMap(keyToId)
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @returns {Iterable<PendingInvite>} the pending invites, in insertion order
|
|
58
|
+
*/
|
|
59
|
+
invites() {
|
|
60
|
+
return this.#byInviteId.values()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {PendingInvite} pendingInvite
|
|
65
|
+
* @throws if adding a duplicate invite ID
|
|
66
|
+
* @returns {void}
|
|
67
|
+
*/
|
|
68
|
+
add(pendingInvite) {
|
|
69
|
+
const {
|
|
70
|
+
invite: { inviteId },
|
|
71
|
+
} = pendingInvite
|
|
72
|
+
assert(!this.#byInviteId.has(inviteId), 'Added duplicate invite')
|
|
73
|
+
this.#byInviteId.set(inviteId, pendingInvite)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {Buffer} inviteId
|
|
78
|
+
* @returns {void}
|
|
79
|
+
*/
|
|
80
|
+
markAccepting(inviteId) {
|
|
81
|
+
const pendingInvite = this.#byInviteId.get(inviteId)
|
|
82
|
+
assert(
|
|
83
|
+
!!pendingInvite,
|
|
84
|
+
`Couldn't find invite for ${inviteId.toString('hex')}`
|
|
85
|
+
)
|
|
86
|
+
this.#byInviteId.set(inviteId, { ...pendingInvite, isAccepting: true })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {Buffer} inviteId
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
hasInviteId(inviteId) {
|
|
94
|
+
return this.#byInviteId.has(inviteId)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {Readonly<Buffer>} projectInviteId
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
isAcceptingForProject(projectInviteId) {
|
|
102
|
+
for (const { invite, isAccepting } of this.invites()) {
|
|
103
|
+
if (isAccepting && invite.projectInviteId.equals(projectInviteId)) {
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {Buffer} inviteId
|
|
112
|
+
* @returns {undefined | PendingInvite}
|
|
113
|
+
*/
|
|
114
|
+
getByInviteId(inviteId) {
|
|
115
|
+
return this.#byInviteId.get(inviteId)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {Buffer} inviteId
|
|
120
|
+
* @returns {boolean} `true` if an invite existed and was deleted, `false` otherwise
|
|
121
|
+
*/
|
|
122
|
+
deleteByInviteId(inviteId) {
|
|
123
|
+
return this.#byInviteId.delete(inviteId)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {Readonly<Buffer>} projectInviteId
|
|
128
|
+
* @returns {PendingInvite[]} the pending invites that were deleted
|
|
129
|
+
*/
|
|
130
|
+
deleteByProjectInviteId(projectInviteId) {
|
|
131
|
+
/** @type {PendingInvite[]} */
|
|
132
|
+
const result = []
|
|
133
|
+
|
|
134
|
+
for (const pendingInvite of this.invites()) {
|
|
135
|
+
if (pendingInvite.invite.projectInviteId.equals(projectInviteId)) {
|
|
136
|
+
result.push(pendingInvite)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const { invite } of result) this.deleteByInviteId(invite.inviteId)
|
|
141
|
+
|
|
142
|
+
return result
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @typedef {Object} InviteApiEvents
|
|
148
|
+
* @property {(invite: Invite) => void} invite-received
|
|
149
|
+
* @property {(invite: Invite, removalReason: InviteRemovalReason) => void} invite-removed
|
|
150
|
+
*/
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @extends {TypedEmitter<InviteApiEvents>}
|
|
154
|
+
*/
|
|
155
|
+
export class InviteApi extends TypedEmitter {
|
|
156
|
+
#getProjectByInviteId
|
|
157
|
+
#addProject
|
|
158
|
+
#pendingInvites = new PendingInvites()
|
|
159
|
+
#l
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {Object} options
|
|
163
|
+
* @param {import('./local-peers.js').LocalPeers} options.rpc
|
|
164
|
+
* @param {object} options.queries
|
|
165
|
+
* @param {(projectInviteId: Readonly<Buffer>) => undefined | { projectPublicId: string }} options.queries.getProjectByInviteId
|
|
166
|
+
* @param {(projectDetails: Pick<ProjectJoinDetails, 'projectKey' | 'encryptionKeys'> & { projectName: string }) => Promise<string>} options.queries.addProject
|
|
167
|
+
* @param {Logger} [options.logger]
|
|
168
|
+
*/
|
|
169
|
+
constructor({ rpc, queries, logger }) {
|
|
170
|
+
super()
|
|
171
|
+
|
|
172
|
+
this.#l = Logger.create('InviteApi', logger)
|
|
173
|
+
|
|
174
|
+
this.rpc = rpc
|
|
175
|
+
this.#getProjectByInviteId = queries.getProjectByInviteId
|
|
176
|
+
this.#addProject = queries.addProject
|
|
177
|
+
|
|
178
|
+
this.rpc.on('invite', (...args) => {
|
|
179
|
+
try {
|
|
180
|
+
this.#handleInviteRpcMessage(...args)
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.error('Error handling invite', err)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
this.rpc.on('invite-cancel', (_peerId, inviteCancel) => {
|
|
187
|
+
try {
|
|
188
|
+
this.#handleInviteCancel(inviteCancel)
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error('Error handling invite cancel', err)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {string} peerId
|
|
197
|
+
* @param {InviteRpcMessage} inviteRpcMessage
|
|
198
|
+
*/
|
|
199
|
+
#handleInviteRpcMessage(peerId, inviteRpcMessage) {
|
|
200
|
+
const invite = { ...inviteRpcMessage, receivedAt: Date.now() }
|
|
201
|
+
|
|
202
|
+
this.#l.log('Received invite %h from %S', invite.inviteId, peerId)
|
|
203
|
+
|
|
204
|
+
const isAlreadyMember = Boolean(
|
|
205
|
+
this.#getProjectByInviteId(invite.projectInviteId)
|
|
206
|
+
)
|
|
207
|
+
if (isAlreadyMember) {
|
|
208
|
+
this.#l.log('Invite %h: already in project', invite.inviteId)
|
|
209
|
+
this.rpc
|
|
210
|
+
.sendInviteResponse(peerId, {
|
|
211
|
+
decision: InviteResponse_Decision.ALREADY,
|
|
212
|
+
inviteId: invite.inviteId,
|
|
213
|
+
})
|
|
214
|
+
.catch(noop)
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const hasAlreadyReceivedThisInvite = this.#pendingInvites.hasInviteId(
|
|
219
|
+
invite.inviteId
|
|
220
|
+
)
|
|
221
|
+
if (hasAlreadyReceivedThisInvite) {
|
|
222
|
+
this.#l.log('Invite %h: already received this invite', invite.inviteId)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this.#pendingInvites.add({ peerId, invite, isAccepting: false })
|
|
227
|
+
this.emit('invite-received', internalToExternal(invite))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @param {InviteCancel} inviteCancel
|
|
232
|
+
*/
|
|
233
|
+
#handleInviteCancel(inviteCancel) {
|
|
234
|
+
const { inviteId } = inviteCancel
|
|
235
|
+
|
|
236
|
+
this.#l.log('Received invite cancel for invite ID %h', inviteId)
|
|
237
|
+
|
|
238
|
+
const pendingInvite = this.#pendingInvites.getByInviteId(inviteId)
|
|
239
|
+
if (!pendingInvite) {
|
|
240
|
+
this.#l.log(
|
|
241
|
+
'Received invite cancel for %h but no such invite exists',
|
|
242
|
+
inviteId
|
|
243
|
+
)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
const { invite, isAccepting } = pendingInvite
|
|
247
|
+
|
|
248
|
+
if (isAccepting) {
|
|
249
|
+
this.#l.log(
|
|
250
|
+
"Received invite cancel for %h but we're already accepting",
|
|
251
|
+
inviteId
|
|
252
|
+
)
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this.#pendingInvites.deleteByInviteId(inviteId)
|
|
257
|
+
this.emit('invite-removed', internalToExternal(invite), 'canceled')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* @returns {Array<Invite>}
|
|
262
|
+
*/
|
|
263
|
+
getPending() {
|
|
264
|
+
return [...this.#pendingInvites.invites()].map(({ invite }) =>
|
|
265
|
+
internalToExternal(invite)
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Attempt to accept the invite.
|
|
271
|
+
*
|
|
272
|
+
* This can fail if the invitor has canceled the invite or if you cannot
|
|
273
|
+
* connect to the invitor's device.
|
|
274
|
+
*
|
|
275
|
+
* If the invite is accepted and you had other invites to the same project,
|
|
276
|
+
* those invites are removed, and the invitors are told that you're already
|
|
277
|
+
* part of this project.
|
|
278
|
+
*
|
|
279
|
+
* @param {Pick<Invite, 'inviteId'>} invite
|
|
280
|
+
* @returns {Promise<string>}
|
|
281
|
+
*/
|
|
282
|
+
async accept({ inviteId: inviteIdString }) {
|
|
283
|
+
const inviteId = Buffer.from(inviteIdString, 'hex')
|
|
284
|
+
|
|
285
|
+
const pendingInvite = this.#pendingInvites.getByInviteId(inviteId)
|
|
286
|
+
if (!pendingInvite) {
|
|
287
|
+
throw new Error(`Cannot find invite ID ${inviteIdString}`)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const { peerId, invite } = pendingInvite
|
|
291
|
+
const { projectName, projectInviteId } = invite
|
|
292
|
+
|
|
293
|
+
/** @param {InviteRemovalReason} removalReason */
|
|
294
|
+
const removePendingInvite = (removalReason) => {
|
|
295
|
+
const didDelete = this.#pendingInvites.deleteByInviteId(inviteId)
|
|
296
|
+
if (didDelete) {
|
|
297
|
+
this.emit('invite-removed', internalToExternal(invite), removalReason)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// This is probably impossible in the UI, but it's theoretically possible
|
|
302
|
+
// to join a project while an invite is pending, so we need to check this.
|
|
303
|
+
const existingProject = this.#getProjectByInviteId(projectInviteId)
|
|
304
|
+
if (existingProject) {
|
|
305
|
+
this.#l.log(
|
|
306
|
+
"Went to accept invite %h but we're already in the project",
|
|
307
|
+
inviteId
|
|
308
|
+
)
|
|
309
|
+
const pendingInvitesDeleted =
|
|
310
|
+
this.#pendingInvites.deleteByProjectInviteId(projectInviteId)
|
|
311
|
+
for (const pendingInvite of pendingInvitesDeleted) {
|
|
312
|
+
this.rpc
|
|
313
|
+
.sendInviteResponse(pendingInvite.peerId, {
|
|
314
|
+
decision: InviteResponse_Decision.ALREADY,
|
|
315
|
+
inviteId: pendingInvite.invite.inviteId,
|
|
316
|
+
})
|
|
317
|
+
.catch(noop)
|
|
318
|
+
this.emit(
|
|
319
|
+
'invite-removed',
|
|
320
|
+
internalToExternal(pendingInvite.invite),
|
|
321
|
+
'accepted'
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
return existingProject.projectPublicId
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
assert(
|
|
328
|
+
!this.#pendingInvites.isAcceptingForProject(projectInviteId),
|
|
329
|
+
`Cannot double-accept invite for project ${projectInviteId
|
|
330
|
+
.toString('hex')
|
|
331
|
+
.slice(0, 7)}`
|
|
332
|
+
)
|
|
333
|
+
this.#pendingInvites.markAccepting(inviteId)
|
|
334
|
+
|
|
335
|
+
const projectDetailsAbortController = new AbortController()
|
|
336
|
+
|
|
337
|
+
const projectDetailsPromise =
|
|
338
|
+
/** @type {typeof pEvent<'got-project-details', [string, ProjectJoinDetails]>} */ (
|
|
339
|
+
pEvent
|
|
340
|
+
)(this.rpc, 'got-project-details', {
|
|
341
|
+
multiArgs: true,
|
|
342
|
+
filter: ([projectDetailsPeerId, details]) =>
|
|
343
|
+
// This peer ID check is probably superfluous because the invite ID
|
|
344
|
+
// should be unguessable, but might be useful if someone forwards an
|
|
345
|
+
// invite message (or if there's an unforeseen bug).
|
|
346
|
+
timingSafeEqual(projectDetailsPeerId, peerId) &&
|
|
347
|
+
timingSafeEqual(inviteId, details.inviteId),
|
|
348
|
+
signal: projectDetailsAbortController.signal,
|
|
349
|
+
})
|
|
350
|
+
.then((args) => args?.[1])
|
|
351
|
+
.catch(noop)
|
|
352
|
+
|
|
353
|
+
this.#l.log('Sending accept response for invite %h', inviteId)
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
await this.rpc.sendInviteResponse(peerId, {
|
|
357
|
+
decision: InviteResponse_Decision.ACCEPT,
|
|
358
|
+
inviteId,
|
|
359
|
+
})
|
|
360
|
+
} catch (e) {
|
|
361
|
+
projectDetailsAbortController.abort()
|
|
362
|
+
removePendingInvite('connection error')
|
|
363
|
+
throw new Error('Could not accept invite: Peer disconnected')
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** @type {string} */ let projectPublicId
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const details = await projectDetailsPromise
|
|
370
|
+
assert(details, 'Expected project details')
|
|
371
|
+
projectPublicId = await this.#addProject({ ...details, projectName })
|
|
372
|
+
} catch (e) {
|
|
373
|
+
removePendingInvite('internal error')
|
|
374
|
+
throw new Error('Failed to join project')
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const pendingInvitesDeleted =
|
|
378
|
+
this.#pendingInvites.deleteByProjectInviteId(projectInviteId)
|
|
379
|
+
|
|
380
|
+
for (const pendingInvite of pendingInvitesDeleted) {
|
|
381
|
+
const isPendingInviteWeJustAccepted =
|
|
382
|
+
// Unlike the above, these don't need to be timing-safe, because
|
|
383
|
+
// it's unlikely this method is vulnerable to timing attacks.
|
|
384
|
+
peerId === pendingInvite.peerId &&
|
|
385
|
+
inviteId.equals(pendingInvite.invite.inviteId)
|
|
386
|
+
if (isPendingInviteWeJustAccepted) continue
|
|
387
|
+
|
|
388
|
+
this.#l.log(
|
|
389
|
+
'Sending "already" response for invite %h to %S',
|
|
390
|
+
inviteId,
|
|
391
|
+
pendingInvite.peerId
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
this.rpc
|
|
395
|
+
.sendInviteResponse(pendingInvite.peerId, {
|
|
396
|
+
decision: InviteResponse_Decision.ALREADY,
|
|
397
|
+
inviteId: pendingInvite.invite.inviteId,
|
|
398
|
+
})
|
|
399
|
+
.catch(noop)
|
|
400
|
+
this.emit(
|
|
401
|
+
'invite-removed',
|
|
402
|
+
internalToExternal(pendingInvite.invite),
|
|
403
|
+
'accepted other'
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
this.emit('invite-removed', internalToExternal(invite), 'accepted')
|
|
408
|
+
|
|
409
|
+
return projectPublicId
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* @param {Pick<Invite, 'inviteId'>} invite
|
|
414
|
+
* @returns {void}
|
|
415
|
+
*/
|
|
416
|
+
reject({ inviteId: inviteIdString }) {
|
|
417
|
+
const inviteId = Buffer.from(inviteIdString, 'hex')
|
|
418
|
+
|
|
419
|
+
const pendingInvite = this.#pendingInvites.getByInviteId(inviteId)
|
|
420
|
+
assert(!!pendingInvite, `Cannot find invite ${inviteId}`)
|
|
421
|
+
|
|
422
|
+
const { peerId, invite, isAccepting } = pendingInvite
|
|
423
|
+
|
|
424
|
+
assert(!isAccepting, `Cannot reject ${inviteIdString}`)
|
|
425
|
+
|
|
426
|
+
this.#l.log('Rejecting invite %h', inviteId)
|
|
427
|
+
|
|
428
|
+
this.rpc
|
|
429
|
+
.sendInviteResponse(peerId, {
|
|
430
|
+
decision: InviteResponse_Decision.REJECT,
|
|
431
|
+
inviteId: invite.inviteId,
|
|
432
|
+
})
|
|
433
|
+
.catch(noop)
|
|
434
|
+
|
|
435
|
+
this.#pendingInvites.deleteByInviteId(inviteId)
|
|
436
|
+
this.emit('invite-removed', internalToExternal(invite), 'rejected')
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* @param {InviteInternal} internal
|
|
442
|
+
* @returns {Invite}
|
|
443
|
+
*/
|
|
444
|
+
function internalToExternal(internal) {
|
|
445
|
+
return {
|
|
446
|
+
...internal,
|
|
447
|
+
inviteId: internal.inviteId.toString('hex'),
|
|
448
|
+
projectInviteId: internal.projectInviteId.toString('hex'),
|
|
449
|
+
}
|
|
450
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** @typedef {string | number | bigint | boolean | undefined | symbol | null} Primitive */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `Map` uses same-value-zero equality for keys, which makes it more difficult
|
|
5
|
+
* to use reference types like buffers.
|
|
6
|
+
*
|
|
7
|
+
* `HashMap` is very similar to `Map`, but accepts a hash function for keys.
|
|
8
|
+
* This function should return a primitive, such as a number or string, which
|
|
9
|
+
* will be used as the key.
|
|
10
|
+
*
|
|
11
|
+
* It doesn't contain all the functionality of `Map` because we don't need it,
|
|
12
|
+
* but it should be fairly easy to update as needed.
|
|
13
|
+
*
|
|
14
|
+
* @template K
|
|
15
|
+
* @template {unknown} V
|
|
16
|
+
* @example
|
|
17
|
+
* const join = (arr) => arr.join(' ')
|
|
18
|
+
*
|
|
19
|
+
* const map = new HashMap(join)
|
|
20
|
+
*
|
|
21
|
+
* map.set([1, 2], 3)
|
|
22
|
+
* map.get([1, 2])
|
|
23
|
+
* // => 3
|
|
24
|
+
*/
|
|
25
|
+
export default class HashMap {
|
|
26
|
+
#hash
|
|
27
|
+
|
|
28
|
+
/** @type {Map<Primitive, V>} */
|
|
29
|
+
#realMap = new Map()
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {(key: K) => Primitive} hash
|
|
33
|
+
* @param {Iterable<[K, V]>} [iterable=[]]
|
|
34
|
+
*/
|
|
35
|
+
constructor(hash, iterable = []) {
|
|
36
|
+
this.#hash = hash
|
|
37
|
+
for (const [key, value] of iterable) this.set(key, value)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @returns {number}
|
|
42
|
+
*/
|
|
43
|
+
get size() {
|
|
44
|
+
return this.#realMap.size
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {K} key The key to remove.
|
|
49
|
+
* @returns {boolean} `true` if the key was present and removed, `false` otherwise.
|
|
50
|
+
*/
|
|
51
|
+
delete(key) {
|
|
52
|
+
const realKey = this.#hash(key)
|
|
53
|
+
return this.#realMap.delete(realKey)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {K} key The key to look up.
|
|
58
|
+
* @returns {undefined | V} The element associated with `key`, or `undefined` if it's not present.
|
|
59
|
+
*/
|
|
60
|
+
get(key) {
|
|
61
|
+
const realKey = this.#hash(key)
|
|
62
|
+
return this.#realMap.get(realKey)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {K} key The key to look up.
|
|
67
|
+
* @returns {boolean} `true` if `key` is present in the map, `false` otherwise.
|
|
68
|
+
*/
|
|
69
|
+
has(key) {
|
|
70
|
+
const realKey = this.#hash(key)
|
|
71
|
+
return this.#realMap.has(realKey)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {K} key The key to update.
|
|
76
|
+
* @param {V} value The value to add at `key`.
|
|
77
|
+
* @returns {this} The map.
|
|
78
|
+
*/
|
|
79
|
+
set(key, value) {
|
|
80
|
+
const realKey = this.#hash(key)
|
|
81
|
+
this.#realMap.set(realKey, value)
|
|
82
|
+
return this
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @returns {IterableIterator<V>}
|
|
87
|
+
*/
|
|
88
|
+
values() {
|
|
89
|
+
return this.#realMap.values()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { assert } from '../utils.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {import('hypercore')<'binary', any>} core Core to unreplicate. Must be ready.
|
|
5
|
+
* @param {import('protomux')} protomux
|
|
6
|
+
*/
|
|
7
|
+
export function unreplicate(core, protomux) {
|
|
8
|
+
assert(core.discoveryKey, 'Core should have a discovery key')
|
|
9
|
+
protomux.unpair({
|
|
10
|
+
protocol: 'hypercore/alpha',
|
|
11
|
+
id: core.discoveryKey,
|
|
12
|
+
})
|
|
13
|
+
for (const channel of protomux) {
|
|
14
|
+
if (channel.protocol !== 'hypercore/alpha') continue
|
|
15
|
+
if (!channel.id.equals(core.discoveryKey)) continue
|
|
16
|
+
channel.close()
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** @import { Duplex as NodeDuplex } from 'node:stream' */
|
|
2
|
+
/** @import { Duplex as StreamxDuplex } from 'streamx' */
|
|
3
|
+
/** @import NoiseSecretStream from '@hyperswarm/secret-stream' */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @internal
|
|
7
|
+
* @typedef {NodeDuplex | StreamxDuplex} RawStream
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @template {RawStream} [T=RawStream]
|
|
12
|
+
* @typedef {NoiseSecretStream<T> & { destroyed: true }} DestroyedNoiseStream
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @template {RawStream} [T=RawStream]
|
|
17
|
+
* @typedef {NoiseSecretStream<T> & {
|
|
18
|
+
* publicKey: Buffer,
|
|
19
|
+
* remotePublicKey: Buffer,
|
|
20
|
+
* handshake: Buffer,
|
|
21
|
+
* destroyed: false
|
|
22
|
+
* }} OpenedNoiseStream
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Utility to await a NoiseSecretStream to open, that returns a stream with the
|
|
27
|
+
* correct types for publicKey and remotePublicKey (which can be null before
|
|
28
|
+
* stream is opened)
|
|
29
|
+
*
|
|
30
|
+
* @template {RawStream} T
|
|
31
|
+
* @param {NoiseSecretStream<T>} stream
|
|
32
|
+
* @returns {Promise<OpenedNoiseStream<T> | DestroyedNoiseStream<T>>}
|
|
33
|
+
*/
|
|
34
|
+
export async function openedNoiseSecretStream(stream) {
|
|
35
|
+
await stream.opened
|
|
36
|
+
return /** @type {OpenedNoiseStream<T> | DestroyedNoiseStream<T>} */ (stream)
|
|
37
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ponyfill of `AbortSignal.any()`.
|
|
3
|
+
*
|
|
4
|
+
* [0]: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static
|
|
5
|
+
*
|
|
6
|
+
* @param {Iterable<AbortSignal>} iterable
|
|
7
|
+
* @returns {AbortSignal}
|
|
8
|
+
*/
|
|
9
|
+
export function abortSignalAny(iterable) {
|
|
10
|
+
for (const signal of iterable) {
|
|
11
|
+
if (signal.aborted) return AbortSignal.abort(signal.reason)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** @type {Array<() => unknown>} */
|
|
15
|
+
const listeners = []
|
|
16
|
+
const controller = new AbortController()
|
|
17
|
+
|
|
18
|
+
for (const signal of iterable) {
|
|
19
|
+
const listener = () => controller.abort(signal.reason)
|
|
20
|
+
signal.addEventListener('abort', listener)
|
|
21
|
+
listeners.push(listener)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return controller.signal
|
|
25
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string | NodeJS.ArrayBufferView} value
|
|
5
|
+
* @returns {NodeJS.ArrayBufferView}
|
|
6
|
+
*/
|
|
7
|
+
const bufferify = (value) =>
|
|
8
|
+
// We use UTF-16 because it's the only supported encoding that doesn't
|
|
9
|
+
// touch surrogate pairs. See [this post][0] for more details.
|
|
10
|
+
//
|
|
11
|
+
// [0]: https://evanhahn.com/crypto-timingsafeequal-with-strings/
|
|
12
|
+
typeof value === 'string' ? Buffer.from(value, 'utf16le') : value
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compare two values in constant time.
|
|
16
|
+
*
|
|
17
|
+
* Useful when you want to avoid leaking data.
|
|
18
|
+
*
|
|
19
|
+
* Like `crypto.timingSafeEqual`, but works with strings and doesn't throw if
|
|
20
|
+
* lengths differ.
|
|
21
|
+
*
|
|
22
|
+
* @template {string | NodeJS.ArrayBufferView} T
|
|
23
|
+
* @param {T} a
|
|
24
|
+
* @param {T} b
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export default function timingSafeEqual(a, b) {
|
|
28
|
+
const bufferA = bufferify(a)
|
|
29
|
+
const bufferB = bufferify(b)
|
|
30
|
+
return (
|
|
31
|
+
bufferA.byteLength === bufferB.byteLength &&
|
|
32
|
+
crypto.timingSafeEqual(bufferA, bufferB)
|
|
33
|
+
)
|
|
34
|
+
}
|