@helia/ipns 10.1.1 → 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 (63) 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/ipns.js +1 -1
  33. package/dist/src/pb/metadata.d.ts +14 -1
  34. package/dist/src/pb/metadata.d.ts.map +1 -1
  35. package/dist/src/pb/metadata.js +36 -3
  36. package/dist/src/pb/metadata.js.map +1 -1
  37. package/dist/src/routing/index.d.ts +13 -1
  38. package/dist/src/routing/index.d.ts.map +1 -1
  39. package/dist/src/routing/index.js +17 -0
  40. package/dist/src/routing/index.js.map +1 -1
  41. package/dist/src/utils.d.ts +5 -0
  42. package/dist/src/utils.d.ts.map +1 -1
  43. package/dist/src/utils.js +18 -0
  44. package/dist/src/utils.js.map +1 -1
  45. package/dist/src/validator.d.ts +6 -0
  46. package/dist/src/validator.d.ts.map +1 -1
  47. package/dist/src/validator.js +24 -14
  48. package/dist/src/validator.js.map +1 -1
  49. package/dist/typedoc-urls.json +23 -1
  50. package/package.json +3 -3
  51. package/src/errors.ts +18 -0
  52. package/src/index.ts +145 -7
  53. package/src/ipns/publisher.ts +22 -20
  54. package/src/ipns/republisher.ts +182 -26
  55. package/src/ipns/resolver.ts +54 -36
  56. package/src/ipns.ts +20 -6
  57. package/src/local-store.ts +77 -22
  58. package/src/pb/ipns.ts +1 -1
  59. package/src/pb/metadata.proto +10 -0
  60. package/src/pb/metadata.ts +45 -4
  61. package/src/routing/index.ts +30 -1
  62. package/src/utils.ts +19 -0
  63. package/src/validator.ts +30 -18
@@ -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,
package/src/pb/ipns.ts CHANGED
@@ -139,7 +139,7 @@ export namespace IPNSEntry {
139
139
  }
140
140
 
141
141
  return obj
142
- }, function * (reader, prefix, length, opts = {}) {
142
+ }, function * (reader, length, prefix, opts = {}) {
143
143
  const end = length == null ? reader.len : reader.pos + length
144
144
 
145
145
  if (prefix !== '.') {
@@ -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
  }
@@ -1,10 +1,29 @@
1
- import { decodeMessage, encodeMessage, message, streamMessage } from 'protons-runtime'
1
+ import { decodeMessage, encodeMessage, enumeration, message, streamMessage } from 'protons-runtime'
2
2
  import type { Codec, DecodeOptions } from 'protons-runtime'
3
3
  import type { Uint8ArrayList } from 'uint8arraylist'
4
4
 
5
+ export enum Upkeep {
6
+ reissue = 'reissue',
7
+ rebroadcast = 'rebroadcast',
8
+ none = 'none'
9
+ }
10
+
11
+ enum __UpkeepValues {
12
+ reissue = 0,
13
+ rebroadcast = 1,
14
+ none = 2
15
+ }
16
+
17
+ export namespace Upkeep {
18
+ export const codec = (): Codec<Upkeep> => {
19
+ return enumeration<Upkeep>(__UpkeepValues)
20
+ }
21
+ }
22
+
5
23
  export interface IPNSPublishMetadata {
6
24
  keyName: string
7
25
  lifetime: number
26
+ upkeep: Upkeep
8
27
  }
9
28
 
10
29
  export namespace IPNSPublishMetadata {
@@ -27,13 +46,19 @@ export namespace IPNSPublishMetadata {
27
46
  w.uint32(obj.lifetime)
28
47
  }
29
48
 
49
+ if (obj.upkeep != null && __UpkeepValues[obj.upkeep] !== 0) {
50
+ w.uint32(24)
51
+ Upkeep.codec().encode(obj.upkeep, w)
52
+ }
53
+
30
54
  if (opts.lengthDelimited !== false) {
31
55
  w.ldelim()
32
56
  }
33
57
  }, (reader, length, opts = {}) => {
34
58
  const obj: any = {
35
59
  keyName: '',
36
- lifetime: 0
60
+ lifetime: 0,
61
+ upkeep: Upkeep.reissue
37
62
  }
38
63
 
39
64
  const end = length == null ? reader.len : reader.pos + length
@@ -50,6 +75,10 @@ export namespace IPNSPublishMetadata {
50
75
  obj.lifetime = reader.uint32()
51
76
  break
52
77
  }
78
+ case 3: {
79
+ obj.upkeep = Upkeep.codec().decode(reader)
80
+ break
81
+ }
53
82
  default: {
54
83
  reader.skipType(tag & 7)
55
84
  break
@@ -58,7 +87,7 @@ export namespace IPNSPublishMetadata {
58
87
  }
59
88
 
60
89
  return obj
61
- }, function * (reader, prefix, length, opts = {}) {
90
+ }, function * (reader, length, prefix, opts = {}) {
62
91
  const end = length == null ? reader.len : reader.pos + length
63
92
 
64
93
  if (prefix !== '.') {
@@ -87,6 +116,13 @@ export namespace IPNSPublishMetadata {
87
116
  }
88
117
  break
89
118
  }
119
+ case 3: {
120
+ yield {
121
+ field: `${prefix}upkeep`,
122
+ value: Upkeep.codec().decode(reader)
123
+ }
124
+ break
125
+ }
90
126
  default: {
91
127
  reader.skipType(tag & 7)
92
128
  break
@@ -117,6 +153,11 @@ export namespace IPNSPublishMetadata {
117
153
  value: number
118
154
  }
119
155
 
156
+ export interface IPNSPublishMetadataUpkeepFieldEvent {
157
+ field: '.upkeep'
158
+ value: Upkeep
159
+ }
160
+
120
161
  export function encode (obj: Partial<IPNSPublishMetadata>): Uint8Array<ArrayBuffer> {
121
162
  return encodeMessage(obj, IPNSPublishMetadata.codec())
122
163
  }
@@ -125,7 +166,7 @@ export namespace IPNSPublishMetadata {
125
166
  return decodeMessage(buf, IPNSPublishMetadata.codec(), opts)
126
167
  }
127
168
 
128
- export function stream (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions<IPNSPublishMetadata>): Generator<IPNSPublishMetadataKeyNameFieldEvent | IPNSPublishMetadataLifetimeFieldEvent> {
169
+ export function stream (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions<IPNSPublishMetadata>): Generator<IPNSPublishMetadataKeyNameFieldEvent | IPNSPublishMetadataLifetimeFieldEvent | IPNSPublishMetadataUpkeepFieldEvent> {
129
170
  return streamMessage(buf, IPNSPublishMetadata.codec(), opts)
130
171
  }
131
172
  }
@@ -6,7 +6,14 @@ import type { AbortOptions } from '@libp2p/interface'
6
6
  import type { ProgressOptions } from 'progress-events'
7
7
 
8
8
  export interface IPNSRoutingPutOptions extends AbortOptions, ProgressOptions {
9
- metadata?: IPNSPublishMetadata
9
+ metadata?: Partial<IPNSPublishMetadata>
10
+
11
+ /**
12
+ * Overwrite an identical stored record instead of keeping the existing one,
13
+ * re-stamping its local timestamp. Passed on republish/rebroadcast so the
14
+ * record is not re-selected for republishing every cycle.
15
+ */
16
+ overwrite?: boolean
10
17
  }
11
18
 
12
19
  export interface IPNSRoutingGetOptions extends AbortOptions, ProgressOptions {
@@ -23,6 +30,28 @@ export interface IPNSRouting {
23
30
  get(routingKey: Uint8Array, options?: IPNSRoutingGetOptions): Promise<Uint8Array>
24
31
  }
25
32
 
33
+ /**
34
+ * Return a printable name for a router, falling back to `CustomRouting()` when
35
+ * the router does not set its own `toString`.
36
+ */
37
+ export function routerName (router: IPNSRouting): string {
38
+ if (router.toString === Object.prototype.toString) {
39
+ return 'CustomRouting()'
40
+ }
41
+
42
+ const name = router.toString()
43
+
44
+ if (typeof name !== 'string' || name === '') {
45
+ return 'CustomRouting()'
46
+ }
47
+
48
+ return name
49
+ }
50
+
51
+ export function isLocalStoreRouting (router: IPNSRouting): boolean {
52
+ return String(router) === 'LocalStoreRouting()'
53
+ }
54
+
26
55
  export type { DatastoreProgressEvents }
27
56
  export type { HeliaRoutingProgressEvents }
28
57
  export type { PubSubProgressEvents }
package/src/utils.ts CHANGED
@@ -281,6 +281,25 @@ export function normalizeKey (key?: PublicKey | CID | MultihashDigest | string):
281
281
  throw new InvalidValueError('Value must be a valid IPNS path starting with /')
282
282
  }
283
283
 
284
+ /**
285
+ * Normalize a key identifier for unpublish: CID/PublicKey/multihash become a
286
+ * MultihashDigest, a keychain key name stays a string.
287
+ */
288
+ export function normalizeKeyName (keyName: CID<unknown, 0x72> | PublicKey | MultihashDigest | string): MultihashDigest | string {
289
+ // a CID/PublicKey/MultihashDigest is always an IPNS name, let it throw if invalid
290
+ if (typeof keyName !== 'string') {
291
+ return normalizeKey(keyName).digest
292
+ }
293
+
294
+ try {
295
+ // a string may be an IPNS name...
296
+ return normalizeKey(keyName).digest
297
+ } catch {
298
+ // ...otherwise treat it as a keychain key name
299
+ return keyName
300
+ }
301
+ }
302
+
284
303
  export function validateCborDataMatchesPbData (entry: IPNSEntry, data: IPNSRecordData): void {
285
304
  if (!uint8ArrayEquals(data.Value, entry.value ?? new Uint8Array(0))) {
286
305
  throw new SignatureVerificationError('Field "value" did not match between protobuf and CBOR')
package/src/validator.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import NanoDate from 'timestamp-nano'
2
+ import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
2
3
  import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
3
4
  import { InvalidEmbeddedPublicKeyError, RecordExpiredError, RecordTooLargeError, SignatureVerificationError, UnsupportedValidityError } from './errors.ts'
4
5
  import { IPNSEntry } from './pb/ipns.ts'
@@ -40,24 +41,7 @@ export async function ipnsValidator (routingKey: Uint8Array, marshalledRecord: U
40
41
  const data = decodeExtensibleData(record.data)
41
42
  const validity = uint8ArrayToString(data.Validity)
42
43
 
43
- let publicKey: PublicKey | undefined
44
-
45
- // try to extract public key from routing key
46
- const routingMultihash = multihashFromIPNSRoutingKey(routingKey)
47
-
48
- // identity hash
49
- if (isCodec(routingMultihash, 0x0)) {
50
- publicKey = await keychain.loadPublicKeyFromProtobuf(routingMultihash.digest, options)
51
- }
52
-
53
- // otherwise try to load key from message
54
- if (publicKey == null && record.publicKey != null) {
55
- publicKey = await keychain.loadPublicKeyFromProtobuf(record.publicKey, options)
56
- }
57
-
58
- if (publicKey == null) {
59
- throw new InvalidEmbeddedPublicKeyError('Could not extract public key from IPNS record or routing key')
60
- }
44
+ const publicKey = await extractPublicKey(routingKey, record, keychain, options)
61
45
 
62
46
  // Validate Signature V2
63
47
  let isValid
@@ -95,6 +79,34 @@ export async function ipnsValidator (routingKey: Uint8Array, marshalledRecord: U
95
79
  return record
96
80
  }
97
81
 
82
+ /**
83
+ * Extract the public key that signed an IPNS record, either from an
84
+ * identity-hashed routing key or from the key embedded in the record.
85
+ */
86
+ export async function extractPublicKey (routingKey: Uint8Array, record: IPNSEntry, keychain: Keychain, options?: AbortOptions): Promise<PublicKey> {
87
+ const routingMultihash = multihashFromIPNSRoutingKey(routingKey)
88
+
89
+ // identity hash, the routing key is the public key
90
+ if (isCodec(routingMultihash, 0x0)) {
91
+ return keychain.loadPublicKeyFromProtobuf(routingMultihash.digest, options)
92
+ }
93
+
94
+ // otherwise the record must embed the public key, and that key must correspond
95
+ // to the routing key, otherwise the record was paired with a routing key it was
96
+ // not signed for
97
+ if (record.publicKey != null) {
98
+ const publicKey = await keychain.loadPublicKeyFromProtobuf(record.publicKey, options)
99
+
100
+ if (!uint8ArrayEquals(publicKey.toMultihash().bytes, routingMultihash.bytes)) {
101
+ throw new InvalidEmbeddedPublicKeyError('Embedded public key does not match the routing key')
102
+ }
103
+
104
+ return publicKey
105
+ }
106
+
107
+ throw new InvalidEmbeddedPublicKeyError('Could not extract public key from IPNS record or routing key')
108
+ }
109
+
98
110
  /**
99
111
  * Returns the number of milliseconds until the record expires.
100
112
  * If the record is already expired, returns 0.