@wopr-network/platform-core 1.40.0 → 1.42.1

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.
@@ -4,11 +4,20 @@ import type { Context } from "hono";
4
4
  * Returns an empty set if the value is undefined or empty.
5
5
  */
6
6
  export declare function parseTrustedProxies(envValue: string | undefined): Set<string>;
7
+ /**
8
+ * Check whether an IPv4 address is in an RFC 1918 private range or loopback.
9
+ * These are always behind a proxy in production (Docker, k8s, cloud VPCs)
10
+ * so trusting X-Forwarded-For from them is safe and expected.
11
+ *
12
+ * Ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8
13
+ */
14
+ export declare function isPrivateIp(ip: string): boolean;
7
15
  /**
8
16
  * Determine the real client IP.
9
17
  *
10
- * - If `socketAddr` matches a trusted proxy, use the **first** (leftmost)
11
- * value from `X-Forwarded-For` (the original client IP).
18
+ * - If `socketAddr` matches a trusted proxy (explicit list OR RFC 1918
19
+ * private IP), use the **first** (leftmost) value from
20
+ * `X-Forwarded-For` (the original client IP).
12
21
  * - Otherwise, use `socketAddr` directly (XFF is untrusted).
13
22
  * - Falls back to `"unknown"` if neither is available.
14
23
  */
@@ -14,19 +14,43 @@ export function parseTrustedProxies(envValue) {
14
14
  function normalizeIp(ip) {
15
15
  return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
16
16
  }
17
+ /**
18
+ * Check whether an IPv4 address is in an RFC 1918 private range or loopback.
19
+ * These are always behind a proxy in production (Docker, k8s, cloud VPCs)
20
+ * so trusting X-Forwarded-For from them is safe and expected.
21
+ *
22
+ * Ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8
23
+ */
24
+ export function isPrivateIp(ip) {
25
+ const parts = ip.split(".");
26
+ if (parts.length !== 4)
27
+ return false;
28
+ const a = Number.parseInt(parts[0], 10);
29
+ const b = Number.parseInt(parts[1], 10);
30
+ if (a === 10)
31
+ return true;
32
+ if (a === 172 && b >= 16 && b <= 31)
33
+ return true;
34
+ if (a === 192 && b === 168)
35
+ return true;
36
+ if (a === 127)
37
+ return true;
38
+ return false;
39
+ }
17
40
  // Parsed once at module load — no per-request overhead.
18
41
  const trustedProxies = parseTrustedProxies(process.env.TRUSTED_PROXY_IPS);
19
42
  /**
20
43
  * Determine the real client IP.
21
44
  *
22
- * - If `socketAddr` matches a trusted proxy, use the **first** (leftmost)
23
- * value from `X-Forwarded-For` (the original client IP).
45
+ * - If `socketAddr` matches a trusted proxy (explicit list OR RFC 1918
46
+ * private IP), use the **first** (leftmost) value from
47
+ * `X-Forwarded-For` (the original client IP).
24
48
  * - Otherwise, use `socketAddr` directly (XFF is untrusted).
25
49
  * - Falls back to `"unknown"` if neither is available.
26
50
  */
27
51
  export function getClientIp(xffHeader, socketAddr, trusted = trustedProxies) {
28
52
  const normalizedSocket = socketAddr ? normalizeIp(socketAddr) : undefined;
29
- if (xffHeader && normalizedSocket && trusted.has(normalizedSocket)) {
53
+ if (xffHeader && normalizedSocket && (trusted.has(normalizedSocket) || isPrivateIp(normalizedSocket))) {
30
54
  const parts = xffHeader.split(",");
31
55
  const first = parts[0]?.trim();
32
56
  if (first)
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import type { Context, MiddlewareHandler } from "hono";
12
12
  import type { IRateLimitRepository } from "../rate-limit-repository.js";
13
- export { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
13
+ export { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
14
14
  export interface RateLimitConfig {
15
15
  /** Maximum number of requests per window. */
16
16
  max: number;
@@ -9,7 +9,7 @@
9
9
  * State is persisted via IRateLimitRepository (DB-backed in production).
10
10
  */
11
11
  import { getClientIpFromContext } from "./get-client-ip.js";
12
- export { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
12
+ export { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
13
13
  // ---------------------------------------------------------------------------
14
14
  // Helpers
15
15
  // ---------------------------------------------------------------------------
@@ -132,8 +132,12 @@ const CHANNEL_VALIDATE_LIMIT = { max: 10 };
132
132
  const FLEET_CREATE_LIMIT = { max: 30 };
133
133
  /** Fleet read operations (GET /fleet/*): 120 req/min */
134
134
  const FLEET_READ_LIMIT = { max: 120 };
135
- /** Default for everything else: 60 req/min */
136
- const DEFAULT_LIMIT = { max: 60 };
135
+ /** Default for everything else: 200 req/min.
136
+ * A single Next.js page load triggers ~60-70 REST requests (sidebar, credits,
137
+ * session validation, marketplace, etc.). 60 was too low and caused users to
138
+ * hit rate limits on their first page load. Sensitive endpoints (auth, billing,
139
+ * signup) have their own stricter limits below. */
140
+ const DEFAULT_LIMIT = { max: 200 };
137
141
  /** Auth login: 5 failed attempts per 15 minutes (WOP-839) */
138
142
  const AUTH_LOGIN_LIMIT = {
139
143
  max: 5,
@@ -63,7 +63,8 @@ export function capabilityRateLimit(config, repo) {
63
63
  return next();
64
64
  }
65
65
  const tenant = c.get("gatewayTenant");
66
- const tenantId = tenant?.id ?? "unknown";
66
+ const attributedTenantId = c.get("attributedTenantId");
67
+ const tenantId = attributedTenantId ?? tenant?.id ?? "unknown";
67
68
  const max = limits[category];
68
69
  const scope = `cap:${category}`;
69
70
  const now = Date.now();
@@ -43,4 +43,4 @@ export declare function creditBalanceCheck(c: Context<GatewayAuthEnv>, deps: Cre
43
43
  /**
44
44
  * Post-call credit debit. Fire-and-forget — never fails the response.
45
45
  */
46
- export declare function debitCredits(deps: CreditGateDeps, tenantId: string, costUsd: number, margin: number, capability: string, provider: string, attributedUserId?: string): Promise<void>;
46
+ export declare function debitCredits(deps: CreditGateDeps, tenantId: string, costUsd: number, margin: number, capability: string, provider: string, attributedUserId?: string, attributedTenantId?: string | null): Promise<void>;
@@ -21,6 +21,11 @@ export async function creditBalanceCheck(c, deps, estimatedCostCents = 0) {
21
21
  if (!deps.creditLedger)
22
22
  return null;
23
23
  const tenant = c.get("gatewayTenant");
24
+ // Platform service accounts bypass the pre-call balance gate.
25
+ // The company never 402s itself. Debit still runs post-call for P&L tracking.
26
+ if (tenant.type === "platform_service") {
27
+ return null;
28
+ }
24
29
  const balance = await deps.creditLedger.balance(tenant.id);
25
30
  const required = Math.max(0, estimatedCostCents);
26
31
  const graceBuffer = deps.graceBufferCents ?? 50; // default -$0.50
@@ -55,7 +60,7 @@ export async function creditBalanceCheck(c, deps, estimatedCostCents = 0) {
55
60
  /**
56
61
  * Post-call credit debit. Fire-and-forget — never fails the response.
57
62
  */
58
- export async function debitCredits(deps, tenantId, costUsd, margin, capability, provider, attributedUserId) {
63
+ export async function debitCredits(deps, tenantId, costUsd, margin, capability, provider, attributedUserId, attributedTenantId) {
59
64
  if (!deps.creditLedger)
60
65
  return;
61
66
  const chargeCredit = withMargin(Credit.fromDollars(costUsd), margin);
@@ -75,6 +80,7 @@ export async function debitCredits(deps, tenantId, costUsd, margin, capability,
75
80
  description: `Gateway ${capability} via ${provider}`,
76
81
  allowNegative: true,
77
82
  attributedUserId,
83
+ ...(attributedTenantId ? { attributed_tenant_id: attributedTenantId } : {}),
78
84
  });
79
85
  // Only fire on first zero-crossing (balance was positive before, now ≤ 0)
80
86
  if (deps.onBalanceExhausted) {
@@ -96,6 +96,7 @@ export function createAnthropicRoutes(deps) {
96
96
  function messagesHandler(deps) {
97
97
  return async (c) => {
98
98
  const tenant = c.get("gatewayTenant");
99
+ const attributedTenantId = c.get("attributedTenantId");
99
100
  // Budget check
100
101
  const budgetResult = await deps.budgetChecker.check(tenant.id, tenant.spendLimits);
101
102
  if (!budgetResult.allowed) {
@@ -174,7 +175,7 @@ function messagesHandler(deps) {
174
175
  provider: "openrouter",
175
176
  timestamp: Date.now(),
176
177
  });
177
- debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter");
178
+ debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter", undefined, attributedTenantId);
178
179
  }
179
180
  return new Response(res.body, {
180
181
  status: res.status,
@@ -109,6 +109,7 @@ export function createOpenAIRoutes(deps) {
109
109
  function chatCompletionsHandler(deps) {
110
110
  return async (c) => {
111
111
  const tenant = c.get("gatewayTenant");
112
+ const attributedTenantId = c.get("attributedTenantId");
112
113
  // Budget check
113
114
  const budgetResult = await deps.budgetChecker.check(tenant.id, tenant.spendLimits);
114
115
  if (!budgetResult.allowed) {
@@ -187,7 +188,7 @@ function chatCompletionsHandler(deps) {
187
188
  provider: "openrouter",
188
189
  timestamp: Date.now(),
189
190
  });
190
- debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter");
191
+ debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter", undefined, attributedTenantId);
191
192
  }
192
193
  return new Response(res.body, {
193
194
  status: res.status,
@@ -219,7 +220,7 @@ function chatCompletionsHandler(deps) {
219
220
  provider: "openrouter",
220
221
  timestamp: Date.now(),
221
222
  });
222
- debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter");
223
+ debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter", undefined, attributedTenantId);
223
224
  }
224
225
  return new Response(responseBody, {
225
226
  status: res.status,
@@ -246,6 +247,7 @@ function chatCompletionsHandler(deps) {
246
247
  function embeddingsHandler(deps) {
247
248
  return async (c) => {
248
249
  const tenant = c.get("gatewayTenant");
250
+ const attributedTenantId = c.get("attributedTenantId");
249
251
  // Budget check
250
252
  const budgetResult = await deps.budgetChecker.check(tenant.id, tenant.spendLimits);
251
253
  if (!budgetResult.allowed) {
@@ -311,7 +313,7 @@ function embeddingsHandler(deps) {
311
313
  provider: "openrouter",
312
314
  timestamp: Date.now(),
313
315
  });
314
- debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "embeddings", "openrouter");
316
+ debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "embeddings", "openrouter", undefined, attributedTenantId);
315
317
  }
316
318
  return new Response(responseBody, {
317
319
  status: res.status,
@@ -11,6 +11,7 @@ export interface GatewayAuthEnv {
11
11
  Variables: {
12
12
  gatewayTenant: GatewayTenant;
13
13
  webhookBody: Record<string, unknown>;
14
+ attributedTenantId: string | null;
14
15
  };
15
16
  }
16
17
  /**
@@ -61,6 +61,8 @@ export function serviceKeyAuth(resolveServiceKey) {
61
61
  }, 401);
62
62
  }
63
63
  c.set("gatewayTenant", tenant);
64
+ const attributedTenantId = c.req.header("x-attribute-to") ?? null;
65
+ c.set("attributedTenantId", attributedTenantId);
64
66
  return next();
65
67
  };
66
68
  }
@@ -34,6 +34,8 @@ export interface GatewayEndpoint {
34
34
  export interface GatewayTenant {
35
35
  /** Tenant ID */
36
36
  id: string;
37
+ /** Tenant type — platform_service accounts bypass the pre-call credit gate */
38
+ type?: "personal" | "org" | "platform_service";
37
39
  /** Spend limits for budget checking */
38
40
  spendLimits: SpendLimits;
39
41
  /** Plan tier for rate limit lookup */
@@ -4,11 +4,20 @@ import type { Context } from "hono";
4
4
  * Returns an empty set if the value is undefined or empty.
5
5
  */
6
6
  export declare function parseTrustedProxies(envValue: string | undefined): Set<string>;
7
+ /**
8
+ * Check whether an IPv4 address is in an RFC 1918 private range or loopback.
9
+ * These are always behind a proxy in production (Docker, k8s, cloud VPCs)
10
+ * so trusting X-Forwarded-For from them is safe and expected.
11
+ *
12
+ * Ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8
13
+ */
14
+ export declare function isPrivateIp(ip: string): boolean;
7
15
  /**
8
16
  * Determine the real client IP.
9
17
  *
10
- * - If `socketAddr` matches a trusted proxy, use the **last** (rightmost)
11
- * value from `X-Forwarded-For` (closest hop to the trusted proxy).
18
+ * - If `socketAddr` matches a trusted proxy (explicit list OR RFC 1918
19
+ * private IP), use the **last** (rightmost) value from
20
+ * `X-Forwarded-For` (closest hop to the trusted proxy).
12
21
  * - Otherwise, use `socketAddr` directly (XFF is untrusted).
13
22
  * - Falls back to `"unknown"` if neither is available.
14
23
  */
@@ -14,19 +14,43 @@ export function parseTrustedProxies(envValue) {
14
14
  function normalizeIp(ip) {
15
15
  return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
16
16
  }
17
+ /**
18
+ * Check whether an IPv4 address is in an RFC 1918 private range or loopback.
19
+ * These are always behind a proxy in production (Docker, k8s, cloud VPCs)
20
+ * so trusting X-Forwarded-For from them is safe and expected.
21
+ *
22
+ * Ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8
23
+ */
24
+ export function isPrivateIp(ip) {
25
+ const parts = ip.split(".");
26
+ if (parts.length !== 4)
27
+ return false;
28
+ const a = Number.parseInt(parts[0], 10);
29
+ const b = Number.parseInt(parts[1], 10);
30
+ if (a === 10)
31
+ return true;
32
+ if (a === 172 && b >= 16 && b <= 31)
33
+ return true;
34
+ if (a === 192 && b === 168)
35
+ return true;
36
+ if (a === 127)
37
+ return true;
38
+ return false;
39
+ }
17
40
  // Parsed once at module load — no per-request overhead.
18
41
  const trustedProxies = parseTrustedProxies(process.env.TRUSTED_PROXY_IPS);
19
42
  /**
20
43
  * Determine the real client IP.
21
44
  *
22
- * - If `socketAddr` matches a trusted proxy, use the **last** (rightmost)
23
- * value from `X-Forwarded-For` (closest hop to the trusted proxy).
45
+ * - If `socketAddr` matches a trusted proxy (explicit list OR RFC 1918
46
+ * private IP), use the **last** (rightmost) value from
47
+ * `X-Forwarded-For` (closest hop to the trusted proxy).
24
48
  * - Otherwise, use `socketAddr` directly (XFF is untrusted).
25
49
  * - Falls back to `"unknown"` if neither is available.
26
50
  */
27
51
  export function getClientIp(xffHeader, socketAddr, trusted = trustedProxies) {
28
52
  const normalizedSocket = socketAddr ? normalizeIp(socketAddr) : undefined;
29
- if (xffHeader && normalizedSocket && trusted.has(normalizedSocket)) {
53
+ if (xffHeader && normalizedSocket && (trusted.has(normalizedSocket) || isPrivateIp(normalizedSocket))) {
30
54
  // Trust XFF — take the rightmost (last) value
31
55
  const parts = xffHeader.split(",");
32
56
  const last = parts[parts.length - 1]?.trim();
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
2
+ import { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
3
3
  describe("parseTrustedProxies", () => {
4
4
  it("returns empty set for undefined", () => {
5
5
  expect(parseTrustedProxies(undefined).size).toBe(0);
@@ -14,6 +14,37 @@ describe("parseTrustedProxies", () => {
14
14
  expect(result.size).toBe(2);
15
15
  });
16
16
  });
17
+ describe("isPrivateIp", () => {
18
+ it("identifies 10.x.x.x as private", () => {
19
+ expect(isPrivateIp("10.0.0.1")).toBe(true);
20
+ expect(isPrivateIp("10.255.255.255")).toBe(true);
21
+ });
22
+ it("identifies 172.16-31.x.x as private", () => {
23
+ expect(isPrivateIp("172.16.0.1")).toBe(true);
24
+ expect(isPrivateIp("172.18.0.5")).toBe(true);
25
+ expect(isPrivateIp("172.31.255.255")).toBe(true);
26
+ });
27
+ it("rejects 172.15.x.x and 172.32.x.x as non-private", () => {
28
+ expect(isPrivateIp("172.15.0.1")).toBe(false);
29
+ expect(isPrivateIp("172.32.0.1")).toBe(false);
30
+ });
31
+ it("identifies 192.168.x.x as private", () => {
32
+ expect(isPrivateIp("192.168.0.1")).toBe(true);
33
+ expect(isPrivateIp("192.168.1.100")).toBe(true);
34
+ });
35
+ it("identifies 127.x.x.x as private (loopback)", () => {
36
+ expect(isPrivateIp("127.0.0.1")).toBe(true);
37
+ });
38
+ it("rejects public IPs", () => {
39
+ expect(isPrivateIp("8.8.8.8")).toBe(false);
40
+ expect(isPrivateIp("1.2.3.4")).toBe(false);
41
+ expect(isPrivateIp("203.0.113.1")).toBe(false);
42
+ });
43
+ it("rejects non-IPv4 strings", () => {
44
+ expect(isPrivateIp("::1")).toBe(false);
45
+ expect(isPrivateIp("not-an-ip")).toBe(false);
46
+ });
47
+ });
17
48
  describe("getClientIp", () => {
18
49
  it("returns socket address when no trusted proxies configured", () => {
19
50
  expect(getClientIp("attacker-spoofed", "1.2.3.4", new Set())).toBe("1.2.3.4");
@@ -37,4 +68,15 @@ describe("getClientIp", () => {
37
68
  it("returns 'unknown' when no socket and no XFF", () => {
38
69
  expect(getClientIp(undefined, undefined, new Set())).toBe("unknown");
39
70
  });
71
+ it("auto-trusts private IPs even without explicit trusted proxy set", () => {
72
+ expect(getClientIp("real-client", "172.18.0.5", new Set())).toBe("real-client");
73
+ expect(getClientIp("real-client", "192.168.1.1", new Set())).toBe("real-client");
74
+ expect(getClientIp("real-client", "10.0.0.1", new Set())).toBe("real-client");
75
+ });
76
+ it("auto-trusts IPv6-mapped private IPs", () => {
77
+ expect(getClientIp("real-client", "::ffff:172.18.0.5", new Set())).toBe("real-client");
78
+ });
79
+ it("does NOT auto-trust public IPs without explicit proxy config", () => {
80
+ expect(getClientIp("spoofed", "8.8.8.8", new Set())).toBe("8.8.8.8");
81
+ });
40
82
  });
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import type { Context, MiddlewareHandler } from "hono";
12
12
  import type { IRateLimitRepository } from "./rate-limit-repository.js";
13
- export { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
13
+ export { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
14
14
  export interface RateLimitConfig {
15
15
  /** Maximum number of requests per window. */
16
16
  max: number;
@@ -9,7 +9,7 @@
9
9
  * State is persisted via IRateLimitRepository (DB-backed in production).
10
10
  */
11
11
  import { getClientIpFromContext } from "./get-client-ip.js";
12
- export { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
12
+ export { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
13
13
  // ---------------------------------------------------------------------------
14
14
  // Helpers
15
15
  // ---------------------------------------------------------------------------
@@ -196,11 +196,17 @@ describe("trusted proxy validation", () => {
196
196
  afterEach(() => {
197
197
  vi.useRealTimers();
198
198
  });
199
- it("ignores X-Forwarded-For when TRUSTED_PROXY_IPS is not set", () => {
199
+ it("auto-trusts private IPs even when TRUSTED_PROXY_IPS is not set", () => {
200
200
  const trusted = parseTrustedProxies(undefined);
201
201
  expect(trusted.size).toBe(0);
202
+ // Private IPs are auto-trusted — XFF is used
202
203
  const ip = getClientIp("spoofed-ip", "192.168.1.100", trusted);
203
- expect(ip).toBe("192.168.1.100");
204
+ expect(ip).toBe("spoofed-ip");
205
+ });
206
+ it("ignores X-Forwarded-For from public IPs when TRUSTED_PROXY_IPS is not set", () => {
207
+ const trusted = parseTrustedProxies(undefined);
208
+ const ip = getClientIp("spoofed-ip", "203.0.113.1", trusted);
209
+ expect(ip).toBe("203.0.113.1");
204
210
  });
205
211
  it("trusts X-Forwarded-For when socket address is in TRUSTED_PROXY_IPS", () => {
206
212
  const trusted = parseTrustedProxies("172.18.0.5,10.0.0.1");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.40.0",
3
+ "version": "1.42.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -19,14 +19,34 @@ function normalizeIp(ip: string): string {
19
19
  return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
20
20
  }
21
21
 
22
+ /**
23
+ * Check whether an IPv4 address is in an RFC 1918 private range or loopback.
24
+ * These are always behind a proxy in production (Docker, k8s, cloud VPCs)
25
+ * so trusting X-Forwarded-For from them is safe and expected.
26
+ *
27
+ * Ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8
28
+ */
29
+ export function isPrivateIp(ip: string): boolean {
30
+ const parts = ip.split(".");
31
+ if (parts.length !== 4) return false;
32
+ const a = Number.parseInt(parts[0], 10);
33
+ const b = Number.parseInt(parts[1], 10);
34
+ if (a === 10) return true;
35
+ if (a === 172 && b >= 16 && b <= 31) return true;
36
+ if (a === 192 && b === 168) return true;
37
+ if (a === 127) return true;
38
+ return false;
39
+ }
40
+
22
41
  // Parsed once at module load — no per-request overhead.
23
42
  const trustedProxies = parseTrustedProxies(process.env.TRUSTED_PROXY_IPS);
24
43
 
25
44
  /**
26
45
  * Determine the real client IP.
27
46
  *
28
- * - If `socketAddr` matches a trusted proxy, use the **first** (leftmost)
29
- * value from `X-Forwarded-For` (the original client IP).
47
+ * - If `socketAddr` matches a trusted proxy (explicit list OR RFC 1918
48
+ * private IP), use the **first** (leftmost) value from
49
+ * `X-Forwarded-For` (the original client IP).
30
50
  * - Otherwise, use `socketAddr` directly (XFF is untrusted).
31
51
  * - Falls back to `"unknown"` if neither is available.
32
52
  */
@@ -37,7 +57,7 @@ export function getClientIp(
37
57
  ): string {
38
58
  const normalizedSocket = socketAddr ? normalizeIp(socketAddr) : undefined;
39
59
 
40
- if (xffHeader && normalizedSocket && trusted.has(normalizedSocket)) {
60
+ if (xffHeader && normalizedSocket && (trusted.has(normalizedSocket) || isPrivateIp(normalizedSocket))) {
41
61
  const parts = xffHeader.split(",");
42
62
  const first = parts[0]?.trim();
43
63
  if (first) return first;
@@ -13,7 +13,7 @@ import type { Context, MiddlewareHandler, Next } from "hono";
13
13
  import type { IRateLimitRepository } from "../rate-limit-repository.js";
14
14
  import { getClientIpFromContext } from "./get-client-ip.js";
15
15
 
16
- export { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
16
+ export { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
17
17
 
18
18
  // ---------------------------------------------------------------------------
19
19
  // Types
@@ -202,8 +202,12 @@ const FLEET_CREATE_LIMIT: Omit<RateLimitConfig, "repo" | "scope"> = { max: 30 };
202
202
  /** Fleet read operations (GET /fleet/*): 120 req/min */
203
203
  const FLEET_READ_LIMIT: Omit<RateLimitConfig, "repo" | "scope"> = { max: 120 };
204
204
 
205
- /** Default for everything else: 60 req/min */
206
- const DEFAULT_LIMIT: Omit<RateLimitConfig, "repo" | "scope"> = { max: 60 };
205
+ /** Default for everything else: 200 req/min.
206
+ * A single Next.js page load triggers ~60-70 REST requests (sidebar, credits,
207
+ * session validation, marketplace, etc.). 60 was too low and caused users to
208
+ * hit rate limits on their first page load. Sensitive endpoints (auth, billing,
209
+ * signup) have their own stricter limits below. */
210
+ const DEFAULT_LIMIT: Omit<RateLimitConfig, "repo" | "scope"> = { max: 200 };
207
211
 
208
212
  /** Auth login: 5 failed attempts per 15 minutes (WOP-839) */
209
213
  const AUTH_LOGIN_LIMIT: Omit<RateLimitConfig, "repo" | "scope"> = {
@@ -94,7 +94,8 @@ export function capabilityRateLimit(
94
94
  }
95
95
 
96
96
  const tenant = c.get("gatewayTenant") as GatewayTenant | undefined;
97
- const tenantId = tenant?.id ?? "unknown";
97
+ const attributedTenantId = c.get("attributedTenantId") as string | null | undefined;
98
+ const tenantId = attributedTenantId ?? tenant?.id ?? "unknown";
98
99
  const max = limits[category];
99
100
  const scope = `cap:${category}`;
100
101
  const now = Date.now();
@@ -54,6 +54,13 @@ export async function creditBalanceCheck(
54
54
  if (!deps.creditLedger) return null;
55
55
 
56
56
  const tenant = c.get("gatewayTenant");
57
+
58
+ // Platform service accounts bypass the pre-call balance gate.
59
+ // The company never 402s itself. Debit still runs post-call for P&L tracking.
60
+ if (tenant.type === "platform_service") {
61
+ return null;
62
+ }
63
+
57
64
  const balance = await deps.creditLedger.balance(tenant.id);
58
65
  const required = Math.max(0, estimatedCostCents);
59
66
  const graceBuffer = deps.graceBufferCents ?? 50; // default -$0.50
@@ -100,6 +107,7 @@ export async function debitCredits(
100
107
  capability: string,
101
108
  provider: string,
102
109
  attributedUserId?: string,
110
+ attributedTenantId?: string | null,
103
111
  ): Promise<void> {
104
112
  if (!deps.creditLedger) return;
105
113
 
@@ -122,6 +130,7 @@ export async function debitCredits(
122
130
  description: `Gateway ${capability} via ${provider}`,
123
131
  allowNegative: true,
124
132
  attributedUserId,
133
+ ...(attributedTenantId ? { attributed_tenant_id: attributedTenantId } : {}),
125
134
  });
126
135
 
127
136
  // Only fire on first zero-crossing (balance was positive before, now ≤ 0)
@@ -136,6 +136,7 @@ export function createAnthropicRoutes(deps: ProtocolDeps): Hono<GatewayAuthEnv>
136
136
  function messagesHandler(deps: ProtocolDeps) {
137
137
  return async (c: Context<GatewayAuthEnv>) => {
138
138
  const tenant = c.get("gatewayTenant");
139
+ const attributedTenantId = c.get("attributedTenantId");
139
140
 
140
141
  // Budget check
141
142
  const budgetResult = await deps.budgetChecker.check(tenant.id, tenant.spendLimits);
@@ -224,7 +225,16 @@ function messagesHandler(deps: ProtocolDeps) {
224
225
  provider: "openrouter",
225
226
  timestamp: Date.now(),
226
227
  });
227
- debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter");
228
+ debitCredits(
229
+ deps,
230
+ tenant.id,
231
+ costUsd,
232
+ deps.defaultMargin,
233
+ "chat-completions",
234
+ "openrouter",
235
+ undefined,
236
+ attributedTenantId,
237
+ );
228
238
  }
229
239
 
230
240
  return new Response(res.body, {
@@ -146,6 +146,7 @@ export function createOpenAIRoutes(deps: ProtocolDeps): Hono<GatewayAuthEnv> {
146
146
  function chatCompletionsHandler(deps: ProtocolDeps) {
147
147
  return async (c: Context<GatewayAuthEnv>) => {
148
148
  const tenant = c.get("gatewayTenant");
149
+ const attributedTenantId = c.get("attributedTenantId");
149
150
 
150
151
  // Budget check
151
152
  const budgetResult = await deps.budgetChecker.check(tenant.id, tenant.spendLimits);
@@ -241,7 +242,16 @@ function chatCompletionsHandler(deps: ProtocolDeps) {
241
242
  provider: "openrouter",
242
243
  timestamp: Date.now(),
243
244
  });
244
- debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter");
245
+ debitCredits(
246
+ deps,
247
+ tenant.id,
248
+ costUsd,
249
+ deps.defaultMargin,
250
+ "chat-completions",
251
+ "openrouter",
252
+ undefined,
253
+ attributedTenantId,
254
+ );
245
255
  }
246
256
 
247
257
  return new Response(res.body, {
@@ -277,7 +287,16 @@ function chatCompletionsHandler(deps: ProtocolDeps) {
277
287
  provider: "openrouter",
278
288
  timestamp: Date.now(),
279
289
  });
280
- debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "chat-completions", "openrouter");
290
+ debitCredits(
291
+ deps,
292
+ tenant.id,
293
+ costUsd,
294
+ deps.defaultMargin,
295
+ "chat-completions",
296
+ "openrouter",
297
+ undefined,
298
+ attributedTenantId,
299
+ );
281
300
  }
282
301
 
283
302
  return new Response(responseBody, {
@@ -309,6 +328,7 @@ function chatCompletionsHandler(deps: ProtocolDeps) {
309
328
  function embeddingsHandler(deps: ProtocolDeps) {
310
329
  return async (c: Context<GatewayAuthEnv>) => {
311
330
  const tenant = c.get("gatewayTenant");
331
+ const attributedTenantId = c.get("attributedTenantId");
312
332
 
313
333
  // Budget check
314
334
  const budgetResult = await deps.budgetChecker.check(tenant.id, tenant.spendLimits);
@@ -391,7 +411,16 @@ function embeddingsHandler(deps: ProtocolDeps) {
391
411
  provider: "openrouter",
392
412
  timestamp: Date.now(),
393
413
  });
394
- debitCredits(deps, tenant.id, costUsd, deps.defaultMargin, "embeddings", "openrouter");
414
+ debitCredits(
415
+ deps,
416
+ tenant.id,
417
+ costUsd,
418
+ deps.defaultMargin,
419
+ "embeddings",
420
+ "openrouter",
421
+ undefined,
422
+ attributedTenantId,
423
+ );
395
424
  }
396
425
 
397
426
  return new Response(responseBody, {
@@ -14,6 +14,7 @@ export interface GatewayAuthEnv {
14
14
  Variables: {
15
15
  gatewayTenant: GatewayTenant;
16
16
  webhookBody: Record<string, unknown>;
17
+ attributedTenantId: string | null;
17
18
  };
18
19
  }
19
20
 
@@ -90,6 +91,8 @@ export function serviceKeyAuth(
90
91
  }
91
92
 
92
93
  c.set("gatewayTenant", tenant);
94
+ const attributedTenantId = c.req.header("x-attribute-to") ?? null;
95
+ c.set("attributedTenantId", attributedTenantId);
93
96
  return next();
94
97
  };
95
98
  }
@@ -47,6 +47,8 @@ export interface GatewayEndpoint {
47
47
  export interface GatewayTenant {
48
48
  /** Tenant ID */
49
49
  id: string;
50
+ /** Tenant type — platform_service accounts bypass the pre-call credit gate */
51
+ type?: "personal" | "org" | "platform_service";
50
52
  /** Spend limits for budget checking */
51
53
  spendLimits: SpendLimits;
52
54
  /** Plan tier for rate limit lookup */
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
2
+ import { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
3
3
 
4
4
  describe("parseTrustedProxies", () => {
5
5
  it("returns empty set for undefined", () => {
@@ -18,6 +18,44 @@ describe("parseTrustedProxies", () => {
18
18
  });
19
19
  });
20
20
 
21
+ describe("isPrivateIp", () => {
22
+ it("identifies 10.x.x.x as private", () => {
23
+ expect(isPrivateIp("10.0.0.1")).toBe(true);
24
+ expect(isPrivateIp("10.255.255.255")).toBe(true);
25
+ });
26
+
27
+ it("identifies 172.16-31.x.x as private", () => {
28
+ expect(isPrivateIp("172.16.0.1")).toBe(true);
29
+ expect(isPrivateIp("172.18.0.5")).toBe(true);
30
+ expect(isPrivateIp("172.31.255.255")).toBe(true);
31
+ });
32
+
33
+ it("rejects 172.15.x.x and 172.32.x.x as non-private", () => {
34
+ expect(isPrivateIp("172.15.0.1")).toBe(false);
35
+ expect(isPrivateIp("172.32.0.1")).toBe(false);
36
+ });
37
+
38
+ it("identifies 192.168.x.x as private", () => {
39
+ expect(isPrivateIp("192.168.0.1")).toBe(true);
40
+ expect(isPrivateIp("192.168.1.100")).toBe(true);
41
+ });
42
+
43
+ it("identifies 127.x.x.x as private (loopback)", () => {
44
+ expect(isPrivateIp("127.0.0.1")).toBe(true);
45
+ });
46
+
47
+ it("rejects public IPs", () => {
48
+ expect(isPrivateIp("8.8.8.8")).toBe(false);
49
+ expect(isPrivateIp("1.2.3.4")).toBe(false);
50
+ expect(isPrivateIp("203.0.113.1")).toBe(false);
51
+ });
52
+
53
+ it("rejects non-IPv4 strings", () => {
54
+ expect(isPrivateIp("::1")).toBe(false);
55
+ expect(isPrivateIp("not-an-ip")).toBe(false);
56
+ });
57
+ });
58
+
21
59
  describe("getClientIp", () => {
22
60
  it("returns socket address when no trusted proxies configured", () => {
23
61
  expect(getClientIp("attacker-spoofed", "1.2.3.4", new Set())).toBe("1.2.3.4");
@@ -46,4 +84,18 @@ describe("getClientIp", () => {
46
84
  it("returns 'unknown' when no socket and no XFF", () => {
47
85
  expect(getClientIp(undefined, undefined, new Set())).toBe("unknown");
48
86
  });
87
+
88
+ it("auto-trusts private IPs even without explicit trusted proxy set", () => {
89
+ expect(getClientIp("real-client", "172.18.0.5", new Set())).toBe("real-client");
90
+ expect(getClientIp("real-client", "192.168.1.1", new Set())).toBe("real-client");
91
+ expect(getClientIp("real-client", "10.0.0.1", new Set())).toBe("real-client");
92
+ });
93
+
94
+ it("auto-trusts IPv6-mapped private IPs", () => {
95
+ expect(getClientIp("real-client", "::ffff:172.18.0.5", new Set())).toBe("real-client");
96
+ });
97
+
98
+ it("does NOT auto-trust public IPs without explicit proxy config", () => {
99
+ expect(getClientIp("spoofed", "8.8.8.8", new Set())).toBe("8.8.8.8");
100
+ });
49
101
  });
@@ -19,14 +19,34 @@ function normalizeIp(ip: string): string {
19
19
  return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
20
20
  }
21
21
 
22
+ /**
23
+ * Check whether an IPv4 address is in an RFC 1918 private range or loopback.
24
+ * These are always behind a proxy in production (Docker, k8s, cloud VPCs)
25
+ * so trusting X-Forwarded-For from them is safe and expected.
26
+ *
27
+ * Ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8
28
+ */
29
+ export function isPrivateIp(ip: string): boolean {
30
+ const parts = ip.split(".");
31
+ if (parts.length !== 4) return false;
32
+ const a = Number.parseInt(parts[0], 10);
33
+ const b = Number.parseInt(parts[1], 10);
34
+ if (a === 10) return true;
35
+ if (a === 172 && b >= 16 && b <= 31) return true;
36
+ if (a === 192 && b === 168) return true;
37
+ if (a === 127) return true;
38
+ return false;
39
+ }
40
+
22
41
  // Parsed once at module load — no per-request overhead.
23
42
  const trustedProxies = parseTrustedProxies(process.env.TRUSTED_PROXY_IPS);
24
43
 
25
44
  /**
26
45
  * Determine the real client IP.
27
46
  *
28
- * - If `socketAddr` matches a trusted proxy, use the **last** (rightmost)
29
- * value from `X-Forwarded-For` (closest hop to the trusted proxy).
47
+ * - If `socketAddr` matches a trusted proxy (explicit list OR RFC 1918
48
+ * private IP), use the **last** (rightmost) value from
49
+ * `X-Forwarded-For` (closest hop to the trusted proxy).
30
50
  * - Otherwise, use `socketAddr` directly (XFF is untrusted).
31
51
  * - Falls back to `"unknown"` if neither is available.
32
52
  */
@@ -37,7 +57,7 @@ export function getClientIp(
37
57
  ): string {
38
58
  const normalizedSocket = socketAddr ? normalizeIp(socketAddr) : undefined;
39
59
 
40
- if (xffHeader && normalizedSocket && trusted.has(normalizedSocket)) {
60
+ if (xffHeader && normalizedSocket && (trusted.has(normalizedSocket) || isPrivateIp(normalizedSocket))) {
41
61
  // Trust XFF — take the rightmost (last) value
42
62
  const parts = xffHeader.split(",");
43
63
  const last = parts[parts.length - 1]?.trim();
@@ -272,12 +272,19 @@ describe("trusted proxy validation", () => {
272
272
  vi.useRealTimers();
273
273
  });
274
274
 
275
- it("ignores X-Forwarded-For when TRUSTED_PROXY_IPS is not set", () => {
275
+ it("auto-trusts private IPs even when TRUSTED_PROXY_IPS is not set", () => {
276
276
  const trusted = parseTrustedProxies(undefined);
277
277
  expect(trusted.size).toBe(0);
278
278
 
279
+ // Private IPs are auto-trusted — XFF is used
279
280
  const ip = getClientIp("spoofed-ip", "192.168.1.100", trusted);
280
- expect(ip).toBe("192.168.1.100");
281
+ expect(ip).toBe("spoofed-ip");
282
+ });
283
+
284
+ it("ignores X-Forwarded-For from public IPs when TRUSTED_PROXY_IPS is not set", () => {
285
+ const trusted = parseTrustedProxies(undefined);
286
+ const ip = getClientIp("spoofed-ip", "203.0.113.1", trusted);
287
+ expect(ip).toBe("203.0.113.1");
281
288
  });
282
289
 
283
290
  it("trusts X-Forwarded-For when socket address is in TRUSTED_PROXY_IPS", () => {
@@ -13,7 +13,7 @@ import type { Context, MiddlewareHandler, Next } from "hono";
13
13
  import { getClientIpFromContext } from "./get-client-ip.js";
14
14
  import type { IRateLimitRepository } from "./rate-limit-repository.js";
15
15
 
16
- export { getClientIp, parseTrustedProxies } from "./get-client-ip.js";
16
+ export { getClientIp, isPrivateIp, parseTrustedProxies } from "./get-client-ip.js";
17
17
 
18
18
  // ---------------------------------------------------------------------------
19
19
  // Types