Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<domain>/` 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 -- <files...>` 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.
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
33 changes: 33 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions backend/scripts/generate-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..');
Expand Down Expand Up @@ -56,6 +57,40 @@ async function generateOpenAPISpec(config: GeneratorConfig): Promise<void> {

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 });

Expand Down
7 changes: 7 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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({
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 12 additions & 5 deletions backend/src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;

Expand Down Expand Up @@ -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
Expand All @@ -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 }
: {}),
Expand Down
92 changes: 92 additions & 0 deletions backend/src/middleware/openapi-validator.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> } | 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);
}
7 changes: 2 additions & 5 deletions backend/src/middleware/validate.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Loading