Add TypeScript source for the proxy server

This commit is contained in:
oonyeje 2026-07-07 04:15:04 +00:00
parent 94d92fa204
commit d1fe43302b

181
src/server.ts Normal file
View File

@ -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<string, ProjectCredentials>;
interface RouteEntry {
apiKeySlot: string;
functionId: string;
}
type RouteMap = Record<string, RouteEntry>;
// ----- 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.
});