mppx 0.3.4 → 0.3.5
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/README.md +0 -52
- package/dist/Challenge.d.ts +8 -0
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +20 -4
- package/dist/Challenge.js.map +1 -1
- package/dist/cli.js +193 -66
- package/dist/cli.js.map +1 -1
- package/dist/server/Mppx.d.ts +2 -0
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +4 -3
- package/dist/server/Mppx.js.map +1 -1
- package/package.json +1 -1
- package/src/Challenge.test.ts +201 -11
- package/src/Challenge.ts +34 -4
- package/src/Store.test.ts +93 -0
- package/src/cli.test.ts +233 -37
- package/src/cli.ts +229 -79
- package/src/client/Transport.test.ts +4 -4
- package/src/internal/env.test.ts +42 -0
- package/src/server/Mppx.test.ts +173 -0
- package/src/server/Mppx.ts +6 -3
- package/src/server/Transport.test.ts +5 -5
- package/src/tempo/server/Session.test.ts +52 -0
- package/src/tempo/server/internal/transport.test.ts +285 -0
package/src/Challenge.test.ts
CHANGED
|
@@ -73,7 +73,7 @@ describe('from', () => {
|
|
|
73
73
|
intent: 'charge',
|
|
74
74
|
request: { amount: '1000000' },
|
|
75
75
|
},
|
|
76
|
-
expectedId: '
|
|
76
|
+
expectedId: 'X6v1eo7fJ76gAxqY0xN9Jd__4lUyDDYmriryOM-5FO4',
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
label: 'with expires',
|
|
@@ -84,7 +84,7 @@ describe('from', () => {
|
|
|
84
84
|
request: { amount: '1000000' },
|
|
85
85
|
expires: '2025-01-06T12:00:00Z',
|
|
86
86
|
},
|
|
87
|
-
expectedId: '
|
|
87
|
+
expectedId: 'ChPX33RkKSZoSUyZcu8ai4hhkvjZJFkZVnvWs5s0iXI',
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
label: 'with digest',
|
|
@@ -95,7 +95,7 @@ describe('from', () => {
|
|
|
95
95
|
request: { amount: '1000000' },
|
|
96
96
|
digest: 'sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE',
|
|
97
97
|
},
|
|
98
|
-
expectedId: '
|
|
98
|
+
expectedId: 'JHB7EFsPVb-xsYCo8LHcOzeX1gfXWVoUSzQsZhKAfKM',
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
label: 'with expires and digest',
|
|
@@ -107,7 +107,7 @@ describe('from', () => {
|
|
|
107
107
|
expires: '2025-01-06T12:00:00Z',
|
|
108
108
|
digest: 'sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE',
|
|
109
109
|
},
|
|
110
|
-
expectedId: '
|
|
110
|
+
expectedId: 'm39jbWWCIfmfJZSwCfvKFFtBl0Qwf9X4nOmDb21peLA',
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
113
|
label: 'with description (not in HMAC input)',
|
|
@@ -118,7 +118,7 @@ describe('from', () => {
|
|
|
118
118
|
request: { amount: '1000000' },
|
|
119
119
|
description: 'Test payment',
|
|
120
120
|
},
|
|
121
|
-
expectedId: '
|
|
121
|
+
expectedId: 'X6v1eo7fJ76gAxqY0xN9Jd__4lUyDDYmriryOM-5FO4',
|
|
122
122
|
},
|
|
123
123
|
{
|
|
124
124
|
label: 'with multi-field request',
|
|
@@ -128,7 +128,7 @@ describe('from', () => {
|
|
|
128
128
|
intent: 'charge',
|
|
129
129
|
request: { amount: '1000000', currency: '0x1234', recipient: '0xabcd' },
|
|
130
130
|
},
|
|
131
|
-
expectedId: '
|
|
131
|
+
expectedId: '_H5TOnnlW0zduQ5OhQ3EyLVze_TqxLDPda2CGZPZxOc',
|
|
132
132
|
},
|
|
133
133
|
{
|
|
134
134
|
label: 'with nested methodDetails in request',
|
|
@@ -138,7 +138,7 @@ describe('from', () => {
|
|
|
138
138
|
intent: 'charge',
|
|
139
139
|
request: { amount: '1000000', currency: '0x1234', methodDetails: { chainId: 42431 } },
|
|
140
140
|
},
|
|
141
|
-
expectedId: '
|
|
141
|
+
expectedId: 'TqujwpuDDg_zsWGINAd5XObO2rRe6uYufpqvtDmr6N8',
|
|
142
142
|
},
|
|
143
143
|
{
|
|
144
144
|
label: 'with empty request',
|
|
@@ -148,7 +148,7 @@ describe('from', () => {
|
|
|
148
148
|
intent: 'charge',
|
|
149
149
|
request: {},
|
|
150
150
|
},
|
|
151
|
-
expectedId: '
|
|
151
|
+
expectedId: 'yLN7yChAejW9WNmb54HpJIWpdb1WWXeA3_aCx4dxmkU',
|
|
152
152
|
},
|
|
153
153
|
{
|
|
154
154
|
label: 'different realm',
|
|
@@ -158,7 +158,7 @@ describe('from', () => {
|
|
|
158
158
|
intent: 'charge',
|
|
159
159
|
request: { amount: '1000000' },
|
|
160
160
|
},
|
|
161
|
-
expectedId: '-
|
|
161
|
+
expectedId: '3F5bOo2a9RUihdwKk4hGRvBvzQmVPBMDvW0YM-8GD00',
|
|
162
162
|
},
|
|
163
163
|
{
|
|
164
164
|
label: 'different method',
|
|
@@ -168,7 +168,7 @@ describe('from', () => {
|
|
|
168
168
|
intent: 'charge',
|
|
169
169
|
request: { amount: '1000000' },
|
|
170
170
|
},
|
|
171
|
-
expectedId: '
|
|
171
|
+
expectedId: 'o0ra2sd7HcB4Ph0Vns69gRDUhSj5WNOnUopcDqKPLz4',
|
|
172
172
|
},
|
|
173
173
|
{
|
|
174
174
|
label: 'different intent',
|
|
@@ -178,7 +178,7 @@ describe('from', () => {
|
|
|
178
178
|
intent: 'session',
|
|
179
179
|
request: { amount: '1000000' },
|
|
180
180
|
},
|
|
181
|
-
expectedId: '
|
|
181
|
+
expectedId: 'aAY7_IEDzsznNYplhOSE8cERQxvjFcT4Lcn-7FHjLVE',
|
|
182
182
|
},
|
|
183
183
|
] as const
|
|
184
184
|
|
|
@@ -559,3 +559,193 @@ describe('verifyId', () => {
|
|
|
559
559
|
expect(Challenge.verify(tampered, { secretKey: 'my-secret' })).toBe(false)
|
|
560
560
|
})
|
|
561
561
|
})
|
|
562
|
+
|
|
563
|
+
describe('opaque', () => {
|
|
564
|
+
test('behavior: meta sets opaque on challenge via from()', () => {
|
|
565
|
+
const challenge = Challenge.from({
|
|
566
|
+
id: 'abc123',
|
|
567
|
+
realm: 'api.example.com',
|
|
568
|
+
method: 'tempo',
|
|
569
|
+
intent: 'charge',
|
|
570
|
+
request: { amount: '1000000' },
|
|
571
|
+
meta: { pi: 'pi_3abc123XYZ' },
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
expect(challenge.opaque).toEqual({ pi: 'pi_3abc123XYZ' })
|
|
575
|
+
expect((challenge.request as Record<string, unknown>).opaque).toBeUndefined()
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
test('behavior: meta sets opaque on challenge via fromMethod()', () => {
|
|
579
|
+
const challenge = Challenge.fromMethod(Methods.charge, {
|
|
580
|
+
id: 'abc123',
|
|
581
|
+
realm: 'api.example.com',
|
|
582
|
+
request: {
|
|
583
|
+
amount: '1',
|
|
584
|
+
currency: '0x20c0000000000000000000000000000000000001',
|
|
585
|
+
decimals: 6,
|
|
586
|
+
recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
|
|
587
|
+
expires: '2025-01-06T12:00:00Z',
|
|
588
|
+
},
|
|
589
|
+
meta: { payment_intent: 'pi_3abc123XYZ' },
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
expect(challenge.opaque).toEqual({ payment_intent: 'pi_3abc123XYZ' })
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
test('behavior: challenge.opaque is undefined when no meta', () => {
|
|
596
|
+
const challenge = Challenge.from({
|
|
597
|
+
id: 'abc123',
|
|
598
|
+
realm: 'api.example.com',
|
|
599
|
+
method: 'tempo',
|
|
600
|
+
intent: 'charge',
|
|
601
|
+
request: { amount: '1000000' },
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
expect(challenge.opaque).toBeUndefined()
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
test('behavior: opaque roundtrips through serialize/deserialize', () => {
|
|
608
|
+
const original = Challenge.from({
|
|
609
|
+
id: 'abc123',
|
|
610
|
+
realm: 'api.example.com',
|
|
611
|
+
method: 'tempo',
|
|
612
|
+
intent: 'charge',
|
|
613
|
+
request: { amount: '1000000' },
|
|
614
|
+
meta: { pi: 'pi_3abc123XYZ', deposit: 'dep_456' },
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
const header = Challenge.serialize(original)
|
|
618
|
+
const deserialized = Challenge.deserialize(header)
|
|
619
|
+
|
|
620
|
+
expect(deserialized.opaque).toEqual({ pi: 'pi_3abc123XYZ', deposit: 'dep_456' })
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
test('behavior: meta with empty object produces opaque: {}', () => {
|
|
624
|
+
const challenge = Challenge.from({
|
|
625
|
+
id: 'abc123',
|
|
626
|
+
realm: 'api.example.com',
|
|
627
|
+
method: 'tempo',
|
|
628
|
+
intent: 'charge',
|
|
629
|
+
request: { amount: '1000000' },
|
|
630
|
+
meta: {},
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
expect(challenge.opaque).toEqual({})
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
test('hmac: opaque affects challenge ID', () => {
|
|
637
|
+
const withMeta = Challenge.from({
|
|
638
|
+
realm: 'api.example.com',
|
|
639
|
+
method: 'tempo',
|
|
640
|
+
intent: 'charge',
|
|
641
|
+
request: { amount: '1000000' },
|
|
642
|
+
meta: { pi: 'pi_3abc123XYZ' },
|
|
643
|
+
secretKey: 'test-secret',
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
const withoutMeta = Challenge.from({
|
|
647
|
+
realm: 'api.example.com',
|
|
648
|
+
method: 'tempo',
|
|
649
|
+
intent: 'charge',
|
|
650
|
+
request: { amount: '1000000' },
|
|
651
|
+
secretKey: 'test-secret',
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
expect(withMeta.id).not.toBe(withoutMeta.id)
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
test('hmac: different opaque values produce different IDs', () => {
|
|
658
|
+
const meta1 = Challenge.from({
|
|
659
|
+
realm: 'api.example.com',
|
|
660
|
+
method: 'tempo',
|
|
661
|
+
intent: 'charge',
|
|
662
|
+
request: { amount: '1000000' },
|
|
663
|
+
meta: { pi: 'pi_111' },
|
|
664
|
+
secretKey: 'test-secret',
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
const meta2 = Challenge.from({
|
|
668
|
+
realm: 'api.example.com',
|
|
669
|
+
method: 'tempo',
|
|
670
|
+
intent: 'charge',
|
|
671
|
+
request: { amount: '1000000' },
|
|
672
|
+
meta: { pi: 'pi_222' },
|
|
673
|
+
secretKey: 'test-secret',
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
expect(meta1.id).not.toBe(meta2.id)
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
test('hmac: same opaque produces same ID', () => {
|
|
680
|
+
const challenge1 = Challenge.from({
|
|
681
|
+
realm: 'api.example.com',
|
|
682
|
+
method: 'tempo',
|
|
683
|
+
intent: 'charge',
|
|
684
|
+
request: { amount: '1000000' },
|
|
685
|
+
meta: { pi: 'pi_3abc123XYZ' },
|
|
686
|
+
secretKey: 'test-secret',
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
const challenge2 = Challenge.from({
|
|
690
|
+
realm: 'api.example.com',
|
|
691
|
+
method: 'tempo',
|
|
692
|
+
intent: 'charge',
|
|
693
|
+
request: { amount: '1000000' },
|
|
694
|
+
meta: { pi: 'pi_3abc123XYZ' },
|
|
695
|
+
secretKey: 'test-secret',
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
expect(challenge1.id).toBe(challenge2.id)
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
test('hmac: verify succeeds with opaque', () => {
|
|
702
|
+
const challenge = Challenge.from({
|
|
703
|
+
realm: 'api.example.com',
|
|
704
|
+
method: 'tempo',
|
|
705
|
+
intent: 'charge',
|
|
706
|
+
request: { amount: '1000000' },
|
|
707
|
+
meta: { pi: 'pi_3abc123XYZ' },
|
|
708
|
+
secretKey: 'my-secret',
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
expect(Challenge.verify(challenge, { secretKey: 'my-secret' })).toBe(true)
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
test('hmac: verify detects tampered opaque', () => {
|
|
715
|
+
const challenge = Challenge.from({
|
|
716
|
+
realm: 'api.example.com',
|
|
717
|
+
method: 'tempo',
|
|
718
|
+
intent: 'charge',
|
|
719
|
+
request: { amount: '1000000' },
|
|
720
|
+
meta: { pi: 'pi_3abc123XYZ' },
|
|
721
|
+
secretKey: 'my-secret',
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
const tampered = {
|
|
725
|
+
...challenge,
|
|
726
|
+
opaque: { pi: 'pi_TAMPERED' },
|
|
727
|
+
}
|
|
728
|
+
expect(Challenge.verify(tampered, { secretKey: 'my-secret' })).toBe(false)
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
test('behavior: multiple key-value pairs in opaque', () => {
|
|
732
|
+
const challenge = Challenge.from({
|
|
733
|
+
id: 'abc123',
|
|
734
|
+
realm: 'api.example.com',
|
|
735
|
+
method: 'tempo',
|
|
736
|
+
intent: 'charge',
|
|
737
|
+
request: { amount: '1000000' },
|
|
738
|
+
meta: {
|
|
739
|
+
payment_intent: 'pi_3abc123XYZ',
|
|
740
|
+
customer: 'cus_xyz',
|
|
741
|
+
session_id: 'sess_abc',
|
|
742
|
+
},
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
expect(challenge.opaque).toEqual({
|
|
746
|
+
payment_intent: 'pi_3abc123XYZ',
|
|
747
|
+
customer: 'cus_xyz',
|
|
748
|
+
session_id: 'sess_abc',
|
|
749
|
+
})
|
|
750
|
+
})
|
|
751
|
+
})
|
package/src/Challenge.ts
CHANGED
|
@@ -27,6 +27,8 @@ export const Schema = z.object({
|
|
|
27
27
|
intent: z.string(),
|
|
28
28
|
/** Payment method (e.g., "tempo", "stripe"). */
|
|
29
29
|
method: z.string(),
|
|
30
|
+
/** Optional server-defined correlation data. Flat string-to-string map; clients MUST NOT modify. */
|
|
31
|
+
opaque: z.optional(z.record(z.string(), z.string())),
|
|
30
32
|
/** Server realm (e.g., hostname). */
|
|
31
33
|
realm: z.string(),
|
|
32
34
|
/** Method-specific request data. */
|
|
@@ -111,11 +113,20 @@ export function from<
|
|
|
111
113
|
const methods extends readonly Method.Method[] | undefined = undefined,
|
|
112
114
|
>(parameters: parameters, options?: from.Options<methods>): from.ReturnType<parameters, methods> {
|
|
113
115
|
void options
|
|
114
|
-
const {
|
|
116
|
+
const {
|
|
117
|
+
description,
|
|
118
|
+
digest,
|
|
119
|
+
meta,
|
|
120
|
+
method: methodName,
|
|
121
|
+
intent,
|
|
122
|
+
realm,
|
|
123
|
+
request,
|
|
124
|
+
secretKey,
|
|
125
|
+
} = parameters
|
|
115
126
|
|
|
116
127
|
const expires = (parameters.expires ?? request.expires) as string
|
|
117
128
|
const id = secretKey
|
|
118
|
-
? computeId({ ...parameters, expires }, { secretKey })
|
|
129
|
+
? computeId({ ...parameters, expires, ...(meta && { opaque: meta }) }, { secretKey })
|
|
119
130
|
: (parameters as { id: string }).id
|
|
120
131
|
|
|
121
132
|
return Schema.parse({
|
|
@@ -127,6 +138,7 @@ export function from<
|
|
|
127
138
|
...(description && { description }),
|
|
128
139
|
...(digest && { digest }),
|
|
129
140
|
...(expires && { expires }),
|
|
141
|
+
...(meta && { opaque: meta }),
|
|
130
142
|
}) as from.ReturnType<parameters, methods>
|
|
131
143
|
}
|
|
132
144
|
|
|
@@ -153,6 +165,8 @@ export declare namespace from {
|
|
|
153
165
|
expires?: string | undefined
|
|
154
166
|
/** Intent type (e.g., "charge", "session"). */
|
|
155
167
|
intent: string
|
|
168
|
+
/** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */
|
|
169
|
+
meta?: Record<string, string> | undefined
|
|
156
170
|
/** Payment method (e.g., "tempo", "stripe"). */
|
|
157
171
|
method: string
|
|
158
172
|
/** Server realm (e.g., hostname). */
|
|
@@ -206,7 +220,7 @@ export function fromMethod<const method extends Method.Method>(
|
|
|
206
220
|
parameters: fromMethod.Parameters<method>,
|
|
207
221
|
): fromMethod.ReturnType<method> {
|
|
208
222
|
const { name: methodName, intent } = method
|
|
209
|
-
const { description, digest, expires, id, realm, secretKey } = parameters
|
|
223
|
+
const { description, digest, expires, id, meta, realm, secretKey } = parameters
|
|
210
224
|
|
|
211
225
|
const request = PaymentRequest.fromMethod(method, parameters.request)
|
|
212
226
|
|
|
@@ -219,6 +233,7 @@ export function fromMethod<const method extends Method.Method>(
|
|
|
219
233
|
description,
|
|
220
234
|
digest,
|
|
221
235
|
expires,
|
|
236
|
+
meta,
|
|
222
237
|
} as from.Parameters) as fromMethod.ReturnType<method>
|
|
223
238
|
}
|
|
224
239
|
|
|
@@ -239,6 +254,8 @@ export declare namespace fromMethod {
|
|
|
239
254
|
digest?: string | undefined
|
|
240
255
|
/** Optional expiration timestamp (ISO 8601). */
|
|
241
256
|
expires?: string | undefined
|
|
257
|
+
/** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */
|
|
258
|
+
meta?: Record<string, string> | undefined
|
|
242
259
|
/** Server realm (e.g., hostname). */
|
|
243
260
|
realm: string
|
|
244
261
|
/** Method-specific request data. */
|
|
@@ -274,6 +291,8 @@ export function serialize(challenge: Challenge): string {
|
|
|
274
291
|
if (challenge.description !== undefined) parts.push(`description="${challenge.description}"`)
|
|
275
292
|
if (challenge.digest !== undefined) parts.push(`digest="${challenge.digest}"`)
|
|
276
293
|
if (challenge.expires !== undefined) parts.push(`expires="${challenge.expires}"`)
|
|
294
|
+
if (challenge.opaque !== undefined)
|
|
295
|
+
parts.push(`opaque="${PaymentRequest.serialize(challenge.opaque)}"`)
|
|
277
296
|
|
|
278
297
|
return `Payment ${parts.join(', ')}`
|
|
279
298
|
}
|
|
@@ -314,13 +333,14 @@ export function deserialize<const methods extends readonly Method.Method[] | und
|
|
|
314
333
|
}
|
|
315
334
|
}
|
|
316
335
|
|
|
317
|
-
const { request, ...rest } = result
|
|
336
|
+
const { request, opaque, ...rest } = result
|
|
318
337
|
if (!request) throw new Error('Missing request parameter.')
|
|
319
338
|
|
|
320
339
|
return from(
|
|
321
340
|
{
|
|
322
341
|
...rest,
|
|
323
342
|
request: PaymentRequest.deserialize(request),
|
|
343
|
+
...(opaque && { meta: PaymentRequest.deserialize(opaque) as Record<string, string> }),
|
|
324
344
|
} as from.Parameters,
|
|
325
345
|
options,
|
|
326
346
|
)
|
|
@@ -404,8 +424,17 @@ export declare namespace verify {
|
|
|
404
424
|
}
|
|
405
425
|
}
|
|
406
426
|
|
|
427
|
+
/** Alias for `challenge.opaque`. Extracts server-defined correlation data from a challenge. */
|
|
428
|
+
export function meta(challenge: Challenge): Record<string, string> | undefined {
|
|
429
|
+
return challenge.opaque
|
|
430
|
+
}
|
|
431
|
+
|
|
407
432
|
/** @internal Computes HMAC-SHA256 challenge ID from parameters. */
|
|
408
433
|
function computeId(challenge: Omit<Challenge, 'id'>, options: { secretKey: string }): string {
|
|
434
|
+
// Each field occupies a fixed positional slot joined by '|'. Optional fields
|
|
435
|
+
// use an empty string when absent so the slot count is stable — this avoids
|
|
436
|
+
// ambiguity between e.g. (expires set, no digest) vs (no expires, digest set)
|
|
437
|
+
// and means adding a new optional field changes all HMACs exactly once.
|
|
409
438
|
const input = [
|
|
410
439
|
challenge.realm,
|
|
411
440
|
challenge.method,
|
|
@@ -413,6 +442,7 @@ function computeId(challenge: Omit<Challenge, 'id'>, options: { secretKey: strin
|
|
|
413
442
|
PaymentRequest.serialize(challenge.request),
|
|
414
443
|
challenge.expires ?? '',
|
|
415
444
|
challenge.digest ?? '',
|
|
445
|
+
challenge.opaque ? PaymentRequest.serialize(challenge.opaque) : '',
|
|
416
446
|
].join('|')
|
|
417
447
|
|
|
418
448
|
const key = Bytes.fromString(options.secretKey)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import * as Store from './Store.js'
|
|
3
|
+
|
|
4
|
+
const nested = {
|
|
5
|
+
name: 'alice',
|
|
6
|
+
scores: [1, 2, 3],
|
|
7
|
+
meta: { active: true, tags: ['a', 'b'] },
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function fakeKv(): Store.cloudflare.Parameters {
|
|
11
|
+
const map = new Map<string, string>()
|
|
12
|
+
return {
|
|
13
|
+
async get(key) {
|
|
14
|
+
return map.get(key) ?? null
|
|
15
|
+
},
|
|
16
|
+
async put(key, value) {
|
|
17
|
+
map.set(key, value)
|
|
18
|
+
},
|
|
19
|
+
async delete(key) {
|
|
20
|
+
map.delete(key)
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe.each([
|
|
26
|
+
{ label: 'memory', create: () => Store.memory() },
|
|
27
|
+
{ label: 'cloudflare', create: () => Store.cloudflare(fakeKv()) },
|
|
28
|
+
{
|
|
29
|
+
label: 'upstash',
|
|
30
|
+
create: () => {
|
|
31
|
+
const kv = fakeKv()
|
|
32
|
+
return Store.upstash({
|
|
33
|
+
get: kv.get,
|
|
34
|
+
set: (key, value) => kv.put(key, value as string),
|
|
35
|
+
del: (key) => kv.delete(key),
|
|
36
|
+
})
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
])('$label', ({ create }) => {
|
|
40
|
+
test('roundtrip', async () => {
|
|
41
|
+
const store = create()
|
|
42
|
+
await store.put('k', nested)
|
|
43
|
+
expect(await store.get('k')).toEqual(nested)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('get missing key returns null', async () => {
|
|
47
|
+
const store = create()
|
|
48
|
+
expect(await store.get('missing')).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('delete removes key', async () => {
|
|
52
|
+
const store = create()
|
|
53
|
+
await store.put('k', 'value')
|
|
54
|
+
await store.delete('k')
|
|
55
|
+
expect(await store.get('k')).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('put overwrites existing value', async () => {
|
|
59
|
+
const store = create()
|
|
60
|
+
await store.put('k', 'first')
|
|
61
|
+
await store.put('k', 'second')
|
|
62
|
+
expect(await store.get('k')).toBe('second')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('json roundtrip behavior', () => {
|
|
67
|
+
test('memory json-roundtrips nested objects', async () => {
|
|
68
|
+
const store = Store.memory()
|
|
69
|
+
const value = { a: [1, { b: 'c' }], d: null }
|
|
70
|
+
await store.put('k', value)
|
|
71
|
+
expect(await store.get('k')).toEqual(value)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('cloudflare json-roundtrips nested objects', async () => {
|
|
75
|
+
const store = Store.cloudflare(fakeKv())
|
|
76
|
+
const value = { a: [1, { b: 'c' }], d: null }
|
|
77
|
+
await store.put('k', value)
|
|
78
|
+
expect(await store.get('k')).toEqual(value)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('upstash passes values through without json serialization', async () => {
|
|
82
|
+
const kv = fakeKv()
|
|
83
|
+
const store = Store.upstash({
|
|
84
|
+
get: kv.get,
|
|
85
|
+
set: (key, value) => kv.put(key, value as string),
|
|
86
|
+
del: (key) => kv.delete(key),
|
|
87
|
+
})
|
|
88
|
+
const value = { a: 1 }
|
|
89
|
+
await store.put('k', value)
|
|
90
|
+
// upstash store does not JSON-serialize; the fake map holds the original reference
|
|
91
|
+
expect(await kv.get('k')).toBe(value)
|
|
92
|
+
})
|
|
93
|
+
})
|