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
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ EMAIL_FROM_ADDRESS=notifications@yieldvault.finance
SLO_READ_THRESHOLD_MS=200
SLO_WRITE_THRESHOLD_MS=500

# Prometheus /metrics endpoint scrape credential
# When set, GET /metrics requires Authorization: Bearer <token> or ApiKey <token>
# METRICS_AUTH_TOKEN=your-scrape-token
# METRICS_SCRAPE_TOKEN=your-scrape-token
# PROMETHEUS_BEARER_TOKEN=your-scrape-token

# Evaluation window for P95 calculation (5 minutes = 300000 ms)
SLO_EVALUATION_WINDOW_MS=300000

Expand Down
17 changes: 17 additions & 0 deletions backend/docs/OPERATIONAL_METRICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,24 @@ LATENCY_UNHEALTHY_THRESHOLD=5000

# Failure rate threshold for "unhealthy" (percent)
FAILURE_RATE_UNHEALTHY_THRESHOLD=10

# Prometheus scrape credential for GET /metrics (optional; default open)
METRICS_AUTH_TOKEN=your-scrape-token
```

### Scrape Authentication

When `METRICS_AUTH_TOKEN` (or `METRICS_SCRAPE_TOKEN` or `PROMETHEUS_BEARER_TOKEN`) is set in the environment, the `GET /metrics` endpoint requires authentication:

```
Authorization: Bearer <METRICS_AUTH_TOKEN>
```
or
```
Authorization: ApiKey <METRICS_AUTH_TOKEN>
```
Requests without a valid credential will receive a `401 Unauthorized` plain-text response.


## Data Privacy

Expand Down
6 changes: 3 additions & 3 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ module.exports = {
},
// Preload script to block problematic modules
setupFiles: ['<rootDir>/src/__tests__/preload.js'],
// Override module resolution to prevent @prisma/instrumentation from loading
moduleDirectories: ['node_modules', '<rootDir>/node_modules'],
moduleNameMapper: {
'^@yieldvault/api-schemas$': '<rootDir>/../packages/api-schemas/src/index.ts',
'^@yieldvault/api-schemas/(.*)$': '<rootDir>/../packages/api-schemas/src/$1',
'^@yieldvault/api-schemas$': '<rootDir>/../packages/api-schemas/dist/index.js',
'^@yieldvault/api-schemas/(.*)$': '<rootDir>/../packages/api-schemas/dist/$1',
'^@prisma/instrumentation$': '<rootDir>/src/__tests__/mocks/prismainstrumentation.js',
'^@opentelemetry/(.*)$': '<rootDir>/src/__tests__/mocks/opentelemetry.js',
// Redirect the sorobanClient module that vaultEndpoints imports (./sorobanClient)
Expand Down
7 changes: 7 additions & 0 deletions backend/src/__tests__/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@ process.env.RATE_LIMIT_WRITES_MAX = process.env.RATE_LIMIT_WRITES_MAX || '100000
process.env.RATE_LIMIT_READS_MAX = process.env.RATE_LIMIT_READS_MAX || '100000';
process.env.RATE_LIMIT_ADMIN_MAX = process.env.RATE_LIMIT_ADMIN_MAX || '100000';
process.env.API_RATE_LIMIT_MAX_REQUESTS = process.env.API_RATE_LIMIT_MAX_REQUESTS || '100000';

const mime = require('mime');
if (typeof mime.getType !== 'function') {
mime.getType = function(path) {
return mime.lookup(path);
};
}
14 changes: 14 additions & 0 deletions backend/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,5 +139,19 @@ export async function ensurePaginationFixtureTransactions(
timestamp: new Date(now - i * 3600000),
},
});

const validWalletTxId = `tx-valid-${prefix}-${i + 1}`;
await prisma.transaction.upsert({
where: { id: validWalletTxId },
update: {},
create: {
id: validWalletTxId,
user: VALID_TEST_WALLET,
amount: ((i + 1) * 15).toFixed(2),
type: i % 2 === 0 ? 'deposit' : 'withdrawal',
status: 'completed',
timestamp: new Date(now - i * 3600000),
},
});
}
}
4 changes: 2 additions & 2 deletions backend/src/apiContractSnapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export const BulkExportJobSchema = z
status: z.enum(['pending', 'processing', 'completed', 'failed', 'cancelled']),
format: z.enum(['csv', 'json']),
generatedBy: z.string(),
filters: z.record(z.unknown()),
filters: z.record(z.string(), z.unknown()),
totalRows: z.number(),
processedRows: z.number(),
errorRows: z.number(),
Expand Down Expand Up @@ -266,7 +266,7 @@ export const ExportManifestSchema = z
id: z.string(),
requester: z.string(),
reportType: z.string(),
filters: z.record(z.unknown()),
filters: z.record(z.string(), z.unknown()),
checksum: z.string(),
generatedAt: z.string(),
fileName: z.string(),
Expand Down
97 changes: 47 additions & 50 deletions backend/src/auditLogEndpoints.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,33 @@
import { Router, Request, Response } from 'express';
import { prisma } from './prisma';
import { validateApiKey } from './middleware/apiKeyAuth';

const router = Router();

router.use(validateApiKey);

export interface AuditLogEntry {
id: string;
timestamp: string;
operation: 'deposit' | 'withdraw' | 'strategy_switch' | 'parameter_change';
operation: string;
actor: string;
details: Record<string, unknown>;
}

function generateMockAuditLogs(): AuditLogEntry[] {
const now = Date.now();
return [
{
id: 'audit-1',
timestamp: new Date(now - 3_600_000).toISOString(),
operation: 'deposit',
actor: 'GABC...1234',
details: { amount: 1000, shares: 990, token: 'USDC' },
},
{
id: 'audit-2',
timestamp: new Date(now - 7_200_000).toISOString(),
operation: 'strategy_switch',
actor: 'GDEF...5678',
details: { from: 'BENJI', to: 'Korean Sovereign' },
},
{
id: 'audit-3',
timestamp: new Date(now - 10_800_000).toISOString(),
operation: 'withdraw',
actor: 'GABC...1234',
details: { shares: 500, assets: 505, token: 'USDC' },
},
{
id: 'audit-4',
timestamp: new Date(now - 14_400_000).toISOString(),
operation: 'parameter_change',
actor: 'GDEF...5678',
details: { parameter: 'min_deposit', oldValue: 100, newValue: 50 },
},
];
}

/**
* @openapi
* /api/v1/audit-logs:
* get:
* summary: Audit log
* description: Returns paginated audit log entries for vault state changes.
* tags: [Audit]
* security:
* - ApiKeyAuth: []
* parameters:
* - in: query
* name: operation
* schema:
* type: string
* enum: [deposit, withdraw, strategy_switch, parameter_change]
* - in: query
* name: actor
* schema:
Expand All @@ -69,36 +40,62 @@ function generateMockAuditLogs(): AuditLogEntry[] {
* responses:
* 200:
* description: Audit log entries
* 401:
* description: Unauthorized
*/
router.get('/', (req: Request, res: Response) => {
const { operation, actor, limit: limitStr } = req.query;
router.get('/', async (req: Request, res: Response) => {
const { operation, action, actor, limit: limitStr } = req.query;
const limit = Math.min(parseInt(limitStr as string, 10) || 20, 100);

let entries = generateMockAuditLogs();
const actionFilter =
(typeof operation === 'string' && operation) ||
(typeof action === 'string' && action) ||
undefined;
const actorFilter = typeof actor === 'string' && actor ? actor : undefined;

if (typeof operation === 'string' && operation) {
entries = entries.filter((e) => e.operation === operation);
}
const where = {
...(actionFilter ? { action: { contains: actionFilter, mode: 'insensitive' as const } } : {}),
...(actorFilter ? { actor: { contains: actorFilter, mode: 'insensitive' as const } } : {}),
};

if (typeof actor === 'string' && actor) {
entries = entries.filter((e) =>
e.actor.toLowerCase().includes(actor.toLowerCase()),
);
}
const [total, rows] = await Promise.all([
prisma.adminAuditLog.count({ where }),
prisma.adminAuditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
}),
]);

const data = entries.slice(0, limit);
const data: AuditLogEntry[] = rows.map((row) => {
let details: Record<string, unknown> = {};
if (row.metadata) {
try {
details = JSON.parse(row.metadata);
} catch {
details = { raw: row.metadata };
}
}
return {
id: row.id,
timestamp: row.createdAt.toISOString(),
operation: row.action,
actor: row.actor,
details,
};
});

res.json({
data,
pagination: {
count: data.length,
limit,
total: entries.length,
total,
nextCursor: null,
prevCursor: null,
currentPage: null,
totalPages: null,
hasNextPage: data.length < entries.length,
hasNextPage: data.length < total,
hasPrevPage: false,
},
timestamp: new Date().toISOString(),
Expand Down
4 changes: 2 additions & 2 deletions backend/src/criticalEntityLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export async function restoreApiKey(
if (!key.deletedAt) {
return { entityType: 'api_key' as const, entityId: apiKeyId, status: 'not_deleted' as const };
}
if (key.tenant.deletedAt) {
if (key.tenant?.deletedAt) {
return {
entityType: 'api_key' as const,
entityId: apiKeyId,
Expand Down Expand Up @@ -273,7 +273,7 @@ export async function restoreApiKey(
let status: CriticalEntityLifecycleStatus = 'not_deleted';
if (!current) {
status = 'not_found';
} else if (current.deletedAt && current.tenant.deletedAt) {
} else if (current.deletedAt && current.tenant?.deletedAt) {
status = 'parent_deleted';
}
return {
Expand Down
4 changes: 2 additions & 2 deletions backend/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,10 @@ export class NoopDatabasePool implements IDatabasePool {
*/
export class DatabaseManager {
private readonly primaryPool: IDatabasePool;
private readonly replicaPool: IDAtabasePool;
private readonly replicaPool: IDatabasePool;
private readonly poolsAreShared: boolean;

constructor(primaryPool?: IDatabasePool, replicaPool?: IDAtabasePool) {
constructor(primaryPool?: IDatabasePool, replicaPool?: IDatabasePool) {
if (primaryPool) {
this.primaryPool = primaryPool;
this.replicaPool = replicaPool || primaryPool;
Expand Down
Loading
Loading