@bsv/sdk 1.8.6 → 1.8.8

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 (65) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/Peer.js +21 -6
  3. package/dist/cjs/src/auth/Peer.js.map +1 -1
  4. package/dist/cjs/src/auth/clients/AuthFetch.js +229 -13
  5. package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
  6. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js +189 -0
  7. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js.map +1 -0
  8. package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js +162 -36
  9. package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
  10. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js +134 -0
  11. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js.map +1 -0
  12. package/dist/cjs/src/kvstore/GlobalKVStore.js +26 -4
  13. package/dist/cjs/src/kvstore/GlobalKVStore.js.map +1 -1
  14. package/dist/cjs/src/kvstore/kvStoreInterpreter.js +7 -3
  15. package/dist/cjs/src/kvstore/kvStoreInterpreter.js.map +1 -1
  16. package/dist/cjs/src/kvstore/types.js +2 -1
  17. package/dist/cjs/src/kvstore/types.js.map +1 -1
  18. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  19. package/dist/esm/src/auth/Peer.js +21 -6
  20. package/dist/esm/src/auth/Peer.js.map +1 -1
  21. package/dist/esm/src/auth/clients/AuthFetch.js +229 -13
  22. package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
  23. package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js +187 -0
  24. package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js.map +1 -0
  25. package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js +162 -36
  26. package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
  27. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js +109 -0
  28. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js.map +1 -0
  29. package/dist/esm/src/kvstore/GlobalKVStore.js +26 -4
  30. package/dist/esm/src/kvstore/GlobalKVStore.js.map +1 -1
  31. package/dist/esm/src/kvstore/kvStoreInterpreter.js +7 -3
  32. package/dist/esm/src/kvstore/kvStoreInterpreter.js.map +1 -1
  33. package/dist/esm/src/kvstore/types.js +2 -1
  34. package/dist/esm/src/kvstore/types.js.map +1 -1
  35. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  36. package/dist/types/src/auth/Peer.d.ts +1 -0
  37. package/dist/types/src/auth/Peer.d.ts.map +1 -1
  38. package/dist/types/src/auth/clients/AuthFetch.d.ts +37 -0
  39. package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
  40. package/dist/types/src/auth/clients/__tests__/AuthFetch.test.d.ts +2 -0
  41. package/dist/types/src/auth/clients/__tests__/AuthFetch.test.d.ts.map +1 -0
  42. package/dist/types/src/auth/transports/SimplifiedFetchTransport.d.ts +6 -0
  43. package/dist/types/src/auth/transports/SimplifiedFetchTransport.d.ts.map +1 -1
  44. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.test.d.ts +2 -0
  45. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.test.d.ts.map +1 -0
  46. package/dist/types/src/kvstore/GlobalKVStore.d.ts.map +1 -1
  47. package/dist/types/src/kvstore/kvStoreInterpreter.d.ts +2 -1
  48. package/dist/types/src/kvstore/kvStoreInterpreter.d.ts.map +1 -1
  49. package/dist/types/src/kvstore/types.d.ts +10 -0
  50. package/dist/types/src/kvstore/types.d.ts.map +1 -1
  51. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  52. package/dist/umd/bundle.js +3 -3
  53. package/dist/umd/bundle.js.map +1 -1
  54. package/docs/reference/kvstore.md +9 -2
  55. package/package.json +1 -1
  56. package/src/auth/Peer.ts +25 -18
  57. package/src/auth/__tests/Peer.test.ts +238 -1
  58. package/src/auth/clients/AuthFetch.ts +327 -18
  59. package/src/auth/clients/__tests__/AuthFetch.test.ts +262 -0
  60. package/src/auth/transports/SimplifiedFetchTransport.ts +185 -35
  61. package/src/auth/transports/__tests__/SimplifiedFetchTransport.test.ts +126 -0
  62. package/src/kvstore/GlobalKVStore.ts +33 -8
  63. package/src/kvstore/__tests/GlobalKVStore.test.ts +129 -0
  64. package/src/kvstore/kvStoreInterpreter.ts +8 -3
  65. package/src/kvstore/types.ts +11 -1
@@ -44,27 +44,37 @@ export class SimplifiedFetchTransport implements Transport {
44
44
  return await new Promise((resolve, reject) => {
45
45
  void (async () => {
46
46
  try {
47
- const responsePromise = this.fetchClient(`${this.baseUrl}/.well-known/auth`, {
48
- method: 'POST',
49
- headers: {
50
- 'Content-Type': 'application/json'
51
- },
52
- body: JSON.stringify(message)
53
- })
47
+ const authUrl = `${this.baseUrl}/.well-known/auth`
48
+ const responsePromise = (async () => {
49
+ try {
50
+ return await this.fetchClient(authUrl, {
51
+ method: 'POST',
52
+ headers: {
53
+ 'Content-Type': 'application/json'
54
+ },
55
+ body: JSON.stringify(message)
56
+ })
57
+ } catch (error) {
58
+ throw this.createNetworkError(authUrl, error)
59
+ }
60
+ })()
54
61
 
55
62
  // For initialRequest message, mark connection as established and start pool.
56
63
  if (message.messageType !== 'initialRequest') {
57
64
  resolve()
58
65
  }
66
+
59
67
  const response = await responsePromise
60
- // Handle the response if data is received and callback is set
61
- if (response.ok && (this.onDataCallback != null)) {
68
+ if (!response.ok) {
69
+ const responseBodyArray = Array.from(new Uint8Array(await response.arrayBuffer()))
70
+ throw this.createUnauthenticatedResponseError(authUrl, response, responseBodyArray)
71
+ }
72
+
73
+ if (this.onDataCallback != null) {
62
74
  const responseMessage = await response.json()
63
75
  this.onDataCallback(responseMessage as AuthMessage)
64
- } else {
65
- // Server may be a non authenticated server
66
- throw new Error('HTTP server failed to authenticate')
67
76
  }
77
+
68
78
  if (message.messageType === 'initialRequest') {
69
79
  resolve()
70
80
  }
@@ -118,22 +128,39 @@ export class SimplifiedFetchTransport implements Transport {
118
128
  }
119
129
 
120
130
  // Send the actual fetch request to the server
121
- const response = await this.fetchClient(url, {
122
- method: httpRequestWithAuthHeaders.method,
123
- headers: httpRequestWithAuthHeaders.headers,
124
- body: httpRequestWithAuthHeaders.body
125
- })
131
+ let response: Response
132
+ try {
133
+ response = await this.fetchClient(url, {
134
+ method: httpRequestWithAuthHeaders.method,
135
+ headers: httpRequestWithAuthHeaders.headers,
136
+ body: httpRequestWithAuthHeaders.body
137
+ })
138
+ } catch (error) {
139
+ throw this.createNetworkError(url, error)
140
+ }
126
141
 
127
- // Check for an acceptable status
128
- if (response.status === 500 && (response.headers.get('x-bsv-auth-request-id') == null &&
129
- response.headers.get('x-bsv-auth-requested-certificates') == null)) {
130
- // Try parsing JSON error
131
- const errorInfo = await response.json()
132
- // Otherwise just throw whatever we got
133
- throw new Error(`HTTP ${response.status} - ${JSON.stringify(errorInfo)}`)
142
+ const responseBodyBuffer = await response.arrayBuffer()
143
+ const responseBodyArray = Array.from(new Uint8Array(responseBodyBuffer))
144
+
145
+ const missingAuthHeaders = ['x-bsv-auth-version', 'x-bsv-auth-identity-key', 'x-bsv-auth-signature']
146
+ .filter(headerName => {
147
+ const headerValue = response.headers.get(headerName)
148
+ return headerValue == null || headerValue.trim().length === 0
149
+ })
150
+
151
+ if (missingAuthHeaders.length > 0) {
152
+ throw this.createUnauthenticatedResponseError(url, response, responseBodyArray, missingAuthHeaders)
134
153
  }
135
154
 
136
- const parsedBody = await response.arrayBuffer()
155
+ const requestedCertificatesHeader = response.headers.get('x-bsv-auth-requested-certificates')
156
+ let requestedCertificates: RequestedCertificateSet | undefined
157
+ if (requestedCertificatesHeader != null) {
158
+ try {
159
+ requestedCertificates = JSON.parse(requestedCertificatesHeader) as RequestedCertificateSet
160
+ } catch (error) {
161
+ throw this.createMalformedHeaderError(url, 'x-bsv-auth-requested-certificates', requestedCertificatesHeader, error)
162
+ }
163
+ }
137
164
  const payloadWriter = new Utils.Writer()
138
165
  if (response.headers.get('x-bsv-auth-request-id') != null) {
139
166
  payloadWriter.write(Utils.toArray(response.headers.get('x-bsv-auth-request-id'), 'base64'))
@@ -171,12 +198,9 @@ export class SimplifiedFetchTransport implements Transport {
171
198
  }
172
199
 
173
200
  // Handle body
174
- if (parsedBody != null) {
175
- const bodyAsArray = Array.from(new Uint8Array(parsedBody))
176
- payloadWriter.writeVarIntNum(bodyAsArray.length)
177
- payloadWriter.write(bodyAsArray)
178
- } else {
179
- payloadWriter.writeVarIntNum(-1)
201
+ payloadWriter.writeVarIntNum(responseBodyArray.length)
202
+ if (responseBodyArray.length > 0) {
203
+ payloadWriter.write(responseBodyArray)
180
204
  }
181
205
 
182
206
  // Build the correct AuthMessage for the response
@@ -184,16 +208,16 @@ export class SimplifiedFetchTransport implements Transport {
184
208
  version: response.headers.get('x-bsv-auth-version'),
185
209
  messageType: response.headers.get('x-bsv-auth-message-type') === 'certificateRequest' ? 'certificateRequest' : 'general',
186
210
  identityKey: response.headers.get('x-bsv-auth-identity-key'),
187
- nonce: response.headers.get('x-bsv-auth-nonce'),
188
- yourNonce: response.headers.get('x-bsv-auth-your-nonce'),
189
- requestedCertificates: JSON.parse(response.headers.get('x-bsv-auth-requested-certificates')) as RequestedCertificateSet,
211
+ nonce: response.headers.get('x-bsv-auth-nonce') ?? undefined,
212
+ yourNonce: response.headers.get('x-bsv-auth-your-nonce') ?? undefined,
213
+ requestedCertificates,
190
214
  payload: payloadWriter.toArray(),
191
215
  signature: Utils.toArray(response.headers.get('x-bsv-auth-signature'), 'hex')
192
216
  }
193
217
 
194
218
  // If the server didn't provide the correct authentication headers, throw an error
195
219
  if (responseMessage.version == null) {
196
- throw new Error('HTTP server failed to authenticate')
220
+ throw this.createUnauthenticatedResponseError(url, response, responseBodyArray)
197
221
  }
198
222
 
199
223
  // Handle the response if data is received and callback is set
@@ -214,6 +238,132 @@ export class SimplifiedFetchTransport implements Transport {
214
238
  }
215
239
  }
216
240
 
241
+ private createNetworkError (url: string, originalError: unknown): Error {
242
+ const baseMessage = `Network error while sending authenticated request to ${url}`
243
+ if (originalError instanceof Error) {
244
+ const error = new Error(`${baseMessage}: ${originalError.message}`)
245
+ error.stack = originalError.stack
246
+ ;(error as any).cause = originalError
247
+ return error
248
+ }
249
+ return new Error(`${baseMessage}: ${String(originalError)}`)
250
+ }
251
+
252
+ private createUnauthenticatedResponseError (
253
+ url: string,
254
+ response: Response,
255
+ bodyBytes: number[],
256
+ missingHeaders: string[] = []
257
+ ): Error {
258
+ const statusText = (response.statusText ?? '').trim()
259
+ const statusDescription = statusText.length > 0
260
+ ? `${response.status} ${statusText}`
261
+ : `${response.status}`
262
+ const headerMessage = missingHeaders.length > 0
263
+ ? `missing headers: ${missingHeaders.join(', ')}`
264
+ : 'response lacked required BSV auth headers'
265
+ const bodyPreview = this.getBodyPreview(bodyBytes, response.headers.get('content-type'))
266
+ const parts = [`Received HTTP ${statusDescription} from ${url} without valid BSV authentication (${headerMessage})`]
267
+ if (bodyPreview != null) {
268
+ parts.push(`body preview: ${bodyPreview}`)
269
+ }
270
+
271
+ const error = new Error(parts.join(' - '))
272
+ ;(error as any).details = {
273
+ url,
274
+ status: response.status,
275
+ statusText: response.statusText,
276
+ missingHeaders,
277
+ bodyPreview
278
+ }
279
+ return error
280
+ }
281
+
282
+ private createMalformedHeaderError (
283
+ url: string,
284
+ headerName: string,
285
+ headerValue: string,
286
+ cause: unknown
287
+ ): Error {
288
+ const errorMessage = `Failed to parse ${headerName} returned by ${url}: ${headerValue}`
289
+ if (cause instanceof Error) {
290
+ const error = new Error(`${errorMessage}. ${cause.message}`)
291
+ error.stack = cause.stack
292
+ ;(error as any).cause = cause
293
+ return error
294
+ }
295
+ return new Error(`${errorMessage}. ${String(cause)}`)
296
+ }
297
+
298
+ private getBodyPreview (bodyBytes: number[], contentType: string | null): string | undefined {
299
+ if (bodyBytes.length === 0) {
300
+ return undefined
301
+ }
302
+
303
+ const maxBytesForPreview = 1024
304
+ const truncated = bodyBytes.length > maxBytesForPreview
305
+ const slice = truncated ? bodyBytes.slice(0, maxBytesForPreview) : bodyBytes
306
+ const isText = this.isTextualContent(contentType, slice)
307
+
308
+ let preview: string
309
+ if (isText) {
310
+ try {
311
+ preview = Utils.toUTF8(slice)
312
+ } catch {
313
+ preview = this.formatBinaryPreview(slice, truncated)
314
+ }
315
+ } else {
316
+ preview = this.formatBinaryPreview(slice, truncated)
317
+ }
318
+
319
+ if (preview.length > 512) {
320
+ preview = `${preview.slice(0, 512)}…`
321
+ }
322
+ if (truncated) {
323
+ preview = `${preview} (truncated)`
324
+ }
325
+ return preview
326
+ }
327
+
328
+ private isTextualContent (contentType: string | null, sample: number[]): boolean {
329
+ if (sample.length === 0) {
330
+ return false
331
+ }
332
+
333
+ if (contentType != null) {
334
+ const lowered = contentType.toLowerCase()
335
+ const textualTokens = [
336
+ 'application/json',
337
+ 'application/problem+json',
338
+ 'application/xml',
339
+ 'application/xhtml+xml',
340
+ 'application/javascript',
341
+ 'application/ecmascript',
342
+ 'application/x-www-form-urlencoded',
343
+ 'text/'
344
+ ]
345
+ if (textualTokens.some(token => lowered.includes(token)) || lowered.includes('charset=')) {
346
+ return true
347
+ }
348
+ }
349
+
350
+ const printableCount = sample.reduce((count, byte) => {
351
+ if (byte === 9 || byte === 10 || byte === 13) {
352
+ return count + 1
353
+ }
354
+ if (byte >= 32 && byte <= 126) {
355
+ return count + 1
356
+ }
357
+ return count
358
+ }, 0)
359
+ return (printableCount / sample.length) > 0.8
360
+ }
361
+
362
+ private formatBinaryPreview (bytes: number[], truncated: boolean): string {
363
+ const hex = bytes.map(byte => byte.toString(16).padStart(2, '0')).join('')
364
+ return `0x${hex}${truncated ? '…' : ''}`
365
+ }
366
+
217
367
  /**
218
368
  * Deserializes a request payload from a byte array into an HTTP request-like structure.
219
369
  *
@@ -0,0 +1,126 @@
1
+ import { jest } from '@jest/globals'
2
+ import { SimplifiedFetchTransport } from '../SimplifiedFetchTransport.js'
3
+ import * as Utils from '../../../primitives/utils.js'
4
+ import { AuthMessage } from '../../types.js'
5
+
6
+ function createGeneralPayload (path = '/resource', method = 'GET'): number[] {
7
+ const writer = new Utils.Writer()
8
+ const requestId = new Array(32).fill(1)
9
+ writer.write(requestId)
10
+
11
+ const methodBytes = Utils.toArray(method, 'utf8')
12
+ writer.writeVarIntNum(methodBytes.length)
13
+ writer.write(methodBytes)
14
+
15
+ const pathBytes = Utils.toArray(path, 'utf8')
16
+ writer.writeVarIntNum(pathBytes.length)
17
+ writer.write(pathBytes)
18
+
19
+ writer.writeVarIntNum(-1) // no query string
20
+ writer.writeVarIntNum(0) // no headers
21
+ writer.writeVarIntNum(-1) // no body
22
+
23
+ return writer.toArray()
24
+ }
25
+
26
+ function createGeneralMessage (overrides: Partial<AuthMessage> = {}): AuthMessage {
27
+ return {
28
+ version: '1.0',
29
+ messageType: 'general',
30
+ identityKey: 'client-key',
31
+ nonce: 'client-nonce',
32
+ yourNonce: 'server-nonce',
33
+ payload: createGeneralPayload(),
34
+ signature: new Array(64).fill(0),
35
+ ...overrides
36
+ }
37
+ }
38
+
39
+ afterEach(() => {
40
+ jest.restoreAllMocks()
41
+ })
42
+
43
+ describe('SimplifiedFetchTransport send', () => {
44
+ test('wraps network failures with context', async () => {
45
+ const fetchMock: jest.MockedFunction<typeof fetch> = jest.fn()
46
+ fetchMock.mockRejectedValue(new Error('network down'))
47
+ const transport = new SimplifiedFetchTransport('https://api.example.com', fetchMock as any)
48
+ await transport.onData(async () => {})
49
+ const message = createGeneralMessage()
50
+
51
+ let caught: any
52
+ await expect((async () => {
53
+ try {
54
+ await transport.send(message)
55
+ } catch (error) {
56
+ caught = error
57
+ throw error
58
+ }
59
+ })()).rejects.toThrow('Network error while sending authenticated request to https://api.example.com/resource: network down')
60
+
61
+ expect(fetchMock).toHaveBeenCalledTimes(1)
62
+ expect(fetchMock.mock.calls[0][0]).toBe('https://api.example.com/resource')
63
+ expect(caught).toBeInstanceOf(Error)
64
+ expect(caught.cause).toBeInstanceOf(Error)
65
+ expect(caught.cause?.message).toBe('network down')
66
+ })
67
+
68
+ test('throws when server omits authentication headers', async () => {
69
+ const response = new Response('missing auth', {
70
+ status: 200,
71
+ headers: {
72
+ 'Content-Type': 'text/plain'
73
+ }
74
+ })
75
+ const fetchMock: jest.MockedFunction<typeof fetch> = jest.fn()
76
+ fetchMock.mockResolvedValue(response)
77
+ const transport = new SimplifiedFetchTransport('https://api.example.com', fetchMock as any)
78
+ await transport.onData(async () => {})
79
+
80
+ const message = createGeneralMessage()
81
+
82
+ let thrown: any
83
+ await expect((async () => {
84
+ try {
85
+ await transport.send(message)
86
+ } catch (error) {
87
+ thrown = error
88
+ throw error
89
+ }
90
+ })()).rejects.toThrow('Received HTTP 200 from https://api.example.com/resource without valid BSV authentication (missing headers: x-bsv-auth-version, x-bsv-auth-identity-key, x-bsv-auth-signature)')
91
+
92
+ expect(thrown.details).toMatchObject({
93
+ url: 'https://api.example.com/resource',
94
+ status: 200,
95
+ missingHeaders: [
96
+ 'x-bsv-auth-version',
97
+ 'x-bsv-auth-identity-key',
98
+ 'x-bsv-auth-signature'
99
+ ]
100
+ })
101
+ expect(thrown.details.bodyPreview).toContain('missing auth')
102
+ })
103
+
104
+ test('rejects malformed requested certificates header', async () => {
105
+ const fetchMock: jest.MockedFunction<typeof fetch> = jest.fn()
106
+ fetchMock.mockResolvedValue(new Response('', {
107
+ status: 200,
108
+ headers: {
109
+ 'x-bsv-auth-version': '0.1',
110
+ 'x-bsv-auth-identity-key': 'server-key',
111
+ 'x-bsv-auth-signature': 'deadbeef',
112
+ 'x-bsv-auth-message-type': 'general',
113
+ 'x-bsv-auth-request-id': Utils.toBase64(new Array(32).fill(2)),
114
+ 'x-bsv-auth-requested-certificates': 'not-json'
115
+ }
116
+ }))
117
+
118
+ const transport = new SimplifiedFetchTransport('https://api.example.com', fetchMock as any)
119
+ await transport.onData(async () => {})
120
+ const message = createGeneralMessage()
121
+
122
+ await expect(transport.send(message)).rejects.toThrow(
123
+ 'Failed to parse x-bsv-auth-requested-certificates returned by https://api.example.com/resource: not-json'
124
+ )
125
+ })
126
+ })
@@ -138,6 +138,7 @@ export class GlobalKVStore {
138
138
  const tokenSetDescription = (options.tokenSetDescription != null && options.tokenSetDescription !== '') ? options.tokenSetDescription : `Create KVStore value for ${key}`
139
139
  const tokenUpdateDescription = (options.tokenUpdateDescription != null && options.tokenUpdateDescription !== '') ? options.tokenUpdateDescription : `Update KVStore value for ${key}`
140
140
  const tokenAmount = options.tokenAmount ?? this.config.tokenAmount
141
+ const tags = options.tags ?? []
141
142
 
142
143
  try {
143
144
  // Check for existing token to spend
@@ -146,13 +147,20 @@ export class GlobalKVStore {
146
147
 
147
148
  // Create PushDrop locking script
148
149
  const pushdrop = new PushDrop(this.wallet, this.config.originator)
150
+ const lockingScriptFields = [
151
+ Utils.toArray(JSON.stringify(protocolID), 'utf8'),
152
+ Utils.toArray(key, 'utf8'),
153
+ Utils.toArray(value, 'utf8'),
154
+ Utils.toArray(controller, 'hex')
155
+ ]
156
+
157
+ // Add tags as optional 5th field for backwards compatibility
158
+ if (tags.length > 0) {
159
+ lockingScriptFields.push(Utils.toArray(JSON.stringify(tags), 'utf8'))
160
+ }
161
+
149
162
  const lockingScript = await pushdrop.lock(
150
- [
151
- Utils.toArray(JSON.stringify(protocolID), 'utf8'),
152
- Utils.toArray(key, 'utf8'),
153
- Utils.toArray(value, 'utf8'),
154
- Utils.toArray(controller, 'hex')
155
- ],
163
+ lockingScriptFields,
156
164
  protocolID ?? this.config.protocolID as WalletProtocol,
157
165
  Utils.toUTF8(Utils.toArray(key, 'utf8')),
158
166
  'anyone',
@@ -409,7 +417,12 @@ export class GlobalKVStore {
409
417
  const output = tx.outputs[result.outputIndex]
410
418
  const decoded = PushDrop.decode(output.lockingScript)
411
419
 
412
- if (decoded.fields.length !== 5) {
420
+ // Support backwards compatibility: old format without tags, new format with tags
421
+ const expectedFieldCount = Object.keys(kvProtocol).length
422
+ const hasTagsField = decoded.fields.length === expectedFieldCount
423
+ const isOldFormat = decoded.fields.length === expectedFieldCount - 1
424
+
425
+ if (!isOldFormat && !hasTagsField) {
413
426
  continue
414
427
  }
415
428
 
@@ -429,11 +442,23 @@ export class GlobalKVStore {
429
442
  continue
430
443
  }
431
444
 
445
+ // Extract tags if present (backwards compatible)
446
+ let tags: string[] | undefined
447
+ if (hasTagsField && decoded.fields[kvProtocol.tags] != null) {
448
+ try {
449
+ tags = JSON.parse(Utils.toUTF8(decoded.fields[kvProtocol.tags]))
450
+ } catch (e) {
451
+ // If tags parsing fails, continue without tags
452
+ tags = undefined
453
+ }
454
+ }
455
+
432
456
  const entry: KVStoreEntry = {
433
457
  key: Utils.toUTF8(decoded.fields[kvProtocol.key]),
434
458
  value: Utils.toUTF8(decoded.fields[kvProtocol.value]),
435
459
  controller: Utils.toHex(decoded.fields[kvProtocol.controller]),
436
- protocolID: JSON.parse(Utils.toUTF8(decoded.fields[kvProtocol.protocolID]))
460
+ protocolID: JSON.parse(Utils.toUTF8(decoded.fields[kvProtocol.protocolID])),
461
+ tags
437
462
  }
438
463
 
439
464
  if (options.includeToken === true) {
@@ -287,6 +287,46 @@ describe('GlobalKVStore', () => {
287
287
  })
288
288
  })
289
289
 
290
+ it('returns entry with tags when token includes tags field', async () => {
291
+ primeResolverWithOneOutput(mockResolver)
292
+
293
+ const originalDecode = (MockPushDrop as any).decode
294
+ ;(MockPushDrop as any).decode = jest.fn().mockReturnValue({
295
+ fields: [
296
+ Array.from(Buffer.from(JSON.stringify([1, 'kvstore']))), // protocolID
297
+ Array.from(Buffer.from(TEST_KEY)), // key
298
+ Array.from(Buffer.from(TEST_VALUE)), // value
299
+ Array.from(Buffer.from(TEST_CONTROLLER, 'hex')), // controller
300
+ // tags field as JSON string so Utils.toUTF8 returns it directly
301
+ '["alpha","beta"]',
302
+ Array.from(Buffer.from('signature')) // signature
303
+ ]
304
+ })
305
+
306
+ const result = await kvStore.get({ key: TEST_KEY })
307
+
308
+ expect(Array.isArray(result)).toBe(true)
309
+ expect(result).toHaveLength(1)
310
+ if (Array.isArray(result) && result.length > 0) {
311
+ expect(result[0].tags).toEqual(['alpha', 'beta'])
312
+ }
313
+
314
+ ;(MockPushDrop as any).decode = originalDecode
315
+ })
316
+
317
+ it('omits tags when token is in old-format (no tags field)', async () => {
318
+ primeResolverWithOneOutput(mockResolver)
319
+
320
+ // primePushDropDecodeToValidValue() already sets old-format (no tags)
321
+ const result = await kvStore.get({ key: TEST_KEY })
322
+
323
+ expect(Array.isArray(result)).toBe(true)
324
+ expect(result).toHaveLength(1)
325
+ if (Array.isArray(result) && result.length > 0) {
326
+ expect(result[0].tags).toBeUndefined()
327
+ }
328
+ })
329
+
290
330
  it('returns entry with history when history=true', async () => {
291
331
  primeResolverWithOneOutput(mockResolver)
292
332
  mockHistorian.buildHistory.mockResolvedValue(['oldValue', TEST_VALUE])
@@ -320,6 +360,67 @@ describe('GlobalKVStore', () => {
320
360
  })
321
361
  })
322
362
 
363
+ it('forwards tags-only queries to the resolver', async () => {
364
+ primeResolverEmpty(mockResolver)
365
+
366
+ const tags = ['group:music', 'env:prod']
367
+ const result = await kvStore.get({ tags })
368
+
369
+ expect(Array.isArray(result)).toBe(true)
370
+ expect(mockResolver.query).toHaveBeenCalledWith({
371
+ service: 'ls_kvstore',
372
+ query: expect.objectContaining({ tags })
373
+ })
374
+ })
375
+
376
+ it('forwards tagQueryMode "all" to the resolver (default)', async () => {
377
+ primeResolverEmpty(mockResolver)
378
+
379
+ const tags = ['music', 'rock']
380
+ const result = await kvStore.get({ tags, tagQueryMode: 'all' })
381
+
382
+ expect(Array.isArray(result)).toBe(true)
383
+ expect(mockResolver.query).toHaveBeenCalledWith({
384
+ service: 'ls_kvstore',
385
+ query: expect.objectContaining({
386
+ tags,
387
+ tagQueryMode: 'all'
388
+ })
389
+ })
390
+ })
391
+
392
+ it('forwards tagQueryMode "any" to the resolver', async () => {
393
+ primeResolverEmpty(mockResolver)
394
+
395
+ const tags = ['music', 'jazz']
396
+ const result = await kvStore.get({ tags, tagQueryMode: 'any' })
397
+
398
+ expect(Array.isArray(result)).toBe(true)
399
+ expect(mockResolver.query).toHaveBeenCalledWith({
400
+ service: 'ls_kvstore',
401
+ query: expect.objectContaining({
402
+ tags,
403
+ tagQueryMode: 'any'
404
+ })
405
+ })
406
+ })
407
+
408
+ it('defaults to tagQueryMode "all" when not specified', async () => {
409
+ primeResolverEmpty(mockResolver)
410
+
411
+ const tags = ['category:news']
412
+ const result = await kvStore.get({ tags })
413
+
414
+ expect(Array.isArray(result)).toBe(true)
415
+ expect(mockResolver.query).toHaveBeenCalledWith({
416
+ service: 'ls_kvstore',
417
+ query: expect.objectContaining({ tags })
418
+ })
419
+ // Verify tagQueryMode is not explicitly set (will default to 'all' on server side)
420
+ const call = (mockResolver.query as jest.Mock).mock.calls[0][0]
421
+ expect(call.query.tagQueryMode).toBeUndefined()
422
+ })
423
+
323
424
  it('includes token data when includeToken=true for key queries', async () => {
324
425
  primeResolverWithOneOutput(mockResolver)
325
426
 
@@ -644,6 +745,34 @@ describe('GlobalKVStore', () => {
644
745
  expect(mockBroadcaster.broadcast).toHaveBeenCalled()
645
746
  })
646
747
 
748
+ it('includes tags field in locking script when options.tags provided', async () => {
749
+ primeResolverEmpty(mockResolver)
750
+
751
+ // Override PushDrop to capture the instance used within set()
752
+ const originalImpl = (MockPushDrop as any).mockImplementation
753
+ const mockLockingScript = { toHex: () => 'mockLockingScriptHex' }
754
+ const localPushDrop = {
755
+ lock: jest.fn().mockResolvedValue(mockLockingScript),
756
+ unlock: jest.fn().mockReturnValue({
757
+ sign: jest.fn().mockResolvedValue({ toHex: () => 'mockUnlockingScript' })
758
+ })
759
+ }
760
+ ;(MockPushDrop as any).mockImplementation(() => localPushDrop as any)
761
+
762
+ const providedTags = ['primary', 'news']
763
+ await kvStore.set(TEST_KEY, TEST_VALUE, { tags: providedTags })
764
+
765
+ // Validate PushDrop.lock was called with 5 fields (protocolID, key, value, controller, tags)
766
+ expect(localPushDrop.lock).toHaveBeenCalled()
767
+ const lockArgs = (localPushDrop.lock as jest.Mock).mock.calls[0]
768
+ const fields = lockArgs[0]
769
+ expect(Array.isArray(fields)).toBe(true)
770
+ expect(fields.length).toBe(5)
771
+
772
+ // Restore original implementation
773
+ ;(MockPushDrop as any).mockImplementation = originalImpl
774
+ })
775
+
647
776
  it('updates existing token when one exists', async () => {
648
777
  // Mock the queryOverlay to return an entry with a token
649
778
  const mockQueryOverlay = jest.spyOn(kvStore as any, 'queryOverlay')
@@ -10,7 +10,8 @@ export interface KVContext { key: string, protocolID: WalletProtocol }
10
10
  /**
11
11
  * KVStore interpreter used by Historian.
12
12
  *
13
- * Validates the KVStore PushDrop tokens: [protocolID, key, value, controller, signature].
13
+ * Validates the KVStore PushDrop tokens: [protocolID, key, value, controller, signature] (old format)
14
+ * or [protocolID, key, value, controller, tags, signature] (new format).
14
15
  * Filters outputs by the provided key in the interpreter context.
15
16
  * Produces the plaintext value for matching outputs; returns undefined otherwise.
16
17
  *
@@ -30,8 +31,12 @@ export const kvStoreInterpreter: InterpreterFunction<string, KVContext> = async
30
31
  // Decode the KVStore token
31
32
  const decoded = PushDrop.decode(output.lockingScript)
32
33
 
33
- // Validate KVStore token format (must have 5 fields: [protocolID, key, value, controller, signature])
34
- if (decoded.fields.length !== Object.keys(kvProtocol).length) return undefined
34
+ // Support backwards compatibility: old format without tags, new format with tags
35
+ const expectedFieldCount = Object.keys(kvProtocol).length
36
+ const hasTagsField = decoded.fields.length === expectedFieldCount
37
+ const isOldFormat = decoded.fields.length === expectedFieldCount - 1
38
+
39
+ if (!isOldFormat && !hasTagsField) return undefined
35
40
 
36
41
  // Only return values for the given key and protocolID
37
42
  const key = Utils.toUTF8(decoded.fields[kvProtocol.key])
@@ -41,6 +41,13 @@ export interface KVStoreQuery {
41
41
  key?: string
42
42
  controller?: PubKeyHex
43
43
  protocolID?: WalletProtocol
44
+ tags?: string[]
45
+ /**
46
+ * Controls tag matching behavior when tags are specified.
47
+ * - 'all': Requires all specified tags to be present (default)
48
+ * - 'any': Requires at least one of the specified tags to be present
49
+ */
50
+ tagQueryMode?: 'all' | 'any'
44
51
  limit?: number
45
52
  skip?: number
46
53
  sortOrder?: 'asc' | 'desc'
@@ -63,6 +70,7 @@ export interface KVStoreSetOptions {
63
70
  tokenSetDescription?: string
64
71
  tokenUpdateDescription?: string
65
72
  tokenAmount?: number
73
+ tags?: string[]
66
74
  }
67
75
 
68
76
  export interface KVStoreRemoveOptions {
@@ -78,6 +86,7 @@ export interface KVStoreEntry {
78
86
  value: string
79
87
  controller: PubKeyHex
80
88
  protocolID: WalletProtocol
89
+ tags?: string[]
81
90
  token?: KVStoreToken
82
91
  history?: string[]
83
92
  }
@@ -110,5 +119,6 @@ export const kvProtocol = {
110
119
  key: 1,
111
120
  value: 2,
112
121
  controller: 3,
113
- signature: 4
122
+ tags: 4,
123
+ signature: 5 // Note: signature moves to position 5 when tags are present
114
124
  }