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
2 changes: 1 addition & 1 deletion jest.config.js → jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand All @@ -26,5 +27,4 @@ export default {
statements: 95,
},
},
testMatch: ['**/tests/integration/**/*.test.ts'],
};
13 changes: 9 additions & 4 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -83,6 +85,7 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
}

app.use(requestLogger);

const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS ?? 'http://localhost:5173')
.split(',')
.map((o) => o.trim());
Expand All @@ -109,8 +112,9 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
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) => {
Expand Down Expand Up @@ -149,8 +153,9 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
});
});

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<unknown, AuthenticatedLocals>) => {
Expand Down
5 changes: 5 additions & 0 deletions src/controllers/depositController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/events/event.emitter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EventEmitter } from 'events';
import {
WebhookConfig,
WebhookEventType,
WebhookPayload,
NewApiCallData,
Expand All @@ -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) {
Expand Down
3 changes: 0 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
49 changes: 49 additions & 0 deletions src/lib/__tests__/pagination.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
41 changes: 41 additions & 0 deletions src/lib/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export interface PaginationParams {
limit: number;
offset: number;
}

export interface PaginationMeta {
total?: number;
limit: number;
offset: number;
}

export interface PaginatedResponse<T> {
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<T>(
data: T[],
meta: PaginationMeta,
): PaginatedResponse<T> {
return { data, meta };
}
15 changes: 14 additions & 1 deletion src/repositories/apiRepository.drizzle.ts
Original file line number Diff line number Diff line change
@@ -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<Api[]> {
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));
Expand Down
15 changes: 15 additions & 0 deletions src/repositories/apiRepository.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -40,11 +41,25 @@

export const defaultApiRepository: ApiRepository = {
async listByDeveloper(developerId, filters = {}) {
const conditions: SQL[] = [eq(schema.apis.developer_id, developerId)];

Check failure on line 44 in src/repositories/apiRepository.ts

View workflow job for this annotation

GitHub Actions / build (20)

This assigned value is not used in subsequent statements
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') {
Expand Down
13 changes: 13 additions & 0 deletions src/repositories/userRepository.ts
Original file line number Diff line number Diff line change
@@ -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<User, 'id' | 'stellar_address' | 'created_at'>;

interface FindUsersResult {
users: UserListItem[];
total: number;
}

export async function findUsers(params: PaginationParams): Promise<FindUsersResult> {

interface PaginatedUsers {
users: Pick<User, 'id' | 'stellar_address' | 'created_at'>[];
Expand All @@ -20,12 +30,15 @@ export async function findUsers(page: number, limit: number): Promise<PaginatedU
created_at: true,
},
orderBy: { created_at: 'desc' },
skip: params.offset,
take: params.limit,
skip,
take: limit,
}),
prisma.user.count(),
]);

return { users, total };
return {
users,
total,
Expand Down
4 changes: 4 additions & 0 deletions src/routes/admin.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { Router } from 'express';
import { adminAuth } from '../middleware/adminAuth.js';
import { findUsers } from '../repositories/userRepository.js';
import { parsePagination, paginatedResponse } from '../lib/pagination.js';

const router = Router();

router.use(adminAuth);

router.get('/users', async (req, res) => {
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));

Expand Down
4 changes: 3 additions & 1 deletion src/webhooks/webhook.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
}
Expand Down Expand Up @@ -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);
});
Expand Down
Loading