@helia/ipns 10.1.1-b34bc7c7 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +40 -0
  2. package/dist/index.min.js +5 -5
  3. package/dist/index.min.js.map +4 -4
  4. package/dist/src/errors.d.ts +11 -0
  5. package/dist/src/errors.d.ts.map +1 -1
  6. package/dist/src/errors.js +13 -0
  7. package/dist/src/errors.js.map +1 -1
  8. package/dist/src/index.d.ts +136 -7
  9. package/dist/src/index.d.ts.map +1 -1
  10. package/dist/src/index.js +41 -0
  11. package/dist/src/index.js.map +1 -1
  12. package/dist/src/ipns/publisher.d.ts +3 -3
  13. package/dist/src/ipns/publisher.d.ts.map +1 -1
  14. package/dist/src/ipns/publisher.js +17 -18
  15. package/dist/src/ipns/publisher.js.map +1 -1
  16. package/dist/src/ipns/republisher.d.ts +6 -1
  17. package/dist/src/ipns/republisher.d.ts.map +1 -1
  18. package/dist/src/ipns/republisher.js +157 -24
  19. package/dist/src/ipns/republisher.js.map +1 -1
  20. package/dist/src/ipns/resolver.d.ts +13 -1
  21. package/dist/src/ipns/resolver.d.ts.map +1 -1
  22. package/dist/src/ipns/resolver.js +40 -31
  23. package/dist/src/ipns/resolver.js.map +1 -1
  24. package/dist/src/ipns.d.ts +6 -3
  25. package/dist/src/ipns.d.ts.map +1 -1
  26. package/dist/src/ipns.js +12 -3
  27. package/dist/src/ipns.js.map +1 -1
  28. package/dist/src/local-store.d.ts +5 -0
  29. package/dist/src/local-store.d.ts.map +1 -1
  30. package/dist/src/local-store.js +68 -23
  31. package/dist/src/local-store.js.map +1 -1
  32. package/dist/src/pb/metadata.d.ts +14 -1
  33. package/dist/src/pb/metadata.d.ts.map +1 -1
  34. package/dist/src/pb/metadata.js +35 -2
  35. package/dist/src/pb/metadata.js.map +1 -1
  36. package/dist/src/routing/index.d.ts +13 -1
  37. package/dist/src/routing/index.d.ts.map +1 -1
  38. package/dist/src/routing/index.js +17 -0
  39. package/dist/src/routing/index.js.map +1 -1
  40. package/dist/src/utils.d.ts +5 -0
  41. package/dist/src/utils.d.ts.map +1 -1
  42. package/dist/src/utils.js +18 -0
  43. package/dist/src/utils.js.map +1 -1
  44. package/dist/src/validator.d.ts +6 -0
  45. package/dist/src/validator.d.ts.map +1 -1
  46. package/dist/src/validator.js +24 -14
  47. package/dist/src/validator.js.map +1 -1
  48. package/dist/typedoc-urls.json +90 -0
  49. package/package.json +2 -2
  50. package/src/errors.ts +18 -0
  51. package/src/index.ts +145 -7
  52. package/src/ipns/publisher.ts +22 -20
  53. package/src/ipns/republisher.ts +182 -26
  54. package/src/ipns/resolver.ts +54 -36
  55. package/src/ipns.ts +20 -6
  56. package/src/local-store.ts +77 -22
  57. package/src/pb/metadata.proto +10 -0
  58. package/src/pb/metadata.ts +44 -3
  59. package/src/routing/index.ts +30 -1
  60. package/src/utils.ts +19 -0
  61. package/src/validator.ts +30 -18
@@ -1,15 +1,24 @@
1
+ import { NotFoundError } from '@libp2p/interface'
1
2
  import { Queue, repeatingTask } from '@libp2p/utils'
3
+ import NanoDate from 'timestamp-nano'
2
4
  import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
3
5
  import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_TTL_NS } from '../constants.ts'
6
+ import { RecordObsoleteError } from '../errors.ts'
4
7
  import { IPNSEntry } from '../pb/ipns.ts'
8
+ import { Upkeep } from '../pb/metadata.ts'
5
9
  import { createIPNSRecord } from '../records.ts'
6
- import { decodeExtensibleData, shouldRepublish } from '../utils.ts'
7
- import type { IPNSRecordData } from '../index.ts'
10
+ import { isLocalStoreRouting, routerName } from '../routing/index.ts'
11
+ import { ipnsSelector } from '../selector.ts'
12
+ import { decodeExtensibleData, multihashToIPNSRoutingKey, shouldRepublish } from '../utils.ts'
13
+ import { extractPublicKey, ipnsValidator } from '../validator.ts'
14
+ import { findRoutingRecords } from './resolver.ts'
15
+ import type { IPNSRecordData, RepublishOptions, RepublishResult } from '../index.ts'
8
16
  import type { LocalStore } from '../local-store.ts'
9
- import type { IPNSRouting } from '../routing/index.ts'
17
+ import type { IPNSRouting, IPNSRoutingPutOptions } from '../routing/index.ts'
10
18
  import type { Keychain, PrivateKey } from '@helia/interface'
11
19
  import type { AbortOptions, ComponentLogger, Logger } from '@libp2p/interface'
12
20
  import type { RepeatingTask } from '@libp2p/utils'
21
+ import type { MultihashDigest } from 'multiformats'
13
22
 
14
23
  export interface IPNSRepublisherComponents {
15
24
  logger: ComponentLogger
@@ -79,7 +88,8 @@ export class IPNSRepublisher {
79
88
  })
80
89
 
81
90
  try {
82
- const recordsToRepublish: Array<{ routingKey: Uint8Array, record: IPNSEntry }> = []
91
+ const recordsToReissue: Array<{ routingKey: Uint8Array, record: IPNSEntry }> = []
92
+ const recordsToRebroadcast: Array<{ routingKey: Uint8Array, record: IPNSEntry }> = []
83
93
  let listed = 0
84
94
 
85
95
  // Find all records using the localStore.list method
@@ -87,23 +97,31 @@ export class IPNSRepublisher {
87
97
  listed++
88
98
 
89
99
  if (metadata == null) {
90
- // Skip if no metadata is found from before we started
91
- // storing metadata or for records republished without a key
100
+ // no metadata: an imported record not yet given an upkeep policy, or a
101
+ // record stored before we tracked metadata
92
102
  this.log('no metadata found for record %b, skipping', routingKey)
93
103
  continue
94
104
  }
95
105
 
106
+ if (metadata.upkeep === Upkeep.none) {
107
+ // Skip republishing, disabled for this record
108
+ this.log('republishing is disabled for record %b, skipping', routingKey)
109
+ continue
110
+ }
111
+
96
112
  let ipnsRecord: IPNSEntry
97
113
 
98
114
  try {
99
115
  ipnsRecord = IPNSEntry.decode(record)
100
116
  } catch (err) {
101
- this.log.trace('skipping invalid record %b because could not decode', routingKey)
117
+ // the record was valid when stored, so a decode failure now means
118
+ // on-disk corruption or a format change worth surfacing
119
+ this.log.error('skipping stored record %b, could not decode it - %e', routingKey, err)
102
120
  continue
103
121
  }
104
122
 
105
123
  if (ipnsRecord.data == null) {
106
- this.log.trace('skipping record %b because data was missing', routingKey)
124
+ this.log.error('skipping stored record %b, its data is missing', routingKey)
107
125
  continue
108
126
  }
109
127
 
@@ -111,17 +129,40 @@ export class IPNSRepublisher {
111
129
 
112
130
  try {
113
131
  data = decodeExtensibleData(ipnsRecord.data)
114
- } catch {
115
- this.log.trace('skipping record %b because could not decode data', routingKey)
132
+ } catch (err) {
133
+ this.log.error('skipping stored record %b, could not decode its data - %e', routingKey, err)
134
+ continue
135
+ }
136
+
137
+ let recordExpiry: Date
138
+
139
+ try {
140
+ recordExpiry = NanoDate.fromString(uint8ArrayToString(data.Validity)).toDate()
141
+ } catch (err) {
142
+ this.log.error('skipping stored record %b, could not parse its validity - %e', routingKey, err)
116
143
  continue
117
144
  }
118
145
 
119
146
  // Only republish records that are within the DHT or record expiry threshold
120
- if (!shouldRepublish(created, new Date(uint8ArrayToString(data.Validity)))) {
147
+ if (!shouldRepublish(created, recordExpiry)) {
121
148
  this.log.trace('skipping record %b within republish threshold', routingKey)
122
149
  continue
123
150
  }
124
151
 
152
+ if (metadata.upkeep === Upkeep.rebroadcast) {
153
+ // only re-broadcast records that are still valid: an expired record
154
+ // cannot be revived without re-signing, which the rebroadcast policy
155
+ // has no key for
156
+ if (recordExpiry.getTime() <= Date.now()) {
157
+ this.log.trace('skipping expired record %b for rebroadcast', routingKey)
158
+ continue
159
+ }
160
+
161
+ recordsToRebroadcast.push({ routingKey, record: ipnsRecord })
162
+ continue
163
+ }
164
+
165
+ // Upkeep.reissue: re-sign the record with a new validity
125
166
  const sequenceNumber = data.Sequence + 1n
126
167
  const ttlNs = data.TTL ?? DEFAULT_TTL_NS
127
168
  let privKey: PrivateKey
@@ -138,36 +179,151 @@ export class IPNSRepublisher {
138
179
  ...options,
139
180
  ttlNs
140
181
  })
141
- recordsToRepublish.push({
182
+
183
+ recordsToReissue.push({
142
184
  routingKey,
143
185
  record: updatedRecord
144
186
  })
145
187
  } catch (err: any) {
146
- this.log.error('error creating updated IPNS record for %s - %e', routingKey, err)
188
+ this.log.error('error creating updated IPNS record for %b - %e', routingKey, err)
147
189
  continue
148
190
  }
149
191
  }
150
192
 
151
- this.log(`found ${recordsToRepublish.length}/${listed} records to republish`)
193
+ this.log(`reissuing ${recordsToReissue.length} and rebroadcasting ${recordsToRebroadcast.length} of ${listed} records`)
194
+
195
+ // reissue: re-publish the re-signed record as-is. the jobs run
196
+ // fire-and-forget, so catch any failure (including a local-store write
197
+ // that #publishToRouters surfaces) rather than leaving an unhandled rejection
198
+ for (const { routingKey, record } of recordsToReissue) {
199
+ queue.add(async () => this.#publishToRouters(routingKey, IPNSEntry.encode(record), options), options)
200
+ .catch((err: any) => { this.log.error('failed to reissue record %b - %e', routingKey, err) })
201
+ }
152
202
 
153
- // Republish each record
154
- for (const { routingKey, record } of recordsToRepublish) {
155
- // Add job to queue to republish the record to all routers
203
+ // rebroadcast: resolve the latest record from the network and re-broadcast
204
+ // whichever of the local and network records is most suitable, so we adopt
205
+ // a newer record rather than pushing a stale local copy
206
+ for (const { routingKey, record } of recordsToRebroadcast) {
156
207
  queue.add(async () => {
157
- try {
158
- const marshaledRecord = IPNSEntry.encode(record)
159
- await Promise.all(
160
- this.routers.map(r => r.put(routingKey, marshaledRecord, options))
161
- )
162
- } catch (err: any) {
163
- this.log.error('error republishing record - %e', err)
164
- }
208
+ const { records } = await findRoutingRecords(this.routers, routingKey, this.keychain, this.log, options)
209
+ const candidates = [record, ...records]
210
+ const best = candidates[ipnsSelector(routingKey, candidates)]
211
+
212
+ await this.#publishToRouters(routingKey, IPNSEntry.encode(best), { ...options, overwrite: true })
165
213
  }, options)
214
+ .catch((err: any) => { this.log.error('failed to rebroadcast record %b - %e', routingKey, err) })
166
215
  }
167
216
  } catch (err: any) {
168
217
  this.log.error('error during republish - %e', err)
169
218
  }
219
+ }
220
+
221
+ /**
222
+ * Persist the record to the local store first, throwing if that fails: it
223
+ * holds the record and its upkeep policy, so that failure must surface rather
224
+ * than be swallowed. Then broadcast to the network routers best-effort,
225
+ * logging any that fail.
226
+ */
227
+ async #publishToRouters (routingKey: Uint8Array, marshaledRecord: Uint8Array, putOptions: IPNSRoutingPutOptions): Promise<void> {
228
+ const localStoreRouter = this.routers.find(isLocalStoreRouting)
229
+
230
+ if (localStoreRouter != null) {
231
+ await localStoreRouter.put(routingKey, marshaledRecord, putOptions)
232
+ this.log('published record %b to %s', routingKey, routerName(localStoreRouter))
233
+ }
234
+
235
+ await Promise.all(
236
+ this.routers
237
+ .filter(router => router !== localStoreRouter)
238
+ .map(async (router) => {
239
+ try {
240
+ await router.put(routingKey, marshaledRecord, putOptions)
241
+ this.log('published record %b to %s', routingKey, routerName(router))
242
+ } catch (err) {
243
+ this.log.error('failed to publish record %b to %s - %e', routingKey, routerName(router), err)
244
+ }
245
+ })
246
+ )
247
+ }
248
+
249
+ async republish (key: MultihashDigest, options: RepublishOptions = {}): Promise<RepublishResult> {
250
+ const routingKey = multihashToIPNSRoutingKey(key)
251
+
252
+ // read the record we hold for this key directly, it was validated when it
253
+ // was imported or published, and reading it through resolve() would recurse
254
+ // and rewrite the local store
255
+ let record: Uint8Array<ArrayBuffer>
256
+
257
+ try {
258
+ ({ record } = await this.localStore.get(routingKey, options))
259
+ } catch (err: any) {
260
+ if (err.name !== 'NotFoundError') {
261
+ throw err
262
+ }
263
+
264
+ throw new NotFoundError('No local record found to republish - import it first')
265
+ }
266
+
267
+ // re-validate: a record that was valid when stored can have expired since
268
+ const localRecord = await ipnsValidator(routingKey, record, this.keychain, options)
269
+
270
+ // unless we are told not to look, check whether the routing already has a
271
+ // more suitable record; if so, back off
272
+ if (options.skipResolution !== true) {
273
+ const { records } = await findRoutingRecords(this.routers, routingKey, this.keychain, this.log, options)
274
+ const candidates = [localRecord, ...records]
275
+ const best = ipnsSelector(routingKey, candidates)
276
+
277
+ if (best !== 0) {
278
+ throw new RecordObsoleteError('A newer record is already published for this key', candidates[best])
279
+ }
280
+ }
281
+
282
+ const metadata = {
283
+ upkeep: Upkeep[options.upkeep ?? 'rebroadcast']
284
+ }
285
+
286
+ // a publish that fails on every router does not throw; use onProgress to see
287
+ // which routers worked and watch the logs
288
+ await this.#publishToRouters(routingKey, record, {
289
+ ...options,
290
+ overwrite: true,
291
+ metadata
292
+ })
293
+
294
+ return {
295
+ record: localRecord,
296
+ publicKey: await extractPublicKey(routingKey, localRecord, this.keychain, options)
297
+ }
298
+ }
299
+
300
+ async import (key: MultihashDigest, record: IPNSEntry | Uint8Array, options?: AbortOptions): Promise<void> {
301
+ const routingKey = multihashToIPNSRoutingKey(key)
302
+ const entry = record instanceof Uint8Array ? IPNSEntry.decode(record) : record
303
+ const marshaledRecord = IPNSEntry.encode(entry)
304
+
305
+ // validate the record, throws if it is invalid (bad signature, expired, etc)
306
+ await ipnsValidator(routingKey, marshaledRecord, this.keychain, options)
307
+
308
+ // do not downgrade a record we already hold
309
+ try {
310
+ const { record: existingBytes } = await this.localStore.get(routingKey, options)
311
+ const existing = IPNSEntry.decode(existingBytes)
312
+
313
+ // if the record we already have wins the selector, the incoming one is obsolete
314
+ if (ipnsSelector(routingKey, [entry, existing]) !== 0) {
315
+ throw new RecordObsoleteError('A newer record is already stored for this key', existing)
316
+ }
317
+ } catch (err: any) {
318
+ // holding no record for this key is the normal case, everything else
319
+ // (the RecordObsoleteError above included) is a real failure
320
+ if (err.name !== 'NotFoundError') {
321
+ throw err
322
+ }
323
+ }
170
324
 
171
- await queue.onIdle(options) // Wait for all jobs to complete
325
+ // store the record locally only, without metadata, so it is served but not
326
+ // automatically republished until republish() gives it an upkeep policy
327
+ await this.localStore.put(routingKey, marshaledRecord, options)
172
328
  }
173
329
  }
@@ -2,6 +2,7 @@ import { base36 } from 'multiformats/bases/base36'
2
2
  import { DEFAULT_TTL_NS } from '../constants.ts'
3
3
  import { InvalidValueError, RecordNotFoundError, RecordsFailedValidationError } from '../errors.ts'
4
4
  import { IPNSEntry } from '../pb/ipns.ts'
5
+ import { isLocalStoreRouting, routerName } from '../routing/index.ts'
5
6
  import { ipnsSelector } from '../selector.ts'
6
7
  import { multihashToIPNSRoutingKey, normalizeKey, IPNS_STRING_PREFIX, ipnsRecordValueToString, decodeExtensibleData } from '../utils.ts'
7
8
  import { ipnsValidator } from '../validator.ts'
@@ -9,7 +10,7 @@ import type { IPNSResolveOptions, IPNSResolveResult } from '../index.ts'
9
10
  import type { LocalStore } from '../local-store.ts'
10
11
  import type { IPNSRouting } from '../routing/index.ts'
11
12
  import type { Routing, Keychain } from '@helia/interface'
12
- import type { ComponentLogger, Logger } from '@libp2p/interface'
13
+ import type { AbortOptions, ComponentLogger, Logger } from '@libp2p/interface'
13
14
  import type { Datastore } from 'interface-datastore'
14
15
  import type { MultihashDigest } from 'multiformats/hashes/interface'
15
16
 
@@ -135,42 +136,15 @@ export class IPNSResolver {
135
136
 
136
137
  this.log('did not have record locally')
137
138
 
138
- let foundInvalid = 0
139
- const errors: Error[] = []
140
-
141
- await Promise.all(
142
- this.routers.map(async (router) => {
143
- let marshaledIPNSRecord: Uint8Array
144
-
145
- try {
146
- marshaledIPNSRecord = await router.get(routingKey, {
147
- ...options,
148
- validate: false
149
- })
150
- } catch (err: any) {
151
- this.log.error('error finding IPNS record using router %s - %e', router.toString(), err)
152
- errors.push(err)
153
-
154
- return
155
- }
156
-
157
- try {
158
- let record: IPNSEntry
159
-
160
- if (options.validate === false) {
161
- record = IPNSEntry.decode(marshaledIPNSRecord)
162
- } else {
163
- record = await ipnsValidator(routingKey, marshaledIPNSRecord, this.keychain, options)
164
- }
165
-
166
- records.push(record)
167
- } catch (err) {
168
- // we found a record, but the validator rejected it
169
- foundInvalid++
170
- this.log.error('error validating IPNS record from router %s - %e', router.toString(), err)
171
- }
172
- })
139
+ const { records: networkRecords, foundInvalid } = await findRoutingRecords(
140
+ this.routers,
141
+ routingKey,
142
+ this.keychain,
143
+ this.log,
144
+ options,
145
+ options.validate !== false
173
146
  )
147
+ records.push(...networkRecords)
174
148
 
175
149
  if (records.length === 0) {
176
150
  if (foundInvalid > 0) {
@@ -187,3 +161,47 @@ export class IPNSResolver {
187
161
  return record
188
162
  }
189
163
  }
164
+
165
+ /**
166
+ * Query the network routers for the records stored under a routing key, plus a
167
+ * count of records that were fetched but failed validation/decoding. Does not
168
+ * touch the local store; the caller selects the record it wants with
169
+ * `ipnsSelector`. Records are validated unless `validate` is false, in which
170
+ * case they are returned decoded without validation.
171
+ */
172
+ export async function findRoutingRecords (routers: IPNSRouting[], routingKey: Uint8Array, keychain: Keychain, log: Logger, options: AbortOptions = {}, validate = true): Promise<{ records: IPNSEntry[], foundInvalid: number }> {
173
+ const records: IPNSEntry[] = []
174
+ let foundInvalid = 0
175
+
176
+ await Promise.all(
177
+ routers.map(async (router) => {
178
+ // the local store is not the network
179
+ if (isLocalStoreRouting(router)) {
180
+ return
181
+ }
182
+
183
+ let marshaledRecord: Uint8Array
184
+
185
+ try {
186
+ marshaledRecord = await router.get(routingKey, { ...options, validate: false })
187
+ } catch (err: any) {
188
+ log.error('error finding IPNS record using router %s - %e', routerName(router), err)
189
+ return
190
+ }
191
+
192
+ try {
193
+ // when the caller opts out of validation, return the record as decoded
194
+ const record = validate
195
+ ? await ipnsValidator(routingKey, marshaledRecord, keychain, options)
196
+ : IPNSEntry.decode(marshaledRecord)
197
+
198
+ records.push(record)
199
+ } catch (err) {
200
+ foundInvalid++
201
+ log.error('error reading IPNS record from router %s - %e', routerName(router), err)
202
+ }
203
+ })
204
+ )
205
+
206
+ return { records, foundInvalid }
207
+ }
package/src/ipns.ts CHANGED
@@ -7,9 +7,9 @@ import { localStore } from './local-store.ts'
7
7
  import { heliaIPNSRouting } from './routing/helia.ts'
8
8
  import { localStoreIPNSRouting } from './routing/local-store.ts'
9
9
  import { ipnsSelector } from './selector.ts'
10
- import { normalizeKey, normalizeValue } from './utils.ts'
10
+ import { normalizeKey, normalizeKeyName, normalizeValue } from './utils.ts'
11
11
  import { ipnsValidator } from './validator.ts'
12
- import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, PublishOptions, IPNSResolveOptions, IPNSResolveResult } from './index.ts'
12
+ import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, PublishOptions, RepublishOptions, RepublishResult, IPNSResolveOptions, IPNSResolveResult, UnpublishOptions } from './index.ts'
13
13
  import type { LocalStore } from './local-store.ts'
14
14
  import type { IPNSRouting } from './routing/index.ts'
15
15
  import type { PublicKey } from '@helia/interface'
@@ -42,12 +42,12 @@ export class IPNS implements IPNSInterface, Startable {
42
42
  routers: this.routers,
43
43
  localStore: this.localStore
44
44
  })
45
- this.republisher = new IPNSRepublisher(components, {
45
+ this.resolver = new IPNSResolver(components, {
46
46
  ...init,
47
47
  routers: this.routers,
48
48
  localStore: this.localStore
49
49
  })
50
- this.resolver = new IPNSResolver(components, {
50
+ this.republisher = new IPNSRepublisher(components, {
51
51
  ...init,
52
52
  routers: this.routers,
53
53
  localStore: this.localStore
@@ -103,15 +103,29 @@ export class IPNS implements IPNSInterface, Startable {
103
103
  return this.publisher.publish(keyName, normalizeValue(value), options)
104
104
  }
105
105
 
106
- async * resolve (key: PublicKey | CID | MultihashDigest | string, options: IPNSResolveOptions = {}): AsyncGenerator<IPNSResolveResult> {
106
+ async * resolve (key: CID<unknown, 0x72> | PublicKey | MultihashDigest | string, options: IPNSResolveOptions = {}): AsyncGenerator<IPNSResolveResult> {
107
107
  const { digest } = normalizeKey(key)
108
108
 
109
109
  yield * this.resolver.resolve(digest, options)
110
110
  }
111
111
 
112
- async unpublish (keyName: string, options?: AbortOptions): Promise<void> {
112
+ async unpublish (keyName: CID<unknown, 0x72> | PublicKey | MultihashDigest | string, options?: UnpublishOptions): Promise<void> {
113
+ keyName = normalizeKeyName(keyName)
114
+
113
115
  return this.publisher.unpublish(keyName, options)
114
116
  }
117
+
118
+ async republish (keyName: CID<unknown, 0x72> | PublicKey | MultihashDigest | string, options: RepublishOptions = {}): Promise<RepublishResult> {
119
+ const { digest } = normalizeKey(keyName)
120
+
121
+ return this.republisher.republish(digest, options)
122
+ }
123
+
124
+ async import (key: CID<unknown, 0x72> | PublicKey | MultihashDigest | string, record: IPNSEntry | Uint8Array, options?: AbortOptions): Promise<void> {
125
+ const { digest } = normalizeKey(key)
126
+
127
+ return this.republisher.import(digest, record, options)
128
+ }
115
129
  }
116
130
 
117
131
  function isLibp2p (obj?: any): obj is Libp2p {
@@ -3,7 +3,7 @@ import { CustomProgressEvent } from 'progress-events'
3
3
  import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
4
4
  import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
5
5
  import { withArrayBuffer } from 'uint8arrays/with-array-buffer'
6
- import { IPNSPublishMetadata } from './pb/metadata.ts'
6
+ import { IPNSPublishMetadata, Upkeep } from './pb/metadata.ts'
7
7
  import { dhtRoutingKey, DHT_RECORD_PREFIX, ipnsMetadataKey } from './utils.ts'
8
8
  import type { DatastoreProgressEvents, IPNSRoutingGetOptions, IPNSRoutingPutOptions } from './routing/index.ts'
9
9
  import type { AbortOptions, Logger } from '@libp2p/interface'
@@ -37,12 +37,67 @@ export interface LocalStore {
37
37
  get(routingKey: Uint8Array, options?: IPNSRoutingGetOptions): Promise<GetResult>
38
38
  has(routingKey: Uint8Array, options?: AbortOptions): Promise<boolean>
39
39
  delete(routingKey: Uint8Array, options?: AbortOptions): Promise<void>
40
+ /**
41
+ * Delete only the IPNS metadata for a record, leaving the record itself in
42
+ * place so it is still served but no longer automatically republished
43
+ */
44
+ deleteMetadata(routingKey: Uint8Array, options?: AbortOptions): Promise<void>
40
45
  /**
41
46
  * List all IPNS records in the datastore
42
47
  */
43
48
  list(options?: ListOptions): AsyncIterable<ListResult>
44
49
  }
45
50
 
51
+ /**
52
+ * Merge a (possibly partial) metadata update into any metadata already stored
53
+ * for the routing key, so fields that are not being changed are preserved.
54
+ *
55
+ * Throws if the existing metadata cannot be read (rather than silently
56
+ * overwriting it and dropping keyName/lifetime), or if the resulting policy
57
+ * needs a keyName it does not have.
58
+ */
59
+ async function mergeMetadata (datastore: Datastore, routingKey: Uint8Array, incoming: Partial<IPNSPublishMetadata>, options?: AbortOptions): Promise<Partial<IPNSPublishMetadata>> {
60
+ let metadata = incoming
61
+
62
+ try {
63
+ // merge into any existing metadata so a partial update (e.g. republish
64
+ // setting only `upkeep`) preserves keyName/lifetime instead of wiping them
65
+ const existing = IPNSPublishMetadata.decode(await datastore.get(ipnsMetadataKey(routingKey), options))
66
+ metadata = { ...existing, ...incoming }
67
+ } catch (err: any) {
68
+ if (err.name !== 'NotFoundError') {
69
+ // the stored metadata is unreadable, surface it rather than silently
70
+ // overwriting and dropping keyName/lifetime
71
+ throw err
72
+ }
73
+ }
74
+
75
+ // the reissue upkeep policy re-signs the record, which needs the key, so
76
+ // refuse to persist that policy without a keyName
77
+ if (metadata.upkeep === Upkeep.reissue && (metadata.keyName == null || metadata.keyName === '')) {
78
+ throw new Error('a keyName is required to reissue a record')
79
+ }
80
+
81
+ return metadata
82
+ }
83
+
84
+ /**
85
+ * Read the stored upkeep metadata for a routing key, if any. A record with no
86
+ * metadata is normal (imported, or unpublished), so that is not an error; only
87
+ * an actual decode/corruption failure is logged.
88
+ */
89
+ async function readMetadata (datastore: Datastore, routingKey: Uint8Array, log: Logger, options?: AbortOptions): Promise<IPNSPublishMetadata | undefined> {
90
+ try {
91
+ return IPNSPublishMetadata.decode(await datastore.get(ipnsMetadataKey(routingKey), options))
92
+ } catch (err: any) {
93
+ if (err.name !== 'NotFoundError') {
94
+ log.error('error deserializing metadata for %b - %e', routingKey, err)
95
+ }
96
+
97
+ return undefined
98
+ }
99
+ }
100
+
46
101
  /**
47
102
  * Read/write IPNS records to the datastore as DHT records.
48
103
  *
@@ -55,18 +110,20 @@ export function localStore (datastore: Datastore, log: Logger): LocalStore {
55
110
  try {
56
111
  const key = dhtRoutingKey(routingKey)
57
112
 
58
- // don't overwrite existing, identical records as this will affect the
59
- // TTL
60
- try {
61
- const existingBuf = await datastore.get(key)
62
- const existingRecord = Record.deserialize(existingBuf)
113
+ if (options.overwrite !== true) {
114
+ // don't overwrite existing, identical records as this will affect the
115
+ // TTL
116
+ try {
117
+ const existingBuf = await datastore.get(key)
118
+ const existingRecord = Record.deserialize(existingBuf)
63
119
 
64
- if (uint8ArrayEquals(existingRecord.value, marshalledRecord)) {
65
- return
66
- }
67
- } catch (err: any) {
68
- if (err.name !== 'NotFoundError') {
69
- throw err
120
+ if (uint8ArrayEquals(existingRecord.value, marshalledRecord)) {
121
+ return
122
+ }
123
+ } catch (err: any) {
124
+ if (err.name !== 'NotFoundError') {
125
+ throw err
126
+ }
70
127
  }
71
128
  }
72
129
 
@@ -78,8 +135,10 @@ export function localStore (datastore: Datastore, log: Logger): LocalStore {
78
135
  batch.put(key, record.serialize())
79
136
 
80
137
  if (options.metadata != null) {
81
- // derive the datastore key for the IPNS metadata from the same routing key
82
- batch.put(ipnsMetadataKey(routingKey), IPNSPublishMetadata.encode(options.metadata))
138
+ // merge into any existing metadata (preserving keyName/lifetime on a
139
+ // partial update) and validate the result before writing it
140
+ const metadata = await mergeMetadata(datastore, routingKey, options.metadata, options)
141
+ batch.put(ipnsMetadataKey(routingKey), IPNSPublishMetadata.encode(metadata))
83
142
  }
84
143
  await batch.commit(options)
85
144
  } catch (err: any) {
@@ -117,6 +176,9 @@ export function localStore (datastore: Datastore, log: Logger): LocalStore {
117
176
  batch.delete(ipnsMetadataKey(routingKey))
118
177
  await batch.commit(options)
119
178
  },
179
+ async deleteMetadata (routingKey, options): Promise<void> {
180
+ await datastore.delete(ipnsMetadataKey(routingKey), options)
181
+ },
120
182
  async * list (options: ListOptions = {}): AsyncIterable<ListResult> {
121
183
  try {
122
184
  options.onProgress?.(new CustomProgressEvent('ipns:routing:datastore:list'))
@@ -134,14 +196,7 @@ export function localStore (datastore: Datastore, log: Logger): LocalStore {
134
196
  const routingKeyBase32 = keyString.substring(DHT_RECORD_PREFIX.length)
135
197
  const routingKey = uint8ArrayFromString(routingKeyBase32, 'base32')
136
198
 
137
- const metadataKey = ipnsMetadataKey(routingKey)
138
- let metadata: IPNSPublishMetadata | undefined
139
- try {
140
- const metadataBuf = await datastore.get(metadataKey, options)
141
- metadata = IPNSPublishMetadata.decode(metadataBuf)
142
- } catch (err: any) {
143
- log.error('Error deserializing metadata for %s - %e', routingKeyBase32, err)
144
- }
199
+ const metadata = await readMetadata(datastore, routingKey, log, options)
145
200
 
146
201
  yield {
147
202
  routingKey,
@@ -1,9 +1,19 @@
1
1
  syntax = "proto3";
2
2
 
3
+ enum Upkeep {
4
+ reissue = 0; // Re-sign the record with a new validity (key must be in keychain)
5
+ rebroadcast = 1; // Re-publish the record as is (while it is still valid)
6
+ none = 2; // Disables automated republishing for the record
7
+ }
8
+
3
9
  message IPNSPublishMetadata {
4
10
  // The name of the key that was used to publish the record
5
11
  string keyName = 1;
6
12
 
7
13
  // Lifetime is the duration of the signature validity in milliseconds
14
+ // Relevant for Upkeep.reissue
8
15
  uint32 lifetime = 2;
16
+
17
+ // Republishing policy
18
+ Upkeep upkeep = 3;
9
19
  }