diff --git a/src/server.ts b/src/server.ts index 5499ae0..1e06a24 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,6 +19,11 @@ * (default: https://appwrite.bsidesolutions.net/v1) * PORT Port to listen on (default: 3012) * LOG_LEVEL quiet | normal | verbose (default: normal) + * NODE_ENV production | staging (default: production). + * When set to anything other than "production", the + * /routes diagnostic endpoint is exposed. In production + * builds, /routes returns 404 to avoid leaking route + * metadata. */ import express, { Request, Response } from 'express'; @@ -120,6 +125,12 @@ const PORT = parseInt(process.env.PORT ?? '3012', 10); const APPWRITE_BASE_URL = (process.env.APPWRITE_BASE_URL ?? 'https://appwrite.bsidesolutions.net/v1').replace(/\/$/, ''); const LOG_LEVEL = (process.env.LOG_LEVEL ?? 'normal').toLowerCase(); +// Whether /routes diagnostic endpoint is exposed. +// Set NODE_ENV=staging in the staging service env vars. Production services +// leave NODE_ENV unset (or set it to "production") and the endpoint is hidden. +const NODE_ENV = (process.env.NODE_ENV ?? 'production').toLowerCase(); +const DIAGNOSTICS_ENABLED = NODE_ENV !== 'production'; + function log(level: 'info' | 'warn' | 'error', message: string): void { if (LOG_LEVEL === 'quiet' && level !== 'error') return; const prefix = level === 'error' ? '[error]' : level === 'warn' ? '[warn]' : '[info]'; @@ -168,6 +179,7 @@ if (process.env.ROUTE_MAP) { } log('info', `LOG_LEVEL: ${LOG_LEVEL}`); +log('info', `NODE_ENV: ${NODE_ENV} (diagnostics endpoint ${DIAGNOSTICS_ENABLED ? 'ENABLED' : 'DISABLED'})`); log('info', `Listening on port ${PORT}`); log('info', `Appwrite base URL: ${APPWRITE_BASE_URL}`); log('info', `Loaded ${Object.keys(PROJECT_CREDENTIALS).length} project credential slot(s):`); @@ -207,19 +219,29 @@ app.get('/health', (_req: Request, res: Response) => { } res.json({ status: 'ok', + node_env: NODE_ENV, + diagnostics_enabled: DIAGNOSTICS_ENABLED, projects: Object.keys(PROJECT_CREDENTIALS), routes: Object.keys(ROUTE_MAP), project_credentials: credsForDisplay, - route_details: ROUTE_MAP, + route_details: DIAGNOSTICS_ENABLED ? ROUTE_MAP : '[hidden - production mode]', timestamp: new Date().toISOString(), }); }); -// ----- Diagnostic: list routes (POST + GET for easy debugging) --------------- +// ----- Diagnostic: list routes (STAGING ONLY) -------------------------------- +// Exposed only when NODE_ENV !== 'production'. Production deployments get a +// 404 so route metadata and credential slot names aren't leaked. app.get('/routes', (_req: Request, res: Response) => { + if (!DIAGNOSTICS_ENABLED) { + log('warn', `/routes requested in production - returning 404`); + res.status(404).json({ error: 'Not found' }); + return; + } res.json({ status: 'ok', + node_env: NODE_ENV, routes: Object.entries(ROUTE_MAP).map(([path, entry]) => ({ path, apiKeySlot: entry.apiKeySlot, @@ -245,7 +267,7 @@ app.post('*', async (req: Request, res: Response) => { log('info', `[${incomingAt}] POST ${pathRoute} project=${projectParam ?? '(none)'} function=${functionParam ?? '(none)'} apiKey=${apiKeyParam ?? '(none)'}`); // Resolve project/function/apiKey. Explicit querystring params take priority, - // then short-path route map (e.g. POST /square). + // then short-path route map (e.g. POST /crown-x-ms-monet__verify_square_webhook). let resolvedProject: string | null = projectParam; let resolvedFunction: string | null = functionParam; let resolvedSlot: string | null = apiKeyParam; @@ -333,7 +355,7 @@ app.use((req: Request, res: Response) => { error: 'Not found', method: req.method, path: req.path, - hint: 'Visit /health or /routes for diagnostic info.', + hint: 'Visit /health for diagnostic info.' + (DIAGNOSTICS_ENABLED ? ' In staging, /routes also lists registered routes.' : ''), }); });