Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add republish record to ipns #745

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion packages/ipns/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -379,6 +379,13 @@ export interface RepublishOptions extends AbortOptions, ProgressOptions<Republis
interval?: number
}

export interface RepublishRecordOptions extends AbortOptions, ProgressOptions<PublishProgressEvents | IPNSRoutingEvents> {
/**
* Only publish to a local datastore (default: false)
*/
offline?: boolean
}

export interface ResolveResult {
/**
* The CID that was resolved
Expand Down Expand Up @@ -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<void>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stick pubKey into RepublishRecordOptions object?

}

export type { IPNSRouting } from './routing/index.js'
Expand Down Expand Up @@ -707,6 +721,33 @@ class DefaultIPNS implements IPNS {

return unmarshalIPNSRecord(record)
}

async republishRecord (record: IPNSRecord, pubKey?: PublicKey, options: RepublishRecordOptions = {}): Promise<void> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

try {
let mh = extractPublicKeyFromIPNSRecord(record)?.toMultihash() // try to extract the public key from the record
if (mh == null) {
// if no public key is provided, use the pubKey that was passed in
mh = pubKey?.toMultihash()
}

if (mh == null) {
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<Error>('ipns:publish:error', err))
throw err
}
}
}

export interface IPNSOptions {
Expand Down
82 changes: 82 additions & 0 deletions packages/ipns/test/republish.spec.ts
Original file line number Diff line number Diff line change
@@ -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 { createIPNSRecord } from 'ipns'
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'

describe('republishRecord', () => {
let testCid: CID
let rsaKey: PrivateKey
let rsaRecord: IPNSRecord
let ed25519Key: PrivateKey
let ed25519Record: IPNSRecord
let name: IPNS
let customRouting: StubbedInstance<IPNSRouting>
let heliaRouting: StubbedInstance<Routing>
let dns: StubbedInstance<DNS>

beforeEach(async () => {
const datastore = new MemoryDatastore()
customRouting = stubInterface<IPNSRouting>()
customRouting.get.throws(new Error('Not found'))
heliaRouting = stubInterface<Routing>()

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)
Comment on lines +46 to +50
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to create all of these before each test? could we create them in the tests that use them?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a nit here, but testCid could probably be moved up into the describe block, and then the records and keys could be moved into individual tests

})

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)
})
})