diff --git a/lib/index.js b/lib/index.js index 197bad1..5a169d4 100644 --- a/lib/index.js +++ b/lib/index.js @@ -9,21 +9,49 @@ const p = require('promisify-tuple') /** * @typedef {{ - * type: 'follow' | 'join', - * hubs: number, - * id?: string, - * _hubMsAddr?: string, - * }} CreateOpts + * 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', + * hubPubkey: string, + * targetPubkey: string, + * }} TunnelConnectCommand + * + * @typedef {`tunnel-connect/${string}/${string}`} TunnelConnectCommandStr * - * @typedef {{type: 'join', address: string}} JoinCommand - * @typedef {{type: 'follow', id: string}} FollowCommand * @typedef {{ * type: 'promise.follow', - * issuer: string, - * issuerType: 'pubkey' | 'identity', - * token: string + * issuerID: string, + * token: string, * }} PromiseFollowCommand - * @typedef {JoinCommand | FollowCommand | PromiseFollowCommand} Command + * + * @typedef {`promise.follow/identity.${string}/${string}`} PromiseFollowCommandStr + * + * @typedef {{ + * type: 'promise.identity-add', + * issuerID: string, + * token: string, + * }} PromiseIdentityAddCommand + * + * @typedef {`promise.identity-add/identity.${string}/${string}`} PromiseIdentityAddCommandStr + * + * @typedef {| JoinCommand + * | FollowCommand + * | TunnelConnectCommand + * | PromiseFollowCommand + * | PromiseIdentityAddCommand + * } Command */ /** @@ -42,7 +70,7 @@ function parseJoinCommand(pieces, uri) { // prettier-ignore throw new Error(`Invalid URI "${uri}" for invite.parse, missing join hub port`) } - // TODO: base58 validation for the pubkey + // TODO: base58 validation for the pubkey (and maybe length) if (!pubkey) { // prettier-ignore throw new Error(`Invalid URI "${uri}" for invite.parse, missing join hub pubkey`) @@ -78,6 +106,29 @@ function parseFollowCommand(pieces, uri) { return { type: 'follow', id } } +/** + * @param {Array} pieces + * @param {string} uri + * @returns {TunnelConnectCommand} + */ +function parseTunnelConnectCommand(pieces, uri) { + const [, hubPubkey, targetPubkey] = pieces + // TODO: base58 validation for the hubPubkey (and maybe length) + if (!hubPubkey) { + // prettier-ignore + throw new Error(`Invalid URI "${uri}" for invite.parse, missing tunnel connect hubPubkey`) + } + // TODO: base58 validation for the targetPubkey (and maybe length) + if (!targetPubkey) { + // prettier-ignore + throw new Error(`Invalid URI "${uri}" for invite.parse, missing tunnel connect targetPubkey`) + } + pieces.shift() + pieces.shift() + pieces.shift() + return { type: 'tunnel-connect', hubPubkey, targetPubkey } +} + /** * @param {Array} pieces * @param {string} uri @@ -96,12 +147,38 @@ function parsePromiseFollowCommand(pieces, uri) { pieces.shift() pieces.shift() pieces.shift() - const [issuerType, issuer] = issuerAndType.split('.') - if (issuerType !== 'pubkey' && issuerType !== 'identity') { + const [issuerType, issuerID] = issuerAndType.split('.') + if (issuerType !== 'identity') { // prettier-ignore throw new Error(`Invalid URI "${uri}" for invite.parse, invalid promise.follow issuer type "${issuerType}"`) } - return { type: 'promise.follow', issuer, issuerType, token } + return { type: 'promise.follow', issuerID, token } +} + +/** + * @param {Array} pieces + * @param {string} uri + * @returns {PromiseIdentityAddCommand} + */ +function parsePromiseIdentityAddCommand(pieces, uri) { + const [, issuerAndType, token] = pieces + if (!issuerAndType) { + // prettier-ignore + throw new Error(`Invalid URI "${uri}" for invite.parse, missing promise.identity-add issuer`) + } + if (!token) { + // prettier-ignore + throw new Error(`Invalid URI "${uri}" for invite.parse, missing promise.identity-add token`) + } + pieces.shift() + pieces.shift() + pieces.shift() + const [issuerType, issuerID] = issuerAndType.split('.') + if (issuerType !== 'identity') { + // prettier-ignore + throw new Error(`Invalid URI "${uri}" for invite.parse, invalid promise.identity-add issuer type "${issuerType}"`) + } + return { type: 'promise.identity-add', issuerID, token } } /** @@ -126,20 +203,24 @@ function parse(uri) { const commands = [] while (pieces.length > 0) { - switch (pieces[0]) { + switch (/** @type {Command['type']} */ (pieces[0])) { case 'join': commands.push(parseJoinCommand(pieces, uri)) break case 'follow': commands.push(parseFollowCommand(pieces, uri)) break + case 'tunnel-connect': + commands.push(parseTunnelConnectCommand(pieces, uri)) + break case 'promise.follow': commands.push(parsePromiseFollowCommand(pieces, uri)) break - default: - console.log('Unknown command', pieces[0]) - pieces.shift() + case 'promise.identity-add': + commands.push(parsePromiseIdentityAddCommand(pieces, uri)) break + default: + throw new Error(`Unknown command: "${pieces[0]}"`) } } @@ -149,61 +230,62 @@ function parse(uri) { module.exports = { name: 'invite', manifest: { - create: 'async', + createForFriend: 'async', + createForMyself: 'async', parse: 'sync', }, parse, /** - * @param {any} sstack + * @param {any} local * @param {any} config */ - init(sstack, config) { - if (!sstack.promise?.create) { + init(local, config) { + if (!local.promise?.create) { throw new Error('ppppp-invite plugin requires ppppp-promise plugin') } - if (!sstack.conn?.connect) { + if (!local.conn?.connect) { throw new Error('ppppp-invite plugin requires ssb-conn plugin') } /** - * @param {CreateOpts} opts + * @param {{ + * hubs?: number, + * id: string, + * _hubMsAddr?: string, + * }} opts + * * @param {CB<{uri: string, url: string}>} cb */ - async function create(opts, cb) { + async function createForFriend(opts, cb) { if (typeof opts !== 'object') { - return cb(new Error('invite.create is missing opts argument')) + return cb(new Error('invite.createForFriend is missing opts argument')) } - const type = opts.type ?? 'follow' - if (type !== 'follow' && type !== 'join') { + if (!opts.id) { // prettier-ignore - return cb(new Error(`invite.create opts.type should be "follow" or "join" but was "${type}"`)) - } - if (type === 'follow' && !opts.id) { - // prettier-ignore - return cb(new Error(`invite.create opts.id is required for type "follow"`)) + return cb(new Error(`invite.createForFriend opts.id is required for type "follow"`)) } const hubs = opts.hubs ?? 1 if (typeof hubs !== 'number') { // prettier-ignore - return cb(new Error(`invite.create opts.hubs should be a number but was ${hubs}`)) + return cb(new Error(`invite.createForFriend opts.hubs should be a number but was ${hubs}`)) } if (!opts._hubMsAddr) { // prettier-ignore - return cb(new Error(`invite.create expected opts._hubMsAddr because loading from connDB not yet supported`)) + return cb(new Error(`invite.createForFriend expected opts._hubMsAddr because loading from connDB not yet supported`)) } // Connect to hub and create token - const [err, rpc] = await p(sstack.conn.connect)(opts._hubMsAddr); + const [err, rpc] = await p(local.conn.connect)(opts._hubMsAddr) if (err) return cb(err) const [err2, hubToken] = await p(rpc.hub.createToken)() if (err2) return cb(err2) // Parse multiserver address // prettier-ignore - const ERROR_MSG = `Invalid multiserver address ${opts._hubMsAddr} for invite.create` + const ERROR_MSG = `Invalid multiserver address ${opts._hubMsAddr} for invite.createForFriend` const msAddr = MultiserverAddress.decode(opts._hubMsAddr) const [netShsAddr, wsShsAddr] = msAddr if (!netShsAddr) return cb(new Error(ERROR_MSG)) @@ -216,17 +298,85 @@ module.exports = { const [pubkey] = shse.data // Create follow promise - const [err3, token] = await p(sstack.promise.create)({type: 'follow'}) + const [err3, token] = await p(local.promise.create)({ type: 'follow' }) if (err3) return cb(err3) + /** @type {JoinCommandStr} */ const joinCommand = `join/${host}/${port}/${pubkey}/${hubToken}` + /** @type {FollowCommandStr} */ const followCommand = `follow/${opts.id}` + /** @type {PromiseFollowCommandStr} */ const promiseCommand = `promise.follow/identity.${opts.id}/${token}` + const uri = `ppppp://invite/${joinCommand}/${followCommand}/${promiseCommand}` const url = `http://${host}/invite#${encodeURIComponent(uri)}` - cb(null, {uri, url}) + cb(null, { uri, url }) } - return { create, parse } + /** + * @param {{ + * hubs?: number, + * id: string, + * _hubMsAddr?: string, + * }} opts + * + * @param {CB<{uri: string, url: string}>} cb + */ + async function createForMyself(opts, cb) { + if (typeof opts !== 'object') { + return cb(new Error('invite.createForMyself is missing opts argument')) + } + if (!opts.id) { + // prettier-ignore + return cb(new Error(`invite.createForMyself opts.id is required for type "follow"`)) + } + const hubs = opts.hubs ?? 1 + if (typeof hubs !== 'number') { + // prettier-ignore + return cb(new Error(`invite.createForMyself opts.hubs should be a number but was ${hubs}`)) + } + + if (!opts._hubMsAddr) { + // prettier-ignore + return cb(new Error(`invite.createForMyself expected opts._hubMsAddr because loading from connDB not yet supported`)) + } + + // Connect to hub and create token + const [err, rpc] = await p(local.conn.connect)(opts._hubMsAddr) + if (err) return cb(err) + const [err2, hubToken] = await p(rpc.hub.createToken)() + if (err2) return cb(err2) + + // Parse multiserver address + // prettier-ignore + const ERROR_MSG = `Invalid multiserver address ${opts._hubMsAddr} for invite.createForMyself` + const msAddr = MultiserverAddress.decode(opts._hubMsAddr) + const [netShsAddr, wsShsAddr] = msAddr + if (!netShsAddr) return cb(new Error(ERROR_MSG)) + const [net, shse] = netShsAddr + if (!net) return cb(new Error(ERROR_MSG)) + if (net.name !== 'net') return cb(new Error(ERROR_MSG)) + const [host, port] = net.data + if (!shse) return cb(new Error(ERROR_MSG)) + if (shse.name !== 'shse') return cb(new Error(ERROR_MSG)) + const [pubkey] = shse.data + + // Create identity-add promise + const promise = { type: 'identity-add', identity: opts.id } + const [err3, token] = await p(local.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}` + /** @type {PromiseIdentityAddCommandStr} */ + const promiseCommand = `promise.identity-add/identity.${opts.id}/${token}` + const uri = `ppppp://invite/${joinCommand}/${tunnelCommand}/${promiseCommand}` + const url = `http://${host}/invite#${encodeURIComponent(uri)}` + cb(null, { uri, url }) + } + + return { createForFriend, createForMyself, parse } }, } diff --git a/package.json b/package.json index f8b6773..daa3eb8 100644 --- a/package.json +++ b/package.json @@ -32,12 +32,12 @@ "husky": "^4.3.0", "ppppp-caps": "github:staltz/ppppp-caps", "ppppp-keypair": "github:staltz/ppppp-keypair", - "ppppp-promise": "file:../promise", + "ppppp-promise": "github:staltz/ppppp-promise", "prettier": "^2.6.2", "pretty-quick": "^3.1.3", "rimraf": "^5.0.1", - "secret-handshake-ext": "~0.0.6", - "secret-stack": "^6.4.2", + "secret-handshake-ext": "0.0.8", + "secret-stack": "ssbc/secret-stack#bare-mode", "typescript": "^5.0.2" }, "scripts": { diff --git a/test/create.test.js b/test/createForFriend.test.js similarity index 85% rename from test/create.test.js rename to test/createForFriend.test.js index 35c2069..22e3f77 100644 --- a/test/create.test.js +++ b/test/createForFriend.test.js @@ -7,8 +7,8 @@ const rimraf = require('rimraf') const Keypair = require('ppppp-keypair') const caps = require('ppppp-caps') -test('create()', async (t) => { - const path = Path.join(os.tmpdir(), 'ppppp-promise-create-0') +test('createForFriend()', async (t) => { + const path = Path.join(os.tmpdir(), 'ppppp-promise-createForFriend-0') rimraf.sync(path) const keypair = Keypair.generate('ed25519', 'alice') @@ -56,16 +56,14 @@ test('create()', async (t) => { }, } - const stack = require('secret-stack/lib/api')([], {}) - .use(require('secret-stack/lib/core')) - .use(require('secret-stack/lib/plugins/net')) + const local = require('secret-stack/bare')({caps}) + .use(require('secret-stack/plugins/net')) .use(require('secret-handshake-ext/secret-stack')) .use(mockConn) .use(mockPromise) .use(require('../lib')) .call(null, { path, - caps, keypair, connections: { outgoing: { @@ -74,8 +72,7 @@ test('create()', async (t) => { }, }) - const { uri, url } = await p(stack.invite.create)({ - type: 'follow', + const { uri, url } = await p(local.invite.createForFriend)({ _hubMsAddr: 'net:example.com:8008~shse:HUB_PUBKEY', id: 'MOCK_ID', }) @@ -91,5 +88,5 @@ test('create()', async (t) => { assert.ok(connectCalled) assert.ok(createTokenCalled) assert.ok(createPromiseCalled) - await p(stack.close)() + await p(local.close)() }) diff --git a/test/createForMyself.test.js b/test/createForMyself.test.js new file mode 100644 index 0000000..1df6a45 --- /dev/null +++ b/test/createForMyself.test.js @@ -0,0 +1,92 @@ +const test = require('node:test') +const assert = require('node:assert') +const Path = require('node:path') +const os = require('node:os') +const p = require('node:util').promisify +const rimraf = require('rimraf') +const Keypair = require('ppppp-keypair') +const caps = require('ppppp-caps') + +test('createForMyself()', async (t) => { + const path = Path.join(os.tmpdir(), 'ppppp-promise-createForMyself-0') + rimraf.sync(path) + const keypair = Keypair.generate('ed25519', 'alice') + + let connectCalled = false + let createTokenCalled = false + let createPromiseCalled = false + + const mockConn = { + name: 'conn', + manifest: { + connect: 'async', + }, + init() { + return { + connect(address, cb) { + connectCalled = true + assert.equal(address, 'net:example.com:8008~shse:HUB_PUBKEY') + const mockRpc = { + hub: { + createToken(cb) { + createTokenCalled = true + cb(null, 'MOCK_TOKEN') + }, + }, + } + cb(null, mockRpc) + }, + } + }, + } + + const mockPromise = { + name: 'promise', + manifest: { + create: 'async', + }, + init() { + return { + create(opts, cb) { + createPromiseCalled = true + assert.deepEqual(opts, { type: 'identity-add', identity: 'MOCK_ID' }) + cb(null, 'MOCK_PROMISE') + }, + } + }, + } + + const local = require('secret-stack/bare')({ caps }) + .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' }], + }, + }, + }) + + const { uri, url } = await p(local.invite.createForMyself)({ + _hubMsAddr: 'net:example.com:8008~shse:HUB_PUBKEY', + id: 'MOCK_ID', + }) + assert.equal( + uri, + `ppppp://invite/join/example.com/8008/HUB_PUBKEY/MOCK_TOKEN/tunnel-connect/HUB_PUBKEY/${local.shse.pubkey}/promise.identity-add/identity.MOCK_ID/MOCK_PROMISE` + ) + assert.equal( + url, + `http://example.com/invite#ppppp%3A%2F%2Finvite%2Fjoin%2Fexample.com%2F8008%2FHUB_PUBKEY%2FMOCK_TOKEN%2Ftunnel-connect%2FHUB_PUBKEY%2F${local.shse.pubkey}%2Fpromise.identity-add%2Fidentity.MOCK_ID%2FMOCK_PROMISE` + ) + + assert.ok(connectCalled) + assert.ok(createTokenCalled) + assert.ok(createPromiseCalled) + await p(local.close)() +}) diff --git a/test/parse.test.js b/test/parse.test.js index f213911..f1587f7 100644 --- a/test/parse.test.js +++ b/test/parse.test.js @@ -29,8 +29,7 @@ test('parse() good cases', (t) => { }, { type: 'promise.follow', - issuerType: 'identity', - issuer: 'ALICE', + issuerID: 'ALICE', token: 'ALICE_TOKEN', }, ])