From fe8bc706700856b2cfc61a8fae6be110cac6ece1 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 12:21:32 -0600 Subject: [PATCH 1/5] fix(consent): keep a connector's declared brand background on the consent mark The consent projection copied only color, kind and svg from the manifest icon and dropped brand.background_color, so a white glyph rendered on the default tile and was invisible. Carry the declared background through the server model and the console model; the shared mark renderer already paints it. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../consent-screen/consent-screen-model.ts | 2 + .../consent-screen-runtime.test.mjs | 38 +++++++++++++++++++ .../server/routes/as-consent-ui-helpers.ts | 23 +++++++++-- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/apps/console/src/components/consent-screen/consent-screen-model.ts b/apps/console/src/components/consent-screen/consent-screen-model.ts index a5ce11cb8..186feea34 100644 --- a/apps/console/src/components/consent-screen/consent-screen-model.ts +++ b/apps/console/src/components/consent-screen/consent-screen-model.ts @@ -23,6 +23,8 @@ export type ConsentTrustTier = "unverified" | "domain" | "verified"; /** Manifest-declared brand glyph, shaped for `ConnectorIcon`'s `icon` prop. */ export interface ConsentSourceIcon { + /** Declared brand background; without it a light glyph can sit on a light tile. */ + readonly backgroundColor?: string | null; readonly color?: string | null; readonly kind?: string | null; readonly svg?: string | null; diff --git a/apps/console/src/components/consent-screen/consent-screen-runtime.test.mjs b/apps/console/src/components/consent-screen/consent-screen-runtime.test.mjs index a673eb807..451b92af4 100644 --- a/apps/console/src/components/consent-screen/consent-screen-runtime.test.mjs +++ b/apps/console/src/components/consent-screen/consent-screen-runtime.test.mjs @@ -250,3 +250,41 @@ test("older consent models without connector provenance keep the genuine fallbac assert.ok(view.container.querySelector(".pdpp-connector-icon .pdpp-monogram")); assert.equal(view.container.querySelector(".pdpp-connector-icon svg"), null, "do not infer a brand from a source ID or account name"); }); + +test("a white-glyph connector mark keeps its declared brand background on the consent screen", async () => { + const brandBackground = "#1DB954"; + const whiteGlyph = ''; + const whiteGlyphManifest = { + ...manifest, + brand: { background_color: brandBackground }, + icon: { kind: "inline_svg", svg: whiteGlyph }, + }; + const whiteGlyphModel = await buildHostedMcpConsentChallengeModel( + "challenge", + "owner", + { + canonicalConnectorKey: () => "spotify", + encodeHostedMcpSelection: () => "source", + encodeHostedMcpStreamSelection: ({ streamName }) => streamName, + getConnectorManifest: async () => whiteGlyphManifest, + hostedMcpSourceKey: () => "spotify:account", + isInternalConnectorId: () => false, + listActiveBindingsForGrant: async () => [{ connectorInstanceId: "account" }], + listRegisteredConnectorIds: async () => [manifest.connector_id], + listStreamsWithRecords: async () => [], + projectBindingForWire: () => ({ display_name: "Spotify - owner", connection_id: "account" }), + }, + {}, + null, + ); + const view = render(createElement(ConsentScreen, { + connectorIndex: {}, model: whiteGlyphModel, acceptAction: async () => "", rejectAction: async () => "", + })); + const tile = view.container.querySelector(".pdpp-connector-icon"); + assert.ok(tile?.querySelector("svg"), "the manifest glyph renders"); + assert.equal( + dom.window.getComputedStyle(tile).backgroundColor, + "rgb(29, 185, 84)", + "a white glyph needs the connector's declared brand background to be visible", + ); +}); diff --git a/reference-implementation/server/routes/as-consent-ui-helpers.ts b/reference-implementation/server/routes/as-consent-ui-helpers.ts index ff186f4a6..d7c1a2213 100644 --- a/reference-implementation/server/routes/as-consent-ui-helpers.ts +++ b/reference-implementation/server/routes/as-consent-ui-helpers.ts @@ -761,7 +761,13 @@ export interface HostedMcpConsentChallengeModel { /** Display provenance only; grant selection still uses the opaque values below. */ readonly connectorId: string; /** Manifest-declared brand glyph, passed straight to ConnectorIcon; null renders its Monogram. */ - readonly icon: { readonly color: string | null; readonly kind: string | null; readonly svg: string | null } | null; + readonly icon: { + /** The connector's declared brand background (`brand.background_color`); a light glyph needs it to be visible. */ + readonly backgroundColor: string | null; + readonly color: string | null; + readonly kind: string | null; + readonly svg: string | null; + } | null; readonly id: string; readonly name: string; readonly selectionValue: string; @@ -847,10 +853,21 @@ export async function buildHostedMcpConsentChallengeModel( continue; } const manifest = await caps.getConnectorManifest(row.connectorId); - const icon = (manifest as { icon?: { color?: string | null; kind?: string | null; svg?: string | null } | null })?.icon; + const declared = manifest as { + brand?: { background_color?: string | null } | null; + icon?: { color?: string | null; kind?: string | null; svg?: string | null } | null; + } | null; + const icon = declared?.icon; icons.set( row.connectorId, - icon?.svg ? { color: icon.color ?? null, kind: icon.kind ?? null, svg: icon.svg } : null + icon?.svg + ? { + backgroundColor: declared?.brand?.background_color ?? null, + color: icon.color ?? null, + kind: icon.kind ?? null, + svg: icon.svg, + } + : null ); } return { From c8d640f1bdc296f0721b2c612dd3ce6e8f1e35c1 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 16:49:17 -0600 Subject: [PATCH 2/5] test(consent): assert the white glyph renders and drop an ignored prop The white-glyph test only checked that some SVG existed, so a fallback mark would have passed the glyph check. It now reads the rendered path fill and requires the manifest's white. It also no longer passes connectorIndex, which ConsentScreen does not accept. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../consent-screen/consent-screen-runtime.test.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/console/src/components/consent-screen/consent-screen-runtime.test.mjs b/apps/console/src/components/consent-screen/consent-screen-runtime.test.mjs index 451b92af4..07ebaf1ba 100644 --- a/apps/console/src/components/consent-screen/consent-screen-runtime.test.mjs +++ b/apps/console/src/components/consent-screen/consent-screen-runtime.test.mjs @@ -278,10 +278,14 @@ test("a white-glyph connector mark keeps its declared brand background on the co null, ); const view = render(createElement(ConsentScreen, { - connectorIndex: {}, model: whiteGlyphModel, acceptAction: async () => "", rejectAction: async () => "", + model: whiteGlyphModel, acceptAction: async () => "", rejectAction: async () => "", })); const tile = view.container.querySelector(".pdpp-connector-icon"); - assert.ok(tile?.querySelector("svg"), "the manifest glyph renders"); + assert.equal( + tile?.querySelector("svg path")?.getAttribute("fill")?.toLowerCase(), + "#ffffff", + "the manifest's white glyph renders, not a fallback mark", + ); assert.equal( dom.window.getComputedStyle(tile).backgroundColor, "rgb(29, 185, 84)", From 78b045788bf82846ba1ed6ac53ac8d140ff8c533 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 23:11:33 -0600 Subject: [PATCH 3/5] refactor(consent): declare brand and icon on ConsentPickerManifest buildHostedMcpConsentChallengeModel read `brand.background_color` and `icon` through a cast to a second manifest shape, while getConnectorManifest promised a ConsentPickerManifest that declared neither field. A producer with an incompatible brand value therefore still compiled. Declare the optional readonly `brand` and `icon` fields on ConsentPickerManifest and read them directly, so the compiler checks both at the capability boundary. Runtime behavior is unchanged. A manifest whose `brand.background_color` is a number now fails to compile; with the cast it compiled. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../server/routes/as-consent-ui-helpers.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/reference-implementation/server/routes/as-consent-ui-helpers.ts b/reference-implementation/server/routes/as-consent-ui-helpers.ts index d7c1a2213..6ccc6b08d 100644 --- a/reference-implementation/server/routes/as-consent-ui-helpers.ts +++ b/reference-implementation/server/routes/as-consent-ui-helpers.ts @@ -92,8 +92,16 @@ export interface ConsentPickerCapabilities { } export interface ConsentPickerManifest { + /** Declared brand; `background_color` is the backdrop a light glyph needs to be visible. */ + readonly brand?: { readonly background_color?: string | null } | null; readonly connector_id?: string | null; readonly display_name?: string | null; + /** Manifest-declared glyph, already allowlist-checked by `validateManifestIcon` on the read that produced it. */ + readonly icon?: { + readonly color?: string | null; + readonly kind?: string | null; + readonly svg?: string | null; + } | null; readonly manifest_uri?: string | null; readonly name?: string | null; readonly source_declaration?: { @@ -853,16 +861,12 @@ export async function buildHostedMcpConsentChallengeModel( continue; } const manifest = await caps.getConnectorManifest(row.connectorId); - const declared = manifest as { - brand?: { background_color?: string | null } | null; - icon?: { color?: string | null; kind?: string | null; svg?: string | null } | null; - } | null; - const icon = declared?.icon; + const icon = manifest?.icon; icons.set( row.connectorId, icon?.svg ? { - backgroundColor: declared?.brand?.background_color ?? null, + backgroundColor: manifest?.brand?.background_color ?? null, color: icon.color ?? null, kind: icon.kind ?? null, svg: icon.svg, From dd899c5f2e9eac34110b378907ee03dab8fac45c Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 6 Nov 2026 06:46:44 -0600 Subject: [PATCH 4/5] fix(consent): accept only hex brand backgrounds on the consent mark brand.background_color reached the browser unchecked: validateManifestIcon covers icon, not brand. A value such as "transparent" painted no backdrop behind a white glyph, which hides the mark again. The consent model now passes the value through hexColorOrNull and falls back to the default tile for anything that is not #rgb or #rrggbb. Document that brand is an implementation extension and operator-trusted manifest data, and pin the fallback, short hex and no-glyph cases. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../consent-screen-runtime.test.mjs | 78 ++++++++++++++----- .../server/connector-manifest-validation.ts | 9 +++ .../server/routes/as-consent-ui-helpers.ts | 13 +++- 3 files changed, 80 insertions(+), 20 deletions(-) diff --git a/apps/console/src/components/consent-screen/consent-screen-runtime.test.mjs b/apps/console/src/components/consent-screen/consent-screen-runtime.test.mjs index 07ebaf1ba..74943f1d3 100644 --- a/apps/console/src/components/consent-screen/consent-screen-runtime.test.mjs +++ b/apps/console/src/components/consent-screen/consent-screen-runtime.test.mjs @@ -251,22 +251,17 @@ test("older consent models without connector provenance keep the genuine fallbac assert.equal(view.container.querySelector(".pdpp-connector-icon svg"), null, "do not infer a brand from a source ID or account name"); }); -test("a white-glyph connector mark keeps its declared brand background on the consent screen", async () => { - const brandBackground = "#1DB954"; - const whiteGlyph = ''; - const whiteGlyphManifest = { - ...manifest, - brand: { background_color: brandBackground }, - icon: { kind: "inline_svg", svg: whiteGlyph }, - }; - const whiteGlyphModel = await buildHostedMcpConsentChallengeModel( +const whiteGlyph = ''; + +function buildModelFor(connectorManifest) { + return buildHostedMcpConsentChallengeModel( "challenge", "owner", { canonicalConnectorKey: () => "spotify", encodeHostedMcpSelection: () => "source", encodeHostedMcpStreamSelection: ({ streamName }) => streamName, - getConnectorManifest: async () => whiteGlyphManifest, + getConnectorManifest: async () => connectorManifest, hostedMcpSourceKey: () => "spotify:account", isInternalConnectorId: () => false, listActiveBindingsForGrant: async () => [{ connectorInstanceId: "account" }], @@ -277,18 +272,65 @@ test("a white-glyph connector mark keeps its declared brand background on the co {}, null, ); - const view = render(createElement(ConsentScreen, { - model: whiteGlyphModel, acceptAction: async () => "", rejectAction: async () => "", +} + +function renderModel(consentModel) { + return render(createElement(ConsentScreen, { + model: consentModel, acceptAction: async () => "", rejectAction: async () => "", })); - const tile = view.container.querySelector(".pdpp-connector-icon"); - assert.equal( - tile?.querySelector("svg path")?.getAttribute("fill")?.toLowerCase(), - "#ffffff", - "the manifest's white glyph renders, not a fallback mark", - ); +} + +test("a white-glyph connector mark keeps its declared brand background on the consent screen", async () => { + const whiteGlyphModel = await buildModelFor({ + ...manifest, + brand: { background_color: "#1DB954" }, + icon: { kind: "inline_svg", svg: whiteGlyph }, + }); + const tile = renderModel(whiteGlyphModel).container.querySelector(".pdpp-connector-icon"); + assert.ok(tile?.querySelector("svg path"), "the manifest's glyph renders, not a fallback mark"); assert.equal( dom.window.getComputedStyle(tile).backgroundColor, "rgb(29, 185, 84)", "a white glyph needs the connector's declared brand background to be visible", ); }); + +// brand.background_color is not covered by validateManifestIcon, so the consent +// model must reject anything that is not a hex color before it reaches the tile. +for (const [label, value] of [ + ["a CSS keyword that paints nothing", "transparent"], + ["a color the glyph cannot contrast against", "white"], + ["a CSS function", "rgb(0 0 0 / 0)"], + ["a declaration break-out", "#fff; background-image: url(//x/y)"], + ["a number", 0xffffff], + ["an empty string", ""], +]) { + test(`an invalid brand background (${label}) falls back to the default tile`, async () => { + const invalidModel = await buildModelFor({ + ...manifest, + brand: { background_color: value }, + icon: { kind: "inline_svg", svg: whiteGlyph }, + }); + assert.equal(invalidModel.sources[0].icon.backgroundColor, null); + const tile = renderModel(invalidModel).container.querySelector(".pdpp-connector-icon"); + assert.ok(tile?.querySelector("svg path"), "the glyph still renders"); + assert.equal(tile.style.backgroundColor, "", "no inline background: the default tile shows"); + }); +} + +test("a short hex brand background is accepted", async () => { + const shortHexModel = await buildModelFor({ + ...manifest, + brand: { background_color: "#1db" }, + icon: { kind: "inline_svg", svg: whiteGlyph }, + }); + assert.equal(shortHexModel.sources[0].icon.backgroundColor, "#1db"); +}); + +test("a brand background without a manifest glyph is not applied to the resolved identity mark", async () => { + const noGlyphModel = await buildModelFor({ ...manifest, brand: { background_color: "#1DB954" }, icon: undefined }); + assert.equal(noGlyphModel.sources[0].icon, null, "the background only travels with a manifest glyph"); + const tile = renderModel(noGlyphModel).container.querySelector(".pdpp-connector-icon"); + assert.ok(tile?.querySelector("svg"), "the resolved identity mark still renders"); + assert.notEqual(tile.style.backgroundColor, "rgb(29, 185, 84)", "the mark keeps its own background, not the manifest's"); +}); diff --git a/reference-implementation/server/connector-manifest-validation.ts b/reference-implementation/server/connector-manifest-validation.ts index b2213ea93..bc958ccf5 100644 --- a/reference-implementation/server/connector-manifest-validation.ts +++ b/reference-implementation/server/connector-manifest-validation.ts @@ -1036,6 +1036,15 @@ export function validateManifestSensitivity(manifest: Record, c const MANIFEST_ICON_ALLOWED_KEYS = new Set(["kind", "svg", "color"]); const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; +/** + * Returns `value` when it is a `#rgb` / `#rrggbb` string, else `null`. For + * manifest colors that reach a browser style but are not covered by + * `validateManifestIcon` (for example `brand.background_color`). + */ +export function hexColorOrNull(value: unknown): string | null { + return typeof value === "string" && HEX_COLOR_RE.test(value) ? value : null; +} + // A brand glyph is shapes, not behavior: this is the complete element and // attribute vocabulary an icon needs. Everything else — script, foreignObject, // iframe, use, image, animate/animateTransform/set, style, a, and every diff --git a/reference-implementation/server/routes/as-consent-ui-helpers.ts b/reference-implementation/server/routes/as-consent-ui-helpers.ts index 6ccc6b08d..7b53e996d 100644 --- a/reference-implementation/server/routes/as-consent-ui-helpers.ts +++ b/reference-implementation/server/routes/as-consent-ui-helpers.ts @@ -26,6 +26,7 @@ import { isLogoFetchAllowed, resolveClientTrust, } from "../client-trust-registry.ts"; +import { hexColorOrNull } from "../connector-manifest-validation.ts"; import { type FetchClientLogoOptions, fetchAndCacheClientLogo } from "../client-logo-cache.ts"; import { type StreamScopeCapability, @@ -92,7 +93,15 @@ export interface ConsentPickerCapabilities { } export interface ConsentPickerManifest { - /** Declared brand; `background_color` is the backdrop a light glyph needs to be visible. */ + /** + * Declared brand; `background_color` is the backdrop a light glyph needs to be visible. + * Implementation extension: `spec-collection-profile.md` does not define `brand`. + * It is operator-trusted manifest data, not connector-asserted identity: manifests + * come from the operator's own install, and the consent screen shows no trust tier + * for a connector. If manifests can ever arrive from an untrusted source, gate this + * on provenance first. The value is checked with `hexColorOrNull` before it leaves + * the server, because `validateManifestIcon` covers `icon` only. + */ readonly brand?: { readonly background_color?: string | null } | null; readonly connector_id?: string | null; readonly display_name?: string | null; @@ -866,7 +875,7 @@ export async function buildHostedMcpConsentChallengeModel( row.connectorId, icon?.svg ? { - backgroundColor: manifest?.brand?.background_color ?? null, + backgroundColor: hexColorOrNull(manifest?.brand?.background_color), color: icon.color ?? null, kind: icon.kind ?? null, svg: icon.svg, From 89240c132193668d26bfab9299fd4a23fdb72b08 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 6 Nov 2026 07:15:44 -0600 Subject: [PATCH 5/5] refactor(consent): share one hex color check with the console hexColorOrNull and its regex lived in the manifest validator while the console kept its own 6-digit regex for the same job. The check now lives in @pdpp/brand-react (hex-color.ts) and both the server and the console import it, so a color accepted by one is accepted by the other. The manifest icon.color check uses it too; behavior is unchanged. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../server/connector-manifest-validation.ts | 14 ++------------ .../server/routes/as-consent-ui-helpers.ts | 2 +- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/reference-implementation/server/connector-manifest-validation.ts b/reference-implementation/server/connector-manifest-validation.ts index bc958ccf5..e455773b3 100644 --- a/reference-implementation/server/connector-manifest-validation.ts +++ b/reference-implementation/server/connector-manifest-validation.ts @@ -12,6 +12,7 @@ import { collectionProfileDeclarationError } from "@pdpp/connector-protocol"; import { tuningEnvironmentKeyOwner } from "../runtime/connector-child-environment.ts"; +import { hexColorOrNull } from "../vendor/brand-react/src/hex-color.ts"; import { normalizeStaticSecretCredentialCapture, StaticSecretCredentialCaptureError, @@ -1034,17 +1035,6 @@ export function validateManifestSensitivity(manifest: Record, c // --------------------------------------------------------------------------- const MANIFEST_ICON_ALLOWED_KEYS = new Set(["kind", "svg", "color"]); -const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; - -/** - * Returns `value` when it is a `#rgb` / `#rrggbb` string, else `null`. For - * manifest colors that reach a browser style but are not covered by - * `validateManifestIcon` (for example `brand.background_color`). - */ -export function hexColorOrNull(value: unknown): string | null { - return typeof value === "string" && HEX_COLOR_RE.test(value) ? value : null; -} - // A brand glyph is shapes, not behavior: this is the complete element and // attribute vocabulary an icon needs. Everything else — script, foreignObject, // iframe, use, image, animate/animateTransform/set, style, a, and every @@ -1257,7 +1247,7 @@ export function validateManifestIcon(manifest: Record, code: st } const svg = iconObj.svg.trim(); assertSvgIsAllowlisted(svg, code); - if (iconObj.color !== undefined && !(isNonEmptyString(iconObj.color) && HEX_COLOR_RE.test(iconObj.color))) { + if (iconObj.color !== undefined && hexColorOrNull(iconObj.color) === null) { throw invalidConnectorManifest("icon.color must be a hex color (e.g. #1ED760) when declared", code); } } diff --git a/reference-implementation/server/routes/as-consent-ui-helpers.ts b/reference-implementation/server/routes/as-consent-ui-helpers.ts index 7b53e996d..a28798dad 100644 --- a/reference-implementation/server/routes/as-consent-ui-helpers.ts +++ b/reference-implementation/server/routes/as-consent-ui-helpers.ts @@ -26,8 +26,8 @@ import { isLogoFetchAllowed, resolveClientTrust, } from "../client-trust-registry.ts"; -import { hexColorOrNull } from "../connector-manifest-validation.ts"; import { type FetchClientLogoOptions, fetchAndCacheClientLogo } from "../client-logo-cache.ts"; +import { hexColorOrNull } from "../../vendor/brand-react/src/hex-color.ts"; import { type StreamScopeCapability, type StreamScopeSelection,