commit 29c8eb3d1f9ef584f7dffe1ebe9130e09adf00dd Author: Andre Staltz Date: Thu Jun 22 13:37:29 2023 +0300 init diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 0000000..7ea0e5f --- /dev/null +++ b/.github/workflows/node.js.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + strategy: + matrix: + node-version: [16.x, 18.x, 20.x] + os: [ubuntu-latest] + + runs-on: ${{ matrix.os }} + timeout-minutes: 2 + + steps: + - name: Checkout the repo + uses: actions/checkout@v2 + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44bcc03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.nyc_output +coverage +pnpm-lock.yaml \ No newline at end of file diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000..1d2127c --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,2 @@ +semi: false +singleQuote: true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9b34bc1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2023 Andre 'Staltz' Medeiros + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ceba577 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ppppp-invite + +**Work in progress** diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..a9668e3 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,231 @@ +// @ts-ignore +const MultiserverAddress = require('multiserver-address') +const p = require('promisify-tuple') + +/** + * @template T + * @typedef {(...args: [NodeJS.ErrnoException] | [null, T]) => void} CB + */ + +/** + * @typedef {{ + * type: 'follow' | 'join', + * hubs: number, + * id?: string, + * _hubMsAddr?: string, + * }} CreateOpts + * + * @typedef {{type: 'join', address: string}} JoinCommand + * @typedef {{type: 'follow', id: string}} FollowCommand + * @typedef {{ + * type: 'promise.follow', + * issuer: string, + * issuerType: 'pubkey' | 'identity', + * token: string + * }} PromiseFollowCommand + * @typedef {JoinCommand | FollowCommand | PromiseFollowCommand} Command + */ + +/** + * @param {Array} pieces + * @param {string} uri + * @returns {JoinCommand} + */ +function parseJoinCommand(pieces, uri) { + const [, host, port, pubkey, token] = pieces + if (!host) { + // prettier-ignore + throw new Error(`Invalid URI "${uri}" for invite.parse, missing join hub address`) + } + // TODO: numeric validation for the port + if (!port) { + // prettier-ignore + throw new Error(`Invalid URI "${uri}" for invite.parse, missing join hub port`) + } + // TODO: base58 validation for the pubkey + if (!pubkey) { + // prettier-ignore + throw new Error(`Invalid URI "${uri}" for invite.parse, missing join hub pubkey`) + } + // TODO: base58 validation for the token + if (!token) { + // prettier-ignore + throw new Error(`Invalid URI "${uri}" for invite.parse, missing join hub token`) + } + pieces.shift() + pieces.shift() + pieces.shift() + pieces.shift() + pieces.shift() + const shse = `shse:${pubkey}.${token}` + const address = `net:${host}:${port}~${shse}` // TODO: add ws address here + return { type: 'join', address } +} + +/** + * @param {Array} pieces + * @param {string} uri + * @returns {FollowCommand} + */ +function parseFollowCommand(pieces, uri) { + const [, id] = pieces + if (!id) { + // prettier-ignore + throw new Error(`Invalid URI "${uri}" for invite.parse, missing follow id`) + } + pieces.shift() + pieces.shift() + return { type: 'follow', id } +} + +/** + * @param {Array} pieces + * @param {string} uri + * @returns {PromiseFollowCommand} + */ +function parsePromiseFollowCommand(pieces, uri) { + const [, issuerAndType, token] = pieces + if (!issuerAndType) { + // prettier-ignore + throw new Error(`Invalid URI "${uri}" for invite.parse, missing promise.follow issuer`) + } + if (!token) { + // prettier-ignore + throw new Error(`Invalid URI "${uri}" for invite.parse, missing promise.follow token`) + } + pieces.shift() + pieces.shift() + pieces.shift() + const [issuerType, issuer] = issuerAndType.split('.') + if (issuerType !== 'pubkey' && 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 } +} + +/** + * @param {`ppppp://invite/${string}`} uri + * @returns {Array} + */ +function parse(uri) { + const url = new URL(uri) + if (url.protocol !== 'ppppp:') { + throw new Error( + `Invalid protocol in URI "${uri}" for invite.parse, expected "ppppp:"` + ) + } + if (url.host !== 'invite') { + throw new Error( + `Invalid host in URI "${uri}" for invite.parse, expected "invite"` + ) + } + const pieces = url.pathname.startsWith('/') + ? url.pathname.substring(1).split('/') + : url.pathname.split('/') + + const commands = [] + while (pieces.length > 0) { + switch (pieces[0]) { + case 'join': + commands.push(parseJoinCommand(pieces, uri)) + break + case 'follow': + commands.push(parseFollowCommand(pieces, uri)) + break + case 'promise.follow': + commands.push(parsePromiseFollowCommand(pieces, uri)) + break + default: + console.log('Unknown command', pieces[0]) + pieces.shift() + break + } + } + + return commands +} + +module.exports = { + name: 'invite', + manifest: { + create: 'async', + parse: 'sync', + }, + + parse, + + /** + * @param {any} sstack + * @param {any} config + */ + init(sstack, config) { + if (!sstack.promise?.create) { + throw new Error('ppppp-invite plugin requires ppppp-promise plugin') + } + if (!sstack.conn?.connect) { + throw new Error('ppppp-invite plugin requires ssb-conn plugin') + } + + /** + * @param {CreateOpts} opts + * @param {CB} cb + */ + async function create(opts, cb) { + if (typeof opts !== 'object') { + return cb(new Error('invite.create is missing opts argument')) + } + const type = opts.type ?? 'follow' + if (type !== 'follow' && type !== 'join') { + // 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"`)) + } + 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}`)) + } + + if (!opts._hubMsAddr) { + // prettier-ignore + return cb(new Error(`invite.create 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); + 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 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 follow promise + const [err3, token] = await p(sstack.promise.create)({type: 'follow'}) + if (err3) return cb(err3) + + const joinCommand = `join/${host}/${port}/${pubkey}/${hubToken}` + const followCommand = `follow/${opts.id}` + const promiseCommand = `promise.follow/identity.${opts.id}/${token}` + const uri = `ppppp://invite/${joinCommand}/${followCommand}/${promiseCommand}` + cb(null, uri) + } + + return { create, parse } + }, +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..36b8e3b --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "ppppp-invite", + "version": "0.0.1", + "description": "PPPPP invite code generator and parser", + "homepage": "https://github.com/staltz/ppppp-invite", + "repository": { + "type": "git", + "url": "git://github.com/staltz/ppppp-invite.git" + }, + "author": "Andre 'Staltz' Medeiros ", + "license": "MIT", + "type": "commonjs", + "main": "lib/index.js", + "files": [ + "lib/**/*" + ], + "engines": { + "node": ">=16" + }, + "exports": { + ".": { + "require": "./lib/index.js" + } + }, + "dependencies": { + "multiserver-address": "~1.0.1", + "promisify-tuple": "1.2.0" + }, + "devDependencies": { + "@types/node": "^20.2.5", + "c8": "^7.11.0", + "husky": "^4.3.0", + "ppppp-caps": "github:staltz/ppppp-caps", + "ppppp-keypair": "github:staltz/ppppp-keypair", + "ppppp-promise": "file:../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", + "typescript": "^5.0.2" + }, + "scripts": { + "build": "tsc", + "test": "node --test", + "format-code": "prettier --write \"*.js\" \"(test|lib)/*.js\"", + "format-code-staged": "pretty-quick --staged --pattern \"*.js\" --pattern \"(test|lib)/*.js\"", + "coverage": "c8 --reporter=lcov npm run test" + }, + "husky": { + "hooks": { + "pre-commit": "pretty-quick --staged" + } + } +} diff --git a/test/create.test.js b/test/create.test.js new file mode 100644 index 0000000..f29ce97 --- /dev/null +++ b/test/create.test.js @@ -0,0 +1,90 @@ +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('create()', async (t) => { + const path = Path.join(os.tmpdir(), 'ppppp-promise-create-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: 'follow' }) + cb(null, 'MOCK_PROMISE') + }, + } + }, + } + + const stack = require('secret-stack/lib/api')([], {}) + .use(require('secret-stack/lib/core')) + .use(require('secret-stack/lib/plugins/net')) + .use(require('secret-handshake-ext/secret-stack')) + .use(mockConn) + .use(mockPromise) + .use(require('../lib')) + .call(null, { + path, + caps, + keypair, + connections: { + outgoing: { + net: [{ transform: 'shse' }], + }, + }, + }) + + const uri = await p(stack.invite.create)({ + _hubMsAddr: 'net:example.com:8008~shse:HUB_PUBKEY', + id: 'MOCK_ID', + }) + assert.equal( + uri, + `ppppp://invite/join/example.com/8008/HUB_PUBKEY/MOCK_TOKEN/follow/MOCK_ID/promise.follow/identity.MOCK_ID/MOCK_PROMISE` + ) + + assert.ok(connectCalled) + assert.ok(createTokenCalled) + assert.ok(createPromiseCalled) + await p(stack.close)() +}) diff --git a/test/parse.test.js b/test/parse.test.js new file mode 100644 index 0000000..c7f3270 --- /dev/null +++ b/test/parse.test.js @@ -0,0 +1,37 @@ +const test = require('node:test') +const assert = require('node:assert') +const plugin = require('../lib/index') + +test('parse() error cases', (t) => { + assert.throws(() => { + plugin.parse('ssb://invite/join/HUB_ADDR/HUB_PUBKEY/HUB_TOKEN') + }) + assert.throws(() => { + plugin.parse('ppppp:invite') + }) + assert.throws(() => { + plugin.parse('ppppp:invite/join/HUB_ADDR') + }) +}) + +test('parse() good cases', (t) => { + const commands = plugin.parse( + 'ppppp://invite/join/HOST/PORT/PUBKEY/TOKEN/follow/ALICE/promise.follow/identity.ALICE/ALICE_TOKEN' + ) + assert.deepEqual(commands, [ + { + type: 'join', + address: 'net:HOST:PORT~shse:PUBKEY.TOKEN', + }, + { + type: 'follow', + id: 'ALICE', + }, + { + type: 'promise.follow', + issuerType: 'identity', + issuer: 'ALICE', + token: 'ALICE_TOKEN', + }, + ]) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f6eb639 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "include": ["lib/**/*.js"], + "exclude": ["coverage/", "node_modules/", "test/"], + "compilerOptions": { + "checkJs": true, + "noEmit": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "lib": ["es2021", "dom"], + "module": "node16", + "skipLibCheck": true, + "strict": true, + "target": "es2021" + } +} \ No newline at end of file