-
+
{sourceLabel(source)}
diff --git a/apps/console/src/app/(console)/explore/explore-canvas.tsx b/apps/console/src/app/(console)/explore/explore-canvas.tsx
index 8ffb1d40c..71a895df9 100644
--- a/apps/console/src/app/(console)/explore/explore-canvas.tsx
+++ b/apps/console/src/app/(console)/explore/explore-canvas.tsx
@@ -1588,7 +1588,7 @@ function StreamDoorLink({
});
return (
- See all '{query}' records in {door.displayName} →
+ See all '{query}' records in {door.displayName} →
);
}
@@ -1610,7 +1610,7 @@ function StreamSeeAllLink({
const totalLabel = typeof link.total === "number" ? ` - ${link.total.toLocaleString()} records` : "";
return (
-
+
{link.displayName} - {humanizeFieldLabel(link.stream)}
{totalLabel} - See all
@@ -1742,7 +1742,7 @@ function ConnectionFacets({
excluded={excluded.includes(c.connectionId)}
key={c.connectionId}
label={c.displayName}
- leading={ }
+ leading={ }
on={selected.includes(c.connectionId)}
onToggle={() => onToggle(c.connectionId)}
onToggleExclude={() => onToggleExclude(c.connectionId)}
@@ -1782,7 +1782,7 @@ function SourceFacetGroup({
-
+
{group.displayName}
{/* The source total is the SAME count KIND as each stream — loaded rows
@@ -2016,7 +2016,8 @@ function FeedRow({
`title` makes the full name recoverable on hover/long-press. */}
@@ -2168,11 +2169,14 @@ function BurstRow({
{loaded.toLocaleString()} {" "}
-
+ {rep?.connectorId ? (
+
+ ) : null}
{streamLabel}
{" "}in view
diff --git a/apps/console/src/app/(console)/explore/page.tsx b/apps/console/src/app/(console)/explore/page.tsx
index edffd5f43..d45a93014 100644
--- a/apps/console/src/app/(console)/explore/page.tsx
+++ b/apps/console/src/app/(console)/explore/page.tsx
@@ -40,6 +40,7 @@ import { dashboardRoutes } from "@pdpp/operator-ui/components/views/routes";
import { assembleExplorerData } from "@pdpp/operator-ui/explore/explore-data-assembler";
import { RecordroomShellWithPalette } from "@/app/(console)/components/recordroom-shell-with-palette.tsx";
import { ServerUnreachable } from "../components/server-unreachable.tsx";
+import { buildConnectorIconMap } from "../lib/connector-icon-map.ts";
import { liveDashboardDataSource } from "../lib/data-source.ts";
import { getRsInternalUrl, ReferenceServerUnreachableError } from "../lib/owner-token.ts";
import { listConnectorManifests } from "../lib/rs-client.ts";
@@ -128,12 +129,8 @@ export default async function RecordsExplorerPage({
try {
const data = await assembleExplorerData(params, liveDashboardDataSource, getRsInternalUrl());
- const connectorIcons = Object.fromEntries(
- (await listConnectorManifests().catch(() => [])).flatMap((manifest) => [
- [manifest.connector_id, manifest.icon] as const,
- ...(manifest.connector_key ? ([[manifest.connector_key, manifest.icon]] as const) : []),
- ])
- );
+ const manifests = await listConnectorManifests().catch(() => []);
+ const connectorIcons = await buildConnectorIconMap(manifests, []);
const order = data.supportsTimelineDirection && requestedOrder === "oldest" ? "oldest" : "newest";
// Relationships for the inspected record come from declared metadata via the
// SAME `records/lib/relationships.ts` helpers the records detail page uses —
diff --git a/apps/console/src/app/(console)/grants/approvals/[approvalId]/approval-review.tsx b/apps/console/src/app/(console)/grants/approvals/[approvalId]/approval-review.tsx
index 678e257d6..3d04abd49 100644
--- a/apps/console/src/app/(console)/grants/approvals/[approvalId]/approval-review.tsx
+++ b/apps/console/src/app/(console)/grants/approvals/[approvalId]/approval-review.tsx
@@ -136,7 +136,8 @@ function SingleArtifactFacts({ artifact, connectorIcons }: { artifact: SingleCon
{artifact.source.kind === "connector" ? (
@@ -203,7 +204,8 @@ function BatchArtifactFacts({ artifact, connectorIcons }: { artifact: BatchConse
{source.source.kind === "connector" ? (
diff --git a/apps/console/src/app/(console)/grants/packages/[packageId]/page.tsx b/apps/console/src/app/(console)/grants/packages/[packageId]/page.tsx
index 94174bb6f..97125e114 100644
--- a/apps/console/src/app/(console)/grants/packages/[packageId]/page.tsx
+++ b/apps/console/src/app/(console)/grants/packages/[packageId]/page.tsx
@@ -295,7 +295,8 @@ function ChildRow({
{connectorId ? (
diff --git a/apps/console/src/app/(console)/grants/page.tsx b/apps/console/src/app/(console)/grants/page.tsx
index a8080894c..6b810c4a4 100644
--- a/apps/console/src/app/(console)/grants/page.tsx
+++ b/apps/console/src/app/(console)/grants/page.tsx
@@ -209,7 +209,7 @@ function GrantRow({
{connectorId ? (
-
+
) : null}
{grantRowLabel(grant)}
diff --git a/apps/console/src/app/(console)/grants/pending-approval-row.tsx b/apps/console/src/app/(console)/grants/pending-approval-row.tsx
index b45a3aa35..38fc49b89 100644
--- a/apps/console/src/app/(console)/grants/pending-approval-row.tsx
+++ b/apps/console/src/app/(console)/grants/pending-approval-row.tsx
@@ -39,7 +39,7 @@ export function PendingApprovalRow({
- {connectorId ?
: null}
+ {connectorId ?
: null}
{technicalClientCaption(approval.client_id) ?? "client —"}
{approval.grant_preview?.source ? ` · source ${formatSourceForDisplay(approval.grant_preview.source)}` : ""}
{previewStreams.length ? ` · streams ${previewStreams.join(", ")}` : ""}
diff --git a/apps/console/src/app/(console)/lib/connector-display-mark.cross-surface.test.ts b/apps/console/src/app/(console)/lib/connector-display-mark.cross-surface.test.ts
new file mode 100644
index 000000000..4353875ef
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/connector-display-mark.cross-surface.test.ts
@@ -0,0 +1,67 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * One connector, every surface. The real Amazon manifest names a white glyph
+ * file (`brand.icon`) and an orange `brand.background_color`. A white glyph
+ * with no backdrop is invisible on a light surface, so the setup pages, the
+ * summary pages and a console with no manifests on disk must paint the same
+ * backdrop.
+ */
+
+import assert from "node:assert/strict";
+import { mock, test } from "node:test";
+import React, { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+
+// The vendored renderer relies on the classic JSX runtime.
+(globalThis as { React?: typeof React }).React = React;
+
+// rs-client reaches `server-only`, which throws outside a server component.
+mock.module("server-only", { namedExports: {} });
+
+const AMAZON = "https://registry.pdpp.dev/connectors/amazon";
+const imports = Promise.all([
+ import("../components/connector-mark.tsx"),
+ import("../sources/lib/relationships.ts"),
+ import("./connector-icon-map.ts"),
+ import("./rs-client.ts"),
+]);
+
+function mark(
+ ConnectorMark: (typeof import("../components/connector-mark.tsx"))["ConnectorMark"],
+ icon: Parameters
[0]["icon"]
+): string {
+ return renderToStaticMarkup(createElement(ConnectorMark, { connectorId: AMAZON, icon, name: "Amazon" }));
+}
+
+test("setup, summary and manifest-less surfaces paint the same backdrop behind a white glyph", async () => {
+ const [{ ConnectorMark }, { findManifestForConnectorId }, { buildConnectorIconMap }, { listConnectorManifests }] =
+ await imports;
+ const manifests = await listConnectorManifests();
+ const manifest = findManifestForConnectorId(manifests, AMAZON);
+ assert.ok(manifest, "the installed connector package ships the Amazon manifest");
+ assert.equal(manifest.brand?.background_color, "#FF9900");
+ assert.equal(manifest.resolvedIconSource, "manifest", "the glyph came from brand.icon, not the bundled asset");
+
+ // Setup pages pass the manifest icon; summary pages read the icon map; a
+ // console with no manifests on disk passes nothing and uses the bundled mark.
+ const setup = mark(ConnectorMark, manifest.icon);
+ const summary = mark(ConnectorMark, (await buildConnectorIconMap(manifests, []))[AMAZON]);
+ const withoutManifests = mark(ConnectorMark, undefined);
+
+ assert.match(setup, /background-color:#FF9900/i);
+ assert.match(setup, /fill="#FFFFFF"/i, "the white glyph is kept, not forced to currentColor");
+ assert.equal(setup, summary);
+ assert.equal(setup, withoutManifests);
+});
+
+test("for every installed manifest the summary map and the setup mark are the same mark", async () => {
+ const [, , { buildConnectorIconMap }, { listConnectorManifests }] = await imports;
+ const manifests = await listConnectorManifests();
+ assert.ok(manifests.length > 0);
+ const icons = await buildConnectorIconMap(manifests, []);
+ for (const manifest of manifests) {
+ assert.deepEqual(icons[manifest.connector_id], manifest.icon ?? undefined, manifest.connector_id);
+ }
+});
diff --git a/apps/console/src/app/(console)/lib/connector-icon-corpus-checks.test.ts b/apps/console/src/app/(console)/lib/connector-icon-corpus-checks.test.ts
new file mode 100644
index 000000000..0356c97e1
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/connector-icon-corpus-checks.test.ts
@@ -0,0 +1,78 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import test from "node:test";
+import { assertIconCorpusConsistent } from "./connector-icon-corpus-checks.ts";
+import iconCorpus from "./connector-icons/sanitized-assets.json" with { type: "json" };
+
+const SVG = ' ';
+const FOO_BAR_COLLISION = /foo[-_]bar/u;
+const FOO = /foo/u;
+const BACKGROUND = /background/u;
+const SOURCE = /source/u;
+const OTHER_SVG = ' ';
+
+function entry(key: string, overrides: Record = {}) {
+ return {
+ background_color: "#112233",
+ connector_id: `https://registry.pdpp.dev/connectors/${key}`,
+ connector_key: key,
+ source_id: null as string | null,
+ svg: SVG,
+ ...overrides,
+ };
+}
+
+test("the checked-in corpus is consistent", () => {
+ assert.doesNotThrow(() => assertIconCorpusConsistent(iconCorpus.entries));
+});
+
+test("two entries whose identities differ only by _/- spelling are rejected", () => {
+ assert.throws(() => assertIconCorpusConsistent([entry("foo-bar"), entry("foo_bar")]), FOO_BAR_COLLISION);
+});
+
+test("a registry-prefixed id that spells another entry's key is rejected", () => {
+ const first = entry("foo");
+ const second = entry("other", { connector_id: "https://registry.pdpp.dev/connectors/foo" });
+ assert.throws(() => assertIconCorpusConsistent([first, second]), FOO);
+});
+
+test("an exact duplicate key is still rejected", () => {
+ assert.throws(() => assertIconCorpusConsistent([entry("foo"), entry("foo")]), FOO);
+});
+
+test("a background that is not a hex color is rejected", () => {
+ for (const bad of ["transparent", "red", "#12", "#fff;x:y"]) {
+ assert.throws(() => assertIconCorpusConsistent([entry("foo", { background_color: bad })]), BACKGROUND, bad);
+ }
+ assert.doesNotThrow(() => assertIconCorpusConsistent([entry("foo", { background_color: undefined })]));
+ assert.doesNotThrow(() => assertIconCorpusConsistent([entry("foo", { background_color: "#fff" })]));
+});
+
+test("a source id shared by entries with different marks is rejected", () => {
+ const source = "https://registry.pdpp.dev/sources/foo";
+ assert.throws(
+ () =>
+ assertIconCorpusConsistent([
+ entry("foo", { source_id: source }),
+ entry("foo-live", { source_id: source, svg: OTHER_SVG }),
+ ]),
+ SOURCE
+ );
+ assert.throws(
+ () =>
+ assertIconCorpusConsistent([
+ entry("foo", { source_id: source }),
+ entry("foo-live", { background_color: "#445566", source_id: source }),
+ ]),
+ SOURCE
+ );
+});
+
+test("a source id shared by entries with the same mark is accepted", () => {
+ const source = "https://registry.pdpp.dev/sources/foo";
+ assert.doesNotThrow(() =>
+ assertIconCorpusConsistent([entry("foo", { source_id: source }), entry("foo-live", { source_id: source })])
+ );
+});
diff --git a/apps/console/src/app/(console)/lib/connector-icon-corpus-checks.ts b/apps/console/src/app/(console)/lib/connector-icon-corpus-checks.ts
new file mode 100644
index 000000000..0e5b1cd31
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/connector-icon-corpus-checks.ts
@@ -0,0 +1,65 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import { hexColorOrNull } from "@pdpp/brand-react/hex-color";
+import { connectorIconIdentityKeys } from "./connector-icon-identity.ts";
+
+interface CorpusEntryLike {
+ readonly background_color?: string | null;
+ readonly connector_id: string;
+ readonly connector_key: string;
+ readonly source_id?: string | null;
+ readonly svg: string;
+}
+
+/**
+ * Fail when the pinned icon corpus could make a shipped mark depend on file
+ * order or carry a free-form style value. The runtime indexes marks by every
+ * accepted spelling of an identity, so the check runs on those spellings, not
+ * on the raw strings:
+ * - no two entries may claim one normalized identity (`foo_bar`/`foo-bar`,
+ * or a registry-prefixed id that spells another entry's key);
+ * - `background_color` is a hex color when present;
+ * - entries sharing a `source_id` carry the same svg and background, since
+ * the source lookup would otherwise resolve to no brand.
+ */
+export function assertIconCorpusConsistent(entries: readonly CorpusEntryLike[]): void {
+ const identityOwner = new Map();
+ const sourceMark = new Map();
+ for (const entry of entries) {
+ assertHexBackground(entry);
+ claimIdentities(entry, identityOwner);
+ if (entry.source_id) {
+ assertSameSourceMark(entry, entry.source_id, sourceMark);
+ }
+ }
+}
+
+function assertHexBackground(entry: CorpusEntryLike): void {
+ const background = entry.background_color;
+ if (background !== undefined && background !== null && hexColorOrNull(background) === null) {
+ throw new Error(`Icon ${entry.connector_key} has a background_color that is not a hex color.`);
+ }
+}
+
+function claimIdentities(entry: CorpusEntryLike, owners: Map): void {
+ for (const identity of [entry.connector_id, entry.connector_key]) {
+ for (const key of connectorIconIdentityKeys(identity)) {
+ const owner = owners.get(key);
+ if (owner !== undefined && owner !== entry) {
+ throw new Error(`Icons ${owner.connector_key} and ${entry.connector_key} claim the same identity "${key}".`);
+ }
+ owners.set(key, entry);
+ }
+ }
+}
+
+function assertSameSourceMark(entry: CorpusEntryLike, sourceId: string, first: Map): void {
+ const previous = first.get(sourceId);
+ if (previous && (previous.svg !== entry.svg || previous.background_color !== entry.background_color)) {
+ throw new Error(
+ `Icons ${previous.connector_key} and ${entry.connector_key} share source ${sourceId} with different marks.`
+ );
+ }
+ first.set(sourceId, previous ?? entry);
+}
diff --git a/apps/console/src/app/(console)/lib/connector-icon-identity.ts b/apps/console/src/app/(console)/lib/connector-icon-identity.ts
new file mode 100644
index 000000000..88b5a53dc
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/connector-icon-identity.ts
@@ -0,0 +1,27 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+const URI_SCHEME = /^[a-z][a-z\d+.-]*:/iu;
+const REGISTRY_PREFIX = "https://registry.pdpp.dev/connectors/";
+const TRAILING_SLASH = /\/$/u;
+
+/** Preserve the first-party registry/key aliases; unrelated URLs remain exact identities. */
+export function connectorIconIdentityKeys(connectorId: string): readonly string[] {
+ const value = connectorId.trim();
+ if (!value) {
+ return [];
+ }
+ const keys = new Set([value]);
+ let localKey: string | null = value;
+ if (value.startsWith(REGISTRY_PREFIX)) {
+ localKey = value.slice(REGISTRY_PREFIX.length).replace(TRAILING_SLASH, "");
+ } else if (URI_SCHEME.test(value)) {
+ localKey = null;
+ }
+ if (localKey !== null) {
+ const canonical = localKey.replaceAll("_", "-");
+ keys.add(canonical);
+ keys.add(canonical.replaceAll("-", "_"));
+ }
+ return [...keys];
+}
diff --git a/apps/console/src/app/(console)/lib/connector-icon-map.test.ts b/apps/console/src/app/(console)/lib/connector-icon-map.test.ts
new file mode 100644
index 000000000..08f17a595
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/connector-icon-map.test.ts
@@ -0,0 +1,199 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import test, { mock } from "node:test";
+import { buildConnectorIconMap, catalogEntryIcon } from "./connector-icon-map.ts";
+import iconCorpus from "./connector-icons/sanitized-assets.json" with { type: "json" };
+import { resolveConnectorIcon } from "./resolve-connector-icon.ts";
+import { resolveManifestDisplayMark } from "./resolve-manifest-display-mark.ts";
+
+const UNSAFE_ONLOAD = /onload/iu;
+const GITHUB_SVG = iconCorpus.entries.find((entry) => entry.connector_key === "github")?.svg;
+assert.ok(GITHUB_SVG);
+
+const INLINE_MANIFEST_MARK = {
+ color: "#123456",
+ kind: "inline_svg",
+ svg: GITHUB_SVG,
+} as const;
+const INLINE_TEMPLATE_MARK = {
+ color: "#654321",
+ kind: "inline_svg",
+ svg: GITHUB_SVG,
+} as const;
+
+test("projects pinned upstream marks by exact connector key and canonical ID", async () => {
+ const icons = await buildConnectorIconMap(
+ [],
+ [{ connector_id: "https://registry.pdpp.dev/connectors/github", connector_key: "github" }]
+ );
+
+ assert.ok(icons.github?.svg?.startsWith(" {
+ const icons = await buildConnectorIconMap([], []);
+ assert.deepEqual(icons.apple_photos, icons["apple-photos"]);
+});
+
+test("preserves the valid inline owner-template mark over the pinned asset", async () => {
+ const icons = await buildConnectorIconMap(
+ [],
+ [{ connector_id: "github", connector_key: "github", icon: INLINE_TEMPLATE_MARK }]
+ );
+
+ assert.deepEqual(icons.github, INLINE_TEMPLATE_MARK);
+});
+
+test("a template mark beats a manifest's fallback mark but not the manifest's own", async () => {
+ const bundled = await resolveManifestDisplayMark({ connector_id: "github", connector_key: "github" }, "/nonexistent");
+ assert.equal(bundled.source, "bundled");
+ const withFallback = await buildConnectorIconMap(
+ [{ connector_id: "github", connector_key: "github", icon: bundled.icon, resolvedIconSource: bundled.source }],
+ [{ connector_id: "github", connector_key: "github", icon: INLINE_TEMPLATE_MARK }]
+ );
+ assert.deepEqual(withFallback.github, INLINE_TEMPLATE_MARK);
+
+ const own = await buildConnectorIconMap(
+ [{ connector_id: "github", connector_key: "github", icon: INLINE_MANIFEST_MARK, resolvedIconSource: "manifest" }],
+ [{ connector_id: "github", connector_key: "github", icon: INLINE_TEMPLATE_MARK }]
+ );
+ assert.deepEqual(own.github, INLINE_MANIFEST_MARK);
+});
+
+test("a manifest and a template that disagree yield the same mark on every surface", async () => {
+ const manifest = { connector_id: "github", connector_key: "github", icon: INLINE_MANIFEST_MARK } as const;
+ // Setup pages render the resolved manifest mark; summary and add pages read the map.
+ const setup = (await resolveManifestDisplayMark(manifest, "/nonexistent")).icon;
+ const withTemplate = await buildConnectorIconMap(
+ [manifest],
+ [{ connector_id: "github", connector_key: "github", icon: INLINE_TEMPLATE_MARK }]
+ );
+ const withoutTemplate = await buildConnectorIconMap([manifest], []);
+
+ assert.deepEqual(setup, withTemplate.github);
+ assert.deepEqual(setup, withoutTemplate.github);
+ assert.equal(withTemplate.github?.color, INLINE_MANIFEST_MARK.color);
+});
+
+test("preserves an explicitly supplied safe inline manifest mark over the pinned asset", async () => {
+ const icons = await buildConnectorIconMap(
+ [{ connector_id: "github", connector_key: "github", icon: INLINE_MANIFEST_MARK }],
+ []
+ );
+
+ assert.deepEqual(icons.github, INLINE_MANIFEST_MARK);
+});
+
+test("rejects unsafe inline SVG and keeps the pinned safe asset", async () => {
+ const icons = await buildConnectorIconMap(
+ [
+ {
+ connector_id: "github",
+ connector_key: "github",
+ icon: { kind: "inline_svg", svg: ' ' },
+ },
+ ],
+ []
+ );
+
+ assert.ok(icons.github?.svg?.startsWith(" {
+ const icons = await buildConnectorIconMap([], []);
+ for (const entry of iconCorpus.entries) {
+ const icon = icons[entry.connector_key];
+ assert.equal(icon?.svg, entry.svg, entry.connector_key);
+ assert.equal(icon?.backgroundColor, entry.background_color, entry.connector_key);
+ assert.equal(icons[entry.connector_id], icon, entry.connector_key);
+ }
+});
+
+test("an iconless owner template preserves a custom manifest mark", async () => {
+ const icons = await buildConnectorIconMap(
+ [{ connector_id: "github", connector_key: "github", icon: INLINE_MANIFEST_MARK }],
+ [{ connector_id: "github", connector_key: "github" }]
+ );
+ assert.deepEqual(icons.github, INLINE_MANIFEST_MARK);
+});
+
+test("a catalog entry keeps its own mark when the map does not index its key", async () => {
+ const icons = await buildConnectorIconMap([], []);
+ assert.deepEqual(
+ catalogEntryIcon({ connectorKey: "not-in-the-map", icon: INLINE_TEMPLATE_MARK }, icons),
+ INLINE_TEMPLATE_MARK
+ );
+ assert.equal(catalogEntryIcon({ connectorKey: "not-in-the-map" }, icons), null);
+ assert.deepEqual(catalogEntryIcon({ connectorKey: "github", icon: INLINE_TEMPLATE_MARK }, icons), icons.github);
+});
+
+test("a catalog entry's own unsafe mark is rejected when the map misses its key", async () => {
+ const icons = await buildConnectorIconMap([], []);
+ const unsafe = {
+ kind: "inline_svg",
+ svg: ' ',
+ };
+ assert.equal(catalogEntryIcon({ connectorKey: "not-in-the-map", icon: unsafe }, icons), null);
+ assert.equal(
+ catalogEntryIcon(
+ { connectorKey: "not-in-the-map", icon: { ...INLINE_TEMPLATE_MARK, backgroundColor: "red;x:y" } },
+ icons
+ )?.backgroundColor,
+ undefined
+ );
+});
+
+test("two manifests whose identities collide on _/- spelling get no mark from that layer", async () => {
+ const warn = mock.method(console, "warn", () => undefined);
+ try {
+ const mark = (svg: string) => ({ kind: "inline_svg", svg }) as const;
+ const icons = await buildConnectorIconMap(
+ [
+ {
+ connector_id: "foo-bar",
+ connector_key: "foo-bar",
+ icon: mark(' '),
+ },
+ {
+ connector_id: "foo_bar",
+ connector_key: "foo_bar",
+ icon: mark(' '),
+ },
+ ] as never,
+ []
+ );
+ assert.equal(icons["foo-bar"], undefined, "the later manifest must not silently take the earlier one's identity");
+ assert.equal(icons.foo_bar, undefined);
+ assert.ok(warn.mock.calls.length > 0, "the collision is reported");
+ } finally {
+ warn.mock.restore();
+ }
+});
+
+test("two manifests that resolve to the same mark under one identity are not a collision", async () => {
+ const warn = mock.method(console, "warn", () => undefined);
+ try {
+ const icons = await buildConnectorIconMap(
+ [
+ { connector_id: "foo-bar", connector_key: "foo-bar", icon: INLINE_MANIFEST_MARK },
+ { connector_id: "foo_bar", connector_key: "foo_bar", icon: INLINE_MANIFEST_MARK },
+ ] as never,
+ []
+ );
+ assert.deepEqual(icons["foo-bar"], INLINE_MANIFEST_MARK);
+ assert.equal(warn.mock.calls.length, 0);
+ } finally {
+ warn.mock.restore();
+ }
+});
+
+test("the map's bundled layer is the resolver's bundled corpus, identity for identity", async () => {
+ const icons = await buildConnectorIconMap([], []);
+ for (const entry of iconCorpus.entries) {
+ assert.deepEqual(icons[entry.connector_key], resolveConnectorIcon(entry.connector_id), entry.connector_key);
+ }
+});
diff --git a/apps/console/src/app/(console)/lib/connector-icon-map.ts b/apps/console/src/app/(console)/lib/connector-icon-map.ts
new file mode 100644
index 000000000..03143f1ee
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/connector-icon-map.ts
@@ -0,0 +1,101 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ConnectorIconLike } from "@pdpp/brand-react";
+import { connectorLookupKey } from "./connector-install-presentation.ts";
+import {
+ bundledIconIdentities,
+ hasConnectorIcon,
+ indexConnectorIcon,
+ validatedConnectorIcon,
+} from "./resolve-connector-icon.ts";
+import { resolveConnectorIconFromSimpleIcons } from "./resolve-connector-icon-simple-icons.ts";
+import { type DisplayMarkLayers, pickDisplayMark } from "./resolve-manifest-display-mark.ts";
+import type { ConnectorManifest, OwnerConnectorTemplate } from "./rs-client.ts";
+
+/** Icons indexed by the exact server-projected connector identity. */
+export type ConnectorIconMap = Readonly>;
+
+function layerForSource(
+ layers: Record>,
+ source: ConnectorManifest["resolvedIconSource"]
+): Map {
+ if (source === "bundled") {
+ return layers.bundled;
+ }
+ return source === "simple-icons" ? layers.generic : layers.manifest;
+}
+
+/**
+ * A display projection; it never adds or changes a connector manifest. Each
+ * source of marks fills one layer, and `pickDisplayMark` (the same function
+ * `resolveManifestDisplayMark` uses) chooses per identity, so the map cannot
+ * order marks differently from the setup pages.
+ */
+export async function buildConnectorIconMap(
+ manifests: readonly ConnectorManifest[],
+ templates: readonly OwnerConnectorTemplate[]
+): Promise {
+ const layers = {
+ bundled: bundledIconIdentities(),
+ generic: new Map(),
+ manifest: new Map(),
+ template: new Map(),
+ } satisfies Record>;
+ // Manifest marks are already normalized (resolveManifestDisplayMark). Which
+ // layer one belongs to follows the source it resolved from.
+ for (const manifest of manifests) {
+ indexConnectorIcon(
+ layerForSource(layers, manifest.resolvedIconSource),
+ [manifest.connector_id, manifest.connector_key],
+ validatedConnectorIcon(manifest.icon)
+ );
+ }
+ for (const template of templates) {
+ indexConnectorIcon(
+ layers.template,
+ [template.connector_id, template.connector_key],
+ validatedConnectorIcon(template.icon)
+ );
+ }
+ // Resolve only identities no layer covers yet, so the fallback cannot
+ // overwrite an approved mark through an alias.
+ const covered = (ids: readonly (string | null | undefined)[]) =>
+ Object.values(layers).some((layer) => hasConnectorIcon(layer, ids));
+ const missing = templates.filter(
+ (template) => template.connector_key && !covered([template.connector_id, template.connector_key])
+ );
+ const fallbacks = await Promise.all(
+ missing.map(async (template) => ({
+ identities: [template.connector_id, template.connector_key],
+ icon: await resolveConnectorIconFromSimpleIcons(template.connector_key ?? ""),
+ }))
+ );
+ for (const fallback of fallbacks) {
+ indexConnectorIcon(layers.generic, fallback.identities, fallback.icon);
+ }
+ const icons = new Map();
+ for (const key of new Set(Object.values(layers).flatMap((layer) => [...layer.keys()]))) {
+ const { icon } = pickDisplayMark({
+ bundled: layers.bundled.get(key),
+ generic: layers.generic.get(key),
+ manifest: layers.manifest.get(key),
+ template: layers.template.get(key),
+ });
+ if (icon) {
+ icons.set(key, icon);
+ }
+ }
+ return Object.fromEntries(icons);
+}
+
+/**
+ * The mark for a catalog entry: the map's choice, else the mark the entry
+ * already carries. A key the map does not index must not discard that mark.
+ */
+export function catalogEntryIcon(
+ entry: { readonly connectorKey: string; readonly icon?: ConnectorIconLike | null },
+ icons: ConnectorIconMap
+): ConnectorIconLike | null {
+ return icons[connectorLookupKey(entry.connectorKey)] ?? validatedConnectorIcon(entry.icon) ?? null;
+}
diff --git a/apps/console/src/app/(console)/lib/connector-icons/NOTICE b/apps/console/src/app/(console)/lib/connector-icons/NOTICE
new file mode 100644
index 000000000..c92397292
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/connector-icons/NOTICE
@@ -0,0 +1,19 @@
+Copyright © 2026 The PDP-Connect Contributors
+Licensed under the Apache License, Version 2.0.
+
+## Third-party connector brand icons
+
+Shipped connector manifests in `packages/polyfill-connectors/manifests/` each
+declare a small brand glyph (`brand.icon`, under `manifests/icons/*.svg`) to
+identify the third-party service the connector connects to (e.g. Amazon,
+GitHub, Spotify). All product names, trademarks, and registered trademarks
+referenced or depicted by these icons are the property of their respective
+owners. These icons are used by the PDP-Connect project strictly to identify
+the products and companies a connector integrates with; their use implies no
+sponsorship, affiliation, or endorsement by the mark holder.
+
+A connector manifest with no legitimately available brand mark should not
+carry an invented or unofficial logo. Consumers of this package's
+`connector-index.json` render a deterministic monogram (the connector's
+initial(s) and a color derived from its name) as the correct default for
+that case — this is a normal, first-class fallback, not an error state.
diff --git a/apps/console/src/app/(console)/lib/connector-icons/sanitized-assets.json b/apps/console/src/app/(console)/lib/connector-icons/sanitized-assets.json
new file mode 100644
index 000000000..c78f24281
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/connector-icons/sanitized-assets.json
@@ -0,0 +1,591 @@
+{
+ "repo": "PDP-Connect/data-connectors",
+ "revision": "23aee3aaeafad3a804d504e8614763f7845add60",
+ "entries": [
+ {
+ "background_color": "#FF9900",
+ "connector_key": "amazon",
+ "connector_id": "https://registry.pdpp.dev/connectors/amazon",
+ "display_name": "Amazon",
+ "path": "connectors/amazon/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/amazon",
+ "svg": " "
+ },
+ {
+ "background_color": "#D97757",
+ "connector_key": "anthropic",
+ "connector_id": "https://registry.pdpp.dev/connectors/anthropic",
+ "display_name": "Claude (Anthropic)",
+ "path": "connectors/anthropic/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/claude",
+ "svg": " "
+ },
+ {
+ "background_color": "#D97757",
+ "connector_key": "anthropic-live",
+ "connector_id": "https://registry.pdpp.dev/connectors/anthropic-live",
+ "display_name": "Claude (Live Session)",
+ "path": "connectors/anthropic_live/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/claude",
+ "svg": " "
+ },
+ {
+ "background_color": "#8E8E93",
+ "connector_key": "apple-contacts",
+ "connector_id": "https://registry.pdpp.dev/connectors/apple-contacts",
+ "display_name": "Apple Contacts",
+ "path": "connectors/apple_contacts/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/apple_contacts",
+ "svg": " "
+ },
+ {
+ "background_color": "#FF2D55",
+ "connector_key": "apple-health",
+ "connector_id": "https://registry.pdpp.dev/connectors/apple-health",
+ "display_name": "Apple Health",
+ "path": "connectors/apple_health/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/apple_health",
+ "svg": " "
+ },
+ {
+ "background_color": "#FA633E",
+ "connector_key": "apple-photos",
+ "connector_id": "https://registry.pdpp.dev/connectors/apple-photos",
+ "display_name": "Apple Photos",
+ "path": "connectors/apple_photos/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/apple_photos",
+ "svg": " "
+ },
+ {
+ "background_color": "#000000",
+ "connector_key": "character-ai",
+ "connector_id": "https://registry.pdpp.dev/connectors/character-ai",
+ "display_name": "Character.AI",
+ "path": "connectors/character_ai/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/character_ai",
+ "svg": " "
+ },
+ {
+ "background_color": "#117ACA",
+ "connector_key": "chase",
+ "connector_id": "https://registry.pdpp.dev/connectors/chase",
+ "display_name": "Chase",
+ "path": "connectors/chase/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/chase",
+ "svg": " "
+ },
+ {
+ "background_color": "#10A37F",
+ "connector_key": "chatgpt",
+ "connector_id": "https://registry.pdpp.dev/connectors/chatgpt",
+ "display_name": "ChatGPT",
+ "path": "connectors/chatgpt/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/chatgpt",
+ "svg": " "
+ },
+ {
+ "background_color": "#D97757",
+ "connector_key": "claude-code",
+ "connector_id": "https://registry.pdpp.dev/connectors/claude-code",
+ "display_name": "Claude Code",
+ "path": "connectors/claude_code/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/claude_code",
+ "svg": " "
+ },
+ {
+ "background_color": "#10A37F",
+ "connector_key": "codex",
+ "connector_id": "https://registry.pdpp.dev/connectors/codex",
+ "display_name": "OpenAI Codex CLI",
+ "path": "connectors/codex/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/codex",
+ "svg": " "
+ },
+ {
+ "background_color": "#5865F2",
+ "connector_key": "discord-browser",
+ "connector_id": "https://registry.pdpp.dev/connectors/discord-browser",
+ "display_name": "Discord (Browser Sign-In)",
+ "path": "connectors/discord_browser/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/discord",
+ "svg": " "
+ },
+ {
+ "background_color": "#FF3008",
+ "connector_key": "doordash",
+ "connector_id": "https://registry.pdpp.dev/connectors/doordash",
+ "display_name": "DoorDash",
+ "path": "connectors/doordash/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/doordash",
+ "svg": " "
+ },
+ {
+ "background_color": "#0866FF",
+ "connector_key": "facebook",
+ "connector_id": "https://registry.pdpp.dev/connectors/facebook",
+ "display_name": "Facebook",
+ "path": "connectors/facebook/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/facebook",
+ "svg": " "
+ },
+ {
+ "background_color": "#00B0B9",
+ "connector_key": "fitbit",
+ "connector_id": "https://registry.pdpp.dev/connectors/fitbit",
+ "display_name": "Fitbit",
+ "path": "connectors/fitbit/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/fitbit",
+ "svg": " "
+ },
+ {
+ "background_color": "#000000",
+ "connector_key": "garmin-browser",
+ "connector_id": "https://registry.pdpp.dev/connectors/garmin-browser",
+ "display_name": "Garmin (Browser Sign-In)",
+ "path": "connectors/garmin_browser/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/garmin_browser",
+ "svg": " "
+ },
+ {
+ "background_color": "#181717",
+ "connector_key": "github",
+ "connector_id": "https://registry.pdpp.dev/connectors/github",
+ "display_name": "GitHub",
+ "path": "connectors/github/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/github",
+ "svg": " "
+ },
+ {
+ "background_color": "#181717",
+ "connector_key": "github-browser",
+ "connector_id": "https://registry.pdpp.dev/connectors/github-browser",
+ "display_name": "GitHub (browser session)",
+ "path": "connectors/github_browser/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/github",
+ "svg": " "
+ },
+ {
+ "background_color": "#EA4335",
+ "connector_key": "gmail",
+ "connector_id": "https://registry.pdpp.dev/connectors/gmail",
+ "display_name": "Gmail",
+ "path": "connectors/gmail/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/gmail",
+ "svg": " "
+ },
+ {
+ "background_color": "#4285F4",
+ "connector_key": "google-calendar",
+ "connector_id": "https://registry.pdpp.dev/connectors/google-calendar",
+ "display_name": "Google Calendar",
+ "path": "connectors/google_calendar/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/google_calendar",
+ "svg": " "
+ },
+ {
+ "background_color": "#34A853",
+ "connector_key": "google-contacts",
+ "connector_id": "https://registry.pdpp.dev/connectors/google-contacts",
+ "display_name": "Google Contacts",
+ "path": "connectors/google_contacts/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/google_contacts",
+ "svg": " "
+ },
+ {
+ "background_color": "#4285F4",
+ "connector_key": "google-maps",
+ "connector_id": "https://registry.pdpp.dev/connectors/google-maps",
+ "display_name": "Google Maps Timeline Import",
+ "path": "connectors/google_maps/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/google_maps",
+ "svg": " "
+ },
+ {
+ "background_color": "#4285F4",
+ "connector_key": "google-maps-data-portability",
+ "connector_id": "https://registry.pdpp.dev/connectors/google-maps-data-portability",
+ "display_name": "Google Maps Data Portability",
+ "path": "connectors/google_maps_data_portability/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/google_maps_data_portability",
+ "svg": " "
+ },
+ {
+ "background_color": "#1A73E8",
+ "connector_key": "google-messages",
+ "connector_id": "https://registry.pdpp.dev/connectors/google-messages",
+ "display_name": "Google Messages (via gmcli)",
+ "path": "connectors/google_messages/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/google_messages",
+ "svg": " "
+ },
+ {
+ "background_color": "#4285F4",
+ "connector_key": "google-takeout",
+ "connector_id": "https://registry.pdpp.dev/connectors/google-takeout",
+ "display_name": "Google Takeout",
+ "path": "connectors/google_takeout/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/google_takeout",
+ "svg": " "
+ },
+ {
+ "background_color": "#00AFF0",
+ "connector_key": "groupme",
+ "connector_id": "https://registry.pdpp.dev/connectors/groupme",
+ "display_name": "GroupMe",
+ "path": "connectors/groupme/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/groupme",
+ "svg": " "
+ },
+ {
+ "background_color": "#E1251B",
+ "connector_key": "heb",
+ "connector_id": "https://registry.pdpp.dev/connectors/heb",
+ "display_name": "H-E-B",
+ "path": "connectors/heb/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/heb",
+ "svg": " "
+ },
+ {
+ "background_color": "#FF3B30",
+ "connector_key": "ical",
+ "connector_id": "https://registry.pdpp.dev/connectors/ical",
+ "display_name": "Calendar (iCal)",
+ "path": "connectors/ical/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/ical",
+ "svg": " "
+ },
+ {
+ "background_color": "#FFD426",
+ "connector_key": "icloud-notes",
+ "connector_id": "https://registry.pdpp.dev/connectors/icloud-notes",
+ "display_name": "iCloud Notes",
+ "path": "connectors/icloud_notes/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/icloud_notes",
+ "svg": " "
+ },
+ {
+ "background_color": "#30D158",
+ "connector_key": "imessage",
+ "connector_id": "https://registry.pdpp.dev/connectors/imessage",
+ "display_name": "iMessage (macOS)",
+ "path": "connectors/imessage/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/imessage",
+ "svg": " "
+ },
+ {
+ "background_color": "#1F2322",
+ "connector_key": "instinct",
+ "connector_id": "https://registry.pdpp.dev/connectors/instinct",
+ "display_name": "Instinct",
+ "path": "connectors/instinct/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/instinct",
+ "svg": " "
+ },
+ {
+ "background_color": "#00A4DC",
+ "connector_key": "jellyfin",
+ "connector_id": "https://registry.pdpp.dev/connectors/jellyfin",
+ "display_name": "Jellyfin",
+ "path": "connectors/jellyfin/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/jellyfin",
+ "svg": " "
+ },
+ {
+ "background_color": "#0A66C2",
+ "connector_key": "linkedin",
+ "connector_id": "https://registry.pdpp.dev/connectors/linkedin",
+ "display_name": "LinkedIn",
+ "path": "connectors/linkedin/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/linkedin",
+ "svg": " "
+ },
+ {
+ "background_color": "#625DF5",
+ "connector_key": "loom",
+ "connector_id": "https://registry.pdpp.dev/connectors/loom",
+ "display_name": "Loom",
+ "path": "connectors/loom/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/loom",
+ "svg": " "
+ },
+ {
+ "background_color": "#E1306C",
+ "connector_key": "meta",
+ "connector_id": "https://registry.pdpp.dev/connectors/meta",
+ "display_name": "Instagram (Meta)",
+ "path": "connectors/meta/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/instagram",
+ "svg": " "
+ },
+ {
+ "background_color": "#0467DF",
+ "connector_key": "muse",
+ "connector_id": "https://registry.pdpp.dev/connectors/muse",
+ "display_name": "Muse (Meta)",
+ "path": "connectors/muse/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/muse",
+ "svg": " "
+ },
+ {
+ "background_color": "#E50914",
+ "connector_key": "netflix-export",
+ "connector_id": "https://registry.pdpp.dev/connectors/netflix-export",
+ "display_name": "Netflix Export",
+ "path": "connectors/netflix_export/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/netflix_export",
+ "svg": " "
+ },
+ {
+ "background_color": "#000000",
+ "connector_key": "notion",
+ "connector_id": "https://registry.pdpp.dev/connectors/notion",
+ "display_name": "Notion",
+ "path": "connectors/notion/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/notion",
+ "svg": " "
+ },
+ {
+ "background_color": "#2F4A73",
+ "connector_key": "oura",
+ "connector_id": "https://registry.pdpp.dev/connectors/oura",
+ "display_name": "Oura",
+ "path": "connectors/oura/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/oura",
+ "svg": " "
+ },
+ {
+ "background_color": "#2F4A73",
+ "connector_key": "oura-browser",
+ "connector_id": "https://registry.pdpp.dev/connectors/oura-browser",
+ "display_name": "Oura (Browser Sign-In)",
+ "path": "connectors/oura_browser/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/oura",
+ "svg": " "
+ },
+ {
+ "background_color": "#1FB8CD",
+ "connector_key": "perplexity",
+ "connector_id": "https://registry.pdpp.dev/connectors/perplexity",
+ "display_name": "Perplexity (Browser Sign-In)",
+ "path": "connectors/perplexity/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/perplexity",
+ "svg": " "
+ },
+ {
+ "background_color": "#EF3F56",
+ "connector_key": "pocket",
+ "connector_id": "https://registry.pdpp.dev/connectors/pocket",
+ "display_name": "Pocket",
+ "path": "connectors/pocket/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/pocket",
+ "svg": " "
+ },
+ {
+ "background_color": "#FF4500",
+ "connector_key": "reddit",
+ "connector_id": "https://registry.pdpp.dev/connectors/reddit",
+ "display_name": "Reddit",
+ "path": "connectors/reddit/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/reddit",
+ "svg": " "
+ },
+ {
+ "background_color": "#95BF47",
+ "connector_key": "shopify",
+ "connector_id": "https://registry.pdpp.dev/connectors/shopify",
+ "display_name": "Shopify Shop",
+ "path": "connectors/shopify/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/shop",
+ "svg": " "
+ },
+ {
+ "background_color": "#3A76F0",
+ "connector_key": "signal",
+ "connector_id": "https://registry.pdpp.dev/connectors/signal",
+ "display_name": "Signal Desktop",
+ "path": "connectors/signal/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/signal",
+ "svg": " "
+ },
+ {
+ "background_color": "#4A154B",
+ "connector_key": "slack",
+ "connector_id": "https://registry.pdpp.dev/connectors/slack",
+ "display_name": "Slack",
+ "path": "connectors/slack/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/slack",
+ "svg": " "
+ },
+ {
+ "background_color": "#4A154B",
+ "connector_key": "slack-browser",
+ "connector_id": "https://registry.pdpp.dev/connectors/slack-browser",
+ "display_name": "Slack (Browser Sign-In)",
+ "path": "connectors/slack_browser/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/slack",
+ "svg": " "
+ },
+ {
+ "background_color": "#1ED760",
+ "connector_key": "spotify",
+ "connector_id": "https://registry.pdpp.dev/connectors/spotify",
+ "display_name": "Spotify",
+ "path": "connectors/spotify/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/spotify",
+ "svg": " "
+ },
+ {
+ "background_color": "#1B2838",
+ "connector_key": "steam",
+ "connector_id": "https://registry.pdpp.dev/connectors/steam",
+ "display_name": "Steam",
+ "path": "connectors/steam/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/steam",
+ "svg": " "
+ },
+ {
+ "background_color": "#FC4C02",
+ "connector_key": "strava",
+ "connector_id": "https://registry.pdpp.dev/connectors/strava",
+ "display_name": "Strava",
+ "path": "connectors/strava/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/strava",
+ "svg": " "
+ },
+ {
+ "background_color": "#FC4C02",
+ "connector_key": "strava-browser",
+ "connector_id": "https://registry.pdpp.dev/connectors/strava-browser",
+ "display_name": "Strava (Browser Sign-In)",
+ "path": "connectors/strava_browser/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/strava",
+ "svg": " "
+ },
+ {
+ "background_color": "#000000",
+ "connector_key": "tiktok",
+ "connector_id": "https://registry.pdpp.dev/connectors/tiktok",
+ "display_name": "TikTok (Browser Sign-In)",
+ "path": "connectors/tiktok/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/tiktok",
+ "svg": " "
+ },
+ {
+ "background_color": "#000000",
+ "connector_key": "twitter-archive",
+ "connector_id": "https://registry.pdpp.dev/connectors/twitter-archive",
+ "display_name": "Twitter/X Archive",
+ "path": "connectors/twitter_archive/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/twitter_archive",
+ "svg": " "
+ },
+ {
+ "background_color": "#000000",
+ "connector_key": "uber",
+ "connector_id": "https://registry.pdpp.dev/connectors/uber",
+ "display_name": "Uber",
+ "path": "connectors/uber/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/uber",
+ "svg": " "
+ },
+ {
+ "background_color": "#12395B",
+ "connector_key": "usaa",
+ "connector_id": "https://registry.pdpp.dev/connectors/usaa",
+ "display_name": "USAA",
+ "path": "connectors/usaa/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/usaa",
+ "svg": " "
+ },
+ {
+ "background_color": "#3D95CE",
+ "connector_key": "venmo",
+ "connector_id": "https://registry.pdpp.dev/connectors/venmo",
+ "display_name": "Venmo",
+ "path": "connectors/venmo/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/venmo",
+ "svg": " "
+ },
+ {
+ "background_color": "#25D366",
+ "connector_key": "whatsapp",
+ "connector_id": "https://registry.pdpp.dev/connectors/whatsapp",
+ "display_name": "WhatsApp (chat export)",
+ "path": "connectors/whatsapp/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/whatsapp",
+ "svg": " "
+ },
+ {
+ "background_color": "#25D366",
+ "connector_key": "whatsapp-web",
+ "connector_id": "https://registry.pdpp.dev/connectors/whatsapp-web",
+ "display_name": "WhatsApp (web)",
+ "path": "connectors/whatsapp_web/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/whatsapp_web",
+ "svg": " "
+ },
+ {
+ "background_color": "#046F46",
+ "connector_key": "wholefoods",
+ "connector_id": "https://registry.pdpp.dev/connectors/wholefoods",
+ "display_name": "Whole Foods",
+ "path": "connectors/wholefoods/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/wholefoods",
+ "svg": " "
+ },
+ {
+ "background_color": "#000000",
+ "connector_key": "whoop",
+ "connector_id": "https://registry.pdpp.dev/connectors/whoop",
+ "display_name": "WHOOP",
+ "path": "connectors/whoop/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/whoop",
+ "svg": " "
+ },
+ {
+ "background_color": "#000000",
+ "connector_key": "whoop-browser",
+ "connector_id": "https://registry.pdpp.dev/connectors/whoop-browser",
+ "display_name": "WHOOP (Browser Sign-In)",
+ "path": "connectors/whoop_browser/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/whoop_browser",
+ "svg": " "
+ },
+ {
+ "background_color": "#000000",
+ "connector_key": "x-browser",
+ "connector_id": "https://registry.pdpp.dev/connectors/x-browser",
+ "display_name": "X (Browser Sign-In)",
+ "path": "connectors/x_browser/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/x",
+ "svg": " "
+ },
+ {
+ "background_color": "#3B5EDA",
+ "connector_key": "ynab",
+ "connector_id": "https://registry.pdpp.dev/connectors/ynab",
+ "display_name": "YNAB",
+ "path": "connectors/ynab/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/ynab",
+ "svg": " "
+ },
+ {
+ "background_color": "#FF0000",
+ "connector_key": "youtube",
+ "connector_id": "https://registry.pdpp.dev/connectors/youtube",
+ "display_name": "YouTube",
+ "path": "connectors/youtube/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/youtube",
+ "svg": " "
+ },
+ {
+ "background_color": "#FF0000",
+ "connector_key": "youtube-takeout",
+ "connector_id": "https://registry.pdpp.dev/connectors/youtube-takeout",
+ "display_name": "YouTube Takeout",
+ "path": "connectors/youtube_takeout/icon.svg",
+ "source_id": "https://registry.pdpp.dev/sources/youtube",
+ "svg": " "
+ }
+ ]
+}
diff --git a/apps/console/src/app/(console)/lib/resolve-connector-icon-path.ts b/apps/console/src/app/(console)/lib/resolve-connector-icon-path.ts
index a599ff773..0f141604d 100644
--- a/apps/console/src/app/(console)/lib/resolve-connector-icon-path.ts
+++ b/apps/console/src/app/(console)/lib/resolve-connector-icon-path.ts
@@ -4,7 +4,7 @@
import { readFile } from "node:fs/promises";
import { resolve, sep } from "node:path";
import type { ConnectorIconLike } from "@pdpp/brand-react";
-import { sanitizeSvg } from "./sanitize-svg.ts";
+import { sanitizeSvg } from "@pdpp/brand-react/sanitize-svg";
/**
* Server-only counterpart to resolve-connector-icon.ts's inline_svg
@@ -17,10 +17,10 @@ import { sanitizeSvg } from "./sanitize-svg.ts";
* the resolved, sanitized SVG into the same inline_svg shape
* resolveConnectorIcon already renders.
*
- * This is icon-resolution LAYER 1 (bundled-with-the-manifest, checked
- * first) of the three-layer resolver: layer 1 here, layer 2 in
- * resolve-connector-icon-simple-icons.ts, layer 3 (deterministic monogram)
- * already implemented by @pdpp/brand-react's ConnectorIcon fallback.
+ * This reads the file a manifest's `brand.icon` names, which is a manifest-
+ * owned mark. Where it sits among the other sources of a mark (the manifest's
+ * inline icon, bundled marks, simple-icons, the monogram) is decided only by
+ * `pickDisplayMark` in resolve-manifest-display-mark.ts.
*/
/**
diff --git a/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.test.ts b/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.test.ts
index b909fd824..5062feea9 100644
--- a/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.test.ts
+++ b/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.test.ts
@@ -2,9 +2,14 @@
// SPDX-License-Identifier: Apache-2.0
import assert from "node:assert/strict";
+import { cpSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import test from "node:test";
import { resolveConnectorIconFromSimpleIcons } from "./resolve-connector-icon-simple-icons.ts";
+const FIGMA_TITLE = /Figma<\/title>/;
+
test("resolves a known brand by connector key (exact slug match)", async () => {
const icon = await resolveConnectorIconFromSimpleIcons("github");
assert.ok(icon);
@@ -28,3 +33,27 @@ test("returns null for an empty or blank connector key", async () => {
assert.equal(await resolveConnectorIconFromSimpleIcons(""), null);
assert.equal(await resolveConnectorIconFromSimpleIcons(" "), null);
});
+
+// Next's standalone tracer copies only the traced files (outputFileTracingIncludes
+// names data/simple-icons.json and icons/*.svg), never the package's own
+// package.json. The lookup must not require a file the build does not ship.
+test("resolves from a standalone-style layout that ships no package.json", async () => {
+ const source = join(process.cwd(), "..", "..", "node_modules", "simple-icons");
+ const root = mkdtempSync(join(tmpdir(), "standalone-layout-"));
+ const previous = process.cwd();
+ try {
+ const pkg = join(root, "node_modules", "simple-icons");
+ mkdirSync(join(pkg, "data"), { recursive: true });
+ mkdirSync(join(pkg, "icons"), { recursive: true });
+ cpSync(join(source, "data", "simple-icons.json"), join(pkg, "data", "simple-icons.json"));
+ cpSync(join(source, "icons", "figma.svg"), join(pkg, "icons", "figma.svg"));
+ mkdirSync(join(root, "apps", "console"), { recursive: true });
+ process.chdir(join(root, "apps", "console"));
+ const icon = await resolveConnectorIconFromSimpleIcons("figma");
+ assert.ok(icon, "a traced standalone package must still resolve");
+ assert.match(icon.svg ?? "", FIGMA_TITLE);
+ } finally {
+ process.chdir(previous);
+ rmSync(root, { force: true, recursive: true });
+ }
+});
diff --git a/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.ts b/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.ts
index e19463977..4a9a9cc4c 100644
--- a/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.ts
+++ b/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.ts
@@ -5,77 +5,79 @@ import { existsSync, readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { ConnectorIconLike } from "@pdpp/brand-react";
-import { sanitizeSvg } from "./sanitize-svg.ts";
+import { sanitizeSvg } from "@pdpp/brand-react/sanitize-svg";
/**
- * Icon-resolution LAYER 2: a vendored, offline lookup into the simple-icons
- * npm package (CC0-licensed SVG markup, ~3,300 brand icons), used only when
- * a connector's manifest declares no bundled icon of its own (layer 1 in
- * resolve-connector-icon-path.ts). This exists for connectors added AFTER
- * today's fix — every manifest currently shipped already has a bundled icon,
- * so layer 1 alone covers the reported bug.
+ * The `generic` layer of `pickDisplayMark` (resolve-manifest-display-mark.ts):
+ * an offline lookup into the installed simple-icons npm package (CC0-licensed
+ * SVG markup, ~3,400 brand icons), used only when neither the manifest nor
+ * the bundled corpus supplies a mark. This exists for connectors added AFTER
+ * the bundled corpus was generated.
*
* Offline/build-time only: this reads a file the npm package already
* installed on disk. It never fetches from a network at render time and
* never calls a third-party logo CDN (logo.dev and similar are explicitly
- * out of scope — see the manifests/icons NOTICE for why).
+ * out of scope — see the connector-icons NOTICE for the attribution terms).
*
* simple-icons' SVG markup is CC0 (public domain), but its LICENSE is
* explicit that CC0 does not waive trademark rights, and it runs a formal
* brand-owner takedown channel. Rendering one of its icons here is the same
- * nominative-fair-use posture as the vendored layer-1 icons: identification
- * only, no implied endorsement. See manifests/icons/NOTICE.md.
+ * nominative-fair-use posture as the bundled icons: identification only, no
+ * implied endorsement. See connector-icons/NOTICE.
*/
const PACKAGE_NAME = "simple-icons";
-const INSTALLED_PACKAGE_ROOT_CANDIDATES = [
- join(process.cwd(), "node_modules", PACKAGE_NAME),
- join(process.cwd(), "..", "..", "node_modules", PACKAGE_NAME),
-];
+/** What the lookup reads; the build's file tracer copies exactly these (next.config.mjs). */
+const SLUG_INDEX = join("data", "simple-icons.json");
interface SimpleIconEntry {
slug: string;
title: string;
}
-let cachedPackageRoot: string | null | undefined;
-let cachedSlugs: Set | null | undefined;
+// Keyed by cwd, because the candidate roots are relative to it: Next's
+// standalone server.js chdirs into apps/console, and a test may chdir too.
+const packageRootByCwd = new Map();
+const slugsByRoot = new Map>();
function resolvePackageRoot(): string | null {
- if (cachedPackageRoot !== undefined) {
- return cachedPackageRoot;
+ const cwd = process.cwd();
+ const cached = packageRootByCwd.get(cwd);
+ if (cached !== undefined) {
+ return cached;
}
- for (const candidate of INSTALLED_PACKAGE_ROOT_CANDIDATES) {
- if (existsSync(join(candidate, "package.json"))) {
- cachedPackageRoot = candidate;
- return candidate;
- }
- }
- cachedPackageRoot = null;
- return null;
+ const root =
+ [join(cwd, "node_modules", PACKAGE_NAME), join(cwd, "..", "..", "node_modules", PACKAGE_NAME)].find((candidate) =>
+ existsSync(join(candidate, SLUG_INDEX))
+ ) ?? null;
+ packageRootByCwd.set(cwd, root);
+ return root;
}
-/** The set of every valid simple-icons slug, loaded once and cached. */
+/** The set of every valid simple-icons slug, loaded once per package root and cached. */
function loadKnownSlugs(packageRoot: string): Set {
- if (cachedSlugs !== undefined && cachedSlugs !== null) {
- return cachedSlugs;
+ const cached = slugsByRoot.get(packageRoot);
+ if (cached) {
+ return cached;
}
+ let slugs: Set;
try {
- const raw = readFileSync(join(packageRoot, "data", "simple-icons.json"), "utf8");
- const entries = JSON.parse(raw) as SimpleIconEntry[];
- cachedSlugs = new Set(entries.map((entry) => entry.slug));
+ const raw = readFileSync(join(packageRoot, SLUG_INDEX), "utf8");
+ slugs = new Set((JSON.parse(raw) as SimpleIconEntry[]).map((entry) => entry.slug));
} catch {
- cachedSlugs = new Set();
+ slugs = new Set();
}
- return cachedSlugs;
+ slugsByRoot.set(packageRoot, slugs);
+ return slugs;
}
/**
* Normalize a connector key to the slug shape simple-icons uses: lowercase,
* digits and letters only. Matches simple-icons' own slug algorithm (a
* lowercased title with non-alphanumeric characters stripped), so this is a
- * direct lookup rather than a fuzzy guess — verified against every
- * currently-shipped connector key with no false positives.
+ * direct lookup rather than a fuzzy guess. Separators are dropped, so
+ * `foo-bar` and `foobar` name the same slug: a connector whose key only
+ * coincides with another brand's slug would show that brand's mark.
*/
function slugFromConnectorKey(connectorKey: string): string {
return connectorKey.toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -86,7 +88,7 @@ function slugFromConnectorKey(connectorKey: string): string {
* connector key (e.g. "claude-code" -> slug "claudecode"). Returns null
* (never throws) when simple-icons is not installed, the key has no known
* slug match, the icon file is missing, or its content fails sanitizeSvg —
- * any of which falls through to the deterministic Monogram (layer 3).
+ * any of which falls through to the deterministic Monogram.
*/
export async function resolveConnectorIconFromSimpleIcons(connectorKey: string): Promise {
if (typeof connectorKey !== "string" || connectorKey.trim().length === 0) {
@@ -97,7 +99,7 @@ export async function resolveConnectorIconFromSimpleIcons(connectorKey: string):
return null;
}
const slug = slugFromConnectorKey(connectorKey);
- if (!slug || !loadKnownSlugs(packageRoot).has(slug)) {
+ if (!(slug && loadKnownSlugs(packageRoot).has(slug))) {
return null;
}
let raw: string;
diff --git a/apps/console/src/app/(console)/lib/resolve-connector-icon.test.ts b/apps/console/src/app/(console)/lib/resolve-connector-icon.test.ts
index 888cf2a41..8effedfca 100644
--- a/apps/console/src/app/(console)/lib/resolve-connector-icon.test.ts
+++ b/apps/console/src/app/(console)/lib/resolve-connector-icon.test.ts
@@ -2,19 +2,163 @@
// SPDX-License-Identifier: Apache-2.0
import assert from "node:assert/strict";
+import { readdirSync, readFileSync } from "node:fs";
+import path from "node:path";
import test from "node:test";
-import { resolveConnectorIcon } from "./resolve-connector-icon.ts";
+import { fileURLToPath } from "node:url";
+import postcss from "postcss";
+import React, { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import ts from "typescript";
+import { ConnectorMark } from "../components/connector-mark.tsx";
+import { connectorIconIdentityKeys } from "./connector-icon-identity.ts";
+import corpus from "./connector-icons/sanitized-assets.json" with { type: "json" };
+import { resolveConnectorIcon, resolveSourceIcon } from "./resolve-connector-icon.ts";
-test("resolveConnectorIcon accepts a non-empty inline SVG", () => {
+// The repository test harness transpiles source JSX with the classic runtime.
+Object.assign(globalThis, { React });
+
+test("resolveConnectorIcon resolves every bundled mark by connector key and stable registry ID", () => {
+ for (const entry of corpus.entries) {
+ const byKey = resolveConnectorIcon(entry.connector_key);
+ const byId = resolveConnectorIcon(entry.connector_id);
+ assert.equal(byKey?.kind, "inline_svg", entry.connector_key);
+ assert.equal(byId?.kind, "inline_svg", entry.connector_id);
+ assert.equal(byKey?.svg, entry.svg, entry.connector_key);
+ assert.equal(byId?.svg, entry.svg, entry.connector_id);
+ }
+});
+
+test("known connector key aliases accept underscore and hyphen spellings", () => {
+ const hyphen = resolveConnectorIcon("apple-contacts");
+ assert.ok(hyphen);
+ assert.equal(resolveConnectorIcon("apple_contacts")?.svg, hyphen.svg);
+});
+
+test("safe supplied icon wins and unsafe SVG falls back to the genuine bundled mark", () => {
+ const custom = { kind: "inline_svg" as const, svg: ' ' };
+ assert.equal(resolveConnectorIcon("amazon", custom)?.svg, custom.svg);
+ const unsafe = { kind: "inline_svg" as const, svg: ' ' };
+ assert.equal(resolveConnectorIcon("amazon", unsafe)?.svg, resolveConnectorIcon("amazon")?.svg);
+});
+
+test("unknown IDs and arbitrary registry origins do not borrow a mark by basename", () => {
+ assert.equal(resolveConnectorIcon("not-a-known-connector"), null);
+ assert.equal(resolveConnectorIcon("https://attacker.example/connectors/amazon"), null);
+ assert.equal(resolveConnectorIcon(""), null);
+});
+
+test("ConnectorMark SSR uses stable connector identity when no optional icon was supplied", () => {
+ const markup = renderToStaticMarkup(
+ createElement(ConnectorMark, { connectorId: "amazon", name: "Custom account label" })
+ );
+ assert.ok(markup.includes(" {
+ const markup = renderToStaticMarkup(
+ createElement(ConnectorMark, { connectorId: "unknown-connector", name: "Example Source" })
+ );
+ assert.equal(markup.includes(" {
assert.deepEqual(
- resolveConnectorIcon({ color: "#111", kind: "inline_svg", svg: " " }),
- { color: "#111", kind: "inline_svg", svg: " " }
+ resolveConnectorIcon("https://registry.pdpp.dev/connectors/apple_contacts/"),
+ resolveConnectorIcon("apple-contacts")
+ );
+ assert.equal(resolveConnectorIcon("https://unrelated.example/connectors/apple-contacts"), null);
+});
+
+test("Core source display uses declared provenance without conflating connector identity", () => {
+ const sourceId = corpus.entries.find((entry) => entry.connector_key === "amazon")?.source_id;
+ assert.ok(sourceId);
+ const connector = resolveConnectorIcon("amazon");
+ assert.deepEqual(resolveSourceIcon(sourceId), connector);
+ assert.equal(resolveConnectorIcon(sourceId), null, "Core source identity is not an operational connector ID");
+ assert.equal(resolveSourceIcon("https://unrelated.example/sources/amazon"), null);
+ const markup = renderToStaticMarkup(createElement(ConnectorMark, { sourceId, name: "Personal source" }));
+ assert.ok(markup.includes(" {
+ const underscored = "urn:vendor:foo_bar";
+ const hyphenated = "urn:vendor:foo-bar";
+ assert.equal(new URL(underscored).protocol, "urn:");
+ assert.deepEqual(connectorIconIdentityKeys(underscored), [underscored]);
+ assert.deepEqual(connectorIconIdentityKeys(hyphenated), [hyphenated]);
+});
+
+const SOURCE_FILE = /\.(?:[cm]?[jt]s|[jt]sx)$/u;
+
+// Geometry is a product-wide renderer decision. Parsing every caller catches
+// future pages too; a snapshot of today's two reported rows would miss them.
+test("product connector marks cannot bypass shared geometry through local rendering or CSS", () => {
+ const root = fileURLToPath(new URL("../../../", import.meta.url));
+ const wrapper = path.join("app", "(console)", "components", "connector-mark.tsx");
+ const violations: string[] = [];
+ for (const relative of readdirSync(root, { recursive: true })) {
+ if (typeof relative !== "string" || relative.includes(".test.")) {
+ continue;
+ }
+ const filename = path.join(root, relative);
+ if (relative.endsWith(".css")) {
+ postcss.parse(readFileSync(filename, "utf8")).walkRules((rule) => {
+ if (rule.selector.includes(".pdpp-connector-icon")) {
+ violations.push(`${relative}: icon CSS belongs to the shared renderer`);
+ }
+ });
+ continue;
+ }
+ if (!SOURCE_FILE.test(relative) || relative === wrapper) {
+ continue;
+ }
+ const source = ts.createSourceFile(
+ filename,
+ readFileSync(filename, "utf8"),
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TSX
+ );
+ for (const statement of source.statements) {
+ if (!ts.isImportDeclaration(statement)) {
+ continue;
+ }
+ const bindings = statement.importClause?.namedBindings;
+ if (
+ bindings &&
+ ts.isNamedImports(bindings) &&
+ bindings.elements.some((binding) => (binding.propertyName ?? binding.name).text === "ConnectorIcon")
+ ) {
+ violations.push(`${relative}: render ConnectorMark instead of importing ConnectorIcon`);
+ }
+ }
+ }
+ assert.deepEqual(violations, []);
+});
+
+test("product connector mark API reserves geometry and exposes only text spacing", () => {
+ type Overrides = Extract[0], "className" | "style" | "size">;
+ const acceptsNoGeometryOverrides: Overrides extends never ? true : false = true;
+ assert.equal(acceptsNoGeometryOverrides, true);
+ const markup = renderToStaticMarkup(
+ createElement(ConnectorMark, { connectorId: "claude-code", name: "Account", spacing: "after" })
);
+ assert.ok(markup.includes('class="pdpp-connector-icon mr-2"'));
});
-test("resolveConnectorIcon returns null for a missing or unusable mark", () => {
- assert.equal(resolveConnectorIcon(null), null);
- assert.equal(resolveConnectorIcon(undefined), null);
- assert.equal(resolveConnectorIcon({ kind: "inline_svg", svg: " " }), null);
- assert.equal(resolveConnectorIcon({ kind: "external_url", svg: "https://example.test/logo.svg" }), null);
+test("no two bundled connectors answer to the same identity with different marks", () => {
+ const owners = new Map();
+ for (const entry of corpus.entries) {
+ for (const identity of [entry.connector_id, entry.connector_key]) {
+ for (const key of connectorIconIdentityKeys(identity)) {
+ const previous = owners.get(key);
+ assert.ok(previous === undefined || previous === entry.connector_key, `${key} is claimed by ${previous} and ${entry.connector_key}`);
+ owners.set(key, entry.connector_key);
+ }
+ }
+ }
});
diff --git a/apps/console/src/app/(console)/lib/resolve-connector-icon.ts b/apps/console/src/app/(console)/lib/resolve-connector-icon.ts
index 42a5f1eb6..ef84a8812 100644
--- a/apps/console/src/app/(console)/lib/resolve-connector-icon.ts
+++ b/apps/console/src/app/(console)/lib/resolve-connector-icon.ts
@@ -2,23 +2,147 @@
// SPDX-License-Identifier: Apache-2.0
import type { ConnectorIconLike } from "@pdpp/brand-react";
+import { hexColorOrNull } from "@pdpp/brand-react/hex-color";
+import { sanitizeSvg } from "@pdpp/brand-react/sanitize-svg";
+import { connectorIconIdentityKeys } from "./connector-icon-identity.ts";
+import iconCorpus from "./connector-icons/sanitized-assets.json" with { type: "json" };
+
+type CorpusEntry = (typeof iconCorpus.entries)[number];
+
+/** Validate an SVG mark at the shared display boundary. */
+export function validatedConnectorIcon(
+ icon: ConnectorIconLike | null | undefined,
+ declaredBackground?: string | null
+): ConnectorIconLike | null {
+ if (icon?.kind !== "inline_svg" || typeof icon.svg !== "string") {
+ return null;
+ }
+ const svg = sanitizeSvg(icon.svg);
+ if (!svg) {
+ return null;
+ }
+ const color = hexColorOrNull(icon.color);
+ const backgroundColor = hexColorOrNull(icon.backgroundColor ?? declaredBackground);
+ return { ...(color ? { color } : {}), ...(backgroundColor ? { backgroundColor } : {}), kind: "inline_svg", svg };
+}
+
+function corpusIcon(entry: CorpusEntry): ConnectorIconLike | null {
+ return validatedConnectorIcon({ backgroundColor: entry.background_color, kind: "inline_svg", svg: entry.svg });
+}
+
+const BUNDLED_ICONS_BY_IDENTITY = new Map();
+const BUNDLED_ICONS_BY_SOURCE = new Map();
+for (const entry of iconCorpus.entries) {
+ const icon = corpusIcon(entry);
+ if (!icon) {
+ continue;
+ }
+ if (entry.source_id) {
+ const previous = BUNDLED_ICONS_BY_SOURCE.get(entry.source_id);
+ const conflicts =
+ previous !== undefined &&
+ (previous === null || previous.svg !== icon.svg || previous.backgroundColor !== icon.backgroundColor);
+ BUNDLED_ICONS_BY_SOURCE.set(entry.source_id, conflicts ? null : icon);
+ }
+ for (const identity of [entry.connector_id, entry.connector_key]) {
+ for (const key of connectorIconIdentityKeys(identity)) {
+ BUNDLED_ICONS_BY_IDENTITY.set(key, icon);
+ }
+ }
+}
+
+/** A copy of the bundled corpus indexed by every accepted identity spelling, for callers that layer it. */
+export function bundledIconIdentities(): Map {
+ return new Map(BUNDLED_ICONS_BY_IDENTITY);
+}
+
+/** Resolve an explicit safe icon first, then a bundled mark for the exact connector identity. */
+export function resolveConnectorIcon(
+ connectorId: string,
+ icon?: ConnectorIconLike | null,
+ declaredBackground?: string | null
+): ConnectorIconLike | null {
+ const supplied = validatedConnectorIcon(icon, declaredBackground);
+ if (supplied) {
+ return supplied;
+ }
+ for (const key of connectorIconIdentityKeys(connectorId)) {
+ const bundled = BUNDLED_ICONS_BY_IDENTITY.get(key);
+ if (bundled) {
+ return bundled;
+ }
+ }
+ return null;
+}
+
+const conflictedKeysByLayer = new WeakMap, Set>();
+
+function sameMark(left: ConnectorIconLike, right: ConnectorIconLike): boolean {
+ return left.svg === right.svg && left.color === right.color && left.backgroundColor === right.backgroundColor;
+}
/**
- * Resolve the one icon shape shared by manifests, owner templates, and view
- * models. Invalid or absent marks intentionally return null so the shared
- * ConnectorMark can render the design-system monogram fallback.
- *
- * Manifests declare their brand glyph as a relative path string
- * (`brand.icon`, e.g. "icons/amazon.svg"), not inline markup. This
- * validator only ever sees the `inline_svg` shape because
- * resolve-connector-icon-path.ts (server-only) resolves that path to
- * sanitized SVG content in listConnectorManifests(), before the manifest
- * reaches this file — which is imported by connector-mark.tsx, a "use
- * client" component, and so must never itself touch the filesystem.
+ * Add a resolved mark under every accepted explicit connector spelling.
+ * Two different marks claiming one spelling in the same layer (for example
+ * `foo_bar` and `foo-bar`) is ambiguous: neither wins, the spelling gets no
+ * mark from this layer, and the collision is logged once.
*/
-export function resolveConnectorIcon(icon: ConnectorIconLike | null | undefined): ConnectorIconLike | null {
- if (icon?.kind !== "inline_svg" || typeof icon.svg !== "string" || icon.svg.trim().length === 0) {
- return null;
+export function indexConnectorIcon(
+ icons: Map,
+ connectorIds: readonly (string | null | undefined)[],
+ icon: ConnectorIconLike | null
+): void {
+ if (!icon) {
+ return;
+ }
+ let conflicted = conflictedKeysByLayer.get(icons);
+ for (const connectorId of connectorIds) {
+ if (!connectorId) {
+ continue;
+ }
+ for (const key of connectorIconIdentityKeys(connectorId)) {
+ const existing = icons.get(key);
+ if (conflicted?.has(key)) {
+ continue;
+ }
+ if (existing && !sameMark(existing, icon)) {
+ conflicted ??= new Set();
+ conflictedKeysByLayer.set(icons, conflicted);
+ conflicted.add(key);
+ icons.delete(key);
+ console.warn(
+ `Connector icon identity "${key}" is claimed by two different marks in one layer; showing neither.`
+ );
+ continue;
+ }
+ icons.set(key, icon);
+ }
+ }
+}
+
+/** True when a map already contains a mark for one of these exact identities or spellings. */
+export function hasConnectorIcon(
+ icons: ReadonlyMap,
+ connectorIds: readonly (string | null | undefined)[]
+): boolean {
+ return connectorIds.some(
+ (connectorId) => connectorId && connectorIconIdentityKeys(connectorId).some((key) => icons.has(key))
+ );
+}
+
+/** Core source IDs stay separate from connector IDs. This display-only lookup
+ * uses declared source provenance first. Only when no corpus entry declares
+ * the ID does it try an EXACT connector identity (key or canonical ID) for
+ * records that carry the connector key as their source ID; it never matches
+ * a partial or case-folded name. A source ID whose declarations conflict
+ * resolves to no brand, and is deliberately not rescued by that fallback. */
+export function resolveSourceIcon(sourceId: string, icon?: ConnectorIconLike | null): ConnectorIconLike | null {
+ const supplied = validatedConnectorIcon(icon);
+ if (supplied) {
+ return supplied;
+ }
+ if (BUNDLED_ICONS_BY_SOURCE.has(sourceId)) {
+ return BUNDLED_ICONS_BY_SOURCE.get(sourceId) ?? null;
}
- return { ...icon, svg: icon.svg.trim() };
+ return BUNDLED_ICONS_BY_IDENTITY.get(sourceId) ?? null;
}
diff --git a/apps/console/src/app/(console)/lib/resolve-manifest-display-mark.test.ts b/apps/console/src/app/(console)/lib/resolve-manifest-display-mark.test.ts
new file mode 100644
index 000000000..12a1897ee
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/resolve-manifest-display-mark.test.ts
@@ -0,0 +1,106 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+import { resolveManifestDisplayMark } from "./resolve-manifest-display-mark.ts";
+
+const WHITE_GLYPH = ' ';
+const INLINE_GLYPH = ' ';
+
+async function withDir(run: (dir: string) => Promise): Promise {
+ const dir = await mkdtemp(join(tmpdir(), "display-mark-"));
+ try {
+ await mkdir(join(dir, "icons"));
+ await writeFile(join(dir, "icons", "white.svg"), WHITE_GLYPH);
+ await run(dir);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+}
+
+test("a path-resolved glyph takes the declared brand backdrop", async () => {
+ await withDir(async (dir) => {
+ const mark = await resolveManifestDisplayMark(
+ { brand: { background_color: "#FF9900", icon: "icons/white.svg" }, connector_id: "zz-new", connector_key: "zz-new" },
+ dir
+ );
+ assert.deepEqual(mark, {
+ icon: { backgroundColor: "#FF9900", kind: "inline_svg", svg: WHITE_GLYPH },
+ source: "manifest",
+ });
+ });
+});
+
+test("an inline mark wins over brand.icon, and its own backdrop wins over the declared one", async () => {
+ await withDir(async (dir) => {
+ const mark = await resolveManifestDisplayMark(
+ {
+ brand: { background_color: "#FF9900", icon: "icons/white.svg" },
+ connector_id: "zz-new",
+ icon: { backgroundColor: "#112233", kind: "inline_svg", svg: INLINE_GLYPH },
+ },
+ dir
+ );
+ assert.deepEqual(mark.icon, { backgroundColor: "#112233", kind: "inline_svg", svg: INLINE_GLYPH });
+ const declaredOnly = await resolveManifestDisplayMark(
+ { brand: { background_color: "#FF9900" }, connector_id: "zz-new", icon: { kind: "inline_svg", svg: INLINE_GLYPH } },
+ dir
+ );
+ assert.equal(declaredOnly.icon?.backgroundColor, "#FF9900");
+ });
+});
+
+test("a declared backdrop that is not a six-digit hex colour is dropped", async () => {
+ await withDir(async (dir) => {
+ const mark = await resolveManifestDisplayMark(
+ { brand: { background_color: "url(javascript:0)", icon: "icons/white.svg" }, connector_id: "zz-new" },
+ dir
+ );
+ assert.equal(mark.icon?.backgroundColor, undefined);
+ assert.equal(mark.icon?.svg, WHITE_GLYPH);
+ });
+});
+
+test("without an own mark, the bundled mark for the identity is used with its own backdrop", async () => {
+ await withDir(async (dir) => {
+ const mark = await resolveManifestDisplayMark(
+ { brand: { background_color: "#000001" }, connector_id: "https://registry.pdpp.dev/connectors/amazon", connector_key: "amazon" },
+ dir
+ );
+ assert.equal(mark.source, "bundled");
+ assert.equal(mark.icon?.backgroundColor, "#FF9900");
+ });
+});
+
+test("a bundled mark outranks the generic simple-icons mark for the same key", async () => {
+ await withDir(async (dir) => {
+ const mark = await resolveManifestDisplayMark({ connector_id: "github", connector_key: "github" }, dir);
+ assert.equal(mark.source, "bundled");
+ });
+});
+
+test("simple-icons is used only when nothing else matches, and nothing leaves the monogram", async () => {
+ await withDir(async (dir) => {
+ const generic = await resolveManifestDisplayMark({ connector_id: "figma", connector_key: "figma" }, dir);
+ assert.equal(generic.source, "simple-icons");
+ assert.ok(generic.icon?.svg);
+ assert.deepEqual(await resolveManifestDisplayMark({ connector_id: "zz-unknown" }, dir), {
+ icon: null,
+ source: undefined,
+ });
+ });
+});
+
+test("an unreadable brand.icon falls through to the bundled mark", async () => {
+ await withDir(async (dir) => {
+ const mark = await resolveManifestDisplayMark(
+ { brand: { icon: "icons/missing.svg" }, connector_id: "amazon", connector_key: "amazon" },
+ dir
+ );
+ assert.equal(mark.source, "bundled");
+ });
+});
diff --git a/apps/console/src/app/(console)/lib/resolve-manifest-display-mark.ts b/apps/console/src/app/(console)/lib/resolve-manifest-display-mark.ts
new file mode 100644
index 000000000..239f63308
--- /dev/null
+++ b/apps/console/src/app/(console)/lib/resolve-manifest-display-mark.ts
@@ -0,0 +1,83 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ConnectorIconLike } from "@pdpp/brand-react";
+import { resolveConnectorIcon, validatedConnectorIcon } from "./resolve-connector-icon.ts";
+import { resolveConnectorIconFromManifestPath } from "./resolve-connector-icon-path.ts";
+import { resolveConnectorIconFromSimpleIcons } from "./resolve-connector-icon-simple-icons.ts";
+import type { ConnectorManifest } from "./rs-client.ts";
+
+type DisplayMarkSource = NonNullable;
+
+/** Where a candidate display mark came from, strongest first. */
+export interface DisplayMarkLayers {
+ /** The mark the manifest itself owns: its inline `icon` or its `brand.icon` file. */
+ readonly manifest?: ConnectorIconLike | null;
+ /** The mark the server projected on the owner connector template. */
+ readonly template?: ConnectorIconLike | null;
+ /** The bundled mark for the connector identity (`sanitized-assets.json`). */
+ readonly bundled?: ConnectorIconLike | null;
+ /** The offline simple-icons mark for `connector_key`. */
+ readonly generic?: ConnectorIconLike | null;
+}
+
+const DISPLAY_MARK_ORDER = ["manifest", "template", "bundled", "generic"] as const;
+
+/**
+ * The one precedence rule for a connector's display mark, first hit wins:
+ * 1. `manifest`: a mark the manifest owns;
+ * 2. `template`: the server-projected template mark, used when the console
+ * has no manifest mark of its own (the server copy is not normalized);
+ * 3. `bundled`: the shipped mark for the connector identity;
+ * 4. `generic`: the offline simple-icons mark;
+ * 5. nothing, which renders the deterministic monogram.
+ * Every surface reaches a mark through this function, so no two surfaces can
+ * order the same layers differently.
+ */
+export function pickDisplayMark(
+ layers: Pick
+): { icon: ConnectorIconLike | null; layer: L | undefined } {
+ // `layers` holds only the keys the caller supplied, so a layer the caller
+ // never passes can never be reported as the source.
+ const supplied: DisplayMarkLayers = layers;
+ for (const layer of DISPLAY_MARK_ORDER) {
+ const icon = supplied[layer];
+ if (icon) {
+ return { icon, layer: layer as L };
+ }
+ }
+ return { icon: null, layer: undefined };
+}
+
+/**
+ * The one place a connector manifest becomes a display mark: sanitized SVG, a
+ * validated backdrop, and where it came from. Every surface renders the result
+ * (`ConnectorManifest.icon`) as is, so none of them re-derives a backdrop.
+ * Layers are ordered by `pickDisplayMark`; a manifest alone has no template.
+ * Marks from the manifest take their backdrop from the mark itself, else from
+ * `brand.background_color`. Bundled and simple-icons marks carry their own.
+ */
+export async function resolveManifestDisplayMark(
+ manifest: Pick,
+ manifestsDir: string
+): Promise<{ icon: ConnectorIconLike | null; source: DisplayMarkSource | undefined }> {
+ const declaredBackground = manifest.brand?.background_color;
+ const inline = validatedConnectorIcon(manifest.icon, declaredBackground);
+ const fromPath =
+ !inline && manifest.brand?.icon
+ ? validatedConnectorIcon(
+ await resolveConnectorIconFromManifestPath(manifestsDir, manifest.brand.icon),
+ declaredBackground
+ )
+ : null;
+ const owned = inline ?? fromPath;
+ const bundled =
+ resolveConnectorIcon(manifest.connector_id) ??
+ (manifest.connector_key ? resolveConnectorIcon(manifest.connector_key) : null);
+ const generic =
+ !(owned || bundled) && manifest.connector_key
+ ? await resolveConnectorIconFromSimpleIcons(manifest.connector_key)
+ : null;
+ const { icon, layer } = pickDisplayMark({ bundled, generic, manifest: owned });
+ return { icon, source: layer === "generic" ? "simple-icons" : layer };
+}
diff --git a/apps/console/src/app/(console)/lib/rs-client.ts b/apps/console/src/app/(console)/lib/rs-client.ts
index 3b182ac07..dccb2454d 100644
--- a/apps/console/src/app/(console)/lib/rs-client.ts
+++ b/apps/console/src/app/(console)/lib/rs-client.ts
@@ -16,12 +16,11 @@
*/
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
+import type { ConnectorIconLike } from "@pdpp/brand-react";
import { findManifestForConnectorId } from "../sources/lib/relationships.ts";
import type { OwnerConnectorTemplateLike } from "./connection-catalog.ts";
import { resolveInstalledConnectorManifestsDir } from "./connector-manifests-dir.ts";
import { isActiveConnectorRunSummaryStatus } from "./connector-run-summary-status.ts";
-import { resolveConnectorIconFromManifestPath } from "./resolve-connector-icon-path.ts";
-import { resolveConnectorIconFromSimpleIcons } from "./resolve-connector-icon-simple-icons.ts";
import {
getOwnerToken,
getRsInternalUrl,
@@ -29,6 +28,7 @@ import {
ResourceServerHttpError,
} from "./owner-token.ts";
import { type CanonicalReadWarning, extractReadWarnings } from "./read-envelope.ts";
+import { resolveManifestDisplayMark } from "./resolve-manifest-display-mark.ts";
import type {
RefAcquisitionCoverageSummary,
RefCollectionReportEntry,
@@ -171,11 +171,9 @@ export interface ConnectorManifest {
connector_key?: string;
display_name?: string;
/** Optional manifest-declared brand glyph; absent renders the Monogram fallback (see ConnectorIcon). */
- icon?: {
- color?: string | null;
- kind?: string | null;
- svg?: string | null;
- } | null;
+ icon?: ConnectorIconLike | null;
+ /** Server-side enrichment provenance; never read from or written to a manifest. */
+ resolvedIconSource?: "manifest" | "bundled" | "simple-icons";
/**
* Shipped manifests declare their brand glyph here as a relative path
* under the connector-manifests directory (e.g. "icons/amazon.svg"), not
@@ -184,6 +182,7 @@ export interface ConnectorManifest {
* single `icon` (inline_svg) shape.
*/
brand?: {
+ background_color?: string | null;
icon?: string | null;
} | null;
name?: string;
@@ -817,21 +816,11 @@ export async function listConnectorManifests(): Promise {
try {
const raw = await readFile(join(dir, file), "utf8");
const manifest = JSON.parse(raw) as ConnectorManifest;
- // Three-layer icon resolution (first hit wins), so every reader
- // downstream keeps consuming the single `icon` (inline_svg) shape it
- // already knows:
- // 1. Bundled: the manifest's own `brand.icon` relative path (e.g.
- // "icons/amazon.svg"), shipped with every connector today.
- // 2. Vendored: a same-slug lookup into the offline simple-icons
- // corpus, for a connector added later with no bundled icon.
- // 3. Deterministic Monogram — @pdpp/brand-react's ConnectorIcon
- // fallback when `icon` is left unset here.
- if (!manifest.icon && manifest.brand?.icon) {
- manifest.icon = await resolveConnectorIconFromManifestPath(dir, manifest.brand.icon);
- }
- if (!manifest.icon && manifest.connector_key) {
- manifest.icon = await resolveConnectorIconFromSimpleIcons(manifest.connector_key);
- }
+ // `icon` leaves here as the one display mark every surface renders
+ // (sanitized SVG, validated backdrop); see resolveManifestDisplayMark.
+ const mark = await resolveManifestDisplayMark(manifest, dir);
+ manifest.icon = mark.icon;
+ manifest.resolvedIconSource = mark.source;
return manifest;
} catch {
return null;
diff --git a/apps/console/src/app/(console)/lib/sanitize-svg.test.ts b/apps/console/src/app/(console)/lib/sanitize-svg.test.ts
deleted file mode 100644
index 0611e4d34..000000000
--- a/apps/console/src/app/(console)/lib/sanitize-svg.test.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright The PDP-Connect Contributors
-// SPDX-License-Identifier: Apache-2.0
-
-import assert from "node:assert/strict";
-import test from "node:test";
-import { sanitizeSvg } from "./sanitize-svg.ts";
-
-test("accepts a minimal bare svg", () => {
- assert.equal(sanitizeSvg(' '), ' ');
-});
-
-test("accepts our shipped-manifest shape (aria-hidden, fill on root)", () => {
- const svg = ' ';
- assert.equal(sanitizeSvg(svg), svg);
-});
-
-test("accepts the simple-icons shape (role, title)", () => {
- const svg = 'GitHub ';
- assert.equal(sanitizeSvg(svg), svg);
-});
-
-test("rejects a script element", () => {
- assert.equal(sanitizeSvg(" "), null);
-});
-
-test("rejects an event-handler attribute", () => {
- assert.equal(sanitizeSvg(' '), null);
-});
-
-test("rejects an external reference via image href", () => {
- assert.equal(sanitizeSvg(' '), null);
-});
-
-test("rejects a javascript: or data: or url() attribute value", () => {
- assert.equal(sanitizeSvg(' '), null);
-});
-
-test("rejects a disallowed element", () => {
- assert.equal(sanitizeSvg('x
'), null);
-});
-
-test("rejects markup without a bare svg root", () => {
- assert.equal(sanitizeSvg(' '), null);
-});
-
-test("rejects non-SVG text", () => {
- assert.equal(sanitizeSvg("just some text, not markup"), null);
-});
-
-test("rejects empty content", () => {
- assert.equal(sanitizeSvg(""), null);
- assert.equal(sanitizeSvg(" "), null);
-});
-
-test("rejects oversized content", () => {
- const huge = `${" ".repeat(1000)} `;
- assert.equal(sanitizeSvg(huge), null);
-});
diff --git a/apps/console/src/app/(console)/schedules/schedule-row.tsx b/apps/console/src/app/(console)/schedules/schedule-row.tsx
index 827606dc7..6d534bce8 100644
--- a/apps/console/src/app/(console)/schedules/schedule-row.tsx
+++ b/apps/console/src/app/(console)/schedules/schedule-row.tsx
@@ -261,7 +261,7 @@ export function ScheduleRow({ connectorIcon, summary, runsHref }: ScheduleRowPro
className="pdpp-body relative font-medium text-foreground before:absolute before:-inset-3 before:content-[''] hover:underline"
href={recordsHref}
>
-
+
{displayName}
{connectorKey}
diff --git a/apps/console/src/app/(console)/sources/[connector]/[stream]/[recordKey]/page.tsx b/apps/console/src/app/(console)/sources/[connector]/[stream]/[recordKey]/page.tsx
index db67a791a..45eab3890 100644
--- a/apps/console/src/app/(console)/sources/[connector]/[stream]/[recordKey]/page.tsx
+++ b/apps/console/src/app/(console)/sources/[connector]/[stream]/[recordKey]/page.tsx
@@ -239,7 +239,7 @@ export default async function RecordDetailPage({
description={
<>
-
+
{sourceLabel}
{ts.label}
diff --git a/apps/console/src/app/(console)/sources/[connector]/[stream]/health/page.tsx b/apps/console/src/app/(console)/sources/[connector]/[stream]/health/page.tsx
index 20b8b830a..33a0a5313 100644
--- a/apps/console/src/app/(console)/sources/[connector]/[stream]/health/page.tsx
+++ b/apps/console/src/app/(console)/sources/[connector]/[stream]/health/page.tsx
@@ -89,6 +89,7 @@ export default async function StreamHealthPage({
return (
-
+
Source {sourceLabel}
}
@@ -283,11 +287,13 @@ function StreamHealthReport({
// to the records segment error boundary.
function StreamHealthUnavailable({
connectionId,
+ connectorId,
connectorIcon,
sourceLabel,
streamName,
}: {
connectionId: string;
+ connectorId: string;
connectorIcon: ConnectorManifest["icon"];
sourceLabel: string;
streamName: string;
@@ -303,7 +309,7 @@ function StreamHealthUnavailable({
]}
description={
-
+
Source {sourceLabel}
}
diff --git a/apps/console/src/app/(console)/sources/[connector]/[stream]/page.tsx b/apps/console/src/app/(console)/sources/[connector]/[stream]/page.tsx
index a3bb1cbc4..e5c3a54fc 100644
--- a/apps/console/src/app/(console)/sources/[connector]/[stream]/page.tsx
+++ b/apps/console/src/app/(console)/sources/[connector]/[stream]/page.tsx
@@ -457,7 +457,7 @@ export default async function StreamPage({
count={headerCount}
description={
-
+
Source {sourceLabel}
diff --git a/apps/console/src/app/(console)/sources/[connector]/page.tsx b/apps/console/src/app/(console)/sources/[connector]/page.tsx
index b653295bc..fec3f69c5 100644
--- a/apps/console/src/app/(console)/sources/[connector]/page.tsx
+++ b/apps/console/src/app/(console)/sources/[connector]/page.tsx
@@ -846,7 +846,7 @@ function ConnectorPageView({
}
title={
-
+
{displayName}
diff --git a/apps/console/src/app/(console)/sources/add/page.tsx b/apps/console/src/app/(console)/sources/add/page.tsx
index fe9136530..eb769b5d6 100644
--- a/apps/console/src/app/(console)/sources/add/page.tsx
+++ b/apps/console/src/app/(console)/sources/add/page.tsx
@@ -15,6 +15,7 @@ import type { ConnectorInstallSnapshot } from "../../lib/connector-install-contr
import type { ConnectorInstallLifecycle } from "../../lib/connector-install-presentation.ts";
import { buildConnectorInstallLifecycleByConnector } from "../../lib/connector-install-presentation.ts";
import { getConnectorInstallSnapshot } from "../../lib/connector-install-client.ts";
+import { buildConnectorIconMap, catalogEntryIcon } from "../../lib/connector-icon-map.ts";
import { ReferenceServerUnreachableError } from "../../lib/owner-token.ts";
import { listConnectorManifests, listOwnerConnectorTemplates } from "../../lib/rs-client.ts";
import { isDeterministicSourcesReadError } from "../read-error-classification.ts";
@@ -64,13 +65,11 @@ export default async function AddSourcePage({ searchParams }: { searchParams: Pr
listOwnerConnectorTemplates(),
getConnectorInstallSnapshot(),
]);
- connectorIcons = Object.fromEntries(
- manifests.flatMap((manifest) => [
- [manifest.connector_id, manifest.icon] as const,
- ...(manifest.connector_key ? ([[manifest.connector_key, manifest.icon]] as const) : []),
- ])
- );
- catalog = buildOwnerConnectorCatalog(manifests, templates, installSnapshot?.catalog ?? []);
+ connectorIcons = await buildConnectorIconMap(manifests, templates);
+ catalog = buildOwnerConnectorCatalog(manifests, templates, installSnapshot?.catalog ?? []).map((entry) => ({
+ ...entry,
+ icon: catalogEntryIcon(entry, connectorIcons),
+ }));
installLifecycleByConnector = installSnapshot
? buildConnectorInstallLifecycleByConnector(installSnapshot.catalog, installSnapshot.status)
: null;
diff --git a/apps/console/src/app/(console)/sources/page.tsx b/apps/console/src/app/(console)/sources/page.tsx
index 26508299c..9ee6c7274 100644
--- a/apps/console/src/app/(console)/sources/page.tsx
+++ b/apps/console/src/app/(console)/sources/page.tsx
@@ -36,6 +36,7 @@ import {
} from "../components/connector-summary-page.tsx";
import { isPagedRequest, parseConnectorSummaryPageState } from "../components/connector-summary-pager.ts";
import { ServerUnreachable } from "../components/server-unreachable.tsx";
+import { buildConnectorIconMap } from "../lib/connector-icon-map.ts";
import { isActiveConnectorRunSummaryStatus } from "../lib/connector-run-summary-status.ts";
import { liveDashboardDataSource } from "../lib/data-source.ts";
import { getReferencePublicOrigin, ReferenceServerUnreachableError } from "../lib/owner-token.ts";
@@ -117,11 +118,16 @@ export default async function RecordsIndexPage({
}
let manifests: Awaited>;
+ let connectorIcons: Awaited>;
let page: Awaited>;
try {
- const [pageResult, connectorManifests] = await Promise.all([fetchSourcesPage(pageState), listConnectorManifests()]);
+ const [pageResult, connectorManifests] = await Promise.all([
+ fetchSourcesPage(pageState),
+ listConnectorManifests(),
+ ]);
page = pageResult;
manifests = connectorManifests;
+ connectorIcons = await buildConnectorIconMap(manifests, []);
} catch (err) {
if (err instanceof ReferenceServerUnreachableError) {
return (
@@ -145,7 +151,7 @@ export default async function RecordsIndexPage({
const summaries = [...page.items];
const runtimeAdvisory: SourcesRuntimeAdvisory | null = buildSourcesRuntimeAdvisory(page.runtime);
- const instances = toSourcesView(summaries, { manifests });
+ const instances = toSourcesView(summaries, { connectorIcons, manifests });
// The poller is mounted unconditionally; `running` (derived from any active
// run) only selects the fast vs. idle cadence. Named `runningCount` to match
// the records-poller mount invariant.
diff --git a/apps/console/src/app/(console)/sources/sources-view-model.ts b/apps/console/src/app/(console)/sources/sources-view-model.ts
index 548cf568d..f638a255d 100644
--- a/apps/console/src/app/(console)/sources/sources-view-model.ts
+++ b/apps/console/src/app/(console)/sources/sources-view-model.ts
@@ -28,6 +28,7 @@
*/
import type { FusedSourceStatus, StreamManifestEntry } from "@pdpp/display";
+import type { ConnectorIconLike } from "@pdpp/brand-react";
import {
deriveSourceDisplayNameFallback,
formatConnectorNameForDisplay,
@@ -593,7 +594,11 @@ function sourceScopeForSummary(summary: RefConnectorSummary, revoked: boolean):
export function toSourceInstanceView(
summary: RefConnectorSummary,
- options: { fallbackDisambiguator?: string | null; manifests?: readonly SourceManifestLike[] } = {}
+ options: {
+ connectorIcons?: Readonly>;
+ fallbackDisambiguator?: string | null;
+ manifests?: readonly SourceManifestLike[];
+ } = {}
): SourceInstanceView {
const connectorId = summary.connector_id;
const connectionId = summary.connection_id;
@@ -727,7 +732,7 @@ export function toSourceInstanceView(
connectorNotInstalled,
detailHref: sourceDetailHrefFor(routeId, summary),
displayName,
- icon: manifest?.icon ?? null,
+ icon: options.connectorIcons?.[connectorId] ?? manifest?.icon ?? null,
id: routeId,
sourceScope,
isLocalDevicePush,
@@ -874,7 +879,10 @@ export function collapseSetupFailedSources(instances: readonly SourceInstanceVie
*/
export function toSourcesView(
summaries: RefConnectorSummary[],
- options: { manifests?: readonly SourceManifestLike[] } = {}
+ options: {
+ connectorIcons?: Readonly>;
+ manifests?: readonly SourceManifestLike[];
+ } = {}
): SourceInstanceView[] {
const visibleSummaries = summaries;
const fallbackCountByConnector = new Map();
diff --git a/apps/console/src/app/(console)/sources/sources-view.css b/apps/console/src/app/(console)/sources/sources-view.css
index 7fd98d1b4..5582350b8 100644
--- a/apps/console/src/app/(console)/sources/sources-view.css
+++ b/apps/console/src/app/(console)/sources/sources-view.css
@@ -75,10 +75,6 @@ a.rr-s-item {
min-width: 0;
}
-.rr-s-item__icon {
- flex-shrink: 0;
-}
-
.rr-s-item__name {
/* The bold human name is the clear primary identity on row 1. */
overflow: hidden;
diff --git a/apps/console/src/app/(console)/sources/sources-view.tsx b/apps/console/src/app/(console)/sources/sources-view.tsx
index e024d5559..458e0a489 100644
--- a/apps/console/src/app/(console)/sources/sources-view.tsx
+++ b/apps/console/src/app/(console)/sources/sources-view.tsx
@@ -394,7 +394,7 @@ function InstanceListItem({
const inner = (
<>
-
+
{instance.displayName}
{/* Keep list rows comparable: connector kind lives in the selected detail
diff --git a/apps/console/src/app/(console)/syncs/[runId]/page.tsx b/apps/console/src/app/(console)/syncs/[runId]/page.tsx
index e3e5b64ff..2db5c04ed 100644
--- a/apps/console/src/app/(console)/syncs/[runId]/page.tsx
+++ b/apps/console/src/app/(console)/syncs/[runId]/page.tsx
@@ -218,11 +218,14 @@ export default async function RunDetailPage({
// Keep the label visible even when this timeline window has no connection id.
const connectorLabel = (
<>
-
+ {connectorId ? (
+
+ ) : null}
{connectorId}
>
);
diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/page.tsx b/apps/console/src/app/(console)/syncs/[runId]/stream/page.tsx
index c764c521b..c6a8fabbd 100644
--- a/apps/console/src/app/(console)/syncs/[runId]/stream/page.tsx
+++ b/apps/console/src/app/(console)/syncs/[runId]/stream/page.tsx
@@ -310,7 +310,7 @@ function RunEndedSurface({
run {statusLabel}
- {connector ? : null}
+ {connector ? : null}
{title}
{description}
@@ -416,7 +416,7 @@ function UnavailableStreamSurface({ connector, runId }: { connector: ConnectorCo
{connector ? (
<>
-
+
{connector.displayName}
{" "}
needs browser control. Keep this page open while the secure browser prepares. The page updates
diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx
index c222b2e40..504b1c647 100644
--- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx
+++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx
@@ -1577,6 +1577,7 @@ export function StreamSurface({
- {connectorName ? (
+ {connectorName && connectorId ? (
-
+
{connectorName}
) : null}
@@ -5951,7 +5954,7 @@ export function ResolvedSurface({ connector, runId }: { connector: ConnectorCont
>
-
+ {connector ? : null}
{subject} is back on it.
@@ -5998,7 +6001,7 @@ function UnsupportedSurface({
>
{connector ? (
-
+
{connector.displayName}
) : null}
diff --git a/apps/console/src/app/(console)/syncs/page.tsx b/apps/console/src/app/(console)/syncs/page.tsx
index 8069890ba..6d256e46d 100644
--- a/apps/console/src/app/(console)/syncs/page.tsx
+++ b/apps/console/src/app/(console)/syncs/page.tsx
@@ -37,6 +37,7 @@ import {
import { isPagedRequest, parseConnectorSummaryPageState } from "../components/connector-summary-pager.ts";
import { LivePoller } from "../components/live-poller.tsx";
import { ServerUnreachable } from "../components/server-unreachable.tsx";
+import { buildConnectorIconMap } from "../lib/connector-icon-map.ts";
import { ReferenceServerUnreachableError } from "../lib/owner-token.ts";
import { type ListResponse, listConnectorSummaries, listRuns, type RunSummary } from "../lib/ref-client.ts";
import { listConnectorManifests } from "../lib/rs-client.ts";
@@ -140,12 +141,12 @@ export default async function RunsPage({ searchParams }: { searchParams: Promise
const pageState = parseConnectorSummaryPageState(params);
let runsResult: ListResponse;
let connectorsPage: Awaited>;
- let connectorManifests: Awaited>;
+ let connectorIcons: Awaited>;
try {
- [runsResult, connectorsPage, connectorManifests] = await Promise.all([
+ [runsResult, connectorsPage, connectorIcons] = await Promise.all([
listRuns(runListQuery(params)),
fetchSyncsConnectorsPage(pageState),
- listConnectorManifests().catch(() => []),
+ listConnectorManifests().catch(() => []).then((manifests) => buildConnectorIconMap(manifests, [])),
]);
} catch (err) {
if (err instanceof ReferenceServerUnreachableError) {
@@ -172,13 +173,6 @@ export default async function RunsPage({ searchParams }: { searchParams: Promise
});
const liveRunCount = runsResult.data.filter(isLiveRun).length;
- const connectorIcons = Object.fromEntries(
- connectorManifests.flatMap((manifest) => [
- [manifest.connector_id, manifest.icon] as const,
- ...(manifest.connector_key ? ([[manifest.connector_key, manifest.icon]] as const) : []),
- ])
- );
-
return (
0} />
diff --git a/apps/console/src/app/(console)/syncs/syncs-view.tsx b/apps/console/src/app/(console)/syncs/syncs-view.tsx
index 873c88507..a24072abd 100644
--- a/apps/console/src/app/(console)/syncs/syncs-view.tsx
+++ b/apps/console/src/app/(console)/syncs/syncs-view.tsx
@@ -159,7 +159,7 @@ function FailureCardPanel({ card, connectorIcons }: { card: FailureCard; connect
-
+
{card.name} — {summary.triggerLabel}
{summary.prose}
@@ -211,7 +211,7 @@ function PendingSetupCardPanel({ card, connectorIcons }: { card: PendingSetupCar
-
+
{card.name} — {card.statusLabel}
{card.what}
@@ -299,7 +299,7 @@ function DuplicateSyncGroupPanel({ group, connectorIcons }: { group: DuplicateSy
Several sources need labels
-
+
{group.total.toLocaleString()} unnamed {group.kind} sources are collapsed in this overview.
@@ -338,7 +338,14 @@ function RecentSyncRow({ connectorIcons, entry }: { connectorIcons: ConnectorIco
-
+ {entry.connectorId ? (
+
+ ) : null}
{entry.connectionName}
{entry.runId}
@@ -676,7 +683,7 @@ function SyncGroupBlock({ connectorIcons, group }: { connectorIcons: ConnectorIc
-
+
{group.name}
{group.connectionId}
diff --git a/apps/console/src/components/consent-screen/consent-screen-client.tsx b/apps/console/src/components/consent-screen/consent-screen-client.tsx
index 33df1e608..e29bfa5db 100644
--- a/apps/console/src/components/consent-screen/consent-screen-client.tsx
+++ b/apps/console/src/components/consent-screen/consent-screen-client.tsx
@@ -7,7 +7,7 @@
* The consent screen — the page an owner uses to decide what an app may read.
*
* Built from the REAL `@pdpp/brand-react` components (IcButton, IcInput,
- * ConnectorIcon, Endorse) so there is zero drift by construction: this renders
+ * Endorse) so there is zero drift by construction: this renders
* the actual component modules the console's `/sources` and `/explore` pages
* render, not a transcription of their output. `consent-screen.module.css`
* supplies ONLY the structural rules those components have no equivalent for
@@ -21,10 +21,9 @@
* is true — what the client said its purpose was, whether it made a retention
* promise — the statement itself arrives in the model and is rendered as-is.
*
- * Connector icons come from each source's own manifest declaration, passed
- * straight to `ConnectorIcon`, which falls back to its Monogram for a
- * connector that declares none. There is no connector-id -> icon map here or
- * anywhere else.
+ * Connector marks use the resolved connector identity and optional icon from
+ * the server. Source IDs and opaque grant-selection values remain separate.
+ * Older servers without display identity retain their explicit-icon fallback.
*
* Preserves the owner feedback items from rounds 1-4 of the design task
* (/home/tnunamak/code/pdpp/local/CONSENT-OWNER-FEEDBACK-0902.md): arbitrary
@@ -35,7 +34,6 @@
*/
import {
- ConnectorIcon,
Endorse,
IcButton,
IcInput,
@@ -48,6 +46,7 @@ import {
IcTooltipTrigger,
} from "@pdpp/brand-react";
import { useEffect, useMemo, useState } from "react";
+import { ConnectorMark } from "@/app/(console)/components/connector-mark.tsx";
import { PdppLogo } from "@/components/pdpp-logo.tsx";
import { ThemeToggle } from "@/components/theme/theme-toggle.tsx";
import styles from "./consent-screen.module.css";
@@ -434,7 +433,7 @@ function SourceRow({
>
-
+
{/* Layout in CSS, not inline: the account handle can only truncate
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 9e23d7c62..a5ce11cb8 100644
--- a/apps/console/src/components/consent-screen/consent-screen-model.ts
+++ b/apps/console/src/components/consent-screen/consent-screen-model.ts
@@ -56,6 +56,8 @@ export interface ConsentStreamModel {
export interface ConsentSourceModel {
readonly account: string;
+ /** Optional for compatibility with servers predating display identity. */
+ readonly connectorId?: string;
readonly icon?: ConsentSourceIcon | null;
readonly id: string;
readonly name: string;
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 575e3016a..a673eb807 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
@@ -226,3 +226,27 @@ test("cleared dates and dates on deselected streams are omitted", async () => {
fireEvent.click(view.getByRole("checkbox", { name: `Share ${saved.label} from Spotify` }));
assert.deepEqual((await submit()).streamRanges, {});
});
+
+test("connector marks use resolved connector identity without changing consent selection", async () => {
+ const source = model.sources[0];
+ assert.equal(source.connectorId, manifest.connector_id);
+ assert.equal(source.id, "spotify:account");
+ assert.equal(source.selectionValue, "source");
+ const view = render(createElement(ConsentScreen, {
+ connectorIndex: {}, model, acceptAction: async () => "", rejectAction: async () => "",
+ }));
+ assert.ok(view.container.querySelector(".pdpp-connector-icon svg"), "the connector mark should not depend on an optional manifest icon");
+});
+
+test("older consent models without connector provenance keep the genuine fallback", () => {
+ const legacy = structuredClone(model);
+ for (const source of legacy.sources) {
+ delete source.connectorId;
+ source.icon = null;
+ }
+ const view = render(createElement(ConsentScreen, {
+ connectorIndex: {}, model: legacy, acceptAction: async () => "", rejectAction: async () => "",
+ }));
+ 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");
+});
diff --git a/apps/console/src/components/consent-screen/consent-screen.module.css b/apps/console/src/components/consent-screen/consent-screen.module.css
index 37443054c..d2de9ab92 100644
--- a/apps/console/src/components/consent-screen/consent-screen.module.css
+++ b/apps/console/src/components/consent-screen/consent-screen.module.css
@@ -232,8 +232,6 @@
}
.sourceIcon {
flex: 0 0 auto;
- width: 24px;
- height: 24px;
}
.sourceText {
display: flex;
diff --git a/apps/console/src/components/consent-screen/consent-screen.test.ts b/apps/console/src/components/consent-screen/consent-screen.test.ts
index f7deaae97..fcfecfc72 100644
--- a/apps/console/src/components/consent-screen/consent-screen.test.ts
+++ b/apps/console/src/components/consent-screen/consent-screen.test.ts
@@ -137,7 +137,7 @@ test("the digest covers the client identity the screen displayed", () => {
test("the consent screen uses the real @pdpp/brand-react component modules", () => {
assert.match(CLIENT_SOURCE, /from ["']@pdpp\/brand-react["']/);
- for (const component of ["ConnectorIcon", "Endorse", "IcButton", "IcInput"]) {
+ for (const component of ["Endorse", "IcButton", "IcInput"]) {
assert.ok(new RegExp(`\\b${component}\\b`).test(CLIENT_SOURCE), `must use the real ${component}`);
}
assert.match(CLIENT_SOURCE, /className="pdpp-sheet"/, "Terms box must use the real neutral sheet surface");
@@ -154,11 +154,10 @@ test("the consent screen carries the console's own theme control", () => {
assert.match(CLIENT_SOURCE, / /, "must render the toggle");
});
-test("connector icons come from the manifest, never a page-local asset directory", () => {
- // ConnectorIcon renders the manifest's own `icon` declaration and falls back
- // to its Monogram. A connector-id -> file map anywhere would mean a
- // connector the console has never heard of renders worse than one it has.
- assert.match(CLIENT_SOURCE, / {
+ assert.match(CLIENT_SOURCE, /import \{ ConnectorMark \} from "@\/app\/\(console\)\/components\/connector-mark\.tsx"/);
+ assert.match(CLIENT_SOURCE, /, 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
-// href/xlink:href variant — is deliberately absent. There is no fetch, no
-// script execution, and no navigation surface reachable from an SVG built
-// only from this vocabulary.
-const SVG_ALLOWED_ELEMENTS = new Set([
- "svg",
- "g",
- "path",
- "circle",
- "rect",
- "ellipse",
- "line",
- "polyline",
- "polygon",
- "title",
- "defs",
-]);
-const SVG_ALLOWED_ATTRIBUTES = new Set([
- "viewbox",
- "xmlns",
- "width",
- "height",
- "fill",
- "stroke",
- "stroke-width",
- "stroke-linecap",
- "stroke-linejoin",
- "d",
- "cx",
- "cy",
- "r",
- "rx",
- "ry",
- "x",
- "y",
- "x1",
- "y1",
- "x2",
- "y2",
- "points",
- "opacity",
- "fill-rule",
- "clip-rule",
- "transform",
-]);
-// Any of these substrings inside an attribute value is grounds for outright
-// rejection regardless of which attribute carries it — a shape-only icon has
-// no legitimate use for a URL scheme or a CSS url() reference.
-const SVG_ATTRIBUTE_VALUE_DENY_RE = /javascript:|data:|url\(/i;
-const SVG_MAX_LENGTH = 10_000;
-
-// Tokenizes top-level XML/SVG markup into tags and the text between them.
-// Deliberately dumb: this only needs to walk ``, ` `, and
-// ` ` shapes far enough to hand each one to the allowlist checks below —
-// it is not a general XML parser and does not need to be, since anything it
-// cannot make sense of is rejected rather than passed through.
-const SVG_TAG_RE = /<([^>]*)>/g;
-// A single `name="value"` or `name='value'` pair inside a tag's attribute list.
-const SVG_ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
-// The element/tag name at the start of a tag body (after any leading `/`).
-const SVG_TAG_NAME_RE = /^([a-zA-Z_:][-a-zA-Z0-9_:.]*)/;
-
-interface ParsedSvgTag {
- attrs: Map;
- attrsExhaustive: boolean;
- closing: boolean;
- name: string;
- selfClosing: boolean;
-}
-
-function parseSvgTag(raw: string): ParsedSvgTag | null {
- let body = raw.trim();
- const closing = body.startsWith("/");
- if (closing) {
- body = body.slice(1).trim();
- }
- const selfClosing = body.endsWith("/");
- if (selfClosing) {
- body = body.slice(0, -1).trim();
- }
- const nameMatch = SVG_TAG_NAME_RE.exec(body);
- const rawName = nameMatch?.[1];
- if (!rawName) {
- return null;
- }
- const name = rawName.toLowerCase();
- const attrSource = body.slice(rawName.length);
- const attrs = new Map();
- SVG_ATTR_RE.lastIndex = 0;
- let match = SVG_ATTR_RE.exec(attrSource);
- while (match) {
- const [, rawAttrName, doubleQuoted, singleQuoted] = match;
- if (rawAttrName) {
- attrs.set(rawAttrName.toLowerCase(), doubleQuoted ?? singleQuoted ?? "");
- }
- match = SVG_ATTR_RE.exec(attrSource);
- }
- // Anything left over after stripping every recognized `name="value"` pair
- // means the attribute list contains something the tokenizer could not
- // classify (an unmatched `<`/`>`, a stray quote, an unquoted value) — that
- // markup is rejected rather than silently dropped.
- const attrsExhaustive = attrSource.replace(SVG_ATTR_RE, "").trim().length === 0;
- return { attrs, attrsExhaustive, closing, name, selfClosing };
-}
-
-// Validates one already-tokenized tag against the element/attribute
-// allowlist (order-preserving). Split out of assertSvgIsAllowlisted so the
-// latter only handles tokenizing the markup and tracking the bare--root
-// invariant; this handles the per-tag allowlist gate.
-function assertTagIsAllowlisted(parsed: ParsedSvgTag, code: string): void {
- if (!parsed.attrsExhaustive) {
- throw invalidConnectorManifest("icon.svg contains malformed markup", code);
- }
- if (!SVG_ALLOWED_ELEMENTS.has(parsed.name)) {
- throw invalidConnectorManifest(`icon.svg contains a disallowed element: ${parsed.name}`, code);
- }
- for (const [attrName, attrValue] of parsed.attrs) {
- if (!SVG_ALLOWED_ATTRIBUTES.has(attrName)) {
- throw invalidConnectorManifest(`icon.svg contains a disallowed attribute: ${attrName}`, code);
- }
- if (SVG_ATTRIBUTE_VALUE_DENY_RE.test(attrValue)) {
- throw invalidConnectorManifest(`icon.svg attribute ${attrName} contains a disallowed value`, code);
- }
- }
-}
-
-/**
- * Validates that `svg` uses only the shape-only element/attribute vocabulary
- * declared above. This is the SOLE XSS defense for manifest-declared icons —
- * `icon.svg` reaches the DOM via dangerouslySetInnerHTML in
- * packages/pdpp-brand-react/src/connector-icon.tsx, and this function is the
- * single choke point that markup must pass through first. It is a strict
- * ALLOWLIST (reject anything not explicitly permitted), not a denylist —
- * nothing here enumerates attack vectors, because there is no elements/
- * attributes vocabulary through which script execution, external fetches, or
- * navigation are reachable in the first place.
- */
+// `icon.svg` is untrusted markup that reaches the DOM through
+// dangerouslySetInnerHTML. The console renders it only when `sanitizeSvg` accepts it,
+// so the server asks the same function instead of keeping its own allowlist:
+// one vocabulary, one verdict. Imported by relative path because the staged
+// reference stack resolves only LOCAL_PACKAGES, not workspace packages.
function assertSvgIsAllowlisted(svg: string, code: string): void {
- if (svg.length > SVG_MAX_LENGTH) {
- throw invalidConnectorManifest(`icon.svg must not exceed ${SVG_MAX_LENGTH} characters`, code);
- }
- SVG_TAG_RE.lastIndex = 0;
- const tags: ParsedSvgTag[] = [];
- let sawSvgRoot = false;
- let cursor = 0;
- let match = SVG_TAG_RE.exec(svg);
- while (match) {
- // Text strictly between tags is inert (SVG has no text-execution sink in
- // this vocabulary — content is the only text ever rendered, and
- // it is not markup), but a bare `<` or `>` outside a well-formed tag is
- // grounds for rejection rather than silent tolerance.
- if (match.index !== cursor && svg.slice(cursor, match.index).includes("<")) {
- throw invalidConnectorManifest("icon.svg contains malformed markup", code);
- }
- cursor = SVG_TAG_RE.lastIndex;
- const parsed = parseSvgTag(match[1] ?? "");
- if (!parsed) {
- throw invalidConnectorManifest("icon.svg contains malformed markup", code);
- }
- assertTagIsAllowlisted(parsed, code);
- if (parsed.name === "svg" && !parsed.closing) {
- sawSvgRoot = true;
- }
- tags.push(parsed);
- match = SVG_TAG_RE.exec(svg);
- }
- if (cursor < svg.length && svg.slice(cursor).includes("<")) {
- throw invalidConnectorManifest("icon.svg contains malformed markup", code);
- }
- if (!sawSvgRoot) {
- throw invalidConnectorManifest("icon.svg must be a bare element", code);
- }
- const [rootTag] = tags;
- if (rootTag?.name !== "svg" || rootTag.closing) {
- throw invalidConnectorManifest("icon.svg must be a bare element", code);
+ if (sanitizeSvg(svg) === null) {
+ throw invalidConnectorManifest(
+ "icon.svg must be one bare element built only from allowed shape elements and attributes",
+ code
+ );
}
}
diff --git a/reference-implementation/server/routes/as-consent-ui-helpers.ts b/reference-implementation/server/routes/as-consent-ui-helpers.ts
index 764a5bf3c..ff186f4a6 100644
--- a/reference-implementation/server/routes/as-consent-ui-helpers.ts
+++ b/reference-implementation/server/routes/as-consent-ui-helpers.ts
@@ -758,6 +758,8 @@ export interface HostedMcpConsentChallengeModel {
readonly reviewDigest: string;
readonly sources: ReadonlyArray<{
readonly account: string;
+ /** 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 id: string;
@@ -898,6 +900,7 @@ export async function buildHostedMcpConsentChallengeModel(
// separately, so using it as a fallback would display two conflicting
// "data types" facts on the same collapsed row.
account: row.connectionName ?? "",
+ connectorId: row.connectorId,
icon: icons.get(row.connectorId) ?? null,
id: row.sourceKey,
name: row.connectorTypeLabel,
diff --git a/reference-implementation/test/manifest-icon.test.ts b/reference-implementation/test/manifest-icon.test.ts
index c88e0bb8a..5691436b1 100644
--- a/reference-implementation/test/manifest-icon.test.ts
+++ b/reference-implementation/test/manifest-icon.test.ts
@@ -15,6 +15,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { validateManifestIcon } from "../server/connector-manifest-validation.ts";
import { startServer } from "../server/index.ts";
+import { sanitizeSvg } from "../vendor/brand-react/src/sanitize-svg.ts";
type StartedServer = Awaited>;
@@ -149,7 +150,7 @@ test("manifest icon.svg containing a script tag is rejected", async () => {
assert.equal(status, 400);
assert.ok(hasErrorBody(body));
// biome-ignore lint/performance/useTopLevelRegex: localized test assertion preserves its explicit contract.
- assert.match(body.error.message, /disallowed element: script/u);
+ assert.match(body.error.message, /icon\.svg must be one bare element/u);
});
});
@@ -164,7 +165,7 @@ test("manifest icon.svg with an event-handler attribute is rejected", async () =
assert.equal(status, 400);
assert.ok(hasErrorBody(body));
// biome-ignore lint/performance/useTopLevelRegex: localized test assertion preserves its explicit contract.
- assert.match(body.error.message, /disallowed attribute: onload/u);
+ assert.match(body.error.message, /icon\.svg must be one bare element/u);
});
});
@@ -176,11 +177,8 @@ test("manifest icon.svg not wrapped in a bare element is rejected", async
);
assert.equal(status, 400);
assert.ok(hasErrorBody(body));
- // is rejected as a disallowed element before the bare-
-root
- // check ever runs — a stronger rejection than the old denylist's, which
- // only checked the root shape.
// biome-ignore lint/performance/useTopLevelRegex: localized test assertion preserves its explicit contract.
- assert.match(body.error.message, /disallowed element: div/u);
+ assert.match(body.error.message, /icon\.svg must be one bare element/u);
});
});
@@ -193,7 +191,7 @@ test("manifest icon.svg whose root is not is rejected even when every elem
assert.equal(status, 400);
assert.ok(hasErrorBody(body));
// biome-ignore lint/performance/useTopLevelRegex: localized test assertion preserves its explicit contract.
- assert.match(body.error.message, /bare /u);
+ assert.match(body.error.message, /icon\.svg must be one bare element/u);
});
});
@@ -329,3 +327,51 @@ test("validateManifestIcon: allowlist rejects disallowed elements/attributes/val
validateManifestIcon({ icon: { kind: "inline_svg", svg: STEAM_SHAPED_SVG } }, "invalid_request")
);
});
+
+// ---------------------------------------------------------------------------
+// One allowlist. The console renders a manifest icon only if sanitizeSvg
+// (vendor/brand-react, the function behind validatedConnectorIcon) accepts it.
+// The server must therefore accept exactly what that function accepts: an icon
+// the server stores but the console drops silently shows a monogram, and an
+// icon the server rejects but the console would render is a second list to
+// keep in step. Every row below was decided differently by the two old lists.
+// ---------------------------------------------------------------------------
+
+const VERDICT_CASES: ReadonlyArray<{ name: string; svg: string }> = [
+ {
+ name: "root with role and aria-hidden",
+ svg: ' ',
+ },
+ { name: " wrapper", svg: ' ' },
+ { name: "duplicate attribute", svg: ' ' },
+ { name: "character reference in a value", svg: ' ' },
+ { name: "backslash escape in a value", svg: ' ' },
+ { name: "unclosed group", svg: ' ' },
+ { name: "stray closing tag", svg: ' ' },
+ { name: "mismatched closing tag", svg: ' ' },
+ { name: "two roots", svg: ' ' },
+ { name: "text after the root", svg: ' trailing' },
+ { name: "title caption", svg: 'Mark ' },
+ { name: "single-quoted attributes", svg: " " },
+ { name: "closing tag with attributes", svg: ' ' },
+ { name: "self-closing root", svg: ' ' },
+ { name: "url() in a value", svg: ' ' },
+ { name: "mixed-case element and attribute names", svg: ' ' },
+ ...XSS_VECTORS,
+];
+
+function serverAccepts(svg: string): boolean {
+ try {
+ validateManifestIcon({ icon: { kind: "inline_svg", svg } }, "invalid_request");
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+for (const { name, svg } of VERDICT_CASES) {
+ test(`server validator and console sanitizer give one verdict: ${name}`, () => {
+ const console = sanitizeSvg(svg) !== null;
+ assert.equal(serverAccepts(svg), console, `console sanitizer ${console ? "accepts" : "rejects"} this SVG`);
+ });
+}
diff --git a/reference-implementation/vendor/brand-react/package.json b/reference-implementation/vendor/brand-react/package.json
index ec279dfb6..fd80dc571 100644
--- a/reference-implementation/vendor/brand-react/package.json
+++ b/reference-implementation/vendor/brand-react/package.json
@@ -8,7 +8,9 @@
"exports": {
".": "./src/index.ts",
"./components.css": "./src/components.css",
+ "./hex-color": "./src/hex-color.ts",
"./product-identity": "./src/product-identity.ts",
+ "./sanitize-svg": "./src/sanitize-svg.ts",
"./shell.css": "./src/shell.css"
},
"scripts": {
diff --git a/reference-implementation/vendor/brand-react/src/connector-icon.css b/reference-implementation/vendor/brand-react/src/connector-icon.css
index b703ca99d..e89873fbd 100644
--- a/reference-implementation/vendor/brand-react/src/connector-icon.css
+++ b/reference-implementation/vendor/brand-react/src/connector-icon.css
@@ -1,22 +1,38 @@
/* Copyright The PDP-Connect Contributors */
/* SPDX-License-Identifier: Apache-2.0 */
-/* ─── ConnectorIcon — manifest-declared brand mark ───────────────────
- Only the manifest-icon (inline_svg) render path uses this class; the
- no-icon fallback renders the shared Monogram primitive (see data-row.tsx
- and its `--pdpp-monogram-hue` / .pdpp-monogram--tinted styling in
- components.css) directly, so the two states share one fallback look
- rather than each defining their own. */
-.pdpp-connector-icon {
- display: inline-flex;
- flex-shrink: 0;
- align-items: center;
- justify-content: center;
- width: 20px;
- height: 20px;
+/* One identity tile for logos and genuine initials fallbacks. Utility sizes
+ remain available for compact rows and larger headings. Brand tiles are
+ rounded independently of the product's square panels and controls. */
+@layer components {
+ .pdpp-connector-icon {
+ display: inline-flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ overflow: hidden;
+ vertical-align: middle;
+ border-radius: 25%;
+ }
+
+ .pdpp-connector-icon > svg {
+ width: 60%;
+ height: 60%;
+ }
+
+ /* Unpainted generic SVGs follow the text color in either theme. Explicit
+ brand fills and strokes remain as declared in the source artwork. */
+ .pdpp-connector-icon > svg:not([fill]) {
+ fill: currentcolor;
+ }
}
-.pdpp-connector-icon svg {
+/* The shared Monogram owns its initials and hue. Only its enclosing tile's
+ geometry changes here; unrelated app/client monograms are unaffected. */
+.pdpp-connector-icon > .pdpp-monogram {
width: 100%;
height: 100%;
+ border-radius: inherit;
}
diff --git a/reference-implementation/vendor/brand-react/src/connector-icon.test.ts b/reference-implementation/vendor/brand-react/src/connector-icon.test.ts
index 467a1b260..8d4ca0d0b 100644
--- a/reference-implementation/vendor/brand-react/src/connector-icon.test.ts
+++ b/reference-implementation/vendor/brand-react/src/connector-icon.test.ts
@@ -20,6 +20,10 @@ const HUE_VAR_RE = /--pdpp-monogram-hue:\s*(-?\d+)/;
const ICON_CLASS = /class="pdpp-connector-icon"/;
const SVG_VIEWBOX = /viewBox="0 0 24 24"/;
const SVG_COLOR_STYLE = /color:#1ED760/;
+const SCRIPT_TAG = / ';
+ const html = renderToStaticMarkup(createElement(ConnectorIcon, { icon: { kind: "inline_svg", svg }, name: "Slack" }));
+ assert.doesNotMatch(html, SCRIPT_TAG);
+ assert.doesNotMatch(html, HANDLER_OR_PAYLOAD);
+ assert.match(html, MONOGRAM_CLASS);
+});
+
+test("ConnectorIcon drops a non-hex color rather than writing it into the style attribute", () => {
+ const svg = ' ';
+ const html = renderToStaticMarkup(
+ createElement(ConnectorIcon, {
+ icon: { backgroundColor: "red;background-image:url(//x.test/a)", color: "#12", kind: "inline_svg", svg },
+ name: "Slack",
+ })
+ );
+ assert.doesNotMatch(html, STYLE_ATTR);
+ assert.doesNotMatch(html, EXTERNAL_HOST);
+ assert.match(html, ICON_CLASS);
+});
diff --git a/reference-implementation/vendor/brand-react/src/connector-icon.tsx b/reference-implementation/vendor/brand-react/src/connector-icon.tsx
index 885a0c576..3d5bed6ce 100644
--- a/reference-implementation/vendor/brand-react/src/connector-icon.tsx
+++ b/reference-implementation/vendor/brand-react/src/connector-icon.tsx
@@ -9,19 +9,20 @@
* — see `server/connector-manifest-validation.ts`'s `validateManifestIcon`)
* and falls back to the shared Monogram primitive (a deterministic
* initial+color derived from `name`) when `icon` is absent. There is no
- * connector-id -> icon map here or anywhere else in the console: every
- * caller passes the manifest value it already has, and a connector this
- * component has never heard of renders exactly as well as one it has.
+ * connector-specific lookup here: callers pass a normalized display mark.
*
- * The no-icon state (Monogram) is a first-class, good-looking default — most
- * connectors will not declare an icon, so this is the common case, not a
- * degraded fallback.
+ * Callers resolve and validate connector assets before rendering. Missing
+ * or rejected assets use the shared deterministic Monogram fallback.
*/
import type { CSSProperties } from "react";
import { Monogram } from "./data-row.tsx";
+import { hexColorOrNull } from "./hex-color.ts";
+import { sanitizeSvg } from "./sanitize-svg.ts";
import "./connector-icon.css";
export interface ConnectorIconLike {
+ /** Display-only backdrop from the connector brand declaration. */
+ readonly backgroundColor?: string | null;
readonly color?: string | null;
readonly kind?: string | null;
readonly svg?: string | null;
@@ -40,33 +41,36 @@ interface ConnectorIconProps {
name: string;
}
-function isRenderableInlineSvgIcon(icon: ConnectorIconLike): icon is ConnectorIconLike & { svg: string } {
- return icon.kind === "inline_svg" && typeof icon.svg === "string" && icon.svg.trim().length > 0;
+/** The allowlisted markup of an `inline_svg` icon, or null when it is absent or unsafe. */
+function safeInlineSvg(icon: ConnectorIconLike): string | null {
+ return icon.kind === "inline_svg" && typeof icon.svg === "string" ? sanitizeSvg(icon.svg) : null;
}
-// The SOLE choke point for icon.svg is assertSvgIsAllowlisted in
-// reference-implementation/server/connector-manifest-validation.ts
-// (validateManifestIcon). It is a strict ALLOWLIST of shape-only elements
-// and attributes — no script, foreignObject, iframe, use, image, animate,
-// set, style, a, or any href/xlink:href; see its doc comment for the full
-// vocabulary — not a denylist, so there is no attack-vector list to keep
-// exhaustive. It also re-runs on every read of a stored manifest
-// (server/auth.ts's parseAndValidateConnectorManifestRow calls
-// validateConnectorManifest, which calls validateManifestIcon, on every
-// fetch), so icon.svg is validated immediately before this component ever
-// sees it, not merely once at registration time.
+// Callers validate SVG markup too, but this is the last step before
+// dangerouslySetInnerHTML, so the shape-only allowlist runs here again.
export function ConnectorIcon({ icon, name, className }: ConnectorIconProps) {
- if (icon && isRenderableInlineSvgIcon(icon)) {
- const cls = ["pdpp-connector-icon", className].filter(Boolean).join(" ");
+ const cls = ["pdpp-connector-icon", className].filter(Boolean).join(" ");
+ const svg = icon ? safeInlineSvg(icon) : null;
+ if (icon && svg) {
+ const color = hexColorOrNull(icon.color);
+ const backgroundColor = hexColorOrNull(icon.backgroundColor);
return (
);
}
- return ;
+ return (
+
+
+
+ );
}
diff --git a/reference-implementation/vendor/brand-react/src/hex-color.test.ts b/reference-implementation/vendor/brand-react/src/hex-color.test.ts
new file mode 100644
index 000000000..c3a58ea29
--- /dev/null
+++ b/reference-implementation/vendor/brand-react/src/hex-color.test.ts
@@ -0,0 +1,27 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import test from "node:test";
+import { hexColorOrNull } from "./hex-color.ts";
+
+test("hexColorOrNull keeps #rgb and #rrggbb and drops everything else", () => {
+ assert.equal(hexColorOrNull("#1ED760"), "#1ED760");
+ assert.equal(hexColorOrNull("#fa0"), "#fa0");
+ for (const bad of [
+ "red",
+ "#12",
+ "#12345",
+ "#12345g",
+ "#1234567",
+ "transparent",
+ "#fff;x:y",
+ " #fff",
+ "",
+ null,
+ undefined,
+ 5,
+ ]) {
+ assert.equal(hexColorOrNull(bad), null, String(bad));
+ }
+});
diff --git a/reference-implementation/vendor/brand-react/src/hex-color.ts b/reference-implementation/vendor/brand-react/src/hex-color.ts
new file mode 100644
index 000000000..938507960
--- /dev/null
+++ b/reference-implementation/vendor/brand-react/src/hex-color.ts
@@ -0,0 +1,14 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/u;
+
+/**
+ * Returns `value` when it is a `#rgb` / `#rrggbb` string, else `null`.
+ * The one check for every manifest color that reaches a browser style: a
+ * free-form CSS value could smuggle extra declarations, such as a `url()`
+ * fetch, into the `style` attribute.
+ */
+export function hexColorOrNull(value: unknown): string | null {
+ return typeof value === "string" && HEX_COLOR_RE.test(value) ? value : null;
+}
diff --git a/reference-implementation/vendor/brand-react/src/sanitize-svg.test.ts b/reference-implementation/vendor/brand-react/src/sanitize-svg.test.ts
new file mode 100644
index 000000000..2c4fcf765
--- /dev/null
+++ b/reference-implementation/vendor/brand-react/src/sanitize-svg.test.ts
@@ -0,0 +1,100 @@
+// Copyright The PDP-Connect Contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import test from "node:test";
+import { sanitizeSvg } from "./sanitize-svg.ts";
+
+test("accepts a minimal bare svg", () => {
+ assert.equal(
+ sanitizeSvg(' '),
+ ' '
+ );
+});
+
+test("accepts our shipped-manifest shape (aria-hidden, fill on root)", () => {
+ const svg =
+ ' ';
+ assert.equal(sanitizeSvg(svg), svg);
+});
+
+test("accepts the simple-icons shape (role, title)", () => {
+ const svg =
+ 'GitHub ';
+ assert.equal(sanitizeSvg(svg), svg);
+});
+
+test("rejects a script element", () => {
+ assert.equal(sanitizeSvg(" "), null);
+});
+
+test("rejects an event-handler attribute", () => {
+ assert.equal(sanitizeSvg(' '), null);
+});
+
+test("rejects an external reference via image href", () => {
+ assert.equal(sanitizeSvg(' '), null);
+});
+
+test("rejects a javascript: or data: or url() attribute value", () => {
+ assert.equal(sanitizeSvg(' '), null);
+});
+
+test("rejects a disallowed element", () => {
+ assert.equal(sanitizeSvg("x
"), null);
+});
+
+test("rejects markup without a bare svg root", () => {
+ assert.equal(sanitizeSvg(' '), null);
+});
+
+test("rejects non-SVG text", () => {
+ assert.equal(sanitizeSvg("just some text, not markup"), null);
+});
+
+test("rejects empty content", () => {
+ assert.equal(sanitizeSvg(""), null);
+ assert.equal(sanitizeSvg(" "), null);
+});
+
+test("rejects oversized content", () => {
+ const huge = `${' '.repeat(1000)} `;
+ assert.equal(sanitizeSvg(huge), null);
+});
+
+test("rejects browser-significant duplicate and character-encoded attributes", () => {
+ const unsafe = [
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ];
+ for (const svg of unsafe) {
+ assert.equal(sanitizeSvg(svg), null, svg);
+ }
+});
+
+test("requires exactly one balanced SVG root", () => {
+ for (const svg of [" ", " ", "", "prefix ", " suffix"]) {
+ assert.equal(sanitizeSvg(svg), null, svg);
+ }
+ assert.equal(sanitizeSvg(' '), ' ');
+});
+
+test("rejects CSS escapes before a presentation attribute reaches the browser", () => {
+ const escaped = [
+ String.raw` `,
+ String.raw` `,
+ ];
+ for (const svg of escaped) {
+ assert.equal(sanitizeSvg(svg), null, svg);
+ }
+});
+
+// No shipped or simple-icons mark uses , and its only useful children
+// (gradients, clip paths) are not allowed elements, so it stays out.
+test("rejects ", () => {
+ assert.equal(sanitizeSvg(' '), null);
+});
diff --git a/apps/console/src/app/(console)/lib/sanitize-svg.ts b/reference-implementation/vendor/brand-react/src/sanitize-svg.ts
similarity index 59%
rename from apps/console/src/app/(console)/lib/sanitize-svg.ts
rename to reference-implementation/vendor/brand-react/src/sanitize-svg.ts
index d6e780b6e..2d590fef4 100644
--- a/apps/console/src/app/(console)/lib/sanitize-svg.ts
+++ b/reference-implementation/vendor/brand-react/src/sanitize-svg.ts
@@ -25,7 +25,6 @@ const SVG_ALLOWED_ELEMENTS = new Set([
"polyline",
"polygon",
"title",
- "defs",
]);
const SVG_ALLOWED_ATTRIBUTES = new Set([
"viewbox",
@@ -58,6 +57,9 @@ const SVG_ALLOWED_ATTRIBUTES = new Set([
"transform",
]);
const SVG_ATTRIBUTE_VALUE_DENY_RE = /javascript:|data:|url\(/i;
+// Measured against the marks this renders: the bundled connector corpus tops out
+// at about 3.4 KB, and 32 of simple-icons' ~3,470 marks exceed 10 KB (the
+// largest is ~54 KB), which fall back to the monogram.
const SVG_MAX_LENGTH = 10_000;
const SVG_TAG_RE = /<([^>]*)>/g;
@@ -69,6 +71,7 @@ interface ParsedSvgTag {
attrsExhaustive: boolean;
closing: boolean;
name: string;
+ selfClosing: boolean;
}
function parseSvgTag(raw: string): ParsedSvgTag | null {
@@ -77,7 +80,8 @@ function parseSvgTag(raw: string): ParsedSvgTag | null {
if (closing) {
body = body.slice(1).trim();
}
- if (body.endsWith("/")) {
+ const selfClosing = body.endsWith("/");
+ if (selfClosing) {
body = body.slice(0, -1).trim();
}
const nameMatch = SVG_TAG_NAME_RE.exec(body);
@@ -93,18 +97,53 @@ function parseSvgTag(raw: string): ParsedSvgTag | null {
while (match) {
const [, rawAttrName, doubleQuoted, singleQuoted] = match;
if (rawAttrName) {
- attrs.set(rawAttrName.toLowerCase(), doubleQuoted ?? singleQuoted ?? "");
+ const attrName = rawAttrName.toLowerCase();
+ const attrValue = doubleQuoted ?? singleQuoted ?? "";
+ // Browsers retain the first duplicate and decode character references.
+ // Shape-only attributes need neither form, so reject duplicates and both escape forms before indexing.
+ if (attrs.has(attrName) || attrValue.includes("&") || attrValue.includes("\\")) {
+ return null;
+ }
+ attrs.set(attrName, attrValue);
}
match = SVG_ATTR_RE.exec(attrSource);
}
const attrsExhaustive = attrSource.replace(SVG_ATTR_RE, "").trim().length === 0;
- return { attrs, attrsExhaustive, closing, name };
+ return { attrs, attrsExhaustive, closing, name, selfClosing };
+}
+
+function isAllowedSvgTag(tag: ParsedSvgTag | null): tag is ParsedSvgTag {
+ if (!tag?.attrsExhaustive) {
+ return false;
+ }
+ return (
+ SVG_ALLOWED_ELEMENTS.has(tag.name) &&
+ [...tag.attrs].every(
+ ([name, value]) => SVG_ALLOWED_ATTRIBUTES.has(name) && !SVG_ATTRIBUTE_VALUE_DENY_RE.test(value)
+ )
+ );
+}
+
+/** Consume one well-formed element, keeping the unmatched opening names. */
+function advanceSvgNesting(tag: ParsedSvgTag, openElements: string[]): boolean {
+ if (tag.closing) {
+ return !tag.selfClosing && tag.attrs.size === 0 && openElements.pop() === tag.name;
+ }
+ if (openElements.length === 0 && tag.name !== "svg") {
+ return false;
+ }
+ if (!tag.selfClosing) {
+ openElements.push(tag.name);
+ }
+ return true;
}
/**
* A strict ALLOWLIST of shape-only SVG elements/attributes (reject anything
* not explicitly permitted), a required bare `` root, and a denylist of
- * `javascript:`/`data:`/`url()` substrings inside any attribute value.
+ * `javascript:`/`data:`/`url()` substrings inside any attribute value. Text
+ * between elements (such as a ``'s caption) is kept verbatim: it is
+ * inert, and only text outside the root element is rejected.
* Returns the trimmed SVG on success, or null on any violation.
*/
export function sanitizeSvg(raw: string): string | null {
@@ -113,33 +152,26 @@ export function sanitizeSvg(raw: string): string | null {
return null;
}
SVG_TAG_RE.lastIndex = 0;
- const tags: ParsedSvgTag[] = [];
+ const openElements: string[] = [];
let sawSvgRoot = false;
let cursor = 0;
let match = SVG_TAG_RE.exec(svg);
while (match) {
- if (match.index !== cursor && svg.slice(cursor, match.index).includes("<")) {
+ const between = svg.slice(cursor, match.index);
+ if (between.includes("<") || (openElements.length === 0 && between.trim().length > 0)) {
return null;
}
cursor = SVG_TAG_RE.lastIndex;
const parsed = parseSvgTag(match[1] ?? "");
- if (!parsed || !parsed.attrsExhaustive || !SVG_ALLOWED_ELEMENTS.has(parsed.name)) {
+ if (!isAllowedSvgTag(parsed)) {
return null;
}
- for (const [attrName, attrValue] of parsed.attrs) {
- if (!SVG_ALLOWED_ATTRIBUTES.has(attrName) || SVG_ATTRIBUTE_VALUE_DENY_RE.test(attrValue)) {
- return null;
- }
- }
- if (parsed.name === "svg" && !parsed.closing) {
- sawSvgRoot = true;
+ const startsRoot = !parsed.closing && openElements.length === 0;
+ if ((startsRoot && sawSvgRoot) || !advanceSvgNesting(parsed, openElements)) {
+ return null;
}
- tags.push(parsed);
+ sawSvgRoot ||= startsRoot;
match = SVG_TAG_RE.exec(svg);
}
- if (cursor < svg.length && svg.slice(cursor).includes("<")) {
- return null;
- }
- const [rootTag] = tags;
- return sawSvgRoot && rootTag?.name === "svg" && !rootTag.closing ? svg : null;
+ return sawSvgRoot && openElements.length === 0 && svg.slice(cursor).trim().length === 0 ? svg : null;
}