feat(server): createWebhookEmitter + createAdcpServer.webhooks integration#632
Merged
Conversation
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).
0291faa to
91d0ed2
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
`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
All 4194 tests pass / 0 fail / 7 annotated skips.
Exports
From `@adcp/client/server`:
Follow-ups
Changeset
`.changeset/webhook-emitter.md` — `minor`. Pure additive: new export surface, new optional `HandlerContext` field, new optional config option. Nothing breaks.