Compare commits
No commits in common. "test" and "main" have entirely different histories.
144
src/server.ts
144
src/server.ts
@ -14,16 +14,10 @@
|
|||||||
*
|
*
|
||||||
* 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. /crown-x-ms-monet__verify_square_webhook=>{"apiKeySlot":"square","functionId":"verify_square_webhook"}
|
* e.g. /square=>{"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)
|
|
||||||
* NODE_ENV production | staging (default: production).
|
|
||||||
* When set to anything other than "production", the
|
|
||||||
* /routes diagnostic endpoint is exposed. In production
|
|
||||||
* builds, /routes returns 404 to avoid leaking route
|
|
||||||
* metadata.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
@ -123,23 +117,6 @@ 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();
|
|
||||||
|
|
||||||
// Whether /routes diagnostic endpoint is exposed.
|
|
||||||
// Set NODE_ENV=staging in the staging service env vars. Production services
|
|
||||||
// leave NODE_ENV unset (or set it to "production") and the endpoint is hidden.
|
|
||||||
const NODE_ENV = (process.env.NODE_ENV ?? 'production').toLowerCase();
|
|
||||||
const DIAGNOSTICS_ENABLED = NODE_ENV !== 'production';
|
|
||||||
|
|
||||||
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 {
|
||||||
@ -151,13 +128,16 @@ 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);
|
||||||
log('error', `PROJECT_CREDENTIALS is not valid JSON: ${message}`);
|
console.error('[startup] PROJECT_CREDENTIALS is not valid JSON:', message);
|
||||||
log('error', `Received value (first 300 chars): ${raw}`);
|
console.error('[startup] 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.`);
|
console.error('[startup] 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);
|
||||||
@ -169,34 +149,23 @@ 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 {
|
||||||
log('error', `Invalid ROUTE_MAP entry for "${path}": expected valid JSON, got "${cfgStr.slice(0, 80)}"`);
|
const message = `expected valid JSON, got "${cfgStr.slice(0, 80)}"`;
|
||||||
|
console.error(`[startup] Invalid ROUTE_MAP entry for "${path}":`, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log('info', `LOG_LEVEL: ${LOG_LEVEL}`);
|
console.log(`[startup] Listening on port ${PORT}`);
|
||||||
log('info', `NODE_ENV: ${NODE_ENV} (diagnostics endpoint ${DIAGNOSTICS_ENABLED ? 'ENABLED' : 'DISABLED'})`);
|
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 ------------------------------------------------------------------
|
||||||
|
|
||||||
@ -210,45 +179,10 @@ 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',
|
||||||
node_env: NODE_ENV,
|
|
||||||
diagnostics_enabled: DIAGNOSTICS_ENABLED,
|
|
||||||
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: DIAGNOSTICS_ENABLED ? ROUTE_MAP : '[hidden - production mode]',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ----- Diagnostic: list routes (STAGING ONLY) --------------------------------
|
|
||||||
// Exposed only when NODE_ENV !== 'production'. Production deployments get a
|
|
||||||
// 404 so route metadata and credential slot names aren't leaked.
|
|
||||||
|
|
||||||
app.get('/routes', (_req: Request, res: Response) => {
|
|
||||||
if (!DIAGNOSTICS_ENABLED) {
|
|
||||||
log('warn', `/routes requested in production - returning 404`);
|
|
||||||
res.status(404).json({ error: 'Not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
|
||||||
node_env: NODE_ENV,
|
|
||||||
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(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -256,7 +190,6 @@ app.get('/routes', (_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');
|
||||||
@ -264,10 +197,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 /crown-x-ms-monet__verify_square_webhook).
|
// then short-path route map (e.g. POST /square).
|
||||||
let resolvedProject: string | null = projectParam;
|
let resolvedProject: string | null = projectParam;
|
||||||
let resolvedFunction: string | null = functionParam;
|
let resolvedFunction: string | null = functionParam;
|
||||||
let resolvedSlot: string | null = apiKeyParam;
|
let resolvedSlot: string | null = apiKeyParam;
|
||||||
@ -277,25 +208,20 @@ 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: project, function, apiKey',
|
error: 'Missing required params',
|
||||||
required: ['project', 'function', 'apiKey'],
|
required: ['project', 'function', 'apiKey'],
|
||||||
or: 'POST to a configured short-path route',
|
or: 'POST to a configured short-path route (e.g. /square)',
|
||||||
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;
|
||||||
}
|
}
|
||||||
@ -303,11 +229,10 @@ 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`;
|
||||||
log('info', `[${incomingAt}] Forwarding -> slot=${resolvedSlot} project=${resolvedProject} function=${resolvedFunction} bytes=${rawBody.length}`);
|
console.log(`[webhook] slot=${resolvedSlot} project=${resolvedProject} function=${resolvedFunction} -> ${appwriteUrl}`);
|
||||||
|
|
||||||
const appwriteResp = await fetch(appwriteUrl, {
|
const appwriteResp = await fetch(appwriteUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -326,41 +251,22 @@ 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);
|
||||||
log('error', `[${incomingAt}] Error forwarding webhook: ${message}`);
|
console.error('[webhook] Error forwarding webhook:', err);
|
||||||
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) => {
|
||||||
log('warn', `${req.method} ${req.path} -> 404`);
|
res.status(404).json({ error: 'Not found. POST webhooks to this proxy with the right params.' });
|
||||||
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 for diagnostic info.' + (DIAGNOSTICS_ENABLED ? ' In staging, /routes also lists registered routes.' : ''),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ----- Start ----------------------------------------------------------------
|
// ----- Start ----------------------------------------------------------------
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
log('info', 'Server is up and accepting connections');
|
// The startup banner already printed above.
|
||||||
});
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user