diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1852abf..2832f298 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -230,6 +230,38 @@ jobs: name: frontend-coverage-report path: frontend/coverage/ retention-days: 14 + # ── SDK browser bundle size check ───────────────────────────────────────── + sdk-bundle-size: + name: SDK browser bundle size (warn > 100 KB gzip) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + cache-dependency-path: sdk/package-lock.json + - run: npm ci + working-directory: sdk + - run: npm run build + working-directory: sdk + - name: Check gzipped browser bundle size + working-directory: sdk + run: | + FILE="dist/browser/index.mjs" + if [ ! -f "$FILE" ]; then + echo "::warning::Browser bundle not found at $FILE" + exit 0 + fi + SIZE=$(gzip -c "$FILE" | wc -c) + SIZE_KB=$(echo "scale=1; $SIZE / 1024" | bc) + echo "Browser bundle gzipped size: ${SIZE_KB} KB" + if [ "$SIZE" -gt 102400 ]; then + echo "::warning file=$FILE::Browser bundle is ${SIZE_KB} KB gzipped — exceeds 100 KB budget" + else + echo "✅ Bundle size ${SIZE_KB} KB gzipped is within 100 KB budget" + fi + # ── Summary gate ─────────────────────────────────────────────────────────── ci: name: CI @@ -247,6 +279,7 @@ jobs: - frontend-typecheck - frontend-lint - frontend-coverage + - sdk-bundle-size if: always() steps: - name: Check all jobs diff --git a/backend/migrations/add_ip_to_admin_audit_log.sql b/backend/migrations/add_ip_to_admin_audit_log.sql new file mode 100644 index 00000000..b154cb17 --- /dev/null +++ b/backend/migrations/add_ip_to_admin_audit_log.sql @@ -0,0 +1,7 @@ +-- Migration: add_ip_to_admin_audit_log +-- Adds ip_address column to admin_audit_log for compliance capture. + +ALTER TABLE admin_audit_log + ADD COLUMN IF NOT EXISTS ip_address VARCHAR(45); + +CREATE INDEX IF NOT EXISTS idx_audit_ip ON admin_audit_log(ip_address); diff --git a/backend/src/admin-audit-log.ts b/backend/src/admin-audit-log.ts index 059a7b43..952d9d65 100644 --- a/backend/src/admin-audit-log.ts +++ b/backend/src/admin-audit-log.ts @@ -7,6 +7,7 @@ export interface AuditLogEntry { target: string | null; params_json: Record | null; tx_hash: string | null; + ip_address: string | null; created_at: Date; } @@ -24,14 +25,15 @@ export class AdminAuditLogService { async log(entry: Omit): Promise { await this.pool.query( - `INSERT INTO admin_audit_log (admin_address, action, target, params_json, tx_hash) - VALUES ($1, $2, $3, $4, $5)`, + `INSERT INTO admin_audit_log (admin_address, action, target, params_json, tx_hash, ip_address) + VALUES ($1, $2, $3, $4, $5, $6)`, [ entry.admin_address, entry.action, entry.target ?? null, entry.params_json ? JSON.stringify(entry.params_json) : null, entry.tx_hash ?? null, + entry.ip_address ?? null, ] ); } diff --git a/backend/src/api.ts b/backend/src/api.ts index 4a64d255..4e7238ca 100644 --- a/backend/src/api.ts +++ b/backend/src/api.ts @@ -189,6 +189,16 @@ app.post('/api/webhooks/:id/rotate-secret', adminLimiter, async (req: Request, r const { newSecret, rotatedAt } = await rotateWebhookSecret(id); const subscriber = await getWebhookSubscriberById(id); + const auditService = new AdminAuditLogService(pool); + await auditService.log({ + admin_address: (req.headers['x-user-id'] as string) || 'unknown', + action: 'rotate_webhook_secret', + target: id, + params_json: null, + tx_hash: null, + ip_address: (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() ?? req.socket.remoteAddress ?? null, + }); + // Notify subscriber of new secret via a signed delivery (best-effort) if (subscriber?.url) { try { @@ -528,6 +538,16 @@ app.post('/api/kyc/config', async (req: Request, res: Response) => { await saveAnchorKycConfig(config); + const auditService = new AdminAuditLogService(pool); + await auditService.log({ + admin_address: (req.headers['x-user-id'] as string) || 'unknown', + action: 'configure_kyc', + target: anchorId, + params_json: { kycServerUrl, pollingIntervalMinutes, enabled }, + tx_hash: null, + ip_address: (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() ?? req.socket.remoteAddress ?? null, + }); + res.json({ success: true, message: 'Anchor KYC config saved successfully' }); } catch (error) { console.error('Error saving anchor KYC config:', error); @@ -570,6 +590,16 @@ app.post('/api/kyc/register', async (req: Request, res: Response) => { const service = new kycService(); await service.registerUserForKyc(userId, anchorId); + const auditService = new AdminAuditLogService(pool); + await auditService.log({ + admin_address: (req.headers['x-user-id'] as string) || 'unknown', + action: 'register_kyc_user', + target: userId, + params_json: { anchorId }, + tx_hash: null, + ip_address: (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() ?? req.socket.remoteAddress ?? null, + }); + res.json({ success: true, message: 'User registered for KYC successfully' }); } catch (error) { console.error('Error registering user for KYC:', error); @@ -831,6 +861,40 @@ app.get('/api/admin/audit-log', async (req: Request, res: Response) => { } }); +// Compliance export — streams all audit log entries as newline-delimited JSON +app.get('/api/admin/audit-log/export', adminLimiter, async (req: Request, res: Response) => { + try { + const auditService = new AdminAuditLogService(pool); + const filter = { + admin_address: req.query.admin_address as string | undefined, + action: req.query.action as string | undefined, + from: req.query.from ? new Date(req.query.from as string) : undefined, + to: req.query.to ? new Date(req.query.to as string) : undefined, + limit: 200, + offset: 0, + }; + + res.setHeader('Content-Type', 'application/x-ndjson'); + res.setHeader('Content-Disposition', 'attachment; filename="audit-log.ndjson"'); + + let offset = 0; + while (true) { + filter.offset = offset; + const { entries } = await auditService.query(filter); + if (entries.length === 0) break; + for (const entry of entries) { + res.write(JSON.stringify(entry) + '\n'); + } + if (entries.length < filter.limit) break; + offset += filter.limit; + } + res.end(); + } catch (error) { + logger.error('Error exporting audit log', error); + if (!res.headersSent) res.status(500).json({ error: 'Failed to export audit log' }); + } +}); + // ── Contract Events ────────────────────────────────────────────────────────── // Persist contract events emitted by the remittance event emitter diff --git a/docker-compose.yml b/docker-compose.yml index bdace332..37e593b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -94,5 +94,31 @@ services: - api - backend + prometheus: + image: prom/prometheus:v2.52.0 + restart: unless-stopped + ports: + - "9090:9090" + volumes: + - ./backend/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./backend/monitoring/alert_rules.yml:/etc/prometheus/alert_rules.yml:ro + command: + - --config.file=/etc/prometheus/prometheus.yml + + grafana: + image: grafana/grafana:10.4.2 + restart: unless-stopped + ports: + - "3003:3000" + environment: + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} + volumes: + - grafana_data:/var/lib/grafana + - ./monitoring/provisioning/dashboards/swiftremit.yml:/etc/grafana/provisioning/dashboards/swiftremit.yml:ro + - ./monitoring/dashboards:/etc/grafana/provisioning/dashboards:ro + depends_on: + - prometheus + volumes: postgres_data: + grafana_data: diff --git a/monitoring/dashboards/swiftremit-business.json b/monitoring/dashboards/swiftremit-business.json new file mode 100644 index 00000000..beae23a7 --- /dev/null +++ b/monitoring/dashboards/swiftremit-business.json @@ -0,0 +1,172 @@ +{ + "__inputs": [], + "__requires": [ + { "type": "grafana", "id": "grafana", "name": "Grafana", "version": "10.0.0" }, + { "type": "datasource", "id": "prometheus", "name": "Prometheus", "version": "1.0.0" } + ], + "annotations": { "list": [] }, + "description": "SwiftRemit key business metrics", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "id": 1, + "title": "Transfer Volume by Corridor ($/hr)", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [ + { + "expr": "sum by (from_currency, to_country) (rate(swiftremit_remittance_volume_stroops_total[1h]))", + "legendFormat": "{{from_currency}} → {{to_country}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { "unit": "short", "displayName": "${__series.name}" }, + "overrides": [] + } + }, + { + "id": 2, + "title": "KYC Approval Rate (%)", + "type": "gauge", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 6, "x": 12, "y": 0 }, + "targets": [ + { + "expr": "100 * sum(swiftremit_kyc_approved_total) / (sum(swiftremit_kyc_approved_total) + sum(swiftremit_kyc_rejected_total) + 1e-9)", + "legendFormat": "Approval Rate", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 70 }, + { "color": "green", "value": 90 } + ] + } + }, + "overrides": [] + } + }, + { + "id": 3, + "title": "Fee Revenue (USDC/hr)", + "type": "stat", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 6, "x": 18, "y": 0 }, + "targets": [ + { + "expr": "sum(rate(swiftremit_accumulated_fees_stroops_total[1h])) / 1e7", + "legendFormat": "Fee Revenue USDC/hr", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2, + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "blue", "value": null } + ] + } + }, + "overrides": [] + } + }, + { + "id": 4, + "title": "Active Agents", + "type": "stat", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 6, "x": 0, "y": 8 }, + "targets": [ + { + "expr": "swiftremit_active_agents_total", + "legendFormat": "Active Agents", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "green", "value": 5 } + ] + } + }, + "overrides": [] + } + }, + { + "id": 5, + "title": "Webhook Delivery Rate (%)", + "type": "timeseries", + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "gridPos": { "h": 8, "w": 18, "x": 6, "y": 8 }, + "targets": [ + { + "expr": "100 * rate(swiftremit_webhook_deliveries_total{result=\"success\"}[5m]) / (rate(swiftremit_webhook_deliveries_total[5m]) + 1e-9)", + "legendFormat": "Delivery Success Rate", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 80 }, + { "color": "green", "value": 95 } + ] + } + }, + "overrides": [] + } + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["swiftremit", "business"], + "templating": { + "list": [ + { + "name": "datasource", + "type": "datasource", + "pluginId": "prometheus", + "label": "Datasource", + "current": {}, + "hide": 0 + } + ] + }, + "time": { "from": "now-6h", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "SwiftRemit Business Metrics", + "uid": "swiftremit-business", + "version": 1 +} diff --git a/monitoring/provisioning/dashboards/swiftremit.yml b/monitoring/provisioning/dashboards/swiftremit.yml new file mode 100644 index 00000000..954461f0 --- /dev/null +++ b/monitoring/provisioning/dashboards/swiftremit.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: SwiftRemit + orgId: 1 + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards diff --git a/sdk/package.json b/sdk/package.json index 1ac809a2..3e953e7e 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -5,8 +5,10 @@ "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", + "browser": "dist/browser/index.mjs", "exports": { ".": { + "browser": "./dist/browser/index.mjs", "import": "./dist/index.mjs", "require": "./dist/index.js", "types": "./dist/index.d.ts" @@ -16,7 +18,7 @@ "dist" ], "scripts": { - "build": "tsup src/index.ts --format cjs,esm --dts --clean", + "build": "tsup", "test": "vitest run", "lint": "tsc --noEmit" }, diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 72a2f487..fafe23d9 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -17,6 +17,8 @@ import type { HealthStatus, CreateRemittanceParams, BatchCreateEntry, + BatchCreateResult, + BatchCreateResponse, GovernanceConfig, DailyLimitStatus, Proposal, @@ -474,6 +476,54 @@ export class SwiftRemitClient { ]); } + /** + * Create multiple remittances with per-item success/failure handling. + * Each entry is prepared independently; failures don't abort the batch. + * Returns a BatchCreateResponse with per-item results. + */ + async createRemittanceBatch( + sender: string, + entries: BatchCreateEntry[] + ): Promise { + if (entries.length === 0) { + throw new SwiftRemitError(ErrorCode.InvalidBatchSize, "Batch must contain at least one entry"); + } + if (entries.length > MAX_BATCH_SIZE) { + throw new SwiftRemitError( + ErrorCode.InvalidBatchSize, + `Batch size ${entries.length} exceeds MAX_BATCH_SIZE (${MAX_BATCH_SIZE})` + ); + } + + const results: BatchCreateResult[] = await Promise.all( + entries.map(async (entry, index): Promise => { + try { + const tx = await withRetry( + () => + this.createRemittance({ + sender, + agent: entry.agent, + amount: entry.amount, + expiry: entry.expiry, + }), + this.retries, + this.retryDelayMs, + this.retryBackoffFactor + ); + return { index, entry, success: true, tx }; + } catch (err) { + return { index, entry, success: false, error: err instanceof Error ? err : new Error(String(err)) }; + } + }) + ); + + return { + results, + successCount: results.filter((r) => r.success).length, + failureCount: results.filter((r) => !r.success).length, + }; + } + /** Create multiple remittances in one batch. */ async batchCreateRemittances( sender: string, diff --git a/sdk/src/index.ts b/sdk/src/index.ts index ef0c80d5..e1ea1c2f 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -14,6 +14,8 @@ export type { HealthStatus, FeeBreakdown, BatchCreateEntry, + BatchCreateResult, + BatchCreateResponse, CreateRemittanceParams, SettlementConfig, EscrowStatus, diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 828e5b65..099f734b 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -125,7 +125,23 @@ export interface FeeBreakdown { netAmount: bigint; } -export interface BatchCreateEntry { +/** Per-item result from createRemittanceBatch. */ +export interface BatchCreateResult { + index: number; + entry: BatchCreateEntry; + success: boolean; + tx?: import("@stellar/stellar-sdk").Transaction; + error?: Error; +} + +/** Response from createRemittanceBatch. */ +export interface BatchCreateResponse { + results: BatchCreateResult[]; + successCount: number; + failureCount: number; +} + + agent: string; /** Amount in stroops */ amount: bigint; diff --git a/sdk/tsup.config.ts b/sdk/tsup.config.ts new file mode 100644 index 00000000..6419b249 --- /dev/null +++ b/sdk/tsup.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from "tsup"; + +export default defineConfig([ + // Node.js: CJS + ESM with full types + { + entry: { index: "src/index.ts" }, + format: ["cjs", "esm"], + dts: true, + clean: true, + sourcemap: true, + target: "node18", + outDir: "dist", + }, + // Browser: IIFE (UMD-compatible) + ESM, no Node.js built-ins + { + entry: { "browser/index": "src/index.ts" }, + format: ["iife", "esm"], + globalName: "SwiftRemitSDK", + dts: false, + clean: false, + sourcemap: true, + target: "es2020", + outDir: "dist", + platform: "browser", + // Prevent Node.js built-ins from leaking into the browser bundle + noExternal: [], + external: ["@stellar/stellar-sdk"], + esbuildOptions(options) { + options.conditions = ["browser"]; + }, + }, +]);