From fa4bc57ae04ff032507959abf11f9f6cbc9e2bf2 Mon Sep 17 00:00:00 2001 From: Daniel N <2color@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:43:52 +0100 Subject: [PATCH 01/14] feat: add republish record function --- packages/ipns/src/index.ts | 43 ++++++++++++++- packages/ipns/test/republish.spec.ts | 82 ++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 packages/ipns/test/republish.spec.ts diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index c4d23e74a..27b908f2c 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -257,7 +257,7 @@ import { NotFoundError, isPublicKey } from '@libp2p/interface' import { logger } from '@libp2p/logger' -import { createIPNSRecord, marshalIPNSRecord, multihashToIPNSRoutingKey, unmarshalIPNSRecord, type IPNSRecord } from 'ipns' +import { createIPNSRecord, extractPublicKeyFromIPNSRecord, marshalIPNSRecord, multihashToIPNSRoutingKey, unmarshalIPNSRecord, type IPNSRecord } from 'ipns' import { ipnsSelector } from 'ipns/selector' import { ipnsValidator } from 'ipns/validator' import { base36 } from 'multiformats/bases/base36' @@ -379,6 +379,13 @@ export interface RepublishOptions extends AbortOptions, ProgressOptions { + /** + * Only publish to a local datastore (default: false) + */ + offline?: boolean +} + export interface ResolveResult { /** * The CID that was resolved @@ -430,6 +437,13 @@ export interface IPNS { * Periodically republish all IPNS records found in the datastore */ republish(options?: RepublishOptions): void + + /** + * Republish an existing IPNS record without the private key + * + * The public key is optional if the record has an embedded public key. + */ + republishRecord(record: IPNSRecord, pubKey?: PublicKey, options?: RepublishRecordOptions): Promise } export type { IPNSRouting } from './routing/index.js' @@ -707,6 +721,33 @@ class DefaultIPNS implements IPNS { return unmarshalIPNSRecord(record) } + + async republishRecord(record: IPNSRecord, pubKey?: PublicKey, options: RepublishRecordOptions = {}): Promise { + try { + let mh = extractPublicKeyFromIPNSRecord(record)?.toMultihash() // try to extract the public key from the record + if (!mh) { + // if no public key is provided, use the pubKey that was passed in + mh = pubKey?.toMultihash() + } + + if (!mh) { + throw new Error('No public key found to determine the routing key') + } + + const routingKey = multihashToIPNSRoutingKey(mh) + const marshaledRecord = marshalIPNSRecord(record) + + await this.localStore.put(routingKey, marshaledRecord, options) + + if (options.offline !== true) { + // publish record to routing + await Promise.all(this.routers.map(async r => { await r.put(routingKey, marshaledRecord, options) })) + } + } catch (err: any) { + options.onProgress?.(new CustomProgressEvent('ipns:publish:error', err)) + throw err + } + } } export interface IPNSOptions { diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts new file mode 100644 index 000000000..582b54dc0 --- /dev/null +++ b/packages/ipns/test/republish.spec.ts @@ -0,0 +1,82 @@ +/* eslint-env mocha */ + +import { generateKeyPair } from '@libp2p/crypto/keys' +import { defaultLogger } from '@libp2p/logger' +import { expect } from 'aegir/chai' +import { MemoryDatastore } from 'datastore-core' +import { CID } from 'multiformats/cid' +import { stubInterface } from 'sinon-ts' +import { ipns } from '../src/index.js' +import type { IPNS, IPNSRecord, IPNSRouting } from '../src/index.js' +import type { Routing } from '@helia/interface' +import type { PrivateKey } from '@libp2p/interface' +import type { DNS } from '@multiformats/dns' +import type { StubbedInstance } from 'sinon-ts' +import { createIPNSRecord } from 'ipns' + +describe('republishRecord', () => { + let testCid: CID + let rsaKey: PrivateKey + let rsaRecord: IPNSRecord + let ed25519Key: PrivateKey + let ed25519Record: IPNSRecord + let name: IPNS + let customRouting: StubbedInstance + let heliaRouting: StubbedInstance + let dns: StubbedInstance + + beforeEach(async () => { + const datastore = new MemoryDatastore() + customRouting = stubInterface() + customRouting.get.throws(new Error('Not found')) + heliaRouting = stubInterface() + + name = ipns( + { + datastore, + routing: heliaRouting, + dns, + logger: defaultLogger(), + }, + { + routers: [customRouting], + }, + ) + + testCid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') + rsaKey = await generateKeyPair('RSA') // RSA will embed the public key in the record + ed25519Key = await generateKeyPair('Ed25519') + rsaRecord = await createIPNSRecord(rsaKey, testCid, 1n, 24 * 60 * 60 * 1000) + ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) + }) + + it('should republish a record using embedded public key', async () => { + await expect(name.republishRecord(rsaRecord)).to.not.be.rejected + }) + + it('should republish a record using provided public key', async () => { + await expect(name.republishRecord(ed25519Record, ed25519Key.publicKey)).to.not.be.rejected + }) + + it('should fail when no public key is available', async () => { + await expect(name.republishRecord(ed25519Record)).to.be.rejectedWith( + 'No public key found to determine the routing key', + ) + }) + + it('should emit progress events on error', async () => { + const events: Error[] = [] + + await expect( + name.republishRecord(ed25519Record, undefined, { + onProgress: (evt) => { + if (evt.type === 'ipns:publish:error') { + events.push(evt.detail) + } + }, + }), + ).to.be.rejected + + expect(events).to.have.lengthOf(1) + }) +}) From 4cdd212386a7a73c6737d1f7383dc40d97ebfe58 Mon Sep 17 00:00:00 2001 From: Daniel N <2color@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:57:15 +0100 Subject: [PATCH 02/14] fix: linting errors --- packages/ipns/src/index.ts | 6 +++--- packages/ipns/test/republish.spec.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 27b908f2c..1b97d9fe0 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -722,15 +722,15 @@ class DefaultIPNS implements IPNS { return unmarshalIPNSRecord(record) } - async republishRecord(record: IPNSRecord, pubKey?: PublicKey, options: RepublishRecordOptions = {}): Promise { + async republishRecord (record: IPNSRecord, pubKey?: PublicKey, options: RepublishRecordOptions = {}): Promise { try { let mh = extractPublicKeyFromIPNSRecord(record)?.toMultihash() // try to extract the public key from the record - if (!mh) { + if (mh == null) { // if no public key is provided, use the pubKey that was passed in mh = pubKey?.toMultihash() } - if (!mh) { + if (mh == null) { throw new Error('No public key found to determine the routing key') } diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 582b54dc0..24daeaa5c 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -4,6 +4,7 @@ import { generateKeyPair } from '@libp2p/crypto/keys' import { defaultLogger } from '@libp2p/logger' import { expect } from 'aegir/chai' import { MemoryDatastore } from 'datastore-core' +import { createIPNSRecord } from 'ipns' import { CID } from 'multiformats/cid' import { stubInterface } from 'sinon-ts' import { ipns } from '../src/index.js' @@ -12,7 +13,6 @@ import type { Routing } from '@helia/interface' import type { PrivateKey } from '@libp2p/interface' import type { DNS } from '@multiformats/dns' import type { StubbedInstance } from 'sinon-ts' -import { createIPNSRecord } from 'ipns' describe('republishRecord', () => { let testCid: CID @@ -36,11 +36,11 @@ describe('republishRecord', () => { datastore, routing: heliaRouting, dns, - logger: defaultLogger(), + logger: defaultLogger() }, { - routers: [customRouting], - }, + routers: [customRouting] + } ) testCid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') @@ -60,7 +60,7 @@ describe('republishRecord', () => { it('should fail when no public key is available', async () => { await expect(name.republishRecord(ed25519Record)).to.be.rejectedWith( - 'No public key found to determine the routing key', + 'No public key found to determine the routing key' ) }) @@ -73,8 +73,8 @@ describe('republishRecord', () => { if (evt.type === 'ipns:publish:error') { events.push(evt.detail) } - }, - }), + } + }) ).to.be.rejected expect(events).to.have.lengthOf(1) From 1faa2d0a4be46618796a6482b70c5cf5b71c7689 Mon Sep 17 00:00:00 2001 From: Daniel N <2color@users.noreply.github.com> Date: Tue, 11 Mar 2025 12:45:47 +0100 Subject: [PATCH 03/14] refactor: accept the key first and validate record --- packages/ipns/src/index.ts | 25 +++++++----- packages/ipns/test/republish.spec.ts | 59 +++++++++++++--------------- 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 1b97d9fe0..2aee5dc28 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -302,7 +302,7 @@ export type ResolveProgressEvents = export type RepublishProgressEvents = ProgressEvent<'ipns:republish:start', unknown> | ProgressEvent<'ipns:republish:success', IPNSRecord> | - ProgressEvent<'ipns:republish:error', { record: IPNSRecord, err: Error }> + ProgressEvent<'ipns:republish:error', { mh?: MultihashDigest<0x00 | 0x12>, record: IPNSRecord, err: Error }> export type ResolveDNSLinkProgressEvents = ResolveProgressEvents | @@ -379,7 +379,7 @@ export interface RepublishOptions extends AbortOptions, ProgressOptions { +export interface RepublishRecordOptions extends AbortOptions, ProgressOptions { /** * Only publish to a local datastore (default: false) */ @@ -441,9 +441,9 @@ export interface IPNS { /** * Republish an existing IPNS record without the private key * - * The public key is optional if the record has an embedded public key. + * The key is a multihash of the public key */ - republishRecord(record: IPNSRecord, pubKey?: PublicKey, options?: RepublishRecordOptions): Promise + republishRecord(key: MultihashDigest<0x00 | 0x12>, record: IPNSRecord , options?: RepublishRecordOptions): Promise } export type { IPNSRouting } from './routing/index.js' @@ -722,21 +722,26 @@ class DefaultIPNS implements IPNS { return unmarshalIPNSRecord(record) } - async republishRecord (record: IPNSRecord, pubKey?: PublicKey, options: RepublishRecordOptions = {}): Promise { + + // TODO: accept string `key` of the IPNS name (both CID and multihash base58btc encoded) + async republishRecord (key: MultihashDigest<0x00 | 0x12>, record: IPNSRecord, options: RepublishRecordOptions = {}): Promise { + let mh: MultihashDigest<0x00 | 0x12> | undefined try { - let mh = extractPublicKeyFromIPNSRecord(record)?.toMultihash() // try to extract the public key from the record + mh = extractPublicKeyFromIPNSRecord(record)?.toMultihash() // embedded public key take precedence (if present) if (mh == null) { - // if no public key is provided, use the pubKey that was passed in - mh = pubKey?.toMultihash() + // if no public key is embedded in the record, use the key that was passed in + mh = key } if (mh == null) { - throw new Error('No public key found to determine the routing key') + throw new Error('No public key multihash found to determine the routing key') } const routingKey = multihashToIPNSRoutingKey(mh) const marshaledRecord = marshalIPNSRecord(record) + await ipnsValidator(routingKey, marshaledRecord) // validate that they key corresponds to the record + await this.localStore.put(routingKey, marshaledRecord, options) if (options.offline !== true) { @@ -744,7 +749,7 @@ class DefaultIPNS implements IPNS { await Promise.all(this.routers.map(async r => { await r.put(routingKey, marshaledRecord, options) })) } } catch (err: any) { - options.onProgress?.(new CustomProgressEvent('ipns:publish:error', err)) + options.onProgress?.(new CustomProgressEvent('ipns:republish:error', { mh, record, err })) throw err } } diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 24daeaa5c..7d3d5617f 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -15,11 +15,7 @@ import type { DNS } from '@multiformats/dns' import type { StubbedInstance } from 'sinon-ts' describe('republishRecord', () => { - let testCid: CID - let rsaKey: PrivateKey - let rsaRecord: IPNSRecord - let ed25519Key: PrivateKey - let ed25519Record: IPNSRecord + const testCid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') let name: IPNS let customRouting: StubbedInstance let heliaRouting: StubbedInstance @@ -30,53 +26,54 @@ describe('republishRecord', () => { customRouting = stubInterface() customRouting.get.throws(new Error('Not found')) heliaRouting = stubInterface() + dns = stubInterface() name = ipns( { datastore, routing: heliaRouting, dns, - logger: defaultLogger() + logger: defaultLogger(), }, { - routers: [customRouting] - } + routers: [customRouting], + }, ) + }) - testCid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') - rsaKey = await generateKeyPair('RSA') // RSA will embed the public key in the record - ed25519Key = await generateKeyPair('Ed25519') - rsaRecord = await createIPNSRecord(rsaKey, testCid, 1n, 24 * 60 * 60 * 1000) - ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) + it('should throw an error when attempting to republish with an invalid key', async () => { + const ed25519Key = await generateKeyPair('Ed25519') + const otherEd25519Key = await generateKeyPair('Ed25519') + const ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) + await expect(name.republishRecord(otherEd25519Key.publicKey.toMultihash(), ed25519Record)).to.be.rejected }) - it('should republish a record using embedded public key', async () => { - await expect(name.republishRecord(rsaRecord)).to.not.be.rejected + it('should republish using the embedded public key', async () => { + const rsaKey = await generateKeyPair('RSA') // RSA will embed the public key in the record + const otherKey = await generateKeyPair('RSA') + const rsaRecord = await createIPNSRecord(rsaKey, testCid, 1n, 24 * 60 * 60 * 1000) + await expect(name.republishRecord(otherKey.publicKey.toMultihash(), rsaRecord)).to.not.be.rejected }) + it('should republish a record using provided public key', async () => { - await expect(name.republishRecord(ed25519Record, ed25519Key.publicKey)).to.not.be.rejected + const ed25519Key = await generateKeyPair('Ed25519') + const ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) + await expect(name.republishRecord(ed25519Key.publicKey.toMultihash(), ed25519Record)).to.not.be.rejected }) - it('should fail when no public key is available', async () => { - await expect(name.republishRecord(ed25519Record)).to.be.rejectedWith( - 'No public key found to determine the routing key' - ) - }) it('should emit progress events on error', async () => { - const events: Error[] = [] + const ed25519Key = await generateKeyPair('Ed25519') + const otherEd25519Key = await generateKeyPair('Ed25519') + const ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) await expect( - name.republishRecord(ed25519Record, undefined, { + name.republishRecord(otherEd25519Key.publicKey.toMultihash(), ed25519Record, { onProgress: (evt) => { - if (evt.type === 'ipns:publish:error') { - events.push(evt.detail) - } - } - }) - ).to.be.rejected - - expect(events).to.have.lengthOf(1) + expect(evt.type).to.equal('ipns:republish:error') + }, + }), + ).to.eventually.be.rejected }) }) From ffa1d0c37a18460410da9790f2b6c3ea0f8ed2b4 Mon Sep 17 00:00:00 2001 From: Daniel N <2color@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:12:56 +0100 Subject: [PATCH 04/14] feat: support republishing with with string keys --- packages/ipns/src/index.ts | 35 ++++++++++++++++++++++------ packages/ipns/test/republish.spec.ts | 24 ++++++++++++++++++- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 2aee5dc28..3cb0d3615 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -272,12 +272,13 @@ import { localStore, type LocalStore } from './routing/local-store.js' import { isCodec, IDENTITY_CODEC, SHA2_256_CODEC } from './utils.js' import type { IPNSRouting, IPNSRoutingEvents } from './routing/index.js' import type { Routing } from '@helia/interface' -import type { AbortOptions, ComponentLogger, Logger, PrivateKey, PublicKey } from '@libp2p/interface' +import type { AbortOptions, ComponentLogger, Logger, PeerId, PrivateKey, PublicKey } from '@libp2p/interface' import type { Answer, DNS, ResolveDnsProgressEvents } from '@multiformats/dns' import type { Datastore } from 'interface-datastore' import type { MultibaseDecoder } from 'multiformats/bases/interface' import type { MultihashDigest } from 'multiformats/hashes/interface' import type { ProgressEvent, ProgressOptions } from 'progress-events' +import { peerIdFromCID, peerIdFromString } from '@libp2p/peer-id' const log = logger('helia:ipns') @@ -441,9 +442,9 @@ export interface IPNS { /** * Republish an existing IPNS record without the private key * - * The key is a multihash of the public key + * The key is a multihash of the public key or a string representation of the PeerID (either base58btc encoded multihash or base36 encoded CID) */ - republishRecord(key: MultihashDigest<0x00 | 0x12>, record: IPNSRecord , options?: RepublishRecordOptions): Promise + republishRecord(key: MultihashDigest<0x00 | 0x12> | string, record: IPNSRecord , options?: RepublishRecordOptions): Promise } export type { IPNSRouting } from './routing/index.js' @@ -722,15 +723,35 @@ class DefaultIPNS implements IPNS { return unmarshalIPNSRecord(record) } + /** + * Convert a string to a PeerId + */ + #getPeerIdFromString (peerIdString: string): PeerId { + // It's either base58btc encoded multihash (identity or sha256) + if (peerIdString.charAt(0) === '1' || peerIdString.charAt(0) === 'Q') { + return peerIdFromString(peerIdString) + } + + // or base36 encoded CID + return peerIdFromCID(CID.parse(peerIdString)) + } - // TODO: accept string `key` of the IPNS name (both CID and multihash base58btc encoded) - async republishRecord (key: MultihashDigest<0x00 | 0x12>, record: IPNSRecord, options: RepublishRecordOptions = {}): Promise { + async republishRecord (key: MultihashDigest<0x00 | 0x12> | string, record: IPNSRecord, options: RepublishRecordOptions = {}): Promise { let mh: MultihashDigest<0x00 | 0x12> | undefined try { - mh = extractPublicKeyFromIPNSRecord(record)?.toMultihash() // embedded public key take precedence (if present) + mh = extractPublicKeyFromIPNSRecord(record)?.toMultihash() // embedded public key take precedence, if present if (mh == null) { // if no public key is embedded in the record, use the key that was passed in - mh = key + if (typeof key === 'string') { + // Convert string key to MultihashDigest + try { + mh = this.#getPeerIdFromString(key).toMultihash() + } catch (err: any) { + throw new Error(`Invalid string key: ${err.message}`) + } + } else { + mh = key + } } if (mh == null) { diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 7d3d5617f..8bb0f6bb7 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -13,6 +13,9 @@ import type { Routing } from '@helia/interface' import type { PrivateKey } from '@libp2p/interface' import type { DNS } from '@multiformats/dns' import type { StubbedInstance } from 'sinon-ts' +import { base36 } from 'multiformats/bases/base36' +import { base32 } from 'multiformats/bases/base32' +import { base58btc } from 'multiformats/bases/base58' describe('republishRecord', () => { const testCid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') @@ -55,13 +58,32 @@ describe('republishRecord', () => { await expect(name.republishRecord(otherKey.publicKey.toMultihash(), rsaRecord)).to.not.be.rejected }) - it('should republish a record using provided public key', async () => { const ed25519Key = await generateKeyPair('Ed25519') const ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) await expect(name.republishRecord(ed25519Key.publicKey.toMultihash(), ed25519Record)).to.not.be.rejected }) + it('should republish a record using a string key (base58btc encoded multihash)', async () => { + const ed25519Key = await generateKeyPair('Ed25519') + const ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) + const keyString = ed25519Key.publicKey.toString() + await expect(name.republishRecord(keyString, ed25519Record)).to.not.be.rejected + }) + + it('should republish a record using a string key (base36 encoded CID)', async () => { + const ed25519Key = await generateKeyPair('Ed25519') + const ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) + const keyString = ed25519Key.publicKey.toCID().toString(base36) + await expect(name.republishRecord(keyString, ed25519Record)).to.not.be.rejected + }) + + it('should republish a record using a string key (base32 encoded CID)', async () => { + const ed25519Key = await generateKeyPair('Ed25519') + const ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) + const keyString = ed25519Key.publicKey.toCID().toString(base32) + await expect(name.republishRecord(keyString, ed25519Record)).to.not.be.rejected + }) it('should emit progress events on error', async () => { const ed25519Key = await generateKeyPair('Ed25519') From a88b7eea2a489485c2d5d9213eb5d31a9df7ac0b Mon Sep 17 00:00:00 2001 From: Daniel N <2color@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:14:44 +0100 Subject: [PATCH 05/14] fix: linting errors --- packages/ipns/src/index.ts | 4 ++-- packages/ipns/test/republish.spec.ts | 18 ++++++++---------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 3cb0d3615..58512dcce 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -257,6 +257,7 @@ import { NotFoundError, isPublicKey } from '@libp2p/interface' import { logger } from '@libp2p/logger' +import { peerIdFromCID, peerIdFromString } from '@libp2p/peer-id' import { createIPNSRecord, extractPublicKeyFromIPNSRecord, marshalIPNSRecord, multihashToIPNSRoutingKey, unmarshalIPNSRecord, type IPNSRecord } from 'ipns' import { ipnsSelector } from 'ipns/selector' import { ipnsValidator } from 'ipns/validator' @@ -278,7 +279,6 @@ import type { Datastore } from 'interface-datastore' import type { MultibaseDecoder } from 'multiformats/bases/interface' import type { MultihashDigest } from 'multiformats/hashes/interface' import type { ProgressEvent, ProgressOptions } from 'progress-events' -import { peerIdFromCID, peerIdFromString } from '@libp2p/peer-id' const log = logger('helia:ipns') @@ -444,7 +444,7 @@ export interface IPNS { * * The key is a multihash of the public key or a string representation of the PeerID (either base58btc encoded multihash or base36 encoded CID) */ - republishRecord(key: MultihashDigest<0x00 | 0x12> | string, record: IPNSRecord , options?: RepublishRecordOptions): Promise + republishRecord(key: MultihashDigest<0x00 | 0x12> | string, record: IPNSRecord, options?: RepublishRecordOptions): Promise } export type { IPNSRouting } from './routing/index.js' diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 8bb0f6bb7..0d074d995 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -5,17 +5,15 @@ import { defaultLogger } from '@libp2p/logger' import { expect } from 'aegir/chai' import { MemoryDatastore } from 'datastore-core' import { createIPNSRecord } from 'ipns' +import { base32 } from 'multiformats/bases/base32' +import { base36 } from 'multiformats/bases/base36' import { CID } from 'multiformats/cid' import { stubInterface } from 'sinon-ts' import { ipns } from '../src/index.js' -import type { IPNS, IPNSRecord, IPNSRouting } from '../src/index.js' +import type { IPNS, IPNSRouting } from '../src/index.js' import type { Routing } from '@helia/interface' -import type { PrivateKey } from '@libp2p/interface' import type { DNS } from '@multiformats/dns' import type { StubbedInstance } from 'sinon-ts' -import { base36 } from 'multiformats/bases/base36' -import { base32 } from 'multiformats/bases/base32' -import { base58btc } from 'multiformats/bases/base58' describe('republishRecord', () => { const testCid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') @@ -36,11 +34,11 @@ describe('republishRecord', () => { datastore, routing: heliaRouting, dns, - logger: defaultLogger(), + logger: defaultLogger() }, { - routers: [customRouting], - }, + routers: [customRouting] + } ) }) @@ -94,8 +92,8 @@ describe('republishRecord', () => { name.republishRecord(otherEd25519Key.publicKey.toMultihash(), ed25519Record, { onProgress: (evt) => { expect(evt.type).to.equal('ipns:republish:error') - }, - }), + } + }) ).to.eventually.be.rejected }) }) From 74c2862a1f4407c4e9560bdf5be752c8ed44bf35 Mon Sep 17 00:00:00 2001 From: Daniel Norman <1992255+2color@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:55:33 +0100 Subject: [PATCH 06/14] Apply suggestions from code review Co-authored-by: Alex Potsides --- packages/ipns/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 58512dcce..d09d468ea 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -382,7 +382,9 @@ export interface RepublishOptions extends AbortOptions, ProgressOptions { /** - * Only publish to a local datastore (default: false) + * Only publish to a local datastore + * + * @default false */ offline?: boolean } From 0da6bcc6e2ed7bc39cc8f33e0f6508c3a6f4aaf5 Mon Sep 17 00:00:00 2001 From: Daniel N <2color@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:58:59 +0100 Subject: [PATCH 07/14] refactor: rename mh to key for consistency --- packages/ipns/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index d09d468ea..554c81daf 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -303,7 +303,7 @@ export type ResolveProgressEvents = export type RepublishProgressEvents = ProgressEvent<'ipns:republish:start', unknown> | ProgressEvent<'ipns:republish:success', IPNSRecord> | - ProgressEvent<'ipns:republish:error', { mh?: MultihashDigest<0x00 | 0x12>, record: IPNSRecord, err: Error }> + ProgressEvent<'ipns:republish:error', { key?: MultihashDigest<0x00 | 0x12>, record: IPNSRecord, err: Error }> export type ResolveDNSLinkProgressEvents = ResolveProgressEvents | @@ -772,7 +772,7 @@ class DefaultIPNS implements IPNS { await Promise.all(this.routers.map(async r => { await r.put(routingKey, marshaledRecord, options) })) } } catch (err: any) { - options.onProgress?.(new CustomProgressEvent('ipns:republish:error', { mh, record, err })) + options.onProgress?.(new CustomProgressEvent('ipns:republish:error', { key: mh, record, err })) throw err } } From 1cd3409a808d00d8d041c0ba299ec23a7c46dcc5 Mon Sep 17 00:00:00 2001 From: Daniel N <2color@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:00:18 +0100 Subject: [PATCH 08/14] test: assert error type --- packages/ipns/test/republish.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 0d074d995..4efe36e3a 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -94,6 +94,6 @@ describe('republishRecord', () => { expect(evt.type).to.equal('ipns:republish:error') } }) - ).to.eventually.be.rejected + ).to.eventually.be.rejected.with.property('name', 'SignatureVerificationError') }) }) From 60ad2b875f98ae67fc0ab9ac2554bd92939356e3 Mon Sep 17 00:00:00 2001 From: Daniel N <2color@users.noreply.github.com> Date: Wed, 12 Mar 2025 09:09:27 +0100 Subject: [PATCH 09/14] docs: add note about valid record --- packages/ipns/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index eed354025..c3071eb49 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -442,8 +442,9 @@ export interface IPNS { republish(options?: RepublishOptions): void /** - * Republish an existing IPNS record without the private key + * Republish an existing IPNS record without the private key. * + * Before republishing the record will be validated to ensure it has a valid signature and lifetime(validity) in the future. * The key is a multihash of the public key or a string representation of the PeerID (either base58btc encoded multihash or base36 encoded CID) */ republishRecord(key: MultihashDigest<0x00 | 0x12> | string, record: IPNSRecord, options?: RepublishRecordOptions): Promise From ef6fb9c2cd4580e695fa3e38acc7e62462b1b688 Mon Sep 17 00:00:00 2001 From: Daniel N <2color@users.noreply.github.com> Date: Wed, 12 Mar 2025 09:14:34 +0100 Subject: [PATCH 10/14] chore: add comment --- packages/ipns/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index c3071eb49..8665a99fb 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -766,7 +766,7 @@ class DefaultIPNS implements IPNS { await ipnsValidator(routingKey, marshaledRecord) // validate that they key corresponds to the record - await this.localStore.put(routingKey, marshaledRecord, options) + await this.localStore.put(routingKey, marshaledRecord, options) // add to local store if (options.offline !== true) { // publish record to routing From 87e7efd82f54ebe1512c1f28f469871bb84b6e92 Mon Sep 17 00:00:00 2001 From: Daniel N <2color@users.noreply.github.com> Date: Wed, 12 Mar 2025 09:37:19 +0100 Subject: [PATCH 11/14] docs: add republishing example --- packages/ipns/README.md | 22 ++++++++++++++++++++++ packages/ipns/src/index.ts | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/packages/ipns/README.md b/packages/ipns/README.md index d557d26ac..6ba9973bc 100644 --- a/packages/ipns/README.md +++ b/packages/ipns/README.md @@ -283,6 +283,28 @@ const name = ipns(node) const result = await name.resolveDNSLink('ipfs.io') ``` +## Example - Republishing an existing IPNS record + +The `republishRecord` method allows you to republish an existing IPNS record without +needing the private key. This is useful for relay nodes or when you want to extend +the availability of a record that was created elsewhere. + +```TypeScript +import { createHelia } from 'helia' +import { ipns } from '@helia/ipns' +import { createDelegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client' +import { CID } from 'multiformats/cid' + +const helia = await createHelia() +const name = ipns(helia) + +const ipnsName = CID.parse('k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4') +const delegatedClient = createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev') +const record = await delegatedClient.get(ipnsName) + +await name.republishRecord(ipnsName.multihash, record) +``` + # Install ```console diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 8665a99fb..1b63ef9de 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -253,6 +253,28 @@ * * const result = await name.resolveDNSLink('ipfs.io') * ``` + * + * @example Republishing an existing IPNS record + * + * The `republishRecord` method allows you to republish an existing IPNS record without + * needing the private key. This is useful for relay nodes or when you want to extend + * the availability of a record that was created elsewhere. + * + * ```TypeScript + * import { createHelia } from 'helia' + * import { ipns } from '@helia/ipns' + * import { createDelegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client' + * import { CID } from 'multiformats/cid' + * + * const helia = await createHelia() + * const name = ipns(helia) + * + * const ipnsName = CID.parse('k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4') + * const delegatedClient = createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev') + * const record = await delegatedClient.get(ipnsName) + * + * await name.republishRecord(ipnsName.multihash, record) + * ``` */ import { NotFoundError, isPublicKey } from '@libp2p/interface' From 42d8fc08df6dd433b3e5eeb39b505659774a896d Mon Sep 17 00:00:00 2001 From: Daniel N <2color@users.noreply.github.com> Date: Wed, 12 Mar 2025 13:01:51 +0100 Subject: [PATCH 12/14] docs: fix example --- packages/ipns/README.md | 7 ++++--- packages/ipns/src/index.ts | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/ipns/README.md b/packages/ipns/README.md index 6ba9973bc..c1b1122ee 100644 --- a/packages/ipns/README.md +++ b/packages/ipns/README.md @@ -298,11 +298,12 @@ import { CID } from 'multiformats/cid' const helia = await createHelia() const name = ipns(helia) -const ipnsName = CID.parse('k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4') +const ipnsName = 'k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4' +const parsedCid = CID.parse('k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4') const delegatedClient = createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev') -const record = await delegatedClient.get(ipnsName) +const record = await delegatedClient.getIPNS(parsedCid) -await name.republishRecord(ipnsName.multihash, record) +await name.republishRecord(ipnsName, record) ``` # Install diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 1b63ef9de..f7d119b5e 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -269,11 +269,12 @@ * const helia = await createHelia() * const name = ipns(helia) * - * const ipnsName = CID.parse('k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4') + * const ipnsName = 'k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4' + * const parsedCid = CID.parse('k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4') * const delegatedClient = createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev') - * const record = await delegatedClient.get(ipnsName) + * const record = await delegatedClient.getIPNS(parsedCid) * - * await name.republishRecord(ipnsName.multihash, record) + * await name.republishRecord(ipnsName, record) * ``` */ From 0fd6e7b7c5a6219ec3c99ec61bb1cf9e4375bdaf Mon Sep 17 00:00:00 2001 From: Daniel N <2color@users.noreply.github.com> Date: Wed, 12 Mar 2025 13:06:49 +0100 Subject: [PATCH 13/14] docs: expect error because cid --- packages/ipns/README.md | 1 + packages/ipns/src/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/ipns/README.md b/packages/ipns/README.md index c1b1122ee..e8681231d 100644 --- a/packages/ipns/README.md +++ b/packages/ipns/README.md @@ -301,6 +301,7 @@ const name = ipns(helia) const ipnsName = 'k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4' const parsedCid = CID.parse('k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4') const delegatedClient = createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev') +// @ts-expect-error const record = await delegatedClient.getIPNS(parsedCid) await name.republishRecord(ipnsName, record) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index f7d119b5e..1cd1be0e0 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -272,6 +272,7 @@ * const ipnsName = 'k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4' * const parsedCid = CID.parse('k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4') * const delegatedClient = createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev') + * // @ts-expect-error * const record = await delegatedClient.getIPNS(parsedCid) * * await name.republishRecord(ipnsName, record) From 3881dacdd288327bf7144c50cd3445349ecbd2eb Mon Sep 17 00:00:00 2001 From: Daniel N <2color@users.noreply.github.com> Date: Wed, 12 Mar 2025 13:25:58 +0100 Subject: [PATCH 14/14] fix: docs error --- packages/ipns/README.md | 3 +-- packages/ipns/src/index.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/ipns/README.md b/packages/ipns/README.md index e8681231d..e22d4f9b5 100644 --- a/packages/ipns/README.md +++ b/packages/ipns/README.md @@ -299,9 +299,8 @@ const helia = await createHelia() const name = ipns(helia) const ipnsName = 'k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4' -const parsedCid = CID.parse('k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4') +const parsedCid: CID = CID.parse(ipnsName) const delegatedClient = createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev') -// @ts-expect-error const record = await delegatedClient.getIPNS(parsedCid) await name.republishRecord(ipnsName, record) diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 1cd1be0e0..3a640a875 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -270,9 +270,8 @@ * const name = ipns(helia) * * const ipnsName = 'k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4' - * const parsedCid = CID.parse('k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4') + * const parsedCid: CID = CID.parse(ipnsName) * const delegatedClient = createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev') - * // @ts-expect-error * const record = await delegatedClient.getIPNS(parsedCid) * * await name.republishRecord(ipnsName, record)