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
27 changes: 15 additions & 12 deletions rest/nodejs/src/utils/signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { type Context, type MiddlewareHandler } from "hono";
import { type ContentfulStatusCode } from "hono/utils/http-status";

import { signatureConfig } from "./config";
import { UcpError, ucpErrorResponse } from "./ucp_error";

// RFC 9421 HTTP Message Signatures for UCP, using the default UCP profile.
// This is the Node twin of the Python server's ucp_signing.py: RFC 9421
Expand Down Expand Up @@ -708,18 +709,20 @@ export async function fetchSigningKeys(
}

function signatureErrorResponse(c: Context, exc: SignatureError): Response {
// The same wire shape as the Python server's error envelope, inside the
// { detail } wrapper this server already uses for its 4xx responses.
return c.json(
{
detail: {
status: "error",
errors: [
{ code: exc.code, message: exc.message, severity: "critical" },
],
},
},
exc.statusCode as ContentfulStatusCode
// ucpErrorResponse is what this server answers its other protocol-level
// rejections with, so the signature path uses it too. error_response.json
// requires a ucp object plus messages[], and message_error.json constrains
// severity to the four values in its enum. Unrecoverable is the right one
// here because no resource exists to act on when a request is turned away
// at the signature layer, which is how message_error.json defines it.
return ucpErrorResponse(
c,
new UcpError(
exc.message,
exc.code,
exc.statusCode as ContentfulStatusCode,
"unrecoverable"
)
);
}

Expand Down
58 changes: 55 additions & 3 deletions rest/nodejs/test/signature.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,23 @@ const AUTHORITY = "merchant.test";
const ORIGIN = `http://${AUTHORITY}`;

type ErrorEnvelope = {
detail: { status: string; errors: Array<{ code: string; message: string }> };
ucp?: { version?: string; status?: string };
messages?: Array<{
type?: string;
code?: string;
content?: string;
severity?: string;
}>;
detail?: unknown;
};

const ERROR_SEVERITIES = new Set([
"recoverable",
"requires_buyer_input",
"requires_buyer_review",
"unrecoverable",
]);

// A minimal app wired like src/index.ts (verifySignature ahead of validation)
// but without the pino middleware, following the lifecycle.test.ts convention.
function buildApp() {
Expand Down Expand Up @@ -222,10 +236,48 @@ async function assertError(
): Promise<void> {
assert.equal(response.status, status, await response.clone().text());
const envelope = (await response.json()) as ErrorEnvelope;
assert.equal(envelope.detail.status, "error");
assert.equal(envelope.detail.errors[0]?.code, code);
assert.equal(envelope.ucp?.status, "error");
assert.equal(envelope.messages?.[0]?.code, code);
}

test("enforced: a rejection body is the UCP error envelope", async () => {
// error_response.json requires ucp and messages[]; each message is a
// message_error.json with type, code, content and an in-enum severity. The
// rest of this server already answers in that shape (see
// error_envelope.test.ts), so the signature path should not differ.
enforce();
const body = checkoutBody();
const response = await postCheckout(
{
"UCP-Agent": `profile="${profileUrl}"`,
"Idempotency-Key": "envelope-1",
"Request-Id": "envelope-1",
"Content-Type": "application/json",
},
body
);

assert.equal(response.status, 401, await response.clone().text());
const envelope = (await response.json()) as ErrorEnvelope;

assert.equal(envelope.detail, undefined, "flat detail shape must be gone");
assert.equal(envelope.ucp?.status, "error");
assert.ok(envelope.ucp?.version, "ucp.version must be present");
assert.ok(
Array.isArray(envelope.messages) && envelope.messages.length === 1,
"messages[] must carry exactly the failure"
);

const message = envelope.messages![0];
assert.equal(message.type, "error");
assert.equal(message.code, "signature_missing");
assert.ok(message.content, "content must explain the failure");
assert.ok(
ERROR_SEVERITIES.has(message.severity ?? ""),
`severity ${message.severity} is outside the message_error.json enum`
);
});

/* Permissive (default) mode: existing clients keep working. */

test("permissive: an unsigned request skips verification entirely", async () => {
Expand Down
Loading