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
9 changes: 9 additions & 0 deletions backend/src/encryption/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ const PII_FIELDS: Record<string, Record<string, FieldConfig>> = {
AuditLog: {
ipAddress: {},
},
// Provider credentials at rest (#1010). Webhook signing secrets and push auth
// keys are the provider credentials persisted through Prisma today; the
// provider-credential vault (`services/provider-credentials`) covers the rest.
Webhook: {
secret: {},
},
PushSubscription: {
auth: {},
},
};

// ---------------------------------------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { formsRouter } from './routes/forms.js';
import { twoFactorAuthRouter } from './routes/2fa.js';
import { webhooksRouter } from './routes/webhooks.js';
import { webhookHandlersRouter } from './routes/webhookHandlers.js';
import { webhookJsonParser } from './middleware/webhookVerification.js';
import { startJobs, getJobScheduler } from './jobs/index.js';
import { batchProcessor } from './services/batch.js';
import { featureFlags } from './config/featureFlags.js';
Expand Down Expand Up @@ -134,6 +135,7 @@ import { getScheduledTasks } from './config/scheduled-tasks.js';
import { bullMQMonitorRouter } from './routes/bullmq-monitor.js';
import { fileUploadRouter } from './routes/file-upload.js';
import { credentialRotationRouter } from './routes/credential-rotation.js';
import { providerCredentialsRouter } from './routes/provider-credentials.js';
import zkIdentityRouter from './routes/zk-identity.js';
import { coldStartMonitorRouter } from './routes/cold-start-monitor.js';
import { rateLimitAnalyticsRouter } from './routes/rate-limit-analytics.js';
Expand Down Expand Up @@ -230,6 +232,10 @@ app.use(httpLogger);
// Incoming webhooks: raw body capture before global JSON parser (#393)
app.use('/webhooks', webhookHandlersRouter);

// Bank-partner payout webhooks need the raw body for HMAC verification (#1006).
// Mounted before the global JSON parser so `req.rawBody` is populated.
app.use('/api/v1/fiat-payments/webhooks', webhookJsonParser);

// KYC document uploads carry base64 file contents (up to 10 MB decoded) (#921)
app.use('/api/v1/kyc', express.json({ limit: '15mb' }));
app.use(express.json());
Expand Down Expand Up @@ -392,6 +398,7 @@ app.use('/api/v1/uploads', fileUploadRouter);

// Credential rotation management with dual-key overlap and audit trail (Issue #395)
app.use('/api/v1/credentials', credentialRotationRouter);
app.use('/api/v1/provider-credentials', providerCredentialsRouter);

// Microservices service mesh — registry, discovery, circuit breakers
app.use('/api/v1/service-mesh', serviceMeshRouter);
Expand Down
15 changes: 14 additions & 1 deletion backend/src/middleware/webhookVerification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
verifyGithubProviderWebhook,
verifyPaypalProviderWebhook,
verifyCustomProviderWebhook,
verifyBankPartnerWebhook,
type ProviderVerificationResult,
} from '../services/webhooks/providers.js';
import { isReplayEvent } from '../services/webhooks/replay.js';
Expand Down Expand Up @@ -49,10 +50,18 @@ const providerVerifiers: Record<WebhookProvider, ProviderVerifier> = {
};

export function verifyWebhookProvider(provider: WebhookProvider) {
return createWebhookVerifier(provider, providerVerifiers[provider]);
}

/**
* Builds a verification middleware around any provider verifier. Extracted so
* additional partners (e.g. the bank-partner payout webhook, #1006) can reuse the
* same store/replay/queue behaviour without widening the `WebhookProvider` enum.
*/
export function createWebhookVerifier(provider: WebhookProvider, verify: ProviderVerifier) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const rawBody = req.rawBody ?? (typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {}));
const verify = providerVerifiers[provider];
const result = verify(req, rawBody);

storeWebhookPayload({
Expand All @@ -62,6 +71,7 @@ export function verifyWebhookProvider(provider: WebhookProvider) {
signature: (req.headers['stripe-signature'] ||
req.headers['x-hub-signature-256'] ||
req.headers['x-signature'] ||
req.headers['x-bank-signature'] ||
'') as string,
verified: result.isValid,
error: result.error,
Expand Down Expand Up @@ -116,3 +126,6 @@ export const webhookVerifiers = {
github: verifyWebhookProvider('github'),
custom: verifyWebhookProvider('custom'),
};

/** Bank-partner payout webhooks use the shared replay/queue machinery (#1006). */
export const bankPartnerWebhookVerifier = createWebhookVerifier('custom', verifyBankPartnerWebhook);
2 changes: 2 additions & 0 deletions backend/src/routes/fiat-payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
FiatPaymentStatus,
fiatPaymentsService,
} from '../services/fiat-payments.js';
import { bankPartnerWebhookVerifier } from '../middleware/webhookVerification.js';

export const fiatPaymentsRouter = Router();

Expand Down Expand Up @@ -168,6 +169,7 @@ fiatPaymentsRouter.get(

fiatPaymentsRouter.post(
'/webhooks/bank-partner',
bankPartnerWebhookVerifier,
validate(bankWebhookSchema),
asyncHandler(async (req, res) => {
const payment = fiatPaymentsService.handleWebhook(req.body);
Expand Down
8 changes: 8 additions & 0 deletions backend/src/routes/payouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,9 @@ function asValidationError(error: unknown): AppError {
if (code === 'PAYOUT_ANOMALY_ALREADY_REVIEWED') {
return new AppError(409, 'Anomaly flag has already been reviewed', 'ALREADY_REVIEWED');
}
if (code === 'IDEMPOTENCY_KEY_REUSE_MISMATCH') {
return new AppError(409, 'Idempotency-Key was reused with a different request body', code);
}
if (!/^[A-Z_]+$/.test(code)) return new AppError(400, 'Invalid request', 'VALIDATION_ERROR');
return new AppError(400, code.toLowerCase().replace(/_/g, ' '), code);
}
Expand All @@ -341,6 +344,10 @@ payoutsRouter.post(
if (typeof asset !== 'string' || !SUPPORTED_ASSETS.includes(asset)) {
throw new AppError(400, `asset must be one of: ${SUPPORTED_ASSETS.join(', ')}`, 'VALIDATION_ERROR');
}
// The service-level idempotency store (#1005) outlives the middleware cache; pass
// the header through so a retry after an eviction still returns the original payout.
const idempotencyKey =
(req.get('Idempotency-Key') as string | undefined) ?? (req.get('X-Idempotency-Key') as string | undefined);
try {
const payout = payoutBatcher.schedule({
merchantId: requireId(merchantId, 'merchantId'),
Expand All @@ -350,6 +357,7 @@ payoutsRouter.post(
scheduledFor: optionalDate(scheduledFor, 'scheduledFor'),
...(provider !== undefined ? { provider } : {}),
...(reference !== undefined ? { reference } : {}),
...(idempotencyKey ? { idempotencyKey } : {}),
actorId: principal.id,
});
res.status(201).json({ data: payout });
Expand Down
114 changes: 114 additions & 0 deletions backend/src/routes/provider-credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* provider-credentials.ts (routes) — Issue #1010
*
* Admin surface for the encrypted-at-rest provider credential vault:
* - GET /provider-credentials → list metadata (no secrets)
* - PUT /provider-credentials/:provider/:name → store / replace a secret
* - GET /provider-credentials/:provider/:name → metadata for one credential
* - POST /provider-credentials/:provider/:name/reveal → decrypt a secret (audited)
* - DELETE /provider-credentials/:provider/:name → remove a secret
* - POST /provider-credentials/rotate-key → re-encrypt under the current master key
*/

import { Router, type Request } from 'express';
import { asyncHandler, AppError } from '../middleware/errorHandler.js';
import { providerCredentialVault as vault } from '../services/provider-credentials.js';

export const providerCredentialsRouter = Router();

/** Best-effort actor identity for the audit trail; never used for authorisation here. */
function actorId(req: Request): string {
const fromUser = req.user?.id;
if (typeof fromUser === 'string' && fromUser.length > 0) return fromUser;
const header = req.headers['x-actor-id'];
return typeof header === 'string' && header.length > 0 ? header : 'unknown';
}

function param(value: string | string[]): string {
return Array.isArray(value) ? value[0] : value;
}

providerCredentialsRouter.get(
'/',
asyncHandler(async (req, res) => {
const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;
const credentials = vault.list(provider ? { provider } : {});
res.json({ data: credentials, count: credentials.length });
}),
);

providerCredentialsRouter.post(
'/rotate-key',
asyncHandler(async (req, res) => {
const result = vault.rotateMasterKey(actorId(req));
if (result.failed.length > 0 && result.rotated === 0) {
throw new AppError(500, 'Credential re-encryption failed', 'PROVIDER_CREDENTIAL_ROTATION_FAILED', result);
}
res.json({ data: result });
}),
);

providerCredentialsRouter.put(
'/:provider/:name',
asyncHandler(async (req, res) => {
const { value, ttlMs } = (req.body ?? {}) as { value?: unknown; ttlMs?: unknown };
if (typeof value !== 'string' || value.length === 0) {
throw new AppError(400, 'value is required', 'VALIDATION_ERROR');
}
if (ttlMs !== undefined && (typeof ttlMs !== 'number' || !Number.isFinite(ttlMs) || ttlMs <= 0)) {
throw new AppError(400, 'ttlMs must be a positive number', 'VALIDATION_ERROR');
}

try {
const credential = vault.put({
provider: param(req.params.provider),
name: param(req.params.name),
value,
...(ttlMs !== undefined ? { ttlMs: ttlMs as number } : {}),
actorId: actorId(req),
});
res.status(201).json({ data: credential });
} catch (error) {
const code = error instanceof Error ? error.message : 'PROVIDER_CREDENTIAL_ERROR';
throw new AppError(400, 'Invalid provider credential', code);
}
}),
);

providerCredentialsRouter.get(
'/:provider/:name',
asyncHandler(async (req, res) => {
const credential = vault.get({
provider: param(req.params.provider),
name: param(req.params.name),
});
if (!credential) throw new AppError(404, 'Provider credential not found', 'NOT_FOUND');
res.json({ data: credential });
}),
);

providerCredentialsRouter.post(
'/:provider/:name/reveal',
asyncHandler(async (req, res) => {
const value = vault.reveal({
provider: param(req.params.provider),
name: param(req.params.name),
actorId: actorId(req),
});
if (value === undefined) throw new AppError(404, 'Provider credential not found', 'NOT_FOUND');
res.json({ data: { value } });
}),
);

providerCredentialsRouter.delete(
'/:provider/:name',
asyncHandler(async (req, res) => {
const removed = vault.delete({
provider: param(req.params.provider),
name: param(req.params.name),
actorId: actorId(req),
});
if (!removed) throw new AppError(404, 'Provider credential not found', 'NOT_FOUND');
res.status(204).send();
}),
);
27 changes: 27 additions & 0 deletions backend/src/routes/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,33 @@ walletRouter.get(
}),
);

// ─── Per-currency balances (#1009) ────────────────────────────────────────────

walletRouter.get(
'/balances',
asyncHandler(async (req: Request, res: Response) => {
const userId = String(req.query.userId || '');
if (!userId) {
return res.status(400).json({ success: false, error: { message: 'userId is required' } });
}

const currency = typeof req.query.currency === 'string' ? req.query.currency : undefined;
if (currency) {
const balance = await WalletAggregationService.getCurrencyBalance(userId, currency);
if (!balance) {
return res.status(404).json({
success: false,
error: { message: `No ${currency.toUpperCase()} balance for this user` },
});
}
return res.status(200).json({ success: true, data: balance });
}

const balances = await WalletAggregationService.getBalancesByCurrency(userId);
res.status(200).json({ success: true, data: balances });
}),
);

// ─── Routing ──────────────────────────────────────────────────────────────────

walletRouter.get(
Expand Down
Loading
Loading