Add detailed startup logs (registered routes, credential slots, key prefix), per-request access logs with timestamps, /routes diagnostic endpoint, and improved 404 error responses that list available routes. Push to test branch so it deploys to the staging proxy via Drone.
Some checks failed
continuous-integration/drone Build is failing

This commit is contained in:
oonyeje 2026-07-08 12:13:43 +00:00
parent 51a9850019
commit a26107fefd

View File

@ -14,10 +14,11 @@
* *
* Optional env vars: * Optional env vars:
* ROUTE_MAP String mapping short paths to { apiKeySlot, functionId } * 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 * APPWRITE_BASE_URL Base URL for the Appwrite API
* (default: https://appwrite.bsidesolutions.net/v1) * (default: https://appwrite.bsidesolutions.net/v1)
* PORT Port to listen on (default: 3012) * PORT Port to listen on (default: 3012)
* LOG_LEVEL quiet | normal | verbose (default: normal)
*/ */
import express, { Request, Response } from 'express'; import express, { Request, Response } from 'express';
@ -117,6 +118,17 @@ function stripWrappingQuotes(s: string): string {
const PORT = parseInt(process.env.PORT ?? '3012', 10); const PORT = parseInt(process.env.PORT ?? '3012', 10);
const APPWRITE_BASE_URL = (process.env.APPWRITE_BASE_URL ?? 'https://appwrite.bsidesolutions.net/v1').replace(/\/$/, ''); 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 = {}; let PROJECT_CREDENTIALS: CredentialsMap = {};
try { try {
@ -128,16 +140,13 @@ try {
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
const raw = (process.env.PROJECT_CREDENTIALS ?? '').slice(0, 300); const raw = (process.env.PROJECT_CREDENTIALS ?? '').slice(0, 300);
console.error('[startup] PROJECT_CREDENTIALS is not valid JSON:', message); log('error', `PROJECT_CREDENTIALS is not valid JSON: ${message}`);
console.error('[startup] Received value (first 300 chars):', raw); log('error', `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', `Tried stripping wrapping quotes and unescaping \\"->". If you still see this, paste the JSON without any escaping.`);
process.exit(1); process.exit(1);
} }
// Parse ROUTE_MAP. Format: "/path=>{json};/path2=>{json2}" // 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 = {}; const ROUTE_MAP: RouteMap = {};
if (process.env.ROUTE_MAP) { if (process.env.ROUTE_MAP) {
const normalizedRouteMap = normalizeEnvValue(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 path = trimmed.slice(0, arrowIdx).trim();
const cfgStr = trimmed.slice(arrowIdx + 2).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<RouteEntry>(cfgStr); const parsed = safeJsonParse<RouteEntry>(cfgStr);
if (parsed) { if (parsed) {
ROUTE_MAP[path] = parsed; ROUTE_MAP[path] = parsed;
} else { } else {
const message = `expected valid JSON, got "${cfgStr.slice(0, 80)}"`; log('error', `Invalid ROUTE_MAP entry for "${path}": expected valid JSON, got "${cfgStr.slice(0, 80)}"`);
console.error(`[startup] Invalid ROUTE_MAP entry for "${path}":`, message);
} }
} }
} }
console.log(`[startup] Listening on port ${PORT}`); log('info', `LOG_LEVEL: ${LOG_LEVEL}`);
console.log(`[startup] Appwrite base URL: ${APPWRITE_BASE_URL}`); log('info', `Listening on port ${PORT}`);
console.log(`[startup] Loaded ${Object.keys(PROJECT_CREDENTIALS).length} project credential slot(s)`); log('info', `Appwrite base URL: ${APPWRITE_BASE_URL}`);
console.log(`[startup] Loaded ${Object.keys(ROUTE_MAP).length} short-path route(s)`); 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 ------------------------------------------------------------------ // ----- App ------------------------------------------------------------------
@ -179,10 +198,35 @@ app.use(express.text({ type: '*/*', limit: '1mb' }));
// ----- Health check --------------------------------------------------------- // ----- Health check ---------------------------------------------------------
app.get('/health', (_req: Request, res: Response) => { app.get('/health', (_req: Request, res: Response) => {
const credsForDisplay: Record<string, { project: string; apiKeyLength: number }> = {};
for (const [slot, creds] of Object.entries(PROJECT_CREDENTIALS)) {
credsForDisplay[slot] = {
project: creds.project,
apiKeyLength: creds.apiKey.length,
};
}
res.json({ res.json({
status: 'ok', status: 'ok',
projects: Object.keys(PROJECT_CREDENTIALS), projects: Object.keys(PROJECT_CREDENTIALS),
routes: Object.keys(ROUTE_MAP), 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(), timestamp: new Date().toISOString(),
}); });
}); });
@ -190,6 +234,7 @@ app.get('/health', (_req: Request, res: Response) => {
// ----- Webhook receiver ----------------------------------------------------- // ----- Webhook receiver -----------------------------------------------------
app.post('*', async (req: Request, res: Response) => { app.post('*', async (req: Request, res: Response) => {
const incomingAt = new Date().toISOString();
try { try {
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`); const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
const projectParam = url.searchParams.get('project'); const projectParam = url.searchParams.get('project');
@ -197,6 +242,8 @@ app.post('*', async (req: Request, res: Response) => {
const apiKeyParam = url.searchParams.get('apiKey'); const apiKeyParam = url.searchParams.get('apiKey');
const pathRoute = url.pathname; 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, // 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 /square).
let resolvedProject: string | null = projectParam; let resolvedProject: string | null = projectParam;
@ -208,20 +255,25 @@ app.post('*', async (req: Request, res: Response) => {
resolvedSlot = route.apiKeySlot; resolvedSlot = route.apiKeySlot;
resolvedFunction = route.functionId; resolvedFunction = route.functionId;
resolvedProject = PROJECT_CREDENTIALS[resolvedSlot]?.project ?? null; resolvedProject = PROJECT_CREDENTIALS[resolvedSlot]?.project ?? null;
log('info', `[${incomingAt}] Routed via path "${pathRoute}" -> slot=${resolvedSlot} function=${resolvedFunction}`);
} }
if (!resolvedProject || !resolvedFunction || !resolvedSlot) { 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({ res.status(400).json({
error: 'Missing required params', error: 'Missing required params: project, function, apiKey',
required: ['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), available_routes: Object.keys(ROUTE_MAP),
hint: 'If you renamed the route, update ROUTE_MAP env var to match the new path.',
}); });
return; return;
} }
const credentials = PROJECT_CREDENTIALS[resolvedSlot]; const credentials = PROJECT_CREDENTIALS[resolvedSlot];
if (!credentials || credentials.project !== resolvedProject) { 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' }); res.status(403).json({ error: 'Unknown project or apiKey slot' });
return; return;
} }
@ -229,10 +281,11 @@ app.post('*', async (req: Request, res: Response) => {
const rawBody: string = typeof req.body === 'string' const rawBody: string = typeof req.body === 'string'
? req.body ? req.body
: JSON.stringify(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 contentType = (req.headers['content-type'] as string | undefined) ?? 'application/json';
const appwriteUrl = `${APPWRITE_BASE_URL}/functions/${resolvedFunction}/executions`; 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, { const appwriteResp = await fetch(appwriteUrl, {
method: 'POST', method: 'POST',
@ -251,22 +304,41 @@ app.post('*', async (req: Request, res: Response) => {
}); });
const responseText = await appwriteResp.text(); 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); res.status(appwriteResp.status).type('application/json').send(responseText);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(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 }); res.status(500).json({ error: 'Internal proxy error', detail: message });
} }
}); });
// ----- 404 ------------------------------------------------------------------ // ----- 404 ------------------------------------------------------------------
app.use((_req: Request, res: Response) => { app.use((req: Request, res: Response) => {
res.status(404).json({ error: 'Not found. POST webhooks to this proxy with the right params.' }); 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 ---------------------------------------------------------------- // ----- Start ----------------------------------------------------------------
app.listen(PORT, () => { app.listen(PORT, () => {
// The startup banner already printed above. log('info', 'Server is up and accepting connections');
}); });