@open-mercato/checkout 0.6.7-develop.6606.1.3b1ec9b1ea → 0.6.7-develop.6621.1.cdbe9dee3d

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 (25) hide show
  1. package/dist/modules/checkout/api/pay/[slug]/route.js +4 -4
  2. package/dist/modules/checkout/api/pay/[slug]/route.js.map +2 -2
  3. package/dist/modules/checkout/api/pay/[slug]/status/[transactionId]/route.js +4 -4
  4. package/dist/modules/checkout/api/pay/[slug]/status/[transactionId]/route.js.map +2 -2
  5. package/dist/modules/checkout/api/pay/[slug]/submit/route.js +5 -5
  6. package/dist/modules/checkout/api/pay/[slug]/submit/route.js.map +2 -2
  7. package/dist/modules/checkout/api/pay/[slug]/verify-password/route.js +4 -4
  8. package/dist/modules/checkout/api/pay/[slug]/verify-password/route.js.map +2 -2
  9. package/dist/modules/checkout/components/PayPage.js +18 -2
  10. package/dist/modules/checkout/components/PayPage.js.map +2 -2
  11. package/dist/modules/checkout/lib/rateLimiter.js +6 -0
  12. package/dist/modules/checkout/lib/rateLimiter.js.map +2 -2
  13. package/package.json +5 -5
  14. package/src/modules/checkout/api/pay/[slug]/route.ts +4 -4
  15. package/src/modules/checkout/api/pay/[slug]/status/[transactionId]/route.ts +4 -4
  16. package/src/modules/checkout/api/pay/[slug]/submit/__tests__/route.test.ts +2 -1
  17. package/src/modules/checkout/api/pay/[slug]/submit/route.ts +5 -5
  18. package/src/modules/checkout/api/pay/[slug]/verify-password/route.ts +4 -4
  19. package/src/modules/checkout/components/PayPage.tsx +18 -2
  20. package/src/modules/checkout/i18n/de.json +1 -0
  21. package/src/modules/checkout/i18n/en.json +1 -0
  22. package/src/modules/checkout/i18n/es.json +1 -0
  23. package/src/modules/checkout/i18n/pl.json +1 -0
  24. package/src/modules/checkout/lib/__tests__/rateLimiter.test.ts +47 -0
  25. package/src/modules/checkout/lib/rateLimiter.ts +11 -0
@@ -1,4 +1,9 @@
1
1
  import { readEndpointRateLimitConfig } from "@open-mercato/shared/lib/ratelimit/config";
2
+ import { getClientIp, RATE_LIMIT_FALLBACK_KEY } from "@open-mercato/shared/lib/ratelimit/helpers";
3
+ function buildCheckoutRateLimitKey(req, rateLimiter, namespace) {
4
+ const clientKey = getClientIp(req, rateLimiter.trustProxyDepth) ?? RATE_LIMIT_FALLBACK_KEY;
5
+ return `${namespace}:${clientKey}`;
6
+ }
2
7
  const checkoutPublicViewRateLimitConfig = readEndpointRateLimitConfig("CHECKOUT_PUBLIC_VIEW", {
3
8
  points: 60,
4
9
  duration: 60,
@@ -24,6 +29,7 @@ const checkoutPasswordRateLimitConfig = readEndpointRateLimitConfig("CHECKOUT_PA
24
29
  keyPrefix: "checkout-password"
25
30
  });
26
31
  export {
32
+ buildCheckoutRateLimitKey,
27
33
  checkoutPasswordRateLimitConfig,
28
34
  checkoutPublicViewRateLimitConfig,
29
35
  checkoutStatusRateLimitConfig,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/checkout/lib/rateLimiter.ts"],
4
- "sourcesContent": ["import { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\n\nexport const checkoutPublicViewRateLimitConfig = readEndpointRateLimitConfig('CHECKOUT_PUBLIC_VIEW', {\n points: 60,\n duration: 60,\n blockDuration: 60,\n keyPrefix: 'checkout-public-view',\n})\n\nexport const checkoutStatusRateLimitConfig = readEndpointRateLimitConfig('CHECKOUT_STATUS', {\n points: 120,\n duration: 60,\n blockDuration: 60,\n keyPrefix: 'checkout-status',\n})\n\nexport const checkoutSubmitRateLimitConfig = readEndpointRateLimitConfig('CHECKOUT_SUBMIT', {\n points: 10,\n duration: 60,\n blockDuration: 60,\n keyPrefix: 'checkout-submit',\n})\n\nexport const checkoutPasswordRateLimitConfig = readEndpointRateLimitConfig('CHECKOUT_PASSWORD', {\n points: 5,\n duration: 60,\n blockDuration: 120,\n keyPrefix: 'checkout-password',\n})\n"],
5
- "mappings": "AAAA,SAAS,mCAAmC;AAErC,MAAM,oCAAoC,4BAA4B,wBAAwB;AAAA,EACnG,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe;AAAA,EACf,WAAW;AACb,CAAC;AAEM,MAAM,gCAAgC,4BAA4B,mBAAmB;AAAA,EAC1F,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe;AAAA,EACf,WAAW;AACb,CAAC;AAEM,MAAM,gCAAgC,4BAA4B,mBAAmB;AAAA,EAC1F,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe;AAAA,EACf,WAAW;AACb,CAAC;AAEM,MAAM,kCAAkC,4BAA4B,qBAAqB;AAAA,EAC9F,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe;AAAA,EACf,WAAW;AACb,CAAC;",
4
+ "sourcesContent": ["import { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { getClientIp, RATE_LIMIT_FALLBACK_KEY } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'\n\nexport function buildCheckoutRateLimitKey(\n req: Request,\n rateLimiter: RateLimiterService,\n namespace: string,\n): string {\n const clientKey = getClientIp(req, rateLimiter.trustProxyDepth) ?? RATE_LIMIT_FALLBACK_KEY\n return `${namespace}:${clientKey}`\n}\n\nexport const checkoutPublicViewRateLimitConfig = readEndpointRateLimitConfig('CHECKOUT_PUBLIC_VIEW', {\n points: 60,\n duration: 60,\n blockDuration: 60,\n keyPrefix: 'checkout-public-view',\n})\n\nexport const checkoutStatusRateLimitConfig = readEndpointRateLimitConfig('CHECKOUT_STATUS', {\n points: 120,\n duration: 60,\n blockDuration: 60,\n keyPrefix: 'checkout-status',\n})\n\nexport const checkoutSubmitRateLimitConfig = readEndpointRateLimitConfig('CHECKOUT_SUBMIT', {\n points: 10,\n duration: 60,\n blockDuration: 60,\n keyPrefix: 'checkout-submit',\n})\n\nexport const checkoutPasswordRateLimitConfig = readEndpointRateLimitConfig('CHECKOUT_PASSWORD', {\n points: 5,\n duration: 60,\n blockDuration: 120,\n keyPrefix: 'checkout-password',\n})\n"],
5
+ "mappings": "AAAA,SAAS,mCAAmC;AAC5C,SAAS,aAAa,+BAA+B;AAG9C,SAAS,0BACd,KACA,aACA,WACQ;AACR,QAAM,YAAY,YAAY,KAAK,YAAY,eAAe,KAAK;AACnE,SAAO,GAAG,SAAS,IAAI,SAAS;AAClC;AAEO,MAAM,oCAAoC,4BAA4B,wBAAwB;AAAA,EACnG,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe;AAAA,EACf,WAAW;AACb,CAAC;AAEM,MAAM,gCAAgC,4BAA4B,mBAAmB;AAAA,EAC1F,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe;AAAA,EACf,WAAW;AACb,CAAC;AAEM,MAAM,gCAAgC,4BAA4B,mBAAmB;AAAA,EAC1F,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe;AAAA,EACf,WAAW;AACb,CAAC;AAEM,MAAM,kCAAkC,4BAA4B,qBAAqB;AAAA,EAC9F,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe;AAAA,EACf,WAAW;AACb,CAAC;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/checkout",
3
- "version": "0.6.7-develop.6606.1.3b1ec9b1ea",
3
+ "version": "0.6.7-develop.6621.1.cdbe9dee3d",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -62,18 +62,18 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@open-mercato/core": "0.6.7-develop.6606.1.3b1ec9b1ea",
66
- "@open-mercato/ui": "0.6.7-develop.6606.1.3b1ec9b1ea",
65
+ "@open-mercato/core": "0.6.7-develop.6621.1.cdbe9dee3d",
66
+ "@open-mercato/ui": "0.6.7-develop.6621.1.cdbe9dee3d",
67
67
  "bcryptjs": "^3.0.3"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@mikro-orm/postgresql": "^7.0.14",
71
- "@open-mercato/shared": "0.6.7-develop.6606.1.3b1ec9b1ea",
71
+ "@open-mercato/shared": "0.6.7-develop.6621.1.cdbe9dee3d",
72
72
  "react": "^19.0.0",
73
73
  "react-dom": "^19.0.0"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.7-develop.6606.1.3b1ec9b1ea",
76
+ "@open-mercato/shared": "0.6.7-develop.6621.1.cdbe9dee3d",
77
77
  "@types/jest": "^30.0.0",
78
78
  "@types/react": "^19.2.17",
79
79
  "@types/react-dom": "^19.2.3",
@@ -3,11 +3,11 @@ import { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fiel
3
3
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
5
  import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
6
- import { checkRateLimit, getClientIp } from '@open-mercato/shared/lib/ratelimit/helpers'
6
+ import { checkRateLimit } from '@open-mercato/shared/lib/ratelimit/helpers'
7
7
  import { CheckoutLink } from '../../../data/entities'
8
8
  import { CHECKOUT_ENTITY_IDS } from '../../../lib/constants'
9
9
  import { resolveCheckoutPublicCustomFields } from '../../../lib/customFields'
10
- import { checkoutPublicViewRateLimitConfig } from '../../../lib/rateLimiter'
10
+ import { buildCheckoutRateLimitKey, checkoutPublicViewRateLimitConfig } from '../../../lib/rateLimiter'
11
11
  import { handleCheckoutRouteError, readCheckoutAccessCookie, requirePreviewContext } from '../../helpers'
12
12
  import {
13
13
  isCheckoutLinkPublic,
@@ -32,8 +32,8 @@ export async function GET(req: Request, { params }: { params: Promise<{ slug: st
32
32
  if (!previewRequested) {
33
33
  try {
34
34
  const rateLimiter = container.resolve('rateLimiterService') as RateLimiterService
35
- const ip = getClientIp(req, 1) ?? 'unknown'
36
- const rateLimitResponse = await checkRateLimit(rateLimiter, checkoutPublicViewRateLimitConfig, `checkout-public-view:${ip}`, 'Too many checkout page requests. Please try again later.')
35
+ const key = buildCheckoutRateLimitKey(req, rateLimiter, 'checkout-public-view')
36
+ const rateLimitResponse = await checkRateLimit(rateLimiter, checkoutPublicViewRateLimitConfig, key, 'Too many checkout page requests. Please try again later.')
37
37
  if (rateLimitResponse) return rateLimitResponse
38
38
  } catch {
39
39
  // Rate limiting is fail-open
@@ -2,10 +2,10 @@ import { NextResponse } from 'next/server'
2
2
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
3
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
4
  import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
5
- import { checkRateLimit, getClientIp } from '@open-mercato/shared/lib/ratelimit/helpers'
5
+ import { checkRateLimit } from '@open-mercato/shared/lib/ratelimit/helpers'
6
6
  import type { PaymentGatewayService } from '@open-mercato/core/modules/payment_gateways/lib/gateway-service'
7
7
  import { CheckoutLink, CheckoutTransaction } from '../../../../../data/entities'
8
- import { checkoutStatusRateLimitConfig } from '../../../../../lib/rateLimiter'
8
+ import { buildCheckoutRateLimitKey, checkoutStatusRateLimitConfig } from '../../../../../lib/rateLimiter'
9
9
  import { handleCheckoutRouteError, requireCheckoutPasswordSession } from '../../../../helpers'
10
10
  import { checkoutTag } from '../../../../openapi'
11
11
 
@@ -20,8 +20,8 @@ export async function GET(req: Request, { params }: { params: Promise<{ slug: st
20
20
  const container = await createRequestContainer()
21
21
  try {
22
22
  const rateLimiter = container.resolve('rateLimiterService') as RateLimiterService
23
- const ip = getClientIp(req, 1) ?? 'unknown'
24
- const rateLimitResponse = await checkRateLimit(rateLimiter, checkoutStatusRateLimitConfig, `checkout-status:${ip}`, 'Too many checkout status requests. Please try again later.')
23
+ const key = buildCheckoutRateLimitKey(req, rateLimiter, 'checkout-status')
24
+ const rateLimitResponse = await checkRateLimit(rateLimiter, checkoutStatusRateLimitConfig, key, 'Too many checkout status requests. Please try again later.')
25
25
  if (rateLimitResponse) return rateLimitResponse
26
26
  } catch {
27
27
  // Rate limiting is fail-open
@@ -24,6 +24,7 @@ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
24
24
  jest.mock('@open-mercato/shared/lib/ratelimit/helpers', () => ({
25
25
  checkRateLimit: jest.fn(),
26
26
  getClientIp: jest.fn(),
27
+ RATE_LIMIT_FALLBACK_KEY: 'global',
27
28
  }))
28
29
 
29
30
  jest.mock('../../../../../events', () => ({
@@ -87,7 +88,7 @@ describe('POST /api/checkout/pay/[slug]/submit', () => {
87
88
  ;(getClientIp as jest.Mock).mockReturnValue('127.0.0.1')
88
89
  ;(createRequestContainer as jest.Mock).mockResolvedValue({
89
90
  resolve: (name: string) => {
90
- if (name === 'rateLimiterService') return {}
91
+ if (name === 'rateLimiterService') return { trustProxyDepth: 0 }
91
92
  if (name === 'em') return { findOne: mockEmFindOne }
92
93
  if (name === 'commandBus') return { execute: mockCommandExecute }
93
94
  if (name === 'paymentGatewayService') {
@@ -6,7 +6,7 @@ import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
6
6
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
7
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
8
8
  import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
9
- import { checkRateLimit, getClientIp } from '@open-mercato/shared/lib/ratelimit/helpers'
9
+ import { checkRateLimit } from '@open-mercato/shared/lib/ratelimit/helpers'
10
10
  import type { PaymentGatewayClientSession } from '@open-mercato/shared/modules/payment_gateways/types'
11
11
  import type { PaymentGatewayService } from '@open-mercato/core/modules/payment_gateways/lib/gateway-service'
12
12
  import { GatewayTransaction } from '@open-mercato/core/modules/payment_gateways/data/entities'
@@ -22,7 +22,7 @@ import {
22
22
  validateDescriptorCurrencies,
23
23
  } from '../../../../lib/utils'
24
24
  import { validateCheckoutCustomerData } from '../../../../lib/customerDataValidation'
25
- import { checkoutSubmitRateLimitConfig } from '../../../../lib/rateLimiter'
25
+ import { buildCheckoutRateLimitKey, checkoutSubmitRateLimitConfig } from '../../../../lib/rateLimiter'
26
26
  import { checkoutTag } from '../../../openapi'
27
27
  import { createLogger } from '@open-mercato/shared/lib/logger'
28
28
 
@@ -276,8 +276,8 @@ export async function POST(req: Request, { params }: { params: Promise<{ slug: s
276
276
  const container = await createRequestContainer()
277
277
  try {
278
278
  const rateLimiter = container.resolve('rateLimiterService') as RateLimiterService
279
- const ip = getClientIp(req, 1) ?? 'unknown'
280
- const rateLimitResponse = await checkRateLimit(rateLimiter, checkoutSubmitRateLimitConfig, `checkout-submit:${ip}`, 'Too many payment attempts. Please try again later.')
279
+ const key = buildCheckoutRateLimitKey(req, rateLimiter, 'checkout-submit')
280
+ const rateLimitResponse = await checkRateLimit(rateLimiter, checkoutSubmitRateLimitConfig, key, 'Too many payment attempts. Please try again later.')
281
281
  if (rateLimitResponse) return rateLimitResponse
282
282
  } catch {
283
283
  // Rate limiting is fail-open
@@ -525,7 +525,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ slug: s
525
525
  providerKey: link.gatewayProviderKey,
526
526
  err: error,
527
527
  })
528
- throw new CrudHttpError(502, { error: 'Unable to start the payment session' })
528
+ throw new CrudHttpError(502, { error: 'checkout.payPage.errors.sessionStart' })
529
529
  }
530
530
  const refreshedTransaction = await findOneWithDecryption(em, CheckoutTransaction, {
531
531
  id: transaction.id,
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
3
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
4
  import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
5
- import { checkRateLimit, getClientIp } from '@open-mercato/shared/lib/ratelimit/helpers'
5
+ import { checkRateLimit } from '@open-mercato/shared/lib/ratelimit/helpers'
6
6
  import { CheckoutLink } from '../../../../data/entities'
7
7
  import { CHECKOUT_PASSWORD_COOKIE } from '../../../../lib/constants'
8
8
  import { publicPasswordVerifySchema } from '../../../../data/validators'
@@ -12,7 +12,7 @@ import {
12
12
  signCheckoutAccessToken,
13
13
  verifyCheckoutPassword,
14
14
  } from '../../../../lib/utils'
15
- import { checkoutPasswordRateLimitConfig } from '../../../../lib/rateLimiter'
15
+ import { buildCheckoutRateLimitKey, checkoutPasswordRateLimitConfig } from '../../../../lib/rateLimiter'
16
16
  import { checkoutTag } from '../../../openapi'
17
17
 
18
18
  export const metadata = {
@@ -25,8 +25,8 @@ export async function POST(req: Request, { params }: { params: Promise<{ slug: s
25
25
  const container = await createRequestContainer()
26
26
  try {
27
27
  const rateLimiter = container.resolve('rateLimiterService') as RateLimiterService
28
- const ip = getClientIp(req, 1) ?? 'unknown'
29
- const rateLimitResponse = await checkRateLimit(rateLimiter, checkoutPasswordRateLimitConfig, `checkout-password:${ip}`, 'Too many password attempts. Please try again later.')
28
+ const key = buildCheckoutRateLimitKey(req, rateLimiter, 'checkout-password')
29
+ const rateLimitResponse = await checkRateLimit(rateLimiter, checkoutPasswordRateLimitConfig, key, 'Too many password attempts. Please try again later.')
30
30
  if (rateLimitResponse) return rateLimitResponse
31
31
  } catch {
32
32
  // Rate limiting is fail-open
@@ -803,6 +803,8 @@ export function PayPageCustomerForm({
803
803
  const value = customerData[field.key]
804
804
  const containerClass = field.kind === 'multiline' ? 'space-y-2 sm:col-span-2' : 'space-y-2'
805
805
  const semanticType = getCheckoutCustomerFieldSemanticType(field)
806
+ const fieldId = `checkout-customer-${field.key}`
807
+ const fieldErrorId = `${fieldId}-error`
806
808
 
807
809
  return (
808
810
  <div key={field.key} className={containerClass}>
@@ -820,6 +822,9 @@ export function PayPageCustomerForm({
820
822
  <Checkbox
821
823
  checked={value === true}
822
824
  disabled={inputsLocked}
825
+ aria-required={field.required ? true : undefined}
826
+ aria-invalid={fieldError ? true : undefined}
827
+ aria-describedby={fieldError ? fieldErrorId : undefined}
823
828
  onCheckedChange={(checked) => onFieldChange(field.key, checked === true)}
824
829
  />
825
830
  <span className="space-y-1">
@@ -836,17 +841,21 @@ export function PayPageCustomerForm({
836
841
  </label>
837
842
  ) : (
838
843
  <>
839
- <label className="text-sm font-medium">
844
+ <label htmlFor={fieldId} className="text-sm font-medium">
840
845
  {field.label}
841
846
  {field.required ? ' *' : ''}
842
847
  </label>
843
848
  {field.kind === 'multiline' ? (
844
849
  <Textarea
850
+ id={fieldId}
845
851
  className={READABLE_INPUT_CLASSNAME}
846
852
  value={typeof value === 'string' ? value : ''}
847
853
  disabled={inputsLocked}
848
854
  onChange={(event) => onFieldChange(field.key, event.target.value)}
849
855
  placeholder={field.placeholder ?? undefined}
856
+ aria-required={field.required ? true : undefined}
857
+ aria-invalid={fieldError ? true : undefined}
858
+ aria-describedby={fieldError ? fieldErrorId : undefined}
850
859
  style={buildReadableInputStyle(themeTokens, Boolean(fieldError), inputsLocked)}
851
860
  />
852
861
  ) : field.kind === 'select' || field.kind === 'radio' ? (
@@ -858,8 +867,11 @@ export function PayPageCustomerForm({
858
867
  }}
859
868
  >
860
869
  <SelectTrigger
870
+ id={fieldId}
861
871
  className={`rounded-xl ${READABLE_INPUT_CLASSNAME}`}
872
+ aria-required={field.required ? true : undefined}
862
873
  aria-invalid={Boolean(fieldError)}
874
+ aria-describedby={fieldError ? fieldErrorId : undefined}
863
875
  style={buildReadableInputStyle(themeTokens, Boolean(fieldError), inputsLocked)}
864
876
  >
865
877
  <SelectValue placeholder={t('checkout.payPage.fields.selectPlaceholder', 'Select...')} />
@@ -875,6 +887,7 @@ export function PayPageCustomerForm({
875
887
  </Select>
876
888
  ) : (
877
889
  <Input
890
+ id={fieldId}
878
891
  className={READABLE_INPUT_CLASSNAME}
879
892
  type={semanticType === 'email' ? 'email' : semanticType === 'phone' ? 'tel' : 'text'}
880
893
  value={typeof value === 'string' ? value : ''}
@@ -882,13 +895,16 @@ export function PayPageCustomerForm({
882
895
  onChange={(event) => onFieldChange(field.key, event.target.value)}
883
896
  placeholder={field.placeholder ?? undefined}
884
897
  autoComplete={semanticType === 'email' ? 'email' : semanticType === 'phone' ? 'tel' : undefined}
898
+ aria-required={field.required ? true : undefined}
899
+ aria-invalid={fieldError ? true : undefined}
900
+ aria-describedby={fieldError ? fieldErrorId : undefined}
885
901
  style={buildReadableInputStyle(themeTokens, Boolean(fieldError), inputsLocked)}
886
902
  />
887
903
  )}
888
904
  </>
889
905
  )}
890
906
  {fieldError ? (
891
- <p className="text-sm" style={buildValidationMessageStyle(themeTokens)}>
907
+ <p id={fieldErrorId} className="text-sm" style={buildValidationMessageStyle(themeTokens)}>
892
908
  {translateValidationMessage(fieldError, fieldPath)}
893
909
  </p>
894
910
  ) : null}
@@ -326,6 +326,7 @@
326
326
  "checkout.payPage.errors.loadMessage": "Diese Zahlungsseite konnte nicht geladen werden. Bitte versuche es erneut.",
327
327
  "checkout.payPage.errors.loadTitle": "Zahlungslink konnte nicht geladen werden",
328
328
  "checkout.payPage.errors.password": "Das Passwort konnte nicht verifiziert werden. Bitte versuche es erneut.",
329
+ "checkout.payPage.errors.sessionStart": "Die Zahlungssitzung konnte nicht gestartet werden. Bitte versuche es erneut.",
329
330
  "checkout.payPage.errors.submit": "Die Zahlung konnte nicht gestartet werden. Bitte versuche es erneut.",
330
331
  "checkout.payPage.fields.booleanFalse": "Nein",
331
332
  "checkout.payPage.fields.booleanTrue": "Ja",
@@ -326,6 +326,7 @@
326
326
  "checkout.payPage.errors.loadMessage": "We couldn't load this payment page. Please try again.",
327
327
  "checkout.payPage.errors.loadTitle": "Unable to load payment link",
328
328
  "checkout.payPage.errors.password": "Unable to verify password. Please try again.",
329
+ "checkout.payPage.errors.sessionStart": "Unable to start the payment session. Please try again.",
329
330
  "checkout.payPage.errors.submit": "Unable to start the payment. Please try again.",
330
331
  "checkout.payPage.fields.booleanFalse": "No",
331
332
  "checkout.payPage.fields.booleanTrue": "Yes",
@@ -326,6 +326,7 @@
326
326
  "checkout.payPage.errors.loadMessage": "No pudimos cargar esta pagina de pago. Intentalo de nuevo.",
327
327
  "checkout.payPage.errors.loadTitle": "No se pudo cargar el enlace de pago",
328
328
  "checkout.payPage.errors.password": "No se pudo verificar la contrasena. Intentalo de nuevo.",
329
+ "checkout.payPage.errors.sessionStart": "No se pudo iniciar la sesión de pago. Inténtalo de nuevo.",
329
330
  "checkout.payPage.errors.submit": "No se pudo iniciar el pago. Intentalo de nuevo.",
330
331
  "checkout.payPage.fields.booleanFalse": "No",
331
332
  "checkout.payPage.fields.booleanTrue": "Si",
@@ -326,6 +326,7 @@
326
326
  "checkout.payPage.errors.loadMessage": "Nie udało się załadować tej strony płatności. Spróbuj ponownie.",
327
327
  "checkout.payPage.errors.loadTitle": "Nie udało się załadować linku płatności",
328
328
  "checkout.payPage.errors.password": "Nie udało się zweryfikować hasła. Spróbuj ponownie.",
329
+ "checkout.payPage.errors.sessionStart": "Nie udało się uruchomić sesji płatności. Spróbuj ponownie.",
329
330
  "checkout.payPage.errors.submit": "Nie udało się rozpocząć płatności. Spróbuj ponownie.",
330
331
  "checkout.payPage.fields.booleanFalse": "Nie",
331
332
  "checkout.payPage.fields.booleanTrue": "Tak",
@@ -0,0 +1,47 @@
1
+ import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
2
+ import { buildCheckoutRateLimitKey } from '../rateLimiter'
3
+
4
+ function makeRateLimiter(trustProxyDepth: number): RateLimiterService {
5
+ return { trustProxyDepth } as RateLimiterService
6
+ }
7
+
8
+ describe('buildCheckoutRateLimitKey', () => {
9
+ it('ignores spoofed forwarding headers in direct mode and uses the bounded fallback', () => {
10
+ const req = new Request('http://localhost', {
11
+ headers: {
12
+ 'x-forwarded-for': 'attacker-controlled',
13
+ 'x-real-ip': 'attacker-controlled',
14
+ },
15
+ })
16
+
17
+ expect(buildCheckoutRateLimitKey(req, makeRateLimiter(0), 'checkout-submit'))
18
+ .toBe('checkout-submit:global')
19
+ })
20
+
21
+ it('uses the trusted right edge with one proxy', () => {
22
+ const req = new Request('http://localhost', {
23
+ headers: { 'x-forwarded-for': 'spoofed, 203.0.113.10' },
24
+ })
25
+
26
+ expect(buildCheckoutRateLimitKey(req, makeRateLimiter(1), 'checkout-password'))
27
+ .toBe('checkout-password:203.0.113.10')
28
+ })
29
+
30
+ it('uses the configured trusted depth with multiple proxies', () => {
31
+ const req = new Request('http://localhost', {
32
+ headers: { 'x-forwarded-for': 'spoofed, 203.0.113.10, 192.0.2.5' },
33
+ })
34
+
35
+ expect(buildCheckoutRateLimitKey(req, makeRateLimiter(2), 'checkout-status'))
36
+ .toBe('checkout-status:203.0.113.10')
37
+ })
38
+
39
+ it('uses the bounded fallback when the forwarded chain is undersized', () => {
40
+ const req = new Request('http://localhost', {
41
+ headers: { 'x-forwarded-for': '203.0.113.10' },
42
+ })
43
+
44
+ expect(buildCheckoutRateLimitKey(req, makeRateLimiter(2), 'checkout-public-view'))
45
+ .toBe('checkout-public-view:global')
46
+ })
47
+ })
@@ -1,4 +1,15 @@
1
1
  import { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'
2
+ import { getClientIp, RATE_LIMIT_FALLBACK_KEY } from '@open-mercato/shared/lib/ratelimit/helpers'
3
+ import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
4
+
5
+ export function buildCheckoutRateLimitKey(
6
+ req: Request,
7
+ rateLimiter: RateLimiterService,
8
+ namespace: string,
9
+ ): string {
10
+ const clientKey = getClientIp(req, rateLimiter.trustProxyDepth) ?? RATE_LIMIT_FALLBACK_KEY
11
+ return `${namespace}:${clientKey}`
12
+ }
2
13
 
3
14
  export const checkoutPublicViewRateLimitConfig = readEndpointRateLimitConfig('CHECKOUT_PUBLIC_VIEW', {
4
15
  points: 60,