@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.
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/src/auth/Peer.js +21 -6
- package/dist/cjs/src/auth/Peer.js.map +1 -1
- package/dist/cjs/src/auth/clients/AuthFetch.js +229 -13
- package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js +189 -0
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js.map +1 -0
- package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js +162 -36
- package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
- package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js +134 -0
- package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js.map +1 -0
- package/dist/cjs/src/kvstore/GlobalKVStore.js +26 -4
- package/dist/cjs/src/kvstore/GlobalKVStore.js.map +1 -1
- package/dist/cjs/src/kvstore/kvStoreInterpreter.js +7 -3
- package/dist/cjs/src/kvstore/kvStoreInterpreter.js.map +1 -1
- package/dist/cjs/src/kvstore/types.js +2 -1
- package/dist/cjs/src/kvstore/types.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/auth/Peer.js +21 -6
- package/dist/esm/src/auth/Peer.js.map +1 -1
- package/dist/esm/src/auth/clients/AuthFetch.js +229 -13
- package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js +187 -0
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js.map +1 -0
- package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js +162 -36
- package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
- package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js +109 -0
- package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js.map +1 -0
- package/dist/esm/src/kvstore/GlobalKVStore.js +26 -4
- package/dist/esm/src/kvstore/GlobalKVStore.js.map +1 -1
- package/dist/esm/src/kvstore/kvStoreInterpreter.js +7 -3
- package/dist/esm/src/kvstore/kvStoreInterpreter.js.map +1 -1
- package/dist/esm/src/kvstore/types.js +2 -1
- package/dist/esm/src/kvstore/types.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/auth/Peer.d.ts +1 -0
- package/dist/types/src/auth/Peer.d.ts.map +1 -1
- package/dist/types/src/auth/clients/AuthFetch.d.ts +37 -0
- package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
- package/dist/types/src/auth/clients/__tests__/AuthFetch.test.d.ts +2 -0
- package/dist/types/src/auth/clients/__tests__/AuthFetch.test.d.ts.map +1 -0
- package/dist/types/src/auth/transports/SimplifiedFetchTransport.d.ts +6 -0
- package/dist/types/src/auth/transports/SimplifiedFetchTransport.d.ts.map +1 -1
- package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.test.d.ts +2 -0
- package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.test.d.ts.map +1 -0
- package/dist/types/src/kvstore/GlobalKVStore.d.ts.map +1 -1
- package/dist/types/src/kvstore/kvStoreInterpreter.d.ts +2 -1
- package/dist/types/src/kvstore/kvStoreInterpreter.d.ts.map +1 -1
- package/dist/types/src/kvstore/types.d.ts +10 -0
- package/dist/types/src/kvstore/types.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +3 -3
- package/dist/umd/bundle.js.map +1 -1
- package/docs/reference/kvstore.md +9 -2
- package/package.json +1 -1
- package/src/auth/Peer.ts +25 -18
- package/src/auth/__tests/Peer.test.ts +238 -1
- package/src/auth/clients/AuthFetch.ts +327 -18
- package/src/auth/clients/__tests__/AuthFetch.test.ts +262 -0
- package/src/auth/transports/SimplifiedFetchTransport.ts +185 -35
- package/src/auth/transports/__tests__/SimplifiedFetchTransport.test.ts +126 -0
- package/src/kvstore/GlobalKVStore.ts +33 -8
- package/src/kvstore/__tests/GlobalKVStore.test.ts +129 -0
- package/src/kvstore/kvStoreInterpreter.ts +8 -3
- 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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
payloadWriter.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
34
|
-
|
|
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])
|
package/src/kvstore/types.ts
CHANGED
|
@@ -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
|
-
|
|
122
|
+
tags: 4,
|
|
123
|
+
signature: 5 // Note: signature moves to position 5 when tags are present
|
|
114
124
|
}
|