Skip to content
Open
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
51 changes: 17 additions & 34 deletions rest/nodejs/src/api/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

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
Expand All @@ -23,11 +22,18 @@ import { publicJwk } from "../utils/webhook_signer";
// reference (samples#153), which serves `public, max-age=3600`.
const PROFILE_CACHE_CONTROL = "public, max-age=3600";

type DiscoveryCapability = {
export type DiscoveryCapability = {
version: string;
spec: string;
schema: string;
extends?: string;
// capability.json $defs/base: extends is oneOf [reverse_domain_name,
// array<reverse_domain_name> (minItems 1)] at both the 2026-04-08 pin
// this server declares and at 2026-08-25 -- "Use array for multi-parent
// extensions." Node's own `discount` entry only has one parent today, so
// this was a type-safety gap, not a live bug; it becomes load-bearing the
// moment a second-parent extension (e.g. a future cart capability) is
// added.
extends?: string | string[];
};

type DiscoveryServiceBinding = {
Expand All @@ -53,7 +59,6 @@ type UcpDiscoveryMetadata = {
services: Record<string, DiscoveryServiceBinding[]>;
capabilities: Record<string, DiscoveryCapability[]>;
payment_handlers: Record<string, DiscoveryPaymentHandler[]>;
keys: Jwk[];
};

/**
Expand Down Expand Up @@ -126,16 +131,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.
// signatures.md, Key Discovery). source/discovery/profile_schema.json
// $defs/base at the 2026-04-08 pin this server declares (config.ts
// UCP_VERSION) requires `ucp` and separately declares `signing_keys` as
// a top-level sibling of `ucp` -- that schema defines no `keys` field
// anywhere, nested or otherwise, so publish signing_keys[] only. ucp#566
// renames this field to a top-level keys[] for 2026-08-25 and later;
// when UCP_VERSION moves to that pin, this field name must move with it
// in the same change, together with signature.ts's extractKeys().
const webhookJwk = publicJwk();

const ucp = {
version: this.ucpVersion,
keys: [webhookJwk],
services: {
"dev.ucp.shopping": [
{
Expand All @@ -162,30 +169,6 @@ export class DiscoveryService {
schema: `https://ucp.dev/${this.ucpVersion}/schemas/shopping/order.json`,
},
],
"dev.ucp.shopping.refund": [
{
version: this.ucpVersion,
spec: `https://ucp.dev/${this.ucpVersion}/specification/shopping/refund`,
schema: `https://ucp.dev/${this.ucpVersion}/schemas/shopping/refund.json`,
extends: "dev.ucp.shopping.order",
},
],
"dev.ucp.shopping.return": [
{
version: this.ucpVersion,
spec: `https://ucp.dev/${this.ucpVersion}/specification/shopping/return`,
schema: `https://ucp.dev/${this.ucpVersion}/schemas/shopping/return.json`,
extends: "dev.ucp.shopping.order",
},
],
"dev.ucp.shopping.dispute": [
{
version: this.ucpVersion,
spec: `https://ucp.dev/${this.ucpVersion}/specification/shopping/dispute`,
schema: `https://ucp.dev/${this.ucpVersion}/schemas/shopping/dispute.json`,
extends: "dev.ucp.shopping.order",
},
],
"dev.ucp.shopping.discount": [
{
version: this.ucpVersion,
Expand Down
23 changes: 16 additions & 7 deletions rest/nodejs/src/utils/signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,20 +643,29 @@ export async function assertProfileUrlAllowed(
}
}

// Pulls the signing keys out of a profile document. keys[] is the canonical
// RFC 7517 JWK Set field per ucp#566, which removed the earlier
// signing_keys[]; this reference verifier reads only keys[].
// Pulls the signing keys out of a profile document. At the UCP version this
// server declares (2026-04-08, config.ts UCP_VERSION),
// 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. `ucp` is required on every real profile
// document, so a reader that looks inside `ucp` whenever it is present can
// never see a top-level sibling field on any real document; this reads the
// top level directly instead.
//
// ucp#566 (merged upstream) renames this field to a top-level keys[] for
// 2026-08-25 and later. When this server's UCP_VERSION moves to that pin,
// this field name must move with it, in the same change, together with
// discovery.ts's publication side.
export function extractKeys(document: unknown): Jwk[] {
if (typeof document !== "object" || document === null) return [];
const doc = document as Record<string, unknown>;
const ucp = "ucp" in doc ? doc["ucp"] : doc;
if (typeof ucp !== "object" || ucp === null || Array.isArray(ucp)) return [];
const value = (ucp as Record<string, unknown>)["keys"];
const value = doc["signing_keys"];
return Array.isArray(value) && value.length ? (value as Jwk[]) : [];
}

// Fetches and caches a signer's published signing keys from its UCP profile
// (the keys[] of the document behind the UCP-Agent profile URL).
// (the top-level signing_keys[] of the document behind the UCP-Agent
// profile URL).
export async function fetchSigningKeys(
profileUrl: string,
options: { allowInsecure?: boolean } = {}
Expand Down
79 changes: 75 additions & 4 deletions rest/nodejs/test/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,18 @@ import { test } from "node:test";

import { Hono } from "hono";

import { DiscoveryService } from "../src/api/discovery";
import {
DiscoveryService,
type DiscoveryCapability,
} from "../src/api/discovery";

type DiscoveryResponse = {
ucp: {
services: Record<string, Array<{ endpoint: string; transport: string }>>;
capabilities: Record<string, Array<{ version: string }>>;
keys?: unknown;
};
signing_keys?: unknown;
};

test("merchant profile uses schema-compliant discovery registries", async () => {
Expand All @@ -47,11 +52,8 @@ test("merchant profile uses schema-compliant discovery registries", async () =>
"dev.ucp.shopping.buyer_consent",
"dev.ucp.shopping.checkout",
"dev.ucp.shopping.discount",
"dev.ucp.shopping.dispute",
"dev.ucp.shopping.fulfillment",
"dev.ucp.shopping.order",
"dev.ucp.shopping.refund",
"dev.ucp.shopping.return",
]);

for (const [name, declarations] of Object.entries(body.ucp.capabilities)) {
Expand All @@ -61,6 +63,75 @@ test("merchant profile uses schema-compliant discovery registries", async () =>
}
});

test("merchant profile declares no capability without a schema at its own version", async () => {
// dev.ucp.shopping.refund/.return/.dispute have no schema file under
// source/schemas/shopping/ at 2026-04-08 (this server's declared version)
// or at 2026-08-25, no route implements them, and neither reference
// python samples server (upstream or our 08-25 golden reference) declares
// them. A capability catalog entry with nothing behind it is not a real
// capability.
const app = new Hono();
const discoveryService = new DiscoveryService();
app.get("/.well-known/ucp", discoveryService.getMerchantProfile);

const response = await app.request("/.well-known/ucp");
const body = (await response.json()) as DiscoveryResponse;

for (const nonSpecCapability of [
"dev.ucp.shopping.refund",
"dev.ucp.shopping.return",
"dev.ucp.shopping.dispute",
]) {
assert.equal(
body.ucp.capabilities[nonSpecCapability],
undefined,
`${nonSpecCapability} has no schema at any pin and must not be declared`
);
}
});

test("merchant profile publishes signing_keys[] at the top level, not ucp.keys[]", async () => {
// source/discovery/profile_schema.json $defs/base at the 2026-04-08 pin
// this server declares (config.ts UCP_VERSION) requires `ucp` and
// separately declares `signing_keys` as a top-level sibling of `ucp`.
// That schema defines no `keys` field, nested or otherwise.
const app = new Hono();
const discoveryService = new DiscoveryService();
app.get("/.well-known/ucp", discoveryService.getMerchantProfile);

const response = await app.request("/.well-known/ucp");
const body = (await response.json()) as DiscoveryResponse;

assert.ok(
Array.isArray(body.signing_keys) && body.signing_keys.length > 0,
"signing_keys[] must be published at the top level"
);
assert.equal(
body.ucp.keys,
undefined,
"ucp.keys[] has no basis in the 2026-04-08 schema and must not be published"
);
});

test("DiscoveryCapability.extends accepts a multi-parent array", () => {
// capability.json $defs/base at both the 2026-04-08 pin (this server's
// declared version) and 2026-08-25: extends is oneOf [reverse_domain_name,
// array<reverse_domain_name> minItems 1] -- "Use array for multi-parent
// extensions." This is a compile-time check: if extends is typed as a
// bare string, this literal fails tsc; the assertion below is the runtime
// half so the test still reports as a test, not just a build step.
const multiParent: DiscoveryCapability = {
version: "2026-04-08",
spec: "https://ucp.dev/2026-04-08/specification/shopping/discount",
schema: "https://ucp.dev/2026-04-08/schemas/shopping/discount.json",
extends: ["dev.ucp.shopping.checkout", "dev.ucp.shopping.cart"],
};
assert.deepEqual(multiParent.extends, [
"dev.ucp.shopping.checkout",
"dev.ucp.shopping.cart",
]);
});

test("merchant profile sends a public, cacheable Cache-Control header", async () => {
// overview.md (Discovery) MUST: "Profile responses MUST include a
// Cache-Control header with `public` and `max-age` of at least 60 seconds.
Expand Down
6 changes: 5 additions & 1 deletion rest/nodejs/test/signature.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,12 @@ before(async () => {
const edJwk = jwkFromPublicKey(edKeys.publicKey, ED_KID);
// A deliberately unsupported (RSA) JWK to exercise algorithm_unsupported.
const rsaJwk = { kid: "rsa-key", kty: "RSA", n: "abc", e: "AQAB" };
// source/discovery/profile_schema.json $defs/base at the 2026-04-08 pin
// this server declares (config.ts UCP_VERSION): signing_keys is a
// top-level sibling of `ucp`, not a field nested inside it.
const good = JSON.stringify({
ucp: { keys: [agentJwk, edJwk, rsaJwk] },
ucp: {},
signing_keys: [agentJwk, edJwk, rsaJwk],
});
const keyless = JSON.stringify({ ucp: {} });

Expand Down
68 changes: 56 additions & 12 deletions rest/nodejs/test/signing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -922,31 +922,42 @@ function profileUrl(path: string): string {
return `http://127.0.0.1:${profilePort}${path}`;
}

test("keys[] is read from the ucp envelope", async () => {
test("signing_keys[] is read from the top level, a sibling of ucp", async () => {
// source/discovery/profile_schema.json $defs/base at the 2026-04-08 pin
// (this server's own declared UCP_VERSION, config.ts) requires `ucp` and
// separately declares `signing_keys` as a sibling top-level property --
// not a field nested inside `ucp`. A real profile document always carries
// `ucp` (schema-required), so this is the realistic shape.
clearKeyCache();
profileResponses["/envelope.json"] = {
status: 200,
body: JSON.stringify({ ucp: { keys: [{ kid: "a" }] } }),
body: JSON.stringify({
ucp: { version: "2026-04-08" },
signing_keys: [{ kid: "a" }],
}),
};
const keys = await fetchSigningKeys(profileUrl("/envelope.json"), {
allowInsecure: true,
});
assert.equal(keys[0]?.kid, "a");
});

test("a top-level keys[] array (no ucp wrapper) is read", async () => {
test("a top-level signing_keys[] array (no ucp wrapper) is read", async () => {
clearKeyCache();
profileResponses["/top.json"] = {
status: 200,
body: JSON.stringify({ keys: [{ kid: "b" }] }),
body: JSON.stringify({ signing_keys: [{ kid: "b" }] }),
};
const keys = await fetchSigningKeys(profileUrl("/top.json"), {
allowInsecure: true,
});
assert.equal(keys[0]?.kid, "b");
});

test("a profile with only the removed signing_keys[] is profile_malformed", async () => {
test("signing_keys[] nested under ucp has no basis in the schema and is not read", async () => {
// The 2026-04-08 profile schema places signing_keys as a sibling of ucp,
// never inside it. Nesting it under ucp is not a legacy shape to tolerate
// -- it was never a valid location at any pin.
clearKeyCache();
profileResponses["/legacy.json"] = {
status: 200,
Expand All @@ -958,6 +969,22 @@ test("a profile with only the removed signing_keys[] is profile_malformed", asyn
);
});

test("a top-level keys[] (the 2026-08-25 field name) is not yet read at the declared 2026-04-08 version", async () => {
// ucp#566 renames signing_keys[] to a top-level keys[] for 2026-08-25 and
// later. This server declares 2026-04-08 (config.ts UCP_VERSION); reading
// keys[] now would verify against a field name this server has not earned
// by bumping its own declared version yet.
clearKeyCache();
profileResponses["/future.json"] = {
status: 200,
body: JSON.stringify({ ucp: {}, keys: [{ kid: "future" }] }),
};
await assertSignatureErrorAsync(
() => fetchSigningKeys(profileUrl("/future.json"), { allowInsecure: true }),
"profile_malformed"
);
});

test("a 3xx response is treated as unreachable (no redirects allowed)", async () => {
clearKeyCache();
profileResponses["/redirect.json"] = {
Expand Down Expand Up @@ -999,7 +1026,7 @@ test("a second fetch within the TTL is served from the cache", async () => {
clearKeyCache();
profileResponses["/cached.json"] = {
status: 200,
body: JSON.stringify({ ucp: { keys: [{ kid: "c" }] } }),
body: JSON.stringify({ ucp: {}, signing_keys: [{ kid: "c" }] }),
};
await fetchSigningKeys(profileUrl("/cached.json"), { allowInsecure: true });
const hitsAfterFirst = profileHits.filter((p) => p === "/cached.json").length;
Expand All @@ -1011,21 +1038,38 @@ test("a second fetch within the TTL is served from the cache", async () => {
assert.equal(hitsAfterSecond, 1);
});

/* extractKeys reads keys[] (canonical per ucp#566) and tolerates junk. */
/* extractKeys reads the top-level signing_keys[] this server's declared
* 2026-04-08 version defines (a sibling of ucp, never nested inside it),
* and tolerates junk. */

test("a non-object profile yields no keys, not an error", () => {
assert.deepEqual(extractKeys(["not", "a", "dict"]), []);
});

test("keys[] under the ucp envelope is the canonical source", () => {
assert.deepEqual(extractKeys({ ucp: { keys: [{ kid: "k" }] } }), [
{ kid: "k" },
]);
test("top-level signing_keys[], a sibling of ucp, is the canonical source", () => {
assert.deepEqual(
extractKeys({
ucp: { version: "2026-04-08" },
signing_keys: [{ kid: "k" }],
}),
[{ kid: "k" }]
);
});

test("the removed signing_keys[] field is not read (ucp#566)", () => {
test("signing_keys[] nested under ucp is not read -- it is a top-level field only", () => {
assert.deepEqual(
extractKeys({ ucp: { signing_keys: [{ kid: "old" }] } }),
[]
);
});

test("keys[] nested under ucp is not read -- no schema at any pin nests keys under ucp", () => {
assert.deepEqual(extractKeys({ ucp: { keys: [{ kid: "k" }] } }), []);
});

test("a top-level keys[] (2026-08-25 name) is not read while this server declares 2026-04-08", () => {
assert.deepEqual(
extractKeys({ ucp: { version: "2026-04-08" }, keys: [{ kid: "future" }] }),
[]
);
});
Loading
Loading