Skip to content
Merged
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
88 changes: 88 additions & 0 deletions clients/typescript/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,10 +705,60 @@ export interface paths {
patch?: never;
trace?: never;
};
"/rates": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Query latest lending and borrow rates across pools
* @description Returns the latest supply and borrow rates, utilisation, and reserves for lending markets.
*/
get: operations["getLendingRates"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
LendingRateEntry: {
id: string;
/** @example blend */
venue: string;
/** @example blend */
source: string;
/** @example mainnet */
network: string;
poolId: string;
poolName?: string | null;
/** @example USDC */
asset: string;
assetAddress?: string | null;
supplyApy: number;
borrowApy: number;
supplyApr?: number | null;
borrowApr?: number | null;
/** @description Borrowed / supplied as a fraction. Null for venues that publish rates only. */
utilization?: number | null;
totalSupply?: number | null;
totalBorrowed?: number | null;
reserves?: number | null;
ledger?: number;
/** Format: date-time */
timestamp: string;
};
LendingRatesResponse: {
network: string;
rates: components["schemas"]["LendingRateEntry"][];
};
InternalErrorResponse: {
/**
* @example internal_error
Expand Down Expand Up @@ -1456,6 +1506,13 @@ export interface components {
};
};
responses: {
/** @description Invalid parameters */
BadRequest: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Payment Required */
PaymentRequired: {
headers: {
Expand Down Expand Up @@ -3118,4 +3175,35 @@ export interface operations {
402: components["responses"]["PaymentRequired"];
};
};
getLendingRates: {
parameters: {
query?: {
/** @description Filter by asset symbol (e.g. USDC, XLM) */
asset?: string;
/** @description Filter by pool contract ID */
poolId?: string;
/** @description Stellar network to query (testnet or mainnet) */
network?: "testnet" | "mainnet";
/** @description Maximum number of rate records to return */
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Latest lending rates */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LendingRatesResponse"];
};
};
400: components["responses"]["BadRequest"];
429: components["responses"]["TooManyRequests"];
};
};
}
3 changes: 3 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2904,6 +2904,9 @@
}
},
"responses": {
"BadRequest": {
"description": "Invalid parameters"
},
"PaymentRequired": {
"description": "Payment Required",
"content": {
Expand Down
2 changes: 2 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1974,6 +1974,8 @@ components:
default: testnet

responses:
BadRequest:
description: Invalid parameters
PaymentRequired:
description: Payment Required
content:
Expand Down
69 changes: 69 additions & 0 deletions src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,75 @@ describe('admin endpoints', () => {
expect(mockFindMany).not.toHaveBeenCalled()
})

// ── Body validation (issue #506): reject bad quotas/policies, don't coerce ──
async function postKey(payload: unknown) {
const app = await buildAdminApp()
return app.inject({
method: 'POST',
url: '/admin/keys',
headers: { 'x-admin-token': 'admin-secret' },
payload: payload as object,
})
}

it('rejects a negative rate and names the field, without touching the database', async () => {
const res = await postKey({ label: 'acme', ratePerMin: -5 })
expect(res.statusCode).toBe(400)
expect(res.json().error).toMatch(/ratePerMin/)
expect(mockCreate).not.toHaveBeenCalled()
})

it('rejects a fractional quota', async () => {
const res = await postKey({ label: 'acme', monthlyQuotaCents: 10.5 })
expect(res.statusCode).toBe(400)
expect(res.json().error).toMatch(/monthlyQuotaCents/)
expect(mockCreate).not.toHaveBeenCalled()
})

it('rejects a zero rate (a key that can never call anything)', async () => {
const res = await postKey({ label: 'acme', ratePerDay: 0 })
expect(res.statusCode).toBe(400)
expect(res.json().error).toMatch(/ratePerDay/)
expect(mockCreate).not.toHaveBeenCalled()
})

it('rejects an out-of-range value above the Int column ceiling', async () => {
const res = await postKey({ label: 'acme', dailyQuotaCents: 9_999_999_999 })
expect(res.statusCode).toBe(400)
expect(res.json().error).toMatch(/dailyQuotaCents/)
expect(mockCreate).not.toHaveBeenCalled()
})

it('rejects an unknown overagePolicy instead of silently defaulting to block', async () => {
const res = await postKey({ label: 'acme', overagePolicy: 'refund_everything' })
expect(res.statusCode).toBe(400)
expect(res.json().error).toMatch(/overagePolicy/)
expect(mockCreate).not.toHaveBeenCalled()
})

it('rejects a per-day rate below the per-minute rate', async () => {
const res = await postKey({ label: 'acme', ratePerMin: 100, ratePerDay: 50 })
expect(res.statusCode).toBe(400)
expect(res.json().error).toMatch(/ratePerDay/)
expect(mockCreate).not.toHaveBeenCalled()
})

it('rejects a blank (whitespace-only) label', async () => {
const res = await postKey({ label: ' ' })
expect(res.statusCode).toBe(400)
expect(res.json().error).toMatch(/label/)
expect(mockCreate).not.toHaveBeenCalled()
})

it('accepts valid rates and policy at the boundary', async () => {
mockCreate.mockImplementation(async ({ data }: any) => ({
id: 'new-id', createdAt: new Date(), ratePerMin: 60, ratePerDay: 10000, ...data,
}))
const res = await postKey({ label: 'acme', ratePerMin: 60, ratePerDay: 60, overagePolicy: 'charge_402' })
expect(res.statusCode).toBe(201)
expect(mockCreate).toHaveBeenCalledTimes(1)
})

it('revokes a key by id', async () => {
mockFindUnique.mockResolvedValue({ id: 'key-1', revokedAt: null })
mockUpdate.mockResolvedValue({ id: 'key-1', revokedAt: new Date() })
Expand Down
101 changes: 82 additions & 19 deletions src/api/admin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import { randomBytes } from 'crypto'
import { z } from 'zod'
import { prisma } from '../db'
import { admin_auth_failures_total } from '../metrics'
import { formatValidationError } from '../validation'
import { hashApiKey, isAdminAuthorized, checkAdminAuth, invalidateApiKey } from './auth'
import { copyUsageHistory } from '../x402/metering'

Expand All @@ -25,6 +27,80 @@ interface RotateKeyBody {
overlapHours?: number
}

/** The only overage policies `src/x402/metering.ts` understands. */
const OVERAGE_POLICIES = ['block', 'charge_402', 'allow_overage'] as const

// Rates and quota cents are stored in Postgres `Int` columns (32-bit signed), so
// their value cannot exceed this without overflowing the column anyway.
const MAX_INT32 = 2_147_483_647

/**
* A rate or quota: a positive integer within the `Int` column's range. Rejects
* zero, negatives, and fractions rather than coercing them, so a mistake fails
* loudly at creation instead of silently minting a key that can never call
* anything or one that is never limited.
*/
const quotaField = (name: string) =>
z
.number({ error: `${name} must be a positive integer` })
.int({ error: `${name} must be a positive integer` })
.min(1, { error: `${name} must be a positive integer` })
.max(MAX_INT32, { error: `${name} must be at most ${MAX_INT32}` })

/**
* Validates the `POST /admin/keys` body and rejects rather than coerces. Every
* field a caller can send is checked here so nothing reaches the database
* unvalidated (see issue #506).
*/
const CreateKeySchema = z
.object({
label: z.string({ error: 'label is required' }).trim().min(1, { error: 'label is required' }),
expiresAt: z.string({ error: 'expiresAt must be a valid future date' }).optional(),
ratePerMin: quotaField('ratePerMin').optional(),
ratePerDay: quotaField('ratePerDay').optional(),
monthlyQuotaCents: quotaField('monthlyQuotaCents').optional(),
dailyQuotaCents: quotaField('dailyQuotaCents').optional(),
overagePolicy: z
.enum(OVERAGE_POLICIES, {
error: `overagePolicy must be one of: ${OVERAGE_POLICIES.join(', ')}`,
})
.optional(),
// Scopes are optional, but an unknown-key strip would silently mint a key
// with no scopes at all — so they must be in the schema. The transform does
// the same de-dupe/trim/drop-empty normalisation the route used to do by
// hand, keeping a single source of truth for what gets stored (issue #506).
scopes: z
.array(z.string(), { error: 'scopes must be an array of strings' })
.optional()
.transform((s) =>
s === undefined ? undefined : Array.from(new Set(s.map((v) => v.trim()).filter(Boolean))),
),
})
.superRefine((data, ctx) => {
if (data.expiresAt !== undefined) {
const t = new Date(data.expiresAt).getTime()
if (!Number.isFinite(t) || t <= Date.now()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['expiresAt'],
message: 'expiresAt must be a valid future date',
})
}
}
// A per-day cap below the per-minute cap can never bind and is always a mistake.
if (
data.ratePerMin !== undefined &&
data.ratePerDay !== undefined &&
data.ratePerDay < data.ratePerMin
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['ratePerDay'],
message: 'ratePerDay must be greater than or equal to ratePerMin',
})
}
})

/**
* Registers admin-only endpoints for minting, listing, and revoking API keys.
*
Expand Down Expand Up @@ -76,39 +152,26 @@ export async function registerAdminRoutes(app: FastifyInstance) {
})
}

const { label, expiresAt, ratePerMin, ratePerDay, monthlyQuotaCents, dailyQuotaCents, overagePolicy, scopes } = req.body ?? {}
if (!label || typeof label !== 'string' || label.trim().length === 0) {
return reply.status(400).send({ error: 'label is required' })
}
if (expiresAt !== undefined && typeof expiresAt !== 'string') {
return reply.status(400).send({ error: 'expiresAt must be a valid future date' })
const parsed = CreateKeySchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send(formatValidationError(parsed.error))
}
const { label, expiresAt, ratePerMin, ratePerDay, monthlyQuotaCents, dailyQuotaCents, overagePolicy, scopes } = parsed.data
const expiration = expiresAt === undefined ? undefined : new Date(expiresAt)
if (expiration && (!Number.isFinite(expiration.getTime()) || expiration <= new Date())) {
return reply.status(400).send({ error: 'expiresAt must be a valid future date' })
}
if (scopes !== undefined) {
if (!Array.isArray(scopes) || scopes.some(s => typeof s !== 'string')) {
return reply.status(400).send({ error: 'scopes must be an array of strings' })
}
}
const normalizedScopes = scopes !== undefined
? Array.from(new Set(scopes.map(s => s.trim()).filter(Boolean)))
: undefined

// Generate the key, store only its hash, and return the plaintext once.
const plaintext = generateApiKey()
const record = await prisma.apiKey.create({
data: {
hash: hashApiKey(plaintext),
label: label.trim(),
label,
...(expiration ? { expiresAt: expiration } : {}),
...(ratePerMin !== undefined ? { ratePerMin } : {}),
...(ratePerDay !== undefined ? { ratePerDay } : {}),
...(monthlyQuotaCents !== undefined ? { monthlyQuotaCents } : {}),
...(dailyQuotaCents !== undefined ? { dailyQuotaCents } : {}),
...(overagePolicy !== undefined ? { overagePolicy } : {}),
...(normalizedScopes !== undefined ? { scopes: normalizedScopes } : {}),
...(scopes !== undefined ? { scopes } : {}),
},
})

Expand Down
Loading