Webhook ingestion — overview
@drakkar.software/starfish-webhook is an inbound ingress: it lets an external
system POST a payload that lands in a Starfish collection. It is the mirror image of
starfish-queuing (which emits change events out after a write); this accepts a
foreign request and turns it into a write.
It is deliberately format-agnostic. The package ships no Slack/Discord/GitHub
adapters. An operator supplies a transform mapping the inbound JSON onto the
document data. This keeps the extension a generic ingress rather than a registry
of third-party schemas.
Design
Plugins cannot register HTTP routes (the sync router is built statically), so this
extension is not a ServerPlugin. It is a framework-neutral handler you mount
yourself and compose beside the sync router:
external POST ─▶ createWebhookHandler ─▶ dispatch(pushRequest) ─▶ syncRouter
│ │
├─ verifyHmac (authenticate the caller) ├─ RBAC / roles
├─ transform (payload → document data) ├─ append-only
├─ seal? (E2EE, optional) └─ afterWrite hooks
└─ sign? (append-author proof) (queuing, audit…)
Because the handler forwards a normal push (via the injected dispatch,
typically syncRouter.fetch), every downstream behaviour — role checks, append-only
serialization, and afterWrite side effects like queuing — runs exactly as for a
first-party client write. The extension adds an ingress; it does not bypass the
pipeline.
The flow, step by step
- Look up the route by the
webhookIdin the path. Unknown →404. - Authenticate the caller. Pluggable: either the built-in HMAC
secret(verifyHmacover the raw body, or${timestamp}.${rawBody}when a timestamp header is set) OR a customauthenticatecallback (no static secret — e.g. a per-tenant token lookup). Bad/missing →401; a route with neither →500. Reason strings never echo the secret or the computed signature. - Transform the parsed payload into the document
data. Returningnullrejects the request (400) — use it to drop payloads you don't accept. - Seal (optional, Option B): encrypt the document to a published space write
key so the server stores only ciphertext. See
02-sealed-write.md. - Sign (optional): attach an append-author proof, required by append-only
collections with
requireAuthorSignature(the default). Without it, a regular create-if-absent push (baseHash: null) is sent instead. - Forward the constructed push
Requesttodispatchand return its response.
Configuration
Provide exactly one auth mechanism — secret (built-in HMAC) or authenticate
(custom, no static secret):
interface WebhookRoute {
// --- auth: pick ONE ---
secret?: string // built-in HMAC shared secret (constant-time compared)
signatureHeader?: string // default "x-webhook-signature"
timestampHeader?: string // optional; enables a replay window
toleranceSeconds?: number // default 300
authenticate?: (ctx: { raw; headers; webhookId }) => boolean | Promise<boolean> // custom, no static secret
// ----------------------
transform: WebhookTransform // (input) => document data | null
target: string // e.g. "/push/events/inbox"
author?: { edPubHex; edPrivHex } // append-author proof
seal?: { recipientKemPubHex } // E2EE — see sealed-write doc
sealer?: { edPubHex; edPrivHex } // signs the seal; required when `seal` is set
forwardHeaders?: Record<string,string> // e.g. an Authorization cap for a gated target
}
Security notes
- Authenticate the caller AND the write separately. The route auth (HMAC
secretor your customauthenticate) verifies the external sender; the forwarded push is authorized by the target collection's roles (useforwardHeadersto attach a cap for a non-public-write target). - For HMAC, prefer a timestamp header (
timestampHeader) so a captured request cannot be replayed indefinitely; tunetoleranceSecondsto your clock skew. - No static operator secret needed. Use
authenticateto verify a per-tenant credential (e.g. a hashed bearer token from your store) so every caller crafts its own secret — no shared platform secret. - Rotate credentials per route. Each webhook has its own; compromise of one never affects another.
- Rate-limit the mounted route as you would any public endpoint (the target
collection's own
rateLimitstill applies to the forwarded push).