diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml new file mode 100644 index 00000000..0b7adb33 --- /dev/null +++ b/.github/workflows/bundle-size.yml @@ -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 diff --git a/.github/workflows/config-validation.yml b/.github/workflows/config-validation.yml new file mode 100644 index 00000000..3c210ae0 --- /dev/null +++ b/.github/workflows/config-validation.yml @@ -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 diff --git a/backend/package.json b/backend/package.json index 6efe6898..5bbcc076 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": "*", diff --git a/backend/scripts/config-docs.ts b/backend/scripts/config-docs.ts new file mode 100644 index 00000000..12e8d057 --- /dev/null +++ b/backend/scripts/config-docs.ts @@ -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 = { + development: developmentOverrides, + staging: stagingOverrides, + production: productionOverrides, +}; + +function render(): string { + const lines: string[] = []; + lines.push(''); + 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)[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}`); +} diff --git a/backend/scripts/config-drift.ts b/backend/scripts/config-drift.ts new file mode 100644 index 00000000..e64b851b --- /dev/null +++ b/backend/scripts/config-drift.ts @@ -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 = { + 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)[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.'); diff --git a/backend/scripts/config-validate.ts b/backend/scripts/config-validate.ts new file mode 100644 index 00000000..73a4b489 --- /dev/null +++ b/backend/scripts/config-validate.ts @@ -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 = { + 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.'); diff --git a/backend/src/config.ts b/backend/src/config.ts index 5ab647f8..54ea57d4 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,8 +1,13 @@ import dotenv from 'dotenv'; import { z } from 'zod'; +import { applyEnvironmentFileDefaults, refreshSecretsManagerConfig, resolveEnvironmentName } from './config/environments/index.js'; dotenv.config(); +// Layer in environment-specific defaults (backend/src/config/environments/*.ts) +// before validating. Real process.env values always win over these defaults. +const activeEnvironment = applyEnvironmentFileDefaults(); + const envSchema = z.object({ NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'), PORT: z.string().default('3001'), @@ -17,49 +22,75 @@ const envSchema = z.object({ COMPRESSION_THRESHOLD: z.string().default('1024'), VAPID_PUBLIC_KEY: z.string().default(''), VAPID_PRIVATE_KEY: z.string().default(''), + AWS_SECRETS_MANAGER_ENABLED: z.enum(['true', 'false']).default('false'), + AWS_SECRETS_MANAGER_SECRET_ID: z.string().default(''), }); -const parsed = envSchema.safeParse(process.env); +function buildConfig() { + const parsed = envSchema.safeParse(process.env); -if (!parsed.success) { - console.error('Invalid environment configuration:', parsed.error.flatten().fieldErrors); - process.exit(1); -} + if (!parsed.success) { + console.error('Invalid environment configuration:', parsed.error.flatten().fieldErrors); + process.exit(1); + } -const env = parsed.data; + const env = parsed.data; + + return { + env: env.NODE_ENV, + environment: activeEnvironment, + isDev: env.NODE_ENV === 'development', + isStaging: env.NODE_ENV === 'staging', + isProd: env.NODE_ENV === 'production', + server: { + port: Number(env.PORT), + }, + cors: { + allowedOrigins: env.CORS_ALLOWED_ORIGINS.split(',').map((o) => o.trim()), + }, + jobs: { + enabled: env.JOBS_ENABLED === 'true', + }, + queue: { + enabled: env.QUEUE_ENABLED === 'true', + }, + stellar: { + network: env.STELLAR_NETWORK, + }, + rateLimit: { + free: Number(env.RATE_LIMIT_FREE), + pro: Number(env.RATE_LIMIT_PRO), + enterprise: Number(env.RATE_LIMIT_ENTERPRISE), + windowMs: Number(env.RATE_LIMIT_WINDOW_MS), + }, + compression: { + threshold: Number(env.COMPRESSION_THRESHOLD), + }, + vapidKeys: env.VAPID_PUBLIC_KEY && env.VAPID_PRIVATE_KEY + ? { publicKey: env.VAPID_PUBLIC_KEY, privateKey: env.VAPID_PRIVATE_KEY } + : null, + secretsManager: { + enabled: env.AWS_SECRETS_MANAGER_ENABLED === 'true', + secretId: env.AWS_SECRETS_MANAGER_SECRET_ID || null, + }, + }; +} -export const config = { - env: env.NODE_ENV, - isDev: env.NODE_ENV === 'development', - isStaging: env.NODE_ENV === 'staging', - isProd: env.NODE_ENV === 'production', - server: { - port: Number(env.PORT), - }, - cors: { - allowedOrigins: env.CORS_ALLOWED_ORIGINS.split(',').map((o) => o.trim()), - }, - jobs: { - enabled: env.JOBS_ENABLED === 'true', - }, - queue: { - enabled: env.QUEUE_ENABLED === 'true', - }, - stellar: { - network: env.STELLAR_NETWORK, - }, - rateLimit: { - free: Number(env.RATE_LIMIT_FREE), - pro: Number(env.RATE_LIMIT_PRO), - enterprise: Number(env.RATE_LIMIT_ENTERPRISE), - windowMs: Number(env.RATE_LIMIT_WINDOW_MS), - }, - compression: { - threshold: Number(env.COMPRESSION_THRESHOLD), - }, - vapidKeys: env.VAPID_PUBLIC_KEY && env.VAPID_PRIVATE_KEY - ? { publicKey: env.VAPID_PUBLIC_KEY, privateKey: env.VAPID_PRIVATE_KEY } - : null, -} as const; +export const config = buildConfig(); export type Config = typeof config; + +/** + * Runtime config refresh (acceptance criteria: "runtime config refresh + * capability"): re-pulls secrets from AWS Secrets Manager (if configured), + * re-validates process.env with the same Zod schema, and mutates the + * exported `config` object in place so existing imports observe the update. + */ +export async function refreshConfig(): Promise { + await refreshSecretsManagerConfig(); + const next = buildConfig(); + Object.assign(config, next); + return config; +} + +export { resolveEnvironmentName }; diff --git a/backend/src/config/environments/development.ts b/backend/src/config/environments/development.ts new file mode 100644 index 00000000..ab0e6359 --- /dev/null +++ b/backend/src/config/environments/development.ts @@ -0,0 +1,9 @@ +import type { EnvironmentOverrides } from './types.js'; + +export const developmentOverrides: EnvironmentOverrides = { + CORS_ALLOWED_ORIGINS: '*', + STELLAR_NETWORK: 'testnet', + JOBS_ENABLED: 'true', + QUEUE_ENABLED: 'true', + AWS_SECRETS_MANAGER_ENABLED: 'false', +}; diff --git a/backend/src/config/environments/index.ts b/backend/src/config/environments/index.ts new file mode 100644 index 00000000..3ca72342 --- /dev/null +++ b/backend/src/config/environments/index.ts @@ -0,0 +1,61 @@ +import { developmentOverrides } from './development.js'; +import { stagingOverrides } from './staging.js'; +import { productionOverrides } from './production.js'; +import { loadSecretsManagerOverrides } from './secrets-manager.js'; +import type { EnvironmentName, EnvironmentOverrides } from './types.js'; + +export * from './types.js'; +export { loadSecretsManagerOverrides }; + +const ENVIRONMENT_CONFIGS: Record = { + development: developmentOverrides, + staging: stagingOverrides, + production: productionOverrides, +}; + +export function resolveEnvironmentName(nodeEnv: string | undefined): EnvironmentName { + if (nodeEnv === 'staging') return 'staging'; + if (nodeEnv === 'production') return 'production'; + return 'development'; +} + +/** Environment file values, without mutating process.env. */ +export function getEnvironmentOverrides(nodeEnv: string | undefined): EnvironmentOverrides { + return ENVIRONMENT_CONFIGS[resolveEnvironmentName(nodeEnv)]; +} + +/** + * Applies environment-file defaults onto process.env, without clobbering + * variables the process was actually started with. Synchronous so it can run + * at config module load time. Precedence: environment file < process.env. + */ +export function applyEnvironmentFileDefaults(): EnvironmentName { + const envName = resolveEnvironmentName(process.env.NODE_ENV); + const overrides = ENVIRONMENT_CONFIGS[envName]; + + for (const [key, value] of Object.entries(overrides)) { + if (process.env[key] === undefined && value !== undefined) { + process.env[key] = value; + } + } + + return envName; +} + +/** + * Fetches secrets from AWS Secrets Manager (when configured) and applies them + * onto process.env, overriding any existing value. Async — call explicitly at + * bootstrap and whenever a runtime secret refresh is needed; not run + * automatically at config load time. + */ +export async function refreshSecretsManagerConfig(): Promise | null> { + const secretsEnabled = process.env.AWS_SECRETS_MANAGER_ENABLED === 'true'; + const secretId = process.env.AWS_SECRETS_MANAGER_SECRET_ID; + if (!secretsEnabled || !secretId) return null; + + const secrets = await loadSecretsManagerOverrides(secretId); + for (const [key, value] of Object.entries(secrets)) { + process.env[key] = value; + } + return secrets; +} diff --git a/backend/src/config/environments/production.ts b/backend/src/config/environments/production.ts new file mode 100644 index 00000000..77abd8c8 --- /dev/null +++ b/backend/src/config/environments/production.ts @@ -0,0 +1,13 @@ +import type { EnvironmentOverrides } from './types.js'; + +export const productionOverrides: EnvironmentOverrides = { + CORS_ALLOWED_ORIGINS: 'https://app.agenticpay.io', + STELLAR_NETWORK: 'public', + JOBS_ENABLED: 'true', + QUEUE_ENABLED: 'true', + RATE_LIMIT_FREE: '60', + RATE_LIMIT_PRO: '300', + RATE_LIMIT_ENTERPRISE: '2000', + AWS_SECRETS_MANAGER_ENABLED: 'true', + AWS_SECRETS_MANAGER_SECRET_ID: 'agenticpay-prod-app-secrets', +}; diff --git a/backend/src/config/environments/secrets-manager.ts b/backend/src/config/environments/secrets-manager.ts new file mode 100644 index 00000000..0c01f70b --- /dev/null +++ b/backend/src/config/environments/secrets-manager.ts @@ -0,0 +1,19 @@ +/** + * Optional AWS Secrets Manager integration. Only activates when + * AWS_SECRETS_MANAGER_ENABLED=true, so local/dev/test never need AWS credentials. + * The @aws-sdk/client-secrets-manager package is imported dynamically so it stays + * an optional dependency for environments that don't use it. + */ +export async function loadSecretsManagerOverrides(secretId: string): Promise> { + try { + const { SecretsManagerClient, GetSecretValueCommand } = await import('@aws-sdk/client-secrets-manager'); + const client = new SecretsManagerClient({ region: process.env.AWS_REGION ?? 'us-east-1' }); + const result = await client.send(new GetSecretValueCommand({ SecretId: secretId })); + if (!result.SecretString) return {}; + const parsed = JSON.parse(result.SecretString) as Record; + return Object.fromEntries(Object.entries(parsed).map(([key, value]) => [key, String(value)])); + } catch (error) { + console.error(`[config] Failed to load secrets from AWS Secrets Manager (secretId=${secretId}):`, error); + throw error; + } +} diff --git a/backend/src/config/environments/staging.ts b/backend/src/config/environments/staging.ts new file mode 100644 index 00000000..88c2c3a4 --- /dev/null +++ b/backend/src/config/environments/staging.ts @@ -0,0 +1,13 @@ +import type { EnvironmentOverrides } from './types.js'; + +export const stagingOverrides: EnvironmentOverrides = { + CORS_ALLOWED_ORIGINS: 'https://staging.agenticpay.app', + STELLAR_NETWORK: 'testnet', + JOBS_ENABLED: 'true', + QUEUE_ENABLED: 'true', + RATE_LIMIT_FREE: '100', + RATE_LIMIT_PRO: '300', + RATE_LIMIT_ENTERPRISE: '1000', + AWS_SECRETS_MANAGER_ENABLED: 'true', + AWS_SECRETS_MANAGER_SECRET_ID: 'agenticpay-staging-app-secrets', +}; diff --git a/backend/src/config/environments/types.ts b/backend/src/config/environments/types.ts new file mode 100644 index 00000000..aed48c71 --- /dev/null +++ b/backend/src/config/environments/types.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +/** + * Per-environment overrides. Every field is optional — an environment file only + * needs to declare values that differ from the shared defaults in `config.ts`. + * Values still pass through the base env schema validation after merging. + */ +export const environmentOverridesSchema = z.object({ + CORS_ALLOWED_ORIGINS: z.string().optional(), + STELLAR_NETWORK: z.enum(['testnet', 'public']).optional(), + JOBS_ENABLED: z.enum(['true', 'false']).optional(), + QUEUE_ENABLED: z.enum(['true', 'false']).optional(), + RATE_LIMIT_FREE: z.string().optional(), + RATE_LIMIT_PRO: z.string().optional(), + RATE_LIMIT_ENTERPRISE: z.string().optional(), + RATE_LIMIT_WINDOW_MS: z.string().optional(), + COMPRESSION_THRESHOLD: z.string().optional(), + AWS_SECRETS_MANAGER_ENABLED: z.enum(['true', 'false']).optional(), + AWS_SECRETS_MANAGER_SECRET_ID: z.string().optional(), +}); + +export type EnvironmentOverrides = z.infer; +export type EnvironmentName = 'development' | 'staging' | 'production'; diff --git a/backend/src/index.ts b/backend/src/index.ts index 79b066a1..bf5697f6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -77,6 +77,7 @@ import { escrowRouter } from './routes/escrow.js'; import { multisigRouter } from './routes/multisig.js'; import { fiatPaymentsRouter } from './routes/fiat-payments.js'; import { paymentLinksRouter } from './routes/payment-links.js'; +import { paymentStrategiesRouter } from './routes/payment-strategies.js'; import { taxRouter } from './routes/tax.js'; import { projectsRouter } from './routes/projects.js'; import { graphQLRouter, graphQLWsRouter } from './graphql/gateway.js'; @@ -377,6 +378,7 @@ app.use('/api/v1/fiat-payments', fiatPaymentsRouter); // Merchant dynamic payment links app.use('/api/v1/payment-links', paymentLinksRouter); +app.use('/api/v1/payment-strategies', paymentStrategiesRouter); // Merchant tax report generation (summary, 1099-K, VAT, nexus, CSV export) app.use('/api/v1/tax', taxRouter); diff --git a/backend/src/routes/email-v2.ts b/backend/src/routes/email-v2.ts index f3c189af..17fa5067 100644 --- a/backend/src/routes/email-v2.ts +++ b/backend/src/routes/email-v2.ts @@ -9,6 +9,7 @@ import EmailPreferenceService from '../services/email-preference.js'; import EmailLocalizationService from '../services/email-localization.js'; import EmailAnalyticsService from '../services/email-analytics.js'; import EmailBatchProcessor from '../services/email-batch-processor.js'; +import { listTemplates, renderTypedTemplate, TEMPLATE_REGISTRY, type TemplateId } from '../templates/registry.js'; const prisma = new PrismaClient(); const templateEngine = new EmailTemplateEngine(); @@ -20,6 +21,36 @@ const batchProcessor = new EmailBatchProcessor(); export const emailV2Router = Router(); +// ── Component-based template registry (preview / A-B testing) ────────────── + +const SAMPLE_DATA: Record> = { + 'welcome-email': { name: 'Alex Rivera', actionUrl: 'https://app.agenticpay.com/onboarding' }, + 'payment-received': { name: 'Alex Rivera', amount: 250, currency: 'USD', sender: 'Jordan Lee', transactionId: 'tx_9f2c1a' }, +}; + +emailV2Router.get('/templates/registry', (_req: Request, res: Response) => { + res.json({ templates: listTemplates() }); +}); + +// Template preview server (acceptance criteria): renders a registered +// template with sample data (or caller-supplied variables) through the real +// component/layout pipeline, optionally pinning an A/B variant. +emailV2Router.get('/templates/registry/:id/preview', (req: Request, res: Response) => { + const templateId = req.params.id as TemplateId; + if (!(templateId in TEMPLATE_REGISTRY)) { + return res.status(404).json({ error: `Unknown template: ${templateId}` }); + } + + const bucketKey = (req.query.bucketKey as string) || (req.query.variant as string) || 'preview'; + try { + const overrides = req.query.variables ? JSON.parse(req.query.variables as string) : {}; + const rendered = renderTypedTemplate(templateId, { ...SAMPLE_DATA[templateId], ...overrides } as never, bucketKey); + res.json(rendered); + } catch (error) { + res.status(400).json({ error: error instanceof Error ? error.message : 'Invalid preview variables' }); + } +}); + // ── Template Management ────────────────────────────────────────────────────── emailV2Router.post('/templates', async (req: Request, res: Response) => { diff --git a/backend/src/routes/payment-strategies.ts b/backend/src/routes/payment-strategies.ts new file mode 100644 index 00000000..faea1d41 --- /dev/null +++ b/backend/src/routes/payment-strategies.ts @@ -0,0 +1,64 @@ +import { Router } from 'express'; +import { asyncHandler, AppError } from '../middleware/errorHandler.js'; +import { providerRegistry } from '../services/payments/provider-registry.js'; +import { unifiedPaymentTracker } from '../services/payments/unified-payment-tracker.js'; +import { selectProviderId } from '../services/payments/payment-router.js'; +import type { PaymentInput } from '../services/payments/providers/types.js'; + +export const paymentStrategiesRouter = Router(); + +// Drives the strategy configuration UI: lists registered strategies, their +// live health, and running success/error metrics. +paymentStrategiesRouter.get('/', asyncHandler(async (_req, res) => { + const ids = providerRegistry.list(); + const healthy = new Set(await providerRegistry.listHealthy()); + const strategies = ids.map((id) => ({ + id, + healthy: healthy.has(id), + metrics: providerRegistry.getMetrics(id), + })); + res.json({ strategies }); +})); + +// Preview which strategy a given network/token/currency would resolve to, +// without executing a payment. Used by the config UI to explain routing. +paymentStrategiesRouter.post('/preview', asyncHandler(async (req, res) => { + const { network, currency, token, preferredProviderId } = req.body; + if (!network || !currency) { + throw new AppError(400, 'network and currency are required', 'VALIDATION_ERROR'); + } + const selected = selectProviderId( + { network, currency, token, amount: 0, toAddress: '', tenantId: '' } as PaymentInput, + preferredProviderId, + ); + res.json({ selectedStrategy: selected }); +})); + +paymentStrategiesRouter.post('/pay', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + const { amount, currency, token, fromAddress, toAddress, network, metadata, preferredProviderId } = req.body; + + if (!tenantId || !amount || !currency || !toAddress || !network) { + throw new AppError(400, 'tenantId, amount, currency, toAddress, and network are required', 'VALIDATION_ERROR'); + } + + const result = await unifiedPaymentTracker.routeAndTrack( + { amount, currency, token, fromAddress, toAddress, network, tenantId, metadata }, + preferredProviderId, + ); + + if (!result.ok) { + throw new AppError(result.error.statusCode, result.error.message, result.error.code, result.error.details); + } + + res.status(201).json(result.value); +})); + +// Unified payment history across every strategy. +paymentStrategiesRouter.get('/payments', asyncHandler(async (req, res) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) throw new AppError(400, 'x-tenant-id header is required', 'VALIDATION_ERROR'); + + const payments = await unifiedPaymentTracker.listByTenant(tenantId); + res.json({ payments }); +})); diff --git a/backend/src/services/email-template-engine.ts b/backend/src/services/email-template-engine.ts index d601ccca..adc08b3c 100644 --- a/backend/src/services/email-template-engine.ts +++ b/backend/src/services/email-template-engine.ts @@ -2,6 +2,12 @@ // Handlebars-style template rendering with localization support import Handlebars from 'handlebars'; +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const COMPONENTS_DIR = join(__dirname, '..', 'templates', 'components'); export interface TemplateVariable { name: string; @@ -27,6 +33,43 @@ export class EmailTemplateEngine { constructor() { this.handlebars = Handlebars.create(); this.registerHelpers(); + this.registerComponents(); + } + + /** + * Register every .hbs file in templates/components/ (header, footer, + * button, layout, ...) as a reusable Handlebars partial, keyed by + * filename. Lets templates opt into shared components via `{{> header}}`. + */ + private registerComponents(): void { + let files: string[] = []; + try { + files = readdirSync(COMPONENTS_DIR).filter((f) => f.endsWith('.hbs')); + } catch { + return; // components dir is optional in some deployments (e.g. bundled builds) + } + for (const file of files) { + const name = basename(file, '.hbs'); + const source = readFileSync(join(COMPONENTS_DIR, file), 'utf-8'); + this.handlebars.registerPartial(name, source); + } + } + + /** + * Renders `contentHtml` as a standalone component, then injects the + * result into the shared `layout` partial (header/footer chrome) — the + * template-inheritance pattern: layout owns the shell, callers only + * supply body content and get consistent chrome for free. + */ + renderWithLayout(contentHtml: string, context: TemplateContext): string { + const body = this.render(contentHtml, context); + const layoutSource = this.handlebars.partials['layout'] as string; + return this.render(layoutSource, { + currentYear: new Date().getFullYear(), + unsubscribeUrl: '#', + ...context, + body: new this.handlebars.SafeString(body), + }); } /** diff --git a/backend/src/services/payments/__tests__/payment-router.test.ts b/backend/src/services/payments/__tests__/payment-router.test.ts new file mode 100644 index 00000000..af7e76a8 --- /dev/null +++ b/backend/src/services/payments/__tests__/payment-router.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { PaymentProviderRegistry } from '../provider-registry.js'; +import { ProviderPaymentRouter, selectProviderId } from '../payment-router.js'; +import { MockPaymentProvider } from '../providers/mock.js'; +import type { PaymentInput } from '../providers/types.js'; + +const baseInput: PaymentInput = { + amount: 10, + currency: 'XLM', + toAddress: 'GABC', + network: 'stellar', + tenantId: 'tenant-1', +}; + +describe('selectProviderId', () => { + it('routes stellar network to the soroban strategy', () => { + expect(selectProviderId({ ...baseInput, network: 'stellar' })).toBe('soroban'); + }); + + it('routes EVM networks to the evm strategy', () => { + expect(selectProviderId({ ...baseInput, network: 'polygon' })).toBe('evm'); + }); + + it('routes fiat currency to the fiat strategy', () => { + expect(selectProviderId({ ...baseInput, network: 'other', currency: 'USD' })).toBe('fiat'); + }); + + it('routes by token symbol when provided, overriding currency', () => { + expect(selectProviderId({ ...baseInput, network: 'other', currency: 'XLM', token: 'EUR' })).toBe('fiat'); + }); + + it('falls back to credit for anything unrecognized', () => { + expect(selectProviderId({ ...baseInput, network: 'other', currency: 'POINTS' })).toBe('credit'); + }); +}); + +describe('ProviderPaymentRouter', () => { + let registry: PaymentProviderRegistry; + let router: ProviderPaymentRouter; + + beforeEach(() => { + registry = new PaymentProviderRegistry(); + router = new ProviderPaymentRouter(registry); + }); + + it('routes to the strategy selected for the network', async () => { + registry.register(new MockPaymentProvider('soroban')); + registry.register(new MockPaymentProvider('evm')); + + const result = await router.route({ ...baseInput, network: 'stellar' }); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.providerId).toBe('soroban'); + }); + + it('falls back to another healthy strategy when the primary one fails', async () => { + registry.register(new MockPaymentProvider('soroban', { shouldFail: true })); + registry.register(new MockPaymentProvider('evm')); + + const result = await router.route({ ...baseInput, network: 'stellar' }); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.providerId).toBe('evm'); + expect(registry.getMetrics('soroban').errorCount).toBe(1); + }); + + it('fails when every strategy fails', async () => { + registry.register(new MockPaymentProvider('soroban', { shouldFail: true })); + + const result = await router.route({ ...baseInput, network: 'stellar' }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('PROVIDER_UNAVAILABLE'); + }); + + it('respects an explicit preferred provider override', async () => { + registry.register(new MockPaymentProvider('soroban')); + registry.register(new MockPaymentProvider('evm')); + + const result = await router.route({ ...baseInput, network: 'stellar' }, 'evm'); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.providerId).toBe('evm'); + }); +}); diff --git a/backend/src/services/payments/payment-router.ts b/backend/src/services/payments/payment-router.ts index ee95bde9..73a5aadd 100644 --- a/backend/src/services/payments/payment-router.ts +++ b/backend/src/services/payments/payment-router.ts @@ -1,25 +1,35 @@ import { BaseService } from '../BaseService.js'; import type { Result } from '../../lib/result.js'; -import { providerRegistry } from './provider-registry.js'; +import { providerRegistry, type PaymentProviderRegistry } from './provider-registry.js'; import type { PaymentInput, PaymentOutput, PaymentProvider } from './providers/types.js'; const EVM_NETWORKS = new Set(['ethereum', 'polygon', 'arbitrum', 'optimism', 'base']); - -function selectProviderId(input: PaymentInput, preferredId?: string): string { - if (preferredId && providerRegistry.get(preferredId)) return preferredId; +const FIAT_CURRENCIES = new Set(['USD', 'EUR', 'GBP']); + +/** + * Strategy selection: chain/network takes priority (a chain can only be + * serviced by the provider that speaks its protocol), then token/currency + * disambiguates within a chain-agnostic path (e.g. fiat rails vs. credit). + */ +export function selectProviderId(input: PaymentInput, preferredId?: string, registry: PaymentProviderRegistry = providerRegistry): string { + if (preferredId && registry.get(preferredId)) return preferredId; if (input.network === 'stellar') return 'soroban'; if (EVM_NETWORKS.has(input.network)) return 'evm'; - if (input.currency === 'USD' || input.currency === 'EUR') return 'fiat'; + if (input.token ? FIAT_CURRENCIES.has(input.token) : FIAT_CURRENCIES.has(input.currency)) return 'fiat'; return 'credit'; } export class ProviderPaymentRouter extends BaseService { + constructor(private readonly registry: PaymentProviderRegistry = providerRegistry) { + super(); + } + async route(input: PaymentInput, preferredProviderId?: string): Promise> { - const primaryId = selectProviderId(input, preferredProviderId); - const candidateIds = [primaryId, ...providerRegistry.list().filter((id) => id !== primaryId)]; + const primaryId = selectProviderId(input, preferredProviderId, this.registry); + const candidateIds = [primaryId, ...this.registry.list().filter((id) => id !== primaryId)]; for (const id of candidateIds) { - const provider: PaymentProvider | undefined = providerRegistry.get(id); + const provider: PaymentProvider | undefined = this.registry.get(id); if (!provider) continue; const start = Date.now(); @@ -27,11 +37,11 @@ export class ProviderPaymentRouter extends BaseService { const latencyMs = Date.now() - start; if (result.ok) { - providerRegistry.recordSuccess(id, latencyMs); + this.registry.recordSuccess(id, latencyMs); return this.ok({ ...result.value, providerId: id }); } - providerRegistry.recordError(id); + this.registry.recordError(id); // Continue to next fallback provider } diff --git a/backend/src/services/payments/providers/mock.ts b/backend/src/services/payments/providers/mock.ts new file mode 100644 index 00000000..2e6ac891 --- /dev/null +++ b/backend/src/services/payments/providers/mock.ts @@ -0,0 +1,58 @@ +import { randomUUID } from 'node:crypto'; +import { ok, err } from '../../../lib/result.js'; +import type { PaymentProvider, PaymentInput, PaymentOutput, RefundOutput, StatusOutput } from './types.js'; + +/** + * In-memory strategy implementation for unit tests and local dev. Never + * touches a real chain — deterministic, synchronous outcomes controlled by + * the caller so strategy-selection/fallback/tracking logic can be tested + * without mocking Stellar/EVM SDKs. + */ +export class MockPaymentProvider implements PaymentProvider { + readonly id: string; + private readonly shouldFail: boolean; + private readonly statuses = new Map(); + + constructor(id = 'mock', options: { shouldFail?: boolean } = {}) { + this.id = id; + this.shouldFail = options.shouldFail ?? false; + } + + async processPayment(input: PaymentInput) { + if (this.shouldFail) { + return err({ code: 'MOCK_PROVIDER_FAILURE', message: `${this.id} provider forced failure`, statusCode: 502 }); + } + + const txHash = `mock_${randomUUID().replace(/-/g, '')}`; + const output: PaymentOutput = { + txHash, + providerId: this.id, + network: input.network, + status: 'pending', + raw: { amount: input.amount, currency: input.currency }, + }; + this.statuses.set(txHash, { txHash, status: 'pending' }); + return ok(output); + } + + async refundPayment(txId: string, amount?: number) { + return ok({ txHash: `${txId}_refund`, refundedAmount: amount ?? 0 } satisfies RefundOutput); + } + + async getStatus(txId: string) { + return ok(this.statuses.get(txId) ?? { txHash: txId, status: 'confirmed' as const }); + } + + validateConfig(): boolean { + return true; + } + + async healthCheck(): Promise { + return !this.shouldFail; + } + + /** Test helper: mark a previously processed tx as confirmed. */ + confirm(txHash: string): void { + this.statuses.set(txHash, { txHash, status: 'confirmed', confirmations: 1 }); + } +} diff --git a/backend/src/services/payments/providers/types.ts b/backend/src/services/payments/providers/types.ts index 4751f2f7..78054bea 100644 --- a/backend/src/services/payments/providers/types.ts +++ b/backend/src/services/payments/providers/types.ts @@ -3,6 +3,8 @@ import type { Result } from '../../../lib/result.js'; export interface PaymentInput { amount: number; currency: string; + /** Token symbol/contract when the payment is a specific token rather than the network's native asset (e.g. "USDC"). */ + token?: string; fromAddress?: string; toAddress: string; network: string; diff --git a/backend/src/services/payments/unified-payment-tracker.ts b/backend/src/services/payments/unified-payment-tracker.ts new file mode 100644 index 00000000..95804dfa --- /dev/null +++ b/backend/src/services/payments/unified-payment-tracker.ts @@ -0,0 +1,68 @@ +import { prisma } from '../../lib/prisma.js'; +import { BaseService } from '../BaseService.js'; +import type { Result } from '../../lib/result.js'; +import { providerPaymentRouter } from './payment-router.js'; +import type { PaymentInput, PaymentOutput } from './providers/types.js'; + +/** + * Persists a strategy-agnostic record of every payment in the `payments` + * table (`providerId` + `txHash` columns), regardless of which + * PaymentProvider strategy actually executed it. This is the single place + * to query "all payments" across Stellar, EVM, fiat, and credit strategies. + */ +export class UnifiedPaymentTracker extends BaseService { + async routeAndTrack(input: PaymentInput, preferredProviderId?: string): Promise> { + const result = await providerPaymentRouter.route(input, preferredProviderId); + + if (result.ok) { + await prisma.payment.create({ + data: { + tenantId: input.tenantId, + txHash: result.value.txHash, + amount: input.amount, + currency: input.currency, + network: result.value.network, + providerId: result.value.providerId, + fromAddress: input.fromAddress, + toAddress: input.toAddress, + status: result.value.status === 'confirmed' ? 'completed' : 'pending', + metadata: input.metadata as any, + }, + }); + } + + return result; + } + + /** Refresh a tracked payment's status by asking its owning strategy. */ + async syncStatus(paymentId: string): Promise> { + const payment = await prisma.payment.findUniqueOrThrow({ where: { id: paymentId } }); + if (!payment.providerId || !payment.txHash) { + return this.fail('Payment has no provider/tx to sync', 400, 'PAYMENT_NOT_TRACKED'); + } + + const { providerRegistry } = await import('./provider-registry.js'); + const provider = providerRegistry.get(payment.providerId); + if (!provider) { + return this.fail(`Unknown provider: ${payment.providerId}`, 404, 'PROVIDER_NOT_FOUND'); + } + + const statusResult = await provider.getStatus(payment.txHash); + if (!statusResult.ok) return statusResult; + + const status = statusResult.value.status === 'confirmed' ? 'completed' : statusResult.value.status; + await prisma.payment.update({ where: { id: paymentId }, data: { status: status as any } }); + return this.ok({ status }); + } + + /** Unified view across all strategies for a tenant. */ + async listByTenant(tenantId: string, limit = 50) { + return prisma.payment.findMany({ + where: { tenantId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } +} + +export const unifiedPaymentTracker = new UnifiedPaymentTracker(); diff --git a/backend/src/templates/components/button.hbs b/backend/src/templates/components/button.hbs new file mode 100644 index 00000000..263c5111 --- /dev/null +++ b/backend/src/templates/components/button.hbs @@ -0,0 +1,7 @@ + + + + +
+ {{label}} +
diff --git a/backend/src/templates/components/footer.hbs b/backend/src/templates/components/footer.hbs new file mode 100644 index 00000000..acef1b15 --- /dev/null +++ b/backend/src/templates/components/footer.hbs @@ -0,0 +1,8 @@ + + + + +
+ © {{currentYear}} AgenticPay. You received this email because you have an AgenticPay account. +
Unsubscribe +
diff --git a/backend/src/templates/components/header.hbs b/backend/src/templates/components/header.hbs new file mode 100644 index 00000000..91c007ae --- /dev/null +++ b/backend/src/templates/components/header.hbs @@ -0,0 +1,5 @@ + + + + +
AgenticPay
diff --git a/backend/src/templates/components/layout.hbs b/backend/src/templates/components/layout.hbs new file mode 100644 index 00000000..871e7a2f --- /dev/null +++ b/backend/src/templates/components/layout.hbs @@ -0,0 +1,21 @@ + + + + + + {{subject}} + + + + + + +
+ + + + +
{{> header}}
{{{body}}}
{{> footer}}
+
+ + diff --git a/backend/src/templates/registry.ts b/backend/src/templates/registry.ts new file mode 100644 index 00000000..3bcf33b7 --- /dev/null +++ b/backend/src/templates/registry.ts @@ -0,0 +1,127 @@ +import { z } from 'zod'; +import EmailTemplateEngine from '../services/email-template-engine.js'; +import type { RenderedTemplate } from '../services/email-template-engine.js'; + +const engine = new EmailTemplateEngine(); + +/** + * Type-safe, component-based email template registry. Each entry owns a Zod + * schema for its variables (validated at render time) plus content HTML that + * composes shared components (`{{> button}}`, etc.) — the layout/header/ + * footer chrome is applied uniformly via `engine.renderWithLayout`. + * Optional `variants` enable A/B testing of subject/content per template. + */ +export interface TemplateVariant { + id: string; + weight: number; + subject: string; + contentHtml: string; +} + +export interface TemplateDefinition { + id: string; + version: number; + schema: Schema; + subject: string; + contentHtml: string; + variants?: TemplateVariant[]; +} + +const welcomeSchema = z.object({ + name: z.string().min(1), + actionUrl: z.string().url(), +}); + +const paymentReceivedSchema = z.object({ + name: z.string().min(1), + amount: z.number().positive(), + currency: z.string().min(2).max(8), + sender: z.string().min(1), + transactionId: z.string().min(1), +}); + +export const TEMPLATE_REGISTRY = { + 'welcome-email': { + id: 'welcome-email', + version: 1, + schema: welcomeSchema, + subject: 'Welcome to AgenticPay, {{name}}!', + contentHtml: ` +

Welcome, {{name}}!

+

We're excited to have you on board.

+ {{> button url=actionUrl label="Get started"}} + `, + variants: [ + { id: 'control', weight: 50, subject: 'Welcome to AgenticPay, {{name}}!', contentHtml: `

Welcome, {{name}}!

We're excited to have you on board.

{{> button url=actionUrl label="Get started"}}` }, + { id: 'urgency', weight: 50, subject: '{{name}}, your AgenticPay account is ready', contentHtml: `

You're all set, {{name}}

Complete setup now to unlock instant payments.

{{> button url=actionUrl label="Finish setup"}}` }, + ], + }, + 'payment-received': { + id: 'payment-received', + version: 1, + schema: paymentReceivedSchema, + subject: 'Payment of {{formatCurrency amount currency}} received', + contentHtml: ` +

Payment received

+

Hi {{name}}, you've received a payment of {{formatCurrency amount currency}} from {{sender}}.

+

Transaction ID: {{transactionId}}

+ `, + }, +} as const satisfies Record; + +export type TemplateId = keyof typeof TEMPLATE_REGISTRY; +export type TemplateVariables = z.infer<(typeof TEMPLATE_REGISTRY)[K]['schema']>; + +/** Deterministic A/B bucketing: same recipient always gets the same variant for a given template. */ +function selectVariant(def: TemplateDefinition, bucketKey: string): { id: string; subject: string; contentHtml: string } { + if (!def.variants || def.variants.length === 0) { + return { id: 'default', subject: def.subject, contentHtml: def.contentHtml }; + } + let hash = 0; + for (let i = 0; i < bucketKey.length; i++) hash = (hash * 31 + bucketKey.charCodeAt(i)) >>> 0; + const totalWeight = def.variants.reduce((sum, v) => sum + v.weight, 0); + let cursor = hash % totalWeight; + for (const variant of def.variants) { + if (cursor < variant.weight) return variant; + cursor -= variant.weight; + } + return def.variants[0]; +} + +/** + * Validates `variables` against the template's Zod schema (compile-time via + * the generic, runtime via `.parse`), selects an A/B variant deterministically + * per `bucketKey` (e.g. recipient email), and renders through the shared + * layout. Throws a ZodError on invalid variables — callers should surface + * that as a 400 rather than silently sending a broken email. + */ +export function renderTypedTemplate( + templateId: K, + variables: TemplateVariables, + bucketKey = 'default', +): RenderedTemplate & { templateId: K; version: number; variant: string } { + const def = TEMPLATE_REGISTRY[templateId] as TemplateDefinition; + const parsed = def.schema.parse(variables); + const variant = selectVariant(def, bucketKey); + + const htmlBody = engine.renderWithLayout(variant.contentHtml, parsed); + const subject = engine.render(variant.subject, parsed); + + return { + templateId, + version: def.version, + variant: variant.id, + subject, + htmlBody, + textBody: htmlBody.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(), + missingVariables: [], + }; +} + +export function listTemplates() { + return Object.values(TEMPLATE_REGISTRY).map((def) => ({ + id: def.id, + version: def.version, + variants: def.variants?.map((v) => v.id) ?? ['default'], + })); +} diff --git a/docs/config/CONFIGURATION.md b/docs/config/CONFIGURATION.md new file mode 100644 index 00000000..f5e35caa --- /dev/null +++ b/docs/config/CONFIGURATION.md @@ -0,0 +1,34 @@ + +# Configuration Reference + +## Runtime configuration keys + +| Key | Type | Description | Env var | Default | Sensitive | +|-----|------|-------------|---------|---------|-----------| +| `features.batchOperations` | boolean | Enable batch payment operations. | `FEATURE_BATCH_OPERATIONS` | `true` | no | +| `features.sandboxMode` | boolean | Enable sandbox-only development behavior. | `SANDBOX_MODE` | `false` | no | +| `fees.platformBps` | number | Platform fee in basis points. | `PLATFORM_FEE_BPS` | `100` | no | +| `rateLimits.free` | number | Default free-tier API request limit. | `RATE_LIMIT_FREE` | `100` | no | +| `rateLimits.pro` | number | Default pro-tier API request limit. | `RATE_LIMIT_PRO` | `300` | no | +| `rateLimits.enterprise` | number | Default enterprise-tier API request limit. | `RATE_LIMIT_ENTERPRISE` | `1000` | no | +| `providers.stellar` | object | Stellar provider settings. | `STELLAR_PROVIDER_CONFIG` | `{"network":"testnet"}` | no | +| `providers.stripe` | object | Stripe provider settings. | `STRIPE_PROVIDER_CONFIG` | `{"enabled":false}` | no | +| `payments.defaultCurrency` | string | Default payment currency for new payment flows. | `DEFAULT_PAYMENT_CURRENCY` | `"XLM"` | no | +| `notifications.webhookRetries` | number | Maximum webhook delivery retry attempts. | `WEBHOOK_RETRY_ATTEMPTS` | `5` | no | + +## Environment overrides + +| Key | development | staging | production | +|-----|---|---|---| +| `AWS_SECRETS_MANAGER_ENABLED` | false | true | true | +| `AWS_SECRETS_MANAGER_SECRET_ID` | — | agenticpay-staging-app-secrets | agenticpay-prod-app-secrets | +| `CORS_ALLOWED_ORIGINS` | * | https://staging.agenticpay.app | https://app.agenticpay.io | +| `JOBS_ENABLED` | true | true | true | +| `QUEUE_ENABLED` | true | true | true | +| `RATE_LIMIT_ENTERPRISE` | — | 1000 | 2000 | +| `RATE_LIMIT_FREE` | — | 100 | 60 | +| `RATE_LIMIT_PRO` | — | 300 | 300 | +| `STELLAR_NETWORK` | testnet | testnet | public | + +Secrets (API keys, DB credentials) are not stored in these files — see `infra/main.tf` +for the AWS Secrets Manager resources referenced by `AWS_SECRETS_MANAGER_SECRET_ID`. diff --git a/frontend/app/[locale]/error.tsx b/frontend/app/[locale]/error.tsx new file mode 100644 index 00000000..e763247b --- /dev/null +++ b/frontend/app/[locale]/error.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { AlertCircle } from 'lucide-react'; + +interface ErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +// Catches errors for any [locale] route segment that doesn't define its own +// error.tsx (auth, forms, logs, onboarding, payments, security, accessibility). +export default function LocaleError({ error, reset }: ErrorProps) { + useEffect(() => { + console.error('[route] Unhandled error:', error); + }, [error]); + + return ( +
+ + + + +

+ {error.message || 'An unexpected error occurred while loading this page.'} +

+ {error.digest && ( +

Error ID: {error.digest}

+ )} + +
+
+
+ ); +} diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index 93405023..afa67255 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -7,6 +7,7 @@ import { Providers } from '@/components/providers'; import PWAWrapper from '@/components/PWAWrapper'; import { OfflineProvider } from '@/components/offline/OfflineProvider'; import { WebVitals } from '@/components/WebVitals'; +import { RouteTransitionMetrics } from '@/components/analytics/RouteTransitionMetrics'; const APP_DOMAIN = process.env.NEXT_PUBLIC_API_URL || 'https://agenticpay.com'; const CDN_DOMAIN = process.env.NEXT_PUBLIC_IMAGE_CDN_DOMAIN || 'cdn.agenticpay.com'; @@ -79,6 +80,7 @@ export default async function LocaleLayout({ children, params }: Props) { {children} + diff --git a/frontend/app/admin/configuration/page.tsx b/frontend/app/admin/configuration/page.tsx index 67f8601f..e629e8a1 100644 --- a/frontend/app/admin/configuration/page.tsx +++ b/frontend/app/admin/configuration/page.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { StrategyConfigPanel } from '@/components/payment/StrategyConfigPanel'; type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue }; @@ -146,6 +147,7 @@ export default function ConfigurationAdminPage() { Values Import/Export Audit + Payment Strategies @@ -246,6 +248,10 @@ export default function ConfigurationAdminPage() { {audit.length} audited changes + + + + {status ?
{status}
: null} diff --git a/frontend/app/admin/error.tsx b/frontend/app/admin/error.tsx new file mode 100644 index 00000000..8639b978 --- /dev/null +++ b/frontend/app/admin/error.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { useEffect } from 'react'; + +interface ErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function AdminError({ error, reset }: ErrorProps) { + useEffect(() => { + console.error('[admin] Unhandled error:', error); + }, [error]); + + return ( +
+
+

Admin panel error

+

+ {error.message || 'An unexpected error occurred in the admin panel.'} +

+ {error.digest &&

Error ID: {error.digest}

} + +
+
+ ); +} diff --git a/frontend/app/dashboard/emails/page.tsx b/frontend/app/dashboard/emails/page.tsx new file mode 100644 index 00000000..767c54fe --- /dev/null +++ b/frontend/app/dashboard/emails/page.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +interface TemplateSummary { + id: string; + version: number; + variants: string[]; +} + +interface PreviewResult { + subject: string; + htmlBody: string; + version: number; + variant: string; +} + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; + +/** Template preview server UI: pick a component-based template + A/B variant and render it live. */ +export default function EmailTemplatesPage() { + const [templates, setTemplates] = useState([]); + const [selectedId, setSelectedId] = useState(''); + const [bucketKey, setBucketKey] = useState('preview-1'); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetch(`${API_URL}/api/v2/email/templates/registry`) + .then((res) => res.json()) + .then((data) => { + setTemplates(data.templates ?? []); + if (data.templates?.[0]) setSelectedId(data.templates[0].id); + }) + .catch((err) => setError(err.message)); + }, []); + + useEffect(() => { + if (!selectedId) return; + fetch(`${API_URL}/api/v2/email/templates/registry/${selectedId}/preview?bucketKey=${encodeURIComponent(bucketKey)}`) + .then((res) => res.json()) + .then((data) => { + if (data.error) { + setError(data.error); + return; + } + setError(null); + setPreview(data); + }) + .catch((err) => setError(err.message)); + }, [selectedId, bucketKey]); + + return ( +
+
+

Email Templates

+ +
+ + setBucketKey(e.target.value)} + placeholder="A/B bucket key (e.g. recipient email)" + /> + {preview && Rendered variant: {preview.variant}} +
+ + {error &&

{error}

} + + {preview && ( +
+
Subject: {preview.subject}
+