From b379e1a38ac972a04fde2a6d7bc16c98b7cc71c3 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 10:26:30 -0600 Subject: [PATCH 1/9] feat(console): show one identity-based connector mark everywhere Replace the per-surface connector logo markup with one shared mark: a 28px rounded tile with a 60% glyph, an official bundled icon looked up by the connector's exact identity, and an initials fallback. All 47 console call sites pass the connector ID instead of sizing the logo themselves. - resolve-connector-icon.ts and connector-icon-map.ts resolve a mark from the connector key or canonical ID. A safe icon the connector supplies wins; unknown IDs and unrelated registry origins get initials, never another brand. Underscore and hyphen spellings alias only for local keys and first-party registry IDs; any other URI scheme (for example a URN) stays an exact identity. - sanitize-svg.ts validates every inline SVG at the display boundary. - sync-connector-icons.mjs generates sanitized-assets.json and the NOTICE and PROVENANCE files from data-connectors at 23aee3aa (65 icons). The generated files are not edited by hand; --check verifies them. - The consent screen model carries the display connectorId so the consent UI can show the mark. The ID is display-only and never selects a grant. Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/console/next.config.mjs | 3 + apps/console/public/connector-icons/NOTICE | 19 + .../public/connector-icons/PROVENANCE.md | 9 + apps/console/scripts/sync-connector-icons.mjs | 100 +++ .../(console)/components/connector-mark.tsx | 20 +- .../components/source-setup-catalog.tsx | 4 +- .../components/views/standing-overview.tsx | 4 + .../components/views/standing-view-model.ts | 2 + .../[connectorId]/launch/page.tsx | 2 +- .../browser-session/[connectorId]/page.tsx | 4 +- .../manual-upload/[connectorId]/page.tsx | 4 +- .../provider-auth/[connectorId]/page.tsx | 4 +- .../static-secret/[connectorId]/page.tsx | 4 +- .../connect/status/[connectionId]/page.tsx | 4 +- .../app/(console)/device-exporters/page.tsx | 4 +- .../app/(console)/explore/explore-canvas.tsx | 24 +- .../src/app/(console)/explore/page.tsx | 9 +- .../[approvalId]/approval-review.tsx | 6 +- .../grants/packages/[packageId]/page.tsx | 3 +- .../console/src/app/(console)/grants/page.tsx | 2 +- .../(console)/grants/pending-approval-row.tsx | 2 +- .../(console)/lib/connector-icon-map.test.ts | 89 +++ .../app/(console)/lib/connector-icon-map.ts | 57 ++ .../app/(console)/lib/connector-icons/NOTICE | 19 + .../lib/connector-icons/sanitized-assets.json | 591 ++++++++++++++++++ .../lib/resolve-connector-icon.test.ts | 148 ++++- .../(console)/lib/resolve-connector-icon.ts | 149 ++++- .../src/app/(console)/lib/rs-client.ts | 11 + .../app/(console)/lib/sanitize-svg.test.ts | 46 +- .../src/app/(console)/lib/sanitize-svg.ts | 66 +- .../app/(console)/schedules/schedule-row.tsx | 2 +- .../[connector]/[stream]/[recordKey]/page.tsx | 2 +- .../[connector]/[stream]/health/page.tsx | 10 +- .../sources/[connector]/[stream]/page.tsx | 2 +- .../(console)/sources/[connector]/page.tsx | 2 +- .../add/local-connector-sources-panel.tsx | 1 + .../src/app/(console)/sources/add/page.tsx | 15 +- .../src/app/(console)/sources/page.tsx | 10 +- .../(console)/sources/sources-view-model.ts | 14 +- .../app/(console)/sources/sources-view.css | 6 +- .../app/(console)/sources/sources-view.tsx | 2 +- .../src/app/(console)/syncs/[runId]/page.tsx | 13 +- .../(console)/syncs/[runId]/stream/page.tsx | 4 +- .../syncs/[runId]/stream/stream-viewer.tsx | 11 +- apps/console/src/app/(console)/syncs/page.tsx | 14 +- .../src/app/(console)/syncs/syncs-view.tsx | 17 +- .../consent-screen/consent-screen-client.tsx | 13 +- .../consent-screen/consent-screen-model.ts | 2 + .../consent-screen-runtime.test.mjs | 24 + .../consent-screen/consent-screen.module.css | 2 - .../consent-screen/consent-screen.test.ts | 11 +- .../server/routes/as-consent-ui-helpers.ts | 3 + .../vendor/brand-react/src/connector-icon.css | 44 +- .../brand-react/src/connector-icon.test.ts | 12 + .../vendor/brand-react/src/connector-icon.tsx | 40 +- 55 files changed, 1494 insertions(+), 191 deletions(-) create mode 100644 apps/console/public/connector-icons/NOTICE create mode 100644 apps/console/public/connector-icons/PROVENANCE.md create mode 100644 apps/console/scripts/sync-connector-icons.mjs create mode 100644 apps/console/src/app/(console)/lib/connector-icon-map.test.ts create mode 100644 apps/console/src/app/(console)/lib/connector-icon-map.ts create mode 100644 apps/console/src/app/(console)/lib/connector-icons/NOTICE create mode 100644 apps/console/src/app/(console)/lib/connector-icons/sanitized-assets.json diff --git a/apps/console/next.config.mjs b/apps/console/next.config.mjs index c7d8bc277..209dc6cc9 100644 --- a/apps/console/next.config.mjs +++ b/apps/console/next.config.mjs @@ -56,6 +56,9 @@ const nextConfig = { "/llms-full.txt": ["../../docs/agent-skills/**/*.md"], "/llms.txt": ["../../docs/agent-skills/**/*.md"], "/well-known/skills/**": ["../../docs/agent-skills/**/*.md"], + // The icon resolver reads this offline corpus by path at runtime rather + // than importing each SVG, so Next's standalone tracer cannot infer it. + "/*": ["../../node_modules/simple-icons/data/simple-icons.json", "../../node_modules/simple-icons/icons/*.svg"], }, outputFileTracingRoot: path.join(__dirname, "../.."), reactStrictMode: true, diff --git a/apps/console/public/connector-icons/NOTICE b/apps/console/public/connector-icons/NOTICE new file mode 100644 index 000000000..c92397292 --- /dev/null +++ b/apps/console/public/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/public/connector-icons/PROVENANCE.md b/apps/console/public/connector-icons/PROVENANCE.md new file mode 100644 index 000000000..fd29c5c8b --- /dev/null +++ b/apps/console/public/connector-icons/PROVENANCE.md @@ -0,0 +1,9 @@ +# Connector icon provenance + +The console bundle uses the manifest-declared SVG files from +[PDP-Connect/data-connectors](https://github.com/PDP-Connect/data-connectors) +revision `23aee3aaeafad3a804d504e8614763f7845add60`. Their connector keys, canonical IDs, source paths, and SVG markup are recorded in the generated +`sanitized-assets.json` bundle under the console library. The bundle is checked +with the console SVG allowlist before it reaches the shared icon renderer. + +See [NOTICE](./NOTICE) for attribution and trademark terms. diff --git a/apps/console/scripts/sync-connector-icons.mjs b/apps/console/scripts/sync-connector-icons.mjs new file mode 100644 index 000000000..1413dc5b9 --- /dev/null +++ b/apps/console/scripts/sync-connector-icons.mjs @@ -0,0 +1,100 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { sanitizeSvg } from "../src/app/(console)/lib/sanitize-svg.ts"; + +const REPOSITORY = "PDP-Connect/data-connectors"; +const REVISION = "23aee3aaeafad3a804d504e8614763f7845add60"; +const EXPECTED_ASSETS = 65; +const MANIFEST_PATH = /^connectors\/[^/]+\/manifest\.json$/u; +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const CONSOLE_DIR = path.resolve(SCRIPT_DIR, ".."); +const LIB_DIR = path.join(CONSOLE_DIR, "src/app/(console)/lib/connector-icons"); +const PUBLIC_DIR = path.join(CONSOLE_DIR, "public/connector-icons"); +const checkOnly = process.argv.includes("--check"); +const sourceRepo = process.argv.slice(2).find((arg) => arg !== "--check"); + +if (!sourceRepo) { + throw new Error("Pass the existing local data-connectors repository path; this script does not clone or fetch."); +} + +function git(...args) { + return execFileSync("git", ["-C", sourceRepo, ...args], { encoding: "utf8", maxBuffer: 1024 * 1024 }); +} + +if (git("rev-parse", `${REVISION}^{commit}`).trim() !== REVISION) { + throw new Error(`Local ${REPOSITORY} repository does not contain pinned revision ${REVISION}.`); +} + +const manifestPaths = git("ls-tree", "-r", "--name-only", REVISION, "--", "connectors/") + .split("\n") + .filter((file) => MANIFEST_PATH.test(file)); +const entries = []; +for (const manifestPath of manifestPaths) { + const manifest = JSON.parse(git("show", `${REVISION}:${manifestPath}`)); + const iconPath = manifest.brand?.icon; + if (typeof iconPath !== "string" || !iconPath.trim()) { + continue; + } + const parts = iconPath.split("/"); + if ( + iconPath.startsWith("/") || + iconPath.includes("\\") || + parts.some((part) => !part || part === "." || part === "..") + ) { + throw new Error(`Unsafe declared icon path in ${manifestPath}.`); + } + const sourcePath = path.posix.join(path.posix.dirname(manifestPath), iconPath); + const svg = git("show", `${REVISION}:${sourcePath}`).trim(); + if (sanitizeSvg(svg) !== svg) { + throw new Error(`Declared SVG failed the console allowlist: ${sourcePath}.`); + } + if (typeof manifest.connector_key !== "string" || typeof manifest.connector_id !== "string") { + throw new Error(`Manifest identity is incomplete: ${manifestPath}.`); + } + entries.push({ + background_color: manifest.brand.background_color, + connector_key: manifest.connector_key, + connector_id: manifest.connector_id, + display_name: manifest.display_name ?? manifest.source?.display?.name ?? manifest.connector_key, + path: sourcePath, + source_id: manifest.source?.id ?? null, + svg, + }); +} +entries.sort((left, right) => { + if (left.connector_key === right.connector_key) { + return 0; + } + return left.connector_key < right.connector_key ? -1 : 1; +}); +if (entries.length !== EXPECTED_ASSETS) { + throw new Error(`Pinned source produced ${entries.length} icon assets; expected ${EXPECTED_ASSETS}.`); +} +const serialized = `${JSON.stringify({ repo: REPOSITORY, revision: REVISION, entries }, null, 2)}\n`; +const notice = git("show", `${REVISION}:NOTICE`); +const provenance = `# Connector icon provenance\n\nThe console bundle uses the manifest-declared SVG files from\n[${REPOSITORY}](https://github.com/${REPOSITORY})\nrevision \`${REVISION}\`. Their connector keys, canonical IDs, source paths, and SVG markup are recorded in the generated\n\`sanitized-assets.json\` bundle under the console library. The bundle is checked\nwith the console SVG allowlist before it reaches the shared icon renderer.\n\nSee [NOTICE](./NOTICE) for attribution and trademark terms.\n`; +const outputs = [ + [path.join(LIB_DIR, "sanitized-assets.json"), serialized], + [path.join(LIB_DIR, "NOTICE"), notice], + [path.join(PUBLIC_DIR, "NOTICE"), notice], + [path.join(PUBLIC_DIR, "PROVENANCE.md"), provenance], +]; +if (checkOnly) { + for (const [file, expected] of outputs) { + if (readFileSync(file, "utf8") !== expected) { + throw new Error(`Generated icon output differs: ${file}. Run this script without --check to refresh it.`); + } + } + process.stdout.write(`Verified ${entries.length} icons from ${REVISION}.\n`); +} else { + for (const [file, contents] of outputs) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, contents); + } + process.stdout.write(`Wrote ${entries.length} icons from ${REVISION}.\n`); +} diff --git a/apps/console/src/app/(console)/components/connector-mark.tsx b/apps/console/src/app/(console)/components/connector-mark.tsx index 787fef280..400045ddd 100644 --- a/apps/console/src/app/(console)/components/connector-mark.tsx +++ b/apps/console/src/app/(console)/components/connector-mark.tsx @@ -2,17 +2,27 @@ // SPDX-License-Identifier: Apache-2.0 import { ConnectorIcon, type ConnectorIconLike } from "@pdpp/brand-react"; -import { resolveConnectorIcon } from "../lib/resolve-connector-icon.ts"; +import { createElement } from "react"; +import { resolveConnectorIcon, resolveSourceIcon } from "../lib/resolve-connector-icon.ts"; + +const TEXT_SPACING_CLASSES = { after: "mr-2", around: "mx-1" } as const; /** One connector identity mark for every console surface. */ export function ConnectorMark({ - className, + spacing, + connectorId, + sourceId, icon, name, }: { - className?: string; + /** Space between this mark and adjacent inline text; geometry belongs to the renderer. */ + spacing?: keyof typeof TEXT_SPACING_CLASSES; icon?: ConnectorIconLike | null; name: string; -}) { - return ; +} & ({ connectorId: string; sourceId?: never } | { sourceId: string; connectorId?: never })) { + return createElement(ConnectorIcon, { + className: spacing ? TEXT_SPACING_CLASSES[spacing] : undefined, + icon: sourceId === undefined ? resolveConnectorIcon(connectorId ?? "", icon) : resolveSourceIcon(sourceId, icon), + name, + }); } diff --git a/apps/console/src/app/(console)/components/source-setup-catalog.tsx b/apps/console/src/app/(console)/components/source-setup-catalog.tsx index ad54d5243..a0aa896f9 100644 --- a/apps/console/src/app/(console)/components/source-setup-catalog.tsx +++ b/apps/console/src/app/(console)/components/source-setup-catalog.tsx @@ -242,7 +242,7 @@ function ExistingSourceLinks({ >

- + {source.displayName}

@@ -488,7 +488,7 @@ function SourceSetupCard({ >

- +

{entry.displayName}

{/* Current support / blocked fact, kept distinct from the next action. */} {status.label ? ( diff --git a/apps/console/src/app/(console)/components/views/standing-overview.tsx b/apps/console/src/app/(console)/components/views/standing-overview.tsx index b4c4a7e98..aadc5e8f1 100644 --- a/apps/console/src/app/(console)/components/views/standing-overview.tsx +++ b/apps/console/src/app/(console)/components/views/standing-overview.tsx @@ -18,6 +18,7 @@ */ import { Endorse, Eyebrow, IcTimestamp, Monogram } from "@pdpp/brand-react"; import "@pdpp/brand-react/components.css"; +import { ConnectorMark } from "../connector-mark.tsx"; import type { StandingData } from "./standing-view-model.ts"; function HeroLine({ line }: { line: StandingData["hero"]["line"] }) { @@ -249,6 +250,9 @@ function AttentionBlock({ {section.rows.map((row) => (
+ {row.connectorId ? ( + + ) : null} {row.what} {row.why} diff --git a/apps/console/src/app/(console)/components/views/standing-view-model.ts b/apps/console/src/app/(console)/components/views/standing-view-model.ts index e0bf517f6..4282e5fcf 100644 --- a/apps/console/src/app/(console)/components/views/standing-view-model.ts +++ b/apps/console/src/app/(console)/components/views/standing-view-model.ts @@ -187,6 +187,7 @@ export interface LatelyView { } export interface AttentionRowView { + connectorId?: string; href: string; id: string; what: string; @@ -937,6 +938,7 @@ function sourceWorkRow(item: SourceWorkItem, hrefs: StandingHrefs): AttentionRow what = `${item.label} ${item.statusLabel}`; } return { + connectorId: item.connectorKey, href: hrefs.connection(item.routeId), id: item.id, what, diff --git a/apps/console/src/app/(console)/connect/browser-session/[connectorId]/launch/page.tsx b/apps/console/src/app/(console)/connect/browser-session/[connectorId]/launch/page.tsx index f5826a03e..2a199dbf5 100644 --- a/apps/console/src/app/(console)/connect/browser-session/[connectorId]/launch/page.tsx +++ b/apps/console/src/app/(console)/connect/browser-session/[connectorId]/launch/page.tsx @@ -67,7 +67,7 @@ export default async function BrowserSessionLaunchPage({ ]} description={ - + DataConnect is starting a secure browser session for {displayName}. } diff --git a/apps/console/src/app/(console)/connect/browser-session/[connectorId]/page.tsx b/apps/console/src/app/(console)/connect/browser-session/[connectorId]/page.tsx index 748f47129..7ed05402a 100644 --- a/apps/console/src/app/(console)/connect/browser-session/[connectorId]/page.tsx +++ b/apps/console/src/app/(console)/connect/browser-session/[connectorId]/page.tsx @@ -254,7 +254,7 @@ export default async function BrowserSessionConnectPage({ breadcrumbs={[{ href: "/sources", label: "Sources" }, { label: pageTitle }]} description={ - + {repairMode ? `Log in to ${displayName} in the secure browser to restore collection. Your existing records and history are preserved.` @@ -264,7 +264,7 @@ export default async function BrowserSessionConnectPage({ } title={ - + {pageTitle} } diff --git a/apps/console/src/app/(console)/connect/manual-upload/[connectorId]/page.tsx b/apps/console/src/app/(console)/connect/manual-upload/[connectorId]/page.tsx index fbc342317..429757ce8 100644 --- a/apps/console/src/app/(console)/connect/manual-upload/[connectorId]/page.tsx +++ b/apps/console/src/app/(console)/connect/manual-upload/[connectorId]/page.tsx @@ -126,13 +126,13 @@ export default async function ManualUploadConnectPage({ breadcrumbs={[{ href: "/sources", label: "Sources" }, { label: `Import ${setup.display_name}` }]} description={ - + Pick a supported export file. DataConnect validates it, imports it, and gives you a coverage receipt you can revisit. } title={ - + {targetConnectionId ? `Import another ${setup.display_name} file` : `Import ${setup.display_name}`} } diff --git a/apps/console/src/app/(console)/connect/provider-auth/[connectorId]/page.tsx b/apps/console/src/app/(console)/connect/provider-auth/[connectorId]/page.tsx index d8305febc..2f685e727 100644 --- a/apps/console/src/app/(console)/connect/provider-auth/[connectorId]/page.tsx +++ b/apps/console/src/app/(console)/connect/provider-auth/[connectorId]/page.tsx @@ -40,13 +40,13 @@ export default async function ProviderAuthPage({ ]} description={ - + Authorize this account in the provider's browser. The connection activates after authorization and account inventory succeed. } title={ - + Authorize {displayName} } diff --git a/apps/console/src/app/(console)/connect/static-secret/[connectorId]/page.tsx b/apps/console/src/app/(console)/connect/static-secret/[connectorId]/page.tsx index 13b9bf7d9..4635c0de5 100644 --- a/apps/console/src/app/(console)/connect/static-secret/[connectorId]/page.tsx +++ b/apps/console/src/app/(console)/connect/static-secret/[connectorId]/page.tsx @@ -163,13 +163,13 @@ export default async function StaticSecretConnectPage({ breadcrumbs={[{ href: "/sources", label: "Sources" }, { label: pageTitle }]} description={ - + {pageDescription} } title={ - + {pageTitle} } diff --git a/apps/console/src/app/(console)/connect/status/[connectionId]/page.tsx b/apps/console/src/app/(console)/connect/status/[connectionId]/page.tsx index 673345f7f..a75d28bd7 100644 --- a/apps/console/src/app/(console)/connect/status/[connectionId]/page.tsx +++ b/apps/console/src/app/(console)/connect/status/[connectionId]/page.tsx @@ -716,13 +716,13 @@ export default async function ConnectionSetupStatusPage({ breadcrumbs={[{ href: "/sources", label: "Sources" }, { label: "Setup status" }]} description={ - + Durable status for the account or import you just submitted. Bookmark it and come back any time. } title={ - + {title} } diff --git a/apps/console/src/app/(console)/device-exporters/page.tsx b/apps/console/src/app/(console)/device-exporters/page.tsx index 26a134d6c..6167cfb60 100644 --- a/apps/console/src/app/(console)/device-exporters/page.tsx +++ b/apps/console/src/app/(console)/device-exporters/page.tsx @@ -156,7 +156,7 @@ function BrowserBoundEnrollmentNotice({ connectorIcon, connectorId }: { connecto surface="human" title={ - + Browser setup not available yet } @@ -308,7 +308,7 @@ function SourceInstanceCard({

- + {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-icon-map.test.ts b/apps/console/src/app/(console)/lib/connector-icon-map.test.ts new file mode 100644 index 000000000..97a865cea --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-icon-map.test.ts @@ -0,0 +1,89 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildConnectorIconMap } from "./connector-icon-map.ts"; +import iconCorpus from "./connector-icons/sanitized-assets.json" with { type: "json" }; + +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_MANIFEST_MARK }], + [{ connector_id: "github", connector_key: "github", icon: INLINE_TEMPLATE_MARK }] + ); + + assert.deepEqual(icons.github, INLINE_TEMPLATE_MARK); +}); + +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); +}); 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..9e704f817 --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-icon-map.ts @@ -0,0 +1,57 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ConnectorIconLike } from "@pdpp/brand-react"; +import iconCorpus from "./connector-icons/sanitized-assets.json" with { type: "json" }; +import { hasConnectorIcon, indexConnectorIcon, validatedConnectorIcon } from "./resolve-connector-icon.ts"; +import { resolveConnectorIconFromSimpleIcons } from "./resolve-connector-icon-simple-icons.ts"; +import type { ConnectorManifest, OwnerConnectorTemplate } from "./rs-client.ts"; + +/** Icons indexed by the exact server-projected connector identity. */ +export type ConnectorIconMap = Readonly>; + +/** A display projection; it never adds or changes a connector manifest. */ +export async function buildConnectorIconMap( + manifests: readonly ConnectorManifest[], + templates: readonly OwnerConnectorTemplate[] +): Promise { + const icons = new Map(); + for (const entry of iconCorpus.entries) { + indexConnectorIcon( + icons, + [entry.connector_key, entry.connector_id], + validatedConnectorIcon({ + backgroundColor: entry.background_color, + kind: "inline_svg", + svg: entry.svg, + }) + ); + } + for (const manifest of manifests) { + const identities = [manifest.connector_id, manifest.connector_key]; + // A generic fallback must not displace the connector's declared asset. + if (manifest.resolvedIconSource === "simple-icons" && hasConnectorIcon(icons, identities)) { + continue; + } + indexConnectorIcon(icons, identities, validatedConnectorIcon(manifest.icon, manifest.brand?.background_color)); + } + for (const template of templates) { + const identities = [template.connector_id, template.connector_key]; + indexConnectorIcon(icons, identities, validatedConnectorIcon(template.icon)); + } + // Resolve only missing marks. Applying explicit inline marks first keeps + // this fallback from overwriting an approved mark through an alias. + const missing = templates.filter( + (template) => template.connector_key && !hasConnectorIcon(icons, [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(icons, fallback.identities, fallback.icon); + } + return Object.fromEntries(icons); +} 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.test.ts b/apps/console/src/app/(console)/lib/resolve-connector-icon.test.ts index 888cf2a41..d79222df5 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,149 @@ // 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 corpus from "./connector-icons/sanitized-assets.json" with { type: "json" }; +import { connectorIconIdentityKeys, 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("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("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"')); }); 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..0a0578d18 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,140 @@ // SPDX-License-Identifier: Apache-2.0 import type { ConnectorIconLike } from "@pdpp/brand-react"; +import iconCorpus from "./connector-icons/sanitized-assets.json" with { type: "json" }; +import { sanitizeSvg } from "./sanitize-svg.ts"; -/** - * 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. - */ -export function resolveConnectorIcon(icon: ConnectorIconLike | null | undefined): ConnectorIconLike | null { - if (icon?.kind !== "inline_svg" || typeof icon.svg !== "string" || icon.svg.trim().length === 0) { +const HEX_COLOR = /^#[0-9a-f]{6}$/iu; +const URI_SCHEME = /^[a-z][a-z\d+.-]*:/iu; +const REGISTRY_PREFIX = "https://registry.pdpp.dev/connectors/"; +const TRAILING_SLASH = /\/$/u; + +type CorpusEntry = (typeof iconCorpus.entries)[number]; + +/** 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]; +} + +/** 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; } - return { ...icon, svg: icon.svg.trim() }; + const svg = sanitizeSvg(icon.svg); + if (!svg) { + return null; + } + const color = typeof icon.color === "string" && HEX_COLOR.test(icon.color) ? icon.color : undefined; + const candidateBackground = icon.backgroundColor ?? declaredBackground; + const backgroundColor = + typeof candidateBackground === "string" && HEX_COLOR.test(candidateBackground) ? candidateBackground : undefined; + 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 }, + entry.background_color + ); +} + +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); + } + } +} + +/** 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; +} + +/** Add a resolved mark under every accepted explicit connector spelling. */ +export function indexConnectorIcon( + icons: Map, + connectorIds: readonly (string | null | undefined)[], + icon: ConnectorIconLike | null +): void { + if (!icon) { + return; + } + for (const connectorId of connectorIds) { + if (connectorId) { + for (const key of connectorIconIdentityKeys(connectorId)) { + 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, plus exact legacy brand aliases. */ +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 BUNDLED_ICONS_BY_IDENTITY.get(sourceId) ?? null; } diff --git a/apps/console/src/app/(console)/lib/rs-client.ts b/apps/console/src/app/(console)/lib/rs-client.ts index 3b182ac07..da6840411 100644 --- a/apps/console/src/app/(console)/lib/rs-client.ts +++ b/apps/console/src/app/(console)/lib/rs-client.ts @@ -176,6 +176,8 @@ export interface ConnectorManifest { kind?: string | null; svg?: string | null; } | null; + /** Server-side enrichment provenance; never read from or written to a manifest. */ + resolvedIconSource?: "manifest" | "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 +186,7 @@ export interface ConnectorManifest { * single `icon` (inline_svg) shape. */ brand?: { + background_color?: string | null; icon?: string | null; } | null; name?: string; @@ -817,6 +820,7 @@ export async function listConnectorManifests(): Promise { try { const raw = await readFile(join(dir, file), "utf8"); const manifest = JSON.parse(raw) as ConnectorManifest; + let resolvedIconSource: ConnectorManifest["resolvedIconSource"] = manifest.icon ? "manifest" : undefined; // Three-layer icon resolution (first hit wins), so every reader // downstream keeps consuming the single `icon` (inline_svg) shape it // already knows: @@ -828,10 +832,17 @@ export async function listConnectorManifests(): Promise { // fallback when `icon` is left unset here. if (!manifest.icon && manifest.brand?.icon) { manifest.icon = await resolveConnectorIconFromManifestPath(dir, manifest.brand.icon); + if (manifest.icon) { + resolvedIconSource = "manifest"; + } } if (!manifest.icon && manifest.connector_key) { manifest.icon = await resolveConnectorIconFromSimpleIcons(manifest.connector_key); + if (manifest.icon) { + resolvedIconSource = "simple-icons"; + } } + manifest.resolvedIconSource = resolvedIconSource; 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 index 0611e4d34..4a56dc2a9 100644 --- a/apps/console/src/app/(console)/lib/sanitize-svg.test.ts +++ b/apps/console/src/app/(console)/lib/sanitize-svg.test.ts @@ -6,16 +6,21 @@ import test from "node:test"; import { sanitizeSvg } from "./sanitize-svg.ts"; test("accepts a minimal bare svg", () => { - assert.equal(sanitizeSvg(''), ''); + assert.equal( + sanitizeSvg(''), + '' + ); }); test("accepts our shipped-manifest shape (aria-hidden, fill on root)", () => { - const svg = ''; + const svg = + ''; assert.equal(sanitizeSvg(svg), svg); }); test("accepts the simple-icons shape (role, title)", () => { - const svg = 'GitHub'; + const svg = + 'GitHub'; assert.equal(sanitizeSvg(svg), svg); }); @@ -36,7 +41,7 @@ test("rejects a javascript: or data: or url() attribute value", () => { }); test("rejects a disallowed element", () => { - assert.equal(sanitizeSvg('
x
'), null); + assert.equal(sanitizeSvg("
x
"), null); }); test("rejects markup without a bare svg root", () => { @@ -53,6 +58,37 @@ test("rejects empty content", () => { }); test("rejects oversized content", () => { - const huge = `${"".repeat(1000)}`; + 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); + } +}); diff --git a/apps/console/src/app/(console)/lib/sanitize-svg.ts b/apps/console/src/app/(console)/lib/sanitize-svg.ts index d6e780b6e..5780eb36d 100644 --- a/apps/console/src/app/(console)/lib/sanitize-svg.ts +++ b/apps/console/src/app/(console)/lib/sanitize-svg.ts @@ -69,6 +69,7 @@ interface ParsedSvgTag { attrsExhaustive: boolean; closing: boolean; name: string; + selfClosing: boolean; } function parseSvgTag(raw: string): ParsedSvgTag | null { @@ -77,7 +78,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,12 +95,45 @@ 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; } /** @@ -113,33 +148,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; } 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..ae0a03736 100644 --- a/apps/console/src/app/(console)/sources/add/page.tsx +++ b/apps/console/src/app/(console)/sources/add/page.tsx @@ -13,8 +13,9 @@ import { type ExistingSourceSetupLink, SourceSetupCatalog } from "../../componen import { buildOwnerConnectorCatalog, type ConnectorCatalogEntry } from "../../lib/connection-catalog.ts"; import type { ConnectorInstallSnapshot } from "../../lib/connector-install-contract.ts"; import type { ConnectorInstallLifecycle } from "../../lib/connector-install-presentation.ts"; -import { buildConnectorInstallLifecycleByConnector } from "../../lib/connector-install-presentation.ts"; +import { buildConnectorInstallLifecycleByConnector, connectorLookupKey } from "../../lib/connector-install-presentation.ts"; import { getConnectorInstallSnapshot } from "../../lib/connector-install-client.ts"; +import { buildConnectorIconMap } 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: connectorIcons[connectorLookupKey(entry.connectorKey)] ?? null, + })); 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..2fc6f1d96 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; @@ -161,7 +157,7 @@ a.rr-s-item { } .rr-s-status[data-tone="destructive"] { - color: var(--destructive-foreground, var(--destructive)); + color: var(--destructive); } @keyframes rr-s-status-pulse { 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