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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
Expand Down
39 changes: 39 additions & 0 deletions src/middlewares/request-id.middleware.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
54 changes: 54 additions & 0 deletions src/modules/config/config.controllers.ts
Original file line number Diff line number Diff line change
@@ -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,
});
};
14 changes: 14 additions & 0 deletions src/modules/config/config.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
@@ -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;
125 changes: 125 additions & 0 deletions src/utils/api-response.utils.ts
Original file line number Diff line number Diff line change
@@ -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<T = unknown> {
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<T>(
res: Response,
data: T,
statusCode = 200,
message?: string
): void {
const body: ApiSuccessResponse<T> = {
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);
}
46 changes: 46 additions & 0 deletions src/utils/slug.utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading