-
Notifications
You must be signed in to change notification settings - Fork 3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
initial invite api #217
initial invite api #217
Changes from 2 commits
811591f
88d06c9
9011e1a
fad5bf7
9a82b7f
057d8d6
754ea9c
09ec579
1d30037
284cffe
fafbef5
a7f6e85
fc3ef15
7f849ee
d9b147e
69a560a
08a5eaf
8003252
efe181a
b2ff8d4
5c3f473
09738ca
8168ebb
146606f
54b9585
cb1a2e7
2c64ed2
bccebdb
9407be3
693af73
370b4be
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
import { TypedEmitter } from 'tiny-typed-emitter' | ||
import { InviteResponse_Decision } from './generated/rpc.js' | ||
import { idToKey, keyToId } from './utils.js' | ||
|
||
/** @typedef {import('./rpc/index.js').MapeoRPC} MapeoRPC */ | ||
|
||
export class InviteApi extends TypedEmitter { | ||
// TODO: are invites persisted beyond this api? | ||
#invites = new Map() | ||
|
||
/** | ||
* @param {Object} options | ||
* @param {MapeoRPC} options.rpc | ||
*/ | ||
constructor({ rpc }) { | ||
super() | ||
this.rpc = rpc | ||
|
||
// TODO: I'm not seeing encryption keys used in the inviteResponse in the rpc api | ||
// what is the purpose of the encryption keys at this stage of the process or afterward? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't track the encryptionKeys property of the invite yet as there wasn't a way to use it yet. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The encryptionKeys and projectKey will be needed in the The keys will need to be stored in the |
||
this.rpc.on('invite', (peerId, invite) => { | ||
const projectId = keyToId(invite.projectKey) | ||
const peerIds = this.#invites.get(projectId) || new Set() | ||
peerIds.add(peerId) | ||
this.#invites.set(projectId, peerIds) | ||
|
||
if (peerIds.size === 1) { | ||
this.emit('invite-received', { | ||
projectId, | ||
peerId, | ||
}) | ||
} | ||
}) | ||
} | ||
|
||
/** | ||
* @param {string} projectId | ||
*/ | ||
accept(projectId) { | ||
this.#respond({ projectId, decision: InviteResponse_Decision.ACCEPT }) | ||
} | ||
|
||
/** | ||
* @param {string} projectId | ||
*/ | ||
reject(projectId) { | ||
this.#respond({ projectId, decision: InviteResponse_Decision.REJECT }) | ||
} | ||
|
||
/** | ||
* @param {Object} options | ||
* @param {string} options.projectId | ||
* @param {InviteResponse_Decision} options.decision | ||
*/ | ||
#respond({ projectId, decision }) { | ||
const peerIds = this.#getPeerIds(projectId) | ||
const projectKey = idToKey(projectId) | ||
|
||
// TODO: should this reply to one peer with `ACCEPT` and the rest with `ALREADY`? | ||
// How is the `ALREADY` decision determined? It looks like that isn't used yet. | ||
// Does anything bad happen if we respond to multiple peers with `ACCEPT`? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the answer is send There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah good question. The impact would be that each of the invitors receiving On reflection I think what would make sense would be to respond There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the case of peers disconnecting, we should send |
||
for (const peerId of peerIds.values()) { | ||
this.rpc.inviteResponse(peerId, { projectKey, decision }) | ||
} | ||
|
||
this.#invites.delete(projectId) | ||
} | ||
|
||
/** | ||
* @param {string} projectId | ||
* @returns {Set<string>} peerIds | ||
*/ | ||
#getPeerIds(projectId) { | ||
return this.#invites.get(projectId) || new Set() | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
import NoiseSecretStream from '@hyperswarm/secret-stream' | ||
|
||
export function replicate(rpc1, rpc2) { | ||
const n1 = new NoiseSecretStream(true, undefined, { | ||
// Keep keypairs deterministic for tests, since we use peer.publicKey as an identifier. | ||
keyPair: NoiseSecretStream.keyPair(Buffer.allocUnsafe(32).fill(0)), | ||
}) | ||
const n2 = new NoiseSecretStream(false, undefined, { | ||
keyPair: NoiseSecretStream.keyPair(Buffer.allocUnsafe(32).fill(1)), | ||
}) | ||
n1.rawStream.pipe(n2.rawStream).pipe(n1.rawStream) | ||
|
||
rpc1.connect(n1) | ||
rpc2.connect(n2) | ||
|
||
return async function destroy() { | ||
return Promise.all([ | ||
/** @type {Promise<void>} */ | ||
( | ||
new Promise((res) => { | ||
n1.on('close', res) | ||
n1.destroy() | ||
}) | ||
), | ||
/** @type {Promise<void>} */ | ||
( | ||
new Promise((res) => { | ||
n2.on('close', res) | ||
n2.destroy() | ||
}) | ||
), | ||
]) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
// @ts-check | ||
import test from 'brittle' | ||
import { MapeoRPC } from '../src/rpc/index.js' | ||
import { InviteApi } from '../src/invite-api.js' | ||
import { replicate } from './helpers/rpc.js' | ||
|
||
test('Accept invite', async (t) => { | ||
t.plan(3) | ||
const r1 = new MapeoRPC() | ||
const r2 = new MapeoRPC() | ||
const inviteApi = new InviteApi({ rpc: r2 }) | ||
|
||
const projectKey = Buffer.allocUnsafe(32).fill(0) | ||
|
||
r1.on('peers', async (peers) => { | ||
t.is(peers.length, 1) | ||
const response = await r1.invite(peers[0].id, { projectKey }) | ||
t.is(response, MapeoRPC.InviteResponse.ACCEPT) | ||
}) | ||
|
||
inviteApi.on('invite-received', ({ projectId }) => { | ||
t.is(projectId, projectKey.toString('hex')) | ||
inviteApi.accept(projectId) | ||
}) | ||
|
||
replicate(r1, r2) | ||
}) | ||
|
||
test('Reject invite', async (t) => { | ||
t.plan(3) | ||
const r1 = new MapeoRPC() | ||
const r2 = new MapeoRPC() | ||
const inviteApi = new InviteApi({ rpc: r2 }) | ||
|
||
const projectKey = Buffer.allocUnsafe(32).fill(0) | ||
|
||
r1.on('peers', async (peers) => { | ||
t.is(peers.length, 1) | ||
const response = await r1.invite(peers[0].id, { projectKey }) | ||
t.is(response, MapeoRPC.InviteResponse.REJECT) | ||
}) | ||
|
||
inviteApi.on('invite-received', ({ projectId }) => { | ||
t.is(projectId, projectKey.toString('hex')) | ||
inviteApi.reject(projectId) | ||
}) | ||
|
||
replicate(r1, r2) | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what happens when we've already accepted an invite a few days ago and got a new invite for the same project? there may need to be some persistence and lookup.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good question.
A previously accepted invite is the same issue as "already on this project". This is what the
ALREADY
invite response for. This means that the InviteApi needs to look up which projects the device is already a member of - @achou11 can you suggest the best API for this? We'll need to pass something down to the InviteApi constructor.If a device is already part of a project and they receive an invite, we should automatically respond with
ALREADY
- there is no need for an event since I don't think the UX on the invitee side should show anything. On the invitor side the UX should show a "already invited" UX when receiving an invite response ofALREADY
.We're not going to persist un-responded invites beyond the lifecycle of the app - they require a connection to the invitor peer to respond anyway.
What to do with previously rejected invites - let's not track rejected invites for now, I've created an issue to deal with this question post-MVP
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Initial thought is to provide a callback in the constructor to determine membership for a project id e.g.
alternatively can pass a
sharedDb
option like we do forMapeoProject
and let the InviteApi handle making the query itself. this approach may be useful if there are other project-related queries that may be neededdon't have a strong preference at the moment, but maybe leaning towards the latter since we kind of have a similar pattern for MapeoProject 🤷
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
with some additional pondering, thinking the sharedDb option is preferred for me, as it allows code specifically needed for the InviteApi to live with the implementation, as opposed to it leaking to the creator of the instance.
I'm sure my thoughts will bounce back and forth before I end up in this position again 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just as I anticipated, seeing #217 (comment) has me thinking about this again.
So to sum it up, the InviteApi needs a way to do these things:
Passing the
sharedDb
option would solve 1 well, but wouldn't (directly) solve 2 without adding duplicate code (I think). Duplicate code isn't necessarily bad though (and I don't think it would be copy-paste identical either...)If we wanted to solve both, passing specific constructor callback options would be the simplest I guess i.e.
maybe that's just the way to go, before I start thinking of some (probably) convoluted approach using events 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
currently using the callback-based approach in https://github.com/digidem/mapeo-core-next/pull/232/files#diff-a2f290f8510d711051a63f76d7d7b06721c1dd5b6ac7d86e8e3024795c2d829dR12, which I think i like right now.
Feels sensible that these API classes don't need to dive into the actual db and do their own queries
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
leaning into the callback-based approach, maybe all of these API classes can have a singular constructor opt called
queries
, which is responsible for being the interface between the class and dbs created outside the class.For example, the InviteApi would have something like:
and instantiation in the
MapeoProject
will look like:honestly this is mostly aesthetic as it's not functionally different than specifying an opt for each callback. gut feeling is that it'd be a little cleaner to work with this, especially if an API class requires many queries to be specified. i also like that when i revisit the code, i'll have a more immediate idea of what db-related operations the API class requires
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I also like the aesthetics of the
queries
object. Makes it more clear what those methods are for.