diff --git a/packages/ipns/README.md b/packages/ipns/README.md index 1515785e9..81b4dae55 100644 --- a/packages/ipns/README.md +++ b/packages/ipns/README.md @@ -173,12 +173,15 @@ It is sometimes useful to be able to republish an existing IPNS record without needing the private key. This allows you to extend the availability of a record that was created elsewhere. +There should be only one republisher per IPNS key. Multiple machines +republishing the same key will conflict on sequence numbers and flood the +DHT with redundant writes. + ```TypeScript import { createHelia } from 'helia' -import { ipns, ipnsValidator } from '@helia/ipns' +import { ipns } from '@helia/ipns' import { delegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client' import { CID } from 'multiformats/cid' -import { multihashToIPNSRoutingKey, marshalIPNSRecord } from 'ipns' import { defaultLogger } from '@libp2p/logger' const helia = await createHelia() @@ -193,18 +196,13 @@ 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) +// republish to routing; throws RecordAlreadyPublishedError if a newer record +// is already resolvable — pass `force: true` only if you know no one else is +// republishing this key +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) ``` # Install diff --git a/packages/ipns/src/errors.ts b/packages/ipns/src/errors.ts index bb7426fb9..f351899fe 100644 --- a/packages/ipns/src/errors.ts +++ b/packages/ipns/src/errors.ts @@ -1,3 +1,5 @@ +import type { IPNSRecord } from 'ipns' + export class RecordsFailedValidationError extends Error { static name = 'RecordsFailedValidationError' @@ -47,3 +49,12 @@ export class RecordNotFoundError extends Error { static name = 'RecordNotFoundError' name = 'RecordNotFoundError' } + +export class RecordAlreadyPublishedError extends Error { + static name = 'RecordAlreadyPublishedError' + name = 'RecordAlreadyPublishedError' + + constructor (message: string, public readonly record: IPNSRecord) { + super(message) + } +} diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 4f9ae0a92..6d95756c9 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -144,12 +144,15 @@ * without needing the private key. This allows you to extend the availability * of a record that was created elsewhere. * + * There should be only one republisher per IPNS key. Multiple machines + * republishing the same key will conflict on sequence numbers and flood the + * DHT with redundant writes. + * * ```TypeScript * import { createHelia } from 'helia' - * import { ipns, ipnsValidator } from '@helia/ipns' + * import { ipns } from '@helia/ipns' * import { delegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client' * import { CID } from 'multiformats/cid' - * import { multihashToIPNSRoutingKey, marshalIPNSRecord } from 'ipns' * import { defaultLogger } from '@libp2p/logger' * * const helia = await createHelia() @@ -164,18 +167,13 @@ * }) * 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) + * // republish to routing; throws RecordAlreadyPublishedError if a newer record + * // is already resolvable — pass `force: true` only if you know no one else is + * // republishing this key + * 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,45 @@ export interface ResolveOptions extends AbortOptions, ProgressOptions { + /** + * A candidate IPNS record to use if no newer records are found + */ + record?: IPNSRecord + + /** + * Only republish to the local datastore, skipping the routers (default: false) + */ + offline?: boolean + + /** + * Skip resolution of latest record before republishing (default: false) + * + * It's important to resolve the latest record before republishing to routers + * Resolution should only be skipped when confident the latest record is already known + */ + skipResolution?: boolean + + /** + * Force the record to be republished even when already resolvable (default: false) + * + * It's important for republishing to be handled by a single machine + * Republishing should only be forced when confident the record is not being republished by other clients + */ + force?: boolean + + /** + * Automated record upkeep policy. (default: "refresh") + * + * Defaults to `refresh` since `republish()` cannot sign new records without + * the private key. + * + * - `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 +333,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 @@ -346,13 +399,26 @@ export interface IPNS { /** * Stop republishing of an IPNS record * - * This will delete the last signed IPNS record from the datastore, but the - * key will remain in the keychain. + * This will delete the last signed IPNS record from the datastore. If a key + * name is passed, the key will remain in the keychain. * * 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 + * + * Updates the record's upkeep policy to `options.upkeep` (default: 'refresh'). + * The background republisher will then keep the record alive accordingly. + * + * Use `unpublish` to stop republishing a key. + * + * @throws {NotFoundError} when no existing records can be found + * @throws {RecordAlreadyPublishedError} when a record is already published; pass `force: true` to bypass + */ + republish(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: RepublishOptions): Promise } export type { IPNSRouting } from './routing/index.ts' diff --git a/packages/ipns/src/ipns.ts b/packages/ipns/src/ipns.ts index d03e2cad7..2e45dfc66 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.ts' import { helia } from './routing/helia.ts' import { localStoreRouting } from './routing/local-store.ts' -import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, IPNSResolveResult, PublishOptions, ResolveOptions } from './index.ts' +import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, IPNSRepublishResult, IPNSResolveResult, PublishOptions, RepublishOptions, ResolveOptions } from './index.ts' import type { LocalStore } from './local-store.ts' import type { IPNSRouting } from './routing/index.ts' 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 1ce05a2a5..dde43dd0d 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.ts' import type { LocalStore } from '../local-store.ts' import type { IPNSRouting } from '../routing/index.ts' @@ -60,13 +62,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 }) })) } @@ -91,11 +94,13 @@ export class IPNSPublisher { } } - async unpublish (keyName: string, options?: AbortOptions): Promise { - const { publicKey } = await this.keychain.exportKey(keyName) - const digest = publicKey.toMultihash() - // @ts-expect-error @libp2p/peer-id needs new multiformats - 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 + } + + 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 4d95c43a2..fc80ae5f1 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -1,13 +1,22 @@ +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.ts' +import { RecordAlreadyPublishedError } from '../errors.ts' +import { ipnsSelector } from '../index.ts' +import { Upkeep } from '../pb/metadata.ts' +import { keyToMultihash, shouldRefresh, shouldRepublish } from '../utils.ts' +import type { IPNSRepublishResult, RepublishOptions } from '../index.ts' import type { LocalStore } from '../local-store.ts' +import type { IPNSResolver } from './resolver.ts' import type { IPNSRouting } from '../routing/index.ts' -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 +26,7 @@ export interface IPNSRepublisherComponents { export interface IPNSRepublisherInit { republishConcurrency?: number republishInterval?: number + resolver: IPNSResolver routers: IPNSRouting[] localStore: LocalStore } @@ -24,6 +34,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 +44,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 +90,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 +100,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 %m, skipping', routingKey) + continue + } + + if (metadata.upkeep === Upkeep.refresh) { + if (!shouldRefresh(created)) { + this.log.trace('skipping record %m within republish threshold', routingKey) + continue + } + keysToRepublish.push(routingKey) + continue + } + let ipnsRecord: IPNSRecord try { ipnsRecord = unmarshalIPNSRecord(record) @@ -97,7 +126,7 @@ export class IPNSRepublisher { // Only republish records that are within the DHT or record expiry threshold if (!shouldRepublish(ipnsRecord, created)) { - this.log.trace(`skipping record ${routingKey.toString()}within republish threshold`) + this.log.trace('skipping record %m within republish threshold', routingKey) continue } const sequenceNumber = ipnsRecord.sequence + 1n @@ -135,10 +164,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 !== 'RecordNotFoundError') { + throw err + } + } + try { + if (!options.skipResolution) { + // published record + const { record } = await this.resolver.resolve(key, { nocache: true }) + publishedRecord = record + records.push(record) + } + } catch (err: any) { + if (err.name !== 'RecordNotFoundError') { + 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 RecordAlreadyPublishedError('Record already published', publishedRecord) + } + + 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 3760b7b66..2a181d29e 100644 --- a/packages/ipns/src/ipns/resolver.ts +++ b/packages/ipns/src/ipns/resolver.ts @@ -179,6 +179,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 4963536e6..531f3a175 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..cfbe74714 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; // Publish a new record with a new validity (key must be in keychain) + refresh = 1; // Republish the record as is (while it is still valid) + none = 2; // 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 7a2ae15e6..9be4da9b3 100644 --- a/packages/ipns/src/pb/metadata.ts +++ b/packages/ipns/src/pb/metadata.ts @@ -1,10 +1,29 @@ -import { decodeMessage, encodeMessage, message, streamMessage } from 'protons-runtime' +import { decodeMessage, encodeMessage, enumeration, message, streamMessage } 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 = 2 +} + +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 @@ -79,6 +108,13 @@ export namespace IPNSPublishMetadata { } break } + case 3: { + yield { + field: `${prefix}.upkeep`, + value: Upkeep.codec().decode(reader) + } + break + } default: { reader.skipType(tag & 7) break @@ -101,6 +137,11 @@ export namespace IPNSPublishMetadata { value: number } + export interface IPNSPublishMetadataUpkeepFieldEvent { + field: '$.upkeep' + value: Upkeep + } + export function encode (obj: Partial): Uint8Array { return encodeMessage(obj, IPNSPublishMetadata.codec()) } @@ -109,7 +150,7 @@ export namespace IPNSPublishMetadata { return decodeMessage(buf, IPNSPublishMetadata.codec(), opts) } - export function stream (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions): Generator { + export function stream (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions): Generator { return streamMessage(buf, IPNSPublishMetadata.codec(), opts) } } diff --git a/packages/ipns/src/routing/index.ts b/packages/ipns/src/routing/index.ts index ba86f4ba5..192c3be81 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..10bf5ffaa 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,16 @@ export function isLibp2pCID (obj?: any): obj is CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId): MultihashDigest<0x00 | 0x12> { + if (isPublicKey(key) || isPeerId(key)) { + // @ts-expect-error @libp2p/peer-id needs new multiformats + return key.toMultihash() + } + + if (isLibp2pCID(key)) { + return key.multihash + } + + return key +} diff --git a/packages/ipns/test/publish.spec.ts b/packages/ipns/test/publish.spec.ts index cb4a98a28..5cb499b44 100644 --- a/packages/ipns/test/publish.spec.ts +++ b/packages/ipns/test/publish.spec.ts @@ -1,10 +1,13 @@ import { start, stop } from '@libp2p/interface' import { peerIdFromCID } from '@libp2p/peer-id' import { expect } from 'aegir/chai' +import { multihashToIPNSRoutingKey } from 'ipns' import { base36 } from 'multiformats/bases/base36' import { CID } from 'multiformats/cid' import Sinon from 'sinon' import { localStore } from '../src/local-store.ts' +import { IPNSPublishMetadata, Upkeep } from '../src/pb/metadata.ts' +import { dhtRoutingKey, ipnsMetadataKey } from '../src/utils.ts' import { createIPNS } from './fixtures/create-ipns.ts' import type { CreateIPNSResult } from './fixtures/create-ipns.ts' import type { IPNS } from '../src/ipns.ts' @@ -206,6 +209,17 @@ describe('publish', () => { expect(result.path).to.equal(path) }) + it('should round-trip the upkeep option through metadata', async () => { + const cases: Array<'republish' | 'refresh' | 'none'> = ['republish', 'refresh', 'none'] + for (const upkeep of cases) { + const { publicKey } = await name.publish(`test-key-upkeep-${upkeep}`, cid, { offline: true, upkeep }) + // @ts-expect-error @libp2p/crypto needs new multiformats + const routingKey = multihashToIPNSRoutingKey(publicKey.toMultihash()) + const metadataBuf = await result.datastore.get(ipnsMetadataKey(routingKey)) + expect(IPNSPublishMetadata.decode(metadataBuf).upkeep).to.equal(Upkeep[upkeep]) + } + }) + describe('localStore error handling', () => { it('should handle datastore errors during publish', async () => { await start(name) @@ -279,3 +293,84 @@ describe('publish', () => { }) }) }) + +describe('unpublish', () => { + let name: IPNS + let result: CreateIPNSResult + + beforeEach(async () => { + result = await createIPNS() + name = result.name + + await start(name) + }) + + afterEach(async () => { + await stop(name) + }) + + it('should unpublish by string keyName', async () => { + const keyName = 'test-key-unpublish-1' + const { publicKey } = await name.publish(keyName, cid, { offline: true }) + // @ts-expect-error @libp2p/crypto needs new multiformats + const routingKey = multihashToIPNSRoutingKey(publicKey.toMultihash()) + + expect(await result.datastore.has(dhtRoutingKey(routingKey))).to.be.true() + expect(await result.datastore.has(ipnsMetadataKey(routingKey))).to.be.true() + + await name.unpublish(keyName) + + expect(await result.datastore.has(dhtRoutingKey(routingKey))).to.be.false() + expect(await result.datastore.has(ipnsMetadataKey(routingKey))).to.be.false() + }) + + it('should unpublish by PublicKey', async () => { + const keyName = 'test-key-unpublish-2' + const { publicKey } = await name.publish(keyName, cid, { offline: true }) + // @ts-expect-error @libp2p/crypto needs new multiformats + const routingKey = multihashToIPNSRoutingKey(publicKey.toMultihash()) + + await name.unpublish(publicKey) + + expect(await result.datastore.has(dhtRoutingKey(routingKey))).to.be.false() + expect(await result.datastore.has(ipnsMetadataKey(routingKey))).to.be.false() + }) + + it('should unpublish by libp2p-key CID', async () => { + const keyName = 'test-key-unpublish-3' + const { publicKey } = await name.publish(keyName, cid, { offline: true }) + // @ts-expect-error @libp2p/crypto needs new multiformats + const routingKey = multihashToIPNSRoutingKey(publicKey.toMultihash()) + + // @ts-expect-error @libp2p/crypto needs new multiformats + await name.unpublish(publicKey.toCID()) + + expect(await result.datastore.has(dhtRoutingKey(routingKey))).to.be.false() + expect(await result.datastore.has(ipnsMetadataKey(routingKey))).to.be.false() + }) + + it('should unpublish by multihash', async () => { + const keyName = 'test-key-unpublish-4' + const { publicKey } = await name.publish(keyName, cid, { offline: true }) + // @ts-expect-error @libp2p/crypto needs new multiformats + const routingKey = multihashToIPNSRoutingKey(publicKey.toMultihash()) + + // @ts-expect-error @libp2p/crypto needs new multiformats + await name.unpublish(publicKey.toMultihash()) + + expect(await result.datastore.has(dhtRoutingKey(routingKey))).to.be.false() + expect(await result.datastore.has(ipnsMetadataKey(routingKey))).to.be.false() + }) + + it('should unpublish by PeerId', async () => { + const keyName = 'test-key-unpublish-5' + const { publicKey } = await name.publish(keyName, cid, { offline: true }) + // @ts-expect-error @libp2p/crypto needs new multiformats + const routingKey = multihashToIPNSRoutingKey(publicKey.toMultihash()) + + await name.unpublish(peerIdFromCID(publicKey.toCID())) + + expect(await result.datastore.has(dhtRoutingKey(routingKey))).to.be.false() + expect(await result.datastore.has(ipnsMetadataKey(routingKey))).to.be.false() + }) +}) diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 579d710ad..3d0cc275f 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.ts' +import { IPNSPublishMetadata, Upkeep } from '../src/pb/metadata.ts' +import { dhtRoutingKey, ipnsMetadataKey } from '../src/utils.ts' import { createIPNS } from './fixtures/create-ipns.ts' import type { IPNS } from '../src/ipns.ts' import type { CreateIPNSResult } from './fixtures/create-ipns.ts' @@ -140,6 +144,33 @@ 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) + // @ts-expect-error @libp2p/crypto needs new multiformats + 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 existing record was republished unchanged (refresh mode) + 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) + }) }) describe('record processing', () => { @@ -160,6 +191,27 @@ 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) + // @ts-expect-error @libp2p/crypto needs new multiformats + 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]) @@ -193,7 +245,8 @@ describe('republish', () => { await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: 24 * 60 * 60 * 1000, + upkeep: Upkeep.republish } }) @@ -206,6 +259,29 @@ 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) + // @ts-expect-error @libp2p/crypto needs new multiformats + 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 since the record is still within the refresh threshold + expect(putStubCustom.called).to.be.false() + expect(putStubHelia.called).to.be.false() + }) }) describe('TTL and lifetime', () => { @@ -409,6 +485,31 @@ describe('republish', () => { expect(putStubHelia.called).to.be.false() }) + it('should handle unable to find existing record to republish', async () => { + const key = await generateKeyPair('Ed25519') + // @ts-expect-error @libp2p/crypto needs new multiformats + 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 since the expired record cannot be resolved + 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') @@ -446,4 +547,320 @@ 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) + // @ts-expect-error @libp2p/crypto needs new multiformats + 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) + // @ts-expect-error @libp2p/crypto needs new multiformats + 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) + // @ts-expect-error @libp2p/crypto needs new multiformats + 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)) + + const metadataBuf = await result.datastore.get(ipnsMetadataKey(routingKey)) + const metadata = IPNSPublishMetadata.decode(metadataBuf) + expect(metadata.upkeep).to.equal(Upkeep.refresh) + }) + + it('should round-trip the upkeep option through metadata', async () => { + const cases: Array<'refresh' | 'none'> = ['refresh', 'none'] + for (const upkeep of cases) { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + // @ts-expect-error @libp2p/crypto needs new multiformats + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record)) + + await name.republish(multihashFromIPNSRoutingKey(routingKey), { upkeep }) + + const metadataBuf = await result.datastore.get(ipnsMetadataKey(routingKey)) + expect(IPNSPublishMetadata.decode(metadataBuf).upkeep).to.equal(Upkeep[upkeep]) + } + }) + + 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) + // @ts-expect-error @libp2p/crypto needs new multiformats + 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) + // @ts-expect-error @libp2p/crypto needs new multiformats + 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 not touch the network when offline and skipResolution are both set', 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) + // @ts-expect-error @libp2p/crypto needs new multiformats + 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 - should never be called + 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, skipResolution: true }) + + expect(storePutSpy.called).to.be.true() + expect(getStubCustom.called).to.be.false() + expect(getStubHelia.called).to.be.false() + expect(result.customRouting.put.called).to.be.false() + expect(result.heliaRouting.put.called).to.be.false() + expect(republished.record.sequence).to.equal(1n) + }) + + it('should skip router resolution and publish local record to routers when skipResolution is set', 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) + // @ts-expect-error @libp2p/crypto needs new multiformats + 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 router GETs - should never be called when skipResolution is set + 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, skipResolution: true }) + + expect(storePutSpy.called).to.be.true() + expect(getStubCustom.called).to.be.false() + expect(getStubHelia.called).to.be.false() + expect(result.customRouting.put.called).to.be.true() + expect(result.heliaRouting.put.called).to.be.true() + expect(republished.record.sequence).to.equal(1n) + }) + + it('should resolve the latest record and write it to the local store only when offline', 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) + // @ts-expect-error @libp2p/crypto needs new multiformats + 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 router GETs to return newer records + 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(3n) + }) + }) + + describe('error handling', () => { + it('should call options.onProgress on error', async () => { + const key = await generateKeyPair('Ed25519') + // @ts-expect-error @libp2p/crypto needs new multiformats + 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) + // @ts-expect-error @libp2p/crypto needs new multiformats + 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') + // @ts-expect-error @libp2p/crypto needs new multiformats + 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) + // @ts-expect-error @libp2p/crypto needs new multiformats + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + getStubCustom = sinon.stub().resolves(marshalIPNSRecord(record)) + // @ts-ignore + result.customRouting.get = getStubCustom + + const err = await expect(name.republish(multihashFromIPNSRoutingKey(routingKey))).to.be.rejectedWith('Record already published') + expect(marshalIPNSRecord(err.record)).to.deep.equal(marshalIPNSRecord(record)) + }) + + it('should rethrow non-RecordNotFoundError from router resolution', async () => { + const key = await generateKeyPair('Ed25519') + // @ts-expect-error @libp2p/crypto needs new multiformats + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // routers return garbage bytes, triggering RecordsFailedValidationError (not RecordNotFoundError) + getStubCustom = sinon.stub().resolves(new Uint8Array([1, 2, 3])) + getStubHelia = sinon.stub().resolves(new Uint8Array([1, 2, 3])) + // @ts-ignore + result.customRouting.get = getStubCustom + // @ts-ignore + result.heliaRouting.get = getStubHelia + + await expect(name.republish(multihashFromIPNSRoutingKey(routingKey))).to.be.rejectedWith('invalid') + }) + + it('should bypass the already-published check when force is true', async () => { + const key = await generateKeyPair('Ed25519') + const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) + // @ts-expect-error @libp2p/crypto needs new multiformats + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record)) + + getStubCustom = sinon.stub().resolves(marshalIPNSRecord(record)) + getStubHelia = sinon.stub().resolves(marshalIPNSRecord(record)) + // @ts-ignore + result.customRouting.get = getStubCustom + // @ts-ignore + result.heliaRouting.get = getStubHelia + + const republished = await name.republish(multihashFromIPNSRoutingKey(routingKey), { force: true }) + + expect(republished.record.sequence).to.equal(1n) + expect(result.customRouting.put.called).to.be.true() + expect(result.heliaRouting.put.called).to.be.true() + }) + }) + }) }) diff --git a/packages/ipns/test/resolve.spec.ts b/packages/ipns/test/resolve.spec.ts index 94fb4902b..12f37187a 100644 --- a/packages/ipns/test/resolve.spec.ts +++ b/packages/ipns/test/resolve.spec.ts @@ -112,11 +112,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()