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
36 changes: 36 additions & 0 deletions .github/workflows/bundle-size.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Bundle Size

on:
pull_request:
paths:
- 'frontend/app/**'
- 'frontend/components/**'
- 'frontend/next.config.ts'
- 'frontend/bundle-budget.json'
- 'frontend/package.json'

jobs:
bundle-size:
name: Enforce bundle size budgets
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Build
run: npm run build
env:
NEXT_TELEMETRY_DISABLED: '1'

- name: Check bundle size budgets
run: npm run bundle:check
44 changes: 44 additions & 0 deletions .github/workflows/config-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Config Validation

on:
pull_request:
paths:
- 'backend/src/config.ts'
- 'backend/src/config/**'
- 'backend/src/services/config/**'
- 'backend/scripts/config-*.ts'
- 'infra/main.tf'
- 'infra/environments/**'
push:
branches: [main]
paths:
- 'backend/src/config.ts'
- 'backend/src/config/**'
- 'backend/src/services/config/**'

jobs:
validate:
name: Validate & compare environment configs
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Validate config schemas (Zod) for every environment
run: npm run config:validate

- name: Detect config drift between environments
run: npm run config:drift

- name: Check generated config documentation is up to date
run: npm run config:docs:check
6 changes: 5 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@
"benchmark": "tsx src/tests/benchmarks/run-benchmarks.ts",
"benchmark:baseline": "tsx src/tests/benchmarks/run-benchmarks.ts --write-baseline",
"benchmark:compare": "tsx src/tests/benchmarks/compare-baseline.ts",
"generate:vapid-keys": "node scripts/generate-vapid-keys.js"
"generate:vapid-keys": "node scripts/generate-vapid-keys.js",
"config:validate": "tsx scripts/config-validate.ts",
"config:drift": "tsx scripts/config-drift.ts",
"config:docs": "tsx scripts/config-docs.ts",
"config:docs:check": "tsx scripts/config-docs.ts --check"
},
"dependencies": {
"@agenticpay/api-spec": "*",
Expand Down
74 changes: 74 additions & 0 deletions backend/scripts/config-docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env tsx
/**
* Generates docs/config/CONFIGURATION.md from the runtime config schema
* (CONFIG_DEFINITIONS) and the per-environment override files. Run with
* `npm run config:docs`. Pass --check to fail CI if the checked-in doc is
* stale (drifted from the source of truth) instead of rewriting it.
*/
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { CONFIG_DEFINITIONS } from '../src/services/config/config-schema.js';
import { developmentOverrides } from '../src/config/environments/development.js';
import { stagingOverrides } from '../src/config/environments/staging.js';
import { productionOverrides } from '../src/config/environments/production.js';
import type { EnvironmentOverrides } from '../src/config/environments/types.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const OUTPUT_PATH = join(__dirname, '..', '..', 'docs', 'config', 'CONFIGURATION.md');

const ENVIRONMENTS: Record<string, EnvironmentOverrides> = {
development: developmentOverrides,
staging: stagingOverrides,
production: productionOverrides,
};

function render(): string {
const lines: string[] = [];
lines.push('<!-- AUTO-GENERATED by `npm run config:docs` in backend/. Do not edit by hand. -->');
lines.push('# Configuration Reference');
lines.push('');
lines.push('## Runtime configuration keys');
lines.push('');
lines.push('| Key | Type | Description | Env var | Default | Sensitive |');
lines.push('|-----|------|-------------|---------|---------|-----------|');
for (const def of Object.values(CONFIG_DEFINITIONS)) {
lines.push(
`| \`${def.key}\` | ${def.type} | ${def.description} | ${def.envVar ? `\`${def.envVar}\`` : '—'} | \`${JSON.stringify(def.defaultValue)}\` | ${def.sensitive ? 'yes' : 'no'} |`,
);
}

lines.push('');
lines.push('## Environment overrides');
lines.push('');
const allKeys = Array.from(
new Set(Object.values(ENVIRONMENTS).flatMap((overrides) => Object.keys(overrides))),
).sort();
lines.push(`| Key | ${Object.keys(ENVIRONMENTS).join(' | ')} |`);
lines.push(`|-----|${Object.keys(ENVIRONMENTS).map(() => '---').join('|')}|`);
for (const key of allKeys) {
const row = Object.values(ENVIRONMENTS).map((overrides) => (overrides as Record<string, unknown>)[key] ?? '—');
lines.push(`| \`${key}\` | ${row.join(' | ')} |`);
}
lines.push('');
lines.push('Secrets (API keys, DB credentials) are not stored in these files — see `infra/main.tf`');
lines.push('for the AWS Secrets Manager resources referenced by `AWS_SECRETS_MANAGER_SECRET_ID`.');
lines.push('');

return lines.join('\n');
}

const content = render();
const checkOnly = process.argv.includes('--check');

if (checkOnly) {
if (!existsSync(OUTPUT_PATH) || readFileSync(OUTPUT_PATH, 'utf-8') !== content) {
console.error(`Config docs are stale. Run \`npm run config:docs\` and commit ${OUTPUT_PATH}.`);
process.exit(1);
}
console.log('Config docs are up to date.');
} else {
mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
writeFileSync(OUTPUT_PATH, content);
console.log(`Wrote ${OUTPUT_PATH}`);
}
62 changes: 62 additions & 0 deletions backend/scripts/config-drift.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env tsx
/**
* CI check: detects unintended config drift between environments.
* - Flags production/staging inheriting risky development defaults
* (wildcard CORS, secrets manager disabled).
* - Prints a key-by-key comparison table across dev/staging/prod so
* reviewers can see exactly what differs.
* Run with `npm run config:drift`.
*/
import { developmentOverrides } from '../src/config/environments/development.js';
import { stagingOverrides } from '../src/config/environments/staging.js';
import { productionOverrides } from '../src/config/environments/production.js';
import type { EnvironmentOverrides } from '../src/config/environments/types.js';

const ENVIRONMENTS: Record<string, EnvironmentOverrides> = {
development: developmentOverrides,
staging: stagingOverrides,
production: productionOverrides,
};

const allKeys = Array.from(
new Set(Object.values(ENVIRONMENTS).flatMap((overrides) => Object.keys(overrides))),
).sort();

console.log('Config comparison across environments\n');
console.log(['key', ...Object.keys(ENVIRONMENTS)].join(' | '));
for (const key of allKeys) {
const row = [key, ...Object.values(ENVIRONMENTS).map((overrides) => String((overrides as Record<string, unknown>)[key] ?? '—'))];
console.log(row.join(' | '));
}

const violations: string[] = [];

if (productionOverrides.CORS_ALLOWED_ORIGINS === '*') {
violations.push('production: CORS_ALLOWED_ORIGINS must not be wildcard "*"');
}
if (stagingOverrides.CORS_ALLOWED_ORIGINS === '*') {
violations.push('staging: CORS_ALLOWED_ORIGINS must not be wildcard "*"');
}
if (productionOverrides.STELLAR_NETWORK !== 'public') {
violations.push('production: STELLAR_NETWORK must be "public"');
}
if (productionOverrides.AWS_SECRETS_MANAGER_ENABLED !== 'true') {
violations.push('production: AWS_SECRETS_MANAGER_ENABLED must be "true"');
}
if (stagingOverrides.AWS_SECRETS_MANAGER_ENABLED !== 'true') {
violations.push('staging: AWS_SECRETS_MANAGER_ENABLED must be "true"');
}
if (
productionOverrides.AWS_SECRETS_MANAGER_SECRET_ID &&
productionOverrides.AWS_SECRETS_MANAGER_SECRET_ID === stagingOverrides.AWS_SECRETS_MANAGER_SECRET_ID
) {
violations.push('production and staging must not share the same AWS_SECRETS_MANAGER_SECRET_ID');
}

if (violations.length > 0) {
console.error('\nConfig drift violations detected:');
violations.forEach((v) => console.error(` - ${v}`));
process.exit(1);
}

console.log('\nNo config drift violations detected.');
63 changes: 63 additions & 0 deletions backend/scripts/config-validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env tsx
/**
* CI check: validates that every environment's config (base process.env
* schema + per-environment overrides in src/config/environments/*.ts) parses
* cleanly against the Zod schemas. Run with `npm run config:validate`.
*/
import { z } from 'zod';
import { developmentOverrides } from '../src/config/environments/development.js';
import { stagingOverrides } from '../src/config/environments/staging.js';
import { productionOverrides } from '../src/config/environments/production.js';
import { environmentOverridesSchema, type EnvironmentName } from '../src/config/environments/types.js';

const envSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
PORT: z.string().default('3001'),
CORS_ALLOWED_ORIGINS: z.string().default('*'),
JOBS_ENABLED: z.enum(['true', 'false']).default('true'),
QUEUE_ENABLED: z.enum(['true', 'false']).default('true'),
STELLAR_NETWORK: z.enum(['testnet', 'public']).default('testnet'),
RATE_LIMIT_FREE: z.string().default('100'),
RATE_LIMIT_PRO: z.string().default('300'),
RATE_LIMIT_ENTERPRISE: z.string().default('1000'),
RATE_LIMIT_WINDOW_MS: z.string().default(String(15 * 60 * 1000)),
COMPRESSION_THRESHOLD: z.string().default('1024'),
AWS_SECRETS_MANAGER_ENABLED: z.enum(['true', 'false']).default('false'),
AWS_SECRETS_MANAGER_SECRET_ID: z.string().default(''),
});

const ENVIRONMENTS: Record<EnvironmentName, unknown> = {
development: developmentOverrides,
staging: stagingOverrides,
production: productionOverrides,
};

let failed = false;

for (const [name, overrides] of Object.entries(ENVIRONMENTS)) {
const overrideResult = environmentOverridesSchema.safeParse(overrides);
if (!overrideResult.success) {
failed = true;
console.error(`[config:validate] ${name}: override file failed schema validation`);
console.error(overrideResult.error.flatten().fieldErrors);
continue;
}

const merged = { ...overrideResult.data, NODE_ENV: name };
const result = envSchema.safeParse(merged);
if (!result.success) {
failed = true;
console.error(`[config:validate] ${name}: merged config failed schema validation`);
console.error(result.error.flatten().fieldErrors);
continue;
}

console.log(`[config:validate] ${name}: OK`);
}

if (failed) {
console.error('\nConfig validation failed.');
process.exit(1);
}

console.log('\nAll environment configs are valid.');
Loading
Loading