diff --git a/src/__tests__/proxy.integration.test.ts b/src/__tests__/proxy.integration.test.ts index 79a4580f..5331f791 100644 --- a/src/__tests__/proxy.integration.test.ts +++ b/src/__tests__/proxy.integration.test.ts @@ -1604,3 +1604,91 @@ describe('Proxy idempotent-method retry contract (#1292)', () => { await new Promise((resolve) => tmpServer.close(() => resolve())); }); }); + +describe('Proxy endpoint price balance check (#1224)', () => { + it('returns 402 INSUFFICIENT_BALANCE without calling upstream when balance is 1 and endpoint price is 5, succeeds at balance 5, and allows free endpoints at balance 0', async () => { + const pricedRegistry = new InMemoryApiRegistry([ + { + id: 'api_priced', + slug: 'priced-api', + base_url: upstreamUrl, + developerId: TEST_DEVELOPER_ID, + endpoints: [ + { endpointId: 'ep_paid', path: 'paid', priceUsdc: 5 }, + { endpointId: 'ep_free', path: 'free', priceUsdc: 0 }, + ], + }, + ]); + const pricedKeys = new Map([ + ['priced-key-123456', { key: 'priced-key-123456', developerId: TEST_DEVELOPER_ID, apiId: 'api_priced' }], + ]); + + const tmpApp = express(); + tmpApp.use(express.json()); + tmpApp.use(requestIdMiddleware); + tmpApp.use( + '/v1/call', + createProxyRouter({ + billing, + rateLimiter, + usageStore, + registry: pricedRegistry, + apiKeys: pricedKeys, + proxyConfig: { + timeoutMs: 2000, + allowedHosts: ['localhost'], + }, + }), + ); + tmpApp.use(errorHandler); + + const tmpServer = await new Promise((resolve) => { + const s = tmpApp.listen(0, () => resolve(s)); + }); + const tmpAddr = tmpServer.address(); + const tmpUrl = + tmpAddr && typeof tmpAddr === 'object' ? `http://localhost:${tmpAddr.port}` : ''; + + try { + let upstreamHits = 0; + setUpstreamHandler((_req, res) => { + upstreamHits++; + res.status(200).json({ ok: true }); + }); + + // 1. Balance 1 < price 5 -> 402 INSUFFICIENT_BALANCE, upstream not called + billing.setBalance(TEST_DEVELOPER_ID, 1); + const resInsufficient = await fetch(`${tmpUrl}/v1/call/priced-api/paid`, { + method: 'GET', + headers: { 'x-api-key': 'priced-key-123456' }, + }); + expect(resInsufficient.status).toBe(402); + const bodyInsufficient = await resInsufficient.json(); + const errInsufficient = bodyInsufficient.error ?? bodyInsufficient; + expect(errInsufficient.code).toBe('INSUFFICIENT_BALANCE'); + expect(errInsufficient.message).toMatch(/insufficient balance/i); + expect(upstreamHits).toBe(0); + + // 2. Balance 5 == price 5 -> 200 OK, upstream called + billing.setBalance(TEST_DEVELOPER_ID, 5); + const resSufficient = await fetch(`${tmpUrl}/v1/call/priced-api/paid`, { + method: 'GET', + headers: { 'x-api-key': 'priced-key-123456' }, + }); + expect(resSufficient.status).toBe(200); + expect(upstreamHits).toBe(1); + + // 3. Balance 0 calling free endpoint (price 0) -> 200 OK, upstream called + billing.setBalance(TEST_DEVELOPER_ID, 0); + const resFree = await fetch(`${tmpUrl}/v1/call/priced-api/free`, { + method: 'GET', + headers: { 'x-api-key': 'priced-key-123456' }, + }); + expect(resFree.status).toBe(200); + expect(upstreamHits).toBe(2); + } finally { + await new Promise((resolve) => tmpServer.close(() => resolve())); + } + }); +}); + diff --git a/src/routes/gatewayRoutes.test.ts b/src/routes/gatewayRoutes.test.ts index 5ad3138f..328fd722 100644 --- a/src/routes/gatewayRoutes.test.ts +++ b/src/routes/gatewayRoutes.test.ts @@ -50,8 +50,8 @@ describe("gateway route - rate limiting", () => { expect(res.status).toBe(429); // Retry-After header is in seconds, rounded up expect(res.headers["retry-after"]).toBe(String(Math.ceil(windowMs / 1000))); - expect(res.body).toHaveProperty("code", "TOO_MANY_REQUESTS"); - expect(res.body).toHaveProperty("message", "Too Many Requests"); + expect(res.body.error ?? res.body).toHaveProperty("code", "TOO_MANY_REQUESTS"); + expect(res.body.error ?? res.body).toHaveProperty("message", "Too Many Requests"); expect(res.body).toHaveProperty("requestId"); }); }); @@ -195,8 +195,8 @@ describe("gateway route - body size limits", () => { expect(res.status).toBe(413); expect(res.headers["content-type"]).toMatch(/application\/json/); - expect(res.body).toHaveProperty("code", "REQUEST_BODY_TOO_LARGE"); - expect(res.body).toHaveProperty("message", "Request body too large"); + expect(res.body.error ?? res.body).toHaveProperty("code", "REQUEST_BODY_TOO_LARGE"); + expect(res.body.error ?? res.body).toHaveProperty("message", "Request body too large"); }); }); @@ -303,9 +303,10 @@ describe("gateway route - API key prefix / hash mismatch (bug #421)", () => { // Must be 401, never 500. expect(res.status).toBe(401); - expect(res.body).toHaveProperty("code", "UNAUTHORIZED"); + const errPayload = res.body.error ?? res.body; + expect(errPayload).toHaveProperty("code", "UNAUTHORIZED"); // Generic message — must not reveal whether the prefix was found. - expect(res.body.message).toMatch(/invalid API key/i); + expect(errPayload.message).toMatch(/invalid API key/i); }); test("returns 401 when prefix does not exist at all (no-prefix path)", async () => { @@ -321,8 +322,9 @@ describe("gateway route - API key prefix / hash mismatch (bug #421)", () => { .set("x-api-key", "totally-unknown-key-xyz"); expect(res.status).toBe(401); - expect(res.body).toHaveProperty("code", "UNAUTHORIZED"); - expect(res.body.message).toMatch(/invalid API key/i); + const errPayload = res.body.error ?? res.body; + expect(errPayload).toHaveProperty("code", "UNAUTHORIZED"); + expect(errPayload.message).toMatch(/invalid API key/i); }); test("happy path — exact key match passes auth and reaches billing", async () => { @@ -355,7 +357,7 @@ describe("gateway route - API key prefix / hash mismatch (bug #421)", () => { .set("x-api-key", validKey); expect(res.status).toBe(401); - expect(res.body).toHaveProperty("code", "UNAUTHORIZED"); + expect(res.body.error ?? res.body).toHaveProperty("code", "UNAUTHORIZED"); }); test("returns 403 when key is revoked (prefix + hash match a revoked key)", async () => { @@ -375,7 +377,7 @@ describe("gateway route - API key prefix / hash mismatch (bug #421)", () => { .set("x-api-key", validKey); expect(res.status).toBe(403); - expect(res.body).toHaveProperty("code", "FORBIDDEN"); + expect(res.body.error ?? res.body).toHaveProperty("code", "FORBIDDEN"); }); test("returns 403 when key is in revocation list", async () => { @@ -404,7 +406,7 @@ describe("gateway route - API key prefix / hash mismatch (bug #421)", () => { .set("x-api-key", validKey); expect(res.status).toBe(403); - expect(res.body).toHaveProperty("code", "FORBIDDEN"); + expect(res.body.error ?? res.body).toHaveProperty("code", "FORBIDDEN"); } finally { resetTokenRevocationService(); } @@ -430,20 +432,16 @@ describe("gateway route - API key prefix / hash mismatch (bug #421)", () => { expect(mismatchRes.status).toBe(401); expect(unknownRes.status).toBe(401); + const mismatchErr = mismatchRes.body.error ?? mismatchRes.body; + const unknownErr = unknownRes.body.error ?? unknownRes.body; // Response codes must be identical — client must not be able to distinguish. - expect(mismatchRes.body.code).toBe(unknownRes.body.code); - expect(mismatchRes.body.message).toBe(unknownRes.body.message); + expect(mismatchErr.code).toBe(unknownErr.code); + expect(mismatchErr.message).toBe(unknownErr.message); }); }); // --------------------------------------------------------------------------- // gatewayRoutes.ts — per-user token-bucket inline invocation (issue #870) -// -// These tests exercise the inline gatewayRateLimit(req, res, () => {...}) -// call path inside the router.all('/:apiId', ...) handler in gatewayRoutes.ts. -// They use createGatewayRouter directly with a gatewayRateLimitMiddleware stub -// injected via GatewayDeps so the new code paths show up in coverage for that -// file, not just gatewayRateLimit.ts. // --------------------------------------------------------------------------- describe("gateway route - per-user token-bucket rate limit (issue #870)", () => { const API_ID = "my-api"; @@ -474,7 +472,6 @@ describe("gateway route - per-user token-bucket rate limit (issue #870)", () => } test("passes through to downstream handler when rate limiter calls next()", async () => { - // Middleware that always calls next() — exercises the gatewayRateLimitPassed=true path const passThroughMiddleware: import("express").RequestHandler = (_req, _res, next) => { next(); }; @@ -484,13 +481,10 @@ describe("gateway route - per-user token-bucket rate limit (issue #870)", () => .get(`/gateway/${API_ID}`) .set("x-api-key", VALID_KEY); - // Billing stub returns 402 — proves the inline check passed and execution continued expect(res.status).toBe(402); }); test("returns 429 and stops processing when rate limiter writes its own response (gatewayRateLimitPassed=false path)", async () => { - // Middleware that writes a 429 itself and does NOT call next() — - // exercises the gatewayRateLimitPassed=false early-return path in gatewayRoutes.ts const blockingMiddleware: import("express").RequestHandler = (_req, res, _next) => { res.status(429).json({ code: "TOO_MANY_REQUESTS", @@ -507,14 +501,10 @@ describe("gateway route - per-user token-bucket rate limit (issue #870)", () => expect(res.status).toBe(429); expect(res.body.code).toBe("TOO_MANY_REQUESTS"); - // Billing must NOT have been called — the handler returned before reaching it - // (confirmed by absence of a 402 status) expect(res.status).not.toBe(402); }); test("rate limiter receives req.apiKeyRecord.userId = keyRecord.developerId", async () => { - // Verify that the inline req.apiKeyRecord assignment writes the correct userId - // so that the token-bucket key matches the developer, not the raw API key string. let capturedUserId: string | undefined; const inspectingMiddleware: import("express").RequestHandler = (req, _res, next) => { @@ -528,13 +518,10 @@ describe("gateway route - per-user token-bucket rate limit (issue #870)", () => .get(`/gateway/${API_ID}`) .set("x-api-key", VALID_KEY); - expect(capturedUserId).toBe("dev1"); // keyRecord.developerId from apiKeys map + expect(capturedUserId).toBe("dev1"); }); test("returns 429 with Retry-After header via real InMemoryGatewayRateLimiter through createGatewayRouter", async () => { - // Wire a real (not stubbed) token-bucket limiter with limit=1 into the gateway router - // to exercise the full path: auth → req.apiKeyRecord assignment → limiter.check() → - // 429 written by middleware → gatewayRateLimitPassed=false → early return. const { createGatewayRateLimitMiddleware, InMemoryGatewayRateLimiter } = await import( "../middleware/gatewayRateLimit.js" ); @@ -544,13 +531,11 @@ describe("gateway route - per-user token-bucket rate limit (issue #870)", () => const app = buildGatewayApp(rl); - // First request — token bucket not yet exhausted, billing stub returns 402 const first = await request(app) .get(`/gateway/${API_ID}`) .set("x-api-key", VALID_KEY); - expect(first.status).toBe(402); // auth + rate limit passed, billing fails fast + expect(first.status).toBe(402); - // Second request — bucket empty, rate limiter writes 429 directly const second = await request(app) .get(`/gateway/${API_ID}`) .set("x-api-key", VALID_KEY); @@ -561,3 +546,99 @@ describe("gateway route - per-user token-bucket rate limit (issue #870)", () => expect(second.body.retryAfterMs).toBeGreaterThan(0); }); }); + +// --------------------------------------------------------------------------- +// Issue #1224 — Require balance covering endpoint price before proxying +// --------------------------------------------------------------------------- +describe("proxy route - endpoint price balance check (issue #1224)", () => { + test("rejects with 402 INSUFFICIENT_BALANCE without contacting upstream when balance is positive but less than endpoint price", async () => { + const { createProxyRouter } = await import("./proxyRoutes.js"); + const { MockSorobanBilling } = await import("../services/billingService.js"); + const { InMemoryRateLimiter } = await import("../services/rateLimiter.js"); + const { InMemoryUsageStore } = await import("../services/usageStore.js"); + const { InMemoryApiRegistry } = await import("../data/apiRegistry.js"); + + const apiKey = "proxy-price-key-123456"; + const devId = "dev_price_1"; + const apiId = "api_price_1"; + const apiKeys = new Map([ + [apiKey, { key: apiKey, developerId: devId, apiId }], + ]); + + const registry = new InMemoryApiRegistry([ + { + id: apiId, + slug: "priced-api", + base_url: "http://localhost:9999", + developerId: devId, + endpoints: [ + { endpointId: "ep_expensive", path: "expensive", priceUsdc: 5 }, + { endpointId: "ep_free", path: "free", priceUsdc: 0 }, + ], + }, + ]); + + // Balance is 1 (< 5 required for /expensive) + const billing = new MockSorobanBilling({ [devId]: 1 }); + const rateLimiter = new InMemoryRateLimiter(100, 60_000); + const usageStore = new InMemoryUsageStore(); + + const originalFetch = global.fetch; + const fetchMock = jest.fn().mockResolvedValue({ + status: 200, + headers: new Headers({ "content-type": "application/json" }), + text: async () => JSON.stringify({ ok: true }), + } as Response); + global.fetch = fetchMock as unknown as typeof fetch; + + try { + const app = express(); + app.use(express.json()); + app.use(requestIdMiddleware); + app.use( + "/v1/call", + createProxyRouter({ + billing, + rateLimiter, + usageStore, + registry, + apiKeys, + proxyConfig: { timeoutMs: 2000, allowedHosts: ["localhost"] }, + }), + ); + app.use(errorHandler); + + // 1. Balance 1 calling 5-USDC endpoint -> 402 INSUFFICIENT_BALANCE, no upstream call + const insufficientRes = await request(app) + .get("/v1/call/priced-api/expensive") + .set("x-api-key", apiKey); + + expect(insufficientRes.status).toBe(402); + const errObj = insufficientRes.body.error ?? insufficientRes.body; + expect(errObj.code).toBe("INSUFFICIENT_BALANCE"); + expect(errObj.message).toMatch(/insufficient balance/i); + expect(fetchMock).not.toHaveBeenCalled(); + + // 2. Balance 5 calling 5-USDC endpoint -> 200 OK, upstream called + billing.setBalance(devId, 5); + const sufficientRes = await request(app) + .get("/v1/call/priced-api/expensive") + .set("x-api-key", apiKey); + + expect(sufficientRes.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // 3. Balance 0 calling 0-USDC (free) endpoint -> 200 OK, upstream called + billing.setBalance(devId, 0); + const freeRes = await request(app) + .get("/v1/call/priced-api/free") + .set("x-api-key", apiKey); + + expect(freeRes.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + } finally { + global.fetch = originalFetch; + } + }); +}); + diff --git a/src/routes/proxyRoutes.ts b/src/routes/proxyRoutes.ts index cafcf43d..2db00c2f 100644 --- a/src/routes/proxyRoutes.ts +++ b/src/routes/proxyRoutes.ts @@ -180,16 +180,28 @@ export function createProxyRouter(deps: ProxyDeps): Router { return; } - // 4. Pre-proxy balance check (ensure they have funds, deduct later) - const currentBalance = await billing.checkBalance(keyRecord.userId); - if (currentBalance <= 0) { - next(new PaymentRequiredError('Payment Required: insufficient balance')); - return; - } - - // 5. Build upstream URL & find price + // 4. Resolve endpoint pricing for the target path and verify balance // req.params[0] captures the wildcard portion after the slug const wildcardPath = req.params[0] ?? ''; + const resolvedEndpoint = Array.isArray(apiEntry.endpoints) && apiEntry.endpoints.length > 0 + ? resolveEndpointPrice(apiEntry.endpoints, wildcardPath) + : endpoint; + const endpointPriceUsdc = Number(resolvedEndpoint?.priceUsdc ?? endpoint.priceUsdc ?? 0); + + if (endpointPriceUsdc > 0) { + const currentBalance = await billing.checkBalance(keyRecord.userId); + if (currentBalance < endpointPriceUsdc) { + next( + new PaymentRequiredError( + 'Payment Required: insufficient balance', + 'INSUFFICIENT_BALANCE', + ), + ); + return; + } + } + + // 5. Build upstream URL const upstreamTarget = buildUpstreamTargetUrl(apiEntry.base_url, wildcardPath); let safeUpstreamTarget: string; @@ -264,6 +276,80 @@ export function createProxyRouter(deps: ProxyDeps): Router { }); res.set('x-request-id', requestId); + // 8. Keep metering and billing consistent — but ONLY after the response + // has been fully delivered to the caller. + // Using a one-shot 'finish' listener (registered before we start + // streaming) ensures we capture the event even if the stream + // completes synchronously. + if (config.recordableStatuses(upstreamStatus)) { + let responseFinished = false; + + res.once('finish', () => { + responseFinished = true; + + setImmediate(() => { + void (async () => { + try { + const recorded = await usageStore.record({ + id: randomUUID(), + requestId, + apiKey: apiKeyHeader, + apiKeyId: keyRecord.id, + apiId: String(apiEntry.id), + endpointId: resolvedEndpoint.endpointId, + userId: keyRecord.userId, + amountUsdc: endpointPriceUsdc, + statusCode: upstreamStatus, + timestamp: new Date().toISOString(), + }); + + if (recorded) { + defaultUsageSseBroadcaster.emitForUser(keyRecord.userId, { + id: randomUUID(), + requestId, + apiKeyId: keyRecord.id, + apiKeyPrefix: maskApiKey(apiKeyHeader), + apiId: String(apiEntry.id), + endpointId: resolvedEndpoint.endpointId, + userId: keyRecord.userId, + amountUsdc: endpointPriceUsdc, + statusCode: upstreamStatus, + timestamp: new Date().toISOString(), + }); + } + + recordEndpointThroughputSaturation({ + apiId: String(apiEntry.id), + endpointId: resolvedEndpoint.endpointId, + endpointPath: resolvedEndpoint.path, + advertisedLimitPerMinute: Number(keyRecord?.rateLimitPerMinute ?? 0), + observedAt: Date.now(), + }); + + if (recorded && endpointPriceUsdc > 0) { + billing.deductCredit(keyRecord.userId, endpointPriceUsdc).catch((err) => { + console.error('Background billing deduction failed:', err); + }); + } + } catch (err) { + recordUsageRecordFailure(); + logger.error('Background usage recording failed', { + requestId, + apiId: String(apiEntry.id), + error: err, + }); + } + })(); + }); + }); + + res.once('close', () => { + if (!responseFinished) { + recordProxyPrematureAbort(); + } + }); + } + // Stream body back res.status(upstreamStatus); if (upstreamRes.body) { @@ -313,7 +399,7 @@ export function createProxyRouter(deps: ProxyDeps): Router { err, requestId, apiId: String(apiEntry.id), - endpointId: endpoint.endpointId, + endpointId: resolvedEndpoint.endpointId, upstreamStatus, }, 'Proxy error after headers sent; destroying response socket', @@ -359,99 +445,6 @@ export function createProxyRouter(deps: ProxyDeps): Router { throw new BadGatewayError('Bad Gateway: upstream unreachable'); } } - - // 8. Keep metering and billing consistent — but ONLY after the response - // has been fully delivered to the caller. - // - // We distinguish two response lifecycle events: - // • 'finish' — Node/Express has flushed all data and ended the - // response normally. This is the success path; we - // record usage here. - // • 'close' — The underlying socket was torn down. When this - // fires WITHOUT a prior 'finish' it means the client - // disconnected mid-stream (premature abort). In that - // case we must NOT record usage because the caller - // never received the response. - // - // Using a one-shot 'finish' listener (registered before we start - // streaming) ensures we capture the event even if the stream - // completes synchronously. The 'close' listener is a guard that - // cancels the deferred work when the socket drops first. - if (config.recordableStatuses(upstreamStatus)) { - // Track whether the response finished cleanly before the socket closed. - let responseFinished = false; - - res.once('finish', () => { - responseFinished = true; - - // Run usage recording in a non-blocking microtask so it does not - // delay the event loop that is already handling the next request. - setImmediate(() => { - void (async () => { - try { - const recorded = await usageStore.record({ - id: randomUUID(), // ID of the usage event itself - requestId, // Idempotency key — prevents double-counts - apiKeyId: keyRecord.id, - apiId: String(apiEntry.id), - endpointId: endpoint.endpointId, - userId: keyRecord.userId, - amountUsdc: endpoint.priceUsdc, - statusCode: upstreamStatus, - timestamp: new Date().toISOString(), - }); - - if (recorded) { - defaultUsageSseBroadcaster.emitForUser(keyRecord.userId, { - id: randomUUID(), - requestId, - apiKeyId: keyRecord.id, - apiKeyPrefix: maskApiKey(apiKeyHeader), - apiId: String(apiEntry.id), - endpointId: endpoint.endpointId, - userId: keyRecord.userId, - amountUsdc: endpoint.priceUsdc, - statusCode: upstreamStatus, - timestamp: new Date().toISOString(), - }); - } - - recordEndpointThroughputSaturation({ - apiId: String(apiEntry.id), - endpointId: endpoint.endpointId, - endpointPath: endpoint.path, - advertisedLimitPerMinute: Number(keyRecord?.rateLimitPerMinute ?? 0), - observedAt: Date.now(), - }); - - // Only deduct billing if this requestId hasn't been processed - // before (idempotency guard inside usageStore.record). - if (recorded && endpoint.priceUsdc > 0) { - billing.deductCredit(keyRecord.userId, endpoint.priceUsdc).catch((err) => { - console.error('Background billing deduction failed:', err); - }); - } - } catch (err) { - recordUsageRecordFailure(); - logger.error('Background usage recording failed', { - requestId, - apiId: String(apiEntry.id), - error: err, - }); - } - })(); - }); - }); - - res.once('close', () => { - // 'close' fires after 'finish' on a normal response, or on its own - // when the socket is destroyed prematurely. Only treat it as an - // abort when 'finish' has NOT already fired. - if (!responseFinished) { - recordProxyPrematureAbort(); - } - }); - } } catch (error) { next(error); }