diff --git a/rest/nodejs/src/api/discovery.ts b/rest/nodejs/src/api/discovery.ts index 1518fdd..c6a442a 100644 --- a/rest/nodejs/src/api/discovery.ts +++ b/rest/nodejs/src/api/discovery.ts @@ -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 @@ -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 (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 = { @@ -53,7 +59,6 @@ type UcpDiscoveryMetadata = { services: Record; capabilities: Record; payment_handlers: Record; - keys: Jwk[]; }; /** @@ -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": [ { @@ -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, diff --git a/rest/nodejs/src/utils/signature.ts b/rest/nodejs/src/utils/signature.ts index 6868afe..3dff2ff 100644 --- a/rest/nodejs/src/utils/signature.ts +++ b/rest/nodejs/src/utils/signature.ts @@ -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; - const ucp = "ucp" in doc ? doc["ucp"] : doc; - if (typeof ucp !== "object" || ucp === null || Array.isArray(ucp)) return []; - const value = (ucp as Record)["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 } = {} diff --git a/rest/nodejs/test/discovery.test.ts b/rest/nodejs/test/discovery.test.ts index 4a4fae1..99aade1 100644 --- a/rest/nodejs/test/discovery.test.ts +++ b/rest/nodejs/test/discovery.test.ts @@ -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>; capabilities: Record>; + keys?: unknown; }; + signing_keys?: unknown; }; test("merchant profile uses schema-compliant discovery registries", async () => { @@ -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)) { @@ -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 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. diff --git a/rest/nodejs/test/signature.test.ts b/rest/nodejs/test/signature.test.ts index a5e10b0..ada0249 100644 --- a/rest/nodejs/test/signature.test.ts +++ b/rest/nodejs/test/signature.test.ts @@ -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: {} }); diff --git a/rest/nodejs/test/signing.test.ts b/rest/nodejs/test/signing.test.ts index 80e17f8..dd102c7 100644 --- a/rest/nodejs/test/signing.test.ts +++ b/rest/nodejs/test/signing.test.ts @@ -922,11 +922,19 @@ 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, @@ -934,11 +942,11 @@ test("keys[] is read from the ucp envelope", async () => { 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, @@ -946,7 +954,10 @@ test("a top-level keys[] array (no ucp wrapper) is read", async () => { 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, @@ -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"] = { @@ -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; @@ -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" }] }), + [] + ); +}); diff --git a/rest/nodejs/test/webhook_signing.test.ts b/rest/nodejs/test/webhook_signing.test.ts index 2148fff..4cee61d 100644 --- a/rest/nodejs/test/webhook_signing.test.ts +++ b/rest/nodejs/test/webhook_signing.test.ts @@ -530,9 +530,12 @@ test("an unsupported key type is rejected with a clear message", async () => { test("the profile publishes the webhook signing key", async () => { // signatures.md, Key Discovery: public keys live in the profile's - // signing_keys[] (a top-level sibling of `ucp` per the discovery profile - // schema). It is also mirrored into ucp.keys[], the JWK Set this server's - // own verifier resolves. + // signing_keys[], a top-level sibling of `ucp` per the discovery profile + // schema at this server's declared version (source/discovery/profile_schema.json + // $defs/base at the 2026-04-08 pin -- config.ts UCP_VERSION). That schema + // defines no `keys` field anywhere, nested or otherwise; this server must + // not publish one, so a peer's strict 2026-04-08 verifier sees exactly the + // fields the schema promises and nothing it does not. const profile = await fetchProfile(); const jwk = publicJwk(); const signingKeys = profile["signing_keys"] as Jwk[]; @@ -542,8 +545,9 @@ test("the profile publishes the webhook signing key", async () => { "signing_keys[] must carry the webhook public JWK" ); const ucp = profile["ucp"] as { keys?: Jwk[] }; - assert.ok( - Array.isArray(ucp.keys) && ucp.keys.some((k) => isDeepStrictEqual(k, jwk)), - "ucp.keys[] must mirror the webhook public JWK" + assert.equal( + ucp.keys, + undefined, + "ucp.keys[] has no basis in the 2026-04-08 schema and must not be published" ); });