diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..bf05f1a --- /dev/null +++ b/src/server.ts @@ -0,0 +1,181 @@ +/** + * 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, apiKey } + * e.g. {"square":{"project":"abc","apiKey":"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) + */ + +import express, { Request, Response } from 'express'; + +// ----- Types ---------------------------------------------------------------- + +interface ProjectCredentials { + project: string; + apiKey: string; +} + +type CredentialsMap = Record; + +interface RouteEntry { + apiKeySlot: string; + functionId: string; +} + +type RouteMap = Record; + +// ----- 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: CredentialsMap = {}; +try { + PROJECT_CREDENTIALS = JSON.parse(process.env.PROJECT_CREDENTIALS ?? '{}') as CredentialsMap; +} catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('[startup] PROJECT_CREDENTIALS is not valid JSON:', message); + process.exit(1); +} + +// Parse ROUTE_MAP. Format: "/path=>{json};/path2=>{json2}" +const ROUTE_MAP: RouteMap = {}; +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) as RouteEntry; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[startup] Invalid ROUTE_MAP entry for "${path}":`, 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)`); + +// ----- App ------------------------------------------------------------------ + +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' })); + +// ----- Health check --------------------------------------------------------- + +app.get('/health', (_req: Request, res: Response) => { + res.json({ + status: 'ok', + projects: Object.keys(PROJECT_CREDENTIALS), + routes: Object.keys(ROUTE_MAP), + timestamp: new Date().toISOString(), + }); +}); + +// ----- Webhook receiver ----------------------------------------------------- + +app.post('*', async (req: Request, res: Response) => { + 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: string | null = projectParam; + let resolvedFunction: string | null = functionParam; + let resolvedSlot: string | null = apiKeyParam; + + if (!resolvedProject && ROUTE_MAP[pathRoute]) { + const route = ROUTE_MAP[pathRoute]; + resolvedSlot = route.apiKeySlot; + resolvedFunction = route.functionId; + resolvedProject = PROJECT_CREDENTIALS[resolvedSlot]?.project ?? null; + } + + if (!resolvedProject || !resolvedFunction || !resolvedSlot) { + 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), + }); + return; + } + + const credentials = PROJECT_CREDENTIALS[resolvedSlot]; + if (!credentials || credentials.project !== resolvedProject) { + res.status(403).json({ error: 'Unknown project or apiKey slot' }); + return; + } + + const rawBody: string = typeof req.body === 'string' + ? req.body + : JSON.stringify(req.body ?? {}); + 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}`); + + const appwriteResp = await fetch(appwriteUrl, { + method: 'POST', + headers: { + 'X-Appwrite-Project': credentials.project, + 'X-Appwrite-Key': credentials.apiKey, + '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) { + const message = err instanceof Error ? err.message : String(err); + console.error('[webhook] Error forwarding webhook:', 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.' }); +}); + +// ----- Start ---------------------------------------------------------------- + +app.listen(PORT, () => { + // The startup banner already printed above. +}); \ No newline at end of file