diff --git a/lib/index.js b/lib/index.js index 8876009..bbda417 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3,48 +3,36 @@ const MultiserverAddress = require('multiserver-address') const p = require('promisify-tuple') /** - * @template T - * @typedef {(...args: [NodeJS.ErrnoException] | [null, T]) => void} CB - */ - -/** + * @typedef {{ pubkey: string }} SHSE + * @typedef {ReturnType} PPPPPPromise + * @typedef {{connect: (addr: string, cb: CB) => void}} ConnPlugin * @typedef {{ * type: 'join', * address: string, * }} JoinCommand - * * @typedef {`join/${string}/${string}/${string}/${string}`} JoinCommandStr - * * @typedef {{ * type: 'follow', * id: string, * }} FollowCommand - * * @typedef {`follow/${string}`} FollowCommandStr - * * @typedef {{ * type: 'tunnel-connect', * address: string, * }} TunnelConnectCommand - * * @typedef {`tunnel-connect/${string}/${string}`} TunnelConnectCommandStr - * * @typedef {{ * type: 'promise.follow', * issuerID: string, * token: string, * }} PromiseFollowCommand - * * @typedef {`promise.follow/account.${string}/${string}`} PromiseFollowCommandStr - * * @typedef {{ * type: 'promise.account-add', * issuerID: string, * token: string, * }} PromiseAccountAddCommand - * * @typedef {`promise.account-add/account.${string}/${string}`} PromiseAccountAddCommandStr - * * @typedef {| JoinCommand * | FollowCommand * | TunnelConnectCommand @@ -53,6 +41,11 @@ const p = require('promisify-tuple') * } Command */ +/** + * @template T + * @typedef {(...args: [NodeJS.ErrnoException] | [null, T]) => void} CB + */ + /** * @param {Array} pieces * @param {string} uri @@ -227,6 +220,31 @@ function parse(uri) { return commands } +/** + * @param {{ shse: SHSE | null }} peer + * @returns {asserts peer is { shse: SHSE }} + */ +function assertSHSEExists(peer) { + if (!peer.shse) throw new Error('"invite" plugin requires "shse" plugin') +} + +/** + * @param {{ promise: PPPPPPromise | null }} peer + * @returns {asserts peer is { promise: PPPPPPromise }} + */ +function assertPromisePlugin(peer) { + // prettier-ignore + if (!peer.promise) throw new Error('"invite" plugin requires "promise" plugin') +} + +/** + * @param {{ conn: ConnPlugin | null }} peer + * @returns {asserts peer is { conn: ConnPlugin }} + */ +function assertConnPlugin(peer) { + if (!peer.conn) throw new Error('"invite" plugin requires "conn" plugin') +} + module.exports = { name: 'invite', manifest: { @@ -238,16 +256,17 @@ module.exports = { parse, /** - * @param {any} local - * @param {any} config + * @param {{ + * shse: SHSE | null; + * promise: PPPPPPromise | null; + * conn: ConnPlugin | null; + * }} peer + * @param {unknown} config */ - init(local, config) { - if (!local.promise?.create) { - throw new Error('ppppp-invite plugin requires ppppp-promise plugin') - } - if (!local.conn?.connect) { - throw new Error('ppppp-invite plugin requires ssb-conn plugin') - } + init(peer, config) { + assertSHSEExists(peer) + assertPromisePlugin(peer) + assertConnPlugin(peer) /** * @param {{ @@ -259,6 +278,12 @@ module.exports = { * @param {CB<{uri: string, url: string}>} cb */ async function createForFriend(opts, cb) { + try { + assertConnPlugin(peer) + assertPromisePlugin(peer) + } catch (err) { + return cb(/**@type {Error}*/ (err)) + } if (typeof opts !== 'object') { return cb(new Error('invite.createForFriend is missing opts argument')) } @@ -278,7 +303,7 @@ module.exports = { } // Connect to hub and create token - const [err, rpc] = await p(local.conn.connect)(opts._hubMsAddr) + const [err, rpc] = await p(peer.conn.connect)(opts._hubMsAddr) if (err) return cb(err) const [err2, hubToken] = await p(rpc.hub.createToken)() if (err2) return cb(err2) @@ -298,7 +323,7 @@ module.exports = { const [pubkey] = shse.data // Create follow promise - const [err3, token] = await p(local.promise.create)({ type: 'follow' }) + const [err3, token] = await p(peer.promise.create)({ type: 'follow' }) if (err3) return cb(err3) /** @type {JoinCommandStr} */ @@ -323,6 +348,13 @@ module.exports = { * @param {CB<{uri: string, url: string}>} cb */ async function createForMyself(opts, cb) { + try { + assertSHSEExists(peer) + assertConnPlugin(peer) + assertPromisePlugin(peer) + } catch (err) { + return cb(/**@type {Error}*/ (err)) + } if (typeof opts !== 'object') { return cb(new Error('invite.createForMyself is missing opts argument')) } @@ -342,7 +374,7 @@ module.exports = { } // Connect to hub and create token - const [err, rpc] = await p(local.conn.connect)(opts._hubMsAddr) + const [err, rpc] = await p(peer.conn.connect)(opts._hubMsAddr) if (err) return cb(err) const [err2, hubToken] = await p(rpc.hub.createToken)() if (err2) return cb(err2) @@ -363,13 +395,13 @@ module.exports = { // Create account-add promise const promise = { type: 'account-add', account: opts.id } - const [err3, token] = await p(local.promise.create)(promise) + const [err3, token] = await p(peer.promise.create)(promise) if (err3) return cb(err3) /** @type {JoinCommandStr} */ const joinCommand = `join/${host}/${port}/${pubkey}/${hubToken}` /** @type {TunnelConnectCommandStr} */ - const tunnelCommand = `tunnel-connect/${pubkey}/${local.shse.pubkey}` + const tunnelCommand = `tunnel-connect/${pubkey}/${peer.shse.pubkey}` /** @type {PromiseAccountAddCommandStr} */ const promiseCommand = `promise.account-add/account.${opts.id}/${token}` const uri = `ppppp://invite/${joinCommand}/${tunnelCommand}/${promiseCommand}` diff --git a/package.json b/package.json index aa3f101..dccb37d 100644 --- a/package.json +++ b/package.json @@ -14,14 +14,14 @@ "files": [ "lib/**/*" ], - "engines": { - "node": ">=16" - }, "exports": { ".": { "require": "./lib/index.js" } }, + "engines": { + "node": ">=16" + }, "dependencies": { "multiserver-address": "~1.0.1", "promisify-tuple": "1.2.0" @@ -36,9 +36,9 @@ "prettier": "^2.6.2", "pretty-quick": "^3.1.3", "rimraf": "^5.0.1", - "secret-handshake-ext": "0.0.8", - "secret-stack": "~7.1.0", - "typescript": "^5.0.2" + "secret-handshake-ext": "0.0.11", + "secret-stack": "~8.0.0", + "typescript": "^5.1.3" }, "scripts": { "clean-check": "tsc --build --clean", diff --git a/protospec.md b/protospec.md new file mode 100644 index 0000000..b6a5684 --- /dev/null +++ b/protospec.md @@ -0,0 +1,118 @@ +`ppppp://invite` URIs are followed by any number of "commands", where each command has a name plus a fixed-length list of arguments. + +## Inviting a new user to the network + +**Invite URL:** + +``` +ppppp://invite/join/HOST/PORT/PUBKEY/TOKEN/follow/ALICE_ID/promise.follow/account.ALICE_ID/ALICE_TOKEN +``` + +made of 3 "commands": + +- `join/HOST/PORT/PUBKEY/TOKEN` + - Meaning "join" this hub at this address, claiming this token to become a member +- `follow/ALICE_ID` + - Meaning that you should follow Alice +- `promise.follow/account.ALICE_ID/ALICE_TOKEN` + - Meaning that Alice (ALICE_ID the `account`, not any single `pubkey`) promised to follow you back if you claim ALICE_TOKEN + +```mermaid +sequenceDiagram + +participant A as Alice +participant H as Hub +participant B as Bob + +note over A: creates aliceToken
for follow promise +A->>A: publishes self-encrypted
msg about aliceToken +A->>H: ask for hub token +activate H +H->>H: create hubToken +H-->>A: hubToken +deactivate H +A->>B: Externally: send invite URL +B->>H: HTTP: open URL +activate H +H-->>B: HTML with PPPPP invite URI +deactivate H +B->>B: open PPPPP app +note over B: parse URI and detect 3 commands +note over B: execute command "join" +B->>H: connect with hubToken in SHSe +activate H +H->>H: add Bob as member +H-->>B: OK +deactivate H +note over B: execute command "follow" +B->>B: follow aliceID +B->>H: muxrpc: connect to anyone online
and try to replicate aliceID +H-->>B: OK +note over B: execute command "promise.follow" +alt If some pubkey of aliceID is online + B->>A: connect with SHS + activate A + B->>A: muxrpc: promise.follow(aliceToken, bobID) + A->>A: detect aliceToken,
apply followback on bobID,
delete aliceToken + A-->>B: OK + deactivate A +end +``` + +## Inviting a new device to my account + +**Invite URL:** + +``` +ppppp://invite/join/HOST/PORT/PUBKEY/TOKEN/tunnel-connect/HUB_PUBKEY/OLD_PUBKEY/promise.account-add/peer.PUBKEY/OLD_TOKEN/promise.account-internal-encryption-key/peer.PUBKEY/OLD_TOKEN +``` + +made of 3 "commands": + +- `join/HOST/PORT/PUBKEY/TOKEN` + - Meaning "join" this hub at this address, claiming this token +- `tunnel-connect/HUB_PUBKEY/OLD_PUBKEY` + - Meaning that you should connect to the old device via a tunnel in the hub +- `promise.account-add/peer.PUBKEY/OLD_TOKEN` TODO implement with peer.PUBKEY + - Meaning that the old device promised to add your pubkey if you claim OLD_TOKEN +- `promise.account-internal-encryption-key/peer.PUBKEY/OLD_TOKEN` TODO implement + - Meaning that the old device promised to send you the internal encryption key + +```mermaid +sequenceDiagram + +participant O as Old device +participant H as Hub +participant N as New device + +note over N: instruct user to create
an invite on the old +note over O: creates oToken
with account-add perm +O->>H: ask for hub token +activate H +H->>H: create hubToken +H-->>O: hubToken +deactivate H +O->>N: Externally: send invite URL or URI +N->>N: input URL or URI +note over N: parse URI and detect 3 commands +note over N: execute command "join" +N->>H: connect with hubToken in SHSe +activate H +H->>H: add New as member +H-->>N: OK +deactivate H +note over N: execute command "tunnel-connect" +alt If old pubkey is online + N->>O: connect with SHS + activate O + note over N: execute command "promise.account-add" + N->>N: consent = sign(":account-add:ACCOUNT_ID", new privkey) + N->>O: muxrpc: promise.accountAdd(oToken, new pubkey, consent) + O->>O: detect oToken,
apply account-add on New,
delete oToken + O-->>N: OK + deactivate O +else If Old is offline + N->>O: connect with SHS + O-->>N: Failure +end +``` \ No newline at end of file diff --git a/test/createForFriend.test.js b/test/createForFriend.test.js index dc54641..bc60f4e 100644 --- a/test/createForFriend.test.js +++ b/test/createForFriend.test.js @@ -56,18 +56,23 @@ test('createForFriend()', async (t) => { }, } - const local = require('secret-stack/bare')({ caps }) + const local = require('secret-stack/bare')() .use(require('secret-stack/plugins/net')) .use(require('secret-handshake-ext/secret-stack')) .use(mockConn) .use(mockPromise) .use(require('../lib')) .call(null, { - path, - keypair, - connections: { - outgoing: { - net: [{ transform: 'shse' }], + shse: { + caps, + }, + global: { + path, + keypair, + connections: { + outgoing: { + net: [{ transform: 'shse' }], + }, }, }, }) diff --git a/test/createForMyself.test.js b/test/createForMyself.test.js index 658ba33..f1df8d4 100644 --- a/test/createForMyself.test.js +++ b/test/createForMyself.test.js @@ -56,18 +56,21 @@ test('createForMyself()', async (t) => { }, } - const local = require('secret-stack/bare')({ caps }) + const local = require('secret-stack/bare')() .use(require('secret-stack/plugins/net')) .use(require('secret-handshake-ext/secret-stack')) .use(mockConn) .use(mockPromise) .use(require('../lib')) .call(null, { - path, - keypair, - connections: { - outgoing: { - net: [{ transform: 'shse' }], + shse: { caps }, + global: { + path, + keypair, + connections: { + outgoing: { + net: [{ transform: 'shse' }], + }, }, }, }) diff --git a/tsconfig.json b/tsconfig.json index f6eb639..cc6e7af 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,13 +3,14 @@ "exclude": ["coverage/", "node_modules/", "test/"], "compilerOptions": { "checkJs": true, - "noEmit": true, + "declaration": true, + "emitDeclarationOnly": true, "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, - "lib": ["es2021", "dom"], + "lib": ["es2022", "dom"], "module": "node16", "skipLibCheck": true, "strict": true, - "target": "es2021" + "target": "es2022" } } \ No newline at end of file