diff --git a/src/app.ts b/src/app.ts index edbf3e5..24915bc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,11 +9,13 @@ import morgan from 'morgan'; import tspecOptions from './tspec.config'; import { SendMail } from './utils/mail.utils'; import { appRateLimit } from './middlewares/rate.middleware'; +import { requestIdMiddleware } from './middlewares/request-id.middleware'; const app: Express = express(); // Middleware setup app.set('trust proxy', 1); +app.use(requestIdMiddleware); app.use(corsMiddleware()); app.use(helmet()); app.use(express.json({ limit: '10mb' })); diff --git a/src/middlewares/request-id.middleware.ts b/src/middlewares/request-id.middleware.ts new file mode 100644 index 0000000..4761880 --- /dev/null +++ b/src/middlewares/request-id.middleware.ts @@ -0,0 +1,39 @@ +// src/middlewares/request-id.middleware.ts +import { Request, Response, NextFunction } from 'express'; +import crypto from 'crypto'; + +/** + * Middleware that assigns a unique request ID to every incoming request. + * + * If the client sends an `X-Request-ID` header the value is forwarded + * (useful for distributed tracing). Otherwise a new UUID v4 is generated. + * + * The ID is: + * - Stored on `req.requestId` for use in controllers and logging + * - Returned in the `X-Request-ID` response header for client correlation + */ +export const requestIdMiddleware = ( + req: Request, + res: Response, + next: NextFunction +): void => { + const requestId = + (req.headers['x-request-id'] as string) || crypto.randomUUID(); + + // Attach to the request object for downstream use + (req as any).requestId = requestId; + + // Echo in the response header so clients can correlate + res.setHeader('X-Request-ID', requestId); + + next(); +}; + +// Augment Express Request type to include requestId +declare global { + namespace Express { + interface Request { + requestId?: string; + } + } +} diff --git a/src/modules/config/config.controllers.ts b/src/modules/config/config.controllers.ts new file mode 100644 index 0000000..03aee0f --- /dev/null +++ b/src/modules/config/config.controllers.ts @@ -0,0 +1,54 @@ +// src/modules/config/config.controllers.ts +import { Request, Response } from 'express'; +import { envConfig } from '../../config'; + +/** + * Public protocol configuration response shape. + * + * This is a lightweight bootstrap payload that clients can fetch once + * on startup to configure themselves without hardcoding values. + */ +interface ProtocolConfig { + /** Current deployment environment */ + environment: string; + /** API version prefix */ + apiVersion: string; + /** Stellar network the server targets */ + network: string; + /** Feature flags for conditional client behaviour */ + features: { + walletConnect: boolean; + emailVerification: boolean; + googleOAuth: boolean; + }; + /** Display-related settings */ + display: { + appName: string; + supportEmail: string; + }; +} + +export const httpGetProtocolConfig = ( + _req: Request, + res: Response +): void => { + const config: ProtocolConfig = { + environment: envConfig.MODE, + apiVersion: 'v1', + network: envConfig.MODE === 'production' ? 'mainnet' : 'testnet', + features: { + walletConnect: true, + emailVerification: true, + googleOAuth: true, + }, + display: { + appName: 'AccessLayer', + supportEmail: 'support@accesslayer.org', + }, + }; + + res.status(200).json({ + success: true, + data: config, + }); +}; diff --git a/src/modules/config/config.routes.ts b/src/modules/config/config.routes.ts new file mode 100644 index 0000000..521df3a --- /dev/null +++ b/src/modules/config/config.routes.ts @@ -0,0 +1,14 @@ +// src/modules/config/config.routes.ts +import { Router } from 'express'; +import { httpGetProtocolConfig } from './config.controllers'; + +const configRouter = Router(); + +/** + * GET /api/v1/config + * Public endpoint returning protocol bootstrap configuration. + * Safe for unauthenticated use - no sensitive data exposed. + */ +configRouter.get('/', httpGetProtocolConfig); + +export default configRouter; diff --git a/src/modules/index.ts b/src/modules/index.ts index 3e85adc..ff59f82 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,10 +1,12 @@ import { Router } from 'express'; import authRouter from './auth/auth.routes'; import healthRouter from './health/health.routes'; +import configRouter from './config/config.routes'; const router = Router(); router.use('/health', healthRouter); router.use('/auth', authRouter); +router.use('/config', configRouter); export default router; diff --git a/src/utils/api-response.utils.ts b/src/utils/api-response.utils.ts new file mode 100644 index 0000000..7db7cc0 --- /dev/null +++ b/src/utils/api-response.utils.ts @@ -0,0 +1,125 @@ +// src/utils/api-response.utils.ts +// Shared API response formatters for consistent client-facing responses. + +import { Response } from 'express'; + +/** + * Standard API error response shape. + * + * Every error returned by the API follows this structure so frontend + * clients can parse errors predictably. + * + * @example + * { + * success: false, + * error: { + * code: "VALIDATION_ERROR", + * message: "Email is required", + * details: [{ field: "email", message: "Required" }] + * } + * } + */ +interface ApiErrorResponse { + success: false; + error: { + code: string; + message: string; + details?: Array<{ field?: string; message: string }>; + }; +} + +/** + * Standard API success response shape. + */ +interface ApiSuccessResponse { + success: true; + data: T; + message?: string; +} + +// ── Error codes ────────────────────────────────────────────── + +export const ErrorCode = { + VALIDATION_ERROR: 'VALIDATION_ERROR', + NOT_FOUND: 'NOT_FOUND', + UNAUTHORIZED: 'UNAUTHORIZED', + FORBIDDEN: 'FORBIDDEN', + CONFLICT: 'CONFLICT', + BAD_REQUEST: 'BAD_REQUEST', + INTERNAL_ERROR: 'INTERNAL_ERROR', + RATE_LIMIT: 'RATE_LIMIT', +} as const; + +export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode]; + +// ── Formatters ─────────────────────────────────────────────── + +/** + * Send a formatted error response. + */ +export function sendError( + res: Response, + statusCode: number, + code: ErrorCodeType, + message: string, + details?: Array<{ field?: string; message: string }> +): void { + const body: ApiErrorResponse = { + success: false, + error: { + code, + message, + ...(details && details.length > 0 ? { details } : {}), + }, + }; + res.status(statusCode).json(body); +} + +/** + * Send a formatted success response. + */ +export function sendSuccess( + res: Response, + data: T, + statusCode = 200, + message?: string +): void { + const body: ApiSuccessResponse = { + success: true, + data, + ...(message ? { message } : {}), + }; + res.status(statusCode).json(body); +} + +// ── Convenience helpers ────────────────────────────────────── + +export function sendValidationError( + res: Response, + message: string, + details?: Array<{ field?: string; message: string }> +): void { + sendError(res, 400, ErrorCode.VALIDATION_ERROR, message, details); +} + +export function sendNotFound(res: Response, resource: string): void { + sendError(res, 404, ErrorCode.NOT_FOUND, `${resource} not found`); +} + +export function sendUnauthorized( + res: Response, + message = 'Unauthorized access' +): void { + sendError(res, 401, ErrorCode.UNAUTHORIZED, message); +} + +export function sendForbidden( + res: Response, + message = 'Access forbidden' +): void { + sendError(res, 403, ErrorCode.FORBIDDEN, message); +} + +export function sendConflict(res: Response, message: string): void { + sendError(res, 409, ErrorCode.CONFLICT, message); +} diff --git a/src/utils/slug.utils.ts b/src/utils/slug.utils.ts new file mode 100644 index 0000000..560c41d --- /dev/null +++ b/src/utils/slug.utils.ts @@ -0,0 +1,46 @@ +// src/utils/slug.utils.ts +// Reusable helper for generating stable creator slugs from names or handles. + +/** + * Generates a URL-safe slug from a name or handle. + * + * - Converts to lowercase + * - Replaces spaces and special characters with hyphens + * - Collapses consecutive hyphens + * - Trims leading/trailing hyphens + * - Strips non-alphanumeric characters (except hyphens) + * + * Repeated calls with the same input always return the same slug. + * + * @example + * generateSlug("John Doe") // "john-doe" + * generateSlug(" Lil Nas X ") // "lil-nas-x" + * generateSlug("café & créme") // "caf-crme" + * generateSlug("Hello---World") // "hello-world" + */ +export function generateSlug(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[\s_]+/g, '-') // spaces and underscores to hyphens + .replace(/[^a-z0-9-]/g, '') // strip non-alphanumeric (except hyphens) + .replace(/-{2,}/g, '-') // collapse multiple hyphens + .replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens +} + +/** + * Generates a slug with an optional numeric suffix for uniqueness. + * + * Useful when the caller has already checked for collisions and needs + * to append a disambiguator. + * + * @example + * generateSlugWithSuffix("John Doe", 2) // "john-doe-2" + */ +export function generateSlugWithSuffix( + input: string, + suffix: number +): string { + const base = generateSlug(input); + return suffix > 0 ? `${base}-${suffix}` : base; +}