diff --git a/src/server.ts b/src/server.ts index b9c9330..5499ae0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,10 +14,11 @@ * * Optional env vars: * ROUTE_MAP String mapping short paths to { apiKeySlot, functionId } - * e.g. /square=>{"apiKeySlot":"square","functionId":"verify_square_webhook"} + * e.g. /crown-x-ms-monet__verify_square_webhook=>{"apiKeySlot":"square","functionId":"verify_square_webhook"} * APPWRITE_BASE_URL Base URL for the Appwrite API * (default: https://appwrite.bsidesolutions.net/v1) * PORT Port to listen on (default: 3012) + * LOG_LEVEL quiet | normal | verbose (default: normal) */ import express, { Request, Response } from 'express'; @@ -117,6 +118,17 @@ function stripWrappingQuotes(s: string): string { 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(); + +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]'; + if (level === 'error') { + console.error(`${prefix} ${message}`); + } else { + console.log(`${prefix} ${message}`); + } +} let PROJECT_CREDENTIALS: CredentialsMap = {}; try { @@ -128,16 +140,13 @@ try { } catch (err) { const message = err instanceof Error ? err.message : String(err); const raw = (process.env.PROJECT_CREDENTIALS ?? '').slice(0, 300); - console.error('[startup] PROJECT_CREDENTIALS is not valid JSON:', message); - console.error('[startup] Received value (first 300 chars):', raw); - console.error('[startup] Tried stripping wrapping quotes and unescaping \\"->". If you still see this, paste the JSON without any escaping.'); + log('error', `PROJECT_CREDENTIALS is not valid JSON: ${message}`); + log('error', `Received value (first 300 chars): ${raw}`); + log('error', `Tried stripping wrapping quotes and unescaping \\"->". If you still see this, paste the JSON without any escaping.`); process.exit(1); } // Parse ROUTE_MAP. Format: "/path=>{json};/path2=>{json2}" -// The inner {json} fragment can have the same escaping problems as top-level -// env vars, so we normalize the full route map as a raw string first, then -// also try safeJsonParse on each per-route fragment as a defensive fallback. const ROUTE_MAP: RouteMap = {}; if (process.env.ROUTE_MAP) { const normalizedRouteMap = normalizeEnvValue(process.env.ROUTE_MAP); @@ -149,23 +158,33 @@ if (process.env.ROUTE_MAP) { const path = trimmed.slice(0, arrowIdx).trim(); const cfgStr = trimmed.slice(arrowIdx + 2).trim(); - // Try direct parse first, then fall back to normalized parse (handles - // cases where the outer wrapper was stripped but inner quotes are still - // escaped). const parsed = safeJsonParse(cfgStr); if (parsed) { ROUTE_MAP[path] = parsed; } else { - const message = `expected valid JSON, got "${cfgStr.slice(0, 80)}"`; - console.error(`[startup] Invalid ROUTE_MAP entry for "${path}":`, message); + log('error', `Invalid ROUTE_MAP entry for "${path}": expected valid JSON, got "${cfgStr.slice(0, 80)}"`); } } } -console.log(`[startup] Listening on port ${PORT}`); -console.log(`[startup] Appwrite base URL: ${APPWRITE_BASE_URL}`); -console.log(`[startup] Loaded ${Object.keys(PROJECT_CREDENTIALS).length} project credential slot(s)`); -console.log(`[startup] Loaded ${Object.keys(ROUTE_MAP).length} short-path route(s)`); +log('info', `LOG_LEVEL: ${LOG_LEVEL}`); +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):`); +for (const [slot, creds] of Object.entries(PROJECT_CREDENTIALS)) { + log('info', ` - slot="${slot}" project="${creds.project}" apiKey="${creds.apiKey.slice(0, 8)}...${creds.apiKey.slice(-4)}" (length=${creds.apiKey.length})`); +} +log('info', `Loaded ${Object.keys(ROUTE_MAP).length} short-path route(s):`); +for (const [path, entry] of Object.entries(ROUTE_MAP)) { + log('info', ` - path="${path}" -> apiKeySlot="${entry.apiKeySlot}" functionId="${entry.functionId}"`); +} +if (LOG_LEVEL === 'verbose') { + log('info', `Full PROJECT_CREDENTIALS (verbose mode):`); + for (const [slot, creds] of Object.entries(PROJECT_CREDENTIALS)) { + log('info', ` ${slot}: project=${creds.project} apiKey=${creds.apiKey}`); + } + log('info', `Full ROUTE_MAP (verbose mode): ${JSON.stringify(ROUTE_MAP)}`); +} // ----- App ------------------------------------------------------------------ @@ -179,10 +198,35 @@ app.use(express.text({ type: '*/*', limit: '1mb' })); // ----- Health check --------------------------------------------------------- app.get('/health', (_req: Request, res: Response) => { + const credsForDisplay: Record = {}; + for (const [slot, creds] of Object.entries(PROJECT_CREDENTIALS)) { + credsForDisplay[slot] = { + project: creds.project, + apiKeyLength: creds.apiKey.length, + }; + } res.json({ status: 'ok', projects: Object.keys(PROJECT_CREDENTIALS), routes: Object.keys(ROUTE_MAP), + project_credentials: credsForDisplay, + route_details: ROUTE_MAP, + timestamp: new Date().toISOString(), + }); +}); + +// ----- Diagnostic: list routes (POST + GET for easy debugging) --------------- + +app.get('/routes', (_req: Request, res: Response) => { + res.json({ + status: 'ok', + routes: Object.entries(ROUTE_MAP).map(([path, entry]) => ({ + path, + apiKeySlot: entry.apiKeySlot, + functionId: entry.functionId, + hasCredentials: !!PROJECT_CREDENTIALS[entry.apiKeySlot], + credentialsProject: PROJECT_CREDENTIALS[entry.apiKeySlot]?.project, + })), timestamp: new Date().toISOString(), }); }); @@ -190,6 +234,7 @@ app.get('/health', (_req: Request, res: Response) => { // ----- Webhook receiver ----------------------------------------------------- app.post('*', async (req: Request, res: Response) => { + const incomingAt = new Date().toISOString(); try { const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`); const projectParam = url.searchParams.get('project'); @@ -197,6 +242,8 @@ app.post('*', async (req: Request, res: Response) => { const apiKeyParam = url.searchParams.get('apiKey'); const pathRoute = url.pathname; + 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). let resolvedProject: string | null = projectParam; @@ -208,20 +255,25 @@ app.post('*', async (req: Request, res: Response) => { resolvedSlot = route.apiKeySlot; resolvedFunction = route.functionId; resolvedProject = PROJECT_CREDENTIALS[resolvedSlot]?.project ?? null; + log('info', `[${incomingAt}] Routed via path "${pathRoute}" -> slot=${resolvedSlot} function=${resolvedFunction}`); } if (!resolvedProject || !resolvedFunction || !resolvedSlot) { + log('warn', `[${incomingAt}] Missing required params. Received: path=${pathRoute} project=${projectParam} function=${functionParam} apiKey=${apiKeyParam}. Available routes: ${JSON.stringify(Object.keys(ROUTE_MAP))}`); res.status(400).json({ - error: 'Missing required params', + error: 'Missing required params: project, function, apiKey', required: ['project', 'function', 'apiKey'], - or: 'POST to a configured short-path route (e.g. /square)', + or: 'POST to a configured short-path route', + received_path: pathRoute, available_routes: Object.keys(ROUTE_MAP), + hint: 'If you renamed the route, update ROUTE_MAP env var to match the new path.', }); return; } const credentials = PROJECT_CREDENTIALS[resolvedSlot]; if (!credentials || credentials.project !== resolvedProject) { + log('error', `[${incomingAt}] Unknown project or apiKey slot. slot=${resolvedSlot} project=${resolvedProject} available slots: ${JSON.stringify(Object.keys(PROJECT_CREDENTIALS))}`); res.status(403).json({ error: 'Unknown project or apiKey slot' }); return; } @@ -229,10 +281,11 @@ app.post('*', async (req: Request, res: Response) => { const rawBody: string = typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {}); + const bodyPreview = rawBody.length > 200 ? rawBody.slice(0, 200) + '...' : rawBody; const contentType = (req.headers['content-type'] as string | undefined) ?? 'application/json'; const appwriteUrl = `${APPWRITE_BASE_URL}/functions/${resolvedFunction}/executions`; - console.log(`[webhook] slot=${resolvedSlot} project=${resolvedProject} function=${resolvedFunction} -> ${appwriteUrl}`); + log('info', `[${incomingAt}] Forwarding -> slot=${resolvedSlot} project=${resolvedProject} function=${resolvedFunction} bytes=${rawBody.length}`); const appwriteResp = await fetch(appwriteUrl, { method: 'POST', @@ -251,22 +304,41 @@ app.post('*', async (req: Request, res: Response) => { }); const responseText = await appwriteResp.text(); + log('info', `[${incomingAt}] Appwrite responded status=${appwriteResp.status} bytes=${responseText.length}`); + log('info', `[${incomingAt}] Body preview: ${bodyPreview.slice(0, 80)}`); res.status(appwriteResp.status).type('application/json').send(responseText); } catch (err) { const message = err instanceof Error ? err.message : String(err); - console.error('[webhook] Error forwarding webhook:', err); + log('error', `[${incomingAt}] Error forwarding webhook: ${message}`); + console.error(err); res.status(500).json({ error: 'Internal proxy error', detail: message }); } }); // ----- 404 ------------------------------------------------------------------ -app.use((_req: Request, res: Response) => { - res.status(404).json({ error: 'Not found. POST webhooks to this proxy with the right params.' }); +app.use((req: Request, res: Response) => { + log('warn', `${req.method} ${req.path} -> 404`); + if (req.method === 'POST') { + res.status(404).json({ + error: 'Not found. POST webhooks to this proxy with the right params.', + method: req.method, + path: req.path, + available_routes: Object.keys(ROUTE_MAP), + hint: 'Either match one of the routes, or pass ?project=&function=&apiKey= as querystring params.', + }); + return; + } + res.status(404).json({ + error: 'Not found', + method: req.method, + path: req.path, + hint: 'Visit /health or /routes for diagnostic info.', + }); }); // ----- Start ---------------------------------------------------------------- app.listen(PORT, () => { - // The startup banner already printed above. -}); \ No newline at end of file + log('info', 'Server is up and accepting connections'); +});