From 3853ced9903d6a1a0bb6af3b0c4d8560a973a402 Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Tue, 20 Oct 2026 00:07:21 +0530 Subject: [PATCH] feat(usage): allow callers to read own usage with no-store and window resets (#513) --- openapi.json | 62 +++++++++++ openapi.yaml | 39 +++++++ src/__tests__/callerUsage.test.ts | 177 ++++++++++++++++++++++++++++++ src/api/usage.ts | 32 +++--- src/x402/metering.ts | 25 +++++ 5 files changed, 320 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/callerUsage.test.ts diff --git a/openapi.json b/openapi.json index c9f1adbe..047787af 100644 --- a/openapi.json +++ b/openapi.json @@ -1962,6 +1962,40 @@ } } }, + "/usage": { + "get": { + "operationId": "getCallerUsage", + "summary": "Get usage and quota for the calling API key (alias for /usage/me)", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Usage summary for the authenticated key", + "headers": { + "Cache-Control": { + "$ref": "#/components/headers/CacheControl" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid API key" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, "/supported": { "get": { "operationId": "getSupported", @@ -3922,6 +3956,34 @@ }, "quotaExceeded": { "type": "boolean" + }, + "dailyResetsAt": { + "type": "string" + }, + "monthlyResetsAt": { + "type": "string" + }, + "resets": { + "type": "object", + "properties": { + "daily": { + "type": "string" + }, + "monthly": { + "type": "string" + } + } + }, + "resetsInSeconds": { + "type": "object", + "properties": { + "daily": { + "type": "integer" + }, + "monthly": { + "type": "integer" + } + } } } }, diff --git a/openapi.yaml b/openapi.yaml index 96a83621..7e4b25ff 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1290,6 +1290,27 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' + /usage: + get: + operationId: getCallerUsage + summary: Get usage and quota for the calling API key (alias for /usage/me) + security: + - apiKeyAuth: [] + responses: + '200': + description: Usage summary for the authenticated key + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/json: + schema: + $ref: '#/components/schemas/UsageResponse' + '401': + description: Missing or invalid API key + '429': + $ref: '#/components/responses/TooManyRequests' + /supported: get: operationId: getSupported @@ -2506,6 +2527,24 @@ components: enum: [block, charge_402, allow_overage] quotaExceeded: type: boolean + dailyResetsAt: + type: string + monthlyResetsAt: + type: string + resets: + type: object + properties: + daily: + type: string + monthly: + type: string + resetsInSeconds: + type: object + properties: + daily: + type: integer + monthly: + type: integer VerifyRequest: type: object diff --git a/src/__tests__/callerUsage.test.ts b/src/__tests__/callerUsage.test.ts new file mode 100644 index 00000000..e3b99ca7 --- /dev/null +++ b/src/__tests__/callerUsage.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' + +vi.mock('../db', () => ({ + prisma: { + apiKey: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + }, +})) + +vi.mock('../redis', () => ({ + redis: { + get: vi.fn(), + multi: vi.fn().mockReturnThis(), + on: vi.fn(), + }, +})) + +import { prisma } from '../db' +import { redis } from '../redis' +import { registerUsageRoutes } from '../api/usage' +import { registerApiKeyAuth, hashApiKey } from '../api/auth' + +const mockFindUnique = prisma.apiKey.findUnique as any +const mockRedisGet = redis.get as any + +beforeEach(() => { + vi.clearAllMocks() +}) + +async function buildTestApp() { + const app = Fastify() + await app.register(registerApiKeyAuth) + await app.register(registerUsageRoutes) + return app +} + +describe('Issue #513: Caller Own Usage (/usage and /usage/me)', () => { + it('returns 401 without API key', async () => { + const app = await buildTestApp() + const res = await app.inject({ + method: 'GET', + url: '/usage', + }) + expect(res.statusCode).toBe(401) + expect(res.json()).toMatchObject({ error: 'Unauthorized' }) + }) + + it('caller can read their own usage with their own key, receiving no-store and reset countdowns', async () => { + const key1Plain = 'lens_test_key_1_12345678901234567890' + const key1Hash = hashApiKey(key1Plain) + + mockFindUnique.mockImplementation(({ where }: any) => { + if (where.hash === key1Hash) { + return Promise.resolve({ + id: 'key-uuid-1', + label: 'Client 1', + ratePerMin: 60, + ratePerDay: 5000, + monthlyQuotaCents: 15000, + dailyQuotaCents: 800, + overagePolicy: 'block', + revokedAt: null, + expiresAt: null, + }) + } + if (where.id === 'key-uuid-1') { + return Promise.resolve({ + id: 'key-uuid-1', + monthlyQuotaCents: 15000, + dailyQuotaCents: 800, + overagePolicy: 'block', + }) + } + return Promise.resolve(null) + }) + + mockRedisGet.mockImplementation((key: string) => { + if (key.endsWith(':calls')) return Promise.resolve('25') + if (key.endsWith(':cents')) return Promise.resolve('150') + return Promise.resolve('0') + }) + + const app = await buildTestApp() + const res = await app.inject({ + method: 'GET', + url: '/usage', + headers: { + authorization: `Bearer ${key1Plain}`, + }, + }) + + expect(res.statusCode).toBe(200) + expect(res.headers['cache-control']).toBe('no-store') + + const body = res.json() + expect(body.keyId).toBe('key-uuid-1') + expect(body.dailyCalls).toBe(25) + expect(body.dailyCents).toBe(150) + expect(body.dailyQuotaCents).toBe(800) + expect(body.dailyRemainingCents).toBe(650) + expect(body.monthlyQuotaCents).toBe(15000) + expect(body.monthlyRemainingCents).toBe(14850) + expect(body.quotaExceeded).toBe(false) + expect(body.dailyResetsAt).toBeDefined() + expect(body.monthlyResetsAt).toBeDefined() + expect(body.resets.daily).toBeDefined() + expect(body.resets.monthly).toBeDefined() + expect(body.resetsInSeconds.daily).toBeGreaterThan(0) + expect(body.resetsInSeconds.monthly).toBeGreaterThan(0) + }) + + it('guarantees no parameter can make it return another key usage (key isolation)', async () => { + const key1Plain = 'lens_test_key_1_12345678901234567890' + const key1Hash = hashApiKey(key1Plain) + + mockFindUnique.mockImplementation(({ where }: any) => { + if (where.hash === key1Hash) { + return Promise.resolve({ + id: 'key-uuid-1', + label: 'Client 1', + ratePerMin: 60, + ratePerDay: 5000, + monthlyQuotaCents: 10000, + dailyQuotaCents: 500, + overagePolicy: 'block', + revokedAt: null, + expiresAt: null, + }) + } + if (where.id === 'key-uuid-1') { + return Promise.resolve({ + id: 'key-uuid-1', + monthlyQuotaCents: 10000, + dailyQuotaCents: 500, + overagePolicy: 'block', + }) + } + if (where.id === 'victim-key-uuid-2') { + return Promise.resolve({ + id: 'victim-key-uuid-2', + monthlyQuotaCents: 999999, + dailyQuotaCents: 999999, + overagePolicy: 'allow_overage', + }) + } + return Promise.resolve(null) + }) + + const app = await buildTestApp() + + // Caller attempts to pass another key ID via query string + const resQuery = await app.inject({ + method: 'GET', + url: '/usage?keyId=victim-key-uuid-2&id=victim-key-uuid-2', + headers: { + authorization: `Bearer ${key1Plain}`, + }, + }) + expect(resQuery.statusCode).toBe(200) + expect(resQuery.json().keyId).toBe('key-uuid-1') + expect(resQuery.json().keyId).not.toBe('victim-key-uuid-2') + + // Also assert on /usage/me + const resMe = await app.inject({ + method: 'GET', + url: '/usage/me?keyId=victim-key-uuid-2', + headers: { + authorization: `Bearer ${key1Plain}`, + }, + }) + expect(resMe.statusCode).toBe(200) + expect(resMe.json().keyId).toBe('key-uuid-1') + }) +}) diff --git a/src/api/usage.ts b/src/api/usage.ts index 63754383..74e55761 100644 --- a/src/api/usage.ts +++ b/src/api/usage.ts @@ -40,20 +40,22 @@ export async function registerUsageRoutes(app: FastifyInstance) { } ) - app.get( - '/usage/me', - async (req, reply) => { - const apiKey = (req as any).apiKey as { id: string } | undefined - if (!apiKey) { - api_key_auth_failures_total.inc({ reason: 'missing' }) - return reply.status(401).send({ - error: 'Unauthorized', - code: 'missing', - message: 'Missing API key. Provide an Authorization: Bearer header.', - }) - } - const summary = await getUsageSummary(apiKey.id) - return reply.send(summary) + const handleCallerUsage = async (req: any, reply: FastifyReply) => { + reply.header('Cache-Control', 'no-store') + const apiKey = req.apiKey as { id: string } | undefined + if (!apiKey) { + api_key_auth_failures_total.inc({ reason: 'missing' }) + return reply.status(401).send({ + error: 'Unauthorized', + code: 'missing', + message: 'Missing API key. Provide an Authorization: Bearer header.', + }) } - ) + // The key presented determines the answer — never a parameter. + const summary = await getUsageSummary(apiKey.id) + return reply.send(summary) + } + + app.get('/usage/me', handleCallerUsage) + app.get('/usage', handleCallerUsage) } diff --git a/src/x402/metering.ts b/src/x402/metering.ts index a0a8f5d7..0e0f1768 100644 --- a/src/x402/metering.ts +++ b/src/x402/metering.ts @@ -23,6 +23,16 @@ export interface UsageSummary { monthlyRemainingCents: number overagePolicy: OveragePolicy quotaExceeded: boolean + dailyResetsAt: string + monthlyResetsAt: string + resets: { + daily: string + monthly: string + } + resetsInSeconds: { + daily: number + monthly: number + } } function getDailyBase(keyId: string): string { @@ -107,6 +117,11 @@ async function getUsageSummaryInternal(keyId: string, config: QuotaConfig): Prom const monthlyCents = parseInt(monthlyCentsRaw ?? '0', 10) const quotaExceeded = dailyCents >= config.dailyQuotaCents || monthlyCents >= config.monthlyQuotaCents + const now = new Date() + const dailyResetDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)) + const monthlyResetDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)) + const dailyResetsAt = dailyResetDate.toISOString() + const monthlyResetsAt = monthlyResetDate.toISOString() return { keyId, @@ -120,6 +135,16 @@ async function getUsageSummaryInternal(keyId: string, config: QuotaConfig): Prom monthlyRemainingCents: Math.max(0, config.monthlyQuotaCents - monthlyCents), overagePolicy: config.overagePolicy, quotaExceeded, + dailyResetsAt, + monthlyResetsAt, + resets: { + daily: dailyResetsAt, + monthly: monthlyResetsAt, + }, + resetsInSeconds: { + daily: secondsUntilEndOfDay(), + monthly: secondsUntilEndOfMonth(), + }, } }