Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions rest/nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,25 @@ Each verified request logs
at `/.well-known/ucp` stays unverified: it is the public document a platform
must read before it can sign anything.

## Webhook Signing

Outbound order-event webhooks are signed as the business, per the
specification's `order.md` (Webhook Signature Verification): every delivery
carries `UCP-Agent` (this server's profile URL), `Signature`,
`Signature-Input`, and a `Content-Digest` over the exact raw body bytes. The
signed components cover the full request-signing table (`@method`,
`@authority`, `@path`, `@query` when the platform URL has one,
`content-digest`, `content-type`, `idempotency-key`, `ucp-agent`) plus the
Standard Webhooks event headers (`webhook-id`, `webhook-timestamp`) and
`x-event-type`. The matching public JWK is published in the served profile's
`signing_keys[]` (and mirrored into `ucp.keys[]`) so platforms can verify.
Retried deliveries reuse the same `Webhook-Id` and `Idempotency-Key` and are
re-signed per attempt.

| Env var | Default | Effect |
| --------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `WEBHOOK_SIGNING_KEY` | (ephemeral) | Path to a PEM private key (EC P-256 or Ed25519) to sign webhooks with. When unset, an ephemeral demo key is generated at startup and published in the profile. |

## Running Conformance Tests

To verify that this server implementation complies with the UCP specifications,
Expand Down
106 changes: 75 additions & 31 deletions rest/nodejs/src/api/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ import {
UcpError,
ucpErrorResponse,
} from "../utils/ucp_error";
import { signRequest } from "../utils/signature";
import { signingKey } from "../utils/webhook_signer";
import { type IdParamContext } from "../utils/validation";

// zCompleteCheckoutRequest and CompleteCheckoutRequest are now imported from SDK models
Expand Down Expand Up @@ -165,7 +167,8 @@ export class CheckoutService {
*/
private async notifyWebhook(
checkout: ExtendedCheckoutResponse,
eventType: string
eventType: string,
baseUrl: string
): Promise<void> {
if (!checkout.platform?.webhook_url) {
return;
Expand All @@ -184,48 +187,85 @@ export class CheckoutService {
}

const webhookUrl = checkout.platform.webhook_url;
const body = JSON.stringify(orderData);
// Serialize exactly once: the signed Content-Digest and the wire body
// must be the same bytes (order.md, Webhook Signature Verification).
const body = Buffer.from(JSON.stringify(orderData), "utf-8");
const webhookId = uuidv4();
const headers = {
"Content-Type": "application/json",
"X-Event-Type": eventType,
"Webhook-Id": uuidv4(),
"Webhook-Id": webhookId,
"Webhook-Timestamp": Math.floor(Date.now() / 1000).toString(),
// A webhook POST is a state-changing request, so the signed-component
// table requires idempotency-key (signatures.md). The event id doubles
// as the key: retries carry the same value, letting the platform
// deduplicate redelivered events.
"Idempotency-Key": webhookId,
// Sign AS this business: the profile URL platforms fetch our
// signing_keys[] from (order.md requires UCP-Agent on deliveries).
"UCP-Agent": `profile="${baseUrl}/.well-known/ucp"`,
};
const maxAttempts = 3;

for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const response = await fetch(webhookUrl, {
method: "POST",
// Delivery failures never propagate into the order flow: a webhook URL
// the signer cannot handle, or a signing-identity error, degrades to a
// logged failure exactly like an undeliverable receiver (the Python
// reference's outer except Exception behaves the same way).
try {
const { privateKey, kid } = signingKey();

for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
// Re-sign per attempt so the signature's `created` timestamp reflects
// the actual send time of each delivery attempt.
const additions = signRequest(
privateKey,
kid,
"POST",
webhookUrl,
headers,
body,
});
if (response.ok) {
return;
undefined,
["webhook-id", "webhook-timestamp", "x-event-type"]
);
try {
const response = await fetch(webhookUrl, {
method: "POST",
headers: { ...headers, ...additions },
body,
// A redirect transparently followed would re-POST to a URL the
// signature does not cover; surface 3xx as a response instead
// (the Python reference's httpx client does not follow either).
redirect: "manual",
});
if (response.ok) {
return;
}
if (response.status < 500) {
console.error(
`Webhook at ${webhookUrl} rejected delivery with status ${response.status}`
);
return;
}
} catch (e) {
if (attempt === maxAttempts) {
console.error(`Failed to notify webhook at ${webhookUrl}`, e);
return;
}
}
if (response.status < 500) {
console.error(
`Webhook at ${webhookUrl} rejected delivery with status ${response.status}`

if (attempt < maxAttempts) {
await new Promise((resolve) =>
setTimeout(resolve, 100 * 2 ** (attempt - 1))
);
return;
}
} catch (e) {
if (attempt === maxAttempts) {
console.error(`Failed to notify webhook at ${webhookUrl}`, e);
return;
}
}

if (attempt < maxAttempts) {
await new Promise((resolve) =>
setTimeout(resolve, 100 * 2 ** (attempt - 1))
);
}
console.error(
`Failed to notify webhook at ${webhookUrl} after ${maxAttempts} attempts`
);
} catch (e) {
console.error(`Failed to notify webhook at ${webhookUrl}`, e);
}

console.error(
`Failed to notify webhook at ${webhookUrl} after ${maxAttempts} attempts`
);
}

private addressesMatch(
Expand Down Expand Up @@ -1108,7 +1148,11 @@ export class CheckoutService {
saveCheckout(id, checkout.status, checkout);

// Notify webhook
await this.notifyWebhook(checkout, "order_placed");
await this.notifyWebhook(
checkout,
"order_placed",
new URL(c.req.url).origin
);

if (idempotencyKey) {
saveIdempotencyRecord(
Expand Down Expand Up @@ -1185,7 +1229,7 @@ export class CheckoutService {
return c.json(checkout, 200);
};

shipOrder = async (orderId: string): Promise<void> => {
shipOrder = async (orderId: string, baseUrl: string): Promise<void> => {
const order = getOrder(orderId);
if (!order) {
throw new Error("Order not found");
Expand All @@ -1209,7 +1253,7 @@ export class CheckoutService {

const checkout = getCheckoutSession(order.checkout_id);
if (checkout) {
await this.notifyWebhook(checkout, "order_shipped");
await this.notifyWebhook(checkout, "order_shipped", baseUrl);
}
};
}
14 changes: 14 additions & 0 deletions rest/nodejs/src/api/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

import { type Context } from "hono";
import { UCP_VERSION } from "../utils/config";
import { type Jwk } from "../utils/signature";
import { publicJwk } from "../utils/webhook_signer";

// overview.md (Discovery) requires the profile response to carry a
// `Cache-Control` header with `public` and a `max-age` of at least 60 seconds,
Expand Down Expand Up @@ -51,6 +53,7 @@ type UcpDiscoveryMetadata = {
services: Record<string, DiscoveryServiceBinding[]>;
capabilities: Record<string, DiscoveryCapability[]>;
payment_handlers: Record<string, DiscoveryPaymentHandler[]>;
keys: Jwk[];
};

/**
Expand Down Expand Up @@ -121,8 +124,18 @@ export class DiscoveryService {
],
};

// Publish the webhook-signing public key so platforms can verify our
// order-event deliveries (order.md, Webhook Signature Verification /
// signatures.md, Key Discovery). The discovery profile schema places
// signing_keys[] at the top level of the served document (a sibling of
// `ucp`); it is mirrored into ucp.keys[], the RFC 7517 JWK Set this
// server's own verifier (signature.ts extractKeys) resolves, so both
// discovery conventions find the key.
const webhookJwk = publicJwk();

const ucp = {
version: this.ucpVersion,
keys: [webhookJwk],
services: {
"dev.ucp.shopping": [
{
Expand Down Expand Up @@ -203,6 +216,7 @@ export class DiscoveryService {

const discoveryProfile = {
ucp,
signing_keys: [webhookJwk],
payment: {
handlers: [
...payment_handlers["com.shopify.shop_pay"],
Expand Down
2 changes: 1 addition & 1 deletion rest/nodejs/src/api/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class TestingService {

const { id } = c.req.valid("param");
try {
await this.checkoutService.shipOrder(id);
await this.checkoutService.shipOrder(id, new URL(c.req.url).origin);
return c.json({ status: "shipped" }, 200);
} catch (e: any) {
if (e.message === "Order not found") {
Expand Down
6 changes: 6 additions & 0 deletions rest/nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,17 @@ import {
} from "./models";
import { verifySignature } from "./utils/signature";
import { IdParamSchema, prettyValidation } from "./utils/validation";
import { signingKey } from "./utils/webhook_signer";

const app = new Hono();

initDbs("databases/products.db", "databases/transactions.db");

// Load (and thereby validate) the webhook-signing identity up front: a
// misconfigured WEBHOOK_SIGNING_KEY must abort the boot loudly, not surface
// as a swallowed per-delivery error that silently degrades every webhook.
signingKey();

const checkoutService = new CheckoutService();
const orderService = new OrderService();
const discoveryService = new DiscoveryService();
Expand Down
12 changes: 12 additions & 0 deletions rest/nodejs/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,15 @@ export const signatureConfig = {
requireSignatures: process.env.REQUIRE_SIGNATURES === "true",
allowInsecureProfileUrls: process.env.ALLOW_INSECURE_PROFILE_URLS === "true",
};

// The business webhook-signing identity (order.md, Webhook Signature
// Verification), sourced from the environment like signatureConfig above and
// mutable so tests can adjust it, mirroring the Python server's config.FLAGS:
//
// * WEBHOOK_SIGNING_KEY: path to a PEM private key (EC P-256 or Ed25519)
// used to sign outbound order-event webhooks as this business. When unset,
// an ephemeral demo key is generated at startup; either way the public JWK
// is published in the served profile's signing_keys[].
export const webhookConfig = {
signingKeyPath: process.env.WEBHOOK_SIGNING_KEY,
};
17 changes: 14 additions & 3 deletions rest/nodejs/src/utils/signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,16 +464,24 @@ function rawSign(privateKey: KeyObject, base: Buffer): Buffer {
}

// Signs a UCP request and returns the headers to add: Content-Digest (when a
// body is present), Signature-Input, and Signature covering exactly the UCP
// required-component set. Used by the tests as the client side of the loop.
// body is present), Signature-Input, and Signature covering the UCP
// required-component set. Used by the tests as the client side of the loop,
// and by the webhook sender as this business's signer.
//
// extraComponents lists additional header components to cover beyond the UCP
// required floor (RFC 9421 permits covering any component). Each is covered
// when the header is present on the request, mirroring how the required
// table conditions on header presence. Webhook deliveries use this to bind
// Webhook-Id and Webhook-Timestamp.
export function signRequest(
privateKey: KeyObject,
kid: string,
method: string,
url: string,
headers: Record<string, string>,
body: Uint8Array,
created?: number
created?: number,
extraComponents: string[] = []
): Record<string, string> {
const target = new URL(url);
const additions: Record<string, string> = {};
Expand All @@ -494,6 +502,9 @@ export function signRequest(

const query = target.search.slice(1);
const components = requiredComponents(method, query !== "", merged, hasBody);
for (const name of extraComponents) {
if (!components.includes(name) && name in merged) components.push(name);
}
const createdAt = created ?? Math.floor(Date.now() / 1000);
const rawParams =
"(" +
Expand Down
Loading
Loading