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..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 @@ -250,3 +250,87 @@ 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"); }); + +const whiteGlyph = ''; + +function buildModelFor(connectorManifest) { + return buildHostedMcpConsentChallengeModel( + "challenge", + "owner", + { + canonicalConnectorKey: () => "spotify", + encodeHostedMcpSelection: () => "source", + encodeHostedMcpStreamSelection: ({ streamName }) => streamName, + getConnectorManifest: async () => connectorManifest, + 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, + ); +} + +function renderModel(consentModel) { + return render(createElement(ConsentScreen, { + model: consentModel, acceptAction: async () => "", rejectAction: async () => "", + })); +} + +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..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,8 +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})$/; - // 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 @@ -1248,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 ff186f4a6..a28798dad 100644 --- a/reference-implementation/server/routes/as-consent-ui-helpers.ts +++ b/reference-implementation/server/routes/as-consent-ui-helpers.ts @@ -27,6 +27,7 @@ import { resolveClientTrust, } from "../client-trust-registry.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, @@ -92,8 +93,24 @@ export interface ConsentPickerCapabilities { } export interface ConsentPickerManifest { + /** + * 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; + /** 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?: { @@ -761,7 +778,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 +870,17 @@ 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 icon = manifest?.icon; icons.set( row.connectorId, - icon?.svg ? { color: icon.color ?? null, kind: icon.kind ?? null, svg: icon.svg } : null + icon?.svg + ? { + backgroundColor: hexColorOrNull(manifest?.brand?.background_color), + color: icon.color ?? null, + kind: icon.kind ?? null, + svg: icon.svg, + } + : null ); } return {