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 {