@leumas/webhooks
Leumas Webhooks — the inbound door. A programmable endpoint that verifies a signature over the RAW request bytes, refuses replays durably, projects the body onto a callable's DECLARED inputs through...
@leumas/webhooks
The inbound door. An outside system POSTs to an unguessable address; this verifies the signature over the raw bytes, projects the body onto a callable's declared inputs, and runs it through @leumas/invoke.
import { createWebhookIngress, createWebhooksRouter } from '@leumas/webhooks';
app.use('/api/hooks', createWebhookIngress({ // PRE-GATE, anonymous, raw-bodied
connector, rateLimit, validateInputs,
invoke: (kind, ref, args, config) => invokeRef(`${kind}:${ref}`, args, binders(), config),
resolveSecret: readVariable, // the secret lives in a `variables` key
inputsFor: (binding) => declaredInputsFor(binding),
}));
app.use('/api/webhooks', createWebhooksRouter({ connector, gate, adminGate, catalog, inputsFor }));
Two routers, two planes of trust
/api/hooks is anonymous by necessity — a third-party sender has no Leumas session and cannot be given one. /api/webhooks is session-gated and admin for everything, because a row here is an execution path reachable with no session at all, which is a stronger claim than action_types makes. All three collections (webhooks, webhook_deliveries, webhook_seen) are in PLATFORM_COLLECTIONS.
[critical] The parts that are load-bearing
The body must not be parsed upstream. BODY_DEFAULTS.byPrefix['/api/hooks'] = null in @leumas/security is what arranges it. An HMAC is over the bytes the sender sent; re-serializing produces different bytes for the same value. Delete that entry and every signed webhook silently stops verifying — and because express.json no-ops on unrecognised content types, it will look fine until a genuine application/json body arrives and the route sees zero bytes behind a 200.
Config is author-owned; the request never contributes to it. In @leumas/invoke an action's config is merged AFTER the caller's args and wins — that is what distinguishes list_records from delete_record. argMap may only target inputs the callable declares, checked at save time by assertArgMap, and assertBinding runs again at dispatch because the row could have been written through /db or before a DENY_ACTIONS change.
Some bindings are refused outright. http-request, send-webhook, fs-, the http kind and module-export: an anonymous URL onto any of them is an SSRF proxy wearing this API's network position. Unpinned crud-config is refused too — it is read and* delete until the author pins op.
Every refusal looks identical. Unknown address, disabled row, bad signature, stale timestamp: one status, one body. Anything else lets a caller enumerate which addresses exist. The reason goes to the delivery log, which is the only place it can safely be said.
The address is masked at write time. The hookId is the credential for an unsigned webhook, so a delivery row never stores it whole — a stored secret outlives whatever gate was in front of it. Headers are picked from an allowlist for the same reason: a stored Authorization is a live credential at rest.
Replay protection is two-layered on purpose. A synchronous in-process claim (the durable check is read-then-create and N parallel copies would all pass) plus a webhook_seen row that survives a restart. The connector exposes no unique index, so neither half alone is sufficient — that limit is real and is stated in verify.js rather than implied.
Guards
pnpm smoke:webhooks — 31 assertions including the one that matters: a signature over raw bytes verifies while the same value re-serialized does not, which is the only real proof the body was never parsed upstream.