diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a492ee5..f5dab316 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,3 +119,10 @@ Pull requests may be sent back for updates if they do not include appropriate va ## Questions and Support If you are unsure about an implementation detail, open an issue or start a discussion before investing heavily in a large change. Early alignment helps us review and merge contributions faster. +# Frontend Domain Modules + +Frontend feature code should live under `frontend/src/domains//` where the current domains are `payments`, `merchants`, `wallets`, `analytics`, `settings`, and `developers`. Each domain owns `components/`, `hooks/`, `api/`, `types/`, and `pages/` subdirectories plus a barrel export from `index.ts`. + +Use domain aliases for feature imports, for example `@payments/hooks` or `@wallets/api`. Shared utilities belong in `@shared/*`, while design-system primitives belong in `@ui/*` or the existing `components/ui` implementation. Direct imports from one domain into another are blocked by the local ESLint boundary rule; move cross-domain code into shared modules instead. + +For mechanical migrations, run `npm run migrate:domain-imports -- ` from `frontend/` and then review the diff. New pages can remain in the Next.js `app/` tree, but feature-specific logic should be colocated in the owning domain module. diff --git a/backend/package.json b/backend/package.json index 926c1582..13378b44 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,6 +34,8 @@ "generate:vapid-keys": "node scripts/generate-vapid-keys.js" }, "dependencies": { + "@agenticpay/api-spec": "*", + "@agenticpay/error-codes": "*", "@agenticpay/types": "*", "@prisma/client": "^5.22.0", "@sentry/node": "^10.50.0", diff --git a/backend/prisma/migrations/20260627020000_configuration_service/migration.sql b/backend/prisma/migrations/20260627020000_configuration_service/migration.sql new file mode 100644 index 00000000..c0190c04 --- /dev/null +++ b/backend/prisma/migrations/20260627020000_configuration_service/migration.sql @@ -0,0 +1,30 @@ +CREATE TABLE "app_configurations" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" JSONB NOT NULL, + "source" TEXT NOT NULL DEFAULT 'database', + "description" TEXT, + "updated_by" TEXT, + "version" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "app_configurations_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "config_audit_logs" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "old_value" JSONB, + "new_value" JSONB, + "actor" TEXT, + "reason" TEXT, + "source" TEXT NOT NULL DEFAULT 'admin', + "request_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "config_audit_logs_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "app_configurations_key_key" ON "app_configurations"("key"); +CREATE INDEX "app_configurations_source_idx" ON "app_configurations"("source"); +CREATE INDEX "config_audit_logs_key_created_at_idx" ON "config_audit_logs"("key", "created_at"); +CREATE INDEX "config_audit_logs_actor_idx" ON "config_audit_logs"("actor"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 23fe862f..ad508310 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -956,6 +956,39 @@ model ApiVersionEndpoint { @@map("api_version_endpoints") } +// ─── Centralized Configuration — Issue #481 ────────────────────────────────── + +model AppConfiguration { + id String @id @default(uuid()) + key String @unique + value Json + source String @default("database") + description String? + updatedBy String? @map("updated_by") + version Int @default(1) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([source]) + @@map("app_configurations") +} + +model ConfigAuditLog { + id String @id @default(uuid()) + key String + oldValue Json? @map("old_value") + newValue Json? @map("new_value") + actor String? + reason String? + source String @default("admin") + requestId String? @map("request_id") + createdAt DateTime @default(now()) @map("created_at") + + @@index([key, createdAt]) + @@index([actor]) + @@map("config_audit_logs") +} + // ─── Payment Categories — Issue #251 ───────────────────────────────────────── enum PaymentCategoryType { diff --git a/backend/scripts/generate-openapi.ts b/backend/scripts/generate-openapi.ts index d214063d..c1a844d9 100644 --- a/backend/scripts/generate-openapi.ts +++ b/backend/scripts/generate-openapi.ts @@ -9,6 +9,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { createOpenAPIGenerator } from '../src/lib/openapi-generator.js'; import { registerRoutesFromRegistry } from '../src/lib/openapi-registry.js'; +import { API_OPERATIONS } from '@agenticpay/api-spec'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const BACKEND_ROOT = path.resolve(__dirname, '..'); @@ -56,6 +57,40 @@ async function generateOpenAPISpec(config: GeneratorConfig): Promise { registerRoutesFromRegistry(generator); + for (const operation of API_OPERATIONS) { + generator.registerPath(operation.method, operation.path.replace(/^\/api\/v1/, ''), { + tags: operation.tags, + summary: operation.summary, + deprecated: operation.deprecated, + responses: Object.fromEntries( + Object.keys(operation.responses).map((status) => [ + status, + { + description: status.startsWith('2') ? 'Successful response' : 'Error response', + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + ]) + ), + ...(operation.sunset + ? { + parameters: [ + { + name: 'Sunset', + in: 'header', + description: `Endpoint sunset date: ${operation.sunset}`, + required: false, + schema: { type: 'string', format: 'date-time' }, + }, + ], + } + : {}), + }); + } + const specDir = path.join(config.outputDir, 'openapi'); fs.mkdirSync(specDir, { recursive: true }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 81171615..a3f99968 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -117,6 +117,9 @@ import { startOutboxPublisher, stopOutboxPublisher } from './outbox/index.js'; import { gasRouter } from './routes/gas.js'; import { vaultsRouter } from './routes/vaults.js'; import { createConnectionManager } from './websocket/connection-manager.js'; +import { configurationRouter } from './routes/configuration.js'; +import { errorsRouter } from './routes/errors.js'; +import { openApiValidator } from './middleware/openapi-validator.js'; // Validate environment variables at startup validateEnv(); @@ -203,6 +206,7 @@ app.use('/webhooks', webhookHandlersRouter); app.use(express.json()); app.use(express.text({ type: ['text/csv', 'text/plain'] })); +app.use('/api', openApiValidator({ validateResponses: process.env.OPENAPI_VALIDATE_RESPONSES === 'true' })); app.use( compressionMiddleware({ @@ -219,6 +223,8 @@ app.use(cacheControlNoStore); app.use(healthRouter); app.use('/docs', docsRouter); +app.use('/api-docs', docsRouter); +app.use('/api', errorsRouter); // Cold start monitoring dashboard — available before auth/rate-limit middleware app.use('/api/v1/cold-start', coldStartMonitorRouter); @@ -339,6 +345,7 @@ app.use('/api/v1/tax', taxRouter); // Third-party backend plugins app.use('/api/v1/admin/plugins', pluginsRouter); +app.use('/api/v1/admin/configuration', configurationRouter); // Smart contract emergency pause management (Issue #513) app.use('/api/v1/admin/contracts/pause', pauseManagerRouter); diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts index 03a42b84..db4b7efc 100644 --- a/backend/src/middleware/errorHandler.ts +++ b/backend/src/middleware/errorHandler.ts @@ -1,4 +1,5 @@ import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import { ERROR_CODE_REGISTRY, resolveErrorCode } from '@agenticpay/error-codes'; type AsyncRouteHandler = (req: Request, res: Response, next: NextFunction) => Promise; @@ -26,10 +27,11 @@ export function notFoundHandler(req: Request, _res: Response, next: NextFunction next(new AppError(404, `Route not found: ${req.method} ${req.originalUrl}`, 'NOT_FOUND')); } -export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) { +export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) { const isAppError = err instanceof AppError; const statusCode = isAppError ? err.statusCode : 500; - const code = isAppError ? err.code : 'INTERNAL_SERVER_ERROR'; + const code = resolveErrorCode(isAppError ? err.code : undefined, statusCode); + const registered = ERROR_CODE_REGISTRY[code]; const isProduction = process.env.NODE_ENV === 'production'; const message = isAppError ? err.message @@ -39,15 +41,20 @@ export function errorHandler(err: unknown, _req: Request, res: Response, _next: ? err.message : 'Unexpected error'; - const logMethod = statusCode >= 500 ? console.error : console.warn; + const logMethod = registered.httpStatus >= 500 ? console.error : console.warn; logMethod(`[${code}] ${message}`, err); - res.status(statusCode).json({ + if (registered.deprecated && registered.sunsetAt) { + res.setHeader('Sunset', registered.sunsetAt); + res.setHeader('Deprecation', 'true'); + } + + res.status(registered.httpStatus || statusCode).json({ error: { code, message, - status: statusCode, ...(isAppError && err.details !== undefined ? { details: err.details } : {}), + ...(req.requestId ? { requestId: req.requestId } : {}), ...(!isProduction && !isAppError && err instanceof Error && err.stack ? { stack: err.stack } : {}), diff --git a/backend/src/middleware/openapi-validator.ts b/backend/src/middleware/openapi-validator.ts new file mode 100644 index 00000000..c080d9eb --- /dev/null +++ b/backend/src/middleware/openapi-validator.ts @@ -0,0 +1,92 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import { API_OPERATIONS, type ApiOperationSchema, pathToRegex } from '@agenticpay/api-spec'; +import { ZodError, type ZodTypeAny } from 'zod'; +import { AppError } from './errorHandler.js'; + +const operations = API_OPERATIONS.map((operation) => ({ + operation, + matcher: pathToRegex(operation.path), +})); + +function findOperation(req: Request): { operation: ApiOperationSchema; params: Record } | undefined { + const path = req.originalUrl.split('?')[0]; + for (const { operation, matcher } of operations) { + if (operation.method !== req.method) return false; + const match = matcher.regex.exec(path); + if (!match) continue; + const params = Object.fromEntries(matcher.params.map((param, index) => [param, decodeURIComponent(match[index + 1] ?? '')])); + return { operation, params }; + } + return undefined; +} + +function parseTarget(schema: ZodTypeAny | undefined, value: unknown, target: string) { + if (!schema) return value; + try { + return schema.parse(value ?? {}); + } catch (error) { + if (error instanceof ZodError) { + throw new AppError(400, 'Request validation failed', 'ERR_VALIDATION_FAILED', { + target, + issues: error.errors.map((issue) => ({ + path: issue.path.join('.') || 'root', + message: issue.message, + })), + }); + } + throw error; + } +} + +function validateResponse(operation: ApiOperationSchema, status: number, body: unknown): void { + const schema = operation.responses[status] ?? operation.responses[Math.floor(status / 100) * 100]; + if (!schema) return; + const result = schema.safeParse(body); + if (!result.success) { + throw new AppError(500, 'Response schema validation failed', 'ERR_INTERNAL', { + operationId: operation.operationId, + status, + issues: result.error.errors.map((issue) => ({ + path: issue.path.join('.') || 'root', + message: issue.message, + })), + }); + } +} + +export function openApiValidator(options: { validateResponses?: boolean } = {}): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + const match = findOperation(req); + if (!match) return next(); + const { operation } = match; + + try { + req.body = parseTarget(operation.request?.body, req.body, 'body'); + req.query = parseTarget(operation.request?.query, req.query, 'query') as typeof req.query; + req.params = parseTarget(operation.request?.params, { ...req.params, ...match.params }, 'params') as typeof req.params; + + if (operation.deprecated && operation.sunset) { + res.setHeader('Sunset', operation.sunset); + res.setHeader('Deprecation', 'true'); + } + + if (options.validateResponses) { + const originalJson = res.json.bind(res); + res.json = ((body: unknown) => { + validateResponse(operation, res.statusCode, body); + return originalJson(body); + }) as typeof res.json; + } + + next(); + } catch (error) { + next(error); + } + }; +} + +export function validateResponseAgainstOpenAPI(operationId: string, status: number, body: unknown): void { + const operation = API_OPERATIONS.find((item) => item.operationId === operationId); + if (!operation) throw new Error(`Unknown OpenAPI operation: ${operationId}`); + validateResponse(operation, status, body); +} diff --git a/backend/src/middleware/validate.ts b/backend/src/middleware/validate.ts index 5a66621b..a6ed3824 100644 --- a/backend/src/middleware/validate.ts +++ b/backend/src/middleware/validate.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { ZodSchema, ZodError, ZodIssue } from 'zod'; +import { AppError } from './errorHandler.js'; export interface ValidationTargets { body?: ZodSchema; @@ -33,11 +34,7 @@ export const validateRequest = (targets: ValidationTargets) => { next(); } catch (error) { if (error instanceof ZodError) { - return res.status(400).json({ - error: 'VALIDATION_FAILED', - message: 'Request validation failed', - details: formatIssues(error.errors), - }); + return next(new AppError(400, 'Request validation failed', 'ERR_VALIDATION_FAILED', formatIssues(error.errors))); } next(error); } diff --git a/backend/src/routes/configuration.ts b/backend/src/routes/configuration.ts new file mode 100644 index 00000000..0ea20d68 --- /dev/null +++ b/backend/src/routes/configuration.ts @@ -0,0 +1,93 @@ +import { Router } from 'express'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { configurationService } from '../services/config/index.js'; + +export const configurationRouter = Router(); + +function actorFromRequest(req: any): string | undefined { + return req.user?.id ?? req.headers['x-actor-id']?.toString(); +} + +configurationRouter.get( + '/', + asyncHandler(async (_req, res) => { + await configurationService.init(); + res.json({ data: configurationService.list() }); + }) +); + +configurationRouter.get( + '/schema', + asyncHandler(async (_req, res) => { + res.json({ data: configurationService.schema() }); + }) +); + +configurationRouter.get( + '/export', + asyncHandler(async (_req, res) => { + await configurationService.init(); + res.json({ values: configurationService.export() }); + }) +); + +configurationRouter.post( + '/import', + asyncHandler(async (req, res) => { + await configurationService.init(); + const updated = await configurationService.import( + req.body.values ?? {}, + actorFromRequest(req), + req.body.reason, + req.requestId + ); + res.json({ updated }); + }) +); + +configurationRouter.get( + '/audit', + asyncHandler(async (req, res) => { + const limit = req.query.limit ? Number(req.query.limit) : undefined; + const rows = await configurationService.audit(limit); + res.json({ + data: rows.map((row) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + })), + }); + }) +); + +configurationRouter.get( + '/:key', + asyncHandler(async (req, res) => { + await configurationService.init(); + res.json(configurationService.get(req.params.key as any)); + }) +); + +configurationRouter.put( + '/:key', + asyncHandler(async (req, res) => { + await configurationService.init(); + const updated = await configurationService.update({ + key: req.params.key, + value: req.body.value, + actor: actorFromRequest(req), + reason: req.body.reason, + requestId: req.requestId, + expectedVersion: req.body.expectedVersion, + }); + res.json(updated); + }) +); + +configurationRouter.put( + '/:key/runtime', + asyncHandler(async (req, res) => { + await configurationService.init(); + const updated = await configurationService.setRuntimeOverride(req.params.key, req.body.value); + res.json(updated); + }) +); diff --git a/backend/src/routes/errors.ts b/backend/src/routes/errors.ts new file mode 100644 index 00000000..6e4e6c1e --- /dev/null +++ b/backend/src/routes/errors.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { listErrorCodes } from '@agenticpay/error-codes'; + +export const errorsRouter = Router(); + +errorsRouter.get('/errors', (_req, res) => { + res.json({ data: listErrorCodes() }); +}); diff --git a/backend/src/services/config/config-schema.ts b/backend/src/services/config/config-schema.ts new file mode 100644 index 00000000..738898d4 --- /dev/null +++ b/backend/src/services/config/config-schema.ts @@ -0,0 +1,146 @@ +import { z } from 'zod'; + +export type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue }; +export type ConfigSource = 'default' | 'environment' | 'database' | 'runtime'; +export type ConfigType = 'string' | 'number' | 'boolean' | 'object' | 'array'; + +export interface ConfigDefinition { + key: string; + description: string; + type: ConfigType; + schema: z.ZodType; + defaultValue: T; + envVar?: string; + sensitive?: boolean; +} + +export interface ResolvedConfig { + key: string; + description: string; + type: ConfigType; + defaultValue: T; + value: T; + source: ConfigSource; + version?: number; + updatedAt?: string; + sensitive?: boolean; +} + +const jsonRecord = z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.unknown()), z.record(z.unknown())])); + +export const CONFIG_DEFINITIONS = { + 'features.batchOperations': { + key: 'features.batchOperations', + description: 'Enable batch payment operations.', + type: 'boolean', + schema: z.boolean(), + defaultValue: true, + envVar: 'FEATURE_BATCH_OPERATIONS', + }, + 'features.sandboxMode': { + key: 'features.sandboxMode', + description: 'Enable sandbox-only development behavior.', + type: 'boolean', + schema: z.boolean(), + defaultValue: false, + envVar: 'SANDBOX_MODE', + }, + 'fees.platformBps': { + key: 'fees.platformBps', + description: 'Platform fee in basis points.', + type: 'number', + schema: z.number().int().min(0).max(10_000), + defaultValue: 100, + envVar: 'PLATFORM_FEE_BPS', + }, + 'rateLimits.free': { + key: 'rateLimits.free', + description: 'Default free-tier API request limit.', + type: 'number', + schema: z.number().int().positive(), + defaultValue: 100, + envVar: 'RATE_LIMIT_FREE', + }, + 'rateLimits.pro': { + key: 'rateLimits.pro', + description: 'Default pro-tier API request limit.', + type: 'number', + schema: z.number().int().positive(), + defaultValue: 300, + envVar: 'RATE_LIMIT_PRO', + }, + 'rateLimits.enterprise': { + key: 'rateLimits.enterprise', + description: 'Default enterprise-tier API request limit.', + type: 'number', + schema: z.number().int().positive(), + defaultValue: 1000, + envVar: 'RATE_LIMIT_ENTERPRISE', + }, + 'providers.stellar': { + key: 'providers.stellar', + description: 'Stellar provider settings.', + type: 'object', + schema: z.object({ + network: z.enum(['testnet', 'public']).default('testnet'), + horizonUrl: z.string().url().optional(), + }), + defaultValue: { network: 'testnet' }, + envVar: 'STELLAR_PROVIDER_CONFIG', + }, + 'providers.stripe': { + key: 'providers.stripe', + description: 'Stripe provider settings.', + type: 'object', + schema: z.object({ + enabled: z.boolean().default(false), + publishableKey: z.string().optional(), + }), + defaultValue: { enabled: false }, + envVar: 'STRIPE_PROVIDER_CONFIG', + }, + 'payments.defaultCurrency': { + key: 'payments.defaultCurrency', + description: 'Default payment currency for new payment flows.', + type: 'string', + schema: z.string().min(2).max(16), + defaultValue: 'XLM', + envVar: 'DEFAULT_PAYMENT_CURRENCY', + }, + 'notifications.webhookRetries': { + key: 'notifications.webhookRetries', + description: 'Maximum webhook delivery retry attempts.', + type: 'number', + schema: z.number().int().min(0).max(25), + defaultValue: 5, + envVar: 'WEBHOOK_RETRY_ATTEMPTS', + }, +} as const satisfies Record; + +export type ConfigKey = keyof typeof CONFIG_DEFINITIONS; + +export function parseEnvValue(definition: ConfigDefinition, raw: string): ConfigValue { + if (definition.type === 'boolean') return raw === 'true' || raw === '1'; + if (definition.type === 'number') return Number(raw); + if (definition.type === 'object' || definition.type === 'array') return JSON.parse(raw); + return raw; +} + +export function validateConfigValue(key: string, value: unknown): ConfigValue { + const definition = CONFIG_DEFINITIONS[key as ConfigKey]; + if (!definition) { + throw new Error(`Unknown configuration key: ${key}`); + } + return definition.schema.parse(value) as ConfigValue; +} + +export function configSchemaSnapshot() { + return Object.values(CONFIG_DEFINITIONS).map((definition) => ({ + key: definition.key, + description: definition.description, + type: definition.type, + defaultValue: definition.defaultValue, + envVar: definition.envVar, + sensitive: definition.sensitive ?? false, + })); +} diff --git a/backend/src/services/config/config-service.ts b/backend/src/services/config/config-service.ts new file mode 100644 index 00000000..74e6f87a --- /dev/null +++ b/backend/src/services/config/config-service.ts @@ -0,0 +1,216 @@ +import { EventEmitter } from 'node:events'; +import Redis from 'ioredis'; +import { z } from 'zod'; +import { AppError } from '../../middleware/errorHandler.js'; +import { + CONFIG_DEFINITIONS, + type ConfigKey, + type ConfigSource, + type ConfigValue, + type ResolvedConfig, + configSchemaSnapshot, + parseEnvValue, + validateConfigValue, +} from './config-schema.js'; +import { ConfigStore, PrismaConfigStore, type StoredConfiguration } from './config-store.js'; + +const CONFIG_CHANGE_CHANNEL = 'agenticpay:config:changed'; + +export interface ConfigChangeEvent { + key: string; + value: ConfigValue; + source: ConfigSource; + version?: number; + changedAt: string; +} + +export class ConfigurationService { + private readonly events = new EventEmitter(); + private readonly runtimeOverrides = new Map(); + private databaseValues = new Map(); + private publisher?: Redis; + private subscriber?: Redis; + private initialized = false; + + constructor(private readonly store: ConfigStore = new PrismaConfigStore()) {} + + async init(): Promise { + if (this.initialized) return; + await this.reload(); + + if (process.env.REDIS_URL) { + this.publisher = new Redis(process.env.REDIS_URL); + this.subscriber = new Redis(process.env.REDIS_URL); + await this.subscriber.subscribe(CONFIG_CHANGE_CHANNEL); + this.subscriber.on('message', (_channel, payload) => { + try { + const event = JSON.parse(payload) as ConfigChangeEvent; + this.reload(event.key).catch((error) => console.warn('[config] reload failed', error)); + this.events.emit('changed', event); + } catch (error) { + console.warn('[config] invalid change event', error); + } + }); + } + + this.initialized = true; + } + + async reload(key?: string): Promise { + if (key) { + const stored = await this.store.get(key); + if (stored) this.databaseValues.set(key, stored); + else this.databaseValues.delete(key); + return; + } + + const rows = await this.store.list(); + this.databaseValues = new Map(rows.map((row) => [row.key, row])); + } + + list(): ResolvedConfig[] { + return Object.keys(CONFIG_DEFINITIONS).map((key) => this.get(key as ConfigKey)); + } + + schema() { + return configSchemaSnapshot(); + } + + get(key: ConfigKey): ResolvedConfig { + const definition = CONFIG_DEFINITIONS[key]; + let value: ConfigValue = definition.defaultValue; + let source: ConfigSource = 'default'; + let version: number | undefined; + let updatedAt: string | undefined; + + if (definition.envVar && process.env[definition.envVar] !== undefined) { + value = parseEnvValue(definition, process.env[definition.envVar] as string); + source = 'environment'; + } + + const stored = this.databaseValues.get(key); + if (stored) { + value = stored.value; + source = 'database'; + version = stored.version; + updatedAt = stored.updatedAt.toISOString(); + } + + if (this.runtimeOverrides.has(key)) { + value = this.runtimeOverrides.get(key) as ConfigValue; + source = 'runtime'; + } + + const parsed = definition.schema.parse(value) as T; + return { + key, + description: definition.description, + type: definition.type, + defaultValue: definition.defaultValue as T, + value: parsed, + source, + version, + updatedAt, + sensitive: definition.sensitive ?? false, + }; + } + + async update(input: { + key: string; + value: unknown; + actor?: string; + reason?: string; + requestId?: string; + expectedVersion?: number; + }): Promise { + let value: ConfigValue; + try { + value = validateConfigValue(input.key, input.value); + } catch (error) { + const details = error instanceof z.ZodError ? error.flatten() : { message: (error as Error).message }; + throw new AppError(400, 'Invalid configuration value', 'ERR_CONFIG_INVALID_VALUE', details); + } + + const definition = CONFIG_DEFINITIONS[input.key as ConfigKey]; + const result = await this.store.upsert({ + key: input.key, + value, + description: definition.description, + actor: input.actor, + expectedVersion: input.expectedVersion, + }); + + if (result.conflict) { + throw new AppError(409, 'Configuration update conflict', 'ERR_CONFIG_CONFLICT', { + expectedVersion: input.expectedVersion, + currentVersion: result.before?.version, + }); + } + + await this.store.audit({ + key: input.key, + oldValue: result.before?.value ?? null, + newValue: value, + actor: input.actor, + reason: input.reason, + source: 'admin', + requestId: input.requestId, + }); + + await this.reload(input.key); + await this.publishChange({ + key: input.key, + value, + source: 'database', + version: result.after.version, + changedAt: new Date().toISOString(), + }); + + return this.get(input.key as ConfigKey); + } + + async setRuntimeOverride(key: string, value: unknown): Promise { + const parsed = validateConfigValue(key, value); + this.runtimeOverrides.set(key, parsed); + const resolved = this.get(key as ConfigKey); + await this.publishChange({ + key, + value: parsed, + source: 'runtime', + version: resolved.version, + changedAt: new Date().toISOString(), + }); + return resolved; + } + + async import(values: Record, actor?: string, reason?: string, requestId?: string): Promise { + let updated = 0; + for (const [key, value] of Object.entries(values)) { + await this.update({ key, value, actor, reason, requestId }); + updated += 1; + } + return updated; + } + + export(): Record { + return Object.fromEntries(this.list().map((entry) => [entry.key, entry.value])); + } + + subscribe(listener: (event: ConfigChangeEvent) => void): () => void { + this.events.on('changed', listener); + return () => this.events.off('changed', listener); + } + + async audit(limit?: number) { + return this.store.listAudit(limit); + } + + private async publishChange(event: ConfigChangeEvent): Promise { + this.events.emit('changed', event); + if (this.publisher) { + await this.publisher.publish(CONFIG_CHANGE_CHANNEL, JSON.stringify(event)); + } + } +} + +export const configurationService = new ConfigurationService(); diff --git a/backend/src/services/config/config-store.ts b/backend/src/services/config/config-store.ts new file mode 100644 index 00000000..e0d22cac --- /dev/null +++ b/backend/src/services/config/config-store.ts @@ -0,0 +1,157 @@ +import { prisma } from '../../lib/prisma.js'; +import type { ConfigValue } from './config-schema.js'; + +export interface StoredConfiguration { + key: string; + value: ConfigValue; + source: string; + description?: string | null; + updatedBy?: string | null; + version: number; + updatedAt: Date; +} + +export interface ConfigAuditEntry { + id: string; + key: string; + oldValue: ConfigValue | null; + newValue: ConfigValue | null; + actor: string | null; + reason: string | null; + source: string; + requestId: string | null; + createdAt: Date; +} + +export interface ConfigStore { + list(): Promise; + get(key: string): Promise; + upsert(input: { + key: string; + value: ConfigValue; + description?: string; + actor?: string; + expectedVersion?: number; + }): Promise<{ before: StoredConfiguration | null; after: StoredConfiguration; conflict: boolean }>; + audit(input: { + key: string; + oldValue: ConfigValue | null; + newValue: ConfigValue | null; + actor?: string; + reason?: string; + source?: string; + requestId?: string; + }): Promise; + listAudit(limit?: number): Promise; +} + +function mapStored(row: any): StoredConfiguration { + return { + key: row.key, + value: row.value, + source: row.source, + description: row.description, + updatedBy: row.updatedBy, + version: row.version, + updatedAt: row.updatedAt, + }; +} + +export class PrismaConfigStore implements ConfigStore { + async list(): Promise { + const rows = await (prisma as any).appConfiguration.findMany({ orderBy: { key: 'asc' } }); + return rows.map(mapStored); + } + + async get(key: string): Promise { + const row = await (prisma as any).appConfiguration.findUnique({ where: { key } }); + return row ? mapStored(row) : null; + } + + async upsert(input: { + key: string; + value: ConfigValue; + description?: string; + actor?: string; + expectedVersion?: number; + }): Promise<{ before: StoredConfiguration | null; after: StoredConfiguration; conflict: boolean }> { + const before = await this.get(input.key); + if (before && input.expectedVersion !== undefined && before.version !== input.expectedVersion) { + return { before, after: before, conflict: true }; + } + + if (!before) { + const row = await (prisma as any).appConfiguration.create({ + data: { + key: input.key, + value: input.value, + description: input.description, + updatedBy: input.actor, + source: 'database', + }, + }); + return { before, after: mapStored(row), conflict: false }; + } + + const result = await (prisma as any).appConfiguration.updateMany({ + where: { + key: input.key, + ...(input.expectedVersion !== undefined ? { version: input.expectedVersion } : {}), + }, + data: { + value: input.value, + description: input.description, + updatedBy: input.actor, + version: { increment: 1 }, + }, + }); + + if (result.count === 0) { + const latest = await this.get(input.key); + return { before: latest, after: latest ?? before, conflict: true }; + } + + const row = await (prisma as any).appConfiguration.findUniqueOrThrow({ where: { key: input.key } }); + return { before, after: mapStored(row), conflict: false }; + } + + async audit(input: { + key: string; + oldValue: ConfigValue | null; + newValue: ConfigValue | null; + actor?: string; + reason?: string; + source?: string; + requestId?: string; + }): Promise { + await (prisma as any).configAuditLog.create({ + data: { + key: input.key, + oldValue: input.oldValue as any, + newValue: input.newValue as any, + actor: input.actor, + reason: input.reason, + source: input.source ?? 'admin', + requestId: input.requestId, + }, + }); + } + + async listAudit(limit = 100): Promise { + const rows = await (prisma as any).configAuditLog.findMany({ + orderBy: { createdAt: 'desc' }, + take: limit, + }); + return rows.map((row: any) => ({ + id: row.id, + key: row.key, + oldValue: row.oldValue, + newValue: row.newValue, + actor: row.actor, + reason: row.reason, + source: row.source, + requestId: row.requestId, + createdAt: row.createdAt, + })); + } +} diff --git a/backend/src/services/config/index.ts b/backend/src/services/config/index.ts new file mode 100644 index 00000000..f42864cd --- /dev/null +++ b/backend/src/services/config/index.ts @@ -0,0 +1,3 @@ +export * from './config-schema.js'; +export * from './config-store.js'; +export * from './config-service.js'; diff --git a/backend/src/test-utils/openapi.ts b/backend/src/test-utils/openapi.ts new file mode 100644 index 00000000..16738969 --- /dev/null +++ b/backend/src/test-utils/openapi.ts @@ -0,0 +1 @@ +export { validateResponseAgainstOpenAPI } from '../middleware/openapi-validator.js'; diff --git a/docs/api/errors.md b/docs/api/errors.md new file mode 100644 index 00000000..2075bb1f --- /dev/null +++ b/docs/api/errors.md @@ -0,0 +1,29 @@ +# AgenticPay Error Codes + +All API errors use: + +```json +{ + "error": { + "code": "ERR_VALIDATION_FAILED", + "message": "Request validation failed", + "details": {}, + "requestId": "req_..." + } +} +``` + +The live registry is available at `GET /api/errors`. + +| Code | Category | HTTP | Resolution | +| --- | --- | ---: | --- | +| `ERR_AUTH_UNAUTHENTICATED` | auth | 401 | Send a valid bearer token or API key and retry. | +| `ERR_AUTH_FORBIDDEN` | auth | 403 | Check account permissions, tenant access, and API key scopes. | +| `ERR_VALIDATION_FAILED` | validation | 400 | Inspect `details` and send values matching the OpenAPI schema. | +| `ERR_RESOURCE_NOT_FOUND` | validation | 404 | Verify the URL, API version, path parameters, and resource identifier. | +| `ERR_CONFIG_INVALID_VALUE` | configuration | 400 | Check the configuration schema before updating the value. | +| `ERR_CONFIG_CONFLICT` | configuration | 409 | Reload the latest configuration and retry with the current version. | +| `ERR_PAYMENT_INSUFFICIENT_FUNDS` | payment | 402 | Add funds or choose a smaller amount. | +| `ERR_BLOCKCHAIN_TRANSACTION_FAILED` | blockchain | 502 | Review provider details, network health, and retry if appropriate. | +| `ERR_RATE_LIMIT_EXCEEDED` | rate_limit | 429 | Back off until the reset time or request a higher tier. | +| `ERR_INTERNAL` | internal | 500 | Retry later and contact support with the request ID if it persists. | diff --git a/frontend/app/admin/configuration/page.tsx b/frontend/app/admin/configuration/page.tsx new file mode 100644 index 00000000..67f8601f --- /dev/null +++ b/frontend/app/admin/configuration/page.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Download, History, RefreshCw, Save, Upload } from 'lucide-react'; +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'; + +type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue }; + +interface ResolvedConfig { + key: string; + description: string; + type: 'string' | 'number' | 'boolean' | 'object' | 'array'; + defaultValue: ConfigValue; + value: ConfigValue; + source: 'default' | 'environment' | 'database' | 'runtime'; + version?: number; + updatedAt?: string; +} + +interface AuditEntry { + id: string; + key: string; + oldValue: ConfigValue | null; + newValue: ConfigValue | null; + actor: string | null; + reason: string | null; + source: string; + requestId: string | null; + createdAt: string; +} + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''; + +function formatValue(value: ConfigValue): string { + return typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value); +} + +function parseValue(raw: string, type: ResolvedConfig['type']): ConfigValue { + if (type === 'boolean') return raw === 'true'; + if (type === 'number') return Number(raw); + if (type === 'object' || type === 'array') return JSON.parse(raw); + return raw; +} + +export default function ConfigurationAdminPage() { + const [configs, setConfigs] = useState([]); + const [audit, setAudit] = useState([]); + const [drafts, setDrafts] = useState>({}); + const [reason, setReason] = useState('Admin configuration update'); + const [importJson, setImportJson] = useState('{}'); + const [status, setStatus] = useState(''); + + const bySource = useMemo(() => { + return configs.reduce>((acc, item) => { + acc[item.source] = (acc[item.source] ?? 0) + 1; + return acc; + }, {}); + }, [configs]); + + async function load() { + setStatus('Loading configuration...'); + const [configResponse, auditResponse] = await Promise.all([ + fetch(`${API_BASE}/api/v1/admin/configuration`, { cache: 'no-store' }), + fetch(`${API_BASE}/api/v1/admin/configuration/audit`, { cache: 'no-store' }), + ]); + const configPayload = await configResponse.json(); + const auditPayload = await auditResponse.json(); + setConfigs(configPayload.data ?? []); + setDrafts( + Object.fromEntries((configPayload.data ?? []).map((item: ResolvedConfig) => [item.key, formatValue(item.value)])) + ); + setAudit(auditPayload.data ?? []); + setStatus(''); + } + + async function save(item: ResolvedConfig) { + setStatus(`Saving ${item.key}...`); + const response = await fetch(`${API_BASE}/api/v1/admin/configuration/${encodeURIComponent(item.key)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + value: parseValue(drafts[item.key], item.type), + reason, + expectedVersion: item.version, + }), + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + setStatus(payload.error?.message ?? 'Save failed'); + return; + } + await load(); + } + + async function exportConfig() { + const response = await fetch(`${API_BASE}/api/v1/admin/configuration/export`, { cache: 'no-store' }); + const payload = await response.json(); + setImportJson(JSON.stringify(payload.values ?? {}, null, 2)); + setStatus('Export copied into the import editor.'); + } + + async function importConfig() { + setStatus('Importing configuration...'); + const response = await fetch(`${API_BASE}/api/v1/admin/configuration/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ values: JSON.parse(importJson), reason }), + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + setStatus(payload.error?.message ?? 'Import failed'); + return; + } + await load(); + } + + useEffect(() => { + load().catch((error) => setStatus(error instanceof Error ? error.message : 'Failed to load configuration')); + }, []); + + return ( +
+
+
+
+

Configuration

+

Centralized runtime configuration with audited updates.

+
+
+ {Object.entries(bySource).map(([source, count]) => ( + + {source}: {count} + + ))} + +
+
+ + + + Values + Import/Export + Audit + + + +
+ setReason(event.target.value)} aria-label="Change reason" /> +
+
+ + + + + + + + + + + {configs.map((item) => ( + + +
KeyValueSourceVersion +
+
{item.key}
+
{item.description}
+
+ {item.type === 'object' || item.type === 'array' ? ( +