/** * bside-webhook-proxy * * Multi-project webhook proxy for incoming webhooks (Square, Stripe, GitHub, etc.). * Receives webhooks that can't send custom auth headers and forwards them to the * appropriate Appwrite function with X-Appwrite-Project + X-Appwrite-Key headers. * * Routing is resolved via querystring params OR a short-path route map loaded * from the ROUTE_MAP environment variable. * * Required env vars: * PROJECT_CREDENTIALS JSON object mapping slot name -> { project, key } * e.g. {"square":{"project":"abc","key":"def"}} * * Optional env vars: * ROUTE_MAP String mapping short paths to { apiKeySlot, functionId } * e.g. /square=>{"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) */ const express = require('express'); const app = express(); // Capture raw request body for all content types so we can forward it as-is. // This is important for services like Square that use signature validation // based on the raw payload bytes. app.use(express.text({ type: '*/*', limit: '1mb' })); // ----- Config --------------------------------------------------------------- const PORT = parseInt(process.env.PORT || '3012', 10); const APPWRITE_BASE_URL = (process.env.APPWRITE_BASE_URL || 'https://appwrite.bsidesolutions.net/v1').replace(/\/$/, ''); let PROJECT_CREDENTIALS = {}; try { PROJECT_CREDENTIALS = JSON.parse(process.env.PROJECT_CREDENTIALS || '{}'); } catch (err) { console.error('[startup] PROJECT_CREDENTIALS is not valid JSON:', err.message); process.exit(1); } // Parse ROUTE_MAP. Format: "/path=>{json};/path2=>{json2}" const ROUTE_MAP = {}; if (process.env.ROUTE_MAP) { for (const entry of process.env.ROUTE_MAP.split(';')) { const trimmed = entry.trim(); if (!trimmed) continue; const arrowIdx = trimmed.indexOf('=>'); if (arrowIdx === -1) continue; const path = trimmed.slice(0, arrowIdx).trim(); const cfgStr = trimmed.slice(arrowIdx + 2).trim(); try { ROUTE_MAP[path] = JSON.parse(cfgStr); } catch (err) { console.error(`[startup] Invalid ROUTE_MAP entry for "${path}":`, err.message); } } } 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)`); // ----- Health check --------------------------------------------------------- app.get('/health', (_req, res) => { res.json({ status: 'ok', projects: Object.keys(PROJECT_CREDENTIALS), routes: Object.keys(ROUTE_MAP), timestamp: new Date().toISOString(), }); }); // ----- Webhook receiver ----------------------------------------------------- app.post('*', async (req, res) => { try { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const projectParam = url.searchParams.get('project'); const functionParam = url.searchParams.get('function'); const apiKeyParam = url.searchParams.get('apiKey'); const pathRoute = url.pathname; // Resolve project/function/apiKey. Explicit querystring params take priority, // then short-path route map (e.g. POST /square). let resolvedProject = projectParam; let resolvedFunction = functionParam; let resolvedSlot = apiKeyParam; if (!resolvedProject && ROUTE_MAP[pathRoute]) { const route = ROUTE_MAP[pathRoute]; resolvedSlot = route.apiKeySlot; resolvedFunction = route.functionId; resolvedProject = PROJECT_CREDENTIALS[resolvedSlot]?.project; } if (!resolvedProject || !resolvedFunction || !resolvedSlot) { return res.status(400).json({ error: 'Missing required params', required: ['project', 'function', 'apiKey'], or: 'POST to a configured short-path route (e.g. /square)', available_routes: Object.keys(ROUTE_MAP), }); } const credentials = PROJECT_CREDENTIALS[resolvedSlot]; if (!credentials || credentials.project !== resolvedProject) { return res.status(403).json({ error: 'Unknown project or apiKey slot' }); } const rawBody = typeof req.body === 'string' ? req.body : JSON.stringify(req.body || {}); const contentType = req.headers['content-type'] || 'application/json'; const appwriteUrl = `${APPWRITE_BASE_URL}/functions/${resolvedFunction}/executions`; console.log(`[webhook] slot=${resolvedSlot} project=${resolvedProject} function=${resolvedFunction} -> ${appwriteUrl}`); const appwriteResp = await fetch(appwriteUrl, { method: 'POST', headers: { 'X-Appwrite-Project': credentials.project, 'X-Appwrite-Key': credentials.key, 'Content-Type': 'application/json', }, body: JSON.stringify({ body: rawBody, async: false, method: 'POST', path: '/webhook', headers: { 'content-type': contentType }, }), }); const responseText = await appwriteResp.text(); res.status(appwriteResp.status).type('application/json').send(responseText); } catch (err) { console.error('[webhook] Error forwarding webhook:', err); res.status(500).json({ error: 'Internal proxy error', detail: err.message }); } }); // ----- 404 ------------------------------------------------------------------ app.use((_req, res) => { res.status(404).json({ error: 'Not found. POST webhooks to this proxy with the right params.' }); }); // ----- Start ---------------------------------------------------------------- app.listen(PORT, () => { // The startup banner already printed above. });