feat(rest/nodejs): sign order-event webhooks (RFC 9421) - #179
Merged
carolinerg1 merged 1 commit intoAug 17, 2026
Merged
Conversation
Order-event webhook deliveries carried no signature: no UCP-Agent, no Signature, no Signature-Input, no Content-Digest, so a platform had no way to verify a delivery against the business, violating order.md (Webhook Signature Verification). Delivery retry landed in Universal-Commerce-Protocol#175; this adds the signing half, mirroring the Python reference (Universal-Commerce-Protocol#169) via the existing RFC 9421 signer from Universal-Commerce-Protocol#162. Every delivery is now signed as the business: UCP-Agent names this server's profile, and Content-Digest, Signature-Input, and Signature cover the exact raw body bytes, with the Standard Webhooks event headers (webhook-id, webhook-timestamp) and x-event-type bound into the signed set through a new extraComponents parameter on signRequest. An Idempotency-Key equal to the Webhook-Id joins each delivery so the signed-component table's state-changing-POST requirement holds and retried events deduplicate. Each retry attempt from the Universal-Commerce-Protocol#175 loop is re-signed with a fresh created timestamp. Redirects are not followed (redirect manual) so a 3xx cannot silently re-POST to a URL the signature does not cover, matching the Python httpx semantics. The matching public JWK is published in the served profile's signing_keys[] and mirrored into ucp.keys[]; the kid is the RFC 7638 JWK thumbprint. WEBHOOK_SIGNING_KEY loads an operator PEM (EC P-256 or Ed25519), validated at startup so a misconfigured key aborts the boot; unset, an ephemeral demo key is generated. 16 new tests including a cross-checked RFC 7638 thumbprint oracle and a clock-stubbed guard proving each retry attempt is freshly signed; full suite 149 passing.
damaz91
approved these changes
Aug 13, 2026
carolinerg1
approved these changes
Aug 17, 2026
carolinerg1
merged commit Aug 17, 2026
7049d8d
into
Universal-Commerce-Protocol:main
16 checks passed
vishkaty
pushed a commit
to vishkaty/samples
that referenced
this pull request
Aug 31, 2026
…drop the ucp.keys[] mirror This server declares UCP_VERSION 2026-04-08 (config.ts:15). At that pin, source/discovery/profile_schema.json $defs/base requires ucp and separately declares signing_keys as a top level sibling of ucp, not a field nested inside it. That schema defines no keys field anywhere. discovery.ts published signing_keys[] correctly at the top level but also mirrored it into a nonstandard nested ucp.keys[], and its own verifier, extractKeys() in signature.ts, read only that nested field. Since ucp is required on every real profile document, a reader that looks inside ucp whenever it is present can never see a top level sibling field on any real document, so the verifier could not resolve the schema correct field the same server publishes. Traced to two of our own merged PRs: Universal-Commerce-Protocol#162 introduced extractKeys reading only a nested keys[], pre-adopting ucp#566 both too early (before the version literal moved) and in the wrong location (nested rather than top level); Universal-Commerce-Protocol#179 then added the top level signing_keys[] publication and, to satisfy the Universal-Commerce-Protocol#162 reader, the nested mirror. extractKeys() now reads the top level signing_keys field directly; discovery.ts no longer publishes the nested field; the fetchSigningKeys() doc comment is corrected to match. Also widens DiscoveryCapability.extends to string | string[] per capability.json (oneOf reverse_domain_name, array minItems 1, both pins), and removes the dev.ucp.shopping.refund/.return/.dispute capability declarations: no schema file exists for them under source/schemas/shopping/ at either pin, no route implements them, and neither reference python samples server declares them. Baseline 154/154 (upstream/main 00333a8, npm ci && npm run build && npm test). After: 160/160, tsc clean. Four kill tests (revert extractKeys, reintroduce the keys field, reintroduce refund/return/dispute, narrow extends back) each confirmed red before restoring green. pre-commit clean on every touched file (prettier reformatted two test files' import wraps, folded in here).
This was referenced Aug 31, 2026
fix(rest/nodejs): read signing_keys[] where the schema places it and drop the ucp.keys[] mirror
#226
Open
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.
Observed vs expected
Order-event webhook deliveries from the Node reference carry no signature: no
UCP-Agent, noSignature, noSignature-Input, noContent-Digest, and the server publishes no signing key. order.md (Webhook Signature Verification) requires webhook payloads to be signed by the business with a key published in its profilesigning_keys[], with those four headers on every delivery. Delivery retry landed in #175; this PR adds the signing half, mirroring what the Python reference gained in #169, built on the RFC 9421 signer this server already has from #162.Fix
src/utils/webhook_signer.ts: the business signing identity.WEBHOOK_SIGNING_KEYloads an operator PEM (EC P-256 or Ed25519; anything else fails the boot loudly at startup); unset, an ephemeral P-256 demo key is generated so the server signs correctly out of the box with no key material in the repo. The kid is the RFC 7638 JWK thumbprint.signRequestgains anextraComponentsparameter: caller-requested headers join the signed set when present, never duplicated. Webhook deliveries bindwebhook-id,webhook-timestamp, andx-event-type, so the event identity a platform dedupes and dispatches on cannot be altered in transit.notifyWebhookkeeps the fix(rest/nodejs): retry transient webhook failures #175 retry loop as merged and layers signing onto it: the order body is serialized exactly once and those same bytes are digested and sent; every delivery carriesUCP-Agent(profile="<origin>/.well-known/ucp"),Signature,Signature-Input,Content-Digest, and anIdempotency-Keyequal to theWebhook-Id(the signed-component table requires idempotency-key on a state-changing POST, and retried events deduplicate under it). Each retry attempt is re-signed with a freshcreated;redirect: "manual"is set on the delivery fetch so a 3xx cannot transparently re-POST to a URL the signature does not cover (the Python server's httpx client does not follow redirects either; a 3xx lands in the existing permanent-rejection branch).signing_keys[]and mirrored intoucp.keys[].src/index.ts: a misconfiguredWEBHOOK_SIGNING_KEYaborts the boot rather than silently degrading every delivery.Verification
@query); every retry attempt signed with stableWebhook-Id/Idempotency-Key; a clock-stubbed guard proving each retry attempt is freshly signed (a mutation hoisting the signing out of the fix(rest/nodejs): retry transient webhook failures #175 loop fails exactly that test); key lifecycle (ephemeral singleton, PEM loading for both key types, deterministic RFC 7638 kid against an independent oracle, unreadable and unsupported-key rejection); profile publication in both locations.ucp_signing.verify_request) against the JWK the Node profile publishes, and a tampered body is rejected.Notes for reviewers
signatureConfig-style seam:WEBHOOK_SIGNING_KEY(README section included).