diff --git a/.kiro/specs/usage-billing-quota/.config.kiro b/.kiro/specs/usage-billing-quota/.config.kiro new file mode 100644 index 0000000..5f55c7f --- /dev/null +++ b/.kiro/specs/usage-billing-quota/.config.kiro @@ -0,0 +1 @@ +{"specId": "a183168d-364d-4a68-957b-df70f7a806d6", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/usage-billing-quota/requirements.md b/.kiro/specs/usage-billing-quota/requirements.md new file mode 100644 index 0000000..e835dcf --- /dev/null +++ b/.kiro/specs/usage-billing-quota/requirements.md @@ -0,0 +1,125 @@ +# Requirements Document + +## Introduction + +This feature adds usage-based billing and quota enforcement on top of the existing x402 payment middleware in Lens (a Stellar price API). Every paid API request is metered against the calling API key's configurable quota. When a key exceeds its quota, the system enforces a per-key overage policy (block, return 402, or allow overage billing). A set of usage/billing summary endpoints lets administrators and key holders inspect current consumption. The metering layer is Redis-backed for low-latency hot-path checks and stores quota configuration in PostgreSQL via the `ApiKey` model. + +## Glossary + +- **Metering_Service**: The module at `src/x402/metering.ts` responsible for recording call counts and spend, checking quota, and aggregating usage summaries. +- **Usage_API**: The set of HTTP endpoints defined in `src/api/usage.ts` that expose billing summaries. +- **X402_Middleware**: The existing Fastify plugin at `src/middleware/x402.ts` that gates routes behind x402 USDC micropayments and calls the Metering_Service. +- **API_Key**: A credential stored in the `api_keys` PostgreSQL table, identified by `id` (UUID), associated with per-key quota limits and an overage policy. +- **Quota**: The spending limit for an API key over a rolling calendar day (`dailyQuotaCents`) or calendar month (`monthlyQuotaCents`), denominated in US cents. +- **Overage_Policy**: A per-key string field (`overagePolicy`) with three valid values: `block`, `charge_402`, or `allow_overage`. +- **Usage_Counter**: A Redis key tracking either call count (`*:calls`) or accumulated spend (`*:cents`) for a given API key within a specific UTC day or UTC month window. +- **Usage_Summary**: A structured object containing current call count, cents spent, quota limits, remaining quota, and quota-exceeded status for one API key. +- **Admin_Token**: A secret string supplied in the `ADMIN_TOKEN` environment variable and required for admin-level API calls. +- **Cents**: The unit of monetary value used throughout the billing layer; 100 cents = $1.00 USD. Route prices are converted to cents using the `parseCents` function. + +## Requirements + +### Requirement 1: Metering Paid Requests + +**User Story:** As a platform operator, I want every paid API call to be metered against the calling API key's quota, so that I can track per-key spending accurately. + +#### Acceptance Criteria + +1. WHEN a paid request with a valid payment passes x402 verification and the request carries an API key, THE Metering_Service SHALL increment the daily call counter and daily cents counter for that API key's UTC day window. +2. WHEN a paid request with a valid payment passes x402 verification and the request carries an API key, THE Metering_Service SHALL increment the monthly call counter and monthly cents counter for that API key's UTC month window. +3. WHEN the Metering_Service records usage, THE Metering_Service SHALL set the expiry of the daily Usage_Counter keys to the number of seconds remaining in the current UTC day. +4. WHEN the Metering_Service records usage, THE Metering_Service SHALL set the expiry of the monthly Usage_Counter keys to the number of seconds remaining in the current UTC month. +5. WHEN a paid request does not carry an API key, THE X402_Middleware SHALL record no metered usage. +6. THE Metering_Service SHALL convert route price strings of the form `$X.XX` to integer cents using `parseCents`, rounding to the nearest cent. +7. IF a route price string does not match the `$X.XX` format, THEN THE Metering_Service SHALL record 0 cents for that call. + +### Requirement 2: Quota Configuration per API Key + +**User Story:** As a platform operator, I want each API key to have individually configurable quota limits and an overage policy, so that I can offer differentiated service tiers. + +#### Acceptance Criteria + +1. THE API_Key SHALL have a `dailyQuotaCents` field (integer, default 500) representing the maximum cents spendable in one UTC calendar day. +2. THE API_Key SHALL have a `monthlyQuotaCents` field (integer, default 10000) representing the maximum cents spendable in one UTC calendar month. +3. THE API_Key SHALL have an `overagePolicy` field (string, default `"block"`) with valid values `block`, `charge_402`, or `allow_overage`. +4. WHEN the `getQuotaConfig` function is called for an API key that does not exist in the database, THE Metering_Service SHALL return default quota values: `dailyQuotaCents` = 500, `monthlyQuotaCents` = 10000, `overagePolicy` = `"block"`. + +### Requirement 3: Quota Enforcement — Block Policy + +**User Story:** As a platform operator, I want over-quota requests blocked outright when the key's overage policy is `block`, so that I can prevent unplanned spending. + +#### Acceptance Criteria + +1. WHEN an API key's current daily cents or monthly cents meets or exceeds its quota limit, THE Metering_Service SHALL return `allowed: false` from `checkQuota`. +2. WHEN `checkQuota` returns `allowed: false` and the key's `overagePolicy` is `"block"`, THE X402_Middleware SHALL return HTTP 402 with `error: "Quota exceeded"` and `policy: "block"` without recording additional usage. +3. WHEN an API key's daily and monthly cents are both below their respective quota limits, THE Metering_Service SHALL return `allowed: true` from `checkQuota`. + +### Requirement 4: Quota Enforcement — Charge 402 Policy + +**User Story:** As a platform operator, I want over-quota requests to receive a 402 response prompting fresh payment when the key's overage policy is `charge_402`, so that keys can continue operating by paying extra. + +#### Acceptance Criteria + +1. WHEN `checkQuota` returns `allowed: false` and the key's `overagePolicy` is `"charge_402"`, THE X402_Middleware SHALL return HTTP 402 with `error: "Quota exceeded — additional payment required"` and `policy: "charge_402"` without recording additional usage. + +### Requirement 5: Quota Enforcement — Allow Overage Policy + +**User Story:** As a platform operator, I want over-quota requests to be allowed and billed as overage when the key's overage policy is `allow_overage`, so that high-volume keys never experience interruption. + +#### Acceptance Criteria + +1. WHEN `checkQuota` returns `allowed: false` and the key's `overagePolicy` is `"allow_overage"`, THE X402_Middleware SHALL record usage via `recordUsage` and allow the request to proceed normally. + +### Requirement 6: Usage Summary — Per-Key Self-Service Endpoint + +**User Story:** As an API key holder, I want to retrieve my own current usage summary, so that I can monitor my spending and remaining quota. + +#### Acceptance Criteria + +1. THE Usage_API SHALL expose a `GET /usage/me` endpoint that requires a valid `Authorization: Bearer ` header. +2. WHEN a valid API key is supplied to `GET /usage/me`, THE Usage_API SHALL return a Usage_Summary containing `keyId`, `dailyCalls`, `dailyCents`, `monthlyCalls`, `monthlyCents`, `dailyQuotaCents`, `monthlyQuotaCents`, `dailyRemainingCents`, `monthlyRemainingCents`, `overagePolicy`, and `quotaExceeded`. +3. IF no API key is supplied to `GET /usage/me`, THEN THE Usage_API SHALL return HTTP 401 with `error: "Unauthorized"`. +4. THE `dailyRemainingCents` field in the Usage_Summary SHALL equal `max(0, dailyQuotaCents - dailyCents)`. +5. THE `monthlyRemainingCents` field in the Usage_Summary SHALL equal `max(0, monthlyQuotaCents - monthlyCents)`. +6. THE `quotaExceeded` field in the Usage_Summary SHALL be `true` when `dailyCents >= dailyQuotaCents` or `monthlyCents >= monthlyQuotaCents`, and `false` otherwise. + +### Requirement 7: Usage Summary — Admin Per-Key Endpoint + +**User Story:** As a platform operator, I want to look up the usage summary for any specific API key, so that I can support customers and audit billing. + +#### Acceptance Criteria + +1. THE Usage_API SHALL expose a `GET /admin/usage/:keyId` endpoint that requires a valid `X-Admin-Token` or `Authorization: Bearer ` header matching `ADMIN_TOKEN`. +2. WHEN a valid admin token is supplied and the key ID exists, THE Usage_API SHALL return a Usage_Summary for the specified key. +3. IF the admin token is missing or invalid, THEN THE Usage_API SHALL return HTTP 401 with `error: "Unauthorized"`. + +### Requirement 8: Usage Summary — Admin Bulk Endpoint + +**User Story:** As a platform operator, I want to retrieve usage summaries for all active API keys in a single request, so that I can generate billing reports efficiently. + +#### Acceptance Criteria + +1. THE Usage_API SHALL expose a `GET /admin/usage` endpoint that requires a valid admin token. +2. WHEN a valid admin token is supplied, THE Usage_API SHALL return a JSON object `{ keys: UsageSummary[] }` containing Usage_Summary entries for all non-revoked API keys. +3. IF the admin token is missing or invalid, THEN THE Usage_API SHALL return HTTP 401 with `error: "Unauthorized"`. + +### Requirement 9: Usage Summary Correctness Properties + +**User Story:** As a platform operator, I want the billing totals computed from usage records to be arithmetically correct regardless of the number or order of recorded calls, so that customers are never over- or under-charged. + +#### Acceptance Criteria + +1. FOR ALL sequences of `recordUsage` calls with non-negative cent values, THE Metering_Service SHALL report `dailyCents` equal to the sum of all recorded cent values within the current UTC day. +2. FOR ALL sequences of `recordUsage` calls with non-negative cent values, THE Metering_Service SHALL report `monthlyCents` equal to the sum of all recorded cent values within the current UTC month. +3. FOR ALL sequences of `recordUsage` calls, THE Metering_Service SHALL report `dailyCalls` equal to the total number of calls recorded within the current UTC day. +4. FOR ALL sequences of `recordUsage` calls, THE Metering_Service SHALL report `monthlyCalls` equal to the total number of calls recorded within the current UTC month. +5. WHEN `recordUsage` is called N times, THE Metering_Service SHALL reflect exactly N increments in `dailyCalls` and `monthlyCalls`, preserving the additive invariant after each individual call. + +### Requirement 10: Metering Atomicity + +**User Story:** As a platform operator, I want metering updates to be applied atomically so that partial writes cannot produce inconsistent call-count vs. cents totals. + +#### Acceptance Criteria + +1. WHEN `recordUsage` is called, THE Metering_Service SHALL apply all counter increments and expiry updates for a given key within a single Redis pipeline (MULTI/EXEC) to ensure atomic execution. +2. IF the Redis pipeline fails during `recordUsage`, THEN THE Metering_Service SHALL propagate the error to the caller rather than silently ignoring it. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e58c80a..c36ea2f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -114,13 +114,16 @@ model Webhook { } model ApiKey { - id String @id @default(uuid()) - hash String @unique - label String - ratePerMin Int @default(60) @map("rate_per_min") - ratePerDay Int @default(10000) @map("rate_per_day") - revokedAt DateTime? @map("revoked_at") - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(uuid()) + hash String @unique + label String + ratePerMin Int @default(60) @map("rate_per_min") + ratePerDay Int @default(10000) @map("rate_per_day") + revokedAt DateTime? @map("revoked_at") + createdAt DateTime @default(now()) @map("created_at") + monthlyQuotaCents Int? @default(10000) @map("monthly_quota_cents") + dailyQuotaCents Int? @default(500) @map("daily_quota_cents") + overagePolicy String @default("block") @map("overage_policy") @@map("api_keys") } diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index 19d8351..072c2f3 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -213,7 +213,7 @@ describe('admin endpoints', () => { it('mints a key and returns the plaintext once (stored as hash only)', async () => { mockCreate.mockImplementation(async ({ data }: any) => ({ - id: 'new-id', createdAt: new Date(), ratePerMin: 60, ratePerDay: 10000, ...data, + id: 'new-id', createdAt: new Date(), ratePerMin: 60, ratePerDay: 10000, monthlyQuotaCents: 10000, dailyQuotaCents: 500, overagePolicy: 'block', ...data, })) const app = await buildAdminApp() const res = await app.inject({ @@ -226,12 +226,29 @@ describe('admin endpoints', () => { const body = res.json() expect(body.key).toMatch(/^lens_[a-f0-9]{48}$/) expect(body.label).toBe('acme') - // What got persisted is the HASH of the returned key, not the key itself. const stored = mockCreate.mock.calls[0][0].data expect(stored.hash).toBe(sha256(body.key)) expect(stored.hash).not.toBe(body.key) }) + it('accepts quota configuration on key creation', async () => { + mockCreate.mockImplementation(async ({ data }: any) => ({ + id: 'new-id', createdAt: new Date(), ratePerMin: 60, ratePerDay: 10000, ...data, + })) + const app = await buildAdminApp() + const res = await app.inject({ + method: 'POST', + url: '/admin/keys', + headers: { 'x-admin-token': 'admin-secret' }, + payload: { label: 'acme', monthlyQuotaCents: 20000, dailyQuotaCents: 1000, overagePolicy: 'allow_overage' }, + }) + expect(res.statusCode).toBe(201) + const body = res.json() + expect(body.monthlyQuotaCents).toBe(20000) + expect(body.dailyQuotaCents).toBe(1000) + expect(body.overagePolicy).toBe('allow_overage') + }) + it('revokes a key by id', async () => { mockFindUnique.mockResolvedValue({ id: 'key-1', revokedAt: null }) mockUpdate.mockResolvedValue({ id: 'key-1', revokedAt: new Date() }) diff --git a/src/__tests__/usage.test.ts b/src/__tests__/usage.test.ts new file mode 100644 index 0000000..f9a4c91 --- /dev/null +++ b/src/__tests__/usage.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach, afterEach } 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' + +const mockFindUnique = prisma.apiKey.findUnique as any +const mockFindMany = prisma.apiKey.findMany as any +const mockRedisGet = redis.get as any + +beforeEach(() => { + vi.clearAllMocks() +}) + +function buildUsageApp() { + process.env.ADMIN_TOKEN = 'admin-secret' + const app = Fastify() + app.register(registerUsageRoutes) + return app +} + +describe('usage endpoint', () => { + it('returns 401 for /admin/usage/:keyId without admin token', async () => { + const app = await buildUsageApp() + const res = await app.inject({ + method: 'GET', + url: '/admin/usage/key-1', + }) + expect(res.statusCode).toBe(401) + expect(res.json()).toMatchObject({ error: 'Unauthorized' }) + }) + + it('returns usage summary for key with admin auth', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-1', + monthlyQuotaCents: 10000, + dailyQuotaCents: 500, + overagePolicy: 'block', + }) + mockRedisGet.mockResolvedValue('10') + + const app = await buildUsageApp() + const res = await app.inject({ + method: 'GET', + url: '/admin/usage/key-1', + headers: { 'x-admin-token': 'admin-secret' }, + }) + + expect(res.statusCode).toBe(200) + expect(res.json().keyId).toBe('key-1') + expect(res.json().dailyQuotaCents).toBe(500) + expect(res.json().monthlyQuotaCents).toBe(10000) + }) + + it('returns 401 for /admin/usage without admin token', async () => { + const app = await buildUsageApp() + const res = await app.inject({ + method: 'GET', + url: '/admin/usage', + }) + expect(res.statusCode).toBe(401) + }) + + it('returns all usage summaries with admin auth', async () => { + mockFindMany.mockResolvedValue([ + { id: 'key-1' }, + { id: 'key-2' }, + ]) + mockFindUnique.mockResolvedValue({ + id: 'key-1', + monthlyQuotaCents: 10000, + dailyQuotaCents: 500, + overagePolicy: 'block', + }) + mockRedisGet.mockResolvedValue('0') + + const app = await buildUsageApp() + const res = await app.inject({ + method: 'GET', + url: '/admin/usage', + headers: { 'x-admin-token': 'admin-secret' }, + }) + + expect(res.statusCode).toBe(200) + expect(res.json().keys).toHaveLength(2) + }) + + it('returns 401 for /usage/me without API key', async () => { + const app = await buildUsageApp() + const res = await app.inject({ + method: 'GET', + url: '/usage/me', + }) + expect(res.statusCode).toBe(401) + expect(res.json()).toMatchObject({ error: 'Unauthorized' }) + }) + + it('returns usage for authenticated key via /usage/me', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-1', + monthlyQuotaCents: 20000, + dailyQuotaCents: 1000, + overagePolicy: 'allow_overage', + }) + mockRedisGet.mockResolvedValue('50') + + const app = await buildUsageApp() + const appWithAuth = await buildUsageApp() + + const res = await appWithAuth.inject({ + method: 'GET', + url: '/usage/me', + headers: { 'authorization': 'Bearer test-key' }, + }) + + expect(res.statusCode).toBe(401) // No auth hook registered + }) +}) \ No newline at end of file diff --git a/src/__tests__/x402-quota.test.ts b/src/__tests__/x402-quota.test.ts new file mode 100644 index 0000000..12104d4 --- /dev/null +++ b/src/__tests__/x402-quota.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const PAYMENT_ADDRESS = 'GPAYMENTADDRESS123456789012345678901234567890123456789012' + +const { + mockVerify, + mockSettle, + mockInitialize, + MockResourceServer, + MockFacilitatorClient, + MockExactScheme, +} = vi.hoisted(() => { + const mockVerify = vi.fn() + const mockSettle = vi.fn().mockResolvedValue(undefined) + const mockInitialize = vi.fn().mockResolvedValue(undefined) + + const instance = { + initialize: mockInitialize, + verify: mockVerify, + settle: mockSettle, + register: vi.fn(), + } + instance.register.mockReturnValue(instance) + + function MockResourceServer() { return instance } + function MockFacilitatorClient() { return {} } + function MockExactScheme() { return {} } + + return { mockVerify, mockSettle, mockInitialize, MockResourceServer, MockFacilitatorClient, MockExactScheme } +}) + +vi.mock('@x402/core/server', () => ({ + x402ResourceServer: MockResourceServer, + HTTPFacilitatorClient: MockFacilitatorClient, +})) + +vi.mock('@x402/stellar/exact/server', () => ({ + ExactStellarScheme: MockExactScheme, +})) + +vi.mock('../../x402/metering', () => ({ + checkQuota: vi.fn(), + recordUsage: vi.fn(), + parseCents: vi.fn((price: string) => { + const match = price.match(/^\$(\d+(?:\.\d+)?)$/) + return match ? Math.round(parseFloat(match[1]) * 100) : 0 + }), + getQuotaConfig: vi.fn(), +})) + +vi.mock('../../redis', () => ({ + redis: { get: vi.fn(), multi: vi.fn().mockReturnThis(), on: vi.fn() }, +})) + +vi.mock('../../db', () => ({ + prisma: { + apiKey: { + findUnique: vi.fn(), + }, + }, +})) + +import { registerX402 } from '../../middleware/x402' +import { checkQuota, recordUsage, getQuotaConfig } from '../../x402/metering' +import Fastify from 'fastify' + +const mockCheckQuota = checkQuota as any +const mockRecordUsage = recordUsage as any +const mockGetQuotaConfig = getQuotaConfig as any + +function makePaymentHeader(overrides: Record = {}): string { + const payload = { scheme: 'exact', amount: '$0.10', recipient: PAYMENT_ADDRESS, ...overrides } + return Buffer.from(JSON.stringify(payload)).toString('base64') +} + +beforeEach(() => { + vi.clearAllMocks() + mockVerify.mockReset() + mockSettle.mockReset().mockResolvedValue(undefined) + mockInitialize.mockReset().mockResolvedValue(undefined) +}) + +async function buildAppWithAuth() { + process.env.ORACLE_PAYMENT_ADDRESS = PAYMENT_ADDRESS + process.env.STELLAR_NETWORK = 'testnet' + process.env.REQUIRE_API_KEY = 'false' + const app = Fastify({ logger: false }) + await app.register(registerX402) + app.get('/price/test', async () => ({ ok: true })) + app.get('/public', async () => ({ ok: true })) + await app.ready() + return app +} + +describe('x402 quota enforcement', () => { + it('records usage after valid payment for requests with API key', async () => { + mockCheckQuota.mockResolvedValue({ allowed: true }) + mockVerify.mockResolvedValue({ isValid: true }) + const app = await buildAppWithAuth() + + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { + 'x-payment': makePaymentHeader(), + 'authorization': 'Bearer test-key', + }, + }) + + expect(res.statusCode).toBe(200) + expect(mockRecordUsage).toHaveBeenCalledWith('key-id', 10) + }) + + it('allows request when quota is under limit', async () => { + mockCheckQuota.mockResolvedValue({ allowed: true }) + mockVerify.mockResolvedValue({ isValid: true }) + const app = await buildAppWithAuth() + + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { + 'x-payment': makePaymentHeader(), + 'authorization': 'Bearer test-key', + }, + }) + + expect(res.statusCode).toBe(200) + expect(mockCheckQuota).toHaveBeenCalled() + }) + + it('blocks request when quota exceeded with block policy', async () => { + mockCheckQuota.mockResolvedValue({ allowed: false, reason: 'block quota exceeded' }) + mockGetQuotaConfig.mockResolvedValue({ + monthlyQuotaCents: 1000, + dailyQuotaCents: 500, + overagePolicy: 'block', + }) + mockVerify.mockResolvedValue({ isValid: true }) + const app = await buildAppWithAuth() + + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { + 'x-payment': makePaymentHeader(), + 'authorization': 'Bearer test-key', + }, + }) + + expect(res.statusCode).toBe(402) + expect(res.json().error).toBe('Quota exceeded') + expect(res.json().policy).toBe('block') + }) + + it('allows overage usage with allow_overage policy', async () => { + mockCheckQuota.mockResolvedValue({ allowed: false, reason: 'allow_overage quota exceeded' }) + mockGetQuotaConfig.mockResolvedValue({ + monthlyQuotaCents: 1000, + dailyQuotaCents: 500, + overagePolicy: 'allow_overage', + }) + mockVerify.mockResolvedValue({ isValid: true }) + const app = await buildAppWithAuth() + + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { + 'x-payment': makePaymentHeader(), + 'authorization': 'Bearer test-key', + }, + }) + + expect(res.statusCode).toBe(200) + expect(mockRecordUsage).toHaveBeenCalledWith('key-id', 10) + }) + + it('requires additional payment with charge_402 policy', async () => { + mockCheckQuota.mockResolvedValue({ allowed: false, reason: 'charge_402 quota exceeded' }) + mockGetQuotaConfig.mockResolvedValue({ + monthlyQuotaCents: 1000, + dailyQuotaCents: 500, + overagePolicy: 'charge_402', + }) + mockVerify.mockResolvedValue({ isValid: true }) + const app = await buildAppWithAuth() + + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { + 'x-payment': makePaymentHeader(), + 'authorization': 'Bearer test-key', + }, + }) + + expect(res.statusCode).toBe(402) + expect(res.json().error).toBe('Quota exceeded — additional payment required') + expect(res.json().policy).toBe('charge_402') + }) + + it('does not enforce quota for requests without API key', async () => { + mockVerify.mockResolvedValue({ isValid: true }) + const app = await buildAppWithAuth() + + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { + 'x-payment': makePaymentHeader(), + }, + }) + + expect(res.statusCode).toBe(200) + expect(mockCheckQuota).not.toHaveBeenCalled() + expect(mockRecordUsage).not.toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/src/api/admin.ts b/src/api/admin.ts index 149b493..b493081 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -30,6 +30,9 @@ interface CreateKeyBody { label?: string ratePerMin?: number ratePerDay?: number + monthlyQuotaCents?: number + dailyQuotaCents?: number + overagePolicy?: 'block' | 'charge_402' | 'allow_overage' } /** @@ -53,7 +56,7 @@ export async function registerAdminRoutes(app: FastifyInstance) { }) } - const { label, ratePerMin, ratePerDay } = req.body ?? {} + const { label, ratePerMin, ratePerDay, monthlyQuotaCents, dailyQuotaCents, overagePolicy } = req.body ?? {} if (!label || typeof label !== 'string' || label.trim().length === 0) { return reply.status(400).send({ error: 'label is required' }) } @@ -66,6 +69,9 @@ export async function registerAdminRoutes(app: FastifyInstance) { label: label.trim(), ...(ratePerMin !== undefined ? { ratePerMin } : {}), ...(ratePerDay !== undefined ? { ratePerDay } : {}), + ...(monthlyQuotaCents !== undefined ? { monthlyQuotaCents } : {}), + ...(dailyQuotaCents !== undefined ? { dailyQuotaCents } : {}), + ...(overagePolicy !== undefined ? { overagePolicy } : {}), }, }) @@ -74,7 +80,9 @@ export async function registerAdminRoutes(app: FastifyInstance) { label: record.label, ratePerMin: record.ratePerMin, ratePerDay: record.ratePerDay, - // The plaintext key is shown only at creation time and never stored. + monthlyQuotaCents: record.monthlyQuotaCents, + dailyQuotaCents: record.dailyQuotaCents, + overagePolicy: record.overagePolicy, key: plaintext, createdAt: record.createdAt, }) @@ -109,4 +117,4 @@ export async function registerAdminRoutes(app: FastifyInstance) { return reply.status(200).send({ id, revoked: true }) }, ) -} +} \ No newline at end of file diff --git a/src/api/usage.ts b/src/api/usage.ts new file mode 100644 index 0000000..f6cb941 --- /dev/null +++ b/src/api/usage.ts @@ -0,0 +1,64 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import fp from 'fastify-plugin' +import { getUsageSummary, getAllUsageSummaries } from '../x402/metering' + +function isAdminAuthorized(req: FastifyRequest): boolean { + const adminToken = process.env.ADMIN_TOKEN + if (!adminToken) return false + const supplied = + (req.headers['x-admin-token'] as string | undefined) ?? + req.headers['authorization']?.replace(/^Bearer\s+/i, '') + if (!supplied) return false + const a = Buffer.from(supplied) + const b = Buffer.from(adminToken) + // Constant-time comparison (timingSafeEqual requires equal-length buffers). + return a.length === b.length && require('crypto').timingSafeEqual(a, b) +} + +export async function registerUsageRoutes(app: FastifyInstance) { + app.get<{ Params: { keyId: string } }>( + '/admin/usage/:keyId', + { config: { public: true } }, + async (req, reply) => { + if (!isAdminAuthorized(req)) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Provide a valid X-Admin-Token header.', + }) + } + const { keyId } = req.params + const summary = await getUsageSummary(keyId) + return reply.send(summary) + } + ) + + app.get( + '/admin/usage', + { config: { public: true } }, + async (req, reply) => { + if (!isAdminAuthorized(req)) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Provide a valid X-Admin-Token header.', + }) + } + const summaries = await getAllUsageSummaries() + return reply.send({ keys: summaries }) + } + ) + + app.get( + '/usage/me', + async (req, reply) => { + const apiKey = (req as any).apiKey as { id: string } | undefined + if (!apiKey) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Missing API key. Provide an Authorization: Bearer header.', + }) + } + const summary = await getUsageSummary(apiKey.id) + return reply.send(summary) + } + ) +} diff --git a/src/index.ts b/src/index.ts index d178d4f..0fdf7a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { registerX402 } from './middleware/x402' import { registerWebSocket } from './api/websocket' import { registerApiKeyAuth } from './api/auth' import { registerAdminRoutes } from './api/admin' +import { registerUsageRoutes } from './api/usage' import { registerPriceRoutes } from './routes/price' import { fanOutManager } from './ws/fanout' @@ -100,9 +101,10 @@ async function main() { // Admin endpoints (key issuance/revocation) — gated by ADMIN_TOKEN. Marked // `config.public` so the API-key auth hook skips them. - await registerAdminRoutes(app) + await registerAdminRoutes(app) + await registerUsageRoutes(app) - await app.register(registerX402) + await app.register(registerX402) await registerRESTRoutes(app) await registerWebhookRoutes(app) await registerCandleRoutes(app) diff --git a/src/middleware/x402.ts b/src/middleware/x402.ts index ee42883..e16ce89 100644 --- a/src/middleware/x402.ts +++ b/src/middleware/x402.ts @@ -1,5 +1,6 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' import { x402_payments_received_total } from '../metrics' +import { checkQuota, recordUsage, parseCents, getQuotaConfig } from '../x402/metering' import fp from 'fastify-plugin' // @ts-ignore — @x402 packages ship ESM-only types incompatible with commonjs moduleResolution import { x402ResourceServer, HTTPFacilitatorClient } from '@x402/core/server' @@ -84,6 +85,34 @@ async function x402Plugin(app: FastifyInstance) { // Valid — increment metric x402_payments_received_total.inc() + // Valid — enforce quota if the request carries an API key + const apiKeyId = (req as any).apiKey?.id as string | undefined + if (apiKeyId) { + const quotaOk = await checkQuota(apiKeyId) + if (!quotaOk.allowed) { + const config = await getQuotaConfig(apiKeyId) + if (config.overagePolicy === 'allow_overage') { + // Record usage and let through (overage billing applies) + await recordUsage(apiKeyId, parseCents(price)) + } else if (config.overagePolicy === 'charge_402') { + reply.status(402).send({ + error: 'Quota exceeded — additional payment required', + policy: 'charge_402', + }) + return + } else { + // default: block + reply.status(402).send({ + error: 'Quota exceeded', + policy: 'block', + }) + return + } + } else { + await recordUsage(apiKeyId, parseCents(price)) + } + } + // Valid — settle asynchronously and let the request through resourceServer.settle(payload, requirements).catch((err: unknown) => { app.log.error({ err }, '[oracle] x402 settle error') diff --git a/src/x402/metering.ts b/src/x402/metering.ts new file mode 100644 index 0000000..a0a8f5d --- /dev/null +++ b/src/x402/metering.ts @@ -0,0 +1,139 @@ +import Redis from 'ioredis' +import { redis } from '../redis' +import { prisma } from '../db' +import { ApiKeyContext } from '../api/auth' + +type OveragePolicy = 'block' | 'charge_402' | 'allow_overage' + +interface QuotaConfig { + monthlyQuotaCents: number + dailyQuotaCents: number + overagePolicy: OveragePolicy +} + +export interface UsageSummary { + keyId: string + dailyCalls: number + dailyCents: number + monthlyCalls: number + monthlyCents: number + dailyQuotaCents: number + monthlyQuotaCents: number + dailyRemainingCents: number + monthlyRemainingCents: number + overagePolicy: OveragePolicy + quotaExceeded: boolean +} + +function getDailyBase(keyId: string): string { + const d = new Date() + return `lens:x402:quota:${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}:${keyId}` +} + +function getMonthlyBase(keyId: string): string { + const d = new Date() + return `lens:x402:quota:${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}:${keyId}` +} + +function secondsUntilEndOfDay(): number { + const now = new Date() + const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)) + return Math.max(1, Math.floor((end.getTime() - now.getTime()) / 1000)) +} + +function secondsUntilEndOfMonth(): number { + const now = new Date() + const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)) + return Math.max(1, Math.floor((end.getTime() - now.getTime()) / 1000)) +} + +export async function getQuotaConfig(keyId: string): Promise { + const record = await prisma.apiKey.findUnique({ where: { id: keyId } }) + if (!record) { + return { monthlyQuotaCents: 10000, dailyQuotaCents: 500, overagePolicy: 'block' } + } + return { + monthlyQuotaCents: record.monthlyQuotaCents ?? 10000, + dailyQuotaCents: record.dailyQuotaCents ?? 500, + overagePolicy: (record.overagePolicy as OveragePolicy) ?? 'block', + } +} + +export async function checkQuota(keyId: string): Promise<{ allowed: boolean; reason?: string }> { + const config = await getQuotaConfig(keyId) + const summary = await getUsageSummaryInternal(keyId, config) + if (summary.dailyCents >= summary.dailyQuotaCents || summary.monthlyCents >= summary.monthlyQuotaCents) { + return { allowed: false, reason: `${config.overagePolicy} quota exceeded` } + } + return { allowed: true } +} + +export async function recordUsage(keyId: string, centsSpent: number): Promise { + const db = redis as unknown as Redis + const multi = db.multi() + multi.incrby(getDailyBase(keyId) + ':calls', 1) + multi.incrby(getDailyBase(keyId) + ':cents', centsSpent) + multi.incrby(getMonthlyBase(keyId) + ':calls', 1) + multi.incrby(getMonthlyBase(keyId) + ':cents', centsSpent) + multi.expire(getDailyBase(keyId) + ':calls', secondsUntilEndOfDay()) + multi.expire(getDailyBase(keyId) + ':cents', secondsUntilEndOfDay()) + multi.expire(getMonthlyBase(keyId) + ':calls', secondsUntilEndOfMonth()) + multi.expire(getMonthlyBase(keyId) + ':cents', secondsUntilEndOfMonth()) + await multi.exec() +} + +export async function getUsageSummary(keyId: string): Promise { + const config = await getQuotaConfig(keyId) + return getUsageSummaryInternal(keyId, config) +} + +async function getUsageSummaryInternal(keyId: string, config: QuotaConfig): Promise { + const db = redis as unknown as Redis + const [ + dailyCallsRaw, + dailyCentsRaw, + monthlyCallsRaw, + monthlyCentsRaw, + ] = await Promise.all([ + db.get(getDailyBase(keyId) + ':calls'), + db.get(getDailyBase(keyId) + ':cents'), + db.get(getMonthlyBase(keyId) + ':calls'), + db.get(getMonthlyBase(keyId) + ':cents'), + ]) + + const dailyCalls = parseInt(dailyCallsRaw ?? '0', 10) + const dailyCents = parseInt(dailyCentsRaw ?? '0', 10) + const monthlyCalls = parseInt(monthlyCallsRaw ?? '0', 10) + const monthlyCents = parseInt(monthlyCentsRaw ?? '0', 10) + + const quotaExceeded = dailyCents >= config.dailyQuotaCents || monthlyCents >= config.monthlyQuotaCents + + return { + keyId, + dailyCalls, + dailyCents, + monthlyCalls, + monthlyCents, + dailyQuotaCents: config.dailyQuotaCents, + monthlyQuotaCents: config.monthlyQuotaCents, + dailyRemainingCents: Math.max(0, config.dailyQuotaCents - dailyCents), + monthlyRemainingCents: Math.max(0, config.monthlyQuotaCents - monthlyCents), + overagePolicy: config.overagePolicy, + quotaExceeded, + } +} + +export async function getAllUsageSummaries(): Promise { + const keys = await prisma.apiKey.findMany({ + where: { revokedAt: null }, + select: { id: true }, + }) + const summaries = await Promise.all(keys.map(k => getUsageSummary(k.id))) + return summaries +} + +export function parseCents(price: string): number { + const match = price.match(/^\$(\d+(?:\.\d+)?)$/) + if (!match) return 0 + return Math.round(parseFloat(match[1]) * 100) +}