@open-mercato/checkout 0.6.4-develop.4382.1.6b4f656b77 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.js +125 -0
- package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.js +139 -0
- package/dist/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-008.spec.js +2 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-008.spec.js.map +2 -2
- package/dist/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.js +115 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.js +66 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.js +52 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.js.map +7 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.js +44 -0
- package/dist/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.js.map +7 -0
- package/dist/modules/checkout/backend/checkout/pay-links/page.js +9 -0
- package/dist/modules/checkout/backend/checkout/pay-links/page.js.map +2 -2
- package/dist/modules/checkout/backend/checkout/templates/page.js +9 -0
- package/dist/modules/checkout/backend/checkout/templates/page.js.map +2 -2
- package/dist/modules/checkout/commands/links.js +67 -0
- package/dist/modules/checkout/commands/links.js.map +2 -2
- package/dist/modules/checkout/commands/templates.js +69 -2
- package/dist/modules/checkout/commands/templates.js.map +2 -2
- package/dist/modules/checkout/components/GatewaySettingsFields.js +32 -15
- package/dist/modules/checkout/components/GatewaySettingsFields.js.map +2 -2
- package/dist/modules/checkout/components/LinkTemplateForm.js +27 -7
- package/dist/modules/checkout/components/LinkTemplateForm.js.map +2 -2
- package/dist/modules/checkout/data/validators.js +18 -2
- package/dist/modules/checkout/data/validators.js.map +2 -2
- package/dist/modules/checkout/lib/utils.js +26 -5
- package/dist/modules/checkout/lib/utils.js.map +2 -2
- package/jest.config.cjs +2 -0
- package/package.json +7 -8
- package/src/modules/checkout/__integration__/TC-CHK-CRUDFORM-001.spec.ts +158 -0
- package/src/modules/checkout/__integration__/TC-CHK-CRUDFORM-002.spec.ts +171 -0
- package/src/modules/checkout/__integration__/TC-CHKT-008.spec.ts +4 -0
- package/src/modules/checkout/__integration__/TC-CHKT-039-null-gateway-edit.spec.ts +131 -0
- package/src/modules/checkout/__integration__/TC-CHKT-039-stale-delete-lock.spec.ts +98 -0
- package/src/modules/checkout/__integration__/TC-CHKT-040-gateway-settings-select.spec.ts +60 -0
- package/src/modules/checkout/__integration__/TC-CHKT-041-template-stale-delete-conflict.spec.ts +58 -0
- package/src/modules/checkout/backend/checkout/pay-links/page.tsx +8 -0
- package/src/modules/checkout/backend/checkout/templates/page.tsx +8 -0
- package/src/modules/checkout/commands/__tests__/optimistic-lock.test.ts +261 -0
- package/src/modules/checkout/commands/__tests__/redo-coverage.test.ts +21 -0
- package/src/modules/checkout/commands/links.ts +67 -0
- package/src/modules/checkout/commands/templates.ts +74 -2
- package/src/modules/checkout/components/GatewaySettingsFields.tsx +40 -18
- package/src/modules/checkout/components/LinkTemplateForm.tsx +27 -7
- package/src/modules/checkout/data/__tests__/validators.test.ts +66 -1
- package/src/modules/checkout/data/validators.ts +18 -2
- package/src/modules/checkout/lib/__tests__/utils.test.ts +112 -0
- package/src/modules/checkout/lib/utils.ts +41 -5
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
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(
|
|
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:
|
|
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 ===
|
|
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:
|
|
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 ?? [],
|