114 lines
3.1 KiB
TypeScript
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;
|