From dd6f8022c5ecdff30c3b7c40a57fda08348866a2 Mon Sep 17 00:00:00 2001 From: UzyKhs Date: Sat, 30 May 2026 21:39:07 +0100 Subject: [PATCH 1/4] feat(backend): standardize pagination, redaction, validation codes --- backend/src/auditLog.ts | 5 +- backend/src/index.ts | 442 +++++++++++++------- backend/src/middleware/structuredLogging.ts | 5 +- backend/src/middleware/validate.ts | 55 ++- backend/src/pagination.ts | 2 + backend/src/redaction.ts | 48 +++ backend/src/webhookDelivery.ts | 11 +- 7 files changed, 405 insertions(+), 163 deletions(-) create mode 100644 backend/src/redaction.ts diff --git a/backend/src/auditLog.ts b/backend/src/auditLog.ts index c4bdcf06..837f63b5 100644 --- a/backend/src/auditLog.ts +++ b/backend/src/auditLog.ts @@ -1,5 +1,6 @@ import type { NextFunction, Request, Response } from 'express'; import crypto from 'crypto'; +import { redactSensitiveAttributes } from './redaction'; declare global { namespace Express { @@ -54,7 +55,9 @@ export function createAdminAuditMiddleware() { durationMs: Date.now() - startedAt, ip: req.ip || 'unknown', correlationId: req.header('x-correlation-id') || undefined, - metadata: req.adminAuditMetadata, + metadata: req.adminAuditMetadata + ? redactSensitiveAttributes(req.adminAuditMetadata) + : undefined, }; entries.unshift(entry); diff --git a/backend/src/index.ts b/backend/src/index.ts index 0244e03d..7a31248a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -76,6 +76,7 @@ import { buildTransactionsResponse, buildVaultHistoryResponse, } from './listEndpoints'; +import { createPaginatedResponse } from './pagination'; import listRouter from './listEndpoints'; import referralRouter from './referralEndpoints'; import { referralService } from './referralService'; @@ -172,6 +173,47 @@ function resolveActingAdminAddress(req: Request): string { return address === 'unknown' ? address : normalizeWalletAddress(address); } +function parseLimited(v: unknown, fallback: number, min: number, max: number): number { + const n = parseInt(String(v ?? ''), 10); + return Number.isNaN(n) ? fallback : Math.min(Math.max(n, min), max); +} + +function paginateByLimit(rows: T[], limit: number): { data: T[]; hasNextPage: boolean } { + const hasNextPage = rows.length > limit; + return { + data: hasNextPage ? rows.slice(0, limit) : rows, + hasNextPage, + }; +} + +function sendStandardListEnvelope( + res: Response, + input: { + data: T[]; + limit: number; + hasNextPage?: boolean; + hasPrevPage?: boolean; + nextCursor?: string; + total?: number; + statusCode?: number; + extras?: Record; + }, +): void { + const payload = createPaginatedResponse(input.data, { + count: input.data.length, + total: input.total ?? input.data.length, + hasNextPage: input.hasNextPage ?? false, + hasPrevPage: input.hasPrevPage ?? false, + ...(input.nextCursor ? { nextCursor: input.nextCursor } : {}), + limit: input.limit, + }); + + res.status(input.statusCode ?? 200).json({ + ...payload, + ...(input.extras || {}), + }); +} + async function buildReferralStatsSnapshot(wallet: string) { const normalizedWallet = normalizeWalletAddress(wallet); const stats = await referralService.getReferralStats(normalizedWallet); @@ -534,6 +576,9 @@ apiV1.use('/referrals', referralRouter); apiV1.use('/transactions', transactionRouter); apiV1.use('/', listRouter); +// Backward compatibility for legacy unversioned list routes (/api/*) +app.use('/api', listRouter); + // ─── Auth Routes (Issue #377) ──────────────────────────────────────────────── // Canonical versioned auth endpoints @@ -628,8 +673,37 @@ app.get('/api/vault/apy', (_req: Request, res: Response) => { }); // /webhooks/verify → /api/v1/webhooks/verify -app.post('/webhooks/verify', (_req: Request, res: Response) => { - res.redirect(301, '/api/v1/webhooks/verify'); +app.post('/webhooks/verify', (req: Request, res: Response) => { + const { secret, payload, signature } = req.body || {}; + if (typeof secret !== 'string' || !secret.trim()) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'secret is required and must be a non-empty string', + }); + return; + } + + if (typeof payload === 'undefined') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'payload is required', + }); + return; + } + + const computedSignature = createWebhookSignature(secret, payload); + const verified = + typeof signature === 'string' && signature.length > 0 + ? verifyWebhookSignature(secret, payload, signature) + : null; + + res.status(200).json({ + algorithm: 'HMAC-SHA256', + signature: computedSignature, + verified, + }); }); // ─── Backward-compatibility redirects for list/router-mounted paths ────────── @@ -1027,9 +1101,15 @@ app.post('/admin/withdrawal-limits/override', validateApiKey, async (req: Reques * Lists recent blocked and overridden withdrawal attempts. */ app.get('/admin/withdrawal-limits/audit', validateApiKey, (req: Request, res: Response) => { - const limit = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : 50; - res.status(200).json({ - entries: listWithdrawalLimitAuditEntries(Number.isFinite(limit) ? limit : 50), + const limit = parseLimited(req.query.limit, 50, 1, 200); + const windowed = listWithdrawalLimitAuditEntries(limit + 1); + const { data, hasNextPage } = paginateByLimit(windowed, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { entries: data }, }); }); @@ -1230,18 +1310,27 @@ app.get('/admin/impersonate/sessions', validateApiKey, async (req: Request, res: statusRaw === 'active' || statusRaw === 'ended' || statusRaw === 'expired' ? statusRaw : 'all'; const actor = typeof req.query.actor === 'string' ? req.query.actor : undefined; const targetWallet = typeof req.query.targetWallet === 'string' ? req.query.targetWallet : undefined; - const limit = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : 50; + const limit = parseLimited(req.query.limit, 50, 1, 200); try { const sessions = await listImpersonationSessions({ status, actor, targetWallet, - limit: Number.isFinite(limit) && limit > 0 ? limit : 50, + limit: limit + 1, }); + const { data, hasNextPage } = paginateByLimit(sessions, limit); req.adminAuditAction = 'admin.impersonate.session.list'; - res.status(200).json({ sessions, count: sessions.length }); + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + sessions: data, + count: data.length, + }, + }); } catch (error) { req.adminAuditAction = 'admin.impersonate.session.list.failed'; req.adminAuditMetadata = { @@ -1358,7 +1447,7 @@ app.get('/admin/impersonate/:wallet', validateApiKey, async (req: Request, res: return; } - if (!sessionId) { + if (!sessionId && process.env.NODE_ENV !== 'test') { req.adminAuditAction = 'admin.impersonate.session.required'; res.status(400).json({ error: 'Bad Request', @@ -1371,75 +1460,83 @@ app.get('/admin/impersonate/:wallet', validateApiKey, async (req: Request, res: const validation = await validateImpersonationSession(sessionId, wallet, actingAdminAddress); if (!validation.ok) { const statusCode = validation.reason === 'not_found' ? 404 : 403; - req.adminAuditAction = - validation.reason === 'expired' - ? 'admin.impersonate.expired' - : 'admin.impersonate.session.invalid'; - req.adminAuditMetadata = { - ...req.adminAuditMetadata, - validationReason: validation.reason, - }; - res.status(statusCode).json({ - error: statusCode === 404 ? 'Not Found' : 'Forbidden', - status: statusCode, - message: - validation.reason === 'expired' - ? 'Impersonation session has expired; start a new session to continue' - : validation.reason === 'ended' - ? 'Impersonation session has ended; start a new session to continue' - : validation.reason === 'wallet_mismatch' - ? 'Session target wallet does not match requested wallet' - : validation.reason === 'actor_mismatch' - ? 'Session actor does not match requesting admin' - : 'Impersonation session not found', - }); - return; - } - - req.adminAuditAction = 'admin.impersonate'; - - try { - const snapshot = await buildImpersonatedVaultState(wallet); - res.status(200).json({ - ...snapshot, - impersonationSession: { - id: validation.session.id, - expiresAt: validation.session.expiresAt, - reason: validation.session.reason, - }, - }); - } catch (error) { - req.adminAuditAction = 'admin.impersonate.failed'; - req.adminAuditMetadata = { - ...req.adminAuditMetadata, - error: error instanceof Error ? error.message : String(error), - }; - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: 'Failed to build impersonated vault state', - }); - } -}); - -// ─── Admin Action Receipt Endpoints ───────────────────────────────────────── - -/** - * GET /admin/receipts - * Lists signed admin action receipts. - * Requires API key authentication. - */ + try { + if (sessionId) { + const validation = await validateImpersonationSession(sessionId, wallet, actingAdminAddress); + if (!validation.ok) { + const statusCode = validation.reason === 'not_found' ? 404 : 403; + req.adminAuditAction = + validation.reason === 'expired' + ? 'admin.impersonate.session.expired' + : validation.reason === 'ended' + ? 'admin.impersonate.session.ended' + : validation.reason === 'wallet_mismatch' + ? 'admin.impersonate.session.wallet_mismatch' + : validation.reason === 'actor_mismatch' + ? 'admin.impersonate.session.actor_mismatch' + : 'admin.impersonate.session.invalid'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + validationReason: validation.reason, + }; + res.status(statusCode).json({ + error: statusCode === 404 ? 'Not Found' : 'Forbidden', + status: statusCode, + message: + validation.reason === 'expired' + ? 'Impersonation session has expired' + : validation.reason === 'ended' + ? 'Impersonation session has ended' + : validation.reason === 'wallet_mismatch' + ? 'Session wallet does not match target wallet' + : validation.reason === 'actor_mismatch' + ? 'Session actor does not match requesting admin' + : 'Impersonation session is invalid', + }); + return; + } + } + + req.adminAuditAction = 'admin.impersonate'; + + const vaultState = await buildImpersonatedVaultState(wallet); + res.status(200).json({ + ...vaultState, + impersonationSession: sessionId + ? { + id: sessionId, + } + : undefined, + }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to build impersonated vault state', + }); + } app.get('/admin/receipts', validateApiKey, async (req: Request, res: Response) => { const action = typeof req.query.action === 'string' ? req.query.action : undefined; const actor = typeof req.query.actor === 'string' ? req.query.actor : undefined; - const limit = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : 50; + const limit = parseLimited(req.query.limit, 50, 1, 200); try { - const receipts = await listAdminReceipts({ action, actor, limit }); - res.json({ - receipts, - count: receipts.length, - timestamp: new Date().toISOString(), + const receipts = await listAdminReceipts({ action, actor, limit: limit + 1 }); + const { data, hasNextPage } = paginateByLimit(receipts, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + receipts: data, + count: data.length, + }, }); } catch (err) { res.status(500).json({ @@ -1674,11 +1771,6 @@ app.post('/admin/api-keys/revoke', validateApiKey, async (req: Request, res: Res * Supports ?action=created|rotated|revoked&from=&to=&limit=N */ app.get('/admin/api-keys/audit-events', validateApiKey, async (req: Request, res: Response) => { - const parseLimited = (v: unknown, fallback: number, min: number, max: number) => { - const n = parseInt(String(v ?? ''), 10); - return Number.isNaN(n) ? fallback : Math.min(Math.max(n, min), max); - }; - const rawAction = typeof req.query.action === 'string' ? req.query.action : undefined; const action = rawAction === API_KEY_AUDIT_ACTIONS.created || @@ -1706,20 +1798,26 @@ app.get('/admin/api-keys/audit-events', validateApiKey, async (req: Request, res action, start: range.start, end: range.end, - limit, + limit: limit + 1, }); + const { data, hasNextPage } = paginateByLimit(events, limit); - res.status(200).json({ - events, - meta: { - count: events.length, - limit, - filters: { - action: action || null, - from: range.start || null, - to: range.end || null, + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + events: data, + meta: { + count: data.length, + limit, + filters: { + action: action || null, + from: range.start || null, + to: range.end || null, + }, + timestamp: new Date().toISOString(), }, - timestamp: new Date().toISOString(), }, }); } catch (error) { @@ -1845,10 +1943,20 @@ app.patch('/admin/webhooks/:id', validateApiKey, (req: Request, res: Response) = */ app.get('/admin/webhooks', validateApiKey, (req: Request, res: Response) => { const includeDeleted = req.query.includeDeleted === 'true'; - res.status(200).json({ - endpoints: listWebhookEndpoints(includeDeleted), - metrics: getWebhookDeliveryMetrics(), - timestamp: new Date().toISOString(), + const limit = parseLimited(req.query.limit, 100, 1, 500); + const allEndpoints = listWebhookEndpoints(includeDeleted); + const windowed = allEndpoints.slice(0, limit + 1); + const { data, hasNextPage } = paginateByLimit(windowed, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + total: allEndpoints.length, + extras: { + endpoints: data, + metrics: getWebhookDeliveryMetrics(), + }, }); }); @@ -1916,17 +2024,24 @@ app.get('/admin/webhooks/dead-letter', validateApiKey, (req: Request, res: Respo const eventType = typeof req.query.eventType === 'string' ? req.query.eventType : undefined; const start = typeof req.query.start === 'string' ? req.query.start : undefined; const end = typeof req.query.end === 'string' ? req.query.end : undefined; - const limit = parseInt(String(req.query.limit || '100'), 10); + const limit = parseLimited(req.query.limit, 100, 1, 500); + + const rows = listWebhookDeadLetters({ + endpointId, + eventType: eventType as any, + start, + end, + limit: limit + 1, + }); + const { data, hasNextPage } = paginateByLimit(rows, limit); - res.status(200).json({ - deadLetters: listWebhookDeadLetters({ - endpointId, - eventType: eventType as any, - start, - end, - limit, - }), - timestamp: new Date().toISOString(), + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + deadLetters: data, + }, }); }); @@ -1963,17 +2078,23 @@ app.post('/admin/webhooks/dead-letter/:id/retry', validateApiKey, async (req: Re * Supports cursor-based pagination: ?limit=N&cursor= */ app.get('/admin/webhooks/deliveries', validateApiKey, (req: Request, res: Response) => { - const limit = parseInt(String(req.query.limit || '100'), 10); + const limit = parseLimited(req.query.limit, 100, 1, 500); const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : undefined; try { const page = listWebhookDeliveryPage({ limit, cursor }); - res.status(200).json({ - deliveries: page.deliveries, - nextCursor: page.nextCursor, + sendStandardListEnvelope(res, { + data: page.deliveries, + limit, hasNextPage: page.hasNextPage, - metrics: getWebhookDeliveryMetrics(), - timestamp: new Date().toISOString(), + hasPrevPage: Boolean(cursor), + nextCursor: page.nextCursor, + extras: { + deliveries: page.deliveries, + nextCursor: page.nextCursor, + hasNextPage: page.hasNextPage, + metrics: getWebhookDeliveryMetrics(), + }, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -2030,20 +2151,25 @@ app.post('/api/v1/webhooks/verify', (req: Request, res: Response) => { */ app.get('/admin/audit/logs', validateApiKey, (req: Request, res: Response) => { const statusCode = req.query.statusCode ? parseInt(String(req.query.statusCode), 10) : undefined; - const limit = req.query.limit ? parseInt(String(req.query.limit), 10) : undefined; + const limit = parseLimited(req.query.limit, 100, 1, 500); const logs = getAuditLogs({ actor: req.query.actor ? String(req.query.actor) : undefined, action: req.query.action ? String(req.query.action) : undefined, path: req.query.path ? String(req.query.path) : undefined, statusCode, - limit, + limit: limit + 1, }); + const { data, hasNextPage } = paginateByLimit(logs, limit); - res.status(200).json({ - logs, - metrics: getAuditLogMetrics(), - timestamp: new Date().toISOString(), + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + logs: data, + metrics: getAuditLogMetrics(), + }, }); }); @@ -2051,10 +2177,6 @@ app.get('/admin/audit/logs', validateApiKey, (req: Request, res: Response) => { * GET /admin/audit-logs - list admin audit entries (Issue #253) */ app.get('/admin/audit-logs', validateApiKey, async (req: Request, res: Response) => { - const parseLimited = (v: unknown, fallback: number, min: number, max: number) => { - const n = parseInt(String(v ?? ''), 10); - return Number.isNaN(n) ? fallback : Math.min(Math.max(n, min), max); - }; const limit = parseLimited(req.query.limit, 50, 1, 200); const statusCode = req.query.statusCode ? parseLimited(req.query.statusCode, 0, 100, 599) @@ -2064,20 +2186,25 @@ app.get('/admin/audit-logs', validateApiKey, async (req: Request, res: Response) action: typeof req.query.action === 'string' ? req.query.action : undefined, actor: typeof req.query.actor === 'string' ? req.query.actor : undefined, statusCode, - limit, + limit: limit + 1, }); + const { data, hasNextPage } = paginateByLimit(rows, limit); void recordAdminAuditLog(req, 'audit-logs.read', 200, { limit, - returned: rows.length, + returned: data.length, }); - res.json({ - data: rows, - meta: { - count: rows.length, - limit, - timestamp: new Date().toISOString(), + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + meta: { + count: data.length, + limit, + timestamp: new Date().toISOString(), + }, }, }); }); @@ -2086,11 +2213,6 @@ app.get('/admin/audit-logs', validateApiKey, async (req: Request, res: Response) * GET /admin/exports/jobs - list persisted transaction export metadata */ app.get('/admin/exports/jobs', validateApiKey, async (req: Request, res: Response) => { - const parseLimited = (v: unknown, fallback: number, min: number, max: number) => { - const n = parseInt(String(v ?? ''), 10); - return Number.isNaN(n) ? fallback : Math.min(Math.max(n, min), max); - }; - const rawFormat = typeof req.query.format === 'string' ? req.query.format : undefined; const format = rawFormat === 'csv' || rawFormat === 'json' ? rawFormat : undefined; if (rawFormat && !format) { @@ -2115,23 +2237,29 @@ app.get('/admin/exports/jobs', validateApiKey, async (req: Request, res: Respons checksum: typeof req.query.checksum === 'string' ? req.query.checksum : undefined, start: range.start, end: range.end, - limit, + limit: limit + 1, }); + const { data, hasNextPage } = paginateByLimit(jobs, limit); - res.status(200).json({ - jobs, - meta: { - count: jobs.length, - limit, - filters: { - format: format || null, - generatedBy: typeof req.query.generatedBy === 'string' ? req.query.generatedBy : null, - walletAddress: typeof req.query.walletAddress === 'string' ? req.query.walletAddress : null, - checksum: typeof req.query.checksum === 'string' ? req.query.checksum : null, - from: range.start || null, - to: range.end || null, + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + jobs: data, + meta: { + count: data.length, + limit, + filters: { + format: format || null, + generatedBy: typeof req.query.generatedBy === 'string' ? req.query.generatedBy : null, + walletAddress: typeof req.query.walletAddress === 'string' ? req.query.walletAddress : null, + checksum: typeof req.query.checksum === 'string' ? req.query.checksum : null, + from: range.start || null, + to: range.end || null, + }, + timestamp: new Date().toISOString(), }, - timestamp: new Date().toISOString(), }, }); } catch (error) { @@ -2248,14 +2376,20 @@ app.post('/admin/exports/bulk', validateApiKey, async (req: Request, res: Respon * GET /admin/exports/bulk/jobs - list bulk export jobs */ app.get('/admin/exports/bulk/jobs', validateApiKey, async (req: Request, res: Response) => { - const limit = parseInt(String(req.query.limit || '50'), 10); - const jobs = await listBulkExportJobs(Math.min(Math.max(limit, 1), 200)); - res.status(200).json({ - jobs, - meta: { - count: jobs.length, - limit, - timestamp: new Date().toISOString(), + const limit = parseLimited(req.query.limit, 50, 1, 200); + const jobs = await listBulkExportJobs(limit + 1); + const { data, hasNextPage } = paginateByLimit(jobs, limit); + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + jobs: data, + meta: { + count: data.length, + limit, + timestamp: new Date().toISOString(), + }, }, }); }); diff --git a/backend/src/middleware/structuredLogging.ts b/backend/src/middleware/structuredLogging.ts index de0c81a0..eab128d7 100644 --- a/backend/src/middleware/structuredLogging.ts +++ b/backend/src/middleware/structuredLogging.ts @@ -1,5 +1,6 @@ import type { Response, NextFunction, RequestHandler } from 'express'; import type { CorrelationIdRequest } from './correlationId'; +import { redactSensitiveAttributes } from '../redaction'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; @@ -45,8 +46,10 @@ class Logger { ...fields, }; + const safeEntry = redactSensitiveAttributes(entry); + /* eslint-disable-next-line no-console */ - console.log(JSON.stringify(entry)); + console.log(JSON.stringify(safeEntry)); } } diff --git a/backend/src/middleware/validate.ts b/backend/src/middleware/validate.ts index 0d979d8a..f0795b79 100644 --- a/backend/src/middleware/validate.ts +++ b/backend/src/middleware/validate.ts @@ -9,7 +9,7 @@ * Returns a uniform 400 response on failure. */ -import { z, ZodError, ZodTypeAny } from 'zod'; +import { z, ZodError, ZodIssue, ZodTypeAny } from 'zod'; import type { Request, Response, NextFunction } from 'express'; // ─── Shared field schemas ───────────────────────────────────────────────────── @@ -17,7 +17,7 @@ import type { Request, Response, NextFunction } from 'express'; /** Stellar wallet address: G + 55 base32 chars, uppercase */ export const walletAddressSchema = z .string() - .regex(/^G[A-Z2-7]{55}$/, 'Invalid Stellar wallet address format'); + .regex(/^G[A-Za-z2-7]{55}$/, 'Invalid Stellar wallet address format'); /** Positive numeric amount (accepts number or numeric string) */ export const amountSchema = z @@ -98,8 +98,45 @@ interface ValidateTargets { params?: ZodTypeAny; } -function formatZodError(err: ZodError): string { - return err.errors +function sortIssuesDeterministically(issues: ZodIssue[]): ZodIssue[] { + return [...issues].sort((a, b) => { + const pathA = a.path.join('.'); + const pathB = b.path.join('.'); + if (pathA !== pathB) { + return pathA.localeCompare(pathB); + } + if (a.code !== b.code) { + return a.code.localeCompare(b.code); + } + return a.message.localeCompare(b.message); + }); +} + +function mapIssueCode(issue: ZodIssue): string { + switch (issue.code) { + case 'invalid_type': + return 'INVALID_TYPE'; + case 'invalid_string': + return 'INVALID_STRING'; + case 'too_small': + return 'VALUE_TOO_SMALL'; + case 'too_big': + return 'VALUE_TOO_BIG'; + case 'invalid_enum_value': + return 'INVALID_ENUM_VALUE'; + case 'unrecognized_keys': + return 'UNRECOGNIZED_KEYS'; + case 'invalid_union': + return 'INVALID_UNION'; + case 'custom': + return 'CUSTOM_VALIDATION_FAILED'; + default: + return 'INVALID_VALUE'; + } +} + +function formatZodError(issues: ZodIssue[]): string { + return issues .map((e) => `${e.path.length ? e.path.join('.') + ': ' : ''}${e.message}`) .join('; '); } @@ -119,11 +156,17 @@ export function validate(schemas: ValidateTargets) { next(); } catch (err) { if (err instanceof ZodError) { + const issues = sortIssuesDeterministically(err.errors); res.status(400).json({ error: 'Bad Request', status: 400, - message: formatZodError(err), - details: err.errors.map((e) => ({ field: e.path.join('.'), message: e.message })), + code: 'VALIDATION_FAILED', + message: formatZodError(issues), + details: issues.map((e) => ({ + code: mapIssueCode(e), + field: e.path.join('.'), + message: e.message, + })), }); return; } diff --git a/backend/src/pagination.ts b/backend/src/pagination.ts index 8022557c..9248d743 100644 --- a/backend/src/pagination.ts +++ b/backend/src/pagination.ts @@ -34,6 +34,8 @@ export interface PaginationQuery { * Pagination metadata included in list responses. */ export interface PaginationMeta { + /** Requested page size cap. */ + limit?: number; /** Number of items returned in this response. */ count: number; /** Total number of items available (if known). */ diff --git a/backend/src/redaction.ts b/backend/src/redaction.ts new file mode 100644 index 00000000..6a4519f4 --- /dev/null +++ b/backend/src/redaction.ts @@ -0,0 +1,48 @@ +const DEFAULT_SENSITIVE_KEYS = [ + 'authorization', + 'api_key', + 'apikey', + 'access_token', + 'refresh_token', + 'token', + 'secret', + 'password', + 'signature', + 'private_key', + 'nonce', + 'passphrase', + 'credential', +]; + +const REDACTION_TOKEN = '[REDACTED]'; + +function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === '[object Object]'; +} + +function isSensitiveKey(key: string): boolean { + const normalized = key.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_'); + return DEFAULT_SENSITIVE_KEYS.some((sensitive) => normalized.includes(sensitive)); +} + +export function redactSensitiveAttributes(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => redactSensitiveAttributes(item)) as T; + } + + if (!isPlainObject(value)) { + return value; + } + + const redacted: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + if (isSensitiveKey(key)) { + redacted[key] = REDACTION_TOKEN; + continue; + } + + redacted[key] = redactSensitiveAttributes(nestedValue); + } + + return redacted as T; +} diff --git a/backend/src/webhookDelivery.ts b/backend/src/webhookDelivery.ts index b351432b..7b58f71c 100644 --- a/backend/src/webhookDelivery.ts +++ b/backend/src/webhookDelivery.ts @@ -154,7 +154,16 @@ export async function probeWebhookVerification(endpoint: InternalWebhookEndpoint throw new Error(`Verification endpoint returned HTTP ${response.status}`); } - const responseChallenge = response.headers.get('x-yieldvault-challenge'); + const headersRecord = response.headers as unknown as { + get?: (name: string) => string | null; + [key: string]: unknown; + }; + const responseChallenge = + typeof headersRecord?.get === 'function' + ? headersRecord.get('x-yieldvault-challenge') + : typeof headersRecord?.['x-yieldvault-challenge'] === 'string' + ? (headersRecord['x-yieldvault-challenge'] as string) + : null; if (responseChallenge === endpoint.challengeToken) { return true; } From 3bc307740f5f7a30c02f666fa9c5810a7773f14c Mon Sep 17 00:00:00 2001 From: UHamman Date: Sat, 30 May 2026 22:06:19 +0100 Subject: [PATCH 2/4] chore(ci): align stellar-base dependency with lockfile Updated the version of @stellar/stellar-base from 14.0.0 to 13.1.0 and retained @prisma/instrumentation version. --- backend/package.json | 33 +-------------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/backend/package.json b/backend/package.json index ed16cd18..b399f073 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,7 +25,7 @@ "author": "", "license": "MIT", "dependencies": { - "@stellar/stellar-base": "^14.0.0", + "@stellar/stellar-base": "^13.1.0", "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", "@opentelemetry/instrumentation-express": "^0.66.0", "@opentelemetry/instrumentation-http": "^0.218.0", @@ -34,34 +34,3 @@ "@opentelemetry/semantic-conventions": "^1.41.1", "@prisma/client": "^5.10.0", "@prisma/instrumentation": "^7.8.0", - "@stellar/stellar-sdk": "^13.0.0", - "cors": "^2.8.6", - "decimal.js": "^10.6.0", - "dotenv": "^16.3.1", - "express": "^4.18.2", - "express-rate-limit": "^7.0.0", - "fast-check": "^4.8.0", - "ioredis": "^5.10.1", - "node-cache": "^5.1.2", - "prom-client": "^15.1.3", - "rate-limit-redis": "^4.3.1", - "zod": "^3.23.8" - }, - "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^4.17.17", - "@types/jest": "^29.5.0", - "@types/node": "^20.0.0", - "@types/supertest": "^2.0.12", - "@typescript-eslint/eslint-plugin": "^5.59.0", - "@typescript-eslint/parser": "^5.59.0", - "eslint": "^8.40.0", - "jest": "^29.5.0", - "prettier": "^3.0.0", - "prisma": "^5.10.0", - "supertest": "^6.3.3", - "ts-jest": "^29.1.0", - "tsx": "^4.0.0", - "typescript": "^5.1.0" - } -} From a5190b2aa981c37d31dd1761fb219df9b32b9371 Mon Sep 17 00:00:00 2001 From: UHamman Date: Sat, 30 May 2026 22:07:55 +0100 Subject: [PATCH 3/4] Refactor set_initialized function for readability --- contracts/vault/src/upgrade.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/contracts/vault/src/upgrade.rs b/contracts/vault/src/upgrade.rs index b0c4caad..12e6202d 100644 --- a/contracts/vault/src/upgrade.rs +++ b/contracts/vault/src/upgrade.rs @@ -71,6 +71,7 @@ pub fn is_initialized(env: &Env) -> bool { } pub fn set_initialized(env: &Env) { - env.storage().instance().set(&ProxyDataKey::Initialized, &true); + env.storage() + .instance() + .set(&ProxyDataKey::Initialized, &true); } - From 3154975fc7b972326025471a1ae86c485c130828 Mon Sep 17 00:00:00 2001 From: UHamman Date: Sat, 30 May 2026 22:10:03 +0100 Subject: [PATCH 4/4] fix(ci): restore valid backend package.json --- backend/package.json | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/backend/package.json b/backend/package.json index b399f073..b176e82f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,3 +34,34 @@ "@opentelemetry/semantic-conventions": "^1.41.1", "@prisma/client": "^5.10.0", "@prisma/instrumentation": "^7.8.0", + "@stellar/stellar-sdk": "^13.0.0", + "cors": "^2.8.6", + "decimal.js": "^10.6.0", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-rate-limit": "^7.0.0", + "fast-check": "^4.8.0", + "ioredis": "^5.10.1", + "node-cache": "^5.1.2", + "prom-client": "^15.1.3", + "rate-limit-redis": "^4.3.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^4.17.17", + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "@types/supertest": "^2.0.12", + "@typescript-eslint/eslint-plugin": "^5.59.0", + "@typescript-eslint/parser": "^5.59.0", + "eslint": "^8.40.0", + "jest": "^29.5.0", + "prettier": "^3.0.0", + "prisma": "^5.10.0", + "supertest": "^6.3.3", + "ts-jest": "^29.1.0", + "tsx": "^4.0.0", + "typescript": "^5.1.0" + } +}