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.
@@ -73,7 +73,7 @@ describe('from', () => {
73
73
  intent: 'charge',
74
74
  request: { amount: '1000000' },
75
75
  },
76
- expectedId: 'SOfbA51LV3LCkGE7RbomqwXdbWVlrZwlW-Z9aOHolxw',
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: 'R1ZSIwoIjkFhMCSzUGiCTesiigf5vV65EQ_3gVNtsNw',
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: 'AiMmBdsSOkOYpXTupMnzVnrzZbqMY_P2i80vENRUSN4',
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: 'FMBGqN7MzpKagHsCcartZM09CnUqv7UgmaCy45Ozgug',
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: 'SOfbA51LV3LCkGE7RbomqwXdbWVlrZwlW-Z9aOHolxw',
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: '5CXJi4bWMz2W54WjnlmoxnwTYe-JKwhw0z32ICQ65Es',
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: 'eid66xXUZsj46Pb30AfAf7m5kPehgianI16rZ-QY8HU',
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: '6kq-PYTyXtaGAHTHCVUrc_hIsAwLeskeQFtDZerMYhM',
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: '-gMjd8UeUvBcqUaUzarVj6ikH_YoDowpaNbEwK1Tmx8',
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: 'DRH9ycmIlZ2lYUatIHCrxpm9K7ig5pniZ3ulleb7vl0',
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: 'INeBi93MhinvbwdUxeUUIaT5Q_ufgLKPYZb5Tg43A1o',
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 { description, digest, method: methodName, intent, realm, request, secretKey } = parameters
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
+ })