@open-mercato/shared 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.
@@ -1,11 +1,13 @@
1
1
  import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
2
+ import { createLogger } from "@open-mercato/shared/lib/logger";
2
3
  const VALID_STRATEGIES = ["memory", "redis"];
4
+ const logger = createLogger("ratelimit").child({ component: "config" });
3
5
  function readRateLimitConfig() {
4
6
  const strategy = process.env.RATE_LIMIT_STRATEGY ?? "memory";
5
7
  if (!VALID_STRATEGIES.includes(strategy)) {
6
8
  throw new Error(`Invalid RATE_LIMIT_STRATEGY "${strategy}". Must be one of: ${VALID_STRATEGIES.join(", ")}`);
7
9
  }
8
- const trustProxyDepth = parsePositiveInt(process.env.RATE_LIMIT_TRUST_PROXY_DEPTH) ?? 1;
10
+ const trustProxyDepth = parseTrustProxyDepth(process.env.RATE_LIMIT_TRUST_PROXY_DEPTH);
9
11
  const integrationTest = parseBooleanWithDefault(process.env.OM_INTEGRATION_TEST, false);
10
12
  const enabled = integrationTest ? false : parseBooleanWithDefault(process.env.RATE_LIMIT_ENABLED, true);
11
13
  return {
@@ -29,6 +31,13 @@ function parsePositiveInt(raw) {
29
31
  const parsed = Number(raw);
30
32
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
31
33
  }
34
+ function parseTrustProxyDepth(raw) {
35
+ if (raw === void 0 || raw.trim() === "") return 0;
36
+ const parsed = Number(raw);
37
+ if (Number.isInteger(parsed) && parsed >= 0) return parsed;
38
+ logger.warn("Invalid RATE_LIMIT_TRUST_PROXY_DEPTH; using safe direct mode", { value: raw });
39
+ return 0;
40
+ }
32
41
  export {
33
42
  readEndpointRateLimitConfig,
34
43
  readRateLimitConfig
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/ratelimit/config.ts"],
4
- "sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport type { RateLimitConfig, RateLimitGlobalConfig, RateLimitStrategy } from './types'\n\nconst VALID_STRATEGIES: RateLimitStrategy[] = ['memory', 'redis']\n\nexport function readRateLimitConfig(): RateLimitGlobalConfig {\n const strategy = (process.env.RATE_LIMIT_STRATEGY ?? 'memory') as RateLimitStrategy\n if (!VALID_STRATEGIES.includes(strategy)) {\n throw new Error(`Invalid RATE_LIMIT_STRATEGY \"${strategy}\". Must be one of: ${VALID_STRATEGIES.join(', ')}`)\n }\n\n const trustProxyDepth = parsePositiveInt(process.env.RATE_LIMIT_TRUST_PROXY_DEPTH) ?? 1\n\n // Integration test runs disable rate limiting globally so suites do not\n // have to juggle per-endpoint bypass headers or reshape default caps.\n // The targeted OM_TEST_MODE + OM_TEST_AUTH_RATE_LIMIT_MODE=opt-in escape\n // hatch (checkAuthRateLimit) still works for suites that explicitly test\n // rate-limit behavior.\n const integrationTest = parseBooleanWithDefault(process.env.OM_INTEGRATION_TEST, false)\n const enabled = integrationTest\n ? false\n : parseBooleanWithDefault(process.env.RATE_LIMIT_ENABLED, true)\n\n return {\n enabled,\n strategy,\n keyPrefix: process.env.RATE_LIMIT_KEY_PREFIX ?? 'rl',\n redisUrl: process.env.REDIS_URL,\n trustProxyDepth,\n }\n}\n\n/**\n * Read per-endpoint rate limit config from environment variables with hardcoded defaults.\n * Environment variable names follow the pattern: RATE_LIMIT_{PREFIX}_POINTS, RATE_LIMIT_{PREFIX}_DURATION, etc.\n */\nexport function readEndpointRateLimitConfig(\n envPrefix: string,\n defaults: { points: number; duration: number; blockDuration?: number; keyPrefix: string },\n): RateLimitConfig {\n return {\n points: parsePositiveInt(process.env[`RATE_LIMIT_${envPrefix}_POINTS`]) ?? defaults.points,\n duration: parsePositiveInt(process.env[`RATE_LIMIT_${envPrefix}_DURATION`]) ?? defaults.duration,\n blockDuration: parsePositiveInt(process.env[`RATE_LIMIT_${envPrefix}_BLOCK_DURATION`]) ?? defaults.blockDuration,\n keyPrefix: defaults.keyPrefix,\n }\n}\n\nfunction parsePositiveInt(raw: string | undefined): number | undefined {\n if (raw === undefined || raw === '') return undefined\n const parsed = Number(raw)\n return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined\n}\n"],
5
- "mappings": "AAAA,SAAS,+BAA+B;AAGxC,MAAM,mBAAwC,CAAC,UAAU,OAAO;AAEzD,SAAS,sBAA6C;AAC3D,QAAM,WAAY,QAAQ,IAAI,uBAAuB;AACrD,MAAI,CAAC,iBAAiB,SAAS,QAAQ,GAAG;AACxC,UAAM,IAAI,MAAM,gCAAgC,QAAQ,sBAAsB,iBAAiB,KAAK,IAAI,CAAC,EAAE;AAAA,EAC7G;AAEA,QAAM,kBAAkB,iBAAiB,QAAQ,IAAI,4BAA4B,KAAK;AAOtF,QAAM,kBAAkB,wBAAwB,QAAQ,IAAI,qBAAqB,KAAK;AACtF,QAAM,UAAU,kBACZ,QACA,wBAAwB,QAAQ,IAAI,oBAAoB,IAAI;AAEhE,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,WAAW,QAAQ,IAAI,yBAAyB;AAAA,IAChD,UAAU,QAAQ,IAAI;AAAA,IACtB;AAAA,EACF;AACF;AAMO,SAAS,4BACd,WACA,UACiB;AACjB,SAAO;AAAA,IACL,QAAQ,iBAAiB,QAAQ,IAAI,cAAc,SAAS,SAAS,CAAC,KAAK,SAAS;AAAA,IACpF,UAAU,iBAAiB,QAAQ,IAAI,cAAc,SAAS,WAAW,CAAC,KAAK,SAAS;AAAA,IACxF,eAAe,iBAAiB,QAAQ,IAAI,cAAc,SAAS,iBAAiB,CAAC,KAAK,SAAS;AAAA,IACnG,WAAW,SAAS;AAAA,EACtB;AACF;AAEA,SAAS,iBAAiB,KAA6C;AACrE,MAAI,QAAQ,UAAa,QAAQ,GAAI,QAAO;AAC5C,QAAM,SAAS,OAAO,GAAG;AACzB,SAAO,OAAO,SAAS,MAAM,KAAK,UAAU,IAAI,SAAS;AAC3D;",
4
+ "sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { RateLimitConfig, RateLimitGlobalConfig, RateLimitStrategy } from './types'\n\nconst VALID_STRATEGIES: RateLimitStrategy[] = ['memory', 'redis']\nconst logger = createLogger('ratelimit').child({ component: 'config' })\n\nexport function readRateLimitConfig(): RateLimitGlobalConfig {\n const strategy = (process.env.RATE_LIMIT_STRATEGY ?? 'memory') as RateLimitStrategy\n if (!VALID_STRATEGIES.includes(strategy)) {\n throw new Error(`Invalid RATE_LIMIT_STRATEGY \"${strategy}\". Must be one of: ${VALID_STRATEGIES.join(', ')}`)\n }\n\n const trustProxyDepth = parseTrustProxyDepth(process.env.RATE_LIMIT_TRUST_PROXY_DEPTH)\n\n // Integration test runs disable rate limiting globally so suites do not\n // have to juggle per-endpoint bypass headers or reshape default caps.\n // The targeted OM_TEST_MODE + OM_TEST_AUTH_RATE_LIMIT_MODE=opt-in escape\n // hatch (checkAuthRateLimit) still works for suites that explicitly test\n // rate-limit behavior.\n const integrationTest = parseBooleanWithDefault(process.env.OM_INTEGRATION_TEST, false)\n const enabled = integrationTest\n ? false\n : parseBooleanWithDefault(process.env.RATE_LIMIT_ENABLED, true)\n\n return {\n enabled,\n strategy,\n keyPrefix: process.env.RATE_LIMIT_KEY_PREFIX ?? 'rl',\n redisUrl: process.env.REDIS_URL,\n trustProxyDepth,\n }\n}\n\n/**\n * Read per-endpoint rate limit config from environment variables with hardcoded defaults.\n * Environment variable names follow the pattern: RATE_LIMIT_{PREFIX}_POINTS, RATE_LIMIT_{PREFIX}_DURATION, etc.\n */\nexport function readEndpointRateLimitConfig(\n envPrefix: string,\n defaults: { points: number; duration: number; blockDuration?: number; keyPrefix: string },\n): RateLimitConfig {\n return {\n points: parsePositiveInt(process.env[`RATE_LIMIT_${envPrefix}_POINTS`]) ?? defaults.points,\n duration: parsePositiveInt(process.env[`RATE_LIMIT_${envPrefix}_DURATION`]) ?? defaults.duration,\n blockDuration: parsePositiveInt(process.env[`RATE_LIMIT_${envPrefix}_BLOCK_DURATION`]) ?? defaults.blockDuration,\n keyPrefix: defaults.keyPrefix,\n }\n}\n\nfunction parsePositiveInt(raw: string | undefined): number | undefined {\n if (raw === undefined || raw === '') return undefined\n const parsed = Number(raw)\n return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined\n}\n\nfunction parseTrustProxyDepth(raw: string | undefined): number {\n if (raw === undefined || raw.trim() === '') return 0\n const parsed = Number(raw)\n if (Number.isInteger(parsed) && parsed >= 0) return parsed\n logger.warn('Invalid RATE_LIMIT_TRUST_PROXY_DEPTH; using safe direct mode', { value: raw })\n return 0\n}\n"],
5
+ "mappings": "AAAA,SAAS,+BAA+B;AACxC,SAAS,oBAAoB;AAG7B,MAAM,mBAAwC,CAAC,UAAU,OAAO;AAChE,MAAM,SAAS,aAAa,WAAW,EAAE,MAAM,EAAE,WAAW,SAAS,CAAC;AAE/D,SAAS,sBAA6C;AAC3D,QAAM,WAAY,QAAQ,IAAI,uBAAuB;AACrD,MAAI,CAAC,iBAAiB,SAAS,QAAQ,GAAG;AACxC,UAAM,IAAI,MAAM,gCAAgC,QAAQ,sBAAsB,iBAAiB,KAAK,IAAI,CAAC,EAAE;AAAA,EAC7G;AAEA,QAAM,kBAAkB,qBAAqB,QAAQ,IAAI,4BAA4B;AAOrF,QAAM,kBAAkB,wBAAwB,QAAQ,IAAI,qBAAqB,KAAK;AACtF,QAAM,UAAU,kBACZ,QACA,wBAAwB,QAAQ,IAAI,oBAAoB,IAAI;AAEhE,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,WAAW,QAAQ,IAAI,yBAAyB;AAAA,IAChD,UAAU,QAAQ,IAAI;AAAA,IACtB;AAAA,EACF;AACF;AAMO,SAAS,4BACd,WACA,UACiB;AACjB,SAAO;AAAA,IACL,QAAQ,iBAAiB,QAAQ,IAAI,cAAc,SAAS,SAAS,CAAC,KAAK,SAAS;AAAA,IACpF,UAAU,iBAAiB,QAAQ,IAAI,cAAc,SAAS,WAAW,CAAC,KAAK,SAAS;AAAA,IACxF,eAAe,iBAAiB,QAAQ,IAAI,cAAc,SAAS,iBAAiB,CAAC,KAAK,SAAS;AAAA,IACnG,WAAW,SAAS;AAAA,EACtB;AACF;AAEA,SAAS,iBAAiB,KAA6C;AACrE,MAAI,QAAQ,UAAa,QAAQ,GAAI,QAAO;AAC5C,QAAM,SAAS,OAAO,GAAG;AACzB,SAAO,OAAO,SAAS,MAAM,KAAK,UAAU,IAAI,SAAS;AAC3D;AAEA,SAAS,qBAAqB,KAAiC;AAC7D,MAAI,QAAQ,UAAa,IAAI,KAAK,MAAM,GAAI,QAAO;AACnD,QAAM,SAAS,OAAO,GAAG;AACzB,MAAI,OAAO,UAAU,MAAM,KAAK,UAAU,EAAG,QAAO;AACpD,SAAO,KAAK,gEAAgE,EAAE,OAAO,IAAI,CAAC;AAC1F,SAAO;AACT;",
6
6
  "names": []
7
7
  }
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
3
  const RATE_LIMIT_ERROR_KEY = "api.errors.rateLimit";
4
4
  const RATE_LIMIT_ERROR_FALLBACK = "Too many requests. Please try again later.";
5
+ const RATE_LIMIT_FALLBACK_KEY = "global";
5
6
  const rateLimitErrorSchema = z.object({
6
7
  error: z.string().describe("Rate limit exceeded message")
7
8
  });
@@ -26,19 +27,20 @@ async function checkRateLimit(rateLimiterService, config, key, errorMessage) {
26
27
  }
27
28
  function getClientIp(req, trustProxyDepth = 0) {
28
29
  const forwarded = req.headers.get("x-forwarded-for");
29
- if (trustProxyDepth <= 0) {
30
+ if (!Number.isInteger(trustProxyDepth) || trustProxyDepth <= 0) {
30
31
  return null;
31
32
  }
32
33
  if (forwarded) {
33
34
  const ips = forwarded.split(",").map((ip) => ip.trim());
34
35
  const clientIndex = ips.length - trustProxyDepth;
35
- return clientIndex >= 0 ? ips[clientIndex] : ips[0];
36
+ return clientIndex >= 0 ? ips[clientIndex] || null : null;
36
37
  }
37
- return req.headers.get("x-real-ip") ?? null;
38
+ return trustProxyDepth === 1 ? req.headers.get("x-real-ip")?.trim() || null : null;
38
39
  }
39
40
  export {
40
41
  RATE_LIMIT_ERROR_FALLBACK,
41
42
  RATE_LIMIT_ERROR_KEY,
43
+ RATE_LIMIT_FALLBACK_KEY,
42
44
  checkRateLimit,
43
45
  getClientIp,
44
46
  rateLimitErrorSchema
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/ratelimit/helpers.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { RateLimitConfig } from './types'\nimport type { RateLimiterService } from './service'\n\nexport const RATE_LIMIT_ERROR_KEY = 'api.errors.rateLimit'\nexport const RATE_LIMIT_ERROR_FALLBACK = 'Too many requests. Please try again later.'\n\nexport const rateLimitErrorSchema = z.object({\n error: z.string().describe('Rate limit exceeded message'),\n})\n\n/**\n * Check rate limit for a request. Returns a 429 NextResponse if rate limited, or null if allowed.\n * Rate limit headers (X-RateLimit-*, Retry-After) are only included on 429 responses.\n */\nexport async function checkRateLimit(\n rateLimiterService: RateLimiterService,\n config: RateLimitConfig,\n key: string,\n errorMessage: string,\n): Promise<NextResponse | null> {\n const result = await rateLimiterService.consume(key, config)\n\n if (!result.allowed) {\n const retryAfterSec = Math.ceil(result.msBeforeNext / 1000)\n return NextResponse.json(\n { error: errorMessage },\n {\n status: 429,\n headers: {\n 'Retry-After': String(retryAfterSec),\n 'X-RateLimit-Limit': String(config.points),\n 'X-RateLimit-Remaining': String(result.remainingPoints),\n 'X-RateLimit-Reset': String(retryAfterSec),\n },\n },\n )\n }\n\n return null\n}\n\n/**\n * Extract client IP from a request, respecting reverse proxy trust depth.\n *\n * @param trustProxyDepth Number of trusted reverse proxies between the client and the app.\n * - 0 (default): Do not trust proxy-provided IP headers; return null.\n * - 1: One trusted proxy (e.g. nginx) \u2014 the last entry in X-Forwarded-For is the client IP.\n * - N: N trusted proxies \u2014 the Nth-from-last entry is the client IP.\n *\n * With depth=0, X-Forwarded-For and X-Real-IP are ignored entirely to prevent spoofing.\n */\nexport function getClientIp(req: Request, trustProxyDepth: number = 0): string | null {\n const forwarded = req.headers.get('x-forwarded-for')\n if (trustProxyDepth <= 0) {\n return null\n }\n\n if (forwarded) {\n const ips = forwarded.split(',').map((ip) => ip.trim())\n const clientIndex = ips.length - trustProxyDepth\n return clientIndex >= 0 ? ips[clientIndex] : ips[0]\n }\n return req.headers.get('x-real-ip') ?? null\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAIX,MAAM,uBAAuB;AAC7B,MAAM,4BAA4B;AAElC,MAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,OAAO,EAAE,OAAO,EAAE,SAAS,6BAA6B;AAC1D,CAAC;AAMD,eAAsB,eACpB,oBACA,QACA,KACA,cAC8B;AAC9B,QAAM,SAAS,MAAM,mBAAmB,QAAQ,KAAK,MAAM;AAE3D,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,gBAAgB,KAAK,KAAK,OAAO,eAAe,GAAI;AAC1D,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,aAAa;AAAA,MACtB;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,eAAe,OAAO,aAAa;AAAA,UACnC,qBAAqB,OAAO,OAAO,MAAM;AAAA,UACzC,yBAAyB,OAAO,OAAO,eAAe;AAAA,UACtD,qBAAqB,OAAO,aAAa;AAAA,QAC3C;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAYO,SAAS,YAAY,KAAc,kBAA0B,GAAkB;AACpF,QAAM,YAAY,IAAI,QAAQ,IAAI,iBAAiB;AACnD,MAAI,mBAAmB,GAAG;AACxB,WAAO;AAAA,EACT;AAEA,MAAI,WAAW;AACb,UAAM,MAAM,UAAU,MAAM,GAAG,EAAE,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;AACtD,UAAM,cAAc,IAAI,SAAS;AACjC,WAAO,eAAe,IAAI,IAAI,WAAW,IAAI,IAAI,CAAC;AAAA,EACpD;AACA,SAAO,IAAI,QAAQ,IAAI,WAAW,KAAK;AACzC;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { RateLimitConfig } from './types'\nimport type { RateLimiterService } from './service'\n\nexport const RATE_LIMIT_ERROR_KEY = 'api.errors.rateLimit'\nexport const RATE_LIMIT_ERROR_FALLBACK = 'Too many requests. Please try again later.'\nexport const RATE_LIMIT_FALLBACK_KEY = 'global'\n\nexport const rateLimitErrorSchema = z.object({\n error: z.string().describe('Rate limit exceeded message'),\n})\n\n/**\n * Check rate limit for a request. Returns a 429 NextResponse if rate limited, or null if allowed.\n * Rate limit headers (X-RateLimit-*, Retry-After) are only included on 429 responses.\n */\nexport async function checkRateLimit(\n rateLimiterService: RateLimiterService,\n config: RateLimitConfig,\n key: string,\n errorMessage: string,\n): Promise<NextResponse | null> {\n const result = await rateLimiterService.consume(key, config)\n\n if (!result.allowed) {\n const retryAfterSec = Math.ceil(result.msBeforeNext / 1000)\n return NextResponse.json(\n { error: errorMessage },\n {\n status: 429,\n headers: {\n 'Retry-After': String(retryAfterSec),\n 'X-RateLimit-Limit': String(config.points),\n 'X-RateLimit-Remaining': String(result.remainingPoints),\n 'X-RateLimit-Reset': String(retryAfterSec),\n },\n },\n )\n }\n\n return null\n}\n\n/**\n * Extract client IP from a request, respecting reverse proxy trust depth.\n *\n * @param trustProxyDepth Number of trusted reverse proxies between the client and the app.\n * - 0 (default): Do not trust proxy-provided IP headers; return null.\n * - 1: One trusted proxy (e.g. nginx) \u2014 the last entry in X-Forwarded-For is the client IP;\n * X-Real-IP is accepted only as a single-proxy fallback when X-Forwarded-For is absent.\n * - N: N trusted proxies \u2014 the Nth-from-last entry is the client IP. If the\n * forwarded chain is shorter than N, return null rather than trusting an\n * attacker-controlled entry.\n *\n * With depth=0, X-Forwarded-For and X-Real-IP are ignored entirely to prevent spoofing.\n */\nexport function getClientIp(req: Request, trustProxyDepth: number = 0): string | null {\n const forwarded = req.headers.get('x-forwarded-for')\n if (!Number.isInteger(trustProxyDepth) || trustProxyDepth <= 0) {\n return null\n }\n\n if (forwarded) {\n const ips = forwarded.split(',').map((ip) => ip.trim())\n const clientIndex = ips.length - trustProxyDepth\n return clientIndex >= 0 ? ips[clientIndex] || null : null\n }\n return trustProxyDepth === 1 ? req.headers.get('x-real-ip')?.trim() || null : null\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAIX,MAAM,uBAAuB;AAC7B,MAAM,4BAA4B;AAClC,MAAM,0BAA0B;AAEhC,MAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,OAAO,EAAE,OAAO,EAAE,SAAS,6BAA6B;AAC1D,CAAC;AAMD,eAAsB,eACpB,oBACA,QACA,KACA,cAC8B;AAC9B,QAAM,SAAS,MAAM,mBAAmB,QAAQ,KAAK,MAAM;AAE3D,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,gBAAgB,KAAK,KAAK,OAAO,eAAe,GAAI;AAC1D,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,aAAa;AAAA,MACtB;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,eAAe,OAAO,aAAa;AAAA,UACnC,qBAAqB,OAAO,OAAO,MAAM;AAAA,UACzC,yBAAyB,OAAO,OAAO,eAAe;AAAA,UACtD,qBAAqB,OAAO,aAAa;AAAA,QAC3C;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAeO,SAAS,YAAY,KAAc,kBAA0B,GAAkB;AACpF,QAAM,YAAY,IAAI,QAAQ,IAAI,iBAAiB;AACnD,MAAI,CAAC,OAAO,UAAU,eAAe,KAAK,mBAAmB,GAAG;AAC9D,WAAO;AAAA,EACT;AAEA,MAAI,WAAW;AACb,UAAM,MAAM,UAAU,MAAM,GAAG,EAAE,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;AACtD,UAAM,cAAc,IAAI,SAAS;AACjC,WAAO,eAAe,IAAI,IAAI,WAAW,KAAK,OAAO;AAAA,EACvD;AACA,SAAO,oBAAoB,IAAI,IAAI,QAAQ,IAAI,WAAW,GAAG,KAAK,KAAK,OAAO;AAChF;",
6
6
  "names": []
7
7
  }
@@ -1,9 +1,10 @@
1
1
  import { RateLimiterService } from "./service.js";
2
2
  import { readRateLimitConfig, readEndpointRateLimitConfig } from "./config.js";
3
- import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, rateLimitErrorSchema } from "./helpers.js";
3
+ import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_FALLBACK_KEY, rateLimitErrorSchema } from "./helpers.js";
4
4
  export {
5
5
  RATE_LIMIT_ERROR_FALLBACK,
6
6
  RATE_LIMIT_ERROR_KEY,
7
+ RATE_LIMIT_FALLBACK_KEY,
7
8
  RateLimiterService,
8
9
  checkRateLimit,
9
10
  getClientIp,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/ratelimit/index.ts"],
4
- "sourcesContent": ["export { RateLimiterService } from './service'\nexport { readRateLimitConfig, readEndpointRateLimitConfig } from './config'\nexport { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, rateLimitErrorSchema } from './helpers'\nexport type { RateLimitConfig, RateLimitResult, RateLimitStrategy, RateLimitGlobalConfig } from './types'\n"],
5
- "mappings": "AAAA,SAAS,0BAA0B;AACnC,SAAS,qBAAqB,mCAAmC;AACjE,SAAS,gBAAgB,aAAa,sBAAsB,2BAA2B,4BAA4B;",
4
+ "sourcesContent": ["export { RateLimiterService } from './service'\nexport { readRateLimitConfig, readEndpointRateLimitConfig } from './config'\nexport { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_FALLBACK_KEY, rateLimitErrorSchema } from './helpers'\nexport type { RateLimitConfig, RateLimitResult, RateLimitStrategy, RateLimitGlobalConfig } from './types'\n"],
5
+ "mappings": "AAAA,SAAS,0BAA0B;AACnC,SAAS,qBAAqB,mCAAmC;AACjE,SAAS,gBAAgB,aAAa,sBAAsB,2BAA2B,yBAAyB,4BAA4B;",
6
6
  "names": []
7
7
  }
@@ -4,7 +4,7 @@ class RateLimiterService {
4
4
  this.limiters = /* @__PURE__ */ new Map();
5
5
  this.redisClient = null;
6
6
  this.globalConfig = globalConfig;
7
- this.trustProxyDepth = globalConfig.trustProxyDepth ?? 1;
7
+ this.trustProxyDepth = globalConfig.trustProxyDepth ?? 0;
8
8
  }
9
9
  async initialize() {
10
10
  if (this.globalConfig.strategy === "redis" && this.globalConfig.redisUrl) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/ratelimit/service.ts"],
4
- "sourcesContent": ["import { RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'\nimport type { RateLimitConfig, RateLimitResult, RateLimitGlobalConfig } from './types'\n\n/** Narrow interface for the ioredis client \u2014 only the methods we actually use. */\ninterface RedisClient {\n disconnect(): void\n}\n\nexport class RateLimiterService {\n private globalConfig: RateLimitGlobalConfig\n private limiters = new Map<string, RateLimiterMemory | RateLimiterRedis>()\n private redisClient: RedisClient | null = null\n\n readonly trustProxyDepth: number\n\n constructor(globalConfig: RateLimitGlobalConfig) {\n this.globalConfig = globalConfig\n this.trustProxyDepth = globalConfig.trustProxyDepth ?? 1\n }\n\n async initialize(): Promise<void> {\n if (this.globalConfig.strategy === 'redis' && this.globalConfig.redisUrl) {\n const { default: Redis } = await import('ioredis')\n this.redisClient = new Redis(this.globalConfig.redisUrl, {\n enableOfflineQueue: false,\n maxRetriesPerRequest: 1,\n })\n }\n }\n\n async consume(key: string, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n\n const limiter = this.getOrCreateLimiter(config)\n\n try {\n const res = await limiter.consume(key, 1)\n return this.toResult(res, true)\n } catch (error) {\n if (error instanceof RateLimiterRes) {\n return this.toResult(error, false)\n }\n return this.disabledResult(config)\n }\n }\n\n async get(key: string, config: RateLimitConfig): Promise<RateLimitResult | null> {\n if (!this.globalConfig.enabled) return null\n\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.get(key)\n return res ? this.toResult(res, res.remainingPoints > 0) : null\n }\n\n async delete(key: string, config: RateLimitConfig): Promise<void> {\n if (!this.globalConfig.enabled) return\n const limiter = this.getOrCreateLimiter(config)\n await limiter.delete(key)\n }\n\n async penalty(key: string, points: number, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.penalty(key, points)\n return this.toResult(res, res.remainingPoints > 0)\n }\n\n async reward(key: string, points: number, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.reward(key, points)\n return this.toResult(res, true)\n }\n\n async block(key: string, durationSec: number, config: RateLimitConfig): Promise<void> {\n if (!this.globalConfig.enabled) return\n const limiter = this.getOrCreateLimiter(config)\n await limiter.block(key, durationSec)\n }\n\n async destroy(): Promise<void> {\n if (this.redisClient) {\n this.redisClient.disconnect()\n }\n this.limiters.clear()\n }\n\n private disabledResult(config: RateLimitConfig): RateLimitResult {\n return { allowed: true, remainingPoints: config.points, msBeforeNext: 0, consumedPoints: 0 }\n }\n\n private getOrCreateLimiter(config: RateLimitConfig): RateLimiterMemory | RateLimiterRedis {\n const cacheKey = `${config.keyPrefix ?? 'default'}:${config.points}:${config.duration}:${config.blockDuration ?? 0}`\n\n let limiter = this.limiters.get(cacheKey)\n if (limiter) return limiter\n\n const prefix = [this.globalConfig.keyPrefix, config.keyPrefix].filter(Boolean).join(':')\n\n const baseOpts = {\n keyPrefix: prefix,\n points: config.points,\n duration: config.duration,\n blockDuration: config.blockDuration ?? 0,\n }\n\n if (this.globalConfig.strategy === 'redis' && this.redisClient) {\n const insuranceLimiter = new RateLimiterMemory(baseOpts)\n limiter = new RateLimiterRedis({\n ...baseOpts,\n storeClient: this.redisClient,\n insuranceLimiter,\n rejectIfRedisNotReady: false,\n })\n } else {\n limiter = new RateLimiterMemory(baseOpts)\n }\n\n this.limiters.set(cacheKey, limiter)\n return limiter\n }\n\n private toResult(res: RateLimiterRes, allowed: boolean): RateLimitResult {\n return {\n allowed,\n remainingPoints: Math.max(res.remainingPoints, 0),\n msBeforeNext: res.msBeforeNext,\n consumedPoints: res.consumedPoints,\n }\n }\n}\n"],
4
+ "sourcesContent": ["import { RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'\nimport type { RateLimitConfig, RateLimitResult, RateLimitGlobalConfig } from './types'\n\n/** Narrow interface for the ioredis client \u2014 only the methods we actually use. */\ninterface RedisClient {\n disconnect(): void\n}\n\nexport class RateLimiterService {\n private globalConfig: RateLimitGlobalConfig\n private limiters = new Map<string, RateLimiterMemory | RateLimiterRedis>()\n private redisClient: RedisClient | null = null\n\n readonly trustProxyDepth: number\n\n constructor(globalConfig: RateLimitGlobalConfig) {\n this.globalConfig = globalConfig\n this.trustProxyDepth = globalConfig.trustProxyDepth ?? 0\n }\n\n async initialize(): Promise<void> {\n if (this.globalConfig.strategy === 'redis' && this.globalConfig.redisUrl) {\n const { default: Redis } = await import('ioredis')\n this.redisClient = new Redis(this.globalConfig.redisUrl, {\n enableOfflineQueue: false,\n maxRetriesPerRequest: 1,\n })\n }\n }\n\n async consume(key: string, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n\n const limiter = this.getOrCreateLimiter(config)\n\n try {\n const res = await limiter.consume(key, 1)\n return this.toResult(res, true)\n } catch (error) {\n if (error instanceof RateLimiterRes) {\n return this.toResult(error, false)\n }\n return this.disabledResult(config)\n }\n }\n\n async get(key: string, config: RateLimitConfig): Promise<RateLimitResult | null> {\n if (!this.globalConfig.enabled) return null\n\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.get(key)\n return res ? this.toResult(res, res.remainingPoints > 0) : null\n }\n\n async delete(key: string, config: RateLimitConfig): Promise<void> {\n if (!this.globalConfig.enabled) return\n const limiter = this.getOrCreateLimiter(config)\n await limiter.delete(key)\n }\n\n async penalty(key: string, points: number, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.penalty(key, points)\n return this.toResult(res, res.remainingPoints > 0)\n }\n\n async reward(key: string, points: number, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.reward(key, points)\n return this.toResult(res, true)\n }\n\n async block(key: string, durationSec: number, config: RateLimitConfig): Promise<void> {\n if (!this.globalConfig.enabled) return\n const limiter = this.getOrCreateLimiter(config)\n await limiter.block(key, durationSec)\n }\n\n async destroy(): Promise<void> {\n if (this.redisClient) {\n this.redisClient.disconnect()\n }\n this.limiters.clear()\n }\n\n private disabledResult(config: RateLimitConfig): RateLimitResult {\n return { allowed: true, remainingPoints: config.points, msBeforeNext: 0, consumedPoints: 0 }\n }\n\n private getOrCreateLimiter(config: RateLimitConfig): RateLimiterMemory | RateLimiterRedis {\n const cacheKey = `${config.keyPrefix ?? 'default'}:${config.points}:${config.duration}:${config.blockDuration ?? 0}`\n\n let limiter = this.limiters.get(cacheKey)\n if (limiter) return limiter\n\n const prefix = [this.globalConfig.keyPrefix, config.keyPrefix].filter(Boolean).join(':')\n\n const baseOpts = {\n keyPrefix: prefix,\n points: config.points,\n duration: config.duration,\n blockDuration: config.blockDuration ?? 0,\n }\n\n if (this.globalConfig.strategy === 'redis' && this.redisClient) {\n const insuranceLimiter = new RateLimiterMemory(baseOpts)\n limiter = new RateLimiterRedis({\n ...baseOpts,\n storeClient: this.redisClient,\n insuranceLimiter,\n rejectIfRedisNotReady: false,\n })\n } else {\n limiter = new RateLimiterMemory(baseOpts)\n }\n\n this.limiters.set(cacheKey, limiter)\n return limiter\n }\n\n private toResult(res: RateLimiterRes, allowed: boolean): RateLimitResult {\n return {\n allowed,\n remainingPoints: Math.max(res.remainingPoints, 0),\n msBeforeNext: res.msBeforeNext,\n consumedPoints: res.consumedPoints,\n }\n }\n}\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.7-develop.6606.1.3b1ec9b1ea";
1
+ const APP_VERSION = "0.6.7-develop.6621.1.cdbe9dee3d";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6606.1.3b1ec9b1ea'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6621.1.cdbe9dee3d'\nexport const appVersion = APP_VERSION\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
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",
@@ -97,7 +97,7 @@
97
97
  "@mikro-orm/core": "^7.1.5",
98
98
  "@mikro-orm/decorators": "^7.1.5",
99
99
  "@mikro-orm/postgresql": "^7.1.5",
100
- "@open-mercato/cache": "0.6.7-develop.6606.1.3b1ec9b1ea",
100
+ "@open-mercato/cache": "0.6.7-develop.6621.1.cdbe9dee3d",
101
101
  "@types/sanitize-html": "^2.16.1",
102
102
  "dotenv": "^17.4.2",
103
103
  "pino": "^10.3.1",
@@ -13,6 +13,18 @@ describe('readRateLimitConfig', () => {
13
13
  expect(readRateLimitConfig().enabled).toBe(true)
14
14
  })
15
15
 
16
+ it('defaults to direct mode when proxy depth is not configured', () => {
17
+ delete process.env.RATE_LIMIT_TRUST_PROXY_DEPTH
18
+
19
+ expect(readRateLimitConfig().trustProxyDepth).toBe(0)
20
+ })
21
+
22
+ it.each(['-1', '1.5', 'not-a-number'])('falls back to direct mode for invalid proxy depth %s', (value) => {
23
+ process.env.RATE_LIMIT_TRUST_PROXY_DEPTH = value
24
+
25
+ expect(readRateLimitConfig().trustProxyDepth).toBe(0)
26
+ })
27
+
16
28
  it('honors RATE_LIMIT_ENABLED=false', () => {
17
29
  process.env.RATE_LIMIT_ENABLED = 'false'
18
30
  delete process.env.OM_INTEGRATION_TEST
@@ -132,11 +132,11 @@ describe('getClientIp', () => {
132
132
  expect(getClientIp(req, 2)).toBe('real-client')
133
133
  })
134
134
 
135
- it('falls back to first IP when fewer entries than trust depth', () => {
135
+ it('returns null when the forwarded chain is shorter than the configured trust depth', () => {
136
136
  const req = new Request('http://localhost', {
137
137
  headers: { 'x-forwarded-for': '192.168.1.1' },
138
138
  })
139
- expect(getClientIp(req, 3)).toBe('192.168.1.1')
139
+ expect(getClientIp(req, 3)).toBeNull()
140
140
  })
141
141
 
142
142
  it('trims whitespace from x-forwarded-for entries', () => {
@@ -220,7 +220,7 @@ describe('RateLimiterService', () => {
220
220
  expect(config.enabled).toBe(true)
221
221
  expect(config.strategy).toBe('memory')
222
222
  expect(config.keyPrefix).toBe('rl')
223
- expect(config.trustProxyDepth).toBe(1)
223
+ expect(config.trustProxyDepth).toBe(0)
224
224
 
225
225
  if (originalEnabled !== undefined) process.env.RATE_LIMIT_ENABLED = originalEnabled; else delete process.env.RATE_LIMIT_ENABLED
226
226
  if (originalStrategy !== undefined) process.env.RATE_LIMIT_STRATEGY = originalStrategy; else delete process.env.RATE_LIMIT_STRATEGY
@@ -1,7 +1,9 @@
1
1
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
2
+ import { createLogger } from '@open-mercato/shared/lib/logger'
2
3
  import type { RateLimitConfig, RateLimitGlobalConfig, RateLimitStrategy } from './types'
3
4
 
4
5
  const VALID_STRATEGIES: RateLimitStrategy[] = ['memory', 'redis']
6
+ const logger = createLogger('ratelimit').child({ component: 'config' })
5
7
 
6
8
  export function readRateLimitConfig(): RateLimitGlobalConfig {
7
9
  const strategy = (process.env.RATE_LIMIT_STRATEGY ?? 'memory') as RateLimitStrategy
@@ -9,7 +11,7 @@ export function readRateLimitConfig(): RateLimitGlobalConfig {
9
11
  throw new Error(`Invalid RATE_LIMIT_STRATEGY "${strategy}". Must be one of: ${VALID_STRATEGIES.join(', ')}`)
10
12
  }
11
13
 
12
- const trustProxyDepth = parsePositiveInt(process.env.RATE_LIMIT_TRUST_PROXY_DEPTH) ?? 1
14
+ const trustProxyDepth = parseTrustProxyDepth(process.env.RATE_LIMIT_TRUST_PROXY_DEPTH)
13
15
 
14
16
  // Integration test runs disable rate limiting globally so suites do not
15
17
  // have to juggle per-endpoint bypass headers or reshape default caps.
@@ -51,3 +53,11 @@ function parsePositiveInt(raw: string | undefined): number | undefined {
51
53
  const parsed = Number(raw)
52
54
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
53
55
  }
56
+
57
+ function parseTrustProxyDepth(raw: string | undefined): number {
58
+ if (raw === undefined || raw.trim() === '') return 0
59
+ const parsed = Number(raw)
60
+ if (Number.isInteger(parsed) && parsed >= 0) return parsed
61
+ logger.warn('Invalid RATE_LIMIT_TRUST_PROXY_DEPTH; using safe direct mode', { value: raw })
62
+ return 0
63
+ }
@@ -5,6 +5,7 @@ import type { RateLimiterService } from './service'
5
5
 
6
6
  export const RATE_LIMIT_ERROR_KEY = 'api.errors.rateLimit'
7
7
  export const RATE_LIMIT_ERROR_FALLBACK = 'Too many requests. Please try again later.'
8
+ export const RATE_LIMIT_FALLBACK_KEY = 'global'
8
9
 
9
10
  export const rateLimitErrorSchema = z.object({
10
11
  error: z.string().describe('Rate limit exceeded message'),
@@ -46,21 +47,24 @@ export async function checkRateLimit(
46
47
  *
47
48
  * @param trustProxyDepth Number of trusted reverse proxies between the client and the app.
48
49
  * - 0 (default): Do not trust proxy-provided IP headers; return null.
49
- * - 1: One trusted proxy (e.g. nginx) — the last entry in X-Forwarded-For is the client IP.
50
- * - N: N trusted proxies the Nth-from-last entry is the client IP.
50
+ * - 1: One trusted proxy (e.g. nginx) — the last entry in X-Forwarded-For is the client IP;
51
+ * X-Real-IP is accepted only as a single-proxy fallback when X-Forwarded-For is absent.
52
+ * - N: N trusted proxies — the Nth-from-last entry is the client IP. If the
53
+ * forwarded chain is shorter than N, return null rather than trusting an
54
+ * attacker-controlled entry.
51
55
  *
52
56
  * With depth=0, X-Forwarded-For and X-Real-IP are ignored entirely to prevent spoofing.
53
57
  */
54
58
  export function getClientIp(req: Request, trustProxyDepth: number = 0): string | null {
55
59
  const forwarded = req.headers.get('x-forwarded-for')
56
- if (trustProxyDepth <= 0) {
60
+ if (!Number.isInteger(trustProxyDepth) || trustProxyDepth <= 0) {
57
61
  return null
58
62
  }
59
63
 
60
64
  if (forwarded) {
61
65
  const ips = forwarded.split(',').map((ip) => ip.trim())
62
66
  const clientIndex = ips.length - trustProxyDepth
63
- return clientIndex >= 0 ? ips[clientIndex] : ips[0]
67
+ return clientIndex >= 0 ? ips[clientIndex] || null : null
64
68
  }
65
- return req.headers.get('x-real-ip') ?? null
69
+ return trustProxyDepth === 1 ? req.headers.get('x-real-ip')?.trim() || null : null
66
70
  }
@@ -1,4 +1,4 @@
1
1
  export { RateLimiterService } from './service'
2
2
  export { readRateLimitConfig, readEndpointRateLimitConfig } from './config'
3
- export { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, rateLimitErrorSchema } from './helpers'
3
+ export { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_FALLBACK_KEY, rateLimitErrorSchema } from './helpers'
4
4
  export type { RateLimitConfig, RateLimitResult, RateLimitStrategy, RateLimitGlobalConfig } from './types'
@@ -15,7 +15,7 @@ export class RateLimiterService {
15
15
 
16
16
  constructor(globalConfig: RateLimitGlobalConfig) {
17
17
  this.globalConfig = globalConfig
18
- this.trustProxyDepth = globalConfig.trustProxyDepth ?? 1
18
+ this.trustProxyDepth = globalConfig.trustProxyDepth ?? 0
19
19
  }
20
20
 
21
21
  async initialize(): Promise<void> {
@@ -27,6 +27,6 @@ export interface RateLimitGlobalConfig {
27
27
  strategy: RateLimitStrategy
28
28
  keyPrefix: string
29
29
  redisUrl?: string
30
- /** Number of trusted reverse proxies for X-Forwarded-For IP extraction (default: 1) */
30
+ /** Number of trusted reverse proxies for X-Forwarded-For IP extraction (default: 0) */
31
31
  trustProxyDepth: number
32
32
  }