11. Being called by the internet
the import webhook — mode: webhook, HMAC signatures over the raw body, dedupe by the sender's event id.
So far everything called your functions for you — a page, a schedule, another function. A webhook reverses it: a partner feed pushes assets into the shelf, signed in to nobody. The function is the whole trust boundary.
The function
examples/config/shelf/main/_functions/import/function.yaml
# A webhook: a partner feed pushes assets into the shelf. The sender has no
# session, so invoke allows anonymous calls and the function itself is the
# only check — it verifies the signature before anything else.
#
# `admin: true` because creating assets is a member's right, not a stranger's.
# The signing secret is set with
# backd secret set --realm shelf --database main --name IMPORT_WEBHOOK_SECRET
mode: webhook
invoke: "true"
admin: true
secrets: [IMPORT_WEBHOOK_SECRET]mode: webhook—ctx.inputis gone;ctx.requestholds the raw{ body, headers }, and the function returns{ status, body }itself.invoke: "true"— anonymous calls must reach it; the sender has no session and never will.secrets:—IMPORT_WEBHOOK_SECRETis shared with the sender out of band, stored per-database, never in git.
examples/config/shelf/main/_functions/import/index.ts
// import: handles "asset pushed" events from a feed. The feed signs the raw
// body (x-signature: sha256=<hex>) and retries any delivery it didn't get a
// 2xx for, so the same event can arrive more than once.
//
// Imported assets arrive as drafts — published_at is the publish function's
// alone, even for a trusted sender.
import { verifySignature } from "../lib/signature.ts";
import type { Context } from "../lib/types.ts";
type ImportEvent = { event_id: string; title: string; kind?: string; url?: string; body?: string; tags?: string[] };
export default async function handler(ctx: Context) {
// 1. Verify the sender, against the exact bytes it signed.
const { body, headers } = ctx.request;
if (!(await verifySignature(ctx.secrets.IMPORT_WEBHOOK_SECRET, body, headers["x-signature"]))) {
return { status: 400, body: "invalid signature" };
}
// 2. Shape the event; the body is untrusted JSON, so fields are whitelisted.
const event = JSON.parse(body) as ImportEvent;
if (!event.event_id || !event.title) {
return { status: 422, body: "need event_id and title" };
}
// 3. Deduplicate by the feed's own event id: record the event FIRST. The
// unique index on imports.event_id refuses a second insert with a 409,
// so a retried delivery stops here — before it can make a second asset.
const db = ctx.admin.db("main");
let record;
try {
record = await db.collection("imports").create({ event_id: event.event_id });
} catch (err) {
if ((err as { status?: number }).status === 409) return { status: 200, body: "already processed" };
throw err;
}
const doc: Record<string, unknown> = { title: event.title, kind: event.kind === "note" ? "note" : "link" };
if (event.url) doc.url = event.url;
if (event.body) doc.body = event.body;
doc.tags = [...(event.tags ?? []), "imported"];
const asset = await db.collection("assets").create(doc);
await db.collection("imports").patch(record.id, { asset_id: asset.id });
return { status: 200, body: "ok" };
}Three steps, in order:
- Verify the sender against the exact bytes it signed —
x-signature: sha256=<hex>is an HMAC of the raw body (lib/signature.ts, the same file the workshop uses). - Dedupe by the sender’s own event id — providers retry until they get a 2xx, so “already processed” is a normal answer. The function records the event first: the unique index on
imports.event_idmakes the second delivery a cheap 409 before any asset is created (creating the asset first would leave a duplicate behind). - Then, and only then, act — fields are whitelisted out of the untrusted JSON, and the asset lands as a draft:
published_atis the publish function’s alone, even for a trusted feed (Webhooks).
Create the function and the imports collection (a record of event ids already handled), plus the shared helper:
fetch the chapter's files (optional — the listings above are the same files)
for f in main/_functions/import/function.yaml main/_functions/import/index.ts main/_functions/lib/signature.ts main/_functions/lib/signature.test.ts main/imports/schema.json main/imports/indexes.json main/imports/collection.yaml; do
mkdir -p "config/shelf/$(dirname "$f")"
curl -fsSL "https://fernandezvara.github.io/backd/tutorial/config/shelf/$f" -o "config/shelf/$f"
doneRebuild and restart as before.
Feeding it
Set the shared secret once — secrets are written through the admin API (the operator’s $OP from chapter 4), never kept in config/:
curl -s -X PUT http://localhost:8080/v1/shelf/_admin/secrets/IMPORT_WEBHOOK_SECRET \
-H "Authorization: Bearer $OP" -H 'Content-Type: application/json' \
-d '{"database":"main","value":"dev-secret"}' # 204
(Pushing before the secret exists answers 500 secret_missing — the function declares it in secrets: and backd refuses to run it without.) Then run the fake feed, which signs like a real provider. It is a small Node script (Node 20 or newer; or run it in a container with docker run --rm --network host -v "$PWD/app":/a:Z docker.io/library/node:22 node /a/push.js …):
curl -fsSL https://fernandezvara.github.io/backd/tutorial/app/push.js -o app/push.js
node app/push.js http://localhost:8080 dev-secret "Title" https://example.com
push.js computes sha256=<hmac> over the body and POSTs — what any provider does, minus the account.
Why no handler test
createContext fakes ctx.input, ctx.db, ctx.call, ctx.email — but not ctx.request, which webhooks alone get (the workshop’s payment_webhook ships untested for the same reason). What is testable is the crypto: signature.test.ts proves sign/verifySignature agree, and that a tampered byte or a wrong secret is refused — which is the part a bad actor probes.
You should see
node push.js …answers200 ok— a new draft lands inassets. A webhook has no user, so the draft has no owner (_meta.owneris empty) and no member’s own-drafts rule matches; the curator’sreadrule from chapter 5 does, so it shows up in the curator’s Review view, marked imported (no owner), ready to publish.- The same
event_idsent twice answers200 already processed— oneimportsdoc, one asset. - A wrong
x-signatureanswers400 invalid signature; missingevent_id/title,422. deno testcovers the signature round-trip.
Next: chapter 12 — running it: API keys, the audit feed, and the hardening checklist.