use TypeScript in JSDoc

This commit is contained in:
Andre Staltz 2023-06-25 18:48:24 +03:00
parent aab707f5da
commit 674e2ba66c
No known key found for this signature in database
GPG Key ID: 9EDE23EA7E8A4890
13 changed files with 362 additions and 86 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ node_modules
pnpm-lock.yaml pnpm-lock.yaml
package-lock.json package-lock.json
coverage coverage
**/*.d.ts
*~ *~
# For misc scripts and experiments: # For misc scripts and experiments:

View File

@ -3,10 +3,23 @@ const b4a = require('b4a')
const MsgV3 = require('./msg-v3') const MsgV3 = require('./msg-v3')
/** /**
* @typedef {import('./index').Rec} Rec * @typedef {import('./index').RecPresent} RecPresent
* @typedef {import('ppppp-keypair').Keypair} Keypair * @typedef {import('ppppp-keypair').Keypair} Keypair
*
* @typedef {Buffer | Uint8Array} B4A
*
* @typedef {{
* name: string;
* setup?: (config: any, cb: any) => void;
* onReady?: (cb: any) => void;
* encrypt: (plaintext: B4A, opts: any) => B4A;
* decrypt: (ciphertext: B4A, opts: any) => B4A | null;
* }} EncryptionFormat
*/ */
/**
* @param {string} str
*/
function ciphertextStrToBuffer(str) { function ciphertextStrToBuffer(str) {
const dot = str.indexOf('.') const dot = str.indexOf('.')
return b4a.from(str.slice(0, dot), 'base64') return b4a.from(str.slice(0, dot), 'base64')
@ -17,21 +30,21 @@ function ciphertextStrToBuffer(str) {
* @param {Keypair} keypair * @param {Keypair} keypair
*/ */
function keypairToSSBKeys(keypair) { function keypairToSSBKeys(keypair) {
const public = b4a.from(base58.decode(keypair.public)).toString('base64') const _public = b4a.from(base58.decode(keypair.public)).toString('base64')
const private = b4a.from(base58.decode(keypair.private)).toString('base64') const _private = b4a.from(base58.decode(keypair.private)).toString('base64')
return { return {
id: `@${public}.ed25519`, id: `@${_public}.ed25519`,
curve: keypair.curve, curve: keypair.curve,
public, public: _public,
private, private: _private,
} }
} }
/** /**
* @param {Rec} rec * @param {RecPresent} rec
* @param {any} peer * @param {any} peer
* @param {any} config * @param {any} config
* @returns {Rec} * @returns {RecPresent}
*/ */
function decrypt(rec, peer, config) { function decrypt(rec, peer, config) {
const msgEncrypted = rec.msg const msgEncrypted = rec.msg
@ -63,6 +76,9 @@ function decrypt(rec, peer, config) {
} }
} }
/**
* @param {RecPresent} rec
*/
function reEncrypt(rec) { function reEncrypt(rec) {
return { return {
hash: rec.hash, hash: rec.hash,

View File

@ -1,47 +1,63 @@
const path = require('node:path') const path = require('node:path')
// @ts-ignore
const push = require('push-stream') const push = require('push-stream')
// @ts-ignore
const AAOL = require('async-append-only-log') const AAOL = require('async-append-only-log')
const promisify = require('promisify-4loc') const promisify = require('promisify-4loc')
const b4a = require('b4a') const b4a = require('b4a')
const base58 = require('bs58') const base58 = require('bs58')
// @ts-ignore
const Obz = require('obz') const Obz = require('obz')
const MsgV2 = require('./msg-v3') const MsgV2 = require('./msg-v3')
const { ReadyGate } = require('./utils') const { ReadyGate } = require('./utils')
const { decrypt } = require('./encryption') const { decrypt } = require('./encryption')
/** /**
* @typedef {import('ppppp-keypair').Keypair} Keypair
* @typedef {import('./msg-v3').Msg} Msg * @typedef {import('./msg-v3').Msg} Msg
* @typedef {import('./encryption').EncryptionFormat} EncryptionFormat
*
* @typedef {Buffer | Uint8Array} B4A
*/ */
/** /**
* @typedef {Object} RecDeleted * @typedef {{
* @property {never} hash * hash?: never;
* @property {never} msg * msg?: never;
* @property {never} received * received?: never;
* @property {Object} misc * misc: {
* @property {number} misc.offset * offset: number;
* @property {number} misc.size * size: number;
* @property {number} misc.seq * seq: number;
*/ * };
* }} RecDeleted
/** *
* @typedef {Object} RecPresent * @typedef {{
* @property {string} hash * hash: string;
* @property {Msg} msg * msg: Msg;
* @property {number} received * received: number;
* @property {Object} misc * misc: {
* @property {number} misc.offset * offset: number;
* @property {number} misc.size * size: number;
* @property {number} misc.seq * seq: number;
* @property {boolean=} misc.private * private?: boolean;
* @property {Object=} misc.originalData * originalData?: any;
* @property {string=} misc.encryptionFormat * encryptionFormat?: string;
*/ * }
* }} RecPresent
/** *
* @typedef {RecPresent | RecDeleted} Rec * @typedef {RecPresent | RecDeleted} Rec
*/ */
/**
* @template T
* @typedef {(...args: [Error] | [null, T]) => void} CB
*/
/**
* @typedef {(...args: [Error] | []) => void} CBVoid
*/
class DBTangle extends MsgV2.Tangle { class DBTangle extends MsgV2.Tangle {
/** /**
* @param {string} rootHash * @param {string} rootHash
@ -55,6 +71,9 @@ class DBTangle extends MsgV2.Tangle {
} }
} }
/**
* @param {string} msgHash
*/
getDeletablesAndErasables(msgHash) { getDeletablesAndErasables(msgHash) {
const erasables = this.shortestPathToRoot(msgHash) const erasables = this.shortestPathToRoot(msgHash)
const sorted = this.topoSort() const sorted = this.topoSort()
@ -66,28 +85,42 @@ class DBTangle extends MsgV2.Tangle {
} }
} }
exports.name = 'db' /**
* @param {any} peer
exports.init = function initDB(peer, config) { * @param {{ path: string; keypair: Keypair; }} config
*/
function initDB(peer, config) {
/** @type {Array<Rec>} */ /** @type {Array<Rec>} */
const recs = [] const recs = []
/** @type {Map<string, EncryptionFormat>} */
const encryptionFormats = new Map() const encryptionFormats = new Map()
const onRecordAdded = Obz() const onRecordAdded = Obz()
const log = AAOL(path.join(config.path, 'db.bin'), { const log = AAOL(path.join(config.path, 'db.bin'), {
cacheSize: 1, cacheSize: 1,
blockSize: 64 * 1024, blockSize: 64 * 1024,
codec: { codec: {
/**
* @param {Msg} msg
*/
encode(msg) { encode(msg) {
return b4a.from(JSON.stringify(msg), 'utf8') return b4a.from(JSON.stringify(msg), 'utf8')
}, },
/**
* @param {B4A} buf
*/
decode(buf) { decode(buf) {
return JSON.parse(buf.toString('utf8')) return JSON.parse(b4a.toString(buf, 'utf8'))
}, },
}, },
/**
* @param {B4A} buf
*/
validateRecord(buf) { validateRecord(buf) {
try { try {
JSON.parse(buf.toString('utf8')) JSON.parse(b4a.toString(buf, 'utf8'))
return true return true
} catch { } catch {
return false return false
@ -95,8 +128,9 @@ exports.init = function initDB(peer, config) {
}, },
}) })
peer.close.hook(function (fn, args) { peer.close.hook(function (/** @type {any} */ fn, /** @type {any} */ args) {
log.close(() => { log.close(() => {
// @ts-ignore
fn.apply(this, args) fn.apply(this, args)
}) })
}) })
@ -107,24 +141,27 @@ exports.init = function initDB(peer, config) {
let i = -1 let i = -1
log.stream({ offsets: true, values: true, sizes: true }).pipe( log.stream({ offsets: true, values: true, sizes: true }).pipe(
push.drain( push.drain(
// @ts-ignore
function drainEach({ offset, value, size }) { function drainEach({ offset, value, size }) {
i += 1 i += 1
if (!value) { if (!value) {
// deleted record // deleted record
recs.push({ misc: { offset, size, seq: i } }) /** @type {RecDeleted} */
const rec = { misc: { offset, size, seq: i } }
recs.push(rec)
return return
} }
// TODO: for performance, dont decrypt on startup, instead decrypt on // TODO: for performance, dont decrypt on startup, instead decrypt on
// demand, or decrypt in the background. Or then store the log with // demand, or decrypt in the background. Or then store the log with
// decrypted msgs and only encrypt when moving it to the network. // decrypted msgs and only encrypt when moving it to the network.
const rec = decrypt(value, peer, config) const rec = decrypt(value, peer, config)
rec.misc ??= {} rec.misc ??= /** @type {Rec['misc']} */ ({})
rec.misc.offset = offset rec.misc.offset = offset
rec.misc.size = size rec.misc.size = size
rec.misc.seq = i rec.misc.seq = i
recs.push(rec) recs.push(rec)
}, },
function drainEnd(err) { function drainEnd(/** @type {any} */ err) {
// prettier-ignore // prettier-ignore
if (err) throw new Error('Failed to initially scan the log', { cause: err }); if (err) throw new Error('Failed to initially scan the log', { cause: err });
scannedLog.setReady() scannedLog.setReady()
@ -133,13 +170,26 @@ exports.init = function initDB(peer, config) {
) )
}) })
/**
* @param {string} hash
* @param {Msg} msg
* @param {CB<Rec>} cb
*/
function logAppend(hash, msg, cb) { function logAppend(hash, msg, cb) {
/** @type {RecPresent} */
const rec = { const rec = {
hash, hash,
msg, msg,
received: Date.now(), received: Date.now(),
misc: {
offset: 0,
size: 0,
seq: 0,
},
} }
log.append(rec, (err, newOffset) => { log.append(
rec,
(/** @type {any} */ err, /** @type {number} */ newOffset) => {
if (err) return cb(new Error('logAppend failed', { cause: err })) if (err) return cb(new Error('logAppend failed', { cause: err }))
const offset = newOffset // latestOffset const offset = newOffset // latestOffset
const size = b4a.from(JSON.stringify(rec), 'utf8').length const size = b4a.from(JSON.stringify(rec), 'utf8').length
@ -148,13 +198,17 @@ exports.init = function initDB(peer, config) {
rec.misc = recExposed.misc = { offset, size, seq } rec.misc = recExposed.misc = { offset, size, seq }
recs.push(recExposed) recs.push(recExposed)
cb(null, rec) cb(null, rec)
}) }
)
} }
/**
* @param {EncryptionFormat} encryptionFormat
*/
function installEncryptionFormat(encryptionFormat) { function installEncryptionFormat(encryptionFormat) {
if (encryptionFormat.setup) { if (encryptionFormat.setup) {
const loaded = new ReadyGate() const loaded = new ReadyGate()
encryptionFormat.setup(config, (err) => { encryptionFormat.setup(config, (/** @type {any} */ err) => {
// prettier-ignore // prettier-ignore
if (err) throw new Error(`Failed to install encryption format "${encryptionFormat.name}"`, {cause: err}); if (err) throw new Error(`Failed to install encryption format "${encryptionFormat.name}"`, {cause: err});
loaded.setReady() loaded.setReady()
@ -164,15 +218,27 @@ exports.init = function initDB(peer, config) {
encryptionFormats.set(encryptionFormat.name, encryptionFormat) encryptionFormats.set(encryptionFormat.name, encryptionFormat)
} }
/**
* @param {string} ciphertextJS
*/
function findEncryptionFormatFor(ciphertextJS) { function findEncryptionFormatFor(ciphertextJS) {
if (!ciphertextJS) return null if (!ciphertextJS) return null
if (typeof ciphertextJS !== 'string') return null if (typeof ciphertextJS !== 'string') return null
const suffix = ciphertextJS.split('.').pop() const suffix = ciphertextJS.split('.').pop()
if (!suffix) {
// prettier-ignore
console.warn('findEncryptionFormatFor() failed to find suffix\n\n' + ciphertextJS)
return null
}
const encryptionFormat = encryptionFormats.get(suffix) ?? null const encryptionFormat = encryptionFormats.get(suffix) ?? null
return encryptionFormat return encryptionFormat
} }
/**
* @param {Array<string>} tangleIds
*/
function populateTangles(tangleIds) { function populateTangles(tangleIds) {
/** @type {Record<string, DBTangle>} */
const tangles = {} const tangles = {}
for (const tangleId of tangleIds) { for (const tangleId of tangleIds) {
tangles[tangleId] ??= new DBTangle(tangleId, records()) tangles[tangleId] ??= new DBTangle(tangleId, records())
@ -180,11 +246,19 @@ exports.init = function initDB(peer, config) {
return tangles return tangles
} }
/**
* @param {CB<void>} cb
*/
function loaded(cb) { function loaded(cb) {
if (cb === void 0) return promisify(loaded)() if (cb === void 0) return promisify(loaded)()
scannedLog.onReady(cb) scannedLog.onReady(cb)
} }
/**
* @param {Msg} msg
* @param {string} tangleRootHash
* @param {CB<Rec>} cb
*/
function add(msg, tangleRootHash, cb) { function add(msg, tangleRootHash, cb) {
const msgHash = MsgV2.getMsgHash(msg) const msgHash = MsgV2.getMsgHash(msg)
@ -223,6 +297,10 @@ exports.init = function initDB(peer, config) {
}) })
} }
/**
* @param {{ keypair?: any; identity: string; domain: string; }} opts
* @param {CB<string>} cb
*/
function initializeFeed(opts, cb) { function initializeFeed(opts, cb) {
const keypair = opts.keypair ?? config.keypair const keypair = opts.keypair ?? config.keypair
const { identity, domain } = opts const { identity, domain } = opts
@ -234,10 +312,15 @@ exports.init = function initDB(peer, config) {
add(feedRoot, MsgV2.getMsgHash(feedRoot), (err, rec) => { add(feedRoot, MsgV2.getMsgHash(feedRoot), (err, rec) => {
// prettier-ignore // prettier-ignore
if (err) return cb(new Error('initializeFeed() failed to add root', { cause: err })); if (err) return cb(new Error('initializeFeed() failed to add root', { cause: err }));
cb(null, rec.hash) const recHash = /** @type {string} */ (rec.hash)
cb(null, recHash)
}) })
} }
/**
* @param {{keypair?: Keypair, _nonce?: string} | null} opts
* @param {CB<Rec>} cb
*/
function createIdentity(opts, cb) { function createIdentity(opts, cb) {
const keypair = opts?.keypair ?? config.keypair const keypair = opts?.keypair ?? config.keypair
@ -257,9 +340,15 @@ exports.init = function initDB(peer, config) {
}) })
} }
/**
* @param {{ keypair: Keypair; identity: string; }} opts
* @param {CB<Rec>} cb
*/
function addToIdentity(opts, cb) { function addToIdentity(opts, cb) {
if (!opts?.keypair) return cb(new Error('identity.add() requires a `keypair`')) if (!opts?.keypair)
if (!opts?.identity) return cb(new Error('identity.add() requires a `identity`')) return cb(new Error('identity.add() requires a `keypair`'))
if (!opts?.identity)
return cb(new Error('identity.add() requires a `identity`'))
const addedKeypair = opts.keypair const addedKeypair = opts.keypair
const signingKeypair = config.keypair const signingKeypair = config.keypair
@ -291,20 +380,31 @@ exports.init = function initDB(peer, config) {
}) })
} }
/**
* @param {{
* keypair?: Keypair;
* encryptionFormat?: string;
* data: any;
* domain: string;
* identity: string;
* tangles?: Array<string>;
* }} opts
* @param {CB<Rec>} cb
*/
function publishToFeed(opts, cb) { function publishToFeed(opts, cb) {
if (!opts) return cb(new Error('feed.publish() requires an `opts`')) if (!opts) return cb(new Error('feed.publish() requires an `opts`'))
const keypair = opts.keypair ?? config.keypair const keypair = opts.keypair ?? config.keypair
const encryptionFormat = encryptionFormats.get(opts.encryptionFormat)
if (opts.data.recps) { if (opts.data.recps) {
if (!encryptionFormat) { if (!encryptionFormats.has(opts.encryptionFormat ?? '')) {
// prettier-ignore // prettier-ignore
return cb(new Error(`feed.publish() does not support encryption format "${opts.encryptionFormat}"`)) return cb(new Error(`feed.publish() does not support encryption format "${opts.encryptionFormat}"`))
} }
} }
if (!opts.data) return cb(new Error('feed.publish() requires a `data`')) if (!opts.data) return cb(new Error('feed.publish() requires a `data`'))
if (!opts.domain) return cb(new Error('feed.publish() requires a `domain`')) if (!opts.domain) return cb(new Error('feed.publish() requires a `domain`'))
if (!opts.identity) return cb(new Error('feed.publish() requires a `identity`')) if (!opts.identity)
return cb(new Error('feed.publish() requires a `identity`'))
initializeFeed(opts, (err, feedRootHash) => { initializeFeed(opts, (err, feedRootHash) => {
// prettier-ignore // prettier-ignore
@ -330,6 +430,9 @@ exports.init = function initDB(peer, config) {
`@${b4a.from(base58.decode(recp)).toString('base64')}.ed25519` `@${b4a.from(base58.decode(recp)).toString('base64')}.ed25519`
), ),
} }
const encryptionFormat = /** @type {EncryptionFormat} */ (
encryptionFormats.get(opts.encryptionFormat ?? '')
)
let ciphertextBuf let ciphertextBuf
try { try {
ciphertextBuf = encryptionFormat.encrypt(plaintext, encryptOpts) ciphertextBuf = encryptionFormat.encrypt(plaintext, encryptOpts)
@ -367,31 +470,47 @@ exports.init = function initDB(peer, config) {
}) })
} }
/**
* @param {string} id
* @param {string} findDomain
*/
function getFeedId(id, findDomain) { function getFeedId(id, findDomain) {
const findIdentity = MsgV2.stripIdentity(id) const findIdentity = MsgV2.stripIdentity(id)
for (const rec of records()) { for (const rec of records()) {
if (MsgV2.isFeedRoot(rec.msg, findIdentity, findDomain)) return rec.hash if (rec.msg && MsgV2.isFeedRoot(rec.msg, findIdentity, findDomain)) {
return rec.hash
}
} }
return null return null
} }
// TODO: improve performance of this when getting many messages, the argument /**
// could be an array of hashes, so we can do a single pass over the records. * @param {string} msgId
*/
function getRecord(msgId) { function getRecord(msgId) {
// TODO: improve performance of this when getting many messages, the arg
// could be an array of hashes, so we can do a single pass over the records.
const isUri = msgId.startsWith('ppppp:') const isUri = msgId.startsWith('ppppp:')
for (let i = 0; i < recs.length; i++) { for (let i = 0; i < recs.length; i++) {
const rec = recs[i] const rec = recs[i]
if (!rec) continue if (!rec) continue
if (isUri && msgId.endsWith(rec.hash)) return rec if (isUri && rec.hash && msgId.endsWith(rec.hash)) return rec
else if (!isUri && rec.hash === msgId) return rec else if (!isUri && rec.hash === msgId) return rec
} }
return null return null
} }
/**
* @param {string} msgId
*/
function get(msgId) { function get(msgId) {
return getRecord(msgId)?.msg return getRecord(msgId)?.msg
} }
/**
* @param {string} msgId
* @param {CBVoid} cb
*/
function del(msgId, cb) { function del(msgId, cb) {
const rec = getRecord(msgId) const rec = getRecord(msgId)
if (!rec) return cb() if (!rec) return cb()
@ -403,6 +522,10 @@ exports.init = function initDB(peer, config) {
}) })
} }
/**
* @param {string} msgId
* @param {CBVoid} cb
*/
function erase(msgId, cb) { function erase(msgId, cb) {
const rec = getRecord(msgId) const rec = getRecord(msgId)
if (!rec) return cb() if (!rec) return cb()
@ -413,6 +536,10 @@ exports.init = function initDB(peer, config) {
cb() cb()
} }
/**
* @param {string} tangleId
* @returns {DBTangle}
*/
function getTangle(tangleId) { function getTangle(tangleId) {
return new DBTangle(tangleId, records()) return new DBTangle(tangleId, records())
} }
@ -460,3 +587,6 @@ exports.init = function initDB(peer, config) {
_getLog: () => log, _getLog: () => log,
} }
} }
exports.name = 'db'
exports.init = initDB

View File

@ -1,6 +1,7 @@
const b4a = require('b4a') const b4a = require('b4a')
const blake3 = require('blake3') const blake3 = require('blake3')
const base58 = require('bs58') const base58 = require('bs58')
// @ts-ignore
const stringify = require('json-canon') const stringify = require('json-canon')
/** /**
@ -14,7 +15,8 @@ const stringify = require('json-canon')
*/ */
function getMsgHashBuf(msg) { function getMsgHashBuf(msg) {
const metadataBuf = b4a.from(stringify(msg.metadata), 'utf8') const metadataBuf = b4a.from(stringify(msg.metadata), 'utf8')
return blake3.hash(metadataBuf).subarray(0, 16) const longHash = b4a.from(blake3.hash(metadataBuf))
return longHash.subarray(0, 16)
} }
/** /**

View File

@ -1,8 +1,10 @@
const crypto = require('node:crypto') const crypto = require('node:crypto')
const base58 = require('bs58') const base58 = require('bs58')
const b4a = require('b4a') const b4a = require('b4a')
// @ts-ignore
const stringify = require('json-canon') const stringify = require('json-canon')
const Keypair = require('ppppp-keypair') const Keypair = require('ppppp-keypair')
// @ts-ignore
const union = require('set.prototype.union') const union = require('set.prototype.union')
const { stripIdentity } = require('./strip') const { stripIdentity } = require('./strip')
const isFeedRoot = require('./is-feed-root') const isFeedRoot = require('./is-feed-root')
@ -12,7 +14,6 @@ const {
validateDomain, validateDomain,
validateData, validateData,
validate, validate,
validateBatch,
validateMsgHash, validateMsgHash,
} = require('./validation') } = require('./validation')
const Tangle = require('./tangle') const Tangle = require('./tangle')
@ -56,6 +57,11 @@ const Tangle = require('./tangle')
* }} CreateOpts * }} CreateOpts
*/ */
/**
* @param {string} id
* @param {string} domain
* @returns {string}
*/
function getFeedRootHash(id, domain) { function getFeedRootHash(id, domain) {
/** @type {Msg} */ /** @type {Msg} */
const msg = { const msg = {
@ -76,6 +82,10 @@ function getFeedRootHash(id, domain) {
return getMsgHash(msg) return getMsgHash(msg)
} }
/**
* @param {Pick<CreateOpts, 'data'>} opts
* @returns {B4A}
*/
function toPlaintextBuffer(opts) { function toPlaintextBuffer(opts) {
return b4a.from(stringify(opts.data), 'utf8') return b4a.from(stringify(opts.data), 'utf8')
} }
@ -93,7 +103,7 @@ function create(opts) {
const identity = opts.identity ? stripIdentity(opts.identity) : null const identity = opts.identity ? stripIdentity(opts.identity) : null
const identityTips = opts.identityTips ? opts.identityTips.sort() : null const identityTips = opts.identityTips ? opts.identityTips.sort() : null
const tangles = {} const tangles = /** @type {Msg['metadata']['tangles']} */ ({})
if (opts.tangles) { if (opts.tangles) {
for (const rootId in opts.tangles) { for (const rootId in opts.tangles) {
if ((err = validateMsgHash(rootId))) throw err if ((err = validateMsgHash(rootId))) throw err
@ -168,7 +178,7 @@ function createRoot(id, domain, keypair) {
/** /**
* @param {Keypair} keypair * @param {Keypair} keypair
* @param {string} nonce * @param {string | (() => string)} nonce
* @returns {Msg} * @returns {Msg}
*/ */
function createIdentity( function createIdentity(
@ -217,5 +227,4 @@ module.exports = {
fromPlaintextBuffer, fromPlaintextBuffer,
Tangle, Tangle,
validate, validate,
validateBatch,
} }

View File

@ -1,5 +1,12 @@
const { stripIdentity } = require('./strip') const { stripIdentity } = require('./strip')
/**
* @typedef {import('.').Msg} Msg
*/
/**
* @param {any} obj
*/
function isEmptyObject(obj) { function isEmptyObject(obj) {
for (const _key in obj) { for (const _key in obj) {
return false return false
@ -7,6 +14,11 @@ function isEmptyObject(obj) {
return true return true
} }
/**
* @param {Msg} msg
* @param {string | 0} id
* @param {string | 0} findDomain
*/
function isFeedRoot(msg, id = 0, findDomain = 0) { function isFeedRoot(msg, id = 0, findDomain = 0) {
const { dataHash, dataSize, identity, identityTips, tangles, domain } = msg.metadata const { dataHash, dataSize, identity, identityTips, tangles, domain } = msg.metadata
if (dataHash !== null) return false if (dataHash !== null) return false

View File

@ -1,15 +1,21 @@
const blake3 = require('blake3') const blake3 = require('blake3')
const b4a = require('b4a') const b4a = require('b4a')
const base58 = require('bs58') const base58 = require('bs58')
// @ts-ignore
const stringify = require('json-canon') const stringify = require('json-canon')
/**
* @typedef {Buffer | Uint8Array} B4A
*/
/** /**
* @param {any} data * @param {any} data
* @returns {[string, number]} * @returns {[string, number]}
*/ */
function representData(data) { function representData(data) {
const dataBuf = b4a.from(stringify(data), 'utf8') const dataBuf = b4a.from(stringify(data), 'utf8')
const dataHash = base58.encode(blake3.hash(dataBuf).subarray(0, 16)) const fullHash = /** @type {B4A} */ (blake3.hash(dataBuf))
const dataHash = base58.encode(fullHash.subarray(0, 16))
const dataSize = dataBuf.length const dataSize = dataBuf.length
return [dataHash, dataSize] return [dataHash, dataSize]
} }

View File

@ -1,5 +1,12 @@
const { getMsgHash } = require('./get-msg-id') const { getMsgHash } = require('./get-msg-id')
/**
* @typedef {import('.').Msg} Msg
*/
/**
* @param {any} msgKey
*/
function stripMsgKey(msgKey) { function stripMsgKey(msgKey) {
if (typeof msgKey === 'object') { if (typeof msgKey === 'object') {
if (msgKey.key) return stripMsgKey(msgKey.key) if (msgKey.key) return stripMsgKey(msgKey.key)

View File

@ -2,6 +2,9 @@
* @typedef {import("./index").Msg} Msg * @typedef {import("./index").Msg} Msg
*/ */
/**
* @param {number} n
*/
function lipmaa(n) { function lipmaa(n) {
let m = 1 let m = 1
let po3 = 3 let po3 = 3
@ -46,7 +49,7 @@ class Tangle {
#rootHash #rootHash
/** /**
* @type {Msg} * @type {Msg | undefined}
*/ */
#rootMsg #rootMsg
@ -77,13 +80,16 @@ class Tangle {
/** /**
* @param {string} rootHash * @param {string} rootHash
* @param {Iterable<Msg>} msgsIter
*/ */
constructor(rootHash) { constructor(rootHash) {
this.#rootHash = rootHash this.#rootHash = rootHash
this.#maxDepth = 0 this.#maxDepth = 0
} }
/**
* @param {string} msgHash
* @param {Msg} msg
*/
add(msgHash, msg) { add(msgHash, msg) {
if (msgHash === this.#rootHash && !this.#rootMsg) { if (msgHash === this.#rootHash && !this.#rootMsg) {
this.#tips.add(msgHash) this.#tips.add(msgHash)
@ -195,10 +201,17 @@ class Tangle {
getFeed() { getFeed() {
if (!this.isFeed()) return null if (!this.isFeed()) return null
if (!this.#rootMsg) {
console.trace('Tangle is missing root message')
return null
}
const { identity, domain } = this.#rootMsg.metadata const { identity, domain } = this.#rootMsg.metadata
return { identity, domain } return { identity, domain }
} }
/**
* @param {string} msgHash
*/
shortestPathToRoot(msgHash) { shortestPathToRoot(msgHash) {
if (!this.#rootMsg) { if (!this.#rootMsg) {
console.trace('Tangle is missing root message') console.trace('Tangle is missing root message')
@ -209,10 +222,10 @@ class Tangle {
while (true) { while (true) {
const prev = this.#prev.get(current) const prev = this.#prev.get(current)
if (!prev) break if (!prev) break
let minDepth = this.#depth.get(current) let minDepth = /** @type {number} */ (this.#depth.get(current))
let min = current let min = current
for (const p of prev) { for (const p of prev) {
const d = this.#depth.get(p) const d = /** @type {number} */ (this.#depth.get(p))
if (d < minDepth) { if (d < minDepth) {
minDepth = d minDepth = d
min = p min = p
@ -226,18 +239,22 @@ class Tangle {
return path return path
} }
precedes(a, b) { /**
* @param {string} msgHashA
* @param {string} msgHashB
*/
precedes(msgHashA, msgHashB) {
if (!this.#rootMsg) { if (!this.#rootMsg) {
console.trace('Tangle is missing root message') console.trace('Tangle is missing root message')
return false return false
} }
if (a === b) return false if (msgHashA === msgHashB) return false
if (b === this.#rootHash) return false if (msgHashB === this.#rootHash) return false
let toCheck = [b] let toCheck = [msgHashB]
while (toCheck.length > 0) { while (toCheck.length > 0) {
const prev = this.#prev.get(toCheck.shift()) const prev = this.#prev.get(/** @type {string} */ (toCheck.shift()))
if (!prev) continue if (!prev) continue
if (prev.includes(a)) return true if (prev.includes(msgHashA)) return true
toCheck.push(...prev) toCheck.push(...prev)
} }
return false return false

View File

@ -1,11 +1,20 @@
const b4a = require('b4a') const b4a = require('b4a')
const base58 = require('bs58') const base58 = require('bs58')
const Keypair = require('ppppp-keypair') const Keypair = require('ppppp-keypair')
// @ts-ignore
const stringify = require('json-canon') const stringify = require('json-canon')
const Tangle = require('./tangle') const Tangle = require('./tangle')
const representData = require('./represent-data') const representData = require('./represent-data')
const isFeedRoot = require('./is-feed-root') const isFeedRoot = require('./is-feed-root')
/**
* @typedef {import('.').Msg} Msg
*/
/**
* @param {Msg} msg
* @returns {string | undefined}
*/
function validateShape(msg) { function validateShape(msg) {
if (!msg || typeof msg !== 'object') { if (!msg || typeof msg !== 'object') {
return 'invalid message: not an object\n' + JSON.stringify(msg) return 'invalid message: not an object\n' + JSON.stringify(msg)
@ -25,7 +34,9 @@ function validateShape(msg) {
return 'invalid message: must have metadata.dataSize\n' + JSON.stringify(msg) return 'invalid message: must have metadata.dataSize\n' + JSON.stringify(msg)
} }
if (!('identity' in msg.metadata)) { if (!('identity' in msg.metadata)) {
return 'invalid message: must have metadata.identity\n' + JSON.stringify(msg) return (
'invalid message: must have metadata.identity\n' + JSON.stringify(msg)
)
} }
if (!('identityTips' in msg.metadata)) { if (!('identityTips' in msg.metadata)) {
// prettier-ignore // prettier-ignore
@ -45,6 +56,10 @@ function validateShape(msg) {
} }
} }
/**
* @param {Msg} msg
* @returns {string | undefined}
*/
function validatePubkey(msg) { function validatePubkey(msg) {
const { pubkey } = msg const { pubkey } = msg
if (typeof pubkey !== 'string') { if (typeof pubkey !== 'string') {
@ -63,6 +78,12 @@ function validatePubkey(msg) {
} }
} }
/**
*
* @param {Msg} msg
* @param {Set<string>} pubkeys
* @returns {string | undefined}
*/
function validateIdentityPubkey(msg, pubkeys) { function validateIdentityPubkey(msg, pubkeys) {
// Unusual case: if the msg is a feed root, ignore the identity and pubkey // Unusual case: if the msg is a feed root, ignore the identity and pubkey
if (isFeedRoot(msg)) return if (isFeedRoot(msg)) return
@ -73,6 +94,10 @@ function validateIdentityPubkey(msg, pubkeys) {
} }
} }
/**
* @param {string} str
* @returns {string | undefined}
*/
function validateMsgHash(str) { function validateMsgHash(str) {
try { try {
const hashBuf = b4a.from(base58.decode(str)) const hashBuf = b4a.from(base58.decode(str))
@ -85,6 +110,10 @@ function validateMsgHash(str) {
} }
} }
/**
* @param {Msg} msg
* @returns {string | undefined}
*/
function validateDataSize(msg) { function validateDataSize(msg) {
const { dataSize } = msg.metadata const { dataSize } = msg.metadata
if (!Number.isSafeInteger(dataSize) || dataSize < 0) { if (!Number.isSafeInteger(dataSize) || dataSize < 0) {
@ -93,6 +122,10 @@ function validateDataSize(msg) {
} }
} }
/**
* @param {Msg} msg
* @returns {string | undefined}
*/
function validateSignature(msg) { function validateSignature(msg) {
const { sig } = msg const { sig } = msg
if (typeof sig !== 'string') { if (typeof sig !== 'string') {
@ -122,10 +155,13 @@ function validateSignature(msg) {
} }
/** /**
* * @typedef {NonNullable<ReturnType<Tangle['getFeed']>>} FeedDetails
* @param {any} msg */
/**
* @param {Msg} msg
* @param {Tangle} tangle * @param {Tangle} tangle
* @param {*} tangleId * @param {string} tangleId
* @returns * @returns
*/ */
function validateTangle(msg, tangle, tangleId) { function validateTangle(msg, tangle, tangleId) {
@ -143,7 +179,7 @@ function validateTangle(msg, tangle, tangleId) {
return `invalid message: depth "${depth}" should have been a positive integer\n` + JSON.stringify(msg) return `invalid message: depth "${depth}" should have been a positive integer\n` + JSON.stringify(msg)
} }
if (tangle.isFeed()) { if (tangle.isFeed()) {
const { identity, domain } = tangle.getFeed() const { identity, domain } = /** @type {FeedDetails} */ (tangle.getFeed())
if (domain !== msg.metadata.domain) { if (domain !== msg.metadata.domain) {
// prettier-ignore // prettier-ignore
return `invalid message: domain "${msg.metadata.domain}" should have been feed domain "${domain}"\n` + JSON.stringify(msg) return `invalid message: domain "${msg.metadata.domain}" should have been feed domain "${domain}"\n` + JSON.stringify(msg)
@ -202,6 +238,11 @@ function validateTangle(msg, tangle, tangleId) {
} }
} }
/**
* @param {Msg} msg
* @param {string} msgHash
* @param {string} tangleId
*/
function validateTangleRoot(msg, msgHash, tangleId) { function validateTangleRoot(msg, msgHash, tangleId) {
if (msgHash !== tangleId) { if (msgHash !== tangleId) {
// prettier-ignore // prettier-ignore
@ -213,6 +254,9 @@ function validateTangleRoot(msg, msgHash, tangleId) {
} }
} }
/**
* @param {string} domain
*/
function validateDomain(domain) { function validateDomain(domain) {
if (!domain || typeof domain !== 'string') { if (!domain || typeof domain !== 'string') {
// prettier-ignore // prettier-ignore
@ -232,6 +276,9 @@ function validateDomain(domain) {
} }
} }
/**
* @param {Msg} msg
*/
function validateData(msg) { function validateData(msg) {
const { data } = msg const { data } = msg
if (data === null) { if (data === null) {
@ -260,6 +307,13 @@ function validateData(msg) {
} }
} }
/**
* @param {Msg} msg
* @param {Tangle} tangle
* @param {Set<string>} pubkeys
* @param {string} msgHash
* @param {string} rootHash
*/
function validate(msg, tangle, pubkeys, msgHash, rootHash) { function validate(msg, tangle, pubkeys, msgHash, rootHash) {
let err let err
if ((err = validateShape(msg))) return err if ((err = validateShape(msg))) return err

View File

@ -6,6 +6,9 @@ class ReadyGate {
this.#ready = false this.#ready = false
} }
/**
* @param {() => void} cb
*/
onReady(cb) { onReady(cb) {
if (this.#ready) cb() if (this.#ready) cb()
else this.#waiting.add(cb) else this.#waiting.add(cb)

View File

@ -14,6 +14,7 @@
"files": [ "files": [
"lib/**/*" "lib/**/*"
], ],
"types": "types/index.d.ts",
"engines": { "engines": {
"node": ">=16" "node": ">=16"
}, },
@ -46,9 +47,11 @@
"pretty-quick": "^3.1.3", "pretty-quick": "^3.1.3",
"rimraf": "^4.4.0", "rimraf": "^4.4.0",
"secret-stack": "^6.4.2", "secret-stack": "^6.4.2",
"ssb-box": "^1.0.1" "ssb-box": "^1.0.1",
"typescript": "^5.1.3"
}, },
"scripts": { "scripts": {
"build": "tsc --build --clean && tsc --build",
"test": "node --test", "test": "node --test",
"format-code": "prettier --write \"(lib|test)/**/*.js\"", "format-code": "prettier --write \"(lib|test)/**/*.js\"",
"format-code-staged": "pretty-quick --staged --pattern \"(lib|test)/**/*.js\"", "format-code-staged": "pretty-quick --staged --pattern \"(lib|test)/**/*.js\"",

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"include": ["lib/**/*.js"],
"exclude": ["coverage/", "node_modules/", "test/"],
"compilerOptions": {
"checkJs": true,
"declaration": true,
"emitDeclarationOnly": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"lib": ["es2022", "dom"],
"module": "node16",
"skipLibCheck": true,
"strict": true,
"target": "es2021"
}
}