Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions jest.env-setup.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Runs in each worker before any module is imported.
// Sets the minimum required env vars so env.ts doesn't call process.exit(1).
process.env.JWT_SECRET = process.env.JWT_SECRET || "test-jwt-secret";
process.env.ADMIN_API_KEY = process.env.ADMIN_API_KEY || "test-admin-key";
process.env.METRICS_API_KEY = process.env.METRICS_API_KEY || "test-metrics-key";
19,377 changes: 8,326 additions & 11,051 deletions package-lock.json

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
{
"name": "callora-backend",
"version": "0.0.1",
"type": "module",
"scripts": {
"build": "tsc",
"prebuild": "npm run error-codes:check && npm run validate:openapi",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"lint": "eslint .",
"db:generate": "drizzle-kit generate:sqlite",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"seed:dev": "tsx scripts/seed-dev.ts",
"typecheck": "tsc --noEmit",
"validate:issue-9": "node scripts/validate-issue-9.mjs",
"validate:openapi": "node scripts/validate-openapi-contract.mjs",
"db:check-migrations": "npx tsx scripts/check-migrations.ts",
"error-codes:generate": "node scripts/generate-error-codes.mjs",
"error-codes:check": "node scripts/generate-error-codes.mjs --check",
"pretest": "npm run error-codes:check",
"test": "jest --forceExit",
"test:serial": "jest --runInBand --forceExit",
"test:unit": "jest --runInBand --forceExit --testPathIgnorePatterns tests/integration",
"test:integration": "jest --runInBand --forceExit tests/integration",
"test:coverage": "jest --runInBand --coverage --forceExit --testPathIgnorePatterns tests/integration"
},
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@prisma/adapter-pg": "^7.4.1",
"@prisma/client": "^7.5.0",
"@stellar/stellar-sdk": "^14.5.0",
"axios": "^1.13.5",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^9.2.2",
"cors": "^2.8.6",
"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",
"pg": "^8.18.0",
"pino": "^10.3.1",
"prisma": "^7.4.1",
"prom-client": "^15.1.0",
"uuid": "^13.0.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/axios": "^0.9.36",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.8",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21",
"@types/helmet": "^0.0.48",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.10.0",
"@types/pg": "^8.16.0",
"@types/supertest": "^6.0.3",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"@useoptic/optic": "^1.0.9",
"drizzle-kit": "^0.20.7",
"eslint": "^10.0.2",
"fast-check": "^3.22.0",
"globals": "^17.3.0",
"jest": "^29.7.0",
"openapi-types": "^12.1.3",
"pg-mem": "^3.0.13",
"picomatch": "^2.3.1",
"supertest": "^7.2.2",
"testcontainers": "^10.10.4",
"ts-jest": "^29.4.6",
"tsx": "^4.7.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1"
},
"overrides": {
"ajv": "8.17.1"
}
}
20 changes: 20 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ import { createApiRouter } from './routes/index.js';
import { createApisRouter } from './routes/apis.js';
import { createWebhooksRouter } from './routes/webhooks.js';
import { createPluginsRouter } from './routes/marketplace/plugins.js';
import { createGatewayHealthRouter } from './routes/gatewayRoutes.js';
import {
HmacObjectStorageClient,
} from './services/scheduledExports.js';
import {
InMemoryExportStore,
ReportExporterService,
} from './services/reportExporter.js';
import { createLogsRouter } from './routes/logs.js';
import { pool } from './db.js';
import {
Expand Down Expand Up @@ -169,6 +177,16 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
dependencies?.vaultRepository ?? new InMemoryVaultRepository();
const lookupDeveloper = dependencies?.findDeveloperByUserId ?? findByUserId;
const persistApi = dependencies?.createApiWithEndpoints ?? createApi;
const reportExporterService = new ReportExporterService(
{ getEvents: async () => [] },
new HmacObjectStorageClient(),
new InMemoryExportStore(),
{
s3Bucket: "contract-exports",
s3Endpoint: "http://localhost",
s3SecretAccessKey: "contract-test-secret",
},
);

// Initialize deposit and vault controllers
const transactionBuilder = new TransactionBuilderService();
Expand Down Expand Up @@ -423,6 +441,7 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
);

app.use("/api/marketplace/plugins", createPluginsRouter());
app.use("/api/gateway", createGatewayHealthRouter());



Expand All @@ -439,6 +458,7 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
usageEventsRepository,
apiRepository,
developerRepository,
reportExporterService,
subscriptionRepository: defaultSubscriptionRepository,
}),
);
Expand Down
55 changes: 55 additions & 0 deletions src/middleware/adminAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { timingSafeEqual } from 'crypto';
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { InternalServerError, UnauthorizedError } from '../errors/index.js';

interface AdminJwtPayload {
role: string;
[key: string]: unknown;
}

/**
* Constant-time string comparison to prevent timing-based key enumeration.
* Returns false immediately if lengths differ (length is not secret here —
* the configured key length is not sensitive information).
*/
function timingSafeStringEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}

export function adminAuth(req: Request, res: Response, next: NextFunction): void {
// Path 1: API key header — use timing-safe comparison to prevent key enumeration
const apiKey = req.header('x-admin-api-key');
const configuredKey = process.env.ADMIN_API_KEY;
if (apiKey && configuredKey && timingSafeStringEqual(apiKey, configuredKey)) {
res.locals.adminActor = 'admin-api-key';
next();
return;
}

// Path 2: Bearer JWT with admin role
const authHeader = req.header('Authorization');
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
const secret = process.env.JWT_SECRET;

if (!secret) {
next(new InternalServerError('JWT_SECRET not configured'));
return;
}

try {
const payload = jwt.verify(token, secret) as AdminJwtPayload;
if (payload.role === 'admin') {
res.locals.adminActor = (payload.sub as string) || (payload.email as string) || 'admin-jwt';
next();
return;
}
} catch {
// Fall through to 401
}
}

next(new UnauthorizedError('Unauthorized: admin access required'));
}
44 changes: 44 additions & 0 deletions src/routes/gatewayRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,50 @@ function mapBreakerState(state: CircuitBreakerState): 'closed' | 'open' | 'half-
}
}

export function createGatewayHealthRouter(
deps: Pick<GatewayDeps, 'registry' | 'breakerRegistry'> = {},
): Router {
const router = Router();
const breakerRegistry = deps.breakerRegistry ?? getDefaultBreakerRegistry();

router.get('/health/:apiSlug', async (req: Request, res: Response, next: NextFunction) => {
try {
const { apiSlug } = req.params;
let apiId = apiSlug;
if (deps.registry) {
const entry = deps.registry.resolve(apiSlug);
if (!entry) {
next(new NotFoundError('API not found'));
return;
}
apiId = entry.id;
}

const cached = healthCache.get(apiSlug);
if (cached && Date.now() - cached.timestamp < HEALTH_CACHE_TTL_MS) {
res.json(cached.data);
return;
}

const rawLatency = await getUpstreamHealth(apiId);
const data = {
apiSlug,
latency: {
p50: rawLatency.p50 === null ? null : Math.round(rawLatency.p50 * 100000) / 100,
p95: rawLatency.p95 === null ? null : Math.round(rawLatency.p95 * 100000) / 100,
},
breaker: { state: mapBreakerState(await breakerRegistry.getState(apiSlug)) },
};
healthCache.set(apiSlug, { data, timestamp: Date.now() });
res.json(data);
} catch (error) {
next(error);
}
});

return router;
}

export function createGatewayRouter(deps: GatewayDeps): Router {
const { billing, rateLimiter, usageStore, upstreamUrl, registry } = deps;
const breakerRegistry = deps.breakerRegistry ?? getDefaultBreakerRegistry();
Expand Down
33 changes: 33 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ import { createLogsRouter } from "./logs.js";
import { createApiKeyRouter } from "./apiKeyRoutes.js";
import { defaultApiRepository } from "../repositories/apiRepository.js";
import { defaultDeveloperRepository } from "../repositories/developerRepository.js";
import { createDeveloperRouter } from "./developerRoutes.js";
import { InMemorySettlementStore } from "../services/settlementStore.js";
import { InMemoryUsageStore } from "../services/usageStore.js";
import { requireAuth, type AuthenticatedLocals } from "../middleware/requireAuth.js";
import { apiKeyRepository } from "../repositories/apiKeyRepository.js";

const openApiPath = path.join(process.cwd(), "docs/openapi.json");
const openApiSpec = JSON.parse(readFileSync(openApiPath, "utf8"));
Expand Down Expand Up @@ -78,6 +83,34 @@ export function createApiRouter(deps: ApiRouterDeps = {}): Router {
);
router.use("/spike", createSpikeRouter());
router.use("/errors", createErrorsRouter({ auditService: deps.auditService }));
router.use(
"/developers",
createDeveloperRouter({
settlementStore: new InMemorySettlementStore(),
usageStore: new InMemoryUsageStore(),
developerRepository: deps.developerRepository ?? defaultDeveloperRepository,
usageEventsRepository: deps.usageEventsRepository,
}),
);
router.get(
"/developers/me/keys",
requireAuth,
(req, res: import("express").Response<unknown, AuthenticatedLocals>) => {
const user = res.locals.authenticatedUser;
const keys = user
? apiKeyRepository.list({ userId: user.id }).map((key) => ({
id: key.id,
apiId: key.apiId,
prefix: key.prefix,
revoked: key.revoked,
scopes: key.scopes,
rateLimitPerMinute: key.rateLimitPerMinute,
createdAt: key.createdAt.toISOString(),
}))
: [];
res.json({ keys });
},
);
router.use("/audit", createAuditRouter({ auditService: deps.auditService }));
router.use("/invoices", createInvoicesRouter());

Expand Down
Loading