Files
kongruity/backend/routes/webhooks.routes.ts

114 lines
3.1 KiB
TypeScript

import { Router, type NextFunction, type Request, type Response } from 'express';
import { getProvider } from '../config/providers.js';
import { verifySignature } from '../lib/signatures.js';
import { enqueue } from '../lib/queue.js';
import { normalize, NormalizationError } from '../services/normalize.service.js';
import {
markDone,
markFailed,
markProcessing,
recordDelivery,
} from '../db/ingest_events.dao.js';
import { createNotes } from '../db/notes.dao.js';
import { isProviderSlug, type NormalizedDelivery } from '../types/integration.js';
const router = Router();
const parseJson = (raw: Buffer): unknown => {
if (raw.length === 0) return {};
return JSON.parse(raw.toString('utf8'));
};
router.post('/:provider', async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.provider;
if (!isProviderSlug(slug)) {
res.status(404).json({ error: `Unknown provider "${slug}"` });
return;
}
const config = getProvider(slug);
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
let payload: unknown;
try {
payload = parseJson(rawBody);
} catch {
res.status(400).json({ error: 'Body is not valid JSON' });
return;
}
const challenge = config.challenge?.(payload);
if (challenge) {
res.status(challenge.status).json(challenge.body);
return;
}
if (config.signatureScheme !== 'none') {
const secret = config.secretEnvVar ? process.env[config.secretEnvVar] : undefined;
if (!secret) {
console.error(`No signing secret configured for provider "${slug}"`);
res.status(500).json({ error: 'Provider is not configured' });
return;
}
const result = verifySignature({
provider: slug,
rawBody,
headers: req.headers,
secret,
});
if (!result.ok) {
res.status(401).json({ error: 'Invalid signature' });
return;
}
}
if (!config.normalize) {
res.status(501).json({ error: `No ingestion mapping for provider "${slug}"` });
return;
}
// Normalizing before the ack costs nothing (it is pure) and lets a malformed
// payload fail loudly here rather than silently in a background job.
let delivery: NormalizedDelivery;
try {
delivery = normalize(slug, payload);
} catch (err) {
if (!(err instanceof NormalizationError)) throw err;
res.status(400).json({ error: err.message });
return;
}
const eventId = await recordDelivery({
provider: slug,
externalId: delivery.externalId,
});
if (eventId === null) {
res.status(200).json({ duplicate: true });
return;
}
const { notes } = delivery;
enqueue(`${slug}:${delivery.externalId}`, async () => {
await markProcessing(eventId);
try {
await createNotes(notes);
await markDone(eventId);
} catch (err) {
await markFailed(eventId, err instanceof Error ? err.message : String(err));
throw err;
}
});
res.status(200).json({ accepted: true, notes: notes.length });
} catch (err) {
next(err);
}
});
export default router;