From 24ad1780f58f7ec2fcb22b6e322436880ce77045 Mon Sep 17 00:00:00 2001 From: "Abdullateef O.G" Date: Sun, 28 Jun 2026 13:52:21 +0100 Subject: [PATCH 1/3] feat: keyset pagination on /api/admin/audit Add admin audit log listing with stable pagination over (created_at, id), repository layer, route tests, and API documentation. Closes #514 --- docs/admin-audit-endpoint.md | 93 +++++++ src/repositories/auditLogRepository.test.ts | 222 +++++++++++++++++ src/repositories/auditLogRepository.ts | 172 +++++++++++++ src/routes/admin.ts | 7 + src/routes/admin/audit.test.ts | 262 ++++++++++++++++++++ src/routes/admin/audit.ts | 139 +++++++++++ 6 files changed, 895 insertions(+) create mode 100644 docs/admin-audit-endpoint.md create mode 100644 src/repositories/auditLogRepository.test.ts create mode 100644 src/repositories/auditLogRepository.ts create mode 100644 src/routes/admin/audit.test.ts create mode 100644 src/routes/admin/audit.ts diff --git a/docs/admin-audit-endpoint.md b/docs/admin-audit-endpoint.md new file mode 100644 index 0000000..4802135 --- /dev/null +++ b/docs/admin-audit-endpoint.md @@ -0,0 +1,93 @@ +# Admin Audit Log Listing + +`GET /api/admin/audit` returns persisted audit log entries for forensic review. Results are ordered by **newest first** using stable keyset (cursor) pagination over `(created_at, id)`. + +## Authentication + +Requires admin credentials (same as other `/api/admin/*` routes): + +- `x-admin-api-key` header, or +- `Authorization: Bearer ` with `role: admin` + +The admin IP allowlist middleware also applies. + +## Query parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `limit` | integer | `20` | Page size (1–100) | +| `cursor` | string | — | Opaque cursor from a previous response's `meta.nextCursor` | +| `event` | string | — | Filter by audit event name (e.g. `LIST_USERS`) | +| `tenant_id` | string | — | Filter by tenant (developer user id) | +| `actor` | string | — | Filter by actor identifier | +| `from` | ISO-8601 datetime | — | Include rows with `created_at >= from` | +| `to` | ISO-8601 datetime | — | Include rows with `created_at <= to` | + +## Response shape + +```json +{ + "data": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "event": "LIST_USERS", + "actor": "admin-api-key", + "tenantId": null, + "clientIp": "203.0.113.10", + "userAgent": "curl/8.5.0", + "correlationId": "req-abc123", + "bodyHash": null, + "details": { "count": 12 }, + "createdAt": "2026-06-28T14:22:01.123Z" + } + ], + "meta": { + "limit": 20, + "hasMore": true, + "nextCursor": "eyJ0aW1lc3RhbXAiOiIyMDI2LTA2LTI4VDE0OjIyOjAxLjEyM1oiLCJpZCI6IjU1MGU4NDAwLWUyOWItNDFkNC1hNzE2LTQ0NjY1NTQ0MDAwMCJ9" + } +} +``` + +## Cursor format + +Cursors are opaque base64-encoded JSON objects: + +```json +{"timestamp":"2026-06-28T14:22:01.123Z","id":"550e8400-e29b-41d4-a716-446655440000"} +``` + +Pass `meta.nextCursor` as the `cursor` query parameter to fetch the next page. When `hasMore` is `false`, there are no additional pages. + +## Error responses + +Invalid query parameters return the standard error envelope: + +```json +{ + "code": "BAD_REQUEST", + "message": "Validation failed", + "requestId": "…", + "details": [ + { "field": "query.cursor", "message": "Invalid cursor format", "code": "INVALID_VALUE" } + ] +} +``` + +## Example + +```bash +# First page +curl -s -H "x-admin-api-key: $ADMIN_API_KEY" \ + "https://api.example.com/api/admin/audit?limit=50&event=LIST_USERS" + +# Next page +curl -s -H "x-admin-api-key: $ADMIN_API_KEY" \ + "https://api.example.com/api/admin/audit?limit=50&cursor=$NEXT_CURSOR" +``` + +## Notes + +- Listing audit logs emits its own `LIST_AUDIT_LOGS` audit event with correlation ID propagation. +- Data is sourced from the `audit_logs` table (migration `0016_audit_enrichment.sql`). +- Cursor pagination avoids offset scans and remains stable when new rows are inserted during paging. diff --git a/src/repositories/auditLogRepository.test.ts b/src/repositories/auditLogRepository.test.ts new file mode 100644 index 0000000..9765340 --- /dev/null +++ b/src/repositories/auditLogRepository.test.ts @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict'; +import { DataType, newDb } from 'pg-mem'; + +jest.mock('../config/env', () => ({ + env: { + PORT: 3000, + NODE_ENV: 'test', + DATABASE_URL: 'postgresql://localhost/callora_test', + DB_HOST: 'localhost', + DB_PORT: 5432, + DB_USER: 'postgres', + DB_PASSWORD: 'postgres', + DB_NAME: 'callora_test', + DB_POOL_MAX: 1, + DB_IDLE_TIMEOUT_MS: 1000, + DB_CONN_TIMEOUT_MS: 1000, + JWT_SECRET: 'test-jwt-secret', + ADMIN_API_KEY: 'test-admin-api-key', + METRICS_API_KEY: 'test-metrics-api-key', + UPSTREAM_URL: 'http://localhost:4000', + PROXY_TIMEOUT_MS: 30000, + CORS_ALLOWED_ORIGINS: 'http://localhost:5173', + SOROBAN_RPC_ENABLED: false, + HORIZON_ENABLED: false, + STELLAR_TESTNET_HORIZON_URL: 'https://horizon-testnet.stellar.org', + STELLAR_MAINNET_HORIZON_URL: 'https://horizon.stellar.org', + SOROBAN_TESTNET_RPC_URL: 'https://soroban-testnet.stellar.org', + SOROBAN_MAINNET_RPC_URL: 'https://soroban-mainnet.stellar.org', + STELLAR_BASE_FEE: 100, + HEALTH_CHECK_DB_TIMEOUT: 2000, + APP_VERSION: '1.0.0', + LOG_LEVEL: 'info', + GATEWAY_PROFILING_ENABLED: false, + }, +})); + +import { + PgAuditLogRepository, + type AuditLogRepositoryQueryable, +} from './auditLogRepository.js'; +import { encodeCursor } from '../lib/cursorPagination.js'; + +function createAuditLogRepository() { + const db = newDb(); + + db.public.registerFunction({ + name: 'now', + returns: DataType.timestamp, + implementation: () => new Date('2026-06-28T00:00:00.000Z'), + }); + + db.public.none(` + CREATE TABLE audit_logs ( + id VARCHAR(255) PRIMARY KEY, + event VARCHAR(255) NOT NULL, + actor VARCHAR(255) NOT NULL, + tenant_id VARCHAR(255), + client_ip VARCHAR(255), + user_agent TEXT, + correlation_id VARCHAR(255), + body_hash TEXT, + details TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + `); + + const { Pool } = db.adapters.createPg(); + const pgPool = new Pool(); + + return { + repository: new PgAuditLogRepository(pgPool as AuditLogRepositoryQueryable), + pgPool, + queryable: pgPool as AuditLogRepositoryQueryable, + db, + }; +} + +async function insertAuditLog( + pool: AuditLogRepositoryQueryable, + values: { + id: string; + event: string; + actor: string; + tenantId?: string | null; + createdAt: Date; + details?: Record; + }, +): Promise { + await pool.query( + ` + INSERT INTO audit_logs ( + id, event, actor, tenant_id, client_ip, user_agent, + correlation_id, body_hash, details, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `, + [ + values.id, + values.event, + values.actor, + values.tenantId ?? null, + '127.0.0.1', + 'jest', + `req-${values.id}`, + null, + values.details ? JSON.stringify(values.details) : null, + values.createdAt, + ], + ); +} + +async function seedAuditLogs( + pool: AuditLogRepositoryQueryable, + count: number, + baseTime = new Date('2026-06-28T00:00:00.000Z'), +): Promise { + for (let i = 0; i < count; i++) { + await insertAuditLog(pool, { + id: `audit-${String(i).padStart(3, '0')}`, + event: 'LIST_USERS', + actor: 'admin-api-key', + createdAt: new Date(baseTime.getTime() + i * 60_000), + details: { index: i }, + }); + } +} + +test('returns newest rows first with next page detection', async () => { + const { repository, pgPool, queryable } = createAuditLogRepository(); + + try { + await seedAuditLogs(queryable, 5); + + const firstPage = await repository.findCursor({ limit: 2 }); + assert.equal(firstPage.entries.length, 2); + assert.equal(firstPage.hasMore, true); + assert.equal(firstPage.entries[0]?.id, 'audit-004'); + assert.equal(firstPage.entries[1]?.id, 'audit-003'); + + const cursor = encodeCursor( + new Date(firstPage.entries[1]!.createdAt), + firstPage.entries[1]!.id, + ); + + const secondPage = await repository.findCursor({ + limit: 2, + afterCursor: { + timestamp: new Date(firstPage.entries[1]!.createdAt), + id: firstPage.entries[1]!.id, + }, + }); + + assert.equal(secondPage.entries.length, 2); + assert.equal(secondPage.hasMore, true); + assert.equal(secondPage.entries[0]?.id, 'audit-002'); + assert.equal(secondPage.entries[1]?.id, 'audit-001'); + assert.notEqual(cursor, ''); + } finally { + await pgPool.end(); + } +}); + +test('returns hasMore=false on the final page', async () => { + const { repository, pgPool, queryable } = createAuditLogRepository(); + + try { + await seedAuditLogs(queryable, 3); + + const page = await repository.findCursor({ + limit: 1, + afterCursor: { + timestamp: new Date('2026-06-28T00:01:00.000Z'), + id: 'audit-001', + }, + }); + + assert.equal(page.entries.length, 1); + assert.equal(page.hasMore, false); + assert.equal(page.entries[0]?.id, 'audit-000'); + } finally { + await pgPool.end(); + } +}); + +test('applies event and tenant filters', async () => { + const { repository, pgPool, queryable } = createAuditLogRepository(); + + try { + await queryable.query( + ` + INSERT INTO audit_logs (id, event, actor, tenant_id, created_at) + VALUES + ('a-1', 'LIST_USERS', 'admin-api-key', 'tenant-a', '2026-06-28T01:00:00.000Z'), + ('a-2', 'SOFT_DELETE_API', 'admin-api-key', 'tenant-b', '2026-06-28T02:00:00.000Z') + `, + ); + + const filtered = await repository.findCursor({ + limit: 10, + event: 'SOFT_DELETE_API', + tenantId: 'tenant-b', + }); + + assert.equal(filtered.entries.length, 1); + assert.equal(filtered.entries[0]?.id, 'a-2'); + assert.equal(filtered.hasMore, false); + } finally { + await pgPool.end(); + } +}); + +test('parses JSON details into objects', async () => { + const { repository, pgPool, queryable } = createAuditLogRepository(); + + try { + await seedAuditLogs(queryable, 1); + const page = await repository.findCursor({ limit: 1 }); + + assert.deepEqual(page.entries[0]?.details, { index: 0 }); + } finally { + await pgPool.end(); + } +}); diff --git a/src/repositories/auditLogRepository.ts b/src/repositories/auditLogRepository.ts new file mode 100644 index 0000000..f7c4087 --- /dev/null +++ b/src/repositories/auditLogRepository.ts @@ -0,0 +1,172 @@ +import type { CursorPayload } from '../lib/cursorPagination.js'; +import { readQuery } from '../db.js'; + +export interface AuditLogEntry { + id: string; + event: string; + actor: string; + tenantId: string | null; + clientIp: string | null; + userAgent: string | null; + correlationId: string | null; + bodyHash: string | null; + details: Record | null; + createdAt: string; +} + +export interface AuditLogCursorFilters { + event?: string; + tenantId?: string; + actor?: string; + from?: Date; + to?: Date; +} + +export interface FindAuditLogsCursorParams extends AuditLogCursorFilters { + limit: number; + afterCursor?: CursorPayload; +} + +export interface FindAuditLogsCursorResult { + entries: AuditLogEntry[]; + hasMore: boolean; +} + +export interface AuditLogRepository { + findCursor(params: FindAuditLogsCursorParams): Promise; +} + +export interface AuditLogRepositoryQueryable { + query(text: string, params?: unknown[]): Promise<{ rows: T[] }>; +} + +interface AuditLogRow { + id: string; + event: string; + actor: string; + tenant_id: string | null; + client_ip: string | null; + user_agent: string | null; + correlation_id: string | null; + body_hash: string | null; + details: string | null; + created_at: Date | string; +} + +const parseDetails = (raw: string | null): Record | null => { + if (!raw) { + return null; + } + + try { + const parsed: unknown = JSON.parse(raw); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +}; + +const mapAuditLogRow = (row: AuditLogRow): AuditLogEntry => ({ + id: row.id, + event: row.event, + actor: row.actor, + tenantId: row.tenant_id, + clientIp: row.client_ip, + userAgent: row.user_agent, + correlationId: row.correlation_id, + bodyHash: row.body_hash, + details: parseDetails(row.details), + createdAt: row.created_at instanceof Date + ? row.created_at.toISOString() + : new Date(row.created_at).toISOString(), +}); + +export class PgAuditLogRepository implements AuditLogRepository { + constructor(private readonly db?: AuditLogRepositoryQueryable) {} + + async findCursor(params: FindAuditLogsCursorParams): Promise { + const fetchLimit = Math.max(1, params.limit) + 1; + const sqlParams: unknown[] = []; + const whereClauses: string[] = []; + + if (params.event) { + sqlParams.push(params.event); + whereClauses.push(`event = $${sqlParams.length}`); + } + + if (params.tenantId) { + sqlParams.push(params.tenantId); + whereClauses.push(`tenant_id = $${sqlParams.length}`); + } + + if (params.actor) { + sqlParams.push(params.actor); + whereClauses.push(`actor = $${sqlParams.length}`); + } + + if (params.from) { + sqlParams.push(params.from); + whereClauses.push(`created_at >= $${sqlParams.length}`); + } + + if (params.to) { + sqlParams.push(params.to); + whereClauses.push(`created_at <= $${sqlParams.length}`); + } + + if (params.afterCursor) { + // Keyset pagination over (created_at DESC, id DESC): + // fetch rows strictly older than the cursor position. + sqlParams.push(params.afterCursor.timestamp); + sqlParams.push(params.afterCursor.id); + const tsIdx = sqlParams.length - 1; + const idIdx = sqlParams.length; + whereClauses.push( + `(created_at < $${tsIdx} OR (created_at = $${tsIdx} AND id < $${idIdx}))`, + ); + } + + sqlParams.push(fetchLimit); + + const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''; + + const result = await this.read( + ` + SELECT + id, + event, + actor, + tenant_id, + client_ip, + user_agent, + correlation_id, + body_hash, + details, + created_at + FROM audit_logs + ${whereSql} + ORDER BY created_at DESC, id DESC + LIMIT $${sqlParams.length} + `, + sqlParams, + ); + + const hasMore = result.rows.length > params.limit; + const entries = result.rows.slice(0, params.limit).map(mapAuditLogRow); + + return { entries, hasMore }; + } + + private read(text: string, params?: unknown[]): Promise<{ rows: T[] }> { + if (this.db) { + return this.db.query(text, params); + } + return readQuery(text, params); + } +} + +export function createDefaultAuditLogRepository(): AuditLogRepository { + return new PgAuditLogRepository(); +} diff --git a/src/routes/admin.ts b/src/routes/admin.ts index a692bb9..396edc9 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -15,6 +15,7 @@ import { } from '../services/quotaService.js'; import { createAdminWebhooksRouter } from './admin/webhooks.js'; import { createAdminApisRouter } from './admin/apis.js'; +import { createAdminAuditRouter } from './admin/audit.js'; const TRUST_PROXY = process.env.TRUST_PROXY_HEADERS === 'true'; const usageStore: UsageAdminStore = createUsageStore(); @@ -211,4 +212,10 @@ router.use('/webhooks', createAdminWebhooksRouter()); // --------------------------------------------------------------------------- router.use('/apis', createAdminApisRouter()); +// --------------------------------------------------------------------------- +// Audit log listing (cursor pagination) +// Mounts: GET /api/admin/audit +// --------------------------------------------------------------------------- +router.use('/audit', createAdminAuditRouter()); + export default router; \ No newline at end of file diff --git a/src/routes/admin/audit.test.ts b/src/routes/admin/audit.test.ts new file mode 100644 index 0000000..7482cb5 --- /dev/null +++ b/src/routes/admin/audit.test.ts @@ -0,0 +1,262 @@ +/** + * Tests for GET /api/admin/audit — cursor-paginated audit log listing. + */ + +jest.mock('better-sqlite3', () => { + return class MockDatabase { + prepare() { + return { get: () => null }; + } + exec() {} + close() {} + }; +}); + +jest.mock('../../config/env', () => ({ + env: { + PORT: 3000, + NODE_ENV: 'test', + DATABASE_URL: 'postgresql://localhost/callora_test', + DB_HOST: 'localhost', + DB_PORT: 5432, + DB_USER: 'postgres', + DB_PASSWORD: 'postgres', + DB_NAME: 'callora_test', + DB_POOL_MAX: 1, + DB_IDLE_TIMEOUT_MS: 1000, + DB_CONN_TIMEOUT_MS: 1000, + JWT_SECRET: 'test-jwt-secret', + ADMIN_API_KEY: 'test-admin-api-key', + METRICS_API_KEY: 'test-metrics-api-key', + UPSTREAM_URL: 'http://localhost:4000', + PROXY_TIMEOUT_MS: 30000, + CORS_ALLOWED_ORIGINS: 'http://localhost:5173', + SOROBAN_RPC_ENABLED: false, + HORIZON_ENABLED: false, + STELLAR_TESTNET_HORIZON_URL: 'https://horizon-testnet.stellar.org', + STELLAR_MAINNET_HORIZON_URL: 'https://horizon.stellar.org', + SOROBAN_TESTNET_RPC_URL: 'https://soroban-testnet.stellar.org', + SOROBAN_MAINNET_RPC_URL: 'https://soroban-mainnet.stellar.org', + STELLAR_BASE_FEE: 100, + HEALTH_CHECK_DB_TIMEOUT: 2000, + APP_VERSION: '1.0.0', + LOG_LEVEL: 'info', + GATEWAY_PROFILING_ENABLED: false, + }, +})); + +import express from 'express'; +import request from 'supertest'; +import { errorHandler } from '../../middleware/errorHandler.js'; +import { requestIdMiddleware } from '../../middleware/requestId.js'; +import { createAdminAuditRouter } from './audit.js'; +import { encodeCursor } from '../../lib/cursorPagination.js'; +import type { + AuditLogEntry, + AuditLogRepository, + FindAuditLogsCursorParams, + FindAuditLogsCursorResult, +} from '../../repositories/auditLogRepository.js'; + +jest.mock('../../logger', () => { + const actual = jest.requireActual('../../logger'); + return { + ...actual, + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + audit: jest.fn(), + }, + }; +}); + +import { logger } from '../../logger.js'; + +const ADMIN_KEY = 'test-audit-admin-key'; + +const baseEntry = (overrides: Partial = {}): AuditLogEntry => ({ + id: 'audit-1', + event: 'LIST_USERS', + actor: 'admin-api-key', + tenantId: null, + clientIp: '127.0.0.1', + userAgent: 'jest', + correlationId: 'req-1', + bodyHash: null, + details: { count: 1 }, + createdAt: '2026-06-28T10:00:00.000Z', + ...overrides, +}); + +class MockAuditLogRepository implements AuditLogRepository { + constructor(private readonly handler: (params: FindAuditLogsCursorParams) => FindAuditLogsCursorResult | Promise) {} + + findCursor(params: FindAuditLogsCursorParams): Promise { + return Promise.resolve(this.handler(params)); + } +} + +function buildApp(repository: AuditLogRepository) { + const app = express(); + app.use(requestIdMiddleware); + app.use((req, res, next) => { + if (req.headers['x-admin-api-key'] !== ADMIN_KEY) { + res.status(401).json({ code: 'UNAUTHORIZED', message: 'Unauthorized', requestId: 'test' }); + return; + } + res.locals.adminActor = 'admin-api-key'; + next(); + }); + app.use('/api/admin/audit', createAdminAuditRouter({ auditLogRepository: repository })); + app.use(errorHandler); + return app; +} + +describe('GET /api/admin/audit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns the first page with nextCursor when more results exist', async () => { + const entries = [ + baseEntry({ id: 'audit-3', createdAt: '2026-06-28T12:00:00.000Z' }), + baseEntry({ id: 'audit-2', createdAt: '2026-06-28T11:00:00.000Z' }), + ]; + const repo = new MockAuditLogRepository(() => ({ entries, hasMore: true })); + const app = buildApp(repo); + + const res = await request(app) + .get('/api/admin/audit?limit=2') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.meta).toEqual({ + limit: 2, + hasMore: true, + nextCursor: encodeCursor(new Date('2026-06-28T11:00:00.000Z'), 'audit-2'), + }); + expect(logger.audit).toHaveBeenCalledWith( + 'LIST_AUDIT_LOGS', + 'admin-api-key', + expect.objectContaining({ count: 2, hasMore: true }), + ); + }); + + it('returns an empty page without nextCursor when no rows exist', async () => { + const repo = new MockAuditLogRepository(() => ({ entries: [], hasMore: false })); + const app = buildApp(repo); + + const res = await request(app) + .get('/api/admin/audit') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(200); + expect(res.body.data).toEqual([]); + expect(res.body.meta).toEqual({ limit: 20, hasMore: false }); + expect(res.body.meta.nextCursor).toBeUndefined(); + }); + + it('passes decoded cursor and filters to the repository', async () => { + const cursor = encodeCursor(new Date('2026-06-28T11:00:00.000Z'), 'audit-2'); + const handler = jest.fn((): FindAuditLogsCursorResult => ({ entries: [], hasMore: false })); + const app = buildApp(new MockAuditLogRepository(handler)); + + await request(app) + .get('/api/admin/audit') + .query({ + cursor, + limit: '5', + event: 'LIST_USERS', + tenant_id: 'dev-1', + actor: 'admin-api-key', + from: '2026-06-01T00:00:00.000Z', + to: '2026-06-30T00:00:00.000Z', + }) + .set('x-admin-api-key', ADMIN_KEY); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 5, + event: 'LIST_USERS', + tenantId: 'dev-1', + actor: 'admin-api-key', + afterCursor: { + timestamp: new Date('2026-06-28T11:00:00.000Z'), + id: 'audit-2', + }, + }), + ); + }); + + it('rejects an invalid cursor with a standardized validation error', async () => { + const repo = new MockAuditLogRepository(() => ({ entries: [], hasMore: false })); + const app = buildApp(repo); + + const res = await request(app) + .get('/api/admin/audit?cursor=not-a-valid-cursor') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('VALIDATION_ERROR'); + expect(res.body.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: 'query.cursor' }), + ]), + ); + }); + + it('rejects a non-numeric limit', async () => { + const repo = new MockAuditLogRepository(() => ({ entries: [], hasMore: false })); + const app = buildApp(repo); + + const res = await request(app) + .get('/api/admin/audit?limit=abc') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(400); + expect(res.body.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: 'query.limit' }), + ]), + ); + }); + + it('rejects an invalid from date', async () => { + const repo = new MockAuditLogRepository(() => ({ entries: [], hasMore: false })); + const app = buildApp(repo); + + const res = await request(app) + .get('/api/admin/audit?from=not-a-date') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(400); + expect(res.body.message).toContain('from'); + }); + + it('rejects when from is after to', async () => { + const repo = new MockAuditLogRepository(() => ({ entries: [], hasMore: false })); + const app = buildApp(repo); + + const res = await request(app) + .get('/api/admin/audit') + .query({ + from: '2026-06-30T00:00:00.000Z', + to: '2026-06-01T00:00:00.000Z', + }) + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(400); + expect(res.body.message).toContain('from'); + }); + + it('requires admin authentication', async () => { + const repo = new MockAuditLogRepository(() => ({ entries: [], hasMore: false })); + const app = buildApp(repo); + + const res = await request(app).get('/api/admin/audit'); + + expect(res.status).toBe(401); + }); +}); diff --git a/src/routes/admin/audit.ts b/src/routes/admin/audit.ts new file mode 100644 index 0000000..be68cb4 --- /dev/null +++ b/src/routes/admin/audit.ts @@ -0,0 +1,139 @@ +/** + * Admin audit-log listing with cursor pagination. + * + * Route: + * GET /api/admin/audit + * + * Pagination uses stable keyset ordering over (created_at DESC, id DESC). + * The opaque `cursor` query param encodes the last row's timestamp and id. + */ + +import { Router } from 'express'; +import { getClientIp } from '../../lib/clientIp.js'; +import { encodeCursor, parseCursor } from '../../lib/cursorPagination.js'; +import { + cursorPaginatedResponse, + parseCursorPagination, +} from '../../lib/pagination.js'; +import { + AppError, + BadRequestError, + InternalServerError, +} from '../../errors/index.js'; +import { ValidationError } from '../../middleware/validate.js'; +import { logger } from '../../logger.js'; +import { + PgAuditLogRepository, + type AuditLogRepository, +} from '../../repositories/auditLogRepository.js'; + +const TRUST_PROXY = process.env.TRUST_PROXY_HEADERS === 'true'; + +const parseOptionalDate = (value: unknown, field: string): Date | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string' || value.trim() === '') { + throw new BadRequestError(`Invalid "${field}" date`); + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new BadRequestError(`Invalid "${field}" date`); + } + + return date; +}; + +const parseOptionalString = (value: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed === '' ? undefined : trimmed; +}; + +export interface AdminAuditRouterDeps { + auditLogRepository?: AuditLogRepository; +} + +export function createAdminAuditRouter(deps: AdminAuditRouterDeps = {}): Router { + const router = Router(); + const auditLogRepository = deps.auditLogRepository ?? new PgAuditLogRepository(); + + router.get('/', async (req, res, next) => { + try { + const { limit, cursor: rawCursor } = parseCursorPagination( + req.query as Record, + ); + + let afterCursor; + if (rawCursor !== undefined) { + afterCursor = parseCursor(rawCursor); + if (!afterCursor) { + throw new ValidationError([ + { + field: 'query.cursor', + message: 'Invalid cursor format', + code: 'INVALID_VALUE', + }, + ]); + } + } + + const event = parseOptionalString(req.query.event); + const tenantId = parseOptionalString(req.query.tenant_id); + const actor = parseOptionalString(req.query.actor); + const from = parseOptionalDate(req.query.from, 'from'); + const to = parseOptionalDate(req.query.to, 'to'); + + if (from && to && from.getTime() > to.getTime()) { + throw new BadRequestError('"from" must be before or equal to "to"'); + } + + const { entries, hasMore } = await auditLogRepository.findCursor({ + limit, + afterCursor, + event, + tenantId, + actor, + from, + to, + }); + + const nextCursor = hasMore && entries.length > 0 + ? encodeCursor(new Date(entries[entries.length - 1]!.createdAt), entries[entries.length - 1]!.id) + : undefined; + + const correlationId = + (typeof req.headers['x-request-id'] === 'string' ? req.headers['x-request-id'] : undefined) ?? + (typeof req.headers['x-correlation-id'] === 'string' ? req.headers['x-correlation-id'] : undefined); + + logger.audit('LIST_AUDIT_LOGS', res.locals.adminActor, { + clientIp: getClientIp(req, TRUST_PROXY), + userAgent: req.get('User-Agent'), + correlationId, + filters: { event, tenantId, actor, from, to }, + limit, + cursorProvided: rawCursor !== undefined, + count: entries.length, + hasMore, + }); + + res.json(cursorPaginatedResponse(entries, { + limit, + hasMore, + nextCursor, + })); + } catch (error) { + if (error instanceof AppError || error instanceof ValidationError) { + next(error); + return; + } + logger.error('Failed to list audit logs:', error); + next(new InternalServerError()); + } + }); + + return router; +} From 7ea6ee4a7f2ffc0021a889d655330e92eb87f837 Mon Sep 17 00:00:00 2001 From: "Abdullateef O.G" Date: Sun, 28 Jun 2026 15:08:47 +0100 Subject: [PATCH 2/3] fix: resolve lint failures blocking CI pipeline - Remove leftover merge conflict markers in usageEventsRepository.pg.ts - Fix WebhookEventType union syntax in webhook.types.ts - Restore missing CursorPayload imports and readDb query calls - Add USAGE_AGGREGATE_NOT_FOUND to error code catalog --- docs/error-codes.md | 1 + docs/error-codes.yaml | 4 ++++ docs/openapi.json | 3 ++- src/repositories/usageEventsRepository.pg.ts | 7 +++---- src/webhooks/webhook.types.ts | 4 ++-- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/error-codes.md b/docs/error-codes.md index 0944e7d..1050835 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -91,6 +91,7 @@ This section is generated from `docs/error-codes.yaml`. Run `npm run error-codes | `REQUEST_BODY_TOO_LARGE` | HTTP fallback derived codes referenced by documentation | | `UNSUPPORTED_MEDIA_TYPE` | HTTP fallback derived codes referenced by documentation | | `UNPROCESSABLE_ENTITY` | HTTP fallback derived codes referenced by documentation | +| `USAGE_AGGREGATE_NOT_FOUND` | Admin usage management | ## Scope and important caveats diff --git a/docs/error-codes.yaml b/docs/error-codes.yaml index bde66d7..c8deea7 100644 --- a/docs/error-codes.yaml +++ b/docs/error-codes.yaml @@ -341,3 +341,7 @@ error_codes: - code: UNPROCESSABLE_ENTITY section: HTTP fallback derived codes referenced by documentation description: Request is syntactically correct but semantically invalid + + - code: USAGE_AGGREGATE_NOT_FOUND + section: Admin usage management + description: Usage aggregate not found for the given developer diff --git a/docs/openapi.json b/docs/openapi.json index c130e7a..7093995 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1841,7 +1841,8 @@ "REQUEST_TIMEOUT", "REQUEST_BODY_TOO_LARGE", "UNSUPPORTED_MEDIA_TYPE", - "UNPROCESSABLE_ENTITY" + "UNPROCESSABLE_ENTITY", + "USAGE_AGGREGATE_NOT_FOUND" ], "description": "Canonical Callora backend error code." } diff --git a/src/repositories/usageEventsRepository.pg.ts b/src/repositories/usageEventsRepository.pg.ts index 1918f11..20c9411 100644 --- a/src/repositories/usageEventsRepository.pg.ts +++ b/src/repositories/usageEventsRepository.pg.ts @@ -7,10 +7,9 @@ import { type UsageBucket, type GroupBy, } from './usageEventsRepository.js'; - feature/usage-cursor-pagination +import { encodeCursor, type CursorPayload } from '../lib/cursorPagination.js'; import { generateCursor, getNextCursor, decodeCursor } from '../lib/pagination.js'; import { readQuery, writeQuery } from '../db.js'; - main export interface CreateUsageEventInput { userId: string; @@ -392,7 +391,7 @@ export class PgUsageEventsRepository implements UsageEventsPgRepository { `; params.push(fetchLimit); - const result = await this.db.query(sql, params); + const result = await this.readDb.query(sql, params); const rows = result.rows; // Check if there are more results @@ -616,7 +615,7 @@ export class PgUsageEventsRepository implements UsageEventsPgRepository { ${limitClause} `; - const result = await this.db.query(sql, sqlParams); + const result = await this.readDb.query(sql, sqlParams); const rows = result.rows.map(mapUsageEventRow); // Determine whether there is a page beyond what we're returning diff --git a/src/webhooks/webhook.types.ts b/src/webhooks/webhook.types.ts index ea912cb..b343cbd 100644 --- a/src/webhooks/webhook.types.ts +++ b/src/webhooks/webhook.types.ts @@ -2,8 +2,8 @@ export type WebhookEventType = | 'new_api_call' | 'settlement_completed' | 'low_balance_alert' - | 'quota.threshold.reached'; - | 'invoice_created' + | 'quota.threshold.reached' + | 'invoice_created'; export interface WebhookConfig { developerId: string; From 1b31834e70ae9c4264f42d9f6a34341b3401d8d7 Mon Sep 17 00:00:00 2001 From: "Abdullateef O.G" Date: Sun, 28 Jun 2026 19:55:19 +0100 Subject: [PATCH 3/3] fix: resolve CI lint and typecheck failures - Fix merge conflict markers and syntax errors blocking ESLint - Align test fixtures with schema fields (plan_overrides, deleted_at) - Add missing dependency, config fields, and error codes for typecheck - Repair broken imports and repository interface implementations --- docs/error-codes.md | 2 + docs/error-codes.yaml | 8 + docs/openapi.json | 4 +- package-lock.json | 307 +++++++++++++++++- package.json | 1 + src/__tests__/developerRevenue.test.ts | 4 + src/__tests__/listingsCache.test.ts | 1 + src/apis.registration.test.ts | 1 + src/app.test.ts | 21 +- src/app.ts | 2 +- src/config/env.ts | 1 + src/config/index.ts | 7 + src/errors/codes.ts | 8 +- src/metrics.ts | 28 ++ src/middleware/gatewayApiKeyAuth.ts | 1 + src/repositories/apiRepository.drizzle.ts | 13 +- src/routes/apiKeyRoutes.test.ts | 17 +- src/routes/apis.test.ts | 3 + src/routes/developerRoutes.test.ts | 1 + src/services/billingReconciliationJob.test.ts | 6 +- src/services/idempotencySweeper.test.ts | 2 +- src/services/revenueSettlementService.ts | 19 +- src/services/settlementStore.test.ts | 14 +- src/workers/monthlyInvoiceJob.ts | 2 +- src/workers/slowQueryAlerter.test.ts | 6 +- tests/chaos/sorobanLatency.test.ts | 2 +- tests/contract/billing.test.ts | 2 +- tests/integration/billing.test.ts | 8 + tests/integration/protected.test.ts | 9 + 29 files changed, 455 insertions(+), 45 deletions(-) diff --git a/docs/error-codes.md b/docs/error-codes.md index 1050835..0c076b3 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -92,6 +92,8 @@ This section is generated from `docs/error-codes.yaml`. Run `npm run error-codes | `UNSUPPORTED_MEDIA_TYPE` | HTTP fallback derived codes referenced by documentation | | `UNPROCESSABLE_ENTITY` | HTTP fallback derived codes referenced by documentation | | `USAGE_AGGREGATE_NOT_FOUND` | Admin usage management | +| `INVALID_EXPORT_SCHEDULE` | Export schedules | +| `EXPORT_SCHEDULE_NOT_FOUND` | Export schedules | ## Scope and important caveats diff --git a/docs/error-codes.yaml b/docs/error-codes.yaml index c8deea7..a181860 100644 --- a/docs/error-codes.yaml +++ b/docs/error-codes.yaml @@ -345,3 +345,11 @@ error_codes: - code: USAGE_AGGREGATE_NOT_FOUND section: Admin usage management description: Usage aggregate not found for the given developer + + - code: INVALID_EXPORT_SCHEDULE + section: Export schedules + description: Export schedule payload or configuration is invalid + + - code: EXPORT_SCHEDULE_NOT_FOUND + section: Export schedules + description: Export schedule not found diff --git a/docs/openapi.json b/docs/openapi.json index 7093995..d697e8d 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1842,7 +1842,9 @@ "REQUEST_BODY_TOO_LARGE", "UNSUPPORTED_MEDIA_TYPE", "UNPROCESSABLE_ENTITY", - "USAGE_AGGREGATE_NOT_FOUND" + "USAGE_AGGREGATE_NOT_FOUND", + "INVALID_EXPORT_SCHEDULE", + "EXPORT_SCHEDULE_NOT_FOUND" ], "description": "Canonical Callora backend error code." } diff --git a/package-lock.json b/package-lock.json index bfaa690..f3a4c92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "dotenv": "^17.3.1", "drizzle-orm": "^0.29.0", "express": "^4.18.2", + "express-openapi-validator": "^5.6.2", "helmet": "^8.1.0", "ip-range-check": "^0.2.0", "jsonwebtoken": "^9.0.3", @@ -57,6 +58,52 @@ "typescript-eslint": "^8.56.1" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.2.1.tgz", + "integrity": "sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==", + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + }, + "peerDependencies": { + "@types/json-schema": "^7.0.15" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -3810,6 +3857,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@mrleebo/prisma-ast": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz", @@ -4285,7 +4338,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -4296,7 +4348,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -4337,7 +4388,6 @@ "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -4350,7 +4400,6 @@ "version": "4.19.8", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -4383,7 +4432,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -4428,7 +4476,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/jsonwebtoken": { @@ -4453,7 +4500,6 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -4463,6 +4509,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", @@ -4488,14 +4543,12 @@ "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { @@ -4512,7 +4565,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -4522,7 +4574,6 @@ "version": "1.15.10", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -4534,7 +4585,6 @@ "version": "0.17.6", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -4882,6 +4932,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -4935,6 +5024,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -5322,9 +5417,19 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -5663,6 +5768,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -6997,6 +7117,86 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-openapi-validator": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/express-openapi-validator/-/express-openapi-validator-5.6.2.tgz", + "integrity": "sha512-fkDn4+ImUC4HTJ1g0cek/ItqYhmEO19AglJd2Iw2OJco0jLIbxIlDGVazmXbvvYeziU4Bnah2h+S2tb6NtWg8w==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^14.2.1", + "@types/multer": "^2.0.0", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "json-schema-traverse": "^1.0.0", + "lodash.clonedeep": "^4.5.0", + "lodash.get": "^4.4.2", + "media-typer": "^1.1.0", + "multer": "^2.0.2", + "ono": "^7.1.3", + "path-to-regexp": "^8.3.0", + "qs": "^6.14.1" + }, + "peerDependencies": { + "express": "*" + } + }, + "node_modules/express-openapi-validator/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/express-openapi-validator/node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/express-openapi-validator/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/express-openapi-validator/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express-openapi-validator/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -7054,7 +7254,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -7078,6 +7277,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -11591,6 +11806,19 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -11907,6 +12135,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mysql2": { "version": "3.15.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", @@ -12198,6 +12445,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-9jnfVriq7uJM4o5ganUY54ntUm+5EK21EGaQ5NWnkWg3zz5ywbbonlBguRcnmF1/HDiIe3zxNxXcO1YPBmPcQQ==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "7.1.3" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -13145,6 +13401,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -13639,6 +13904,14 @@ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "license": "MIT" }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -14672,6 +14945,12 @@ "node": ">= 0.4" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 3acad92..079b8fd 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dotenv": "^17.3.1", "drizzle-orm": "^0.29.0", "express": "^4.18.2", + "express-openapi-validator": "^5.6.2", "helmet": "^8.1.0", "ip-range-check": "^0.2.0", "jsonwebtoken": "^9.0.3", diff --git a/src/__tests__/developerRevenue.test.ts b/src/__tests__/developerRevenue.test.ts index f94c73e..ed85a1c 100644 --- a/src/__tests__/developerRevenue.test.ts +++ b/src/__tests__/developerRevenue.test.ts @@ -30,6 +30,7 @@ const developerRepository = { website: null, description: null, category: null, + plan_overrides: null, created_at: new Date('2026-01-01T00:00:00.000Z'), updated_at: new Date('2026-01-01T00:00:00.000Z'), }; @@ -142,6 +143,7 @@ function seedProfile(userId: string, id: number): void { website: null, description: null, category: null, + plan_overrides: null, created_at: new Date('2026-01-01T00:00:00.000Z'), updated_at: new Date('2026-01-01T00:00:00.000Z'), }); @@ -158,6 +160,7 @@ beforeAll(() => { website: null, description: null, category: 'analytics', + plan_overrides: null, created_at: new Date('2026-01-01T00:00:00.000Z'), updated_at: new Date('2026-01-01T00:00:00.000Z'), }); @@ -168,6 +171,7 @@ beforeAll(() => { website: null, description: null, category: 'finance', + plan_overrides: null, created_at: new Date('2026-01-01T00:00:00.000Z'), updated_at: new Date('2026-01-01T00:00:00.000Z'), }); diff --git a/src/__tests__/listingsCache.test.ts b/src/__tests__/listingsCache.test.ts index 3018cce..dca5afa 100644 --- a/src/__tests__/listingsCache.test.ts +++ b/src/__tests__/listingsCache.test.ts @@ -34,6 +34,7 @@ function makeApi(overrides: Partial = {}): Api { status: 'active', created_at: new Date(0), updated_at: new Date(0), + deleted_at: null, ...overrides, }; } diff --git a/src/apis.registration.test.ts b/src/apis.registration.test.ts index 8802d3e..9d23995 100644 --- a/src/apis.registration.test.ts +++ b/src/apis.registration.test.ts @@ -26,6 +26,7 @@ const developerProfile: Developer = { website: null, description: null, category: null, + plan_overrides: null, created_at: new Date(0), updated_at: new Date(0), }; diff --git a/src/app.test.ts b/src/app.test.ts index 4709261..6b2829f 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -79,6 +79,7 @@ const developerProfile: Developer = { website: null, description: null, category: null, + plan_overrides: null, created_at: new Date(1000), updated_at: new Date(1000), }; @@ -95,6 +96,7 @@ const sampleApis: Api[] = [ status: 'active', created_at: new Date(1000), updated_at: new Date(1000), + deleted_at: null, }, { id: 102, @@ -107,6 +109,7 @@ const sampleApis: Api[] = [ status: 'active', created_at: new Date(1000), updated_at: new Date(1000), + deleted_at: null, }, { id: 103, @@ -119,6 +122,7 @@ const sampleApis: Api[] = [ status: 'archived', created_at: new Date(1000), updated_at: new Date(1000), + deleted_at: null, }, ]; @@ -137,6 +141,7 @@ class FakeApiRepository implements ApiRepository { status: api.status ?? 'draft', created_at: new Date(1000), updated_at: new Date(1000), + deleted_at: null, }; this.apis.push(created); return created; @@ -225,6 +230,18 @@ class FakeApiRepository implements ApiRepository { async delete(_id: number) { return false; } + + async restore(id: number): Promise { + const index = this.apis.findIndex((api) => api.id === id); + if (index === -1) return null; + const restored = { ...this.apis[index]!, deleted_at: null, updated_at: new Date() }; + this.apis[index] = restored; + return restored; + } + + async bulkCreateEndpoints() { + return []; + } } const createDeveloperRepository = (profile?: Developer): DeveloperRepository => ({ @@ -245,6 +262,7 @@ const createDeveloperRepository = (profile?: Developer): DeveloperRepository => website: null, description: null, category: null, + plan_overrides: null, created_at: new Date(), updated_at: new Date(), }; @@ -680,7 +698,7 @@ test('GET /api/apis/:id returns api with empty endpoints list', async () => { // POST /api/developers/apis — publish a new API // --------------------------------------------------------------------------- -const mockDeveloper = { id: 42, user_id: 'dev-1', name: 'Alice', website: null, description: null, category: null, created_at: new Date(), updated_at: new Date() }; +const mockDeveloper = { id: 42, user_id: 'dev-1', name: 'Alice', website: null, description: null, category: null, plan_overrides: null, created_at: new Date(), updated_at: new Date() }; const validApiBody = { name: 'My Weather API', @@ -712,6 +730,7 @@ const makeApp = (hasDeveloper = true) => status: input.status ?? 'draft', created_at: new Date(), updated_at: new Date(), + deleted_at: null, endpoints: input.endpoints.map((ep, idx) => ({ id: idx + 1, api_id: 1, diff --git a/src/app.ts b/src/app.ts index efdf030..9fe5a81 100644 --- a/src/app.ts +++ b/src/app.ts @@ -41,7 +41,7 @@ import { TransactionBuilderService } from './services/transactionBuilder.js'; import { requestIdMiddleware } from './middleware/requestId.js'; import { createMemoryAccountingMiddleware } from './middleware/memoryAccounting.js'; import { validate } from './middleware/validate.js'; -import { requestLogger } from './middleware/logging.js'; +import { createAccessLogMiddleware, requestLogger } from './middleware/accessLog.js'; import { InMemoryRestRateLimiter, createRestRateLimitMiddleware } from './middleware/restRateLimit.js'; import type { RestRateLimitOptions } from './middleware/restRateLimit.js'; import { auditEnrichMiddleware } from './middleware/auditEnrich.js'; diff --git a/src/config/env.ts b/src/config/env.ts index 20ab6ca..afc5823 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -153,6 +153,7 @@ export const envSchema = z HEALTH_CHECK_DB_TIMEOUT: z.coerce.number().default(2_000), APIS_CACHE_TTL_MS: z.coerce.number().int().positive().optional(), LISTINGS_CACHE_WARMUP_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), + BULK_ENDPOINT_LIMIT: z.coerce.number().int().positive().default(100), APP_VERSION: z.string().default("1.0.0"), // Logging diff --git a/src/config/index.ts b/src/config/index.ts index 8fcdf5f..a290a39 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -217,6 +217,13 @@ export const config = { }, bulkEndpointLimit: env.BULK_ENDPOINT_LIMIT, + slowQueryAlerter: { + webhookUrl: env.SLOW_QUERY_ALERT_WEBHOOK_URL, + p95ThresholdMs: env.SLOW_QUERY_P95_THRESHOLD_MS, + pollIntervalMs: env.SLOW_QUERY_POLL_INTERVAL_MS, + dedupWindowMs: env.SLOW_QUERY_DEDUP_WINDOW_SECONDS * 1000, + }, + memoryAccounting: { enabled: env.MEMORY_ACCOUNTING_ENABLED, thresholdMb: env.MEMORY_ACCOUNTING_THRESHOLD_MB, diff --git a/src/errors/codes.ts b/src/errors/codes.ts index fbbeb6d..1ff5e13 100644 --- a/src/errors/codes.ts +++ b/src/errors/codes.ts @@ -247,7 +247,13 @@ export const ErrorCode = { UNPROCESSABLE_ENTITY: "UNPROCESSABLE_ENTITY", /** Usage aggregate not found for the given developer */ - USAGE_AGGREGATE_NOT_FOUND: "USAGE_AGGREGATE_NOT_FOUND" + USAGE_AGGREGATE_NOT_FOUND: "USAGE_AGGREGATE_NOT_FOUND", + + /** Export schedule payload or configuration is invalid */ + INVALID_EXPORT_SCHEDULE: "INVALID_EXPORT_SCHEDULE", + + /** Export schedule not found */ + EXPORT_SCHEDULE_NOT_FOUND: "EXPORT_SCHEDULE_NOT_FOUND" } as const; diff --git a/src/metrics.ts b/src/metrics.ts index 9209aa2..323070a 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -447,6 +447,33 @@ export function recordCacheMiss(): void { apisListingCacheMisses.inc(); } +// ── Gateway API key lookup counter ──────────────────────────────────────────── +// +// Metric: gateway_api_key_lookup_total +// Type: Counter +// Labels: outcome — hit | miss | revoked | expired +// Purpose: Track API key lookup outcomes in gateway auth middleware. +// ───────────────────────────────────────────────────────────────────────────── + +const gatewayApiKeyLookupTotal = new client.Counter({ + name: 'gateway_api_key_lookup_total', + help: 'Total API key lookups in gateway auth middleware', + labelNames: ['outcome'] as const, +}); + +register.registerMetric(gatewayApiKeyLookupTotal); + +export type ApiKeyLookupOutcome = 'hit' | 'miss' | 'revoked' | 'expired'; + +export function recordApiKeyLookup(outcome: ApiKeyLookupOutcome): void { + gatewayApiKeyLookupTotal.inc({ outcome }); +} + +/** Reset gateway API key lookup metrics. Used in tests to isolate metric state. */ +export function resetApiKeyLookupMetrics(): void { + gatewayApiKeyLookupTotal.reset(); +} + // ── Proxy premature-abort counter ───────────────────────────────────────────── // // Metric: proxy_premature_aborts_total @@ -499,6 +526,7 @@ export function resetAllMetrics(): void { gatewayUpstreamBreakerState.reset(); resetSlowQueryAlerterMetrics(); resetReplicaMetrics(); + resetApiKeyLookupMetrics(); } // ── Replica routing metrics ─────────────────────────────────────────────────── diff --git a/src/middleware/gatewayApiKeyAuth.ts b/src/middleware/gatewayApiKeyAuth.ts index 4795c97..e42d288 100644 --- a/src/middleware/gatewayApiKeyAuth.ts +++ b/src/middleware/gatewayApiKeyAuth.ts @@ -17,6 +17,7 @@ export interface GatewayApiKeyRecord { createdAt?: Date | string; lastUsedAt?: Date | string | null; tier?: string; + expiresAt?: Date | string | null; } export interface GatewayAuthCandidate< diff --git a/src/repositories/apiRepository.drizzle.ts b/src/repositories/apiRepository.drizzle.ts index 7d6a9df..053cecf 100644 --- a/src/repositories/apiRepository.drizzle.ts +++ b/src/repositories/apiRepository.drizzle.ts @@ -1,4 +1,4 @@ -import { eq, and, like, type SQL } from "drizzle-orm"; +import { eq, and, like, isNotNull, type SQL } from "drizzle-orm"; import { db, schema } from "../db/index.js"; import type { Api, ApiEndpoint, NewApi, NewApiEndpoint } from "../db/schema.js"; import type { @@ -120,6 +120,17 @@ export class DrizzleApiRepository implements ApiRepository { return result.changes > 0; } + async restore(id: number): Promise { + const now = new Date(); + const [restored] = await db + .update(schema.apis) + .set({ deleted_at: null, updated_at: now }) + .where(and(eq(schema.apis.id, id), isNotNull(schema.apis.deleted_at))) + .returning(); + + return restored ?? null; + } + async listByDeveloper( developerId: number, filters: ApiListFilters = {}, diff --git a/src/routes/apiKeyRoutes.test.ts b/src/routes/apiKeyRoutes.test.ts index 963bbaf..39a30ff 100644 --- a/src/routes/apiKeyRoutes.test.ts +++ b/src/routes/apiKeyRoutes.test.ts @@ -15,6 +15,7 @@ const developerProfile: Developer = { website: null, description: null, category: null, + plan_overrides: null, created_at: new Date(1000), updated_at: new Date(1000), }; @@ -30,6 +31,7 @@ const ownedApi: Api = { status: 'active', created_at: new Date(1000), updated_at: new Date(1000), + deleted_at: null, }; const otherApi: Api = { @@ -43,6 +45,7 @@ const otherApi: Api = { status: 'active', created_at: new Date(1000), updated_at: new Date(1000), + deleted_at: null, }; const createDeveloperRepository = (): DeveloperRepository => ({ @@ -68,7 +71,13 @@ const createApiRepository = (apis: Api[]): ApiRepository => ({ return null; }, async delete() { - return true; + return false; + }, + async restore() { + return null; + }, + async bulkCreateEndpoints() { + return []; }, async listByDeveloper(developerId: number) { return apis.filter((api) => api.developer_id === developerId); @@ -82,12 +91,6 @@ const createApiRepository = (apis: Api[]): ApiRepository => ({ async getEndpoints() { return []; }, - async createWithEndpoints() { - throw new Error('not implemented'); - }, - async delete() { - return false; - }, }); function createTestApp(apis: Api[] = [ownedApi]) { diff --git a/src/routes/apis.test.ts b/src/routes/apis.test.ts index 1c1edee..20c4e8e 100644 --- a/src/routes/apis.test.ts +++ b/src/routes/apis.test.ts @@ -21,6 +21,7 @@ const developerProfile: Developer = { website: null, description: null, category: null, + plan_overrides: null, created_at: new Date(1000), updated_at: new Date(1000), }; @@ -160,6 +161,7 @@ describe('POST /api/apis/:id/endpoints/bulk', () => { status: 'active', created_at: new Date(1000), updated_at: new Date(1000), + deleted_at: null, }; const unownedApi: Api = { @@ -173,6 +175,7 @@ describe('POST /api/apis/:id/endpoints/bulk', () => { status: 'active', created_at: new Date(1000), updated_at: new Date(1000), + deleted_at: null, }; const repo = new InMemoryApiRepository( diff --git a/src/routes/developerRoutes.test.ts b/src/routes/developerRoutes.test.ts index 849c955..9c16824 100644 --- a/src/routes/developerRoutes.test.ts +++ b/src/routes/developerRoutes.test.ts @@ -26,6 +26,7 @@ const makeDeveloper = (overrides: Partial = {}): Developer => ({ website: null, description: null, category: null, + plan_overrides: null, created_at: new Date('2026-01-01T00:00:00.000Z'), updated_at: new Date('2026-01-01T00:00:00.000Z'), ...overrides, diff --git a/src/services/billingReconciliationJob.test.ts b/src/services/billingReconciliationJob.test.ts index 3539ddf..3785e00 100644 --- a/src/services/billingReconciliationJob.test.ts +++ b/src/services/billingReconciliationJob.test.ts @@ -16,9 +16,9 @@ function makeDb( ledgerRows: { developer_id: string; total: string }[], ): ReconciliationQueryable { return { - async query(sql: string) { - if (sql.includes('usage_events')) return { rows: usageRows }; - if (sql.includes('revenue_ledger')) return { rows: ledgerRows }; + async query(sql: string, _params?: unknown[]): Promise<{ rows: T[] }> { + if (sql.includes('usage_events')) return { rows: usageRows as T[] }; + if (sql.includes('revenue_ledger')) return { rows: ledgerRows as T[] }; return { rows: [] }; }, }; diff --git a/src/services/idempotencySweeper.test.ts b/src/services/idempotencySweeper.test.ts index 09f1a62..988a6a2 100644 --- a/src/services/idempotencySweeper.test.ts +++ b/src/services/idempotencySweeper.test.ts @@ -40,7 +40,7 @@ describe('idempotency sweeper', () => { const metrics = await register.getMetricsAsJSON(); const gauge = metrics.find((m: any) => m.name === 'idempotency_store_rows'); expect(gauge).toBeDefined(); - expect(gauge.values.some((value: any) => Number(value.value) === 5)).toBe(true); + expect(gauge!.values.some((value: any) => Number(value.value) === 5)).toBe(true); }); it('skips delete when lock is held by another instance and still updates the gauge', async () => { diff --git a/src/services/revenueSettlementService.ts b/src/services/revenueSettlementService.ts index 8e90590..15b77a3 100644 --- a/src/services/revenueSettlementService.ts +++ b/src/services/revenueSettlementService.ts @@ -411,6 +411,21 @@ export class RevenueSettlementService { ); } + private async emitSettlementCompleted( + settlementId: string, + developerId: string, + amount: number, + txHash: string, + ): Promise { + calloraEvents.emit('settlement_completed', developerId, { + settlementId, + amount: amount.toFixed(7), + asset: 'USDC', + txHash, + settledAt: new Date().toISOString(), + }); + } + private async recordFailedSettlement( settlementId: string, developerId: string, @@ -426,13 +441,13 @@ export class RevenueSettlementService { } catch (statusError) { console.error( `Settlement ${settlementId} failed for dev ${developerId} and could not persist failure status:`, - this.getErrorMessage(statusError) + this.getErrorMessage(statusError), ); } console.error( `Settlement ${settlementId} failed for dev ${developerId}:`, - errorMessage ?? 'Unknown settlement failure' + errorMessage ?? 'Unknown settlement failure', ); } diff --git a/src/services/settlementStore.test.ts b/src/services/settlementStore.test.ts index 259f7aa..87ed66c 100644 --- a/src/services/settlementStore.test.ts +++ b/src/services/settlementStore.test.ts @@ -11,7 +11,7 @@ import assert from 'node:assert/strict'; import type { Pool, PoolClient, QueryResult } from 'pg'; -import { PostgresSettlementStore, InMemorySettlementStore } from './settlementStore'; +import { PostgresSettlementStore, InMemorySettlementStore } from './settlementStore.js'; import type { Settlement } from '../types/developer.js'; // --------------------------------------------------------------------------- @@ -38,9 +38,9 @@ function createMockClient(queryResults: (QueryResult | Error)[]): PoolClient { function createMockPool(client: PoolClient): Pool { return { connect: async () => client, - query: async (_sql: string | unknown, _params?: unknown[]) => { + query: async (sql: string, params?: unknown[]) => { // Delegate to the mock client's query method - return client.query(_sql, _params); + return client.query(sql, params); }, } as unknown as Pool; } @@ -394,10 +394,10 @@ describe('PostgresSettlementStore', () => { makeQr([]), ]); // Override query to capture params - client.query = async (_sql: string | unknown, params?: unknown[]) => { + client.query = (async (_sql: string, params?: unknown[]) => { capturedParams = params ?? []; return makeQr([]); - }; + }) as PoolClient['query']; pool = createMockPool(client); store = new PostgresSettlementStore(pool); @@ -419,10 +419,10 @@ describe('PostgresSettlementStore', () => { client = createMockClient([ makeQr([]), ]); - client.query = async (_sql: string | unknown, params?: unknown[]) => { + client.query = (async (_sql: string, params?: unknown[]) => { capturedParams = params ?? []; return makeQr([]); - }; + }) as PoolClient['query']; pool = createMockPool(client); store = new PostgresSettlementStore(pool); diff --git a/src/workers/monthlyInvoiceJob.ts b/src/workers/monthlyInvoiceJob.ts index 5d16a75..63af48d 100644 --- a/src/workers/monthlyInvoiceJob.ts +++ b/src/workers/monthlyInvoiceJob.ts @@ -1,4 +1,4 @@ -import { InvoiceService } from "../services/invoiceService.js"; +import { InvoiceService } from "../services/InvoiceService.js"; export class MonthlyInvoiceJob { constructor( diff --git a/src/workers/slowQueryAlerter.test.ts b/src/workers/slowQueryAlerter.test.ts index e9fced9..02fd2fd 100644 --- a/src/workers/slowQueryAlerter.test.ts +++ b/src/workers/slowQueryAlerter.test.ts @@ -407,19 +407,19 @@ describe('slowQueryAlerter', () => { (m: any) => m.name === 'slow_query_alerter_runs_total', ); expect(runsMetric).toBeDefined(); - expect(runsMetric.values[0].value).toBe(1); + expect(runsMetric!.values[0].value).toBe(1); const alertsMetric = metrics.find( (m: any) => m.name === 'slow_query_alerter_alerts_total', ); expect(alertsMetric).toBeDefined(); - expect(alertsMetric.values[0].value).toBe(1); + expect(alertsMetric!.values[0].value).toBe(1); const gaugeMetric = metrics.find( (m: any) => m.name === 'slow_query_alerter_queries_above_threshold', ); expect(gaugeMetric).toBeDefined(); - expect(gaugeMetric.values[0].value).toBe(2); + expect(gaugeMetric!.values[0].value).toBe(2); job.stop(); }); diff --git a/tests/chaos/sorobanLatency.test.ts b/tests/chaos/sorobanLatency.test.ts index 6578e6b..538c7fa 100644 --- a/tests/chaos/sorobanLatency.test.ts +++ b/tests/chaos/sorobanLatency.test.ts @@ -1,4 +1,4 @@ -import { injectLatency, withSorobanLatencyWrapper } from './sorobanLatency'; +import { injectLatency, withSorobanLatencyWrapper } from './sorobanLatency.js'; describe('Soroban Latency Chaos Harness', () => { beforeEach(() => { diff --git a/tests/contract/billing.test.ts b/tests/contract/billing.test.ts index 1af00a6..2fc9c8a 100644 --- a/tests/contract/billing.test.ts +++ b/tests/contract/billing.test.ts @@ -1,5 +1,5 @@ import request from 'supertest'; -import { createApp } from '../../src/app'; +import { createApp } from '../../src/app.js'; describe('POST /api/billing/deduct OpenAPI Contract', () => { const app = createApp(); diff --git a/tests/integration/billing.test.ts b/tests/integration/billing.test.ts index a508d16..bbd24cd 100644 --- a/tests/integration/billing.test.ts +++ b/tests/integration/billing.test.ts @@ -1431,6 +1431,14 @@ class DatabaseSettlementStore implements SettlementStore { completed_at: null, })); } + + scheduleRetry(settlementId: string, retryAfter: string): void { + this.db.public.none(` + UPDATE settlements + SET status = 'retryable', retry_after = '${escapeSqlLiteral(retryAfter)}' + WHERE id = '${escapeSqlLiteral(settlementId)}' + `); + } } class DatabaseApiRegistry implements ApiRegistry { diff --git a/tests/integration/protected.test.ts b/tests/integration/protected.test.ts index a80161d..864bf7b 100644 --- a/tests/integration/protected.test.ts +++ b/tests/integration/protected.test.ts @@ -149,6 +149,7 @@ const testDeveloper: Developer = { website: null, description: null, category: null, + plan_overrides: null, created_at: new Date(0), updated_at: new Date(0), }; @@ -189,6 +190,7 @@ class StubApiRepository implements ApiRepository { status: 'draft' as const, created_at: new Date(), updated_at: new Date(), + deleted_at: null, }; } async update() { @@ -206,6 +208,7 @@ class StubApiRepository implements ApiRepository { status: input.status ?? 'draft', created_at: new Date(), updated_at: new Date(), + deleted_at: null, endpoints: [], }; } @@ -224,6 +227,12 @@ class StubApiRepository implements ApiRepository { async delete(_id: number) { return false; } + async restore() { + return null; + } + async bulkCreateEndpoints() { + return []; + } } /**