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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<svg viewBox="0 0 24 24"><path fill="#ffffff" d="M3 3h18v18H3z"/></svg>';

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");
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1034,8 +1035,6 @@ export function validateManifestSensitivity(manifest: Record<string, unknown>, 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
Expand Down Expand Up @@ -1248,7 +1247,7 @@ export function validateManifestIcon(manifest: Record<string, unknown>, 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);
}
}
Expand Down
36 changes: 33 additions & 3 deletions reference-implementation/server/routes/as-consent-ui-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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?: {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading