diff --git a/jest.config.js b/jest.config.cjs similarity index 91% rename from jest.config.js rename to jest.config.cjs index 426351b..9eb6235 100644 --- a/jest.config.js +++ b/jest.config.cjs @@ -2,6 +2,7 @@ export default { preset: 'ts-jest/presets/default-esm', testEnvironment: 'node', extensionsToTreatAsEsm: ['.ts'], + testMatch: ['**/?(*.)+(spec|test).ts'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', }, @@ -26,5 +27,4 @@ export default { statements: 95, }, }, - testMatch: ['**/tests/integration/**/*.test.ts'], }; diff --git a/src/app.ts b/src/app.ts index 15787f3..5847c27 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,6 +13,8 @@ import { apiStatusEnum, type ApiStatus } from './db/schema.js'; import { requireAuth, type AuthenticatedLocals } from './middleware/requireAuth.js'; import { buildDeveloperAnalytics } from './services/developerAnalytics.js'; import { errorHandler } from './middleware/errorHandler.js'; +import adminRouter from './routes/admin.js'; +import { parsePagination, paginatedResponse } from './lib/pagination.js'; import { InMemoryVaultRepository, type VaultRepository } from './repositories/vaultRepository.js'; import { DepositController } from './controllers/depositController.js'; import { TransactionBuilderService } from './services/transactionBuilder.js'; @@ -83,6 +85,7 @@ export const createApp = (dependencies?: Partial) => { } app.use(requestLogger); + const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS ?? 'http://localhost:5173') .split(',') .map((o) => o.trim()); @@ -109,8 +112,9 @@ export const createApp = (dependencies?: Partial) => { res.json({ status: 'ok', service: 'callora-backend' }); }); - app.get('/api/apis', (_req, res) => { - res.json({ apis: [] }); + app.get('/api/apis', (req, res) => { + const { limit, offset } = parsePagination(req.query as { limit?: string; offset?: string }); + res.json(paginatedResponse([], { limit, offset })); }); app.get('/api/apis/:id', async (req, res) => { @@ -149,8 +153,9 @@ export const createApp = (dependencies?: Partial) => { }); }); - app.get('/api/usage', (_req, res) => { - res.json({ calls: 0, period: 'current' }); + app.get('/api/usage', (req, res) => { + const { limit, offset } = parsePagination(req.query as { limit?: string; offset?: string }); + res.json(paginatedResponse([], { limit, offset })); }); app.get('/api/developers/apis', requireAuth, async (req, res: express.Response) => { diff --git a/src/controllers/depositController.ts b/src/controllers/depositController.ts index e860556..a23e304 100644 --- a/src/controllers/depositController.ts +++ b/src/controllers/depositController.ts @@ -140,6 +140,11 @@ export class DepositController { network: unsignedTx.network, contractId: vault.contractId, amount: validation.normalizedAmount!, + operation: { + type: unsignedTx.operation.type, + function: unsignedTx.operation.function as 'deposit', + args: unsignedTx.operation.args, + }, operation: unsignedTx.operation as DepositPrepareResponse['operation'], metadata: { fee: unsignedTx.fee, diff --git a/src/events/event.emitter.ts b/src/events/event.emitter.ts index 401e710..294d04b 100644 --- a/src/events/event.emitter.ts +++ b/src/events/event.emitter.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'events'; import { + WebhookConfig, WebhookEventType, WebhookPayload, NewApiCallData, @@ -24,7 +25,7 @@ async function handleEvent( }; const configs = WebhookStore.getByEvent(event).filter( - (cfg) => cfg.developerId === developerId + (cfg: WebhookConfig) => cfg.developerId === developerId ); if (configs.length > 0) { diff --git a/src/index.ts b/src/index.ts index d69e64f..d65c616 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,11 +9,8 @@ const PORT = process.env.PORT ?? 3000; // Inject the metrics middleware globally to track all incoming requests app.use(metricsMiddleware); - -// Register the securely guarded metrics endpoint app.get('/api/metrics', metricsEndpoint); -// Execute the server only if this file is run directly if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { app.listen(PORT, () => { logger.info(`Callora backend listening on http://localhost:${PORT}`); diff --git a/src/lib/__tests__/pagination.test.ts b/src/lib/__tests__/pagination.test.ts new file mode 100644 index 0000000..388277f --- /dev/null +++ b/src/lib/__tests__/pagination.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { parsePagination, paginatedResponse } from '../pagination.js'; + +describe('parsePagination', () => { + it('returns defaults when no query params given', () => { + assert.deepEqual(parsePagination({}), { limit: 20, offset: 0 }); + }); + + it('parses valid limit and offset', () => { + assert.deepEqual(parsePagination({ limit: '10', offset: '30' }), { limit: 10, offset: 30 }); + }); + + it('clamps limit to max 100', () => { + assert.deepEqual(parsePagination({ limit: '500' }), { limit: 100, offset: 0 }); + }); + + it('clamps limit to min 1', () => { + assert.deepEqual(parsePagination({ limit: '0' }), { limit: 1, offset: 0 }); + assert.deepEqual(parsePagination({ limit: '-5' }), { limit: 1, offset: 0 }); + }); + + it('clamps offset to min 0', () => { + assert.deepEqual(parsePagination({ offset: '-10' }), { limit: 20, offset: 0 }); + }); + + it('handles non-numeric strings gracefully', () => { + assert.deepEqual(parsePagination({ limit: 'abc', offset: 'xyz' }), { limit: 20, offset: 0 }); + }); +}); + +describe('paginatedResponse', () => { + it('wraps data and meta into the envelope', () => { + const result = paginatedResponse([{ id: '1' }], { total: 1, limit: 20, offset: 0 }); + assert.deepEqual(result, { + data: [{ id: '1' }], + meta: { total: 1, limit: 20, offset: 0 }, + }); + }); + + it('works without total in meta', () => { + const result = paginatedResponse([], { limit: 20, offset: 0 }); + assert.deepEqual(result, { + data: [], + meta: { limit: 20, offset: 0 }, + }); + assert.equal('total' in result.meta, false); + }); +}); diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts new file mode 100644 index 0000000..fcd34d8 --- /dev/null +++ b/src/lib/pagination.ts @@ -0,0 +1,41 @@ +export interface PaginationParams { + limit: number; + offset: number; +} + +export interface PaginationMeta { + total?: number; + limit: number; + offset: number; +} + +export interface PaginatedResponse { + data: T[]; + meta: PaginationMeta; +} + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; + +export function parsePagination(query: { + limit?: string; + offset?: string; +}): PaginationParams { + const parsedLimit = parseInt(query.limit ?? '', 10); + const limit = Math.min( + MAX_LIMIT, + Math.max(1, Number.isNaN(parsedLimit) ? DEFAULT_LIMIT : parsedLimit), + ); + + const parsedOffset = parseInt(query.offset ?? '', 10); + const offset = Math.max(0, Number.isNaN(parsedOffset) ? 0 : parsedOffset); + + return { limit, offset }; +} + +export function paginatedResponse( + data: T[], + meta: PaginationMeta, +): PaginatedResponse { + return { data, meta }; +} diff --git a/src/repositories/apiRepository.drizzle.ts b/src/repositories/apiRepository.drizzle.ts index d095ba2..daf35ab 100644 --- a/src/repositories/apiRepository.drizzle.ts +++ b/src/repositories/apiRepository.drizzle.ts @@ -1,10 +1,23 @@ -import { eq, and } from 'drizzle-orm'; +import { eq, and, type SQL } from 'drizzle-orm'; import { db, schema } from '../db/index.js'; import type { Api } from '../db/schema.js'; import type { ApiDetails, ApiEndpointInfo, ApiListFilters, ApiRepository } from './apiRepository.js'; export class DrizzleApiRepository implements ApiRepository { async listByDeveloper(developerId: number, filters: ApiListFilters = {}): Promise { + const conditions: SQL[] = [eq(schema.apis.developer_id, developerId)]; + if (filters.status) { + conditions.push(eq(schema.apis.status, filters.status)); + } + const results = await db.select().from(schema.apis).where(and(...conditions)); + let rows = results as Api[]; + if (typeof filters.offset === 'number') { + rows = rows.slice(filters.offset); + } + if (typeof filters.limit === 'number') { + rows = rows.slice(0, filters.limit); + } + return rows; const conditions = [eq(schema.apis.developer_id, developerId)]; if (filters.status) { conditions.push(eq(schema.apis.status, filters.status)); diff --git a/src/repositories/apiRepository.ts b/src/repositories/apiRepository.ts index f9a0e70..e31250f 100644 --- a/src/repositories/apiRepository.ts +++ b/src/repositories/apiRepository.ts @@ -1,3 +1,4 @@ +import { eq, and, type SQL } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm'; import { db, schema } from '../db/index.js'; import type { Api, ApiStatus } from '../db/schema.js'; @@ -40,11 +41,25 @@ export interface ApiRepository { export const defaultApiRepository: ApiRepository = { async listByDeveloper(developerId, filters = {}) { + const conditions: SQL[] = [eq(schema.apis.developer_id, developerId)]; const conditions = [eq(schema.apis.developer_id, developerId)]; if (filters.status) { conditions.push(eq(schema.apis.status, filters.status)); } + const results = await db + .select() + .from(schema.apis) + .where(and(...conditions)); + + let rows = results as Api[]; + if (typeof filters.offset === 'number') { + rows = rows.slice(filters.offset); + } + if (typeof filters.limit === 'number') { + rows = rows.slice(0, filters.limit); + } + return rows; let query = db.select().from(schema.apis).where(and(...conditions)); if (typeof filters.limit === 'number') { diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index 7bfa845..0830773 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -1,5 +1,15 @@ import prisma from '../lib/prisma.js'; import type { User } from '../generated/prisma/client.js'; +import type { PaginationParams } from '../lib/pagination.js'; + +export type UserListItem = Pick; + +interface FindUsersResult { + users: UserListItem[]; + total: number; +} + +export async function findUsers(params: PaginationParams): Promise { interface PaginatedUsers { users: Pick[]; @@ -20,12 +30,15 @@ export async function findUsers(page: number, limit: number): Promise { try { + const { limit, offset } = parsePagination(req.query as { limit?: string; offset?: string }); + const { users, total } = await findUsers({ limit, offset }); + res.json(paginatedResponse(users, { total, limit, offset })); const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20)); diff --git a/src/webhooks/webhook.routes.ts b/src/webhooks/webhook.routes.ts index dd8e311..938bc52 100644 --- a/src/webhooks/webhook.routes.ts +++ b/src/webhooks/webhook.routes.ts @@ -30,7 +30,7 @@ router.post('/', async (req: Request, res: Response) => { try { await validateWebhookUrl(url); - } catch (err) { + } catch (err: unknown) { if (err instanceof WebhookValidationError) { return res.status(400).json({ error: err instanceof Error ? err.message : String(err) }); } @@ -60,6 +60,8 @@ router.get('/:developerId', (req: Request, res: Response) => { return res.status(404).json({ error: 'No webhook registered for this developer.' }); } // Never expose the secret + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { secret: _s, ...safeConfig } = config; const { secret: _s, ...safeConfig } = config; // eslint-disable-line @typescript-eslint/no-unused-vars return res.json(safeConfig); });