Infrastructure build to support third-party app integrations
This commit is contained in:
113
backend/routes/webhooks.routes.ts
Normal file
113
backend/routes/webhooks.routes.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user