Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .kiro/specs/usage-billing-quota/.config.kiro
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"specId": "a183168d-364d-4a68-957b-df70f7a806d6", "workflowType": "requirements-first", "specType": "feature"}
125 changes: 125 additions & 0 deletions .kiro/specs/usage-billing-quota/requirements.md
Original file line number Diff line number Diff line change
@@ -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 <key>` 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 <token>` 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.
17 changes: 10 additions & 7 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
21 changes: 19 additions & 2 deletions src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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() })
Expand Down
136 changes: 136 additions & 0 deletions src/__tests__/usage.test.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
Loading