import type { IPNSPublishTarget } from '@wovin/core/ipns' import { Logger } from 'besonders-logger' import { base64pad } from 'multiformats/bases/base64' import { resolveIPNSSequence, unmarshalIPNSRecord } from './ipns-record.ts' import { CID } from 'multiformats/cid' import * as W3Name from 'w3name' const { WARN, LOG, DEBUG, ERROR } = Logger.setup(Logger.INFO) /** * Try to resolve IPNS name to get existing revision. * Returns null if record doesn't exist (404). * Throws on network errors or server errors. * * This does a custom HTTP check first to distinguish 404 from network errors, * then delegates to W3Name.resolve() for validation if record exists. */ async function tryResolveIPNS(ipns: W3Name.WritableName): Promise { const url = `https://name.web3.storage/name/${ipns.toString()}` let response: Response try { response = await fetch(url, { signal: AbortSignal.timeout(30_000) }) } catch (err) { // Network error (no connection, DNS failure, etc.) throw ERROR('[w3name] Network error resolving IPNS:', err) } // 404 = record never published if (response.status === 404) { DEBUG('[w3name] IPNS record not found (never published):', ipns.toString()) return null } // Other HTTP errors (5xx server error, etc.) if (!response.ok) { throw ERROR(`[w3name] HTTP ${response.status} resolving IPNS:`, response.statusText) } // Success - use W3Name.resolve to get validated Revision // (We could parse the record ourselves, but W3Name does validation/signature checks) const existing = await W3Name.resolve(ipns) return existing } /** * Publish CID to IPNS, automatically handling increment vs v0. * Returns the revision for further processing (e.g., Kubo integration). */ export async function publishIPNS(ipnsPrivateKey: Uint8Array, cid: CID): Promise { const TIMEOUT_MS = 30_000 const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error(`publishIPNS timed out after ${TIMEOUT_MS}ms`)), TIMEOUT_MS), ) return Promise.race([_publishIPNSImpl(ipnsPrivateKey, cid), timeout]) } async function _publishIPNSImpl(ipnsPrivateKey: Uint8Array, cid: CID): Promise { const value = `/ipfs/${cid}` const ipns = await W3Name.from(ipnsPrivateKey) let revision: W3Name.Revision const existing = await tryResolveIPNS(ipns) if (existing) { // Record exists - increment sequence number revision = await W3Name.increment(existing, value) DEBUG('[w3name] incrementing revision for', ipns.toString()) } else { // First publish - use v0 revision = await W3Name.v0(ipns, value) DEBUG('[w3name] creating initial revision for', ipns.toString()) } await W3Name.publish(revision, ipns.key) DEBUG('[w3name] published', cid.toString(), 'to', ipns.toString()) return revision // Return for Kubo integration or other uses } // ───────────────────────────────────────────────────────────────────────────── // zt.ax naming service target (primary) // ───────────────────────────────────────────────────────────────────────────── /** * Resolve the current IPNS sequence from a zt.ax-compatible naming service. * * The naming service API uses the simple W3NameRecord format: * `GET /` → `{ value: "/ipfs/", seq?: number, validity?: string }` * * Also supports the legacy w3name record format (`{ record: "", value: "..." }`) * for backward compatibility with hybrid services. */ export async function resolveZtaxSequence( baseUrl: string, ipnsName: string, ): Promise { const url = `${baseUrl.replace(/\/+$/, '')}/${ipnsName}` let response: Response try { response = await fetch(url) } catch (err) { throw new Error(`Network error resolving sequence from ${baseUrl}: ${err}`) } if (response.status === 404) return null if (!response.ok) { throw new Error(`HTTP ${response.status} resolving sequence from ${baseUrl}: ${response.statusText}`) } const body: Record = await response.json() // Format 1: simple W3NameRecord with seq number if (typeof body.seq === 'number') { return BigInt(body.seq) } // Format 2: legacy w3name record with base64-encoded protobuf if (typeof body.record === 'string') { try { const bytes = base64pad.baseDecode(body.record) const entry = unmarshalIPNSRecord(bytes) return entry.sequence } catch { WARN('[resolveZtaxSequence] failed to unmarshal record for', ipnsName) return null } } // No sequence info — treat as never published or zero return null } /** * Create an IPNSPublishTarget that publishes to a zt.ax naming service. * * Uses HTTP POST to publish signed IPNS records and HTTP GET to resolve * the current sequence number, following the same wire format as the * standard w3name API but at a configurable base URL. * * The naming service is expected to support: * - `POST /` — publish signed IPNS record (base64-encoded body) * - `GET /` — resolve current record (`{ value, seq?, record? }`) * * @param baseUrl - Base URL of the naming service (default: https://ipfs.zt.ax) */ export function ztaxTarget(baseUrl = 'https://ipfs.zt.ax'): IPNSPublishTarget { const cleanBase = baseUrl.replace(/\/+$/, '') return { name: 'zt.ax', async publish(ipnsName: string, recordBytes: Uint8Array) { const url = `${cleanBase}/${ipnsName}` DEBUG(`[ztaxTarget] POST ${url}`) const res = await fetch(url, { method: 'POST', body: base64pad.baseEncode(recordBytes), }) if (!res.ok) { let detail = '' try { const body = await res.json() as Record detail = body.message ? ` — ${body.message}` : '' } catch { detail = res.statusText ? ` — ${res.statusText}` : '' } throw new Error(`zt.ax HTTP ${res.status}${detail}`) } }, async resolveSequence(ipnsName: string): Promise { return resolveZtaxSequence(cleanBase, ipnsName) }, } } // ───────────────────────────────────────────────────────────────────────────── // w3name naming service target (deprecated — read‑only backup) // ───────────────────────────────────────────────────────────────────────────── /** * Create an IPNSPublishTarget for the w3name service. * * @deprecated w3name is shut down for publishing. This target is kept as a * read‑only backup for sequence resolution only (no publish method). * Use {@link ztaxTarget} for all publishing. * * @param serviceUrl - Base URL of the w3name-compatible service (default: https://name.web3.storage) */ export function w3nameTarget(serviceUrl = 'https://name.web3.storage'): IPNSPublishTarget { return { name: 'w3name', // No publish() — w3name is read-only; publishing goes through ztaxTarget async resolveSequence(ipnsName: string): Promise { return resolveIPNSSequence(serviceUrl, ipnsName) }, } } // ───────────────────────────────────────────────────────────────────────────── export async function generateIpnsKey() { return W3Name.create() // Returns W3Name.WritableName type } export async function getW3NamePublic(pk: Uint8Array) { const ipns = await W3Name.from(pk) return ipns.toString() }