@comapeo/core 2.3.2 → 3.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/src/invite-api.js DELETED
@@ -1,450 +0,0 @@
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 'string-timing-safe-equal'
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
- }