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
92 changes: 92 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,95 @@ TELEGRAM_ADMIN_CHAT_IDS=

# Enable/disable Telegram bot entirely
TELEGRAM_BOT_ENABLED=true

# -----------------------------------------------------------------------------
# Security & CORS
# -----------------------------------------------------------------------------
# Comma-separated list of allowed origins in production (e.g. https://app.bridgewatch.io)
CORS_ALLOWED_ORIGINS=
# Bootstrap token for API keys administration
API_KEY_BOOTSTRAP_TOKEN=

# -----------------------------------------------------------------------------
# Advanced Logging
# -----------------------------------------------------------------------------
# File path to write logs to (if not set, writes to stdout)
LOG_FILE=
# Maximum log file size before rotation in bytes (default 100MB)
LOG_MAX_FILE_SIZE=104857600
# Maximum log files to retain
LOG_MAX_FILES=10
# Number of days to retain rotated logs
LOG_RETENTION_DAYS=30
# Log the full request bodies (warning: high volume/noise)
LOG_REQUEST_BODY=false
# Log the full response bodies
LOG_RESPONSE_BODY=false
# Allow logging sensitive data
LOG_SENSITIVE_DATA=false
# Threshold in milliseconds to count a request as slow (default 1s)
REQUEST_SLOW_THRESHOLD_MS=1000

# -----------------------------------------------------------------------------
# Enhanced Rate Limiting
# -----------------------------------------------------------------------------
RATE_LIMIT_ENABLE_DYNAMIC=true
RATE_LIMIT_GLOBAL_ALERT_THRESHOLD=0.9
RATE_LIMIT_BURST_ALERT_THRESHOLD=0.8
RATE_LIMIT_SUSTAINED_ALERT_THRESHOLD=0.7
RATE_LIMIT_STATS_RETENTION_HOURS=168
RATE_LIMIT_ENABLE_MONITORING=true
RATE_LIMIT_ADMIN_API_KEY_PREFIX=admin_

# Per-endpoint rate limits (requests per window)
RATE_LIMIT_ENDPOINT_ASSETS=200
RATE_LIMIT_ENDPOINT_BRIDGES=150
RATE_LIMIT_ENDPOINT_ALERTS=50
RATE_LIMIT_ENDPOINT_ANALYTICS=100
RATE_LIMIT_ENDPOINT_CONFIG=30
RATE_LIMIT_ENDPOINT_HEALTH=1000

# -----------------------------------------------------------------------------
# Price Caching & WebSocket
# -----------------------------------------------------------------------------
REDIS_PRICE_CACHE_PREFIX=price:aggregated
# Secret token required to subscribe to private WebSocket channels (e.g. "alerts")
WS_AUTH_SECRET=

# -----------------------------------------------------------------------------
# Discord Bot Integration
# -----------------------------------------------------------------------------
DISCORD_BOT_TOKEN=
DISCORD_CLIENT_ID=

# -----------------------------------------------------------------------------
# Health Check Configuration
# -----------------------------------------------------------------------------
HEALTH_CHECK_TIMEOUT_MS=5000
HEALTH_CHECK_INTERVAL_MS=30000
HEALTH_CHECK_MEMORY_THRESHOLD=90
HEALTH_CHECK_DISK_THRESHOLD=80
HEALTH_CHECK_EXTERNAL_APIS=true

# -----------------------------------------------------------------------------
# Maintenance Mode
# -----------------------------------------------------------------------------
MAINTENANCE_MODE=false
MAINTENANCE_MESSAGE=System is under maintenance
MAINTENANCE_SEVERITY=warning
STATUS_PAGE_URL=

# -----------------------------------------------------------------------------
# Data Validation
# -----------------------------------------------------------------------------
VALIDATION_STRICT_MODE=false
VALIDATION_ADMIN_BYPASS=true
VALIDATION_BATCH_SIZE=100
VALIDATION_MAX_BATCH_SIZE=1000
VALIDATION_DUPLICATE_CHECK=true
VALIDATION_NORMALIZATION=true
VALIDATION_CONSISTENCY_CHECKS=true
VALIDATION_ERROR_THRESHOLD=0.1
VALIDATION_WARNING_THRESHOLD=0.3
VALIDATION_DATA_QUALITY_THRESHOLD=70

11 changes: 3 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,17 +148,16 @@ jobs:
run: npm ci

- name: Lint
run: npm run lint
run: npm --workspace=frontend run lint

- name: Build
run: npm run build
run: npm --workspace=frontend run build

- name: Storybook build
run: npm --workspace=frontend run build-storybook

- name: Test
run: npm run test
continue-on-error: true
run: npm --workspace=frontend run test

- name: Visual Regression Tests
run: npm --workspace=frontend run test:visual
Expand Down Expand Up @@ -206,16 +205,12 @@ jobs:

- name: Format Check
run: cargo fmt --all -- --check
continue-on-error: true

- name: Lint (Clippy)
run: cargo clippy -- -D warnings
continue-on-error: true

- name: Build
run: cargo build --verbose
continue-on-error: true

- name: Test
run: cargo test --verbose
continue-on-error: true
7 changes: 4 additions & 3 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ jobs:
contents: read

strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'typescript' ]
language: [ 'javascript-typescript' ]

steps:
- name: Checkout repository
Expand Down Expand Up @@ -51,9 +52,9 @@ jobs:
run: npm audit --audit-level=high
continue-on-error: true

# Rust audit requires cargo-audit, we'll use a pre-built action
# Rust audit using rustsec/audit-check
- name: Rust Audit
uses: actions-rs/audit-check@v1
uses: rustsec/audit-check@v2.0.0
continue-on-error: true
with:
token: ${{ secrets.GITHUB_TOKEN }}
6 changes: 3 additions & 3 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@
"@fastify/swagger-ui": "^5.2.5",
"@fastify/websocket": "^11.2.0",
"@stellar/stellar-sdk": "^12.3.0",
"@types/nodemailer": "^7.0.11",
"@types/pdfkit": "^0.17.5",
"bullmq": "^5.13.0",
"csv-stringify": "^6.7.0",
"discord.js": "^14.26.3",
Expand All @@ -61,14 +59,16 @@
},
"devDependencies": {
"@types/node": "^20.17.0",
"@types/nodemailer": "^7.0.11",
"@types/node-fetch": "^2.6.13",
"@types/pdfkit": "^0.17.5",
"@types/pg": "^8.11.10",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^2.1.9",
"eslint": "^8.57.1",
"tsx": "^4.19.2",
"typescript": "^5.9.3",
"vitest": "^2.1.5"
"vitest": "^2.1.9"
}
}
6 changes: 5 additions & 1 deletion backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const envSchema = z.object({
PORT: z.coerce.number().default(3001),
WS_PORT: z.coerce.number().default(3002),

// CORS — comma-separated list of allowed origins for production
CORS_ALLOWED_ORIGINS: z.string().optional(),

// PostgreSQL + TimescaleDB
POSTGRES_HOST: z.string().default("localhost"),
POSTGRES_PORT: z.coerce.number().default(5432),
Expand Down Expand Up @@ -203,7 +206,8 @@ export const SUPPORTED_ASSETS: StellarAssetConfig[] = [
{ code: "USDC", issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" },
{ code: "PYUSD", issuer: "GBHZAE5IQTOPQZ66TFWZYIYCHQ6T3GMWHDKFEXAKYWJ2BHLZQ227KRYE" },
{ code: "EURC", issuer: "GDQOE23CFSUMSVZZ4YRVXGW7PCFNIAHLMRAHDE4Z32DIBQGH4KZZK2KZ" },
{ code: "FOBXX", issuer: "GBX7VUT2UTUKO2H76J26D7QYWNFW6C2NYN6K74Y3K43HGBXYZ" },
// TODO: FOBXX issuer address is truncated (46 chars instead of 56). Verify correct address before enabling.
// { code: "FOBXX", issuer: "GBX7VUT2UTUKO2H76J26D7QYWNFW6C2NYN6K74Y3K43HGBXYZ" },
];

const parsed = envSchema.safeParse(process.env);
Expand Down
7 changes: 6 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,13 @@ export async function buildServer() {
await registerUsageMetrics(server as any);

// Register plugins
const corsOrigin = config.NODE_ENV === "production"
? (config as any).CORS_ALLOWED_ORIGINS
? (config as any).CORS_ALLOWED_ORIGINS.split(",").map((s: string) => s.trim())
: false // block all cross-origin in production if not configured
: true; // allow all origins in development/test
await server.register(cors, {
origin: true,
origin: corsOrigin,
credentials: true,
});

Expand Down
13 changes: 7 additions & 6 deletions backend/src/services/circuitBreaker.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as StellarSdk from "@stellar/stellar-sdk";
import { SorobanRpc } from "@stellar/stellar-sdk";
import { config } from "../config/index.js";
import { logger } from "../utils/logger.js";
import { getMetricsService } from "./metrics.service.js";
Expand Down Expand Up @@ -91,12 +92,12 @@ class CircuitBreakerService {
.build();

const result = await this.server.simulateTransaction(tx);
if ((result as any).result) {
if (SorobanRpc.Api.isSimulationSuccess(result)) {
return StellarSdk.xdr.ScVal.fromXDR((result as any).result.retval, 'base64').value() === 1;
}
return false;
} catch (error) {
logger.error({ error }, "Circuit breaker check failed");
logger.error(error as Error, "Circuit breaker check failed");
// In case of error, assume not paused to avoid blocking operations
return false;
}
Expand Down Expand Up @@ -124,12 +125,12 @@ class CircuitBreakerService {
.build();

const result = await this.server.simulateTransaction(tx);
if ((result as any).result) {
if (SorobanRpc.Api.isSimulationSuccess(result)) {
return StellarSdk.xdr.ScVal.fromXDR((result as any).result.retval, 'base64').value() === 1;
}
return false;
} catch (error) {
logger.error({ error }, "Whitelist check failed");
logger.error(error as Error, "Whitelist check failed");
return false;
}
}
Expand All @@ -154,12 +155,12 @@ class CircuitBreakerService {
.build();

const result = await this.server.simulateTransaction(tx);
if ((result as any).result) {
if (SorobanRpc.Api.isSimulationSuccess(result)) {
return StellarSdk.xdr.ScVal.fromXDR((result as any).result.retval, 'base64').value() === 1;
}
return false;
} catch (error) {
logger.error({ error }, "Asset whitelist check failed");
logger.error(error as Error, "Asset whitelist check failed");
return false;
}
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/formatters/telegram.formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function escapeTelegramMarkdown(text: string): string {

// Escape special characters used in markdown v2
// Characters to escape: _ * [ ] ( ) ~ ` > # + - = | { } . !
const specialChars = /([_*[\]()~`>#+-=|{}.!])/g;
const specialChars = /([_*[\]()~`>#+\-=|{}.!])/g;
return text.replace(specialChars, "\\$1");
}

Expand Down
16 changes: 8 additions & 8 deletions backend/tests/api/preferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,14 @@ const mockedService = {
vi.mock("../../src/services/preferences.service.js", () => {
return {
PreferencesService: class PreferencesService {
getPreferences = mockedService.getPreferences;
getPreference = mockedService.getPreference;
setPreference = mockedService.setPreference;
bulkUpdatePreferences = mockedService.bulkUpdatePreferences;
resetPreference = mockedService.resetPreference;
exportPreferences = mockedService.exportPreferences;
importPreferences = mockedService.importPreferences;
onPreferencesUpdated = mockedService.onPreferencesUpdated;
getPreferences = (...args: any[]) => mockedService.getPreferences(...args);
getPreference = (...args: any[]) => mockedService.getPreference(...args);
setPreference = (...args: any[]) => mockedService.setPreference(...args);
bulkUpdatePreferences = (...args: any[]) => mockedService.bulkUpdatePreferences(...args);
resetPreference = (...args: any[]) => mockedService.resetPreference(...args);
exportPreferences = (...args: any[]) => mockedService.exportPreferences(...args);
importPreferences = (...args: any[]) => mockedService.importPreferences(...args);
onPreferencesUpdated = (...args: any[]) => mockedService.onPreferencesUpdated(...args);
},
};
});
Expand Down
47 changes: 35 additions & 12 deletions backend/tests/services/alert.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@
type AlertCondition,
type MetricSnapshot,
} from "../../src/services/alert.service.js";
import { alertRoutingService } from "../../src/services/alertRouting.service.js";

vi.mock("../../src/services/alertRouting.service.js", () => ({
alertRoutingService: {
routeAlert: vi.fn().mockResolvedValue(undefined),
},
}));

vi.mock("../../src/workers/circuitBreaker.worker.js", () => ({
circuitBreakerQueue: {
add: vi.fn().mockResolvedValue(undefined),
},
}));

const suppressionServiceMock = {
shouldSuppress: vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -207,7 +220,7 @@
metrics: { price_deviation_bps: 300, health_score: 30 },
};

expect(await service.evaluateAsset(bothFire)).toHaveLength(1);

Check failure on line 223 in backend/tests/services/alert.service.test.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

tests/services/alert.service.test.ts > AlertService — evaluateConditions (via evaluateAsset) > AND: fires when both conditions are true

AssertionError: expected [] to have a length of 1 but got +0 - Expected + Received - 1 + 0 ❯ tests/services/alert.service.test.ts:223:51
});

it("OR: fires when only one condition is true", async () => {
Expand Down Expand Up @@ -237,7 +250,7 @@
metrics: { price_deviation_bps: 300, health_score: 70 },
};

expect(await service.evaluateAsset(oneFire)).toHaveLength(1);

Check failure on line 253 in backend/tests/services/alert.service.test.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

tests/services/alert.service.test.ts > AlertService — evaluateConditions (via evaluateAsset) > OR: fires when only one condition is true

AssertionError: expected [] to have a length of 1 but got +0 - Expected + Received - 1 + 0 ❯ tests/services/alert.service.test.ts:253:50
});

it("OR: does not fire when neither condition is true", async () => {
Expand Down Expand Up @@ -299,7 +312,7 @@
};

const events = await service.evaluateAsset(snapshot);
expect(events).toHaveLength(1);

Check failure on line 315 in backend/tests/services/alert.service.test.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

tests/services/alert.service.test.ts > AlertService — evaluateConditions (via evaluateAsset) > fires after cooldown has elapsed

AssertionError: expected [] to have a length of 1 but got +0 - Expected + Received - 1 + 0 ❯ tests/services/alert.service.test.ts:315:20
});

it("uses 0 for missing metric values", async () => {
Expand Down Expand Up @@ -330,34 +343,42 @@
vi.spyOn(service, "getActiveRulesForAsset").mockResolvedValue([rule]);
vi.spyOn(service as any, "persistEvent").mockResolvedValue(undefined);
vi.spyOn(service as any, "markRuleTriggered").mockResolvedValue(undefined);
const webhookSpy = vi
.spyOn(service, "dispatchWebhook")
.mockResolvedValue(undefined);
const routeSpy = vi
.spyOn(alertRoutingService, "routeAlert")
.mockResolvedValue(undefined as any);

const snapshot: MetricSnapshot = {
assetCode: "USDC",
metrics: { price_deviation_bps: 300 },
};

await service.evaluateAsset(snapshot);
expect(webhookSpy).toHaveBeenCalledOnce();
expect(routeSpy).toHaveBeenCalledWith(

Check failure on line 356 in backend/tests/services/alert.service.test.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

tests/services/alert.service.test.ts > AlertService — evaluateConditions (via evaluateAsset) > dispatches webhook when configured

AssertionError: expected "spy" to be called with arguments: [ ObjectContaining{…} ] Received: Number of calls: 0 ❯ tests/services/alert.service.test.ts:356:22
expect.objectContaining({
webhookUrl: "https://hooks.example.com/alert",
})
);
});

it("does not dispatch webhook when not configured", async () => {
vi.spyOn(service, "getActiveRulesForAsset").mockResolvedValue([makeRule()]);
vi.spyOn(service as any, "persistEvent").mockResolvedValue(undefined);
vi.spyOn(service as any, "markRuleTriggered").mockResolvedValue(undefined);
const webhookSpy = vi
.spyOn(service, "dispatchWebhook")
.mockResolvedValue(undefined);
const routeSpy = vi
.spyOn(alertRoutingService, "routeAlert")
.mockResolvedValue(undefined as any);

const snapshot: MetricSnapshot = {
assetCode: "USDC",
metrics: { price_deviation_bps: 300 },
};

await service.evaluateAsset(snapshot);
expect(webhookSpy).not.toHaveBeenCalled();
expect(routeSpy).toHaveBeenCalledWith(

Check failure on line 377 in backend/tests/services/alert.service.test.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

tests/services/alert.service.test.ts > AlertService — evaluateConditions (via evaluateAsset) > does not dispatch webhook when not configured

AssertionError: expected "spy" to be called with arguments: [ ObjectContaining{…} ] Received: Number of calls: 0 ❯ tests/services/alert.service.test.ts:377:22
expect.objectContaining({
webhookUrl: null,
})
);
});

it("returns events for all assets in batchEvaluate", async () => {
Expand Down Expand Up @@ -391,7 +412,7 @@
];

const events = await service.batchEvaluate(snapshots);
expect(events).toHaveLength(2);

Check failure on line 415 in backend/tests/services/alert.service.test.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

tests/services/alert.service.test.ts > AlertService — evaluateConditions (via evaluateAsset) > returns events for all assets in batchEvaluate

AssertionError: expected [ { eventId: '', …(19) } ] to have a length of 2 but got 1 - Expected + Received - 2 + 1 ❯ tests/services/alert.service.test.ts:415:20
const types = events.map((e) => e.alertType);
expect(types).toContain("price_deviation");
expect(types).toContain("supply_mismatch");
Expand Down Expand Up @@ -438,9 +459,9 @@
} as AlertCondition,
],
});
vi.spyOn(service, "getActiveRulesForAsset").mockResolvedValue([rule]);
vi.spyOn(service as any, "persistEvent").mockResolvedValue(undefined);
vi.spyOn(service as any, "markRuleTriggered").mockResolvedValue(undefined);
const rulesSpy = vi.spyOn(service, "getActiveRulesForAsset").mockResolvedValue([rule]);
const persistSpy = vi.spyOn(service as any, "persistEvent").mockResolvedValue(undefined);
const markSpy = vi.spyOn(service as any, "markRuleTriggered").mockResolvedValue(undefined);

const snapshot: MetricSnapshot = {
assetCode: "USDC",
Expand All @@ -448,10 +469,12 @@
};

const events = await service.evaluateAsset(snapshot);
expect(events).toHaveLength(1);

Check failure on line 472 in backend/tests/services/alert.service.test.ts

View workflow job for this annotation

GitHub Actions / Unit Tests

tests/services/alert.service.test.ts > AlertService — evaluateConditions (via evaluateAsset) > handles all six alert types

AssertionError: expected [] to have a length of 1 but got +0 - Expected + Received - 1 + 0 ❯ tests/services/alert.service.test.ts:472:22
expect(events[0].alertType).toBe(alertType);

vi.restoreAllMocks();
rulesSpy.mockRestore();
persistSpy.mockRestore();
markSpy.mockRestore();
suppressionServiceMock.shouldSuppress.mockResolvedValue({
suppressed: false,
matchedRule: null,
Expand Down
Loading
Loading