diff --git a/backend/package-lock.json b/backend/package-lock.json index 5344f3ff3..bdac45755 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,7 @@ "decimal.js": "^10.4.3", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-async-errors": "^3.1.1", "express-rate-limit": "^7.0.0", "fast-check": "^4.8.0", "ioredis": "^5.10.1", @@ -6208,6 +6209,14 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-async-errors": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz", + "integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==", + "peerDependencies": { + "express": "^4.16.2" + } + }, "node_modules/express-rate-limit": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.4.1.tgz", diff --git a/backend/package.json b/backend/package.json index 39511349e..334bf26c0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -66,6 +66,7 @@ "decimal.js": "^10.6.0", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-async-errors": "^3.1.1", "express-rate-limit": "^7.0.0", "fast-check": "^4.8.0", "ioredis": "^5.10.1", diff --git a/backend/src/__tests__/walletAliasEndpoints.test.ts b/backend/src/__tests__/walletAliasEndpoints.test.ts index bd5714d1b..c7a9f97da 100644 --- a/backend/src/__tests__/walletAliasEndpoints.test.ts +++ b/backend/src/__tests__/walletAliasEndpoints.test.ts @@ -1,5 +1,6 @@ import request from 'supertest'; import app from '../index'; +import { issueTokenPair } from '../auth'; import { registerApiKey } from '../middleware/apiKeyAuth'; import { disconnectPrismaClient } from '../prismaClient'; import { walletAliasMappingService } from '../walletAliasService'; @@ -8,7 +9,12 @@ import { VALID_TEST_WALLET, SECOND_TEST_WALLET } from './setup'; describe('Wallet alias API endpoints', () => { const stellarWallet = VALID_TEST_WALLET; const providerAlias = 'wallet-connect-test-alias'; - const authHeader = { Authorization: `ApiKey ${process.env.ADMIN_API_KEY || 'test-admin-key'}` }; + const adminAuthHeader = { Authorization: `ApiKey ${process.env.ADMIN_API_KEY || 'test-admin-key'}` }; + + async function bearerAuth(walletAddress = stellarWallet): Promise { + const { accessToken } = await issueTokenPair(walletAddress); + return `Bearer ${accessToken}`; + } beforeEach(async () => { await walletAliasMappingService.resetForTests(); @@ -21,6 +27,7 @@ describe('Wallet alias API endpoints', () => { it('POST /api/v1/wallet-aliases/link links provider aliases to a canonical identity', async () => { const res = await request(app) .post('/api/v1/wallet-aliases/link') + .set('Authorization', await bearerAuth(SECOND_TEST_WALLET)) .send({ primaryAlias: SECOND_TEST_WALLET, primarySource: 'stellar', @@ -36,9 +43,77 @@ describe('Wallet alias API endpoints', () => { expect(res.body.canonicalWallet).toBe(SECOND_TEST_WALLET); }); + it('requires authentication before linking wallet aliases', async () => { + const res = await request(app) + .post('/api/v1/wallet-aliases/link') + .send({ + primaryAlias: stellarWallet, + primarySource: 'stellar', + linkedAlias: providerAlias, + linkedSource: 'walletconnect', + }); + + expect(res.status).toBe(401); + }); + + it('requires authentication for both wallet alias resolve endpoints', async () => { + const resolved = await request(app).get('/api/v1/wallet-aliases/resolve'); + const identity = await request(app).get('/api/v1/wallet-aliases/wallet-alias:test'); + + expect(resolved.status).toBe(401); + expect(identity.status).toBe(401); + }); + + it('only allows linking from the authenticated wallet and prevents cross-owner merges', async () => { + const firstLink = await request(app) + .post('/api/v1/wallet-aliases/link') + .set('Authorization', await bearerAuth(stellarWallet)) + .send({ + primaryAlias: stellarWallet, + primarySource: 'stellar', + linkedAlias: providerAlias, + linkedSource: 'walletconnect', + }); + expect(firstLink.status).toBe(200); + + const mismatchedPrimary = await request(app) + .post('/api/v1/wallet-aliases/link') + .set('Authorization', await bearerAuth(SECOND_TEST_WALLET)) + .send({ + primaryAlias: stellarWallet, + primarySource: 'stellar', + linkedAlias: 'another-provider-alias', + linkedSource: 'walletconnect', + }); + expect(mismatchedPrimary.status).toBe(403); + + const unownedStellarLink = await request(app) + .post('/api/v1/wallet-aliases/link') + .set('Authorization', await bearerAuth(stellarWallet)) + .send({ + primaryAlias: stellarWallet, + primarySource: 'stellar', + linkedAlias: SECOND_TEST_WALLET, + linkedSource: 'stellar', + }); + expect(unownedStellarLink.status).toBe(403); + + const crossOwnerLink = await request(app) + .post('/api/v1/wallet-aliases/link') + .set('Authorization', await bearerAuth(SECOND_TEST_WALLET)) + .send({ + primaryAlias: SECOND_TEST_WALLET, + primarySource: 'stellar', + linkedAlias: providerAlias, + linkedSource: 'walletconnect', + }); + expect(crossOwnerLink.status).toBe(409); + }); + it('GET /api/v1/wallet-aliases/resolve returns canonical linkage for a provider alias', async () => { const linked = await request(app) .post('/api/v1/wallet-aliases/link') + .set('Authorization', await bearerAuth()) .send({ primaryAlias: stellarWallet, primarySource: 'stellar', @@ -48,6 +123,7 @@ describe('Wallet alias API endpoints', () => { const res = await request(app) .get('/api/v1/wallet-aliases/resolve') + .set('Authorization', await bearerAuth()) .query({ alias: providerAlias, source: 'walletconnect' }); expect(res.status).toBe(200); @@ -55,9 +131,50 @@ describe('Wallet alias API endpoints', () => { expect(res.body.canonicalWallet).toBe(stellarWallet); }); + it('does not reveal an identity group to a different wallet', async () => { + const linked = await request(app) + .post('/api/v1/wallet-aliases/link') + .set('Authorization', await bearerAuth()) + .send({ + primaryAlias: stellarWallet, + primarySource: 'stellar', + linkedAlias: providerAlias, + linkedSource: 'walletconnect', + }); + + const res = await request(app) + .get('/api/v1/wallet-aliases/resolve') + .set('Authorization', await bearerAuth(SECOND_TEST_WALLET)) + .query({ alias: providerAlias, source: 'walletconnect' }); + const identity = await request(app) + .get(`/api/v1/wallet-aliases/${linked.body.canonicalId}`) + .set('Authorization', await bearerAuth(SECOND_TEST_WALLET)); + + expect(res.status).toBe(404); + expect(identity.status).toBe(404); + }); + + it('forwards rejected async handlers to the Express error boundary', async () => { + const resolveAlias = jest + .spyOn(walletAliasMappingService, 'resolveAlias') + .mockRejectedValueOnce(new Error('database unavailable')); + + try { + const res = await request(app) + .get('/api/v1/wallet-aliases/resolve') + .set('Authorization', await bearerAuth()) + .query({ alias: providerAlias, source: 'walletconnect' }); + + expect(res.status).toBe(500); + } finally { + resolveAlias.mockRestore(); + } + }); + it('GET /api/v1/wallet-aliases/:canonicalId returns linked aliases', async () => { const linked = await request(app) .post('/api/v1/wallet-aliases/link') + .set('Authorization', await bearerAuth()) .send({ primaryAlias: stellarWallet, primarySource: 'stellar', @@ -65,7 +182,9 @@ describe('Wallet alias API endpoints', () => { linkedSource: 'walletconnect', }); - const res = await request(app).get(`/api/v1/wallet-aliases/${linked.body.canonicalId}`); + const res = await request(app) + .get(`/api/v1/wallet-aliases/${linked.body.canonicalId}`) + .set('Authorization', await bearerAuth()); expect(res.status).toBe(200); expect(res.body.aliases).toEqual(expect.arrayContaining([stellarWallet, providerAlias])); @@ -75,6 +194,7 @@ describe('Wallet alias API endpoints', () => { it('GET /admin/wallet-aliases lists persisted identity groups', async () => { const linked = await request(app) .post('/api/v1/wallet-aliases/link') + .set('Authorization', await bearerAuth()) .send({ primaryAlias: stellarWallet, primarySource: 'stellar', @@ -82,7 +202,7 @@ describe('Wallet alias API endpoints', () => { linkedSource: 'walletconnect', }); - const res = await request(app).get('/admin/wallet-aliases').set(authHeader); + const res = await request(app).get('/admin/wallet-aliases').set(adminAuthHeader); expect(res.status).toBe(200); expect(res.body.count).toBe(1); @@ -93,6 +213,7 @@ describe('Wallet alias API endpoints', () => { it('DELETE /admin/wallet-aliases/:canonicalId deletes an identity group', async () => { const linked = await request(app) .post('/api/v1/wallet-aliases/link') + .set('Authorization', await bearerAuth()) .send({ primaryAlias: stellarWallet, primarySource: 'stellar', @@ -102,13 +223,14 @@ describe('Wallet alias API endpoints', () => { const deleted = await request(app) .delete(`/admin/wallet-aliases/${linked.body.canonicalId}`) - .set(authHeader); + .set(adminAuthHeader); expect(deleted.status).toBe(200); expect(deleted.body.aliasesDeleted).toBe(2); const resolved = await request(app) .get('/api/v1/wallet-aliases/resolve') + .set('Authorization', await bearerAuth()) .query({ alias: providerAlias, source: 'walletconnect' }); expect(resolved.status).toBe(404); @@ -118,6 +240,7 @@ describe('Wallet alias API endpoints', () => { registerApiKey('wallet-alias-viewer-key', { role: 'viewer' }); const linked = await request(app) .post('/api/v1/wallet-aliases/link') + .set('Authorization', await bearerAuth()) .send({ primaryAlias: stellarWallet, primarySource: 'stellar', diff --git a/backend/src/__tests__/walletAliasService.test.ts b/backend/src/__tests__/walletAliasService.test.ts index 1dbde5a56..6692e3efa 100644 --- a/backend/src/__tests__/walletAliasService.test.ts +++ b/backend/src/__tests__/walletAliasService.test.ts @@ -1,5 +1,5 @@ import { getPrismaClient, disconnectPrismaClient } from '../prismaClient'; -import { WalletAliasMappingService } from '../walletAliasService'; +import { WalletAliasConflictError, WalletAliasMappingService } from '../walletAliasService'; import { VALID_TEST_WALLET, SECOND_TEST_WALLET } from './setup'; describe('WalletAliasMappingService', () => { @@ -70,6 +70,20 @@ describe('WalletAliasMappingService', () => { ).toBe(true); }); + it('does not merge aliases already owned by different canonical identities', async () => { + await service.registerAlias(VALID_TEST_WALLET, 'stellar'); + await service.registerAlias('wallet-connect-alias', 'walletconnect'); + + await expect( + service.linkProviderIdentity( + VALID_TEST_WALLET, + 'stellar', + 'wallet-connect-alias', + 'walletconnect', + ), + ).rejects.toBeInstanceOf(WalletAliasConflictError); + }); + it('hydrates persisted aliases into a fresh service instance', async () => { const first = await service.linkProviderIdentity( VALID_TEST_WALLET, diff --git a/backend/src/adminAudit.ts b/backend/src/adminAudit.ts index 2e7d47aaf..40447e162 100644 --- a/backend/src/adminAudit.ts +++ b/backend/src/adminAudit.ts @@ -2,6 +2,7 @@ import type { Request } from 'express'; import { prisma } from './prisma'; import { resetAuditLogs } from './auditLog'; import { redactSensitiveLogAttributes } from './auditRedaction'; +import { logger } from './middleware/structuredLogging'; import crypto from 'crypto'; type AuditStorageMode = 'memory' | 'prisma' | 'hybrid'; @@ -107,6 +108,20 @@ export async function recordAdminAuditLog( } } +export function recordAdminAuditLogInBackground( + req: Request, + action: string, + statusCode: number, + metadata: Record = {}, +): void { + void recordAdminAuditLog(req, action, statusCode, metadata).catch((error: unknown) => { + logger.error('Failed to persist background admin audit log', { + action, + error: error instanceof Error ? error.message : String(error), + }); + }); +} + export async function listAdminAuditLogs(filters: AuditLogFilters): Promise { const storageMode = normalizeStorageMode(process.env.ADMIN_AUDIT_LOG_STORAGE); if (storageMode === 'memory') { diff --git a/backend/src/index.ts b/backend/src/index.ts index 751630bd1..0cf813306 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,6 +8,7 @@ dotenv.config(); import { initTracing, shutdownTracing, getCurrentTraceId } from './tracing'; initTracing(); +import 'express-async-errors'; import express, { Express, Request, Response, NextFunction, ErrorRequestHandler } from 'express'; import NodeCache from 'node-cache'; import { @@ -37,7 +38,7 @@ import { import { idempotencyStore } from './idempotency'; import { createAdminAuditMiddleware, getAuditLogs, countAuditLogs, getAuditLogMetrics } from './auditLog'; import { AuditLogQuerySchema } from './middleware/validate'; -import { recordAdminAuditLog } from './adminAudit'; +import { recordAdminAuditLog, recordAdminAuditLogInBackground } from './adminAudit'; import { recordAdminConfigChange, listAdminConfigChanges, getActorFromRequest } from './adminConfigChangeAudit'; @@ -274,6 +275,15 @@ const cacheVaultMetricsTtl = parseInt(process.env.CACHE_TTL_MS || process.env.CA // Configure logger logger.configure(logLevel); +if (nodeEnv !== 'test') { + process.on('unhandledRejection', (reason: unknown) => { + logger.error('Unhandled promise rejection', { + error: reason instanceof Error ? reason.message : String(reason), + stack: reason instanceof Error ? reason.stack : undefined, + }); + }); +} + // Coordinates SIGTERM/SIGINT: stops new requests immediately, drains // in-flight ones, then runs DB/job cleanup — see onShutdown registrations // near the bottom of this file. @@ -1450,7 +1460,7 @@ app.post('/admin/apy/backfill', validateApiKey, validate({ body: ApyBackfillBody try { if (isDryRunRequest(req)) { - void recordAdminAuditLog(req, 'apy.backfill.dry_run', 200, { + recordAdminAuditLogInBackground(req, 'apy.backfill.dry_run', 200, { start, end, actor, @@ -1483,7 +1493,7 @@ app.post('/admin/apy/backfill', validateApiKey, validate({ body: ApyBackfillBody }, }); - void recordAdminAuditLog(req, 'apy.backfill', 200, { + recordAdminAuditLogInBackground(req, 'apy.backfill', 200, { start, end, created: result.created, @@ -1557,7 +1567,7 @@ app.post('/admin/maintenance', validateApiKey, validate({ body: MaintenanceToggl }; if (dryRun) { - void recordAdminAuditLog(req, 'maintenance.toggle.dry_run', 200, { + recordAdminAuditLogInBackground(req, 'maintenance.toggle.dry_run', 200, { enabled: preview.enabled, previousEnabled: previous.enabled, reason: preview.reason, @@ -1607,7 +1617,7 @@ app.post('/admin/maintenance', validateApiKey, validate({ body: MaintenanceToggl previousEnabled: previous.enabled, }); - void recordAdminAuditLog(req, 'maintenance.toggle', 200, { + recordAdminAuditLogInBackground(req, 'maintenance.toggle', 200, { enabled: next.enabled, previousEnabled: previous.enabled, reason: next.reason, @@ -2023,7 +2033,7 @@ app.post('/admin/events/replay', validateApiKey, validate({ body: EventReplayBod const actor = resolveActingAdminAddress(req); if (isDryRunRequest(req)) { - void recordAdminAuditLog(req, 'events.replay.manual.dry_run', 200, { + recordAdminAuditLogInBackground(req, 'events.replay.manual.dry_run', 200, { fromLedger, toLedger, ledgerCount: toLedger - fromLedger + 1, @@ -2062,7 +2072,7 @@ app.post('/admin/events/replay', validateApiKey, validate({ body: EventReplayBod }); // Record replay job metadata - void recordAdminAuditLog(req, 'events.replay.manual', 200, { + recordAdminAuditLogInBackground(req, 'events.replay.manual', 200, { fromLedger, toLedger, processedCount: result.processedCount, @@ -2086,7 +2096,7 @@ app.post('/admin/events/replay', validateApiKey, validate({ body: EventReplayBod const errorMessage = err instanceof Error ? err.message : String(err); // Record failed replay attempt - void recordAdminAuditLog(req, 'events.replay.manual.failed', 500, { + recordAdminAuditLogInBackground(req, 'events.replay.manual.failed', 500, { error: errorMessage, timestamp: new Date().toISOString(), }); @@ -2217,7 +2227,7 @@ app.post('/admin/allowlist/add', validateApiKey, validate({ body: AllowlistWalle }, }); - void recordAdminAuditLog(req, 'allowlist.add', added ? 201 : 200, { + recordAdminAuditLogInBackground(req, 'allowlist.add', added ? 201 : 200, { walletAddress, added, actor, @@ -2261,7 +2271,7 @@ app.delete('/admin/allowlist/remove', validateApiKey, validate({ body: Allowlist }, }); - void recordAdminAuditLog(req, 'allowlist.remove', 200, { + recordAdminAuditLogInBackground(req, 'allowlist.remove', 200, { walletAddress, actor, receiptId: receipt.id, @@ -2330,7 +2340,7 @@ app.delete('/admin/wallet-aliases/:canonicalId', validateApiKey, async (req: Req } const actor = resolveActingAdminAddress(req); - void recordAdminAuditLog(req, 'wallet-alias.delete', 200, { + recordAdminAuditLogInBackground(req, 'wallet-alias.delete', 200, { actor, canonicalId, aliases: existing.aliases, @@ -3099,7 +3109,7 @@ app.delete('/admin/webhooks/:id', validateApiKey, validate({ params: IdParamSche return; } - void recordAdminAuditLog(req, 'webhook.delete', 200, { + recordAdminAuditLogInBackground(req, 'webhook.delete', 200, { endpointId: endpoint.id, url: endpoint.url, actor, @@ -3127,7 +3137,7 @@ app.post('/admin/webhooks/:id/restore', validateApiKey, validate({ params: IdPar return; } - void recordAdminAuditLog(req, 'webhook.restore', 200, { + recordAdminAuditLogInBackground(req, 'webhook.restore', 200, { endpointId: endpoint.id, url: endpoint.url, actor, @@ -3327,7 +3337,7 @@ app.get('/admin/audit-logs', validateApiKey, validate({ query: AuditLogQuerySche const rows = getAuditLogs({ ...filters, limit: limit + 1, offset }); const { data, hasNextPage } = paginateByLimit(rows, limit); - void recordAdminAuditLog(req, 'audit-logs.read', 200, { + recordAdminAuditLogInBackground(req, 'audit-logs.read', 200, { limit, returned: data.length, }); @@ -3495,7 +3505,12 @@ app.post('/admin/exports/bulk', validateApiKey, validate({ body: BulkExportBodyS filters: filters || {}, }); - void processBulkExportJob(job.id).catch(() => {}); + void processBulkExportJob(job.id).catch((error: unknown) => { + logger.error('Bulk export job failed', { + jobId: job.id, + error: error instanceof Error ? error.message : String(error), + }); + }); res.status(201).json({ message: 'Bulk export job created', @@ -3632,7 +3647,7 @@ app.get('/admin/jobs/metrics', validateApiKey, (req: Request, res: Response) => activeJobs: Object.values(metrics.runtime).filter((job) => job.inFlight > 0).length, }; - void recordAdminAuditLog(req, 'jobs.metrics.read', 200); + recordAdminAuditLogInBackground(req, 'jobs.metrics.read', 200); res.json({ summary, @@ -4228,7 +4243,7 @@ app.post('/admin/jobs/dead-letters/:id/retry', validateApiKey, async (req: Reque const actor = resolveActingAdminAddress(req); if (isDryRunRequest(req)) { - void recordAdminAuditLog(req, 'jobs.dlq.retry.dry_run', 200, { + recordAdminAuditLogInBackground(req, 'jobs.dlq.retry.dry_run', 200, { id: req.params.id, jobName: record.jobName, actor, @@ -4245,7 +4260,7 @@ app.post('/admin/jobs/dead-letters/:id/retry', validateApiKey, async (req: Reque } const outcome = await retryDeadLetter(req.params.id); - void recordAdminAuditLog(req, 'jobs.dlq.retry', outcome.success ? 200 : 500, { + recordAdminAuditLogInBackground(req, 'jobs.dlq.retry', outcome.success ? 200 : 500, { id: req.params.id, jobName: record.jobName, success: outcome.success, @@ -4291,7 +4306,7 @@ app.post('/admin/jobs/dead-letters/:id/resolve', validateApiKey, validate({ para return; } - void recordAdminAuditLog(req, 'jobs.dlq.resolve', 200, { + recordAdminAuditLogInBackground(req, 'jobs.dlq.resolve', 200, { id: req.params.id, jobName: record.jobName, actor, @@ -4337,7 +4352,7 @@ app.delete('/admin/jobs/dead-letters/:id', validateApiKey, (req: Request, res: R return; } - void recordAdminAuditLog(req, 'jobs.dlq.discard', 200, { + recordAdminAuditLogInBackground(req, 'jobs.dlq.discard', 200, { id: req.params.id, jobName: record.jobName, actor, @@ -4383,7 +4398,7 @@ app.post('/admin/jobs/dead-letters/bulk-retry', validateApiKey, validate({ body: const summary = await bulkRetryDeadLetters(ids); - void recordAdminAuditLog(req, 'jobs.dlq.bulk_retry', 200, { + recordAdminAuditLogInBackground(req, 'jobs.dlq.bulk_retry', 200, { requestedCount: ids.length, retried: summary.retried, failed: summary.failed, @@ -4432,7 +4447,7 @@ app.post('/admin/jobs/dead-letters/bulk-discard', validateApiKey, validate({ bod const summary = bulkDiscardDeadLetters(ids); - void recordAdminAuditLog(req, 'jobs.dlq.bulk_discard', 200, { + recordAdminAuditLogInBackground(req, 'jobs.dlq.bulk_discard', 200, { requestedCount: ids.length, discardedCount: summary.discarded, actor, @@ -4469,7 +4484,7 @@ app.post('/admin/jobs/dead-letters/process', validateApiKey, validate({ body: De const summary = await processDeadLetterQueue(batchSize); - void recordAdminAuditLog(req, 'jobs.dlq.process', 200, { + recordAdminAuditLogInBackground(req, 'jobs.dlq.process', 200, { batchSize, processed: summary.processed, succeeded: summary.succeeded, @@ -4679,7 +4694,7 @@ app.delete('/admin/webhooks/deduplication/:eventId', validateApiKey, (req: Reque return; } - void recordAdminAuditLog(req, 'webhook.dedup.entry.removed', 200, { eventId }); + recordAdminAuditLogInBackground(req, 'webhook.dedup.entry.removed', 200, { eventId }); res.status(200).json({ message: `Deduplication entry '${eventId}' removed`, @@ -4718,7 +4733,7 @@ app.delete('/admin/webhooks/deduplication', validateApiKey, (req: Request, res: webhookDeduplicationStore.flush(); - void recordAdminAuditLog(req, 'webhook.dedup.store.flushed', 200, { + recordAdminAuditLogInBackground(req, 'webhook.dedup.store.flushed', 200, { flushedCount: metrics.activeFingerprints, }); @@ -5206,7 +5221,7 @@ app.post('/admin/scoped-tokens', validateApiKey, validate({ body: ScopedTokenCre expiresInSeconds: typeof expiresInSeconds === 'number' && expiresInSeconds > 0 ? expiresInSeconds : undefined, createdBy: actor, }); - void recordAdminAuditLog(req, 'scoped-token.created', 201, { keyId: token.keyId, label: token.label, permissions: token.permissions, actor }); + recordAdminAuditLogInBackground(req, 'scoped-token.created', 201, { keyId: token.keyId, label: token.label, permissions: token.permissions, actor }); res.status(201).json({ message: 'Scoped admin token created', keyId: token.keyId, secret, label: token.label, permissions: token.permissions, expiresAt: token.expiresAt, createdAt: token.createdAt }); } catch (error) { res.status(400).json({ error: 'Bad Request', status: 400, message: error instanceof Error ? error.message : 'Failed to create scoped token' }); @@ -5271,7 +5286,7 @@ app.post('/admin/scoped-tokens/:keyId/rotate', validateApiKey, async (req: Reque res.status(404).json({ error: 'Not Found', status: 404, message: 'Scoped token not found or already revoked' }); return; } - void recordAdminAuditLog(req, 'scoped-token.rotated', 200, { keyId: result.keyId, rotatedAt: result.rotatedAt, actor }); + recordAdminAuditLogInBackground(req, 'scoped-token.rotated', 200, { keyId: result.keyId, rotatedAt: result.rotatedAt, actor }); res.status(200).json({ message: 'Scoped token rotated', keyId: result.keyId, newSecret: result.newSecret, rotatedAt: result.rotatedAt }); }); @@ -5291,7 +5306,7 @@ app.post('/admin/scoped-tokens/:keyId/revoke', validateApiKey, async (req: Reque res.status(404).json({ error: 'Not Found', status: 404, message: 'Scoped token not found or already revoked' }); return; } - void recordAdminAuditLog(req, 'scoped-token.revoked', 200, { keyId: req.params.keyId, actor }); + recordAdminAuditLogInBackground(req, 'scoped-token.revoked', 200, { keyId: req.params.keyId, actor }); res.status(200).json({ message: 'Scoped token revoked', keyId: req.params.keyId, timestamp: new Date().toISOString() }); }); @@ -5391,7 +5406,7 @@ app.post( return; } - void recordAdminAuditLog(req, 'withdrawal-recovery.resumed', 200, { + recordAdminAuditLogInBackground(req, 'withdrawal-recovery.resumed', 200, { sagaId: outcome.saga.id, withdrawalId: outcome.saga.withdrawalId, status: outcome.status, @@ -5454,7 +5469,7 @@ app.post( return; } - void recordAdminAuditLog(req, 'withdrawal-recovery.resolved', 200, { + recordAdminAuditLogInBackground(req, 'withdrawal-recovery.resolved', 200, { sagaId: saga.id, withdrawalId: saga.withdrawalId, outcome: saga.status, @@ -5471,7 +5486,7 @@ app.post( */ app.post('/admin/withdrawals/recovery/sweep', validateApiKey, async (req: Request, res: Response) => { const result = await withdrawalRecoveryCoordinator.sweep(); - void recordAdminAuditLog(req, 'withdrawal-recovery.swept', 200, { + recordAdminAuditLogInBackground(req, 'withdrawal-recovery.swept', 200, { resumed: result.resumed.length, stale: result.stale.length, actor: resolveActingAdminAddress(req), @@ -5524,7 +5539,7 @@ app.post( data: { name, symbol, tenantId }, }); - void recordAdminAuditLog(req, 'vault.created', 201, { + recordAdminAuditLogInBackground(req, 'vault.created', 201, { vaultId: vault.id, name: vault.name, symbol: vault.symbol, diff --git a/backend/src/types/express-async-errors.d.ts b/backend/src/types/express-async-errors.d.ts new file mode 100644 index 000000000..fea09e766 --- /dev/null +++ b/backend/src/types/express-async-errors.d.ts @@ -0,0 +1 @@ +declare module 'express-async-errors'; diff --git a/backend/src/vaultEndpoints.ts b/backend/src/vaultEndpoints.ts index 9a25673d6..1f33f8ddb 100644 --- a/backend/src/vaultEndpoints.ts +++ b/backend/src/vaultEndpoints.ts @@ -766,33 +766,12 @@ router.post( cooldownTotal: cooldownSec, }); } -router.post('/strategy', depositsLimiter, requireFlag('strategy-selection'), validate({ body: VaultStrategyBodySchema }), (req: Request, res: Response) => { - const cooldownSec = parseInt(process.env.STRATEGY_SWITCH_COOLDOWN_SEC || '0', 10); - const lastSwitchIso = process.env.LAST_STRATEGY_SWITCH_TIME || null; - const now = Date.now(); - const lastSwitchMs = lastSwitchIso ? new Date(lastSwitchIso).getTime() : 0; - - if (cooldownSec > 0 && lastSwitchMs > 0) { - const elapsed = Math.floor((now - lastSwitchMs) / 1000); - if (elapsed < cooldownSec) { - const retryAfter = cooldownSec - elapsed; - res.setHeader('Retry-After', String(retryAfter)); - res.status(429).json({ - error: 'Too Many Requests', - status: 429, - code: 'STRATEGY_COOLDOWN_ACTIVE', - message: `Strategy switch cooldown active. Retry in ${retryAfter}s.`, - cooldownRemaining: retryAfter, - cooldownTotal: cooldownSec, - }); - return; } const strategyId = typeof req.body?.strategyId === 'string' ? req.body.strategyId : 'default'; const previousStrategyId = typeof req.body?.previousStrategyId === 'string' ? req.body.previousStrategyId : undefined; - // Record switch time for cooldown tracking process.env.LAST_STRATEGY_SWITCH_TIME = new Date().toISOString(); void eventOutboxService @@ -822,11 +801,9 @@ router.post('/strategy', depositsLimiter, requireFlag('strategy-selection'), val }); }); - res.status(200).json({ message: 'Strategy selection endpoint (v2 preview)' }); + return res.status(200).json({ message: 'Strategy selection endpoint (v2 preview)' }); } ); - return res.status(200).json({ message: 'Strategy selection endpoint (v2 preview)' }); -}); /** * POST /api/v1/vault/gasless-deposits diff --git a/backend/src/walletAliasEndpoints.ts b/backend/src/walletAliasEndpoints.ts index 4d385a0f5..64f871e20 100644 --- a/backend/src/walletAliasEndpoints.ts +++ b/backend/src/walletAliasEndpoints.ts @@ -1,7 +1,9 @@ -import { Router, Request, Response } from 'express'; -import { walletAliasMappingService } from './walletAliasService'; +import { Router, Response } from 'express'; +import { WalletAliasConflictError, walletAliasMappingService } from './walletAliasService'; import { validate, WalletAliasLinkSchema, WalletAliasResolveQuerySchema } from './middleware/validate'; -import { readsLimiter } from './rateLimiter'; +import { requireAuth, type AuthenticatedRequest } from './auth'; +import { readsLimiter, writesLimiter } from './rateLimiter'; +import { isValidStellarAddress, normalizeWalletAddress } from './walletUtils'; const router = Router(); @@ -11,9 +13,10 @@ const router = Router(); */ router.post( '/link', - readsLimiter, + writesLimiter, + requireAuth, validate({ body: WalletAliasLinkSchema }), - async (req: Request, res: Response) => { + async (req: AuthenticatedRequest, res: Response) => { const { primaryAlias, primarySource, linkedAlias, linkedSource } = req.body as { primaryAlias: string; primarySource: string; @@ -21,7 +24,47 @@ router.post( linkedSource: string; }; + const authenticatedWallet = req.jwtPayload?.sub; + const linkedSourceIsStellar = + linkedSource.trim().toLowerCase().replace(/[^a-z0-9]+/g, '') === 'stellar'; + if ( + !authenticatedWallet || + primarySource.toLowerCase() !== 'stellar' || + !isValidStellarAddress(primaryAlias) || + normalizeWalletAddress(primaryAlias) !== normalizeWalletAddress(authenticatedWallet) || + (linkedSourceIsStellar && + (!isValidStellarAddress(linkedAlias) || + normalizeWalletAddress(linkedAlias) !== normalizeWalletAddress(authenticatedWallet))) + ) { + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'The primary and linked Stellar wallets must belong to the authenticated session', + }); + return; + } + try { + const [primaryMapping, linkedMapping] = await Promise.all([ + walletAliasMappingService.resolveAlias(primaryAlias, primarySource), + walletAliasMappingService.resolveAlias(linkedAlias, linkedSource), + ]); + + if ( + (primaryMapping && !ownsMapping(primaryMapping, authenticatedWallet)) || + (linkedMapping && !ownsMapping(linkedMapping, authenticatedWallet)) || + (primaryMapping && + linkedMapping && + primaryMapping.canonicalId !== linkedMapping.canonicalId) + ) { + res.status(409).json({ + error: 'Conflict', + status: 409, + message: 'The requested aliases are already linked to a different wallet identity', + }); + return; + } + const mapping = await walletAliasMappingService.linkProviderIdentity( primaryAlias, primarySource, @@ -40,6 +83,15 @@ router.post( canonicalWallet, }); } catch (err) { + if (err instanceof WalletAliasConflictError) { + res.status(409).json({ + error: 'Conflict', + status: 409, + message: err.message, + }); + return; + } + res.status(400).json({ error: 'Bad Request', status: 400, @@ -55,9 +107,10 @@ router.post( */ router.get( '/resolve', + requireAuth, readsLimiter, validate({ query: WalletAliasResolveQuerySchema }), - async (req: Request, res: Response) => { + async (req: AuthenticatedRequest, res: Response) => { const { alias, source } = req.query as { alias: string; source: string }; const mapping = await walletAliasMappingService.resolveAlias(alias, source); @@ -70,11 +123,19 @@ router.get( return; } - const canonicalWallet = await walletAliasMappingService.resolveCanonicalWallet(alias, source); + const authenticatedWallet = req.jwtPayload?.sub; + if (!authenticatedWallet || !ownsMapping(mapping, authenticatedWallet)) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'No identity linkage found for the provided alias', + }); + return; + } res.status(200).json({ ...mapping, - canonicalWallet, + canonicalWallet: normalizeWalletAddress(authenticatedWallet), }); }, ); @@ -83,19 +144,36 @@ router.get( * GET /api/v1/wallet-aliases/:canonicalId * Returns all aliases linked to a canonical identity. */ -router.get('/:canonicalId', readsLimiter, async (req: Request, res: Response) => { - const mapping = await walletAliasMappingService.getIdentityLinks(req.params.canonicalId); - - if (!mapping) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Canonical identity not found', - }); - return; - } +router.get( + '/:canonicalId', + requireAuth, + readsLimiter, + async (req: AuthenticatedRequest, res: Response) => { + const mapping = await walletAliasMappingService.getIdentityLinks(req.params.canonicalId); + + if (!mapping || !req.jwtPayload?.sub || !ownsMapping(mapping, req.jwtPayload.sub)) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Canonical identity not found', + }); + return; + } + + res.status(200).json(mapping); + }, +); - res.status(200).json(mapping); -}); +function ownsMapping( + mapping: { aliases: string[] }, + walletAddress: string, +): boolean { + const normalizedWallet = normalizeWalletAddress(walletAddress); + return mapping.aliases.some( + (alias) => + isValidStellarAddress(alias) && + normalizeWalletAddress(alias) === normalizedWallet, + ); +} export default router; diff --git a/backend/src/walletAliasService.ts b/backend/src/walletAliasService.ts index 395a0ad56..c69d98cc9 100644 --- a/backend/src/walletAliasService.ts +++ b/backend/src/walletAliasService.ts @@ -13,6 +13,13 @@ export interface WalletAliasMapping { sources: string[]; } +export class WalletAliasConflictError extends Error { + constructor() { + super('The aliases are already linked to different canonical identities'); + this.name = 'WalletAliasConflictError'; + } +} + type PrismaTransaction = Parameters[0]>[0]; type PrismaExecutor = PrismaClient | PrismaTransaction; @@ -181,8 +188,95 @@ export class WalletAliasMappingService { linkedAlias: string, linkedSource: string, ): Promise { - const primary = await this.registerAlias(primaryAlias, primarySource); - return this.registerAlias(linkedAlias, linkedSource, primary.canonicalId); + const normalizedPrimarySource = this.normalizeSource(primarySource); + const normalizedLinkedSource = this.normalizeSource(linkedSource); + const normalizedPrimaryAlias = this.normalizeAlias(primaryAlias, normalizedPrimarySource); + const normalizedLinkedAlias = this.normalizeAlias(linkedAlias, normalizedLinkedSource); + + if (!normalizedPrimaryAlias || !normalizedLinkedAlias) { + throw new Error('Alias value is required'); + } + if (!normalizedPrimarySource || !normalizedLinkedSource) { + throw new Error('Source value is required'); + } + + await this.loadFromDatabase(); + + const result = await this.prismaProvider().$transaction( + async (tx) => { + const primaryAliasKey = this.buildAliasKey(normalizedPrimaryAlias, normalizedPrimarySource); + const linkedAliasKey = this.buildAliasKey(normalizedLinkedAlias, normalizedLinkedSource); + + for (const source of new Set([normalizedPrimarySource, normalizedLinkedSource])) { + await tx.walletAliasSource.upsert({ + where: { source }, + create: { source }, + update: {}, + }); + } + + const [existingPrimary, existingLinked] = await Promise.all([ + tx.walletAlias.findUnique({ where: { aliasKey: primaryAliasKey } }), + tx.walletAlias.findUnique({ where: { aliasKey: linkedAliasKey } }), + ]); + + if ( + existingPrimary && + existingLinked && + existingPrimary.canonicalId !== existingLinked.canonicalId + ) { + throw new WalletAliasConflictError(); + } + + const canonicalId = + existingPrimary?.canonicalId ?? + existingLinked?.canonicalId ?? + (await this.createCanonicalId(tx)); + + await tx.walletCanonicalIdentity.upsert({ + where: { id: canonicalId }, + create: { id: canonicalId }, + update: {}, + }); + + for (const entry of [ + { + alias: normalizedPrimaryAlias, + source: normalizedPrimarySource, + aliasKey: primaryAliasKey, + }, + { + alias: normalizedLinkedAlias, + source: normalizedLinkedSource, + aliasKey: linkedAliasKey, + }, + ]) { + await tx.walletAlias.upsert({ + where: { aliasKey: entry.aliasKey }, + create: { + canonicalId, + alias: entry.alias, + source: entry.source, + aliasKey: entry.aliasKey, + }, + update: { + canonicalId, + alias: entry.alias, + source: entry.source, + }, + }); + } + + return this.getIdentityLinksFromDatabase(canonicalId, tx); + }, + { isolationLevel: 'Serializable' }, + ); + + await this.loadFromDatabase(true); + if (!result) { + throw new Error('Failed to link wallet aliases'); + } + return result; } /**