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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ CORS_ALLOWED_ORIGINS=http://localhost:5173
SOROBAN_RPC_ENABLED=false
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
SOROBAN_RPC_TIMEOUT=2000
SOROBAN_BILLING_RPC_URL=https://soroban-testnet.stellar.org
SOROBAN_BILLING_CONTRACT_ID=your-vault-contract-id
SOROBAN_BILLING_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
SOROBAN_BILLING_SOURCE_ACCOUNT=your-backend-source-account
SOROBAN_BILLING_BACKEND_SECRET_KEY=your-backend-secret-key
SOROBAN_BILLING_BALANCE_FN=balance
SOROBAN_BILLING_DEDUCT_FN=deduct
SOROBAN_BILLING_RPC_TIMEOUT_MS=5000

# -----------------------------------------------------------------------------
# Horizon (optional — set HORIZON_ENABLED=true to activate)
Expand Down
25 changes: 5 additions & 20 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,9 @@ export const config = {
idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS,
connectionTimeoutMillis: env.DB_CONN_TIMEOUT_MS,
},

jwt: {
secret: env.JWT_SECRET,
},

metrics: {
apiKey: env.METRICS_API_KEY,
},
Expand Down
2 changes: 1 addition & 1 deletion src/controllers/vaultController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ describe('VaultController - getBalance', () => {
expect(response.status).toBe(200);

// Validate response structure
expect(response.body).toBeObject();
expect(response.body).toEqual(expect.any(Object));
expect(response.body).toHaveProperty('balance_usdc');
expect(response.body).toHaveProperty('contractId');
expect(response.body).toHaveProperty('network');
Expand Down
56 changes: 55 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,60 @@ import { config } from './config/index.js';
// Helper for Jest/CommonJS compat
const isDirectExecution = process.argv[1] && (process.argv[1].endsWith('index.ts') || process.argv[1].endsWith('index.js'));

interface GracefulShutdownOptions {
server: Server;
activeConnections: Set<Socket>;
closeDatabase: () => Promise<void>;
logger?: Pick<typeof console, 'log' | 'warn' | 'error'>;
timeoutMs?: number;
}

export function createGracefulShutdownHandler({
server,
activeConnections,
closeDatabase,
logger = console,
timeoutMs = 10_000,
}: GracefulShutdownOptions) {
let inFlight: Promise<number> | null = null;

return (signal: NodeJS.Signals): Promise<number> => {
if (inFlight) {
return inFlight;
}

inFlight = new Promise<number>((resolve) => {
logger.log(`Received ${signal}, shutting down gracefully`);

const timeout = setTimeout(() => {
for (const socket of activeConnections) {
socket.destroy();
}
}, timeoutMs);

server.close(async (error?: Error) => {
clearTimeout(timeout);

if (error) {
logger.error('Error while closing HTTP server', error);
resolve(1);
return;
}

try {
await closeDatabase();
resolve(0);
} catch (closeError) {
logger.error('Error while closing data resources', closeError);
resolve(1);
}
});
});

return inFlight;
};
}

export const app = express();

app.get('/api/health', (_req, res) => {
Expand Down Expand Up @@ -133,7 +187,7 @@ if (isDirectExecution) {
});

const onSignal = (signal: NodeJS.Signals) => {
void gracefulShutdown(signal).then((exitCode) => {
void gracefulShutdown(signal).then((exitCode: number) => {
process.exit(exitCode);
});
};
Expand Down
15 changes: 10 additions & 5 deletions src/lib/prisma.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { PrismaClient } from '../generated/prisma/client.js';
import { PrismaPg } from '@prisma/adapter-pg';

let prisma: PrismaClient;
type PrismaClientLike = {
$disconnect: () => Promise<void>;
[key: string]: unknown;
};

function getPrismaClient(): PrismaClient {
let prisma: PrismaClientLike | undefined;

function getPrismaClient(): PrismaClientLike {
if (!prisma) {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is required');
}
const adapter = new PrismaPg({ connectionString });
prisma = new PrismaClient({ adapter });
const { PrismaClient } = require('@prisma/client');

Check failure on line 17 in src/lib/prisma.ts

View workflow job for this annotation

GitHub Actions / build (20)

A `require()` style import is forbidden
prisma = new PrismaClient({ adapter }) as PrismaClientLike;
}
return prisma;
}
Expand All @@ -22,7 +27,7 @@
await prisma.$disconnect();
}

export default new Proxy({} as PrismaClient, {
export default new Proxy({} as PrismaClientLike, {
get(_target, prop, receiver) {
const client = getPrismaClient();
const value = Reflect.get(client, prop, receiver);
Expand Down
54 changes: 31 additions & 23 deletions src/middleware/ipAllowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,14 @@ export function createIpAllowlist(config: IpAllowlistConfig) {
}

// Log configuration for security audit
logger.info('IP allowlist middleware configured', {
allowedRangesCount: allowedRanges.length,
trustProxy,
proxyHeaders,
enabled,
});
logger.info(
`IP allowlist middleware configured ${JSON.stringify({
allowedRangesCount: allowedRanges.length,
trustProxy,
proxyHeaders,
enabled,
})}`
);

return (req: Request, res: Response, next: NextFunction): void => {
// Skip IP checking if allowlist is disabled
Expand All @@ -114,11 +116,13 @@ export function createIpAllowlist(config: IpAllowlistConfig) {

// Validate extracted IP format
if (!isValidIp(clientIp)) {
logger.warn('Invalid IP format detected', {
ip: clientIp,
userAgent: req.get('User-Agent'),
path: req.path,
});
logger.warn(
`Invalid IP format detected ${JSON.stringify({
ip: clientIp,
userAgent: req.get('User-Agent'),
path: req.path,
})}`
);

res.status(400).json({
error: 'Bad Request: invalid client IP format',
Expand All @@ -132,13 +136,15 @@ export function createIpAllowlist(config: IpAllowlistConfig) {

if (!isAllowed) {
// Log blocked attempt for security monitoring
logger.warn('IP allowlist blocked request', {
clientIp,
path: req.path,
method: req.method,
userAgent: req.get('User-Agent'),
timestamp: new Date().toISOString(),
});
logger.warn(
`IP allowlist blocked request ${JSON.stringify({
clientIp,
path: req.path,
method: req.method,
userAgent: req.get('User-Agent'),
timestamp: new Date().toISOString(),
})}`
);

res.status(403).json({
error: 'Forbidden: IP address not allowed',
Expand All @@ -148,11 +154,13 @@ export function createIpAllowlist(config: IpAllowlistConfig) {
}

// Log successful allowlist check for audit trail
logger.debug('IP allowlist check passed', {
clientIp,
path: req.path,
method: req.method,
});
logger.info(
`IP allowlist check passed ${JSON.stringify({
clientIp,
path: req.path,
method: req.method,
})}`
);

next();
};
Expand Down
Loading
Loading