@open-mercato/checkout 0.6.4-develop.4371.1.8f3030407e → 0.6.4

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 (52) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.js +125 -0
  3. package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.js.map +7 -0
  4. package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.js +139 -0
  5. package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.js.map +7 -0
  6. package/dist/modules/checkout/__integration__/TC-CHKT-008.spec.js +2 -0
  7. package/dist/modules/checkout/__integration__/TC-CHKT-008.spec.js.map +2 -2
  8. package/dist/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.js +115 -0
  9. package/dist/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.js.map +7 -0
  10. package/dist/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.js +66 -0
  11. package/dist/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.js.map +7 -0
  12. package/dist/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.js +52 -0
  13. package/dist/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.js.map +7 -0
  14. package/dist/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.js +44 -0
  15. package/dist/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.js.map +7 -0
  16. package/dist/modules/checkout/backend/checkout/pay-links/page.js +9 -0
  17. package/dist/modules/checkout/backend/checkout/pay-links/page.js.map +2 -2
  18. package/dist/modules/checkout/backend/checkout/templates/page.js +9 -0
  19. package/dist/modules/checkout/backend/checkout/templates/page.js.map +2 -2
  20. package/dist/modules/checkout/commands/links.js +67 -0
  21. package/dist/modules/checkout/commands/links.js.map +2 -2
  22. package/dist/modules/checkout/commands/templates.js +69 -2
  23. package/dist/modules/checkout/commands/templates.js.map +2 -2
  24. package/dist/modules/checkout/components/GatewaySettingsFields.js +32 -15
  25. package/dist/modules/checkout/components/GatewaySettingsFields.js.map +2 -2
  26. package/dist/modules/checkout/components/LinkTemplateForm.js +27 -7
  27. package/dist/modules/checkout/components/LinkTemplateForm.js.map +2 -2
  28. package/dist/modules/checkout/data/validators.js +18 -2
  29. package/dist/modules/checkout/data/validators.js.map +2 -2
  30. package/dist/modules/checkout/lib/utils.js +26 -5
  31. package/dist/modules/checkout/lib/utils.js.map +2 -2
  32. package/jest.config.cjs +2 -0
  33. package/package.json +7 -8
  34. package/src/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.ts +158 -0
  35. package/src/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.ts +171 -0
  36. package/src/modules/checkout/__integration__/TC-CHKT-008.spec.ts +4 -0
  37. package/src/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.ts +131 -0
  38. package/src/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.ts +98 -0
  39. package/src/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.ts +60 -0
  40. package/src/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.ts +58 -0
  41. package/src/modules/checkout/backend/checkout/pay-links/page.tsx +8 -0
  42. package/src/modules/checkout/backend/checkout/templates/page.tsx +8 -0
  43. package/src/modules/checkout/commands/__tests__/optimistic-lock.test.ts +261 -0
  44. package/src/modules/checkout/commands/__tests__/redo-coverage.test.ts +21 -0
  45. package/src/modules/checkout/commands/links.ts +67 -0
  46. package/src/modules/checkout/commands/templates.ts +74 -2
  47. package/src/modules/checkout/components/GatewaySettingsFields.tsx +40 -18
  48. package/src/modules/checkout/components/LinkTemplateForm.tsx +27 -7
  49. package/src/modules/checkout/data/__tests__/validators.test.ts +66 -1
  50. package/src/modules/checkout/data/validators.ts +18 -2
  51. package/src/modules/checkout/lib/__tests__/utils.test.ts +112 -0
  52. package/src/modules/checkout/lib/utils.ts +41 -5
@@ -1,4 +1,11 @@
1
- import { createLinkSchema, createTemplateSchema } from '../validators'
1
+ import {
2
+ createLinkSchema,
3
+ createTemplateSchema,
4
+ updateLinkSchema,
5
+ updateTemplateSchema,
6
+ } from '../validators'
7
+
8
+ const TEMPLATE_ID = '020b29c5-db01-4ee3-8080-1d9b185c5e29'
2
9
 
3
10
  describe('checkout validators', () => {
4
11
  test('createTemplateSchema accepts payloads that omit optional normalized fields', () => {
@@ -44,4 +51,62 @@ describe('checkout validators', () => {
44
51
  expect(result.slug).toBeNull()
45
52
  expect(result.password).toBeNull()
46
53
  })
54
+
55
+ test('updateTemplateSchema rejects a cleared gatewayProviderKey', () => {
56
+ expect(() =>
57
+ updateTemplateSchema.parse({
58
+ id: TEMPLATE_ID,
59
+ name: 'Consulting Fee',
60
+ pricingMode: 'fixed',
61
+ fixedPriceAmount: 49.99,
62
+ fixedPriceCurrencyCode: 'USD',
63
+ gatewayProviderKey: null,
64
+ }),
65
+ ).toThrow()
66
+
67
+ expect(() =>
68
+ updateTemplateSchema.parse({
69
+ id: TEMPLATE_ID,
70
+ name: 'Consulting Fee',
71
+ pricingMode: 'fixed',
72
+ fixedPriceAmount: 49.99,
73
+ fixedPriceCurrencyCode: 'USD',
74
+ gatewayProviderKey: ' ',
75
+ }),
76
+ ).toThrow()
77
+ })
78
+
79
+ test('updateTemplateSchema accepts edits that omit gatewayProviderKey', () => {
80
+ const result = updateTemplateSchema.parse({
81
+ id: TEMPLATE_ID,
82
+ name: 'Consulting Fee renamed',
83
+ })
84
+
85
+ expect(Object.prototype.hasOwnProperty.call(result, 'gatewayProviderKey')).toBe(false)
86
+ })
87
+
88
+ test('updateLinkSchema accepts a null gatewayProviderKey (issue #2505)', () => {
89
+ const result = updateLinkSchema.parse({
90
+ id: TEMPLATE_ID,
91
+ name: 'Consulting Fee link',
92
+ pricingMode: 'fixed',
93
+ fixedPriceAmount: 49.99,
94
+ fixedPriceCurrencyCode: 'USD',
95
+ gatewayProviderKey: null,
96
+ })
97
+
98
+ expect(result.gatewayProviderKey).toBeNull()
99
+ })
100
+
101
+ test('createTemplateSchema still rejects a null gatewayProviderKey', () => {
102
+ expect(() =>
103
+ createTemplateSchema.parse({
104
+ name: 'QA template',
105
+ pricingMode: 'fixed',
106
+ fixedPriceAmount: 49.99,
107
+ fixedPriceCurrencyCode: 'USD',
108
+ gatewayProviderKey: null,
109
+ }),
110
+ ).toThrow()
111
+ })
47
112
  })
@@ -109,7 +109,7 @@ const checkoutContentSchema = z.object({
109
109
  customAmountMax: positiveMoneySchema.optional().nullable(),
110
110
  customAmountCurrencyCode: currencyCodeSchema.optional().nullable(),
111
111
  priceListItems: z.array(priceListItemSchema).optional().nullable(),
112
- gatewayProviderKey: requiredTrimmedString('checkout.validation.gatewayProviderKey.required'),
112
+ gatewayProviderKey: optionalTrimmedString,
113
113
  gatewaySettings: gatewaySettingsSchema,
114
114
  customFieldsetCode: optionalFieldsetCodeSchema,
115
115
  collectCustomerDetails: z.boolean().default(true),
@@ -174,7 +174,16 @@ function validatePricingConsistency<T extends z.infer<typeof checkoutContentSche
174
174
  }
175
175
  }
176
176
 
177
- export const createTemplateSchema = checkoutContentSchema.superRefine(validatePricingConsistency)
177
+ export const createTemplateSchema = checkoutContentSchema.superRefine((value, ctx) => {
178
+ validatePricingConsistency(value, ctx)
179
+ if (!value.gatewayProviderKey) {
180
+ ctx.addIssue({
181
+ code: z.ZodIssueCode.custom,
182
+ message: 'checkout.validation.gatewayProviderKey.required',
183
+ path: ['gatewayProviderKey'],
184
+ })
185
+ }
186
+ })
178
187
 
179
188
  function applyPartialPricingConsistency(value: Record<string, unknown>, ctx: z.RefinementCtx) {
180
189
  if (!value.pricingMode) return
@@ -195,6 +204,13 @@ export const updateTemplateSchema = checkoutContentSchema.partial().extend({
195
204
  customerFieldsSchema: z.array(customerFieldDefinitionSchema).optional(),
196
205
  }).superRefine((value, ctx) => {
197
206
  applyPartialPricingConsistency(value, ctx)
207
+ if (Object.prototype.hasOwnProperty.call(value, 'gatewayProviderKey') && !value.gatewayProviderKey) {
208
+ ctx.addIssue({
209
+ code: z.ZodIssueCode.custom,
210
+ message: 'checkout.validation.gatewayProviderKey.required',
211
+ path: ['gatewayProviderKey'],
212
+ })
213
+ }
198
214
  })
199
215
 
200
216
  export const createLinkSchema = createTemplateSchema.safeExtend({
@@ -1,3 +1,4 @@
1
+ import { createHmac } from 'crypto'
1
2
  import type { EntityManager } from '@mikro-orm/postgresql'
2
3
  import { clearPaymentGatewayDescriptors, registerPaymentGatewayDescriptor } from '@open-mercato/shared/modules/payment_gateways/types'
3
4
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
@@ -157,6 +158,32 @@ describe('checkout utils', () => {
157
158
  expect(buildConsentProof(link, { terms: true, privacyPolicy: false })).not.toHaveProperty('privacyPolicy')
158
159
  })
159
160
 
161
+ it('derives consent markdownHash from the server secret so it is tamper-evident', () => {
162
+ const link = createLink({
163
+ legalDocuments: {
164
+ terms: { title: 'Terms', markdown: 'terms body', required: true },
165
+ },
166
+ })
167
+
168
+ process.env.AUTH_SECRET = 'consent-secret-a'
169
+ const proofWithSecretA = buildConsentProof(link, { terms: true, privacyPolicy: false }) as {
170
+ terms: { markdownHash: string }
171
+ }
172
+ const hashWithSecretA = proofWithSecretA.terms.markdownHash
173
+
174
+ const forgedHashFromPublicConstant = createHmac('sha256', 'terms').update('terms body').digest('hex')
175
+ expect(hashWithSecretA).not.toBe(forgedHashFromPublicConstant)
176
+
177
+ const forgedHashFromPlainSha = createHmac('sha256', 'terms').update('terms\nterms body').digest('hex')
178
+ expect(hashWithSecretA).not.toBe(forgedHashFromPlainSha)
179
+
180
+ process.env.AUTH_SECRET = 'consent-secret-b'
181
+ const proofWithSecretB = buildConsentProof(link, { terms: true, privacyPolicy: false }) as {
182
+ terms: { markdownHash: string }
183
+ }
184
+ expect(proofWithSecretB.terms.markdownHash).not.toBe(hashWithSecretA)
185
+ })
186
+
160
187
  it('verifies password access tokens only for the matching slug', () => {
161
188
  const token = signCheckoutAccessToken('launch-offer', {
162
189
  linkId: 'link_1',
@@ -208,6 +235,91 @@ describe('checkout utils', () => {
208
235
  })).toBe(true)
209
236
  })
210
237
 
238
+ // #2675 — the checkout access cookie used to embed the link's bcrypt
239
+ // passwordHash verbatim as `sessionVersion`. The payload segment is only
240
+ // signed, not encrypted, so anyone with the cookie could base64url-decode
241
+ // it and run an offline crack against the hash. The fix derives a
242
+ // non-reversible HMAC of the input before embedding it.
243
+ describe('access cookie payload does not expose the raw sessionVersion (#2675)', () => {
244
+ const bcryptHash = '$2b$10$abcdefghijklmnopqrstuv1234567890ABCDEFGHIJKLMNOPQRSTUVwxyz12'
245
+ const BCRYPT_PREFIX_RE = /^\$2[abxy]\$/
246
+
247
+ function decodePayloadSegment(token: string): { sessionVersion?: string | null } {
248
+ const [encoded] = token.split('.')
249
+ const json = Buffer.from(encoded, 'base64url').toString('utf-8')
250
+ return JSON.parse(json) as { sessionVersion?: string | null }
251
+ }
252
+
253
+ it('never embeds a bcrypt-shaped sessionVersion in the payload', () => {
254
+ const token = signCheckoutAccessToken('launch-offer', {
255
+ linkId: 'link_1',
256
+ sessionVersion: bcryptHash,
257
+ })
258
+
259
+ const payload = decodePayloadSegment(token)
260
+ expect(payload.sessionVersion).toBeTruthy()
261
+ expect(payload.sessionVersion).not.toBe(bcryptHash)
262
+ expect(payload.sessionVersion ?? '').not.toMatch(BCRYPT_PREFIX_RE)
263
+ })
264
+
265
+ it('verifies a cookie signed with the same passwordHash', () => {
266
+ const token = signCheckoutAccessToken('launch-offer', {
267
+ linkId: 'link_1',
268
+ sessionVersion: bcryptHash,
269
+ })
270
+
271
+ expect(verifyCheckoutAccessToken(token, 'launch-offer', {
272
+ linkId: 'link_1',
273
+ sessionVersion: bcryptHash,
274
+ })).toBe(true)
275
+ })
276
+
277
+ it('rotates the cookie when the passwordHash changes (old token rejected against new hash)', () => {
278
+ const token = signCheckoutAccessToken('launch-offer', {
279
+ linkId: 'link_1',
280
+ sessionVersion: bcryptHash,
281
+ })
282
+
283
+ const rotatedHash = '$2b$10$ZZZZZZZZZZZZZZZZZZZZZuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuQ'
284
+ expect(verifyCheckoutAccessToken(token, 'launch-offer', {
285
+ linkId: 'link_1',
286
+ sessionVersion: rotatedHash,
287
+ })).toBe(false)
288
+ })
289
+
290
+ it('handles a null sessionVersion defensively without revealing input', () => {
291
+ const token = signCheckoutAccessToken('launch-offer', {
292
+ linkId: 'link_1',
293
+ sessionVersion: null,
294
+ })
295
+
296
+ const payload = decodePayloadSegment(token)
297
+ expect(payload.sessionVersion).toBeNull()
298
+ // A caller that does not enforce a sessionVersion still accepts the cookie
299
+ // (the signature + slug + linkId + exp are the floor of authenticity).
300
+ expect(verifyCheckoutAccessToken(token, 'launch-offer', {
301
+ linkId: 'link_1',
302
+ })).toBe(true)
303
+ })
304
+
305
+ it('produces a different cookie payload for two different bcrypt hashes (rotation semantics intact)', () => {
306
+ const tokenA = signCheckoutAccessToken('launch-offer', {
307
+ linkId: 'link_1',
308
+ sessionVersion: bcryptHash,
309
+ })
310
+ const tokenB = signCheckoutAccessToken('launch-offer', {
311
+ linkId: 'link_1',
312
+ sessionVersion: '$2b$10$differentdifferentdifferentdifferentdifferentdifferent',
313
+ })
314
+
315
+ const decodedA = decodePayloadSegment(tokenA).sessionVersion
316
+ const decodedB = decodePayloadSegment(tokenB).sessionVersion
317
+ expect(decodedA).toBeTruthy()
318
+ expect(decodedB).toBeTruthy()
319
+ expect(decodedA).not.toBe(decodedB)
320
+ })
321
+ })
322
+
211
323
  it('releases the link lock when a reserved transaction reaches a terminal status', () => {
212
324
  const link = createLink({
213
325
  activeReservationCount: 1,
@@ -121,6 +121,20 @@ export function toMoneyString(value: string | number | null | undefined): string
121
121
 
122
122
  export { normalizeOptionalString, buildCheckoutAttachmentPreviewUrl } from './client-utils'
123
123
 
124
+ function normalizeJsonRecord(value: unknown): Record<string, unknown> {
125
+ if (!value) return {}
126
+ if (typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>
127
+ if (typeof value !== 'string') return {}
128
+ try {
129
+ const parsed = JSON.parse(value)
130
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
131
+ ? parsed as Record<string, unknown>
132
+ : {}
133
+ } catch {
134
+ return {}
135
+ }
136
+ }
137
+
124
138
  export function deriveConfiguredCurrencies(input: TemplateOrLinkInput): string[] {
125
139
  const currencies = new Set<string>()
126
140
  if (input.pricingMode === 'fixed' && input.fixedPriceCurrencyCode) currencies.add(input.fixedPriceCurrencyCode)
@@ -156,7 +170,7 @@ export function toTemplateOrLinkMutationInput(
156
170
  customAmountCurrencyCode: record.customAmountCurrencyCode ?? null,
157
171
  priceListItems: record.priceListItems ?? null,
158
172
  gatewayProviderKey: record.gatewayProviderKey ?? '',
159
- gatewaySettings: record.gatewaySettings ?? {},
173
+ gatewaySettings: normalizeJsonRecord(record.gatewaySettings),
160
174
  customFieldsetCode: record.customFieldsetCode ?? null,
161
175
  collectCustomerDetails: record.collectCustomerDetails,
162
176
  customerFieldsSchema: (record.customerFieldsSchema ?? []) as CreateTemplateInput['customerFieldsSchema'],
@@ -250,6 +264,17 @@ function normalizeCheckoutAccessSessionVersion(value: Date | string | null | und
250
264
  return value instanceof Date ? value.toISOString() : value
251
265
  }
252
266
 
267
+ // Derive a non-reversible cookie-safe representation of `sessionVersion`
268
+ // so the embedded payload never exposes the raw input (typically the
269
+ // checkout link's bcrypt passwordHash — see #2675). The HMAC keeps
270
+ // rotation semantics intact: any change to the input produces a fresh
271
+ // digest, invalidating outstanding cookies.
272
+ function deriveCheckoutAccessSessionVersion(value: Date | string | null | undefined): string | null {
273
+ const normalized = normalizeCheckoutAccessSessionVersion(value)
274
+ if (normalized == null) return null
275
+ return createHmac('sha256', getCheckoutAccessTokenSecret()).update(normalized).digest('base64url')
276
+ }
277
+
253
278
  export function signCheckoutAccessToken(
254
279
  slug: string,
255
280
  options?: { linkId?: string | null; sessionVersion?: Date | string | null },
@@ -257,7 +282,7 @@ export function signCheckoutAccessToken(
257
282
  const payload = Buffer.from(JSON.stringify({
258
283
  slug,
259
284
  linkId: options?.linkId ?? null,
260
- sessionVersion: normalizeCheckoutAccessSessionVersion(options?.sessionVersion),
285
+ sessionVersion: deriveCheckoutAccessSessionVersion(options?.sessionVersion),
261
286
  exp: Date.now() + (60 * 60 * 1000),
262
287
  }), 'utf-8').toString('base64url')
263
288
  const signature = createHmac('sha256', getCheckoutAccessTokenSecret()).update(payload).digest('base64url')
@@ -285,7 +310,7 @@ export function verifyCheckoutAccessToken(
285
310
  if (parsed.slug !== slug || typeof parsed.exp !== 'number' || parsed.exp <= Date.now()) return false
286
311
  if (options?.linkId && parsed.linkId !== options.linkId) return false
287
312
  if (options?.sessionVersion) {
288
- return parsed.sessionVersion === normalizeCheckoutAccessSessionVersion(options.sessionVersion)
313
+ return parsed.sessionVersion === deriveCheckoutAccessSessionVersion(options.sessionVersion)
289
314
  }
290
315
  return true
291
316
  } catch {
@@ -325,6 +350,17 @@ export function applyTerminalTransactionState(
325
350
  }
326
351
  }
327
352
 
353
+ // Security model: the consent proof is a GDPR/consent audit trail, so `markdownHash`
354
+ // must be tamper-evident. It is an HMAC keyed with the server-side checkout secret
355
+ // (never a public constant), which an attacker who can edit a stored consent row
356
+ // cannot recompute without the secret. The document key is folded into the HMAC
357
+ // message to namespace each document while keeping a single server-held key.
358
+ export function computeConsentMarkdownHash(documentKey: string, markdown: string): string {
359
+ return createHmac('sha256', getCheckoutAccessTokenSecret())
360
+ .update(`${documentKey}\n${markdown}`)
361
+ .digest('hex')
362
+ }
363
+
328
364
  export function buildConsentProof(link: CheckoutLink, acceptedLegalConsents: PublicSubmitInput['acceptedLegalConsents']) {
329
365
  const proof: Record<string, unknown> = {}
330
366
  const legalDocuments = link.legalDocuments && typeof link.legalDocuments === 'object'
@@ -339,7 +375,7 @@ export function buildConsentProof(link: CheckoutLink, acceptedLegalConsents: Pub
339
375
  title: document.title ?? key,
340
376
  required: document.required === true,
341
377
  acceptedAt: new Date().toISOString(),
342
- markdownHash: createHmac('sha256', key).update(document.markdown).digest('hex'),
378
+ markdownHash: computeConsentMarkdownHash(key, document.markdown),
343
379
  }
344
380
  }
345
381
  return proof
@@ -416,7 +452,7 @@ export function serializeTemplateOrLink(record: CheckoutLinkTemplate | CheckoutL
416
452
  customAmountCurrencyCode: record.customAmountCurrencyCode ?? null,
417
453
  priceListItems: record.priceListItems ?? [],
418
454
  gatewayProviderKey: record.gatewayProviderKey ?? null,
419
- gatewaySettings: record.gatewaySettings ?? {},
455
+ gatewaySettings: normalizeJsonRecord(record.gatewaySettings),
420
456
  customFieldsetCode: record.customFieldsetCode ?? null,
421
457
  collectCustomerDetails: record.collectCustomerDetails,
422
458
  customerFieldsSchema: record.customerFieldsSchema ?? [],