diff --git a/packages/ipns/README.md b/packages/ipns/README.md index 1515785e9..9b8799d11 100644 --- a/packages/ipns/README.md +++ b/packages/ipns/README.md @@ -193,18 +193,12 @@ const delegatedClient = delegatedRoutingV1HttpApiClient({ }) const record = await delegatedClient.getIPNS(parsedCid) -const routingKey = multihashToIPNSRoutingKey(parsedCid.multihash) -const marshaledRecord = marshalIPNSRecord(record) - -// validate that they key corresponds to the record -await ipnsValidator(routingKey, marshaledRecord) - -// publish record to routing -await Promise.all( - name.routers.map(async r => { - await r.put(routingKey, marshaledRecord) - }) -) +// publish the latest existing record to routing +// use `options.force` if the record is already published +const { record: latestRecord } = await name.republish(parsedCid, { record }) + +// stop republishing a key +await name.unpublish(parsedCid) ``` # Install diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index c90f5a5eb..c99aef514 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -34,7 +34,11 @@ * value. * * ```TypeScript - * import { createHelia } from 'helia' + * import { createHelia } from 'helia'export interface IPNSRecordMetadata { + keyName: string + lifetime: number + upkeep: Upkeep +} * import { ipns } from '@helia/ipns' * import { unixfs } from '@helia/unixfs' * import { generateKeyPair } from '@libp2p/crypto/keys' @@ -164,18 +168,12 @@ * }) * const record = await delegatedClient.getIPNS(parsedCid) * - * const routingKey = multihashToIPNSRoutingKey(parsedCid.multihash) - * const marshaledRecord = marshalIPNSRecord(record) - * - * // validate that they key corresponds to the record - * await ipnsValidator(routingKey, marshaledRecord) + * // publish the latest existing record to routing + * // use `options.force` if the record is already published + * const { record: latestRecord } = await name.republish(parsedCid, { record }) * - * // publish record to routing - * await Promise.all( - * name.routers.map(async r => { - * await r.put(routingKey, marshaledRecord) - * }) - * ) + * // stop republishing a key + * await name.unpublish(parsedCid) * ``` */ @@ -206,6 +204,11 @@ export type ResolveProgressEvents = ProgressEvent<'ipns:resolve:success', IPNSRecord> | ProgressEvent<'ipns:resolve:error', Error> +export type RepublishProgressEvents = + ProgressEvent<'ipns:republish:start'> | + ProgressEvent<'ipns:republish:success', IPNSRecord> | + ProgressEvent<'ipns:republish:error', Error> + export type DatastoreProgressEvents = ProgressEvent<'ipns:routing:datastore:put'> | ProgressEvent<'ipns:routing:datastore:get'> | @@ -219,7 +222,7 @@ export interface PublishOptions extends AbortOptions, ProgressOptions { @@ -256,6 +263,33 @@ export interface ResolveOptions extends AbortOptions, ProgressOptions { + /** + * A candidate IPNS record to use if no newer records are found + */ + record?: IPNSRecord + + /** + * Initially only republish to a local datastore (default: false) + */ + offline?: boolean + + /** + * Force the record to be republished even if already resolvable + * + * @default false + */ + force?: boolean + + /** + * Automated record upkeep policy. (default: "refresh") + * + * - `refresh`: republish the existing record until it expires + * - `none`: disable automated publishing + */ + upkeep?: 'refresh' | 'none' +} + export interface ResolveResult { /** * The CID that was resolved @@ -287,6 +321,13 @@ export interface IPNSPublishResult { publicKey: PublicKey } +export interface IPNSRepublishResult { + /** + * The published record + */ + record: IPNSRecord +} + export interface IPNSResolver { /** * Accepts a libp2p public key, a CID with the libp2p-key codec and either the @@ -352,7 +393,16 @@ export interface IPNS { * Note that the record may still be resolved by other peers until it expires * or is no longer valid. */ - unpublish(keyName: string, options?: AbortOptions): Promise + unpublish(keyName: string | CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: AbortOptions): Promise + + /** + * Republish the latest known existing record to all routers + * + * This will automatically be done regularly unless `options.repeat` is false + * + * Use `unpublish` to stop republishing a key + */ + republish(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: RepublishOptions): Promise } export type { IPNSRouting } from './routing/index.js' diff --git a/packages/ipns/src/ipns.ts b/packages/ipns/src/ipns.ts index b1d8ff6d3..01d767949 100644 --- a/packages/ipns/src/ipns.ts +++ b/packages/ipns/src/ipns.ts @@ -5,7 +5,7 @@ import { IPNSResolver } from './ipns/resolver.ts' import { localStore } from './local-store.js' import { helia } from './routing/helia.js' import { localStoreRouting } from './routing/local-store.ts' -import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, IPNSResolveResult, PublishOptions, ResolveOptions } from './index.js' +import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, IPNSRepublishResult, IPNSResolveResult, PublishOptions, RepublishOptions, ResolveOptions } from './index.js' import type { LocalStore } from './local-store.js' import type { IPNSRouting } from './routing/index.js' import type { AbortOptions, PeerId, PublicKey, Startable } from '@libp2p/interface' @@ -34,13 +34,14 @@ export class IPNS implements IPNSInterface, Startable { routers: this.routers, localStore: this.localStore }) - this.republisher = new IPNSRepublisher(components, { + this.resolver = new IPNSResolver(components, { ...init, routers: this.routers, localStore: this.localStore }) - this.resolver = new IPNSResolver(components, { + this.republisher = new IPNSRepublisher(components, { ...init, + resolver: this.resolver, routers: this.routers, localStore: this.localStore }) @@ -81,7 +82,11 @@ export class IPNS implements IPNSInterface, Startable { return this.resolver.resolve(key, options) } - async unpublish (keyName: string, options?: AbortOptions): Promise { + async unpublish (keyName: string | CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: AbortOptions): Promise { return this.publisher.unpublish(keyName, options) } + + async republish (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: RepublishOptions = {}): Promise { + return this.republisher.republish(key, options) + } } diff --git a/packages/ipns/src/ipns/publisher.ts b/packages/ipns/src/ipns/publisher.ts index c1e19173f..9c13bf631 100644 --- a/packages/ipns/src/ipns/publisher.ts +++ b/packages/ipns/src/ipns/publisher.ts @@ -4,6 +4,8 @@ import { createIPNSRecord, marshalIPNSRecord, multihashToIPNSRoutingKey, unmarsh import { CID } from 'multiformats/cid' import { CustomProgressEvent } from 'progress-events' import { DEFAULT_LIFETIME_MS, DEFAULT_TTL_NS } from '../constants.ts' +import { Upkeep } from '../pb/metadata.ts' +import { keyToMultihash } from '../utils.ts' import type { IPNSPublishResult, PublishOptions } from '../index.js' import type { LocalStore } from '../local-store.js' import type { IPNSRouting } from '../routing/index.js' @@ -57,13 +59,14 @@ export class IPNSPublisher { const record = await createIPNSRecord(privKey, value, sequenceNumber, lifetime, { ...options, ttlNs }) const marshaledRecord = marshalIPNSRecord(record) + const metadata = { keyName, lifetime, upkeep: Upkeep[options.upkeep ?? 'republish'] } if (options.offline === true) { // only store record locally - await this.localStore.put(routingKey, marshaledRecord, { ...options, metadata: { keyName, lifetime } }) + await this.localStore.put(routingKey, marshaledRecord, { ...options, metadata }) } else { // publish record to routing (including the local store) await Promise.all(this.routers.map(async r => { - await r.put(routingKey, marshaledRecord, { ...options, metadata: { keyName, lifetime } }) + await r.put(routingKey, marshaledRecord, { ...options, metadata }) })) } @@ -88,10 +91,13 @@ export class IPNSPublisher { } } - async unpublish (keyName: string, options?: AbortOptions): Promise { - const { publicKey } = await this.keychain.exportKey(keyName) - const digest = publicKey.toMultihash() - const routingKey = multihashToIPNSRoutingKey(digest) + async unpublish (keyName: string | CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: AbortOptions): Promise { + if (typeof keyName === 'string') { + const { publicKey } = await this.keychain.exportKey(keyName) + keyName = publicKey.toMultihash() + } + + const routingKey = multihashToIPNSRoutingKey(keyToMultihash(keyName)) await this.localStore.delete(routingKey, options) } } diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 07ddd117e..60f4c9650 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -1,13 +1,21 @@ +import { NotFoundError } from '@libp2p/interface' import { Queue, repeatingTask } from '@libp2p/utils' -import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord } from 'ipns' +import { createIPNSRecord, marshalIPNSRecord, multihashFromIPNSRoutingKey, multihashToIPNSRoutingKey, unmarshalIPNSRecord } from 'ipns' +import { ipnsValidator } from 'ipns/validator' +import { CustomProgressEvent } from 'progress-events' import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_TTL_NS } from '../constants.ts' -import { shouldRepublish } from '../utils.js' +import { ipnsSelector } from '../index.ts' +import { Upkeep } from '../pb/metadata.ts' +import { keyToMultihash, shouldRefresh, shouldRepublish } from '../utils.js' +import type { IPNSRepublishResult, RepublishOptions } from '../index.ts' import type { LocalStore } from '../local-store.js' +import type { IPNSResolver } from './resolver.ts' import type { IPNSRouting } from '../routing/index.js' -import type { AbortOptions, ComponentLogger, Libp2p, Logger, PrivateKey } from '@libp2p/interface' +import type { AbortOptions, ComponentLogger, Libp2p, Logger, PeerId, PrivateKey, PublicKey } from '@libp2p/interface' import type { Keychain } from '@libp2p/keychain' import type { RepeatingTask } from '@libp2p/utils' import type { IPNSRecord } from 'ipns' +import type { CID, MultihashDigest } from 'multiformats/cid' export interface IPNSRepublisherComponents { logger: ComponentLogger @@ -17,6 +25,7 @@ export interface IPNSRepublisherComponents { export interface IPNSRepublisherInit { republishConcurrency?: number republishInterval?: number + resolver: IPNSResolver routers: IPNSRouting[] localStore: LocalStore } @@ -24,6 +33,7 @@ export interface IPNSRepublisherInit { export class IPNSRepublisher { public readonly routers: IPNSRouting[] private readonly localStore: LocalStore + private readonly resolver: IPNSResolver private readonly republishTask: RepeatingTask private readonly log: Logger private readonly keychain: Keychain @@ -33,6 +43,7 @@ export class IPNSRepublisher { constructor (components: IPNSRepublisherComponents, init: IPNSRepublisherInit) { this.log = components.logger.forComponent('helia:ipns') this.localStore = init.localStore + this.resolver = init.resolver this.keychain = components.libp2p.services.keychain this.republishConcurrency = init.republishConcurrency || DEFAULT_REPUBLISH_CONCURRENCY this.started = components.libp2p.status === 'started' @@ -78,6 +89,7 @@ export class IPNSRepublisher { try { const recordsToRepublish: Array<{ routingKey: Uint8Array, record: IPNSRecord }> = [] + const keysToRepublish: Array = [] // Find all records using the localStore.list method for await (const { routingKey, record, metadata, created } of this.localStore.list(options)) { @@ -87,6 +99,22 @@ export class IPNSRepublisher { this.log(`no metadata found for record ${routingKey.toString()}, skipping`) continue } + + if (metadata.upkeep === Upkeep.none) { + // Skip republishing, disabled for this record + this.log(`republishing is disabled for record ${routingKey.toString()}, skipping`) + continue + } + + if (metadata.upkeep === Upkeep.refresh) { + if (!shouldRefresh(created)) { + this.log.trace(`skipping record ${routingKey.toString()} within republish threshold`) + continue + } + keysToRepublish.push(routingKey) + continue + } + let ipnsRecord: IPNSRecord try { ipnsRecord = unmarshalIPNSRecord(record) @@ -135,10 +163,94 @@ export class IPNSRepublisher { } }, options) } + for (const routingKey of keysToRepublish) { + // resolve the latest record + let latestRecord: IPNSRecord + try { + const { record } = await this.resolver.resolve(multihashFromIPNSRoutingKey(routingKey)) + latestRecord = record + } catch (err: any) { + this.log.error('unable to find existing record to republish - %e', err) + continue + } + + // Add job to queue to republish the existing record to all routers + queue.add(async () => { + try { + await Promise.all( + this.routers.map(r => r.put(routingKey, marshalIPNSRecord(latestRecord), { ...options, overwrite: true })) + ) + } catch (err: any) { + this.log.error('error republishing existing record - %e', err) + } + }, options) + } } catch (err: any) { this.log.error('error during republish - %e', err) } await queue.onIdle(options) // Wait for all jobs to complete } + + async republish (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: RepublishOptions = {}): Promise { + const records: IPNSRecord[] = [] + let publishedRecord: IPNSRecord | null = null + const digest = keyToMultihash(key) + const routingKey = multihashToIPNSRoutingKey(digest) + + try { + // collect records for key + if (options.record != null) { + // user supplied record + await ipnsValidator(routingKey, marshalIPNSRecord(options.record)) + records.push(options.record) + } + try { + // local record + const { record } = await this.resolver.resolve(key, { offline: true }) + records.push(record) + } catch (err: any) { + if (err.name !== 'NotFoundError') { + throw err + } + } + try { + if (options.offline !== true) { + // published record + const { record } = await this.resolver.resolve(key, { nocache: true }) + publishedRecord = record + records.push(record) + } + } catch (err: any) { + if (err.name !== 'NotFoundError') { + throw err + } + } + if (records.length === 0) { + throw new NotFoundError('Found no existing records to republish') + } + + // check if record is already published + if (options.force !== true && publishedRecord != null) { + throw new Error('Record already published') + } + + const selectedRecord = records[ipnsSelector(routingKey, records.map(marshalIPNSRecord))] + const marshaledRecord = marshalIPNSRecord(selectedRecord) + + const metadata = { upkeep: Upkeep[options.upkeep ?? 'refresh'] } + if (options.offline) { + await this.localStore.put(routingKey, marshaledRecord, { ...options, overwrite: true, metadata }) + } else { + await Promise.all( + this.routers.map(r => r.put(routingKey, marshaledRecord, { ...options, overwrite: true, metadata })) + ) + } + + return { record: selectedRecord } + } catch (err: any) { + options.onProgress?.(new CustomProgressEvent('ipns:republish:error', err)) + throw err + } + } } diff --git a/packages/ipns/src/ipns/resolver.ts b/packages/ipns/src/ipns/resolver.ts index bc7a22f87..9036b3b07 100644 --- a/packages/ipns/src/ipns/resolver.ts +++ b/packages/ipns/src/ipns/resolver.ts @@ -178,6 +178,11 @@ export class IPNSResolver { this.routers.map(async (router) => { let record: Uint8Array + // skip checking cache when nocache is true + if (String(router) === 'LocalStoreRouting()' && options.nocache === true) { + return + } + try { record = await router.get(routingKey, { ...options, diff --git a/packages/ipns/src/local-store.ts b/packages/ipns/src/local-store.ts index 4d325a8aa..811fb7cdc 100644 --- a/packages/ipns/src/local-store.ts +++ b/packages/ipns/src/local-store.ts @@ -54,18 +54,20 @@ export function localStore (datastore: Datastore, log: Logger): LocalStore { try { const key = dhtRoutingKey(routingKey) - // don't overwrite existing, identical records as this will affect the - // TTL - try { - const existingBuf = await datastore.get(key) - const existingRecord = Record.deserialize(existingBuf) - - if (uint8ArrayEquals(existingRecord.value, marshalledRecord)) { - return - } - } catch (err: any) { - if (err.name !== 'NotFoundError') { - throw err + if (options.overwrite !== true) { + // don't overwrite existing, identical records as this will affect the + // TTL + try { + const existingBuf = await datastore.get(key) + const existingRecord = Record.deserialize(existingBuf) + + if (uint8ArrayEquals(existingRecord.value, marshalledRecord)) { + return + } + } catch (err: any) { + if (err.name !== 'NotFoundError') { + throw err + } } } diff --git a/packages/ipns/src/pb/metadata.proto b/packages/ipns/src/pb/metadata.proto index 143d9445b..3f61277aa 100644 --- a/packages/ipns/src/pb/metadata.proto +++ b/packages/ipns/src/pb/metadata.proto @@ -1,9 +1,19 @@ syntax = "proto3"; +enum Upkeep { + republish = 0; // Republish the record with an updated ttl (key must be in keychain) + refresh = 1; // Republish the record as is (while it is still valid) + none = 3; // Disables automated republishing for the record +} + message IPNSPublishMetadata { // The name of the key that was used to publish the record string keyName = 1; // Lifetime is the duration of the signature validity in milliseconds + // Relevant for Upkeep.REPUBLISH uint32 lifetime = 2; + + // Republishing policy + Upkeep upkeep = 3; } diff --git a/packages/ipns/src/pb/metadata.ts b/packages/ipns/src/pb/metadata.ts index c4d25d59d..f95d463f4 100644 --- a/packages/ipns/src/pb/metadata.ts +++ b/packages/ipns/src/pb/metadata.ts @@ -1,10 +1,29 @@ -import { decodeMessage, encodeMessage, message } from 'protons-runtime' +import { decodeMessage, encodeMessage, enumeration, message } from 'protons-runtime' import type { Codec, DecodeOptions } from 'protons-runtime' import type { Uint8ArrayList } from 'uint8arraylist' +export enum Upkeep { + republish = 'republish', + refresh = 'refresh', + none = 'none' +} + +enum __UpkeepValues { + republish = 0, + refresh = 1, + none = 3 +} + +export namespace Upkeep { + export const codec = (): Codec => { + return enumeration(__UpkeepValues) + } +} + export interface IPNSPublishMetadata { keyName: string lifetime: number + upkeep: Upkeep } export namespace IPNSPublishMetadata { @@ -27,13 +46,19 @@ export namespace IPNSPublishMetadata { w.uint32(obj.lifetime) } + if (obj.upkeep != null && __UpkeepValues[obj.upkeep] !== 0) { + w.uint32(24) + Upkeep.codec().encode(obj.upkeep, w) + } + if (opts.lengthDelimited !== false) { w.ldelim() } }, (reader, length, opts = {}) => { const obj: any = { keyName: '', - lifetime: 0 + lifetime: 0, + upkeep: Upkeep.republish } const end = length == null ? reader.len : reader.pos + length @@ -50,6 +75,10 @@ export namespace IPNSPublishMetadata { obj.lifetime = reader.uint32() break } + case 3: { + obj.upkeep = Upkeep.codec().decode(reader) + break + } default: { reader.skipType(tag & 7) break diff --git a/packages/ipns/src/routing/index.ts b/packages/ipns/src/routing/index.ts index 4221faf4d..a9facf2b8 100644 --- a/packages/ipns/src/routing/index.ts +++ b/packages/ipns/src/routing/index.ts @@ -6,7 +6,8 @@ import type { AbortOptions } from '@libp2p/interface' import type { ProgressOptions } from 'progress-events' export interface PutOptions extends AbortOptions, ProgressOptions { - metadata?: IPNSPublishMetadata + metadata?: Partial + overwrite?: boolean } export interface GetOptions extends AbortOptions, ProgressOptions { diff --git a/packages/ipns/src/utils.ts b/packages/ipns/src/utils.ts index 32e9aaa20..62839bb49 100644 --- a/packages/ipns/src/utils.ts +++ b/packages/ipns/src/utils.ts @@ -1,7 +1,8 @@ -import { InvalidParametersError } from '@libp2p/interface' +import { InvalidParametersError, isPeerId, isPublicKey } from '@libp2p/interface' import { Key } from 'interface-datastore' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { DHT_EXPIRY_MS, REPUBLISH_THRESHOLD } from './constants.ts' +import type { PeerId, PublicKey } from '@libp2p/interface' import type { IPNSRecord } from 'ipns' import type { CID } from 'multiformats/cid' import type { MultihashDigest } from 'multiformats/hashes/interface' @@ -43,11 +44,9 @@ export function ipnsMetadataKey (key: Uint8Array): Key { export function shouldRepublish (ipnsRecord: IPNSRecord, created: Date): boolean { const now = Date.now() - const dhtExpiry = created.getTime() + DHT_EXPIRY_MS const recordExpiry = new Date(ipnsRecord.validity).getTime() - // If the DHT expiry is within the threshold, republish it - if (dhtExpiry - now < REPUBLISH_THRESHOLD) { + if (shouldRefresh(created)) { return true } @@ -59,6 +58,18 @@ export function shouldRepublish (ipnsRecord: IPNSRecord, created: Date): boolean return false } +export function shouldRefresh (created: Date): boolean { + const now = Date.now() + const dhtExpiry = created.getTime() + DHT_EXPIRY_MS + + // If the DHT expiry is within the threshold, republish it + if (dhtExpiry - now < REPUBLISH_THRESHOLD) { + return true + } + + return false +} + function isCID (obj?: any): obj is CID { return obj?.asCID === obj } @@ -78,3 +89,15 @@ export function isLibp2pCID (obj?: any): obj is CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId): MultihashDigest<0x00 | 0x012> { + if (isPublicKey(key) || isPeerId(key)) { + return key.toMultihash() + } + + if (isLibp2pCID(key)) { + return key.multihash + } + + return key +} diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 9ce5d6e6b..e89c30046 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -1,10 +1,14 @@ import { generateKeyPair } from '@libp2p/crypto/keys' import { start, stop } from '@libp2p/interface' +import { Record } from '@libp2p/kad-dht' import { expect } from 'aegir/chai' -import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord, multihashToIPNSRoutingKey } from 'ipns' +import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord, multihashToIPNSRoutingKey, multihashFromIPNSRoutingKey } from 'ipns' import { CID } from 'multiformats/cid' import sinon from 'sinon' +import { REPUBLISH_THRESHOLD } from '../src/constants.ts' import { localStore } from '../src/local-store.js' +import { IPNSPublishMetadata, Upkeep } from '../src/pb/metadata.ts' +import { dhtRoutingKey, ipnsMetadataKey } from '../src/utils.ts' import { createIPNS } from './fixtures/create-ipns.js' import type { IPNS } from '../src/ipns.js' import type { CreateIPNSResult } from './fixtures/create-ipns.js' @@ -137,6 +141,32 @@ describe('republish', () => { const republishedRecord = unmarshalIPNSRecord(callArgs[1]) expect(republishedRecord.sequence).to.equal(2n) // Incremented from 1n }) + + it('should republish existing records', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // create a dht record with a timeReceived < now - REPUBLISH_THRESHOLD + const timeReceived = new Date(Date.now() - REPUBLISH_THRESHOLD - 60 * 60 * 1000) + const dhtRecord = new Record(routingKey, marshalIPNSRecord(record), timeReceived) + + // Store the dht record and metadata in the real datastore + await result.datastore.put(dhtRoutingKey(routingKey), dhtRecord.serialize()) + await result.datastore.put(ipnsMetadataKey(routingKey), IPNSPublishMetadata.encode({ upkeep: Upkeep.refresh })) + + // Start publishing + await start(name) + await waitForStubCall(putStubCustom) + + // Verify the record was republished with incremented sequence + expect(putStubCustom.called).to.be.true() + const callArgs = putStubCustom.firstCall.args + expect(callArgs[0]).to.deep.equal(routingKey) + + const republishedRecord = unmarshalIPNSRecord(callArgs[1]) + expect(republishedRecord.sequence).to.equal(1n) // Incremented from 1n + }) }) describe('record processing', () => { @@ -156,6 +186,26 @@ describe('republish', () => { expect(putStubCustom.called).to.be.false() }) + it('should skip records with republishing disabled', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record without metadata (simulate old records) + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record), { + metadata: { + upkeep: Upkeep.none + } + }) + + await start(name) + await new Promise(resolve => setTimeout(resolve, 20)) + + // Verify no records were republished + expect(putStubCustom.called).to.be.false() + }) + it('should handle invalid records gracefully', async () => { const routingKey = new Uint8Array([1, 2, 3, 4]) @@ -201,6 +251,28 @@ describe('republish', () => { const republishedRecord = unmarshalIPNSRecord(callArgs[1]) expect(republishedRecord.sequence).to.equal(6n) // Incremented from 5n }) + + it('should skip republishing existing records created within republish threshold', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record in the real datastore using the localStore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record), { + metadata: { + upkeep: Upkeep.refresh + } + }) + + // Start publishing + await start(name) + await new Promise(resolve => setTimeout(resolve, 20)) + + // Should not republish due to unmarshal error + expect(putStubCustom.called).to.be.false() + expect(putStubHelia.called).to.be.false() + }) }) describe('TTL and lifetime', () => { @@ -399,6 +471,30 @@ describe('republish', () => { expect(putStubHelia.called).to.be.false() }) + it('should handle unable to find existing record to republish', async () => { + const key = await generateKeyPair('Ed25519') + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // create an expired record + const record = await createIPNSRecord(key, testCid, 1n, 0) + + // Store the record in the real datastore using the localStore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record), { + metadata: { + upkeep: Upkeep.refresh + } + }) + + // Start publishing + await start(name) + await new Promise(resolve => setTimeout(resolve, 20)) + + // Should not republish due to unmarshal error + expect(putStubCustom.called).to.be.false() + expect(putStubHelia.called).to.be.false() + }) + it('should continue republishing other records when one record fails', async () => { const key1 = await generateKeyPair('Ed25519') const key2 = await generateKeyPair('Ed25519') @@ -434,4 +530,184 @@ describe('republish', () => { expect(putStubHelia.called).to.be.true() }) }) + + describe('republish', () => { + let getStubCustom: sinon.SinonStub + let getStubHelia: sinon.SinonStub + + beforeEach(async () => { + result = await createIPNS() + name = result.name + + // Stub the routers by default to reject + getStubCustom = sinon.stub().rejects() + getStubHelia = sinon.stub().rejects() + // @ts-ignore + result.customRouting.get = getStubCustom + // @ts-ignore + result.heliaRouting.get = getStubHelia + }) + + describe('basic functionality', () => { + it('should lookup latest record in cache and in routers', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record in the real datastore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record)) + + // @ts-ignore + const storeGetSpy = sinon.spy(name.localStore, 'get') + + await name.republish(multihashFromIPNSRoutingKey(routingKey)) + + expect(storeGetSpy.called).to.be.true() + expect(getStubCustom.called).to.be.true() + expect(getStubHelia.called).to.be.true() + }) + + it('should use options.record if necessary', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + const republished = await name.republish(multihashFromIPNSRoutingKey(routingKey), { record }) + + expect(republished.record).to.equal(record) + }) + + it('should write to metadata', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record in the real datastore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record)) + + await name.republish(multihashFromIPNSRoutingKey(routingKey)) + + expect(() => result.datastore.get(ipnsMetadataKey(routingKey))).to.not.throw() + }) + + it('should overwrite the created date on the dht record', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record in the real datastore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record)) + + const { created } = await store.get(routingKey) + + await name.republish(multihashFromIPNSRoutingKey(routingKey)) + + const { created: newCreated } = await store.get(routingKey) + + expect(newCreated).does.not.equal(created) + }) + + it('should publish the latest record record on all routers and return the published record', async () => { + const key = await generateKeyPair('Ed25519') + const record1 = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const record2 = await createIPNSRecord(key, testCid, 2n, 24 * 60 * 60 * 1000) + const record3 = await createIPNSRecord(key, testCid, 3n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record in the real datastore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record1)) + + // Stub the routers by default to reject + getStubCustom = sinon.stub().resolves(marshalIPNSRecord(record2)) + getStubHelia = sinon.stub().resolves(marshalIPNSRecord(record3)) + // @ts-ignore + result.customRouting.get = getStubCustom + // @ts-ignore + result.heliaRouting.get = getStubHelia + + // @ts-ignore + const storePutSpy = sinon.spy(name.localStore, 'put') + + const republished = await name.republish(multihashFromIPNSRoutingKey(routingKey), { force: true }) + + expect(storePutSpy.called).to.be.true() + expect(result.customRouting.put.called).to.be.true() + expect(result.heliaRouting.put.called).to.be.true() + expect(republished.record.sequence).to.equal(3n) + }) + + it('should publish the local record record offline and return the local record', async () => { + const key = await generateKeyPair('Ed25519') + const record1 = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const record2 = await createIPNSRecord(key, testCid, 2n, 24 * 60 * 60 * 1000) + const record3 = await createIPNSRecord(key, testCid, 3n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // Store the record in the real datastore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record1)) + + // Stub the routers by default to reject + getStubCustom = sinon.stub().resolves(marshalIPNSRecord(record2)) + getStubHelia = sinon.stub().resolves(marshalIPNSRecord(record3)) + // @ts-ignore + result.customRouting.get = getStubCustom + // @ts-ignore + result.heliaRouting.get = getStubHelia + + // @ts-ignore + const storePutSpy = sinon.spy(name.localStore, 'put') + + const republished = await name.republish(multihashFromIPNSRoutingKey(routingKey), { force: true, offline: true }) + + expect(storePutSpy.called).to.be.true() + expect(result.customRouting.put.called).to.be.false() + expect(result.heliaRouting.put.called).to.be.false() + expect(republished.record.sequence).to.equal(1n) + }) + }) + + describe('error handling', () => { + it('should call options.onProgress on error', async () => { + const key = await generateKeyPair('Ed25519') + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + const onProgress = sinon.stub().resolves() + + await expect(name.republish(multihashFromIPNSRoutingKey(routingKey), { onProgress })).to.be.rejected() + expect(onProgress.called).to.be.true() + }) + + it('should throw if options.record is invalid', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, -1) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + await expect(name.republish(multihashFromIPNSRoutingKey(routingKey), { record })).to.be.rejectedWith('record has expired') + }) + + it('should throw if no existing records were found to republish', async () => { + const key = await generateKeyPair('Ed25519') + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + await expect(name.republish(multihashFromIPNSRoutingKey(routingKey))).to.be.rejectedWith('Found no existing records to republish') + }) + + it('should throw if the record is already published', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + getStubCustom = sinon.stub().resolves(marshalIPNSRecord(record)) + // @ts-ignore + result.customRouting.get = getStubCustom + + await expect(name.republish(multihashFromIPNSRoutingKey(routingKey))).to.be.rejectedWith('Record already published') + }) + }) + }) }) diff --git a/packages/ipns/test/resolve.spec.ts b/packages/ipns/test/resolve.spec.ts index d178f9d02..8ee5ae24c 100644 --- a/packages/ipns/test/resolve.spec.ts +++ b/packages/ipns/test/resolve.spec.ts @@ -111,11 +111,16 @@ describe('resolve', () => { heliaRouting.get.resolves(marshalIPNSRecord(record)) + // @ts-ignore + const storeGetSpy = Sinon.spy(name.localStore, 'get') const resolvedValue = await name.resolve(publicKey, { nocache: true }) expect(resolvedValue.cid.toString()).to.equal(cid.toV1().toString()) + // check that localStore.get not called + expect(storeGetSpy.called).to.be.false() + expect(heliaRouting.get.called).to.be.true() expect(customRouting.get.called).to.be.true()