Skip to content

feat(server): createWebhookEmitter + createAdcpServer.webhooks integration#632

Merged
bokelley merged 5 commits into
mainfrom
bokelley/webhook-emitter
Apr 20, 2026
Merged

feat(server): createWebhookEmitter + createAdcpServer.webhooks integration#632
bokelley merged 5 commits into
mainfrom
bokelley/webhook-emitter

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Publisher-side webhook emission — the symmetric counterpart to PR #629's receiver dedup. Closes the "we haven't spun up an actual server and watched the full stack verify" gap flagged during #631 review, and the `createWebhookEmitter` follow-up listed in that PR's body.

What

createWebhookEmitter (`src/lib/server/webhook-emitter.ts`)

One call — `emit({ url, payload, operation_id })` — and the emitter handles:

  • RFC 9421 signing with a fresh nonce per attempt (adcp#2423).
  • Stable `idempotency_key` per `operation_id` reused across retries (adcp#2417) — regenerating on retry is the AdCP Testing Framework with Authentication, Hierarchical Logging & Security Features #1 at-least-once-delivery bug the runner-side conformance suite catches.
  • Serialize once, sign once, post once. JSON with compact separators (`,` / `:`, no spaces), byte-identical bytes feed both the `content-digest` input and the HTTP body. Closes the Python `json.dumps` default-spacing trap pinned by adcp#2478.
  • Retry with exponential backoff + jitter on 5xx / 429. Terminal on 4xx and on 401 responses carrying `WWW-Authenticate: Signature error="webhook_signature_*"` — retrying a signature failure produces identical bytes and identical rejection.
  • Pluggable `WebhookIdempotencyKeyStore` (default in-memory). Production publishers with multi-replica emitters swap in a durable backend the same way `AsyncHandlerConfig.webhookDedup` accepts one on the receiver side.
  • HMAC-SHA256 / Bearer fallback for legacy buyers that registered `push_notification_config.authentication.credentials`. HMAC path uses the same compact-separators pinning.

`createAdcpServer` integration

New `webhooks?: { signerKey, retries?, idempotencyKeyStore?, … }` config option. When set, `ctx.emitWebhook` is populated on every handler's context:

```ts
createAdcpServer({
name, version,
webhooks: { signerKey: { keyid, alg: 'ed25519', privateKey: jwk } },
mediaBuy: {
createMediaBuy: async (params, ctx) => {
const media_buy_id = await persist(params);
await ctx.emitWebhook({
url: params.push_notification_config.url,
payload: { task: { task_id, status: 'completed', result: { media_buy_id } } },
operation_id: `create_media_buy.${media_buy_id}`,
});
return { media_buy_id, packages: [] };
},
},
});
```

When `webhooks` is omitted `ctx.emitWebhook` is `undefined`, so handlers that don't emit don't need the option.

Tests

  • 14 unit tests (`test/lib/webhook-emitter.test.js`): happy path + 9421 round-trip through our own verifier; retry on 503/429 with stable key + fresh nonce; terminal 4xx; terminal 401 with `WWW-Authenticate: Signature`; max-attempts cap; cross-call stability (same operation_id → same key); malformed-generator rejection; HMAC + Bearer fallback headers; observability hooks fire with correct attempt numbers.
  • 3 full-stack E2E tests (`test/lib/webhook-emitter-server-e2e.test.js`): `createAdcpServer` with a real handler → `ctx.emitWebhook` → real HTTP POST → receiver captures → `verifyWebhookSignature` accepts. No mocks on the signer or verifier path. Also covers the `ctx.emitWebhook = undefined` omission case and cross-emit idempotency-key stability within a single handler invocation.

All 4194 tests pass / 0 fail / 7 annotated skips.

Exports

From `@adcp/client/server`:

  • `createWebhookEmitter`, `memoryWebhookKeyStore`
  • Types: `WebhookEmitter`, `WebhookEmitterOptions`, `WebhookEmitParams`, `WebhookEmitResult`, `WebhookEmitAttempt`, `WebhookEmitAttemptResult`, `WebhookIdempotencyKeyStore`, `WebhookRetryOptions`, `WebhookAuthentication`
  • `HandlerContext.emitWebhook` — new optional field, populated when `webhooks` config is set.

Follow-ups

  • Brand.json → JWKS auto-resolver. Receiver-side helper so `webhook_signing: { brand: 'acme.example' }` works without callers hand-building a `JwksResolver`. Separate PR; the current `StaticJwksResolver` / `HttpsJwksResolver` already cover the direct-URL case.
  • Observability helpers — today `onAttempt` / `onAttemptResult` give raw data; a thin metric emitter (retry-count, terminal-errors, latency histograms) could be a small follow-up.

Changeset

`.changeset/webhook-emitter.md` — `minor`. Pure additive: new export surface, new optional `HandlerContext` field, new optional config option. Nothing breaks.

Comment thread src/lib/server/webhook-emitter.ts Fixed
bokelley and others added 4 commits April 20, 2026 01:03
Publisher-side webhook emission — the symmetric counterpart to PR #629's
receiver dedup. One ctx.emitWebhook(url, payload, operation_id) call and
the emitter handles:

- RFC 9421 signing with fresh nonce per attempt (adcp#2423).
- Stable idempotency_key per operation_id across retries (adcp#2417).
- JSON serialized once with compact separators and posted byte-identically
  (adcp#2478) — closes the Python json.dumps-default-spacing trap.
- Retry with exp backoff + jitter on 5xx/429. Terminal on 4xx and on 401
  responses carrying WWW-Authenticate: Signature error="webhook_signature_*".
- Pluggable WebhookIdempotencyKeyStore (default in-memory); swap in a
  durable backend for multi-replica publishers.
- HMAC-SHA256 / Bearer fallback modes for legacy buyers.

createAdcpServer integration: new webhooks? config option. When set,
ctx.emitWebhook is populated on every handler's context — completion
handlers post signed webhooks without constructing the signer, fetching,
or tracking idempotency themselves.

Tests:
- 14 unit tests covering every delivery path (happy / retry / terminal /
  401+Signature / cap / cross-call stability / HMAC + Bearer fallback /
  observability).
- 3 full-stack E2E tests: createAdcpServer → ctx.emitWebhook → real HTTP
  → receiver captures → verifyWebhookSignature accepts. No mocks on the
  signer/verifier path. Closes the "haven't spun up an actual server"
  gap flagged during PR #631 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Non-interactive Claude Code harness that answers "can an agent with our
SDK + a build-*-agent skill actually stand up a server that passes a
compliance storyboard?"

Flow:
  1. Pre-populates a tmp workspace with package.json pointing @adcp/client
     at the repo root (file:<repo>), runs npm install upfront so Claude
     never touches deps.
  2. Invokes claude -p <prompt> --dangerously-skip-permissions with the
     skill content + target storyboard + fixed port embedded in the prompt.
  3. After Claude exits cleanly, runs the start.sh it produced, waits for
     the port, invokes bin/adcp.js storyboard run <url> <id> --json,
     reports pass/fail, tears down the spawned server + workspace.

Usage:
  npm run compliance:agent-skill -- \
    --skill skills/build-seller-agent/SKILL.md \
    --storyboard universal/idempotency

Clean boundaries: Claude is a code-writer, harness is the orchestrator —
keeps failure modes diagnosable (was it the skill guidance, the SDK, the
grader, or the agent itself).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First live run surfaced two plumbing issues beyond the Claude prompt:

1. The compliance runner hard-refuses http:// URLs (production agents
   MUST terminate TLS). Since the harness always binds on loopback, it
   passes --allow-http explicitly.

2. The prompt's "verify the server starts, then kill it" step taught
   Claude to leave a process on port 4200 when the harness tried to
   start its own. Tightened the instruction to "do NOT run the server,
   not even to verify" and added defense-in-depth port-cleanup in the
   harness (lsof | kill -9) before bash start.sh.

Validated end-to-end: Claude builds server.ts + start.sh, harness starts
it, grader runs against it, storyboard reports real conformance results
(1 partial pass on `idempotency` — ACCOUNT_NOT_FOUND and missing-key
rejection are both skill-guidance gaps the build-seller-agent skill can
be tightened against).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First live agent-skill-storyboard harness run surfaced that the skill's
resolveAccount example only handled { account_id } references — the
idempotency conformance grader (and any other storyboard using the
canonical { brand: { domain }, operator } shape) short-circuits with
ACCOUNT_NOT_FOUND before the idempotency layer ever runs. That masked
real conformance signal behind the wrong error code.

Changes:

1. Example resolveAccount now handles BOTH AccountReference branches.
   Includes a dev-mode wildcard for brand+operator so compliance graders
   reach the handler, with an explicit note to replace with a real
   tenant lookup in production.

2. Idempotency section now calls out the ordering gotcha: resolveAccount
   runs BEFORE idempotency, so null-returning resolveAccount surfaces as
   ACCOUNT_NOT_FOUND rather than the missing-key/replay error the spec
   actually wants. Tells readers what to fix when their storyboard runs
   fail with unexpected error codes.

Also: prettier-format the skill file (CI check).
@bokelley bokelley force-pushed the bokelley/webhook-emitter branch from 0291faa to 91d0ed2 Compare April 20, 2026 05:05
Retries are re-resolved per-emit inside `emit()` (to honor per-call
overrides), so the factory-level `defaultRetries` was dead. CodeQL
flagged it on PR review.
@bokelley bokelley merged commit 078b52c into main Apr 20, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant