diff --git a/packages/ipns/README.md b/packages/ipns/README.md index 36f91163d..7bfc41cb3 100644 --- a/packages/ipns/README.md +++ b/packages/ipns/README.md @@ -171,6 +171,44 @@ for await (const result of name.resolve(publicKey)) { } ``` +## Example - Republishing an existing IPNS record + +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 } from '@helia/ipns' +import { delegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client' +import { defaultLogger } from 'birnam' +import { CID } from 'multiformats/cid' + +const helia = await createHelia() +const name = ipns(helia) + +const ipnsName = 'k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4' +const parsedCid: CID = CID.parse(ipnsName) +const delegatedClient = delegatedRoutingV1HttpApiClient({ + url: 'https://delegated-ipfs.dev' +})({ + logger: defaultLogger() +}) +const record = await delegatedClient.getIPNS(parsedCid) + +// 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 }) + +// stop republishing a key +await name.unpublish(parsedCid) +``` + # Install ```console diff --git a/packages/ipns/package.json b/packages/ipns/package.json index 286a0086a..9c394e88a 100644 --- a/packages/ipns/package.json +++ b/packages/ipns/package.json @@ -88,6 +88,7 @@ "cborg": "^5.1.1", "delay": "^7.0.0", "interface-datastore": "^10.0.1", + "it-last": "^3.0.11", "multiformats": "^14.0.0", "progress-events": "^1.1.0", "protons-runtime": "^7.0.0", @@ -102,7 +103,6 @@ "aegir": "^48.0.11", "datastore-core": "^12.0.1", "it-drain": "^3.0.12", - "it-last": "^3.0.11", "protons": "^9.0.1", "sinon": "^22.0.0", "sinon-ts": "^2.0.0" diff --git a/packages/ipns/src/errors.ts b/packages/ipns/src/errors.ts index 5bedf2eb8..d4c560cc8 100644 --- a/packages/ipns/src/errors.ts +++ b/packages/ipns/src/errors.ts @@ -1,3 +1,5 @@ +import type { IPNSRecord } from './records.ts' + export class RecordsFailedValidationError extends Error { static name = 'RecordsFailedValidationError' name = 'RecordsFailedValidationError' @@ -28,6 +30,18 @@ export class RecordNotFoundError extends Error { name = 'RecordNotFoundError' } +export class RecordAlreadyPublishedError extends Error { + static name = 'RecordAlreadyPublishedError' + name = 'RecordAlreadyPublishedError' + record: IPNSRecord + + constructor (message: string, record: IPNSRecord) { + super(message) + + this.record = record + } +} + export class SignatureCreationError extends Error { static name = 'SignatureCreationError' name = 'SignatureCreationError' diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 3f9fc8103..3832f5caf 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -141,6 +141,44 @@ * console.info(result.record.value) * } * ``` + * + * @example Republishing an existing IPNS record + * + * 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 } from '@helia/ipns' + * import { delegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client' + * import { defaultLogger } from 'birnam' + * import { CID } from 'multiformats/cid' + * + * const helia = await createHelia() + * const name = ipns(helia) + * + * const ipnsName = 'k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4' + * const parsedCid: CID = CID.parse(ipnsName) + * const delegatedClient = delegatedRoutingV1HttpApiClient({ + * url: 'https://delegated-ipfs.dev' + * })({ + * logger: defaultLogger() + * }) + * const record = await delegatedClient.getIPNS(parsedCid) + * + * // 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 }) + * + * // stop republishing a key + * await name.unpublish(parsedCid) + * ``` */ import { CID } from 'multiformats/cid' @@ -169,6 +207,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'> | @@ -184,7 +227,7 @@ export interface PublishOptions extends AbortOptions, ProgressOptions { @@ -227,6 +276,58 @@ export interface ResolveOptions extends AbortOptions, ProgressOptions { + /** + * A candidate IPNS record (either as an object or pre-marshaled) to use if no + * newer records are found + */ + record?: IPNSRecord | Uint8Array + + /** + * Only republish to the local datastore, skipping the routers + * + * @default false + */ + offline?: boolean + + /** + * Skip resolution of latest record before republishing. + * + * 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. + * + * @default false + */ + skipResolution?: boolean + + /** + * Force the record to be republished even when already resolvable. + * + * 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 + * + * @default false + */ + force?: boolean + + /** + * Automated record upkeep policy. + * + * 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 + * + * @default 'refresh' + */ + upkeep?: 'refresh' | 'none' +} + export interface ResolveResult { /** * The resolved record @@ -251,6 +352,13 @@ export interface PublishResult { publicKey: PublicKey } +export interface RepublishResult { + /** + * The published record + */ + record: IPNSRecord +} + export interface IPNSResolver { /** * Accepts a libp2p public key, a CID with the libp2p-key codec and either the @@ -312,12 +420,27 @@ export interface IPNS { /** * Stop republishing of an IPNS record * - * This will delete the last signed IPNS record from the datastore. + * 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 otherwise no longer valid. */ - unpublish(keyName: string, options?: AbortOptions): Promise + unpublish(key: CID | PublicKey | MultihashDigest | string, options?: AbortOptions): Promise + + /** + * Republish the latest known existing record to all routers + * + * Updates the record's upkeep policy to `options.upkeep`. + * + * 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 | string, 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 5c14aee86..bbc4cf263 100644 --- a/packages/ipns/src/ipns.ts +++ b/packages/ipns/src/ipns.ts @@ -6,9 +6,9 @@ import { localStore } from './local-store.ts' import { helia } from './routing/helia.ts' import { localStoreRouting } from './routing/local-store.ts' import { ipnsSelector } from './selector.ts' -import { normalizeKey, normalizeValue, unmarshalIPNSRecord } from './utils.ts' +import { normalizeKey, normalizeKeyName, normalizeValue, unmarshalIPNSRecord } from './utils.ts' import { ipnsValidator } from './validator.ts' -import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, PublishResult, PublishOptions, ResolveOptions, ResolveResult } from './index.ts' +import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, RepublishResult, PublishOptions, PublishResult, RepublishOptions, ResolveOptions, ResolveResult } from './index.ts' import type { LocalStore } from './local-store.ts' import type { IPNSRouting } from './routing/index.ts' import type { PublicKey } from '@helia/interface' @@ -41,13 +41,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 }) @@ -101,18 +102,28 @@ export class IPNS implements IPNSInterface, Startable { } async publish (keyName: string, value: PublicKey | CID | MultihashDigest | string, options: PublishOptions = {}): Promise { - return this.publisher.publish(keyName, normalizeValue(value), options) + value = normalizeValue(value) + + return this.publisher.publish(keyName, value, options) } - async * resolve (key: PublicKey | CID | MultihashDigest | string, options: ResolveOptions = {}): AsyncGenerator { - const { digest } = normalizeKey(key) + async * resolve (name: CID | PublicKey | MultihashDigest | string, options: ResolveOptions = {}): AsyncGenerator { + const { digest } = normalizeKey(name) yield * this.resolver.resolve(digest, options) } - async unpublish (keyName: string, options?: AbortOptions): Promise { + async unpublish (keyName: CID | PublicKey | MultihashDigest | string, options?: AbortOptions): Promise { + keyName = normalizeKeyName(keyName) + return this.publisher.unpublish(keyName, options) } + + async republish (keyName: CID | PublicKey | MultihashDigest | string, options: RepublishOptions = {}): Promise { + keyName = normalizeKeyName(keyName) + + return this.republisher.republish(keyName, options) + } } function isLibp2p (obj?: any): obj is Libp2p { diff --git a/packages/ipns/src/ipns/publisher.ts b/packages/ipns/src/ipns/publisher.ts index 4cc768972..b2c599ea3 100644 --- a/packages/ipns/src/ipns/publisher.ts +++ b/packages/ipns/src/ipns/publisher.ts @@ -1,6 +1,7 @@ import { base36 } from 'multiformats/bases/base36' import { CustomProgressEvent } from 'progress-events' import { DEFAULT_LIFETIME_MS, DEFAULT_TTL_NS } from '../constants.ts' +import { Upkeep } from '../pb/metadata.ts' import { createIPNSRecord } from '../records.ts' import { marshalIPNSRecord, multihashToIPNSRoutingKey, unmarshalIPNSRecord } from '../utils.ts' import type { PublishResult, PublishOptions } from '../index.ts' @@ -9,6 +10,7 @@ import type { IPNSRouting } from '../routing/index.ts' import type { Keychain, PrivateKey } from '@helia/interface' import type { AbortOptions, ComponentLogger } from '@libp2p/interface' import type { Datastore } from 'interface-datastore' +import type { MultihashDigest } from 'multiformats' export interface IPNSPublisherComponents { datastore: Datastore @@ -52,13 +54,14 @@ export class IPNSPublisher { const record = await createIPNSRecord(key, 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 }) })) } @@ -93,10 +96,13 @@ export class IPNSPublisher { } } - async unpublish (keyName: string, options?: AbortOptions): Promise { - const key = await this.keychain.exportKey(keyName, options) - const digest = key.publicKey.toMultihash() - const routingKey = multihashToIPNSRoutingKey(digest) + async unpublish (keyName: string | MultihashDigest, options?: AbortOptions): Promise { + if (typeof keyName === 'string') { + const key = await this.keychain.exportKey(keyName, options) + keyName = key.publicKey.toMultihash() + } + + const routingKey = multihashToIPNSRoutingKey(keyName) await this.localStore.delete(routingKey, options) } } diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index e5d95e922..7509d409c 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -1,14 +1,23 @@ +import { NotFoundError } from '@libp2p/interface' import { Queue, repeatingTask } from '@libp2p/utils' +import last from 'it-last' +import { CustomProgressEvent } from 'progress-events' import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_TTL_NS } from '../constants.ts' +import { RecordAlreadyPublishedError } from '../errors.ts' +import { Upkeep } from '../pb/metadata.ts' import { createIPNSRecord } from '../records.ts' -import { marshalIPNSRecord, unmarshalIPNSRecord } from '../utils.ts' +import { ipnsSelector } from '../selector.ts' +import { marshalIPNSRecord, multihashFromIPNSRoutingKey, multihashToIPNSRoutingKey, shouldRefresh, unmarshalIPNSRecord } from '../utils.ts' import { shouldRepublish } from '../utils.ts' -import type { IPNSRecord } from '../index.ts' +import { ipnsValidator } from '../validator.ts' +import type { IPNSRecord, RepublishOptions, RepublishResult } 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 { Keychain, PrivateKey } from '@helia/interface' import type { AbortOptions, ComponentLogger, Logger } from '@libp2p/interface' import type { RepeatingTask } from '@libp2p/utils' +import type { MultihashDigest } from 'multiformats' export interface IPNSRepublisherComponents { logger: ComponentLogger @@ -18,6 +27,7 @@ export interface IPNSRepublisherComponents { export interface IPNSRepublisherInit { republishConcurrency?: number republishInterval?: number + resolver: IPNSResolver routers: IPNSRouting[] localStore: LocalStore } @@ -25,6 +35,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 @@ -34,6 +45,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.keychain this.republishConcurrency = init.republishConcurrency || DEFAULT_REPUBLISH_CONCURRENCY this.started = false @@ -79,6 +91,7 @@ export class IPNSRepublisher { try { const recordsToRepublish: Array<{ routingKey: Uint8Array, record: IPNSRecord }> = [] + const keysToRepublish: Array = [] let listed = 0 // Find all records using the localStore.list method @@ -91,7 +104,24 @@ export class IPNSRepublisher { this.log('no metadata found for record %b, skipping', routingKey) 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 = await unmarshalIPNSRecord(routingKey, record, this.keychain, options) } catch (err: any) { @@ -104,6 +134,7 @@ export class IPNSRepublisher { this.log.trace('skipping record %b within republish threshold', routingKey) continue } + const sequenceNumber = ipnsRecord.sequence + 1n const ttlNs = ipnsRecord.ttl ?? DEFAULT_TTL_NS let privKey: PrivateKey @@ -120,6 +151,7 @@ export class IPNSRepublisher { ...options, ttlNs }) + recordsToRepublish.push({ routingKey, record: updatedRecord @@ -138,6 +170,7 @@ export class IPNSRepublisher { queue.add(async () => { try { const marshaledRecord = marshalIPNSRecord(record) + await Promise.all( this.routers.map(r => r.put(routingKey, marshaledRecord, options)) ) @@ -146,10 +179,148 @@ export class IPNSRepublisher { } }, options) } + + for (const routingKey of keysToRepublish) { + // resolve the latest record + let latestRecord: IPNSRecord + try { + const result = await last(this.resolver.resolve(multihashFromIPNSRoutingKey(routingKey))) + + if (result == null) { + throw new Error('Could not resolve routing key, no records found') + } + + latestRecord = result.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: string | MultihashDigest, options: RepublishOptions = {}): Promise { + const records: IPNSRecord[] = [] + let routingRecord: IPNSRecord | undefined + let localRecord: IPNSRecord | undefined + + if (typeof key === 'string') { + const privateKey = await this.keychain.exportKey(key, options) + key = privateKey.publicKey.toMultihash() + } + + const routingKey = multihashToIPNSRoutingKey(key) + + try { + // collect records for key + if (options.record != null) { + const record = options.record instanceof Uint8Array ? await unmarshalIPNSRecord(routingKey, options.record, this.keychain, options) : options.record + + // user supplied record + await ipnsValidator(record) + records.push(record) + } + + try { + // local record + const result = await last(this.resolver.resolve(key, { + offline: true + })) + + if (result != null) { + localRecord = result.record + records.push(result.record) + } + } catch (err: any) { + if (err.name !== 'RecordNotFoundError') { + throw err + } + } + + try { + if (!options.skipResolution) { + // published record + const result = await last(this.resolver.resolve(key, { + nocache: true + })) + + if (result != null) { + routingRecord = result.record + records.push(result.record) + } + } + } catch (err: any) { + if (err.name !== 'RecordNotFoundError') { + throw err + } + } + + if (records.length === 0) { + throw new NotFoundError('Found no existing records to republish') + } + + const selectedRecord = records[ipnsSelector(routingKey, records)] + const marshaledRecord = marshalIPNSRecord(selectedRecord) + + const metadata = { + upkeep: Upkeep[options.upkeep ?? 'refresh'] + } + + await Promise.all( + this.routers + .filter(r => { + // update all routings if force is true + if (options.force === true) { + return true + } + + // only update local store if it did not have the record or the + // selected record is more recent than the local copy + if (r.toString() === 'LocalStoreRouting()') { + return localRecord == null || (selectedRecord.sequence > localRecord.sequence) + } + + // update routing if not running in offline mode + return options.offline !== true + }) + .map(r => r.put(routingKey, marshaledRecord, { + ...options, + overwrite: true, + metadata + })) + ) + + const localRecordSequence = localRecord?.sequence ?? 0n + const routingRecordSequence = routingRecord?.sequence ?? 0n + + // if we tried to republish an out of date record, throw an error + + // check if the latest version of the record came from the routing + if (options.force !== true && options.offline !== true && routingRecord != null && routingRecordSequence > localRecordSequence) { + throw new RecordAlreadyPublishedError('Record already published', routingRecord) + } + + 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 3f4b99ea8..faa7a7408 100644 --- a/packages/ipns/src/ipns/resolver.ts +++ b/packages/ipns/src/ipns/resolver.ts @@ -125,6 +125,11 @@ export class IPNSResolver { this.routers.map(async (router) => { let marshaledIPNSRecord: Uint8Array + // skip checking cache when nocache is true + if (String(router) === 'LocalStoreRouting()' && options.nocache === true) { + return + } + try { marshaledIPNSRecord = 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/records.ts b/packages/ipns/src/records.ts index 3e4d063ff..9c03ae14c 100644 --- a/packages/ipns/src/records.ts +++ b/packages/ipns/src/records.ts @@ -211,7 +211,7 @@ const _create = async (privateKey: PrivateKey, value: string, seq: number | bigi const data = createCborData(value, validityType, isoValidity, seq, ttl) const sigData = ipnsRecordDataForV2Sig(data) const signatureV2 = await privateKey.sign(sigData, options) - const publicKey = shouldEmbedPublicKey(privateKey.publicKey) ? privateKey.publicKey : undefined + const publicKey = privateKey.publicKey let record: any if (options.v1Compatible === true) { @@ -264,10 +264,3 @@ const signLegacyV1 = async (privateKey: PrivateKey, value: string, validityType: throw new SignatureCreationError('Record signature creation failed') } } - -/** - * Returns true if the public key multihash is not an identity hash - */ -function shouldEmbedPublicKey (key: PublicKey): boolean { - return key.toMultihash().code !== 0 -} 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 b90bb891a..2a238dc0a 100644 --- a/packages/ipns/src/utils.ts +++ b/packages/ipns/src/utils.ts @@ -62,11 +62,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 } @@ -78,6 +76,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 } @@ -122,7 +132,7 @@ export function ipnsRecordDataForV2Sig (data: Uint8Array): Uint8Array { } export function marshalIPNSRecord (obj: IPNSRecord | IPNSRecordV2): Uint8Array { - let publicKey: Uint8Array | undefined = obj.publicKey?.toProtobuf() + let publicKey: Uint8Array | undefined = obj.publicKey.toProtobuf() // do not embed public keys whose multihash is an identity hash as these can // be derived from the routing key @@ -397,6 +407,14 @@ export function normalizeKey (key?: PublicKey | CID | MultihashDi throw new InvalidValueError('Value must be a valid IPNS path starting with /') } +export function normalizeKeyName (keyName: CID | PublicKey | MultihashDigest | string): MultihashDigest | string { + try { + return normalizeKey(keyName).digest + } catch { + return keyName.toString() + } +} + function validateCborDataMatchesPbData (entry: IpnsEntry): void { if (entry.data == null) { throw new InvalidRecordDataError('Record data is missing') diff --git a/packages/ipns/test/publish.spec.ts b/packages/ipns/test/publish.spec.ts index 24fe3ed96..9852fbd78 100644 --- a/packages/ipns/test/publish.spec.ts +++ b/packages/ipns/test/publish.spec.ts @@ -5,6 +5,8 @@ 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, multihashToIPNSRoutingKey } 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' @@ -208,6 +210,16 @@ describe('publish', () => { expect(result.record.value).to.equal(`/ipfs/${cid.toV1()}${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 }) + 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) @@ -288,3 +300,67 @@ 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 }) + 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 }) + + 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 }) + const routingKey = multihashToIPNSRoutingKey(publicKey.toMultihash()) + + 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 }) + const routingKey = multihashToIPNSRoutingKey(publicKey.toMultihash()) + + 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() + }) +}) diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 2cca9dd20..a8e98c83e 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -1,9 +1,15 @@ -import { start, stop } from '@libp2p/interface' +import { ed25519Crypto } from '@ipshipyard/crypto' +import { NotFoundError, start, stop } from '@libp2p/interface' +import { Record } from '@libp2p/kad-dht' import { expect } from 'aegir/chai' import { CID } from 'multiformats/cid' import sinon from 'sinon' +import NanoDate from 'timestamp-nano' +import { REPUBLISH_THRESHOLD } from '../src/constants.ts' import { localStore } from '../src/local-store.ts' +import { IPNSPublishMetadata, Upkeep } from '../src/pb/metadata.ts' import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord, multihashToIPNSRoutingKey } from '../src/records.ts' +import { dhtRoutingKey, ipnsMetadataKey, multihashFromIPNSRoutingKey } 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' @@ -134,6 +140,32 @@ describe('republish', () => { const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], result.keychain) expect(republishedRecord.sequence).to.equal(2n) // Incremented from 1n }) + + it('should republish existing records', async () => { + const key = await ed25519Crypto().generatePrivateKey() + const record = await createIPNSRecord(key, `/ipfs/${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 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 = await unmarshalIPNSRecord(routingKey, callArgs[1], result.keychain) + expect(republishedRecord.sequence).to.equal(1n) + }) }) describe('record processing', () => { @@ -153,6 +185,26 @@ describe('republish', () => { expect(putStubCustom.called).to.be.false() }) + it('should skip records with republishing disabled', async () => { + const key = await result.keychain.generateKey('test-key') + const record = await createIPNSRecord(key, `/ipfs/${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]) @@ -182,7 +234,8 @@ describe('republish', () => { await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'test-key', - lifetime: SHORTENED_VALIDITY + lifetime: SHORTENED_VALIDITY, + upkeep: Upkeep.republish } }) @@ -195,6 +248,28 @@ describe('republish', () => { const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], result.keychain) expect(republishedRecord.sequence).to.equal(6n) // Incremented from 5n }) + + it('should skip republishing existing records created within republish threshold', async () => { + const key = await result.keychain.generateKey('test-key') + const record = await createIPNSRecord(key, `/ipfs/${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 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', () => { @@ -381,6 +456,30 @@ describe('republish', () => { expect(putStubHelia.called).to.be.false() }) + it('should handle unable to find existing record to republish', async () => { + const key = await result.keychain.generateKey('test-key') + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + // create an expired record + const record = await createIPNSRecord(key, `/ipfs/${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 result.keychain.generateKey('test-key-1') const key2 = await result.keychain.generateKey('test-key-2') @@ -412,4 +511,363 @@ 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 result.keychain.generateKey('test-key') + const record = await createIPNSRecord(key, `/ipfs/${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 passed', async () => { + const key = await result.keychain.generateKey('test-key') + const record = await createIPNSRecord(key, `/ipfs/${testCid}`, 1n, 24 * 60 * 60 * 1000) + const republished = await name.republish(key.publicKey.toMultihash(), { + record + }) + + expect(republished.record).to.equal(record) + }) + + it('should write to metadata', async () => { + const key = await result.keychain.generateKey('test-key') + const record = await createIPNSRecord(key, `/ipfs/${testCid}`, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + await name.republish(multihashFromIPNSRoutingKey(routingKey), { + record + }) + + 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 result.keychain.generateKey(`test-key-${upkeep}`) + const record = await createIPNSRecord(key, `/ipfs/${testCid}`, 1n, 24 * 60 * 60 * 1000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + await name.republish(multihashFromIPNSRoutingKey(routingKey), { + record, + 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 result.keychain.generateKey('test-key') + const record = await createIPNSRecord(key, `/ipfs/${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 result.keychain.generateKey('test-key') + const record1 = await createIPNSRecord(key, `/ipfs/${testCid}`, 1n, 24 * 60 * 60 * 1000) + const record2 = await createIPNSRecord(key, `/ipfs/${testCid}`, 2n, 24 * 60 * 60 * 1000) + const record3 = await createIPNSRecord(key, `/ipfs/${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 not touch the network when offline and skipResolution are both set', async () => { + const key = await result.keychain.generateKey('test-key') + const record1 = await createIPNSRecord(key, `/ipfs/${testCid}`, 1n, 24 * 60 * 60 * 1000) + const record2 = await createIPNSRecord(key, `/ipfs/${testCid}`, 2n, 24 * 60 * 60 * 1000) + const record3 = await createIPNSRecord(key, `/ipfs/${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 - 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), { + offline: true, + skipResolution: true + }) + + expect(storePutSpy.called).to.be.false('updated the local store when it already contained the latest record') + expect(getStubCustom.called).to.be.false('searched custom routing') + expect(getStubHelia.called).to.be.false('searched Helia routing') + expect(result.customRouting.put.called).to.be.false('published to custom routing') + expect(result.heliaRouting.put.called).to.be.false('published to Helia routing') + 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 result.keychain.generateKey('test-key') + const record1 = await createIPNSRecord(key, `/ipfs/${testCid}`, 1n, 24 * 60 * 60 * 1000) + const record2 = await createIPNSRecord(key, `/ipfs/${testCid}`, 2n, 24 * 60 * 60 * 1000) + const record3 = await createIPNSRecord(key, `/ipfs/${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 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 result.keychain.generateKey('test-key') + const record1 = await createIPNSRecord(key, `/ipfs/${testCid}`, 1n, 24 * 60 * 60 * 1000) + const record2 = await createIPNSRecord(key, `/ipfs/${testCid}`, 2n, 24 * 60 * 60 * 1000) + const record3 = await createIPNSRecord(key, `/ipfs/${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 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') + + await name.republish(multihashFromIPNSRoutingKey(routingKey), { + offline: true + }) + + expect(storePutSpy.called).to.be.true('did not update the local record when a newer version was found in the routing') + expect(result.customRouting.put.called).to.be.false() + expect(result.heliaRouting.put.called).to.be.false() + }) + + it('should publish a valid local record when no records are found in the routing', async () => { + const key = await result.keychain.generateKey('test-key') + const record = await createIPNSRecord(key, `/ipfs/${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)) + + // Stub router GETs to not find any records + result.customRouting.get = sinon.stub().rejects(new NotFoundError()) + result.heliaRouting.get = sinon.stub().rejects(new NotFoundError()) + + // @ts-expect-error localStore property is private + const localStorePutSpy = sinon.spy(name.localStore, 'put') + + const republished = await name.republish(multihashFromIPNSRoutingKey(routingKey)) + + expect(localStorePutSpy.called).to.be.false('updated local store when the serial number was the same') + expect(result.customRouting.put.called).to.be.true('did not publish cached record') + expect(result.heliaRouting.put.called).to.be.true('did not publish cached record') + expect(republished.record.sequence).to.equal(1n) + }) + + it('should not publish an expired local record when no records are found in the routing', async () => { + const key = await result.keychain.generateKey('test-key') + const record = await createIPNSRecord(key, `/ipfs/${testCid}`, 1n, -60_000) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) + + expect(NanoDate.fromString(record.validity).getTimeT() * 1_000).to.be.lessThan(Date.now(), 'record was not expired') + + // Store the record in the real datastore + const store = localStore(result.datastore, result.log) + await store.put(routingKey, marshalIPNSRecord(record)) + + // Stub router GETs to not find any records + result.customRouting.get = sinon.stub().rejects(new NotFoundError()) + result.heliaRouting.get = sinon.stub().rejects(new NotFoundError()) + + // @ts-expect-error localStore property is private + const storePutSpy = sinon.spy(name.localStore, 'put') + + await expect(name.republish(multihashFromIPNSRoutingKey(routingKey))).to.eventually.be.rejected + .with.property('name', 'NotFoundError') + + expect(storePutSpy.called).to.be.false('updated local store') + expect(result.customRouting.put.called).to.be.false('published expired record') + expect(result.heliaRouting.put.called).to.be.false('published expired record') + }) + }) + + describe('error handling', () => { + it('should call options.onProgress on error', async () => { + const key = await result.keychain.generateKey('test-key') + 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 result.keychain.generateKey('test-key') + const record = await createIPNSRecord(key, `/ipfs/${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 result.keychain.generateKey('test-key') + 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 result.keychain.generateKey('test-key') + const record = await createIPNSRecord(key, `/ipfs/${testCid}`, 1n, 24 * 60 * 60 * 1000) + 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.eventually.be.rejected() + + expect(err).to.have.property('name', 'RecordAlreadyPublishedError') + expect(marshalIPNSRecord(err.record)).to.deep.equal(marshalIPNSRecord(record)) + }) + + it('should rethrow non-RecordNotFoundError from router resolution', async () => { + const key = await result.keychain.generateKey('test-key') + 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 result.keychain.generateKey('test-key') + const record = await createIPNSRecord(key, `/ipfs/${testCid}`, 1n, 24 * 60 * 60 * 1000) + 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 ec49c7996..872fa6c29 100644 --- a/packages/ipns/test/resolve.spec.ts +++ b/packages/ipns/test/resolve.spec.ts @@ -111,6 +111,9 @@ describe('resolve', () => { heliaRouting.get.resolves(marshalIPNSRecord(record)) + // @ts-ignore cannot access private localStore property + const storeGetSpy = Sinon.spy(name.localStore, 'get') + const result = await last(name.resolve(publicKey, { nocache: true })) @@ -121,6 +124,9 @@ describe('resolve', () => { expect(result.record.value).to.equal(`/ipfs/${cid.toV1()}`) + // 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()