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
62 changes: 62 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
}
}
},
Expand Down
39 changes: 39 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
177 changes: 177 additions & 0 deletions src/__tests__/callerUsage.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
32 changes: 17 additions & 15 deletions src/api/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key> 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 <key> 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)
}
25 changes: 25 additions & 0 deletions src/x402/metering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
},
}
}

Expand Down
Loading