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
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -247,6 +279,7 @@ jobs:
- frontend-typecheck
- frontend-lint
- frontend-coverage
- sdk-bundle-size
if: always()
steps:
- name: Check all jobs
Expand Down
7 changes: 7 additions & 0 deletions backend/migrations/add_ip_to_admin_audit_log.sql
Original file line number Diff line number Diff line change
@@ -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);
6 changes: 4 additions & 2 deletions backend/src/admin-audit-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface AuditLogEntry {
target: string | null;
params_json: Record<string, unknown> | null;
tx_hash: string | null;
ip_address: string | null;
created_at: Date;
}

Expand All @@ -24,14 +25,15 @@ export class AdminAuditLogService {

async log(entry: Omit<AuditLogEntry, 'id' | 'created_at'>): Promise<void> {
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,
]
);
}
Expand Down
64 changes: 64 additions & 0 deletions backend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
172 changes: 172 additions & 0 deletions monitoring/dashboards/swiftremit-business.json
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions monitoring/provisioning/dashboards/swiftremit.yml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading