Skip to content
Open
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
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CI

on:
push:
branches:
- main
- fix/**
pull_request:

permissions:
contents: read

jobs:
quality:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [20, 22]

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm

- name: Install dependencies
run: npm ci

- name: Typecheck
run: npm run typecheck

- name: Lint
run: npm run lint

- name: Test
run: npm run test:coverage

- name: Build
run: npm run build
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ EXPOSE 3000
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "dist/server.js"]
CMD ["node", "dist/src/server.js"]
156 changes: 102 additions & 54 deletions config/gateway.ts
Original file line number Diff line number Diff line change
@@ -1,98 +1,146 @@
import 'dotenv/config';

import { z } from 'zod';

const upstreamSchema = z.array(
z.object({
prefix: z.string().min(1).startsWith('/'),
target: z.string().url(),
}),
);

type Upstream = z.infer<typeof upstreamSchema>[number];

function parseInteger(name: string, fallback: number): number {
const raw = process.env[name];

Check warning on line 15 in config/gateway.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

config/gateway.ts#L15

Variable Assigned to Object Injection Sink
if (!raw) return fallback;
if (!/^-?\d+$/.test(raw.trim())) {
throw new Error(`${name} must be an integer`);
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) {
throw new Error(`${name} must be an integer`);
}
return parsed;
}

function parseFloatValue(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const parsed = Number.parseFloat(raw);
if (!Number.isFinite(parsed)) {
throw new Error(`${name} must be a finite number`);
}
return parsed;
}

function parseCsv(name: string, fallback: string): string[] {
return (process.env[name] ?? fallback)

Check warning on line 38 in config/gateway.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

config/gateway.ts#L38

Generic Object Injection Sink
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}

function parseUpstreams(): Upstream[] {
const fallback: Upstream[] = [
{ prefix: '/api/users', target: 'http://user-service:8080' },
{ prefix: '/api/products', target: 'http://product-service:8080' },
{ prefix: '/api/orders', target: 'http://order-service:8080' },
];

const raw = process.env.UPSTREAMS;
if (!raw) return fallback;

try {
return upstreamSchema.parse(JSON.parse(raw));
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown parse error';
throw new Error(`UPSTREAMS must be a JSON array of { prefix, target } entries: ${message}`);
}
}

export const config = {
server: {
host: process.env.HOST ?? '0.0.0.0',
port: parseInt(process.env.PORT ?? '3000', 10),
port: parseInteger('PORT', 3000),
trustProxy: process.env.TRUST_PROXY === 'true',
},

// ── Upstream services routed through the gateway ──────────────────────────
upstreams: JSON.parse(process.env.UPSTREAMS ?? JSON.stringify([
{ prefix: '/api/users', target: 'http://user-service:8080' },
{ prefix: '/api/products', target: 'http://product-service:8080' },
{ prefix: '/api/orders', target: 'http://order-service:8080' },
])),
upstreams: parseUpstreams(),

// ── JWT ───────────────────────────────────────────────────────────────────
jwt: {
secret: process.env.JWT_SECRET ?? 'change-me-in-production',
issuer: process.env.JWT_ISSUER ?? 'api-gateway',
expiresIn: process.env.JWT_EXPIRES ?? '1h',
publicPaths: (process.env.JWT_PUBLIC_PATHS ?? '/health,/metrics,/ready').split(',').map(s => s.trim()),
secret: process.env.JWT_SECRET ?? (() => { throw new Error('JWT_SECRET environment variable is required for security') })(),
issuer: process.env.JWT_ISSUER ?? 'api-gateway',
expiresIn: process.env.JWT_EXPIRES ?? '1h',
publicPaths: parseCsv('JWT_PUBLIC_PATHS', '/health,/metrics,/ready'),
},

// ── Rate limiting (per-client, sliding window via Redis) ─────────────────
rateLimit: {
global: {
max: parseInt(process.env.RATE_LIMIT_GLOBAL_MAX ?? '500', 10),
timeWindowMs: parseInt(process.env.RATE_LIMIT_GLOBAL_WINDOW ?? '60000', 10),
max: parseInteger('RATE_LIMIT_GLOBAL_MAX', 500),
timeWindowMs: parseInteger('RATE_LIMIT_GLOBAL_WINDOW', 60_000),
},
auth: {
max: parseInt(process.env.RATE_LIMIT_AUTH_MAX ?? '10', 10),
timeWindowMs: parseInt(process.env.RATE_LIMIT_AUTH_WINDOW ?? '60000', 10),
max: parseInteger('RATE_LIMIT_AUTH_MAX', 10),
timeWindowMs: parseInteger('RATE_LIMIT_AUTH_WINDOW', 60_000),
},
perRoute: JSON.parse(process.env.RATE_LIMIT_PER_ROUTE ?? '{}'),
perRoute: JSON.parse(process.env.RATE_LIMIT_PER_ROUTE ?? '{}') as Record<string, unknown>,
Comment thread
donny-devops marked this conversation as resolved.
Comment thread
donny-devops marked this conversation as resolved.
},

// ── DDoS / flood protection ───────────────────────────────────────────────
ddos: {
requestsPerSecond: parseInt(process.env.DDOS_RPS ?? '100', 10),
burstMultiplier: parseFloat(process.env.DDOS_BURST ?? '2.0'),
banDurationMs: parseInt(process.env.DDOS_BAN_MS ?? '300000', 10), // 5 min
slowlorisTimeoutMs: parseInt(process.env.DDOS_SLOWLORIS ?? '5000', 10),
maxPayloadBytes: parseInt(process.env.MAX_PAYLOAD_BYTES ?? '1048576', 10), // 1 MB
requestsPerSecond: parseInteger('DDOS_RPS', 100),
burstMultiplier: parseFloatValue('DDOS_BURST', 2.0),
banDurationMs: parseInteger('DDOS_BAN_MS', 300_000),
slowlorisTimeoutMs: parseInteger('DDOS_SLOWLORIS', 5_000),
maxPayloadBytes: parseInteger('MAX_PAYLOAD_BYTES', 1_048_576),
},

// ── Input sanitisation ────────────────────────────────────────────────────
sanitise: {
stripXss: process.env.SANITISE_XSS !== 'false',
stripSqlPatterns: process.env.SANITISE_SQL !== 'false',
stripPathTraversal: process.env.SANITISE_PATH !== 'false',
maxStringLength: parseInt(process.env.MAX_STRING_LEN ?? '10000', 10),
allowedContentTypes: (process.env.ALLOWED_CT ?? 'application/json,application/x-www-form-urlencoded,multipart/form-data').split(',').map(s => s.trim()),
stripXss: process.env.SANITISE_XSS !== 'false',
stripSqlPatterns: process.env.SANITISE_SQL !== 'false',
stripPathTraversal: process.env.SANITISE_PATH !== 'false',
maxStringLength: parseInteger('MAX_STRING_LEN', 10_000),
allowedContentTypes: parseCsv(
'ALLOWED_CT',
'application/json,application/x-www-form-urlencoded',
),
},

// ── CORS ──────────────────────────────────────────────────────────────────
cors: {
origin: process.env.CORS_ORIGIN ?? '*',
credentials: process.env.CORS_CREDENTIALS === 'true',
origin: process.env.CORS_ORIGIN ?? '*',
credentials: process.env.CORS_CREDENTIALS === 'true',
},

// ── Redis (rate limiting store + ban list) ────────────────────────────────
redis: {
host: process.env.REDIS_HOST ?? 'localhost',
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
password: process.env.REDIS_PASSWORD ?? undefined,
db: parseInt(process.env.REDIS_DB ?? '0', 10),
host: process.env.REDIS_HOST || null,
port: parseInteger('REDIS_PORT', 6379),
password: process.env.REDIS_PASSWORD || undefined,
db: parseInteger('REDIS_DB', 0),
keyPrefix: 'gw:',
},

// ── Observability — Elasticsearch / OpenSearch (ELK) ─────────────────────
elk: {
enabled: process.env.ELK_ENABLED === 'true',
node: process.env.ELASTICSEARCH_URL ?? 'http://elasticsearch:9200',
index: process.env.ELK_INDEX ?? 'gateway-transactions',
apiKey: process.env.ELASTICSEARCH_API_KEY,
flushBytes: parseInt(process.env.ELK_FLUSH_BYTES ?? '1048576', 10),
flushInterval: parseInt(process.env.ELK_FLUSH_INTERVAL ?? '5000', 10),
enabled: process.env.ELK_ENABLED === 'true',
node: process.env.ELASTICSEARCH_URL ?? 'http://elasticsearch:9200',
index: process.env.ELK_INDEX ?? 'gateway-transactions',
apiKey: process.env.ELASTICSEARCH_API_KEY,
flushBytes: parseInteger('ELK_FLUSH_BYTES', 1_048_576),
flushInterval: parseInteger('ELK_FLUSH_INTERVAL', 5_000),
},

// ── Observability — Prometheus / Grafana ─────────────────────────────────
metrics: {
enabled: process.env.METRICS_ENABLED !== 'false',
path: process.env.METRICS_PATH ?? '/metrics',
enabled: process.env.METRICS_ENABLED !== 'false',
path: process.env.METRICS_PATH ?? '/metrics',
defaultLabels: {
service: 'api-gateway',
env: process.env.NODE_ENV ?? 'development',
service: 'api-gateway',
env: process.env.NODE_ENV ?? 'development',
},
},

// ── OpenTelemetry tracing ─────────────────────────────────────────────────
otel: {
enabled: process.env.OTEL_ENABLED === 'true',
serviceName: process.env.OTEL_SERVICE_NAME ?? 'api-gateway',
exporterUrl: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://otel-collector:4317',
enabled: process.env.OTEL_ENABLED === 'true',
serviceName: process.env.OTEL_SERVICE_NAME ?? 'api-gateway',
exporterUrl: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://otel-collector:4317',
},
} as const;

Expand Down
24 changes: 24 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.ts', 'config/**/*.ts', 'tests/**/*.ts'],
languageOptions: {
globals: {
process: 'readonly',
Buffer: 'readonly',
console: 'readonly',
},
parserOptions: {
project: './tsconfig.json',
},
},
Comment on lines +9 to +18
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/consistent-type-imports': 'error',
},
},
);
Loading