@open-mercato/checkout 0.6.8-develop.6962.1.bd029a2bb2 → 0.6.8-develop.6964.1.36b364cfd8

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.
@@ -1,6 +1,16 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { createLogger } from '@open-mercato/shared/lib/logger'
1
3
  import { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'
2
- import { getClientIp, RATE_LIMIT_FALLBACK_KEY } from '@open-mercato/shared/lib/ratelimit/helpers'
4
+ import {
5
+ checkRateLimit,
6
+ getClientIp,
7
+ RATE_LIMIT_FALLBACK_KEY,
8
+ RATE_LIMIT_UNAVAILABLE_FALLBACK,
9
+ } from '@open-mercato/shared/lib/ratelimit/helpers'
3
10
  import type { RateLimiterService } from '@open-mercato/shared/lib/ratelimit/service'
11
+ import type { RateLimitConfig } from '@open-mercato/shared/lib/ratelimit/types'
12
+
13
+ const logger = createLogger('checkout')
4
14
 
5
15
  export function buildCheckoutRateLimitKey(
6
16
  req: Request,
@@ -11,6 +21,80 @@ export function buildCheckoutRateLimitKey(
11
21
  return `${namespace}:${clientKey}`
12
22
  }
13
23
 
24
+ /**
25
+ * `fail-closed` rejects with 503 when the limiter cannot decide, for endpoints where an
26
+ * unenforced limit is worse than a rejected request. `fail-open` lets the request through
27
+ * and only logs, for read-only endpoints.
28
+ */
29
+ export type CheckoutRateLimitPosture = 'fail-open' | 'fail-closed'
30
+
31
+ type RateLimiterContainer = {
32
+ resolve: (name: string) => unknown
33
+ hasRegistration?: (name: string) => boolean
34
+ }
35
+
36
+ /**
37
+ * `rateLimiterService` is registered conditionally — `getCachedRateLimiterService()` returns
38
+ * null when the rate-limit configuration itself is invalid, and then the DI key never exists.
39
+ * That is a deployment-time misconfiguration rather than a transient outage, so it is kept
40
+ * distinct from a limiter that is registered but cannot decide.
41
+ */
42
+ function resolveOptionalRateLimiter(container: RateLimiterContainer): RateLimiterService | null {
43
+ if (typeof container.hasRegistration === 'function' && !container.hasRegistration('rateLimiterService')) {
44
+ return null
45
+ }
46
+ try {
47
+ return (container.resolve('rateLimiterService') as RateLimiterService | undefined) ?? null
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Run the checkout rate-limit guard for one request and return the response that should be
55
+ * sent instead of handling it, or null to continue.
56
+ *
57
+ * Three outcomes are deliberately distinguished:
58
+ * - the limiter is not configured at all → logged as a configuration error and the check is
59
+ * skipped, matching `sales/quotes/accept`; the endpoint is never reported as temporarily
60
+ * unavailable for a condition that lasts until an operator fixes the environment
61
+ * - the limiter is configured but could not decide (degraded, or the check threw) → 503 under
62
+ * `fail-closed`, a logged warning under `fail-open`
63
+ * - the limiter decided the quota is exhausted → the 429 from `checkRateLimit`
64
+ */
65
+ export async function enforceCheckoutRateLimit(options: {
66
+ req: Request
67
+ container: RateLimiterContainer
68
+ config: RateLimitConfig
69
+ namespace: string
70
+ errorMessage: string
71
+ posture: CheckoutRateLimitPosture
72
+ }): Promise<NextResponse | null> {
73
+ const { req, container, config, namespace, errorMessage, posture } = options
74
+ const rateLimiter = resolveOptionalRateLimiter(container)
75
+ if (!rateLimiter) {
76
+ logger.error('Rate limiter service is not registered — check RATE_LIMIT_* configuration; checkout rate limiting is not enforced', {
77
+ namespace,
78
+ posture,
79
+ })
80
+ return null
81
+ }
82
+
83
+ try {
84
+ const key = buildCheckoutRateLimitKey(req, rateLimiter, namespace)
85
+ return await checkRateLimit(rateLimiter, config, key, errorMessage, {
86
+ failClosed: posture === 'fail-closed',
87
+ })
88
+ } catch (error) {
89
+ if (posture === 'fail-closed') {
90
+ logger.error('Checkout rate limit check failed, rejecting request', { err: error, namespace })
91
+ return NextResponse.json({ error: RATE_LIMIT_UNAVAILABLE_FALLBACK }, { status: 503 })
92
+ }
93
+ logger.warn('Checkout rate limit check failed, allowing request', { err: error, namespace })
94
+ return null
95
+ }
96
+ }
97
+
14
98
  export const checkoutPublicViewRateLimitConfig = readEndpointRateLimitConfig('CHECKOUT_PUBLIC_VIEW', {
15
99
  points: 60,
16
100
  duration: 60,