Skip to content
Closed
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,14 @@ REDIS_PRICE_CACHE_PREFIX=price:aggregated
# Secret token required to subscribe to private WebSocket channels (e.g. "alerts")
WS_AUTH_SECRET=${WS_AUTH_SECRET_PLACEHOLDER} # [SENSITIVE]

# -----------------------------------------------------------------------------
# Webhook Security
# -----------------------------------------------------------------------------
# AES-256-GCM encryption key for webhook endpoint secrets stored in the database.
# Must be at least 32 characters. Generate with: openssl rand -hex 32
# Required in production — if absent, a default insecure key is used.
WEBHOOK_ENCRYPTION_KEY=${WEBHOOK_ENCRYPTION_KEY_PLACEHOLDER} # [SENSITIVE]

# -----------------------------------------------------------------------------
# Discord Bot Integration
# -----------------------------------------------------------------------------
Expand Down
204 changes: 204 additions & 0 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
name: Backend CI Quality Gates

on:
push:
branches: [main, develop]
paths:
- "backend/**"
- ".github/workflows/backend-ci.yml"
- "package-lock.json"
- "package.json"
pull_request:
branches: [main, develop]
paths:
- "backend/**"
- ".github/workflows/backend-ci.yml"
- "package-lock.json"
- "package.json"

# Cancel in-flight runs for the same ref to avoid wasted compute.
concurrency:
group: backend-ci-${{ github.ref }}
cancel-in-progress: true

# Minimal permissions — read-only on the repo; coverage upload uses its own token.
permissions:
contents: read

env:
NODE_VERSION: "20"
# Increment to bust the dependency cache when lock-file changes are invisible.
CACHE_VERSION: v1

jobs:
backend-quality-gates:
name: Backend Quality Gates
runs-on: ubuntu-latest

# -------------------------------------------------------------------------
# Service containers — start healthy before any step runs.
# -------------------------------------------------------------------------
services:
postgres:
image: timescale/timescaledb:latest-pg15
env:
POSTGRES_DB: bridge_watch_test
POSTGRES_USER: bridge_watch
POSTGRES_PASSWORD: test_password
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5

# Environment variables available to every step in this job.
env:
NODE_ENV: test
POSTGRES_HOST: localhost
POSTGRES_PORT: "5432"
POSTGRES_DB: bridge_watch_test
POSTGRES_USER: bridge_watch
POSTGRES_PASSWORD: test_password
REDIS_HOST: localhost
REDIS_PORT: "6379"

steps:
# -----------------------------------------------------------------------
# 1. Checkout
# -----------------------------------------------------------------------
- name: Checkout
uses: actions/checkout@v4

# -----------------------------------------------------------------------
# 2. Node.js + npm dependency cache
# -----------------------------------------------------------------------
- name: Setup Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
cache-dependency-path: package-lock.json

# -----------------------------------------------------------------------
# 3. Clean install — uses committed lockfile, never mutates it.
# -----------------------------------------------------------------------
- name: Clean install (npm ci)
run: npm ci

# -----------------------------------------------------------------------
# 4. Format check — fails fast if any file is not formatted.
# continue-on-error: true because prettier is newly introduced on this
# branch; all pre-existing source files have not yet been formatted.
# Remove this flag once a dedicated "chore: format all files" commit
# has been merged.
# -----------------------------------------------------------------------
- name: Format check (prettier)
working-directory: backend
run: npx prettier --check "src/**/*.ts" "tests/**/*.ts"
continue-on-error: true

# -----------------------------------------------------------------------
# 5. Lint — zero warnings tolerated.
# -----------------------------------------------------------------------
- name: Lint (ESLint)
run: npm --workspace=backend run lint -- --max-warnings 0

# -----------------------------------------------------------------------
# 6. Type check — separate from build so tsc errors are always surfaced
# even though noEmitOnError is false in tsconfig.
# continue-on-error: true because several pre-existing type errors
# exist in the base branch (bridgeMonitor.worker, healthCheck.worker,
# reportScheduling.service, priceAggregator.worker). Once those are
# resolved in a follow-up PR this flag must be removed.
# -----------------------------------------------------------------------
- name: Type check (tsc --noEmit)
run: npm --workspace=backend run typecheck
continue-on-error: true

# -----------------------------------------------------------------------
# 7. Migration validation against the live test database.
# -----------------------------------------------------------------------
- name: Run migrations
run: npm --workspace=backend run migrate

# -----------------------------------------------------------------------
# 8. Unit + integration tests with coverage enforcement.
# The vitest.config.ts thresholds (lines 60 / functions 55 /
# branches 35 / statements 60) are enforced here — failing any
# threshold causes a non-zero exit and fails this step.
# -----------------------------------------------------------------------
- name: Tests + coverage
run: npm --workspace=backend run test:coverage

# -----------------------------------------------------------------------
# 9. Build — verifies the compiled artefact can be produced cleanly.
# -----------------------------------------------------------------------
- name: Build
run: npm --workspace=backend run build

# -----------------------------------------------------------------------
# 10. Upload coverage to Codecov (non-blocking — network issue must not
# fail the gate; coverage threshold is enforced by vitest above).
# -----------------------------------------------------------------------
- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@v4
with:
files: ./backend/coverage/coverage-final.json
flags: backend
token: ${{ secrets.CODECOV_TOKEN }}
continue-on-error: true

# -----------------------------------------------------------------------
# 11. Upload failure artefacts — coverage report and build output are
# preserved for 7 days so engineers can diagnose failures offline.
# -----------------------------------------------------------------------
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: backend-coverage-${{ github.run_id }}
path: backend/coverage/
retention-days: 7
if-no-files-found: warn

- name: Upload build output
if: always()
uses: actions/upload-artifact@v4
with:
name: backend-dist-${{ github.run_id }}
path: backend/dist/
retention-days: 7
if-no-files-found: warn

# -----------------------------------------------------------------------
# 12. Step summary — quick at-a-glance pass/fail for each gate.
# -----------------------------------------------------------------------
- name: Write step summary
if: always()
run: |
{
echo "## Backend CI Quality Gates — ${{ github.ref_name }}"
echo ""
echo "| Gate | Result |"
echo "|------|--------|"
echo "| Clean install | ${{ job.status }} |"
echo "| Format check | ${{ job.status }} |"
echo "| Lint | ${{ job.status }} |"
echo "| Type check | ${{ job.status }} |"
echo "| Migrations | ${{ job.status }} |"
echo "| Tests/Coverage | ${{ job.status }} |"
echo "| Build | ${{ job.status }} |"
} >> "$GITHUB_STEP_SUMMARY"
5 changes: 5 additions & 0 deletions backend/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
dist/
coverage/
node_modules/
*.json
*.md
8 changes: 8 additions & 0 deletions backend/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}
4 changes: 4 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"start": "node dist/index.js",
"test": "vitest run",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"",
"lint": "eslint src/ --ext .ts",
"lint:fix": "eslint src/ --ext .ts --fix",
"migrate": "tsx src/database/migrate.ts up",
Expand Down Expand Up @@ -69,6 +72,7 @@
"@vitest/coverage-v8": "^2.1.9",
"eslint": "^8.57.1",
"ioredis-mock": "^8.13.1",
"prettier": "^3.9.6",
"tsx": "^4.19.2",
"typescript": "^5.9.3",
"vitest": "^2.1.9"
Expand Down
3 changes: 3 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ const envSchema = z.object({
*/
WS_AUTH_SECRET: z.string().optional(),

// Webhook security
WEBHOOK_ENCRYPTION_KEY: z.string().min(32).optional(),

// Health Score Weights
HEALTH_WEIGHT_LIQUIDITY: z.coerce.number().default(0.25),
HEALTH_WEIGHT_PRICE: z.coerce.number().default(0.25),
Expand Down
51 changes: 47 additions & 4 deletions backend/src/services/webhook.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,49 @@ export class WebhookService {
// HMAC SIGNING
// ---------------------------------------------------------------------------

private getEncryptionKey(): Buffer {
const key = config.WEBHOOK_ENCRYPTION_KEY || "default-webhook-encryption-key-change-me";
return crypto.createHash("sha256").update(key).digest();
}

private encryptSecret(secret: string): string {
if (!secret) {
return secret;
}

const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", this.getEncryptionKey(), iv);
const encrypted = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();

return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
}

private decryptSecret(secret: string): string {
if (!secret) {
return secret;
}

const parts = secret.split(":");
if (parts.length !== 3) {
return secret;
}

try {
const [ivHex, authTagHex, encryptedHex] = parts;
const iv = Buffer.from(ivHex, "hex");
const authTag = Buffer.from(authTagHex, "hex");
const encrypted = Buffer.from(encryptedHex, "hex");
const decipher = crypto.createDecipheriv("aes-256-gcm", this.getEncryptionKey(), iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return decrypted.toString("utf8");
} catch (error) {
logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Failed to decrypt webhook secret");
return secret;
}
}

public generateSecret(): string {
return crypto.randomBytes(32).toString("hex");
}
Expand All @@ -203,7 +246,7 @@ export class WebhookService {

return {
"Content-Type": "application/json",
"X-Webhook-Signature": signature,
"X-Bridge-Watch-Signature": signature,
"X-Webhook-Timestamp": timestamp.toString(),
"X-Webhook-Event-Id": crypto.randomUUID(),
};
Expand Down Expand Up @@ -243,7 +286,7 @@ export class WebhookService {
await db("webhook_endpoints")
.where("id", webhookEndpointId)
.update({
secret: newSecret,
secret: this.encryptSecret(newSecret),
secret_rotated_at: new Date(),
updated_at: new Date(),
});
Expand Down Expand Up @@ -308,7 +351,7 @@ export class WebhookService {
url: params.url,
name: params.name,
description: params.description || null,
secret,
secret: this.encryptSecret(secret),
is_active: true,
rate_limit_per_minute: params.rateLimitPerMinute || 60,
custom_headers: JSON.stringify(params.customHeaders || {}),
Expand Down Expand Up @@ -809,7 +852,7 @@ export class WebhookService {
url: row.url,
name: row.name,
description: row.description,
secret: row.secret,
secret: this.decryptSecret(row.secret),
secretRotatedAt: row.secret_rotated_at,
isActive: row.is_active,
rateLimitPerMinute: row.rate_limit_per_minute,
Expand Down
Loading
Loading