From 8ab37756a0e270a54d21171610393e3ebef5f046 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 4 Nov 2026 14:54:24 -0600 Subject: [PATCH 01/16] site: add "Is your app on PDPP?" page at /apps, behind a flag A visitor types an app name and sees what they can do with its data through PDPP today, then whether the company has signed the Principles or registered. It is a read-only view over lists PDPP already publishes: the signed connector catalogue, the Supporters register and the conformance register. Nothing is collected or sent. The catalogue is committed as a snapshot, refreshed by scripts/sync-connector-catalog.mjs. The snapshot is pinned to the manifest and layer digests; the script does not check the catalogue's cosign signature. The page returns not found unless NEXT_PUBLIC_PDPP_APP_DIRECTORY=true. No nav links are added. Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/site/scripts/sync-connector-catalog.mjs | 208 +++++++ .../scripts/sync-connector-catalog.test.ts | 125 ++++ apps/site/src/app/(concept)/apps/page.test.ts | 63 ++ apps/site/src/app/(concept)/apps/page.tsx | 85 +++ .../src/components/site/app-directory.tsx | 112 ++++ apps/site/src/data/connector-catalog.json | 548 ++++++++++++++++++ apps/site/src/lib/app-directory-sources.ts | 52 ++ apps/site/src/lib/app-directory.test.ts | 234 ++++++++ apps/site/src/lib/app-directory.ts | 267 +++++++++ apps/site/src/lib/site-config.ts | 12 +- 10 files changed, 1704 insertions(+), 2 deletions(-) create mode 100644 apps/site/scripts/sync-connector-catalog.mjs create mode 100644 apps/site/scripts/sync-connector-catalog.test.ts create mode 100644 apps/site/src/app/(concept)/apps/page.test.ts create mode 100644 apps/site/src/app/(concept)/apps/page.tsx create mode 100644 apps/site/src/components/site/app-directory.tsx create mode 100644 apps/site/src/data/connector-catalog.json create mode 100644 apps/site/src/lib/app-directory-sources.ts create mode 100644 apps/site/src/lib/app-directory.test.ts create mode 100644 apps/site/src/lib/app-directory.ts diff --git a/apps/site/scripts/sync-connector-catalog.mjs b/apps/site/scripts/sync-connector-catalog.mjs new file mode 100644 index 000000000..e00910d53 --- /dev/null +++ b/apps/site/scripts/sync-connector-catalog.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +// Refreshes src/data/connector-catalog.json, the snapshot behind /apps. +// +// The source is the connector catalogue, an OCI artifact at +// ghcr.io/pdp-connect/connector-catalog:latest that anyone can read without a +// login. This script pulls it, checks the bytes against the layer digest and +// size the registry reports, then reads each connector's manifest at the +// catalogue's source commit for the labels of what a person can bring. +// +// TRUST: the snapshot is pinned to digests, not signed. The manifest digest and +// layer digest are written into the file so anyone can fetch the same artifact +// and compare. This script does NOT verify the catalogue's cosign signature. A +// reader who needs that guarantee must verify the artifact itself. +// +// Run: node scripts/sync-connector-catalog.mjs [--out ] + +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const REGISTRY = "ghcr.io"; +const REPOSITORY = "pdp-connect/connector-catalog"; +const SOURCE_NAME = `${REGISTRY}/${REPOSITORY}`; +const CATALOG_MEDIA_TYPE = "application/vnd.pdpp.connector-catalog.v1+json"; +const MANIFEST_ACCEPT = "application/vnd.oci.image.manifest.v1+json"; +const CONNECTOR_MANIFEST_BASE = "https://raw.githubusercontent.com/PDP-Connect/data-connectors"; +const TIERS = new Set(["supported", "preview", "development"]); + +const DEFAULT_OUTPUT = fileURLToPath(new URL("../src/data/connector-catalog.json", import.meta.url)); + +function sha256Digest(bytes) { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +/** + * Reads the labels a connector declares for what it brings. Returns [] for a + * missing or unreadable manifest: labels are an extra, and a connector with + * none is still listed. + */ +export function labelsFromManifest(manifest) { + const streams = Array.isArray(manifest?.streams) ? manifest.streams : []; + const labels = []; + for (const stream of streams) { + const label = stream?.display?.label; + if (typeof label === "string" && label.trim() && !labels.includes(label.trim())) { + labels.push(label.trim()); + } + } + return labels; +} + +/** + * Pure transform: raw catalogue plus per-connector manifests to the snapshot + * the page renders. Keeps only what the page needs. Nothing here touches the + * network, so a test can feed it a small fixture. + * + * `manifests` maps connector_key to the parsed connector manifest, or to null + * or undefined when that manifest was missing. + */ +export function buildSnapshot({ catalog, manifests, manifestDigest, layerDigest }) { + if (!Array.isArray(catalog?.connectors)) { + throw new Error("Catalogue has no connectors array"); + } + const connectors = catalog.connectors.map((connector) => { + if (!(connector.connector_key && connector.display_name)) { + throw new Error("Catalogue connector is missing connector_key or display_name"); + } + if (!TIERS.has(connector.tier)) { + throw new Error(`Connector ${connector.connector_key} has unknown tier ${String(connector.tier)}`); + } + return { + key: connector.connector_key, + display_name: connector.display_name, + tier: connector.tier, + labels: labelsFromManifest(manifests[connector.connector_key]), + }; + }); + connectors.sort((a, b) => a.display_name.localeCompare(b.display_name, "en") || a.key.localeCompare(b.key, "en")); + return { + source: SOURCE_NAME, + manifest_digest: manifestDigest, + layer_digest: layerDigest, + generated_at: catalog.generated_at, + source_commit: catalog.source_commit, + connectors, + }; +} + +async function getJson(url, init) { + const response = await fetch(url, init); + if (!response.ok) { + throw new Error(`${url} returned ${response.status}`); + } + return response; +} + +/** Pulls the catalogue anonymously and checks its digest and size. */ +export async function fetchCatalog() { + const tokenResponse = await getJson(`https://${REGISTRY}/token?scope=repository:${REPOSITORY}:pull`); + const { token } = await tokenResponse.json(); + const headers = { Authorization: `Bearer ${token}` }; + + const manifestResponse = await getJson(`https://${REGISTRY}/v2/${REPOSITORY}/manifests/latest`, { + headers: { ...headers, Accept: MANIFEST_ACCEPT }, + }); + const manifestBytes = Buffer.from(await manifestResponse.arrayBuffer()); + const manifestDigest = sha256Digest(manifestBytes); + const reported = manifestResponse.headers.get("docker-content-digest"); + if (reported && reported !== manifestDigest) { + throw new Error(`Manifest digest mismatch: registry says ${reported}, bytes hash to ${manifestDigest}`); + } + const manifest = JSON.parse(manifestBytes.toString("utf8")); + const layers = (manifest.layers ?? []).filter((candidate) => candidate.mediaType === CATALOG_MEDIA_TYPE); + if (layers.length !== 1) { + throw new Error(`Expected one ${CATALOG_MEDIA_TYPE} layer, found ${layers.length}`); + } + const [layer] = layers; + + const blobResponse = await getJson(`https://${REGISTRY}/v2/${REPOSITORY}/blobs/${layer.digest}`, { + headers, + redirect: "follow", + }); + const blobBytes = Buffer.from(await blobResponse.arrayBuffer()); + verifyBlob(blobBytes, layer); + + return { catalog: JSON.parse(blobBytes.toString("utf8")), manifestDigest, layerDigest: layer.digest }; +} + +/** Throws unless the bytes match the layer's digest and size. */ +export function verifyBlob(bytes, layer) { + const actual = sha256Digest(bytes); + if (actual !== layer.digest) { + throw new Error(`Catalogue digest mismatch: expected ${layer.digest}, got ${actual}`); + } + if (bytes.length !== layer.size) { + throw new Error(`Catalogue size mismatch: expected ${layer.size}, got ${bytes.length}`); + } +} + +async function fetchConnectorManifest(sourceCommit, key) { + const url = `${CONNECTOR_MANIFEST_BASE}/${sourceCommit}/connectors/${key}/manifest.json`; + const response = await fetch(url); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`${url} returned ${response.status}`); + } + return await response.json(); +} + +// One small request per connector, all at once: the catalogue lists a few +// dozen, and a missing manifest is a null rather than a failure. +async function fetchManifests(catalog) { + const entries = await Promise.all( + catalog.connectors.map(async (connector) => [ + connector.connector_key, + await fetchConnectorManifest(catalog.source_commit, connector.connector_key), + ]) + ); + return Object.fromEntries(entries); +} + +// Measured against the repo formatter: it keeps a JSON array on one line up to this width. +const LINE_WIDTH = 80; +const TRAILING_COMMA = /,$/; +const STRING_ARRAY = /^( *)("[^"]+": )\[\n((?: +"(?:[^"\\]|\\.)*",?\n)+) *\]/gm; + +/** + * JSON.stringify output, with each short array of strings on one line. The + * committed file is checked by the repo formatter, which lays arrays out the + * same way, so a fresh sync leaves no formatting diff. + */ +export function formatSnapshot(snapshot) { + const text = JSON.stringify(snapshot, null, 2).replace(STRING_ARRAY, (whole, indent, key, body) => { + const items = body + .trim() + .split("\n") + .map((item) => item.trim().replace(TRAILING_COMMA, "")); + const line = `${indent}${key}[${items.join(", ")}]`; + return line.length <= LINE_WIDTH ? line : whole; + }); + return `${text}\n`; +} + +export async function syncConnectorCatalog(outputPath = DEFAULT_OUTPUT) { + const { catalog, manifestDigest, layerDigest } = await fetchCatalog(); + const manifests = await fetchManifests(catalog); + const snapshot = buildSnapshot({ catalog, manifests, manifestDigest, layerDigest }); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, formatSnapshot(snapshot)); + return snapshot; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const outIndex = process.argv.indexOf("--out"); + const outputPath = outIndex === -1 ? DEFAULT_OUTPUT : path.resolve(process.argv[outIndex + 1]); + const snapshot = await syncConnectorCatalog(outputPath); + const withLabels = snapshot.connectors.filter((connector) => connector.labels.length > 0).length; + console.log( + `Wrote ${snapshot.connectors.length} connectors (${withLabels} with labels) to ${outputPath}\n` + + `manifest ${snapshot.manifest_digest}\nlayer ${snapshot.layer_digest}\ncommit ${snapshot.source_commit}` + ); +} diff --git a/apps/site/scripts/sync-connector-catalog.test.ts b/apps/site/scripts/sync-connector-catalog.test.ts new file mode 100644 index 000000000..7517719f3 --- /dev/null +++ b/apps/site/scripts/sync-connector-catalog.test.ts @@ -0,0 +1,125 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import test from "node:test"; +import { buildSnapshot, formatSnapshot, labelsFromManifest, verifyBlob } from "./sync-connector-catalog.mjs"; + +const UNKNOWN_TIER = /unknown tier/; +const NO_CONNECTORS = /no connectors array/; +const DIGEST_MISMATCH = /digest mismatch/; +const SIZE_MISMATCH = /size mismatch/; + +const CATALOG = { + catalog_version: "1.0", + connectors: [ + { + connector_id: "https://registry.pdpp.dev/connectors/strava", + connector_key: "strava", + display_name: "Strava", + latest: { digest: "sha256:aa", version: "1.0.0" }, + runtime_requirements: {}, + setup: { modality: "oauth" }, + tier: "supported", + versions: [], + }, + { + connector_key: "spotify", + display_name: "Spotify", + tier: "preview", + }, + { + connector_key: "no-manifest", + display_name: "Alpha", + tier: "development", + }, + ], + generated_at: "2026-11-04T02:08:53.000Z", + source_commit: "abc123", +}; + +const MANIFESTS = { + spotify: { + streams: [ + { display: { label: "Your playlists" }, name: "playlists" }, + { display: { label: "Your saved tracks" }, name: "saved" }, + { display: { label: "Your playlists" }, name: "duplicate" }, + { name: "no-display" }, + { display: { label: " " }, name: "blank" }, + ], + }, + strava: { streams: [] }, +}; + +test("buildSnapshot keeps only what the page needs, sorted by display name", () => { + const snapshot = buildSnapshot({ + catalog: CATALOG, + layerDigest: "sha256:layer", + manifestDigest: "sha256:manifest", + manifests: { ...MANIFESTS, "no-manifest": null }, + }); + + assert.deepEqual(snapshot, { + connectors: [ + { display_name: "Alpha", key: "no-manifest", labels: [], tier: "development" }, + { display_name: "Spotify", key: "spotify", labels: ["Your playlists", "Your saved tracks"], tier: "preview" }, + { display_name: "Strava", key: "strava", labels: [], tier: "supported" }, + ], + generated_at: "2026-11-04T02:08:53.000Z", + layer_digest: "sha256:layer", + manifest_digest: "sha256:manifest", + source: "ghcr.io/pdp-connect/connector-catalog", + source_commit: "abc123", + }); +}); + +test("buildSnapshot treats a manifest that was never fetched as no labels", () => { + const snapshot = buildSnapshot({ + catalog: CATALOG, + layerDigest: "sha256:layer", + manifestDigest: "sha256:manifest", + manifests: {}, + }); + assert.equal(snapshot.connectors.length, 3); + assert.ok(snapshot.connectors.every((connector: { labels: unknown[] }) => connector.labels.length === 0)); +}); + +test("buildSnapshot rejects an unknown tier and a catalogue without connectors", () => { + const base = { layerDigest: "x", manifestDigest: "y", manifests: {} }; + assert.throws( + () => + buildSnapshot({ + ...base, + catalog: { connectors: [{ connector_key: "a", display_name: "A", tier: "beta" }] }, + }), + UNKNOWN_TIER + ); + assert.throws(() => buildSnapshot({ ...base, catalog: {} }), NO_CONNECTORS); +}); + +test("labelsFromManifest ignores a missing manifest", () => { + assert.deepEqual(labelsFromManifest(null), []); + assert.deepEqual(labelsFromManifest({}), []); +}); + +test("verifyBlob accepts matching bytes and rejects a wrong digest or size", () => { + const bytes = Buffer.from('{"connectors":[]}'); + const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; + verifyBlob(bytes, { digest, size: bytes.length }); + assert.throws(() => verifyBlob(bytes, { digest: "sha256:00", size: bytes.length }), DIGEST_MISMATCH); + assert.throws(() => verifyBlob(bytes, { digest, size: bytes.length + 1 }), SIZE_MISMATCH); +}); + +test("formatSnapshot keeps short label lists on one line and round-trips", () => { + const snapshot = buildSnapshot({ + catalog: CATALOG, + layerDigest: "sha256:layer", + manifestDigest: "sha256:manifest", + manifests: MANIFESTS, + }); + const text = formatSnapshot(snapshot); + assert.ok(text.endsWith("}\n")); + assert.ok(text.includes('"labels": ["Your playlists", "Your saved tracks"]')); + assert.deepEqual(JSON.parse(text), snapshot); +}); diff --git a/apps/site/src/app/(concept)/apps/page.test.ts b/apps/site/src/app/(concept)/apps/page.test.ts new file mode 100644 index 000000000..01538c5e8 --- /dev/null +++ b/apps/site/src/app/(concept)/apps/page.test.ts @@ -0,0 +1,63 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { appDirectoryEnabled } from "@/lib/site-config.ts"; +import Page from "./page.tsx"; + +const FLAG = "NEXT_PUBLIC_PDPP_APP_DIRECTORY"; + +function withFlag(value: string | undefined, run: () => T): T { + const previous = process.env[FLAG]; + if (value === undefined) { + delete process.env[FLAG]; + } else { + process.env[FLAG] = value; + } + try { + return run(); + } finally { + if (previous === undefined) { + delete process.env[FLAG]; + } else { + process.env[FLAG] = previous; + } + } +} + +test("only the value true turns the app directory on", () => { + assert.equal( + withFlag(undefined, () => appDirectoryEnabled()), + false + ); + for (const value of ["", "false", "1", "yes", "on"]) { + assert.equal( + withFlag(value, () => appDirectoryEnabled()), + false, + value + ); + } + for (const value of ["true", " TRUE "]) { + assert.equal( + withFlag(value, () => appDirectoryEnabled()), + true, + value + ); + } +}); + +test("the page is not found when the flag is off", async () => { + const previous = process.env[FLAG]; + delete process.env[FLAG]; + try { + await assert.rejects(Page(), (error: unknown) => { + const digest = (error as { digest?: string }).digest ?? ""; + return digest.includes("404") || digest.includes("NEXT_NOT_FOUND"); + }); + } finally { + if (previous !== undefined) { + process.env[FLAG] = previous; + } + } +}); diff --git a/apps/site/src/app/(concept)/apps/page.tsx b/apps/site/src/app/(concept)/apps/page.tsx new file mode 100644 index 000000000..549e4e7ef --- /dev/null +++ b/apps/site/src/app/(concept)/apps/page.tsx @@ -0,0 +1,85 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { PdppConceptDoc, PdppConceptPage } from "@/components/layout/concept-page.tsx"; +import { PdppAppDirectory } from "@/components/site/app-directory.tsx"; +import { Text } from "@/components/typography/text.tsx"; +import { buildDirectory } from "@/lib/app-directory.ts"; +import { readConnectorSnapshot, readRegisterEntries } from "@/lib/app-directory-sources.ts"; +import { readPublicSupporters } from "@/lib/public-supporters.ts"; +import { appDirectoryEnabled } from "@/lib/site-config.ts"; +import { repoBlobUrl } from "@/lib/site-facts.ts"; + +export const metadata: Metadata = { + alternates: { canonical: "/apps" }, + description: "Search for an app to see what you can do with its data through PDPP today.", + openGraph: { url: "/apps" }, + title: "Is your app on PDPP? - PDPP", +}; + +const DATA_CONNECTORS_URL = "https://github.com/PDP-Connect/data-connectors"; + +// "Is your app on PDPP?" +// +// A read-only view over lists PDPP already publishes: the signed connector +// catalogue (committed snapshot), the Supporters register and the conformance +// register. No data is collected and nothing is sent anywhere. The search runs +// in the visitor's browser over rows built here, so the page is static. +// +// Behind NEXT_PUBLIC_PDPP_APP_DIRECTORY. Off, the route does not exist. +export default async function Page() { + if (!appDirectoryEnabled()) { + notFound(); + } + + const [snapshot, supporters, register] = await Promise.all([ + readConnectorSnapshot(), + readPublicSupporters(), + readRegisterEntries(), + ]); + const directory = buildDirectory({ register, snapshot, supporters }); + + return ( + + +
+ + Is your app on PDPP? + + + Search for an app to see what you can do with its data through PDPP today, and whether the company has + signed or registered. + +
+ +
+ +
+ + + Connector catalogue updated {directory.catalogueUpdated}. Company status comes from the public{" "} + + Supporters + {" "} + and conformance registers, as set out in{" "} + + GOVERNANCE.md + + . Connectors are built in{" "} + + PDP-Connect/data-connectors + + . + +
+
+ ); +} diff --git a/apps/site/src/components/site/app-directory.tsx b/apps/site/src/components/site/app-directory.tsx new file mode 100644 index 000000000..130ad1f1d --- /dev/null +++ b/apps/site/src/components/site/app-directory.tsx @@ -0,0 +1,112 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { Text } from "@/components/typography/text.tsx"; +import { type DirectoryEntry, searchDirectory } from "@/lib/app-directory.ts"; +import { cn } from "@/lib/utils.ts"; + +// The app search on /apps. +// +// The server page joins the three published lists into `entries` and passes +// them down, so the page itself is static. This component only filters them as +// the visitor types. The query stays in this component: it is not sent +// anywhere, stored, or put in the URL. + +const fieldClassName = cn( + "w-full border border-border bg-background px-3 py-2", + "font-sans text-[15px] text-foreground", + "focus-visible:border-primary focus-visible:outline-none" +); + +function countLine(count: number, searching: boolean): string { + if (searching) { + return `${count} ${count === 1 ? "result" : "results"}`; + } + return `${count} ${count === 1 ? "app" : "apps"}`; +} + +function AppRow({ entry }: { entry: DirectoryEntry }) { + return ( +
  • +
    + + {entry.name} + + {entry.tierLabel && ( + + {entry.tierLabel} + + )} +
    + + {entry.headline} + + {entry.labels && ( + + {entry.labels} + + )} + + {entry.status} + +
  • + ); +} + +export function PdppAppDirectory({ entries }: { entries: readonly DirectoryEntry[] }) { + const [query, setQuery] = useState(""); + const trimmed = query.trim(); + const shown = searchDirectory(entries, query); + const showsDevelopment = shown.some((entry) => entry.tier === "development"); + + return ( +
    +
    + + setQuery(event.target.value)} + placeholder="Search for an app, for example Spotify" + type="search" + value={query} + /> + + {countLine(shown.length, trimmed.length > 0)} + +
    + + {shown.length === 0 ? ( +
    + + Nothing for “{trimmed}” yet. + + + + Ask for it when you sign the Principles + + +
    + ) : ( +
      + {shown.map((entry) => ( + + ))} +
    + )} + + {showsDevelopment && ( + + Connectors marked In development may not work for everyone yet. + + )} +
    + ); +} diff --git a/apps/site/src/data/connector-catalog.json b/apps/site/src/data/connector-catalog.json new file mode 100644 index 000000000..766b2bcc6 --- /dev/null +++ b/apps/site/src/data/connector-catalog.json @@ -0,0 +1,548 @@ +{ + "source": "ghcr.io/pdp-connect/connector-catalog", + "manifest_digest": "sha256:5be68a20e8bcc0892727fb0d1d57821ced6cc8ba02a57b236058862c3e4ddcb4", + "layer_digest": "sha256:c484ef45e0fd733ecaa7cb9037d58b7b84c9a2b8ee061b85cec573a51765ba15", + "generated_at": "2026-11-04T02:08:53.000Z", + "source_commit": "28a179299109dba249c90c41324f9abe0378ddb9", + "connectors": [ + { + "key": "amazon", + "display_name": "Amazon", + "tier": "supported", + "labels": [ + "Your Amazon orders", + "Your order line items", + "Your Amazon account" + ] + }, + { + "key": "apple-contacts", + "display_name": "Apple Contacts", + "tier": "preview", + "labels": [] + }, + { + "key": "apple-health", + "display_name": "Apple Health", + "tier": "development", + "labels": [] + }, + { + "key": "apple-photos", + "display_name": "Apple Photos", + "tier": "development", + "labels": [] + }, + { + "key": "ical", + "display_name": "Calendar (iCal)", + "tier": "development", + "labels": ["Your calendar events"] + }, + { + "key": "character-ai", + "display_name": "Character.AI", + "tier": "development", + "labels": [] + }, + { + "key": "chase", + "display_name": "Chase", + "tier": "supported", + "labels": [ + "Your Chase accounts", + "Your Chase transactions", + "Your current Chase activity", + "Your Chase statements", + "Your Chase balance snapshots" + ] + }, + { + "key": "chatgpt", + "display_name": "ChatGPT", + "tier": "supported", + "labels": [ + "Your conversations", + "Your messages", + "ChatGPT memories", + "Your custom GPTs", + "Your custom instructions", + "Your shared conversations", + "Your ChatGPT plan", + "Your ChatGPT account profile", + "Your ChatGPT usage summary" + ] + }, + { + "key": "anthropic", + "display_name": "Claude (Anthropic)", + "tier": "preview", + "labels": [ + "Your Claude account", + "Your Claude plan", + "Your Claude conversations", + "Your Claude messages", + "Your Claude projects", + "Your Claude project documents" + ] + }, + { + "key": "anthropic-live", + "display_name": "Claude (Live Session)", + "tier": "preview", + "labels": [] + }, + { + "key": "claude-code", + "display_name": "Claude Code", + "tier": "supported", + "labels": [] + }, + { + "key": "discord-browser", + "display_name": "Discord (Browser Sign-In)", + "tier": "development", + "labels": [] + }, + { + "key": "doordash", + "display_name": "DoorDash", + "tier": "development", + "labels": ["Your DoorDash orders", "Your DoorDash order items"] + }, + { + "key": "facebook", + "display_name": "Facebook", + "tier": "development", + "labels": [ + "Your Facebook profile", + "Your posts", + "Your friends", + "Your groups", + "Your likes and reactions", + "Your comments" + ] + }, + { + "key": "fitbit", + "display_name": "Fitbit", + "tier": "development", + "labels": [ + "Your Fitbit exercise", + "Your Fitbit daily activity", + "Your Fitbit sleep" + ] + }, + { + "key": "garmin-browser", + "display_name": "Garmin (Browser Sign-In)", + "tier": "development", + "labels": [] + }, + { + "key": "github-browser", + "display_name": "GitHub (browser session)", + "tier": "preview", + "labels": [] + }, + { + "key": "gmail", + "display_name": "Gmail", + "tier": "preview", + "labels": [ + "Your messages", + "Your conversations", + "Your labels", + "The text of your emails", + "Your attachments" + ] + }, + { + "key": "google-calendar", + "display_name": "Google Calendar", + "tier": "development", + "labels": [] + }, + { + "key": "google-contacts", + "display_name": "Google Contacts", + "tier": "development", + "labels": [] + }, + { + "key": "google-maps-data-portability", + "display_name": "Google Maps Data Portability", + "tier": "development", + "labels": [] + }, + { + "key": "google-maps", + "display_name": "Google Maps Timeline Import", + "tier": "supported", + "labels": [] + }, + { + "key": "google-takeout", + "display_name": "Google Takeout", + "tier": "development", + "labels": [] + }, + { + "key": "groupme", + "display_name": "GroupMe", + "tier": "development", + "labels": [ + "Your GroupMe groups", + "Messages from your GroupMe groups", + "Your GroupMe direct messages", + "Your GroupMe attachments" + ] + }, + { + "key": "heb", + "display_name": "H-E-B", + "tier": "preview", + "labels": [ + "Your H-E-B orders", + "Your H-E-B order line items", + "Your H-E-B profile", + "Your H-E-B product nutrition" + ] + }, + { + "key": "icloud-notes", + "display_name": "iCloud Notes", + "tier": "preview", + "labels": [] + }, + { + "key": "imessage", + "display_name": "iMessage (macOS)", + "tier": "development", + "labels": [ + "Your iMessages", + "Your iMessage chat participants", + "Your iMessage attachments" + ] + }, + { + "key": "meta", + "display_name": "Instagram (Meta)", + "tier": "preview", + "labels": [ + "Your Instagram profile", + "Your posts", + "Likes on your posts", + "Accounts you follow", + "Your Instagram ad preferences" + ] + }, + { + "key": "instinct", + "display_name": "Instinct", + "tier": "development", + "labels": [ + "Your Instinct profile", + "How you reach your Instinct", + "Services connected to your Instinct", + "Your enrolled devices", + "People your Instinct trusts", + "What your Instinct vault holds", + "Your chats with your agent", + "Your conversations with your agent" + ] + }, + { + "key": "jellyfin", + "display_name": "Jellyfin", + "tier": "preview", + "labels": ["Your Jellyfin libraries", "Your Jellyfin items"] + }, + { + "key": "linkedin", + "display_name": "LinkedIn", + "tier": "preview", + "labels": [ + "Your LinkedIn profile", + "Your work experience", + "Your education", + "Your skills", + "Your languages", + "Your connections" + ] + }, + { + "key": "loom", + "display_name": "Loom", + "tier": "development", + "labels": ["Your Loom videos", "Your Loom transcripts"] + }, + { + "key": "muse", + "display_name": "Muse (Meta)", + "tier": "development", + "labels": [ + "Your Muse chats", + "Your Muse messages", + "Your Muse agent activity", + "Your Muse goals", + "Your Muse scheduled tasks", + "Your Muse agent's memory", + "Your Muse files", + "Your Muse connected apps" + ] + }, + { + "key": "netflix-export", + "display_name": "Netflix Export", + "tier": "development", + "labels": [] + }, + { + "key": "notion", + "display_name": "Notion", + "tier": "preview", + "labels": ["Your Notion pages", "Your Notion databases"] + }, + { + "key": "codex", + "display_name": "OpenAI Codex CLI", + "tier": "supported", + "labels": [ + "Your Codex CLI sessions", + "Your Codex messages", + "Codex tool calls", + "Your Codex approval rules", + "Your Codex prompts", + "Your Codex skills" + ] + }, + { + "key": "oura", + "display_name": "Oura", + "tier": "development", + "labels": ["Your sleep sessions", "Your readiness", "Your activity"] + }, + { + "key": "oura-browser", + "display_name": "Oura (Browser Sign-In)", + "tier": "preview", + "labels": [] + }, + { + "key": "perplexity", + "display_name": "Perplexity (Browser Sign-In)", + "tier": "development", + "labels": ["Your Perplexity threads", "Your Perplexity messages"] + }, + { + "key": "pocket", + "display_name": "Pocket", + "tier": "development", + "labels": ["Your Pocket saves"] + }, + { + "key": "reddit", + "display_name": "Reddit", + "tier": "supported", + "labels": [ + "Your Reddit posts", + "Your Reddit post stats", + "Your Reddit comments", + "Your Reddit comment stats", + "Your saved Reddit items", + "Your upvoted Reddit items", + "Your upvoted Reddit item stats", + "Your downvoted Reddit items", + "Your downvoted Reddit item stats", + "Your hidden Reddit items", + "Your hidden Reddit item stats" + ] + }, + { + "key": "shopify", + "display_name": "Shopify Shop", + "tier": "preview", + "labels": ["Your Shop app orders"] + }, + { + "key": "slack-browser", + "display_name": "Slack (Browser Sign-In)", + "tier": "development", + "labels": [] + }, + { + "key": "spotify", + "display_name": "Spotify", + "tier": "preview", + "labels": [ + "Your playlists", + "Your playlist tracks", + "Your saved tracks", + "Your Spotify profile", + "Recently played contexts", + "Your top artists", + "Your top tracks" + ] + }, + { + "key": "steam", + "display_name": "Steam", + "tier": "preview", + "labels": [ + "Your Steam profile", + "Your owned games", + "Your recently played games", + "Your Steam friends", + "Your Steam level" + ] + }, + { + "key": "strava", + "display_name": "Strava", + "tier": "supported", + "labels": ["Your Strava activities"] + }, + { + "key": "strava-browser", + "display_name": "Strava (Browser Sign-In)", + "tier": "preview", + "labels": [] + }, + { + "key": "tiktok", + "display_name": "TikTok (Browser Sign-In)", + "tier": "development", + "labels": [ + "Your TikTok profile", + "Your TikTok videos", + "Videos you liked on TikTok", + "Your TikTok Favorites", + "Your TikTok Favorites collections", + "Videos in your TikTok Favorites collections", + "Accounts you follow on TikTok", + "Your TikTok followers" + ] + }, + { + "key": "twitter-archive", + "display_name": "Twitter/X Archive", + "tier": "development", + "labels": [] + }, + { + "key": "uber", + "display_name": "Uber", + "tier": "supported", + "labels": ["Your Uber trips", "Your Uber receipts"] + }, + { + "key": "usaa", + "display_name": "USAA", + "tier": "preview", + "labels": [ + "Your USAA accounts", + "Your USAA account balances (daily)", + "Your USAA transactions", + "Your USAA statements", + "Your USAA inbox", + "Your credit card billing details", + "Your credit card balances (daily)" + ] + }, + { + "key": "venmo", + "display_name": "Venmo", + "tier": "preview", + "labels": [ + "Your Venmo profile", + "Your Venmo friends", + "Your Venmo transactions" + ] + }, + { + "key": "whatsapp", + "display_name": "WhatsApp (chat export)", + "tier": "supported", + "labels": [ + "Your WhatsApp chats", + "Your WhatsApp messages", + "Your WhatsApp media attachments" + ] + }, + { + "key": "whatsapp-web", + "display_name": "WhatsApp (web)", + "tier": "development", + "labels": [] + }, + { + "key": "wholefoods", + "display_name": "Whole Foods", + "tier": "development", + "labels": [ + "Your Amazon account", + "Your Whole Foods orders", + "Your Whole Foods items", + "Nutrition facts" + ] + }, + { + "key": "whoop", + "display_name": "WHOOP", + "tier": "supported", + "labels": [ + "Your WHOOP profile", + "Your WHOOP body measurements", + "Your WHOOP cycles", + "Your WHOOP recoveries", + "Your WHOOP sleeps", + "Your WHOOP workouts" + ] + }, + { + "key": "whoop-browser", + "display_name": "WHOOP (Browser Sign-In)", + "tier": "development", + "labels": [] + }, + { + "key": "x-browser", + "display_name": "X (Browser Sign-In)", + "tier": "development", + "labels": [] + }, + { + "key": "ynab", + "display_name": "YNAB", + "tier": "supported", + "labels": [ + "Your budgets", + "Your accounts", + "Account balances (daily)", + "Your budget category groups", + "Your budget categories", + "Your payees", + "Payee GPS locations", + "Your transactions", + "Your scheduled transactions", + "Monthly budget summaries", + "Monthly category history" + ] + }, + { + "key": "youtube", + "display_name": "YouTube", + "tier": "preview", + "labels": [ + "Your YouTube channel", + "Your subscriptions", + "Your playlists", + "Your playlist items", + "Your liked videos", + "Your watch later list", + "Your watch history" + ] + } + ] +} diff --git a/apps/site/src/lib/app-directory-sources.ts b/apps/site/src/lib/app-directory-sources.ts new file mode 100644 index 000000000..b575182d0 --- /dev/null +++ b/apps/site/src/lib/app-directory-sources.ts @@ -0,0 +1,52 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +// Server-side readers for the two files /apps reads from this repository. +// Both ship in the repo and hold no personal data, so they are read at build +// time. The Supporters register has its own reader in public-supporters.ts. + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import type { ConnectorSnapshot, RegisterEntry } from "./app-directory.ts"; + +const SNAPSHOT_PATH = ["src", "data", "connector-catalog.json"] as const; +const REGISTER_PATH = ["public", "register", "index.json"] as const; + +const snapshotSchema = z.object({ + connectors: z.array( + z.object({ + display_name: z.string(), + key: z.string(), + labels: z.array(z.string()), + tier: z.enum(["supported", "preview", "development"]), + }) + ), + generated_at: z.string(), + layer_digest: z.string(), + manifest_digest: z.string(), + source: z.string(), + source_commit: z.string(), +}); + +const registerSchema = z.array( + z.object({ + organisation: z.string(), + role: z.string(), + since: z.string(), + specVersion: z.string(), + state: z.string(), + status: z.string(), + }) +); + +export async function readConnectorSnapshot(): Promise { + const file = path.join(process.cwd(), ...SNAPSHOT_PATH); + return snapshotSchema.parse(JSON.parse(await readFile(file, "utf8"))); +} + +/** The conformance register. Empty until Registered and Verified open. */ +export async function readRegisterEntries(): Promise { + const file = path.join(process.cwd(), ...REGISTER_PATH); + return registerSchema.parse(JSON.parse(await readFile(file, "utf8"))); +} diff --git a/apps/site/src/lib/app-directory.test.ts b/apps/site/src/lib/app-directory.test.ts new file mode 100644 index 000000000..f36359ef1 --- /dev/null +++ b/apps/site/src/lib/app-directory.test.ts @@ -0,0 +1,234 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { + appName, + buildDirectory, + type ConnectorSnapshot, + type DirectoryEntry, + formatDate, + labelsLine, + type RegisterEntry, + searchDirectory, +} from "./app-directory.ts"; +import { readConnectorSnapshot, readRegisterEntries } from "./app-directory-sources.ts"; +import type { PublicSupporter } from "./public-supporters.ts"; + +const OURA_DATA = /your Oura data/; +const COMPANY_CLAIM = /\b(is on|is part of|supports|takes part|partner)\b/i; +const COMMUNITY_BUILT = /built by the PDPP community/; +const COMPANY_OR_OFFICIAL = /company|official/i; + +const SNAPSHOT: ConnectorSnapshot = { + connectors: [ + { display_name: "Oura", key: "oura", labels: [], tier: "development" }, + { display_name: "Oura (Browser Sign-In)", key: "oura-browser", labels: ["Your sleep"], tier: "preview" }, + { + display_name: "Spotify", + key: "spotify", + labels: [ + "Your playlists", + "Your saved tracks", + "Your top artists", + "Your top tracks", + "Your history", + "Your podcasts", + ], + tier: "preview", + }, + { display_name: "Strava", key: "strava", labels: ["Your activities"], tier: "supported" }, + ], + generated_at: "2026-11-04T02:08:53.000Z", + layer_digest: "sha256:layer", + manifest_digest: "sha256:manifest", + source: "ghcr.io/pdp-connect/connector-catalog", + source_commit: "abc", +}; + +function supporter(publicName: string, type: string, signedOn = "2026-09-09"): PublicSupporter { + return { country: "Germany", principlesVersion: "1.0", publicName, signedOn, type }; +} + +function registration(organisation: string, status: string, state = "Current"): RegisterEntry { + return { organisation, role: "Source", since: "2026-12-01", specVersion: "0.2", state, status }; +} + +function directory(supporters: PublicSupporter[] = [], register: RegisterEntry[] = []) { + return buildDirectory({ register, snapshot: SNAPSHOT, supporters }).entries; +} + +function find(entries: readonly DirectoryEntry[], name: string): DirectoryEntry { + const entry = entries.find((candidate) => candidate.name === name); + assert.ok(entry, `no entry named ${name}`); + return entry; +} + +test("a connector alone: you can bring your data, and the company has not signed or registered", () => { + const spotify = find(directory(), "Spotify"); + assert.equal( + spotify.headline, + "You can bring your Spotify data into PDPP today, with a connector built by the PDPP community." + ); + assert.equal(spotify.labels, "Your playlists, Your saved tracks, Your top artists, Your top tracks + 2 more"); + assert.equal(spotify.status, "Spotify has not signed the Principles or registered with PDPP."); + assert.equal(spotify.tierLabel, "Preview"); +}); + +test("a signed organisation with a connector shows the signing date", () => { + const spotify = find(directory([supporter("Spotify", "Company", "2026-09-09")]), "Spotify"); + assert.equal(spotify.status, "Spotify signed the PDPP Principles on 9 Sep 2026. It has not registered with PDPP."); + assert.equal( + spotify.headline, + "You can bring your Spotify data into PDPP today, with a connector built by the PDPP community." + ); +}); + +test("a signed and registered organisation says both", () => { + const spotify = find( + directory([supporter("Spotify", "Company", "2026-09-09")], [registration("Spotify", "Registered Source")]), + "Spotify" + ); + assert.equal(spotify.status, "Spotify signed the PDPP Principles on 9 Sep 2026 and is a Registered Source."); +}); + +test("a Verified status also names the company a Partner", () => { + const spotify = find(directory([], [registration("Spotify", "Verified Source")]), "Spotify"); + assert.equal(spotify.status, "Spotify has not signed the Principles. It is a Verified Source. It is a PDPP Partner."); +}); + +test("a Registered status alone does not make a Partner", () => { + const spotify = find(directory([], [registration("Spotify", "Registered Source")]), "Spotify"); + assert.equal(spotify.status, "Spotify has not signed the Principles. It is a Registered Source."); +}); + +test("an ended registration is ignored", () => { + const spotify = find(directory([], [registration("Spotify", "Verified Source", "Lapsed")]), "Spotify"); + assert.equal(spotify.status, "Spotify has not signed the Principles or registered with PDPP."); +}); + +test("a signed organisation without a connector is its own row, with no connector claim", () => { + const kalo = find(directory([supporter("Kalo", "Company", "2026-09-05")]), "Kalo"); + assert.equal(kalo.kind, "company"); + assert.equal(kalo.headline, "There is no PDPP connector for Kalo yet."); + assert.equal(kalo.status, "Kalo signed the PDPP Principles on 5 Sep 2026. It has not registered with PDPP."); + assert.equal(kalo.tier, null); +}); + +test("individuals never appear", () => { + const entries = directory([supporter("Tim N.", "Individual"), supporter("Spotify", "Individual")]); + assert.equal(entries.length, SNAPSHOT.connectors.length); + assert.equal(find(entries, "Spotify").status, "Spotify has not signed the Principles or registered with PDPP."); + assert.deepEqual(searchDirectory(entries, "tim"), []); +}); + +test("a company is matched by whole name, not by substring", () => { + const entries = directory([supporter("Spot", "Company")]); + assert.equal(find(entries, "Spotify").status, "Spotify has not signed the Principles or registered with PDPP."); + assert.equal(find(entries, "Spot").kind, "company"); +}); + +test("a bracketed company name links the connector to its company", () => { + const snapshot: ConnectorSnapshot = { + ...SNAPSHOT, + connectors: [{ display_name: "Claude (Anthropic)", key: "anthropic", labels: [], tier: "preview" }], + }; + const { entries } = buildDirectory({ + register: [], + snapshot, + supporters: [supporter("Anthropic", "Company", "2026-10-01")], + }); + assert.equal(entries.length, 1); + assert.equal(entries[0]?.status, "Claude signed the PDPP Principles on 1 Oct 2026. It has not registered with PDPP."); +}); + +test("separate connectors for the same app stay separate rows", () => { + const oura = directory().filter((entry) => entry.name.startsWith("Oura")); + assert.deepEqual( + oura.map((entry) => entry.name), + ["Oura", "Oura (Browser Sign-In)"] + ); + assert.match(oura[1]?.headline ?? "", OURA_DATA); +}); + +test("a connector in development is not promised as available today", () => { + const oura = find(directory(), "Oura"); + assert.equal(oura.tierLabel, "In development"); + assert.equal(oura.headline, "A connector for Oura built by the PDPP community is in development."); +}); + +test("search is case-insensitive and matches a substring of the app name", () => { + const entries = directory(); + assert.deepEqual( + searchDirectory(entries, " OURA ").map((entry) => entry.name), + ["Oura", "Oura (Browser Sign-In)"] + ); + assert.deepEqual( + searchDirectory(entries, "potif").map((entry) => entry.name), + ["Spotify"] + ); +}); + +test("search matches an organisation by name and shows company rows only then", () => { + const entries = directory([supporter("Open Data Labs", "Company")]); + assert.equal(searchDirectory(entries, "").length, SNAPSHOT.connectors.length); + assert.deepEqual( + searchDirectory(entries, "data LABS").map((entry) => entry.name), + ["Open Data Labs"] + ); +}); + +test("search matches the linked company's name", () => { + const { entries } = buildDirectory({ + register: [], + snapshot: { + ...SNAPSHOT, + connectors: [{ display_name: "Claude (Anthropic)", key: "a", labels: [], tier: "preview" }], + }, + supporters: [supporter("Anthropic", "Company")], + }); + assert.equal(searchDirectory(entries, "anthropic").length, 1); +}); + +test("no match gives an empty list", () => { + assert.deepEqual(searchDirectory(directory(), "zzz"), []); +}); + +test("the first line never says a company is on, supports or takes part in PDPP", () => { + const entries = directory( + [supporter("Spotify", "Company"), supporter("Kalo", "Company")], + [registration("Strava", "Verified Source")] + ); + for (const entry of entries) { + assert.doesNotMatch(entry.headline, COMPANY_CLAIM, entry.headline); + } + for (const entry of entries.filter((candidate) => candidate.kind === "connector")) { + assert.match(entry.headline, COMMUNITY_BUILT); + assert.doesNotMatch(entry.headline, COMPANY_OR_OFFICIAL); + } +}); + +test("formatDate, appName and labelsLine", () => { + assert.equal(formatDate("2026-11-04T02:08:53.000Z"), "4 Nov 2026"); + assert.equal(formatDate("2026-09-05"), "5 Sep 2026"); + assert.equal(formatDate("soon"), "soon"); + assert.equal(appName("Oura (Browser Sign-In)"), "Oura"); + assert.equal(appName("Twitter/X Archive"), "Twitter/X Archive"); + assert.equal(labelsLine(["A", "B"]), "A, B"); + assert.equal(labelsLine(["A", "B", "C", "D"]), "A, B, C, D"); + assert.equal(labelsLine(["A", "B", "C", "D", "E"]), "A, B, C, D + 1 more"); +}); + +test("the committed snapshot and register parse and build a directory", async () => { + const snapshot = await readConnectorSnapshot(); + const register = await readRegisterEntries(); + const supporters = JSON.parse( + await readFile(new URL("../../public/principles/supporters.json", import.meta.url), "utf8") + ) as PublicSupporter[]; + const { entries } = buildDirectory({ register, snapshot, supporters }); + assert.ok(snapshot.connectors.length > 0); + assert.equal(entries.filter((entry) => entry.kind === "connector").length, snapshot.connectors.length); + assert.ok(entries.every((entry) => !entry.matchNames.includes("tim n."))); +}); diff --git a/apps/site/src/lib/app-directory.ts b/apps/site/src/lib/app-directory.ts new file mode 100644 index 000000000..3dd989b62 --- /dev/null +++ b/apps/site/src/lib/app-directory.ts @@ -0,0 +1,267 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +// The logic behind /apps ("Is your app on PDPP?"). +// +// Read-only view over three lists PDPP already publishes: the signed connector +// catalogue (committed as a snapshot), the Supporters register and the +// conformance register. Nothing here collects data or sends it anywhere. +// +// WORDING RULE: a connector is something the PDPP community built. It says +// nothing about the company. A company's own status comes only from the +// Supporters and conformance registers, and the first line of a row never says +// the company is on PDPP, supports it, or takes part. + +import type { PublicSupporter } from "./public-supporters.ts"; + +export type ConnectorTier = "supported" | "preview" | "development"; + +/** The committed catalogue snapshot (src/data/connector-catalog.json). */ +export interface ConnectorSnapshot { + connectors: readonly { display_name: string; key: string; labels: readonly string[]; tier: ConnectorTier }[]; + generated_at: string; + layer_digest: string; + manifest_digest: string; + source: string; + source_commit: string; +} + +/** One row of the conformance register (public/register/index.json). */ +export interface RegisterEntry { + organisation: string; + role: string; + since: string; + specVersion: string; + state: string; + status: string; +} + +export const TIER_LABELS: Record = { + development: "In development", + preview: "Preview", + supported: "Supported", +}; + +const MAX_LABELS = 4; +const ENDED_STATE = /lapsed|withdrawn|revoked|expired/i; +const TRAILING_PARENTHETICAL = /\s*\(([^)]*)\)\s*$/; +const NON_ALPHANUMERIC = /[^\p{L}\p{N}]+/gu; +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const; +const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})/; + +/** "2026-09-05" or "2026-11-04T02:08:53Z" to "5 Sep 2026" (UTC, no locale). */ +export function formatDate(value: string): string { + const match = ISO_DATE.exec(value); + const month = match ? MONTHS[Number(match[2]) - 1] : undefined; + if (!(match && month)) { + return value; + } + return `${Number(match[3])} ${month} ${match[1]}`; +} + +function normalise(name: string): string { + return name.toLocaleLowerCase("en").replace(NON_ALPHANUMERIC, " ").trim(); +} + +/** "Oura (Browser Sign-In)" to "Oura". The row title keeps the full name. */ +export function appName(displayName: string): string { + return displayName.replace(TRAILING_PARENTHETICAL, "").trim() || displayName; +} + +// Names a company could be listed under for this connector: the app name, and +// the bracketed part ("Claude (Anthropic)" is Anthropic's). Matching is by +// whole name, never substring, so "Meta" is not "Metaculus". +function companyNames(displayName: string): string[] { + const names = [normalise(appName(displayName))]; + const bracketed = TRAILING_PARENTHETICAL.exec(displayName)?.[1]; + if (bracketed) { + names.push(normalise(bracketed)); + } + return names; +} + +/** What the registers say about one company. */ +export interface CompanyStatus { + /** Registered or Verified statuses on the conformance register that have not ended. */ + registrations: readonly { since: string; status: string }[]; + /** Date the company signed the Principles, when it did. */ + signedOn: string | null; +} + +export interface DirectoryEntry { + /** What a person can do with this app's data. Empty for a company with no connector. */ + headline: string; + id: string; + kind: "connector" | "company"; + labels: string; + /** Lower-cased names a search can match: the app and the company. */ + matchNames: readonly string[]; + name: string; + /** Muted line: the company's own status. */ + status: string; + tier: ConnectorTier | null; + tierLabel: string | null; +} + +export interface Directory { + catalogueUpdated: string; + entries: readonly DirectoryEntry[]; +} + +function isOrganisation(supporter: PublicSupporter): boolean { + return supporter.type.trim().toLowerCase() !== "individual"; +} + +function holdsVerified(status: CompanyStatus): boolean { + return status.registrations.some((registration) => registration.status.startsWith("Verified")); +} + +/** The muted status line. Pure wording, tested for every combination. */ +export function statusLine(name: string, status: CompanyStatus): string { + const registered = status.registrations.map((registration) => `a ${registration.status}`); + const partner = holdsVerified(status) ? " It is a PDPP Partner." : ""; + if (status.signedOn && registered.length > 0) { + return `${name} signed the PDPP Principles on ${formatDate(status.signedOn)} and is ${joinList(registered)}.${partner}`; + } + if (status.signedOn) { + return `${name} signed the PDPP Principles on ${formatDate(status.signedOn)}. It has not registered with PDPP.`; + } + if (registered.length > 0) { + return `${name} has not signed the Principles. It is ${joinList(registered)}.${partner}`; + } + return `${name} has not signed the Principles or registered with PDPP.`; +} + +function joinList(items: readonly string[]): string { + if (items.length <= 2) { + return items.join(" and "); + } + return `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`; +} + +/** "A, B, C, D + 3 more" */ +export function labelsLine(labels: readonly string[]): string { + if (labels.length <= MAX_LABELS) { + return labels.join(", "); + } + return `${labels.slice(0, MAX_LABELS).join(", ")} + ${labels.length - MAX_LABELS} more`; +} + +function connectorHeadline(name: string, tier: ConnectorTier): string { + // A connector in development may not run yet, so it is not promised as + // something a person can do today. + if (tier === "development") { + return `A connector for ${name} built by the PDPP community is in development.`; + } + return `You can bring your ${name} data into PDPP today, with a connector built by the PDPP community.`; +} + +interface Company { + name: string; + registrations: { since: string; status: string }[]; + signedOn: string | null; +} + +function collectCompanies( + supporters: readonly PublicSupporter[], + register: readonly RegisterEntry[] +): Map { + const companies = new Map(); + const ensure = (name: string): Company => { + const key = normalise(name); + const existing = companies.get(key); + if (existing) { + return existing; + } + const created: Company = { name, registrations: [], signedOn: null }; + companies.set(key, created); + return created; + }; + for (const supporter of supporters) { + // Individuals are people, not apps. They never reach this page. + if (isOrganisation(supporter)) { + const company = ensure(supporter.publicName); + if (!company.signedOn || supporter.signedOn < company.signedOn) { + company.signedOn = supporter.signedOn; + } + } + } + for (const entry of register) { + if (!ENDED_STATE.test(entry.state)) { + ensure(entry.organisation).registrations.push({ since: entry.since, status: entry.status }); + } + } + return companies; +} + +/** Joins the three lists into the rows the page shows. */ +export function buildDirectory({ + register, + snapshot, + supporters, +}: { + register: readonly RegisterEntry[]; + snapshot: ConnectorSnapshot; + supporters: readonly PublicSupporter[]; +}): Directory { + const companies = collectCompanies(supporters, register); + const linked = new Set(); + const entries: DirectoryEntry[] = []; + + for (const connector of snapshot.connectors) { + const name = appName(connector.display_name); + const match = companyNames(connector.display_name).find((candidate) => companies.has(candidate)); + const company = match ? companies.get(match) : undefined; + if (match) { + linked.add(match); + } + entries.push({ + headline: connectorHeadline(name, connector.tier), + id: `connector:${connector.key}`, + kind: "connector", + labels: labelsLine(connector.labels), + matchNames: [connector.display_name, company?.name].filter((n): n is string => Boolean(n)).map(lower), + name: connector.display_name, + status: statusLine(name, company ?? { registrations: [], signedOn: null }), + tier: connector.tier, + tierLabel: TIER_LABELS[connector.tier], + }); + } + + const companyEntries: DirectoryEntry[] = []; + for (const [key, company] of companies) { + if (!linked.has(key)) { + companyEntries.push({ + headline: `There is no PDPP connector for ${company.name} yet.`, + id: `company:${key}`, + kind: "company", + labels: "", + matchNames: [lower(company.name)], + name: company.name, + status: statusLine(company.name, company), + tier: null, + tierLabel: null, + }); + } + } + companyEntries.sort((a, b) => a.name.localeCompare(b.name, "en")); + + return { catalogueUpdated: formatDate(snapshot.generated_at), entries: [...entries, ...companyEntries] }; +} + +function lower(value: string): string { + return value.toLocaleLowerCase("en"); +} + +/** + * Case-insensitive substring match on the app name and the company name. + * With no query, every connector is shown; a company with no connector shows + * only when the search matches it. + */ +export function searchDirectory(entries: readonly DirectoryEntry[], query: string): DirectoryEntry[] { + const needle = lower(query.trim()); + if (!needle) { + return entries.filter((entry) => entry.kind === "connector"); + } + return entries.filter((entry) => entry.matchNames.some((name) => name.includes(needle))); +} diff --git a/apps/site/src/lib/site-config.ts b/apps/site/src/lib/site-config.ts index e8f378138..46bb4a306 100644 --- a/apps/site/src/lib/site-config.ts +++ b/apps/site/src/lib/site-config.ts @@ -40,12 +40,15 @@ export function isPlaceholder(value: string): boolean { return PLACEHOLDER_PATTERN.test(value); } -// Feature flags. All four default OFF: a flag that defaults on ships the +// Feature flags. All default OFF: a flag that defaults on ships the // feature the moment the code lands, which is the opposite of what a flag is // for. `reviewOpen` gates the banner and the nav dropdown entry; `signingLive` // gates the whole Supporter form and the /api/sign route; `accessorApplications` // and `operatorApplications` gate the Accessor and Operator apply buttons on -// /participate until their test suites are published. +// /participate until their test suites are published. `appDirectoryEnabled()` +// gates the /apps page ("Is your app on PDPP?"); set +// NEXT_PUBLIC_PDPP_APP_DIRECTORY=true to turn it on. It is a function, not a +// `siteFlags` field, so the page reads the variable when it renders. function flag(value: string | undefined): boolean { return value?.trim().toLowerCase() === "true"; } @@ -61,6 +64,11 @@ export const siteFlags = { signingLive: flag(process.env.NEXT_PUBLIC_PDPP_SIGNING_LIVE), } as const; +/** True when /apps is switched on. Off: the page returns not found. */ +export function appDirectoryEnabled(): boolean { + return flag(process.env.NEXT_PUBLIC_PDPP_APP_DIRECTORY); +} + export const siteConfig = { /** Where the Supporter form posts. Same-origin by default. */ formEndpoint: configured(process.env.NEXT_PUBLIC_PDPP_FORM_ENDPOINT, "form endpoint not set"), From d47c28834a61a641ca1fc8389f677404806b5b53 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 4 Nov 2026 14:55:47 -0600 Subject: [PATCH 02/16] site: read connector labels from underscored source folders, say open-source Catalogue keys are hyphenated but most source folders use underscores, so 22 connectors had no labels. Most connectors were written by maintainers, so 'open-source connector' is the accurate description. Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/site/scripts/sync-connector-catalog.mjs | 26 ++-- .../scripts/sync-connector-catalog.test.ts | 13 +- apps/site/src/data/connector-catalog.json | 122 ++++++++++++++---- apps/site/src/lib/app-directory.test.ts | 10 +- apps/site/src/lib/app-directory.ts | 6 +- 5 files changed, 138 insertions(+), 39 deletions(-) diff --git a/apps/site/scripts/sync-connector-catalog.mjs b/apps/site/scripts/sync-connector-catalog.mjs index e00910d53..0c401d796 100644 --- a/apps/site/scripts/sync-connector-catalog.mjs +++ b/apps/site/scripts/sync-connector-catalog.mjs @@ -141,16 +141,26 @@ export function verifyBlob(bytes, layer) { } } +// Catalogue keys use hyphens (`apple-health`); most source folders use +// underscores (`connectors/apple_health`). Try the key as written, then that form. +export function manifestFolderCandidates(key) { + const underscored = key.replaceAll("-", "_"); + return underscored === key ? [key] : [key, underscored]; +} + async function fetchConnectorManifest(sourceCommit, key) { - const url = `${CONNECTOR_MANIFEST_BASE}/${sourceCommit}/connectors/${key}/manifest.json`; - const response = await fetch(url); - if (response.status === 404) { - return null; - } - if (!response.ok) { - throw new Error(`${url} returned ${response.status}`); + for (const folder of manifestFolderCandidates(key)) { + const url = `${CONNECTOR_MANIFEST_BASE}/${sourceCommit}/connectors/${folder}/manifest.json`; + const response = await fetch(url); + if (response.status === 404) { + continue; + } + if (!response.ok) { + throw new Error(`${url} returned ${response.status}`); + } + return await response.json(); } - return await response.json(); + return null; } // One small request per connector, all at once: the catalogue lists a few diff --git a/apps/site/scripts/sync-connector-catalog.test.ts b/apps/site/scripts/sync-connector-catalog.test.ts index 7517719f3..45b070c6b 100644 --- a/apps/site/scripts/sync-connector-catalog.test.ts +++ b/apps/site/scripts/sync-connector-catalog.test.ts @@ -4,7 +4,13 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import test from "node:test"; -import { buildSnapshot, formatSnapshot, labelsFromManifest, verifyBlob } from "./sync-connector-catalog.mjs"; +import { + buildSnapshot, + formatSnapshot, + labelsFromManifest, + manifestFolderCandidates, + verifyBlob, +} from "./sync-connector-catalog.mjs"; const UNKNOWN_TIER = /unknown tier/; const NO_CONNECTORS = /no connectors array/; @@ -123,3 +129,8 @@ test("formatSnapshot keeps short label lists on one line and round-trips", () => assert.ok(text.includes('"labels": ["Your playlists", "Your saved tracks"]')); assert.deepEqual(JSON.parse(text), snapshot); }); + +test("a hyphenated catalogue key also looks in the underscored source folder", () => { + assert.deepEqual(manifestFolderCandidates("apple-health"), ["apple-health", "apple_health"]); + assert.deepEqual(manifestFolderCandidates("spotify"), ["spotify"]); +}); diff --git a/apps/site/src/data/connector-catalog.json b/apps/site/src/data/connector-catalog.json index 766b2bcc6..2abd0f70a 100644 --- a/apps/site/src/data/connector-catalog.json +++ b/apps/site/src/data/connector-catalog.json @@ -19,19 +19,35 @@ "key": "apple-contacts", "display_name": "Apple Contacts", "tier": "preview", - "labels": [] + "labels": ["Your address books", "Your contacts", "Your contact groups"] }, { "key": "apple-health", "display_name": "Apple Health", "tier": "development", - "labels": [] + "labels": [ + "Your activity", + "Your body measurements", + "Your reproductive health", + "Your hearing and sound exposure", + "Your vital signs", + "Your lab and test results", + "Your sleep", + "Your mindful minutes", + "Your nutrition", + "Your alcohol consumption", + "Your mobility", + "Your symptoms", + "Your other Health data", + "Your workouts", + "What was imported" + ] }, { "key": "apple-photos", "display_name": "Apple Photos", "tier": "development", - "labels": [] + "labels": ["Your exported photos and videos"] }, { "key": "ical", @@ -43,7 +59,7 @@ "key": "character-ai", "display_name": "Character.AI", "tier": "development", - "labels": [] + "labels": ["Your chats", "Your messages"] }, { "key": "chase", @@ -90,19 +106,37 @@ "key": "anthropic-live", "display_name": "Claude (Live Session)", "tier": "preview", - "labels": [] + "labels": [ + "Your Claude conversations", + "Your Claude messages", + "Your Claude plan", + "Your Claude usage limits" + ] }, { "key": "claude-code", "display_name": "Claude Code", "tier": "supported", - "labels": [] + "labels": [ + "Your Claude Code sessions", + "Your Claude Code messages", + "Claude Code events", + "Your Claude Code skills", + "Your Claude Code memory notes", + "Your custom slash commands", + "Your Claude Code usage" + ] }, { "key": "discord-browser", "display_name": "Discord (Browser Sign-In)", "tier": "development", - "labels": [] + "labels": [ + "Your Discord profile", + "Your Discord servers", + "Your linked accounts", + "Your messages in servers" + ] }, { "key": "doordash", @@ -137,7 +171,13 @@ "key": "garmin-browser", "display_name": "Garmin (Browser Sign-In)", "tier": "development", - "labels": [] + "labels": [ + "Your Garmin daily summaries", + "Your Garmin sleep", + "Your Garmin HRV status", + "Your Garmin training status", + "Your Garmin activities" + ] }, { "key": "github-browser", @@ -161,31 +201,39 @@ "key": "google-calendar", "display_name": "Google Calendar", "tier": "development", - "labels": [] + "labels": ["Your Google calendars", "Your Google Calendar events"] }, { "key": "google-contacts", "display_name": "Google Contacts", "tier": "development", - "labels": [] + "labels": ["Your Google contacts", "Your Google contact groups"] }, { "key": "google-maps-data-portability", "display_name": "Google Maps Data Portability", "tier": "development", - "labels": [] + "labels": ["Your Google Maps export jobs"] }, { "key": "google-maps", "display_name": "Google Maps Timeline Import", "tier": "supported", - "labels": [] + "labels": [ + "Your Google Maps location points", + "Your Google Maps visits and activities" + ] }, { "key": "google-takeout", "display_name": "Google Takeout", "tier": "development", - "labels": [] + "labels": [ + "Your Google location history", + "Your YouTube watch history", + "Your Google search history", + "Your Google photos and videos" + ] }, { "key": "groupme", @@ -213,7 +261,11 @@ "key": "icloud-notes", "display_name": "iCloud Notes", "tier": "preview", - "labels": [] + "labels": [ + "Your iCloud account", + "Your iCloud notes", + "Your iCloud notes folders" + ] }, { "key": "imessage", @@ -296,7 +348,7 @@ "key": "netflix-export", "display_name": "Netflix Export", "tier": "development", - "labels": [] + "labels": ["Your Netflix viewing history"] }, { "key": "notion", @@ -327,7 +379,7 @@ "key": "oura-browser", "display_name": "Oura (Browser Sign-In)", "tier": "preview", - "labels": [] + "labels": ["Your sleep sessions", "Your readiness", "Your activity"] }, { "key": "perplexity", @@ -369,7 +421,18 @@ "key": "slack-browser", "display_name": "Slack (Browser Sign-In)", "tier": "development", - "labels": [] + "labels": [ + "Slack workspace", + "Your Slack channels", + "People and bots in your Slack workspace", + "Your Slack messages", + "Link previews and message attachments", + "Slack reactions", + "Files shared in Slack", + "Slack user groups", + "Your Slack reminders", + "Your starred and saved items" + ] }, { "key": "spotify", @@ -407,7 +470,7 @@ "key": "strava-browser", "display_name": "Strava (Browser Sign-In)", "tier": "preview", - "labels": [] + "labels": ["Your Strava activities"] }, { "key": "tiktok", @@ -428,7 +491,7 @@ "key": "twitter-archive", "display_name": "Twitter/X Archive", "tier": "development", - "labels": [] + "labels": ["Your tweets", "Your DMs"] }, { "key": "uber", @@ -474,7 +537,12 @@ "key": "whatsapp-web", "display_name": "WhatsApp (web)", "tier": "development", - "labels": [] + "labels": [ + "Your WhatsApp chats", + "Your WhatsApp messages", + "Your WhatsApp contacts", + "Your WhatsApp groups" + ] }, { "key": "wholefoods", @@ -504,13 +572,23 @@ "key": "whoop-browser", "display_name": "WHOOP (Browser Sign-In)", "tier": "development", - "labels": [] + "labels": [ + "Your WHOOP cycles", + "Your WHOOP recoveries", + "Your WHOOP sleeps", + "Your WHOOP workouts" + ] }, { "key": "x-browser", "display_name": "X (Browser Sign-In)", "tier": "development", - "labels": [] + "labels": [ + "Your X profile", + "Your X posts", + "Posts you liked on X", + "Posts you bookmarked on X" + ] }, { "key": "ynab", diff --git a/apps/site/src/lib/app-directory.test.ts b/apps/site/src/lib/app-directory.test.ts index f36359ef1..c6907c981 100644 --- a/apps/site/src/lib/app-directory.test.ts +++ b/apps/site/src/lib/app-directory.test.ts @@ -19,7 +19,7 @@ import type { PublicSupporter } from "./public-supporters.ts"; const OURA_DATA = /your Oura data/; const COMPANY_CLAIM = /\b(is on|is part of|supports|takes part|partner)\b/i; -const COMMUNITY_BUILT = /built by the PDPP community/; +const OPEN_SOURCE_CONNECTOR = /open-source connector/; const COMPANY_OR_OFFICIAL = /company|official/i; const SNAPSHOT: ConnectorSnapshot = { @@ -70,7 +70,7 @@ test("a connector alone: you can bring your data, and the company has not signed const spotify = find(directory(), "Spotify"); assert.equal( spotify.headline, - "You can bring your Spotify data into PDPP today, with a connector built by the PDPP community." + "You can bring your Spotify data into PDPP today, with an open-source connector." ); assert.equal(spotify.labels, "Your playlists, Your saved tracks, Your top artists, Your top tracks + 2 more"); assert.equal(spotify.status, "Spotify has not signed the Principles or registered with PDPP."); @@ -82,7 +82,7 @@ test("a signed organisation with a connector shows the signing date", () => { assert.equal(spotify.status, "Spotify signed the PDPP Principles on 9 Sep 2026. It has not registered with PDPP."); assert.equal( spotify.headline, - "You can bring your Spotify data into PDPP today, with a connector built by the PDPP community." + "You can bring your Spotify data into PDPP today, with an open-source connector." ); }); @@ -156,7 +156,7 @@ test("separate connectors for the same app stay separate rows", () => { test("a connector in development is not promised as available today", () => { const oura = find(directory(), "Oura"); assert.equal(oura.tierLabel, "In development"); - assert.equal(oura.headline, "A connector for Oura built by the PDPP community is in development."); + assert.equal(oura.headline, "An open-source connector for Oura is in development."); }); test("search is case-insensitive and matches a substring of the app name", () => { @@ -205,7 +205,7 @@ test("the first line never says a company is on, supports or takes part in PDPP" assert.doesNotMatch(entry.headline, COMPANY_CLAIM, entry.headline); } for (const entry of entries.filter((candidate) => candidate.kind === "connector")) { - assert.match(entry.headline, COMMUNITY_BUILT); + assert.match(entry.headline, OPEN_SOURCE_CONNECTOR); assert.doesNotMatch(entry.headline, COMPANY_OR_OFFICIAL); } }); diff --git a/apps/site/src/lib/app-directory.ts b/apps/site/src/lib/app-directory.ts index 3dd989b62..c6abb2315 100644 --- a/apps/site/src/lib/app-directory.ts +++ b/apps/site/src/lib/app-directory.ts @@ -7,7 +7,7 @@ // catalogue (committed as a snapshot), the Supporters register and the // conformance register. Nothing here collects data or sends it anywhere. // -// WORDING RULE: a connector is something the PDPP community built. It says +// WORDING RULE: a connector is open-source code anyone can run. It says // nothing about the company. A company's own status comes only from the // Supporters and conformance registers, and the first line of a row never says // the company is on PDPP, supports it, or takes part. @@ -151,9 +151,9 @@ function connectorHeadline(name: string, tier: ConnectorTier): string { // A connector in development may not run yet, so it is not promised as // something a person can do today. if (tier === "development") { - return `A connector for ${name} built by the PDPP community is in development.`; + return `An open-source connector for ${name} is in development.`; } - return `You can bring your ${name} data into PDPP today, with a connector built by the PDPP community.`; + return `You can bring your ${name} data into PDPP today, with an open-source connector.`; } interface Company { From b07eb7fbf36168daa4d19aa24779b67696b83d83 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 4 Nov 2026 14:57:01 -0600 Subject: [PATCH 03/16] site: lint fixes for the connector catalogue sync Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/site/scripts/sync-connector-catalog.mjs | 1 + apps/site/src/lib/app-directory.test.ts | 10 ++-------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/site/scripts/sync-connector-catalog.mjs b/apps/site/scripts/sync-connector-catalog.mjs index 0c401d796..c9ffbfd2d 100644 --- a/apps/site/scripts/sync-connector-catalog.mjs +++ b/apps/site/scripts/sync-connector-catalog.mjs @@ -151,6 +151,7 @@ export function manifestFolderCandidates(key) { async function fetchConnectorManifest(sourceCommit, key) { for (const folder of manifestFolderCandidates(key)) { const url = `${CONNECTOR_MANIFEST_BASE}/${sourceCommit}/connectors/${folder}/manifest.json`; + // biome-ignore lint/performance/noAwaitInLoops: the second folder is tried only when the first is missing. const response = await fetch(url); if (response.status === 404) { continue; diff --git a/apps/site/src/lib/app-directory.test.ts b/apps/site/src/lib/app-directory.test.ts index c6907c981..aac9ae498 100644 --- a/apps/site/src/lib/app-directory.test.ts +++ b/apps/site/src/lib/app-directory.test.ts @@ -68,10 +68,7 @@ function find(entries: readonly DirectoryEntry[], name: string): DirectoryEntry test("a connector alone: you can bring your data, and the company has not signed or registered", () => { const spotify = find(directory(), "Spotify"); - assert.equal( - spotify.headline, - "You can bring your Spotify data into PDPP today, with an open-source connector." - ); + assert.equal(spotify.headline, "You can bring your Spotify data into PDPP today, with an open-source connector."); assert.equal(spotify.labels, "Your playlists, Your saved tracks, Your top artists, Your top tracks + 2 more"); assert.equal(spotify.status, "Spotify has not signed the Principles or registered with PDPP."); assert.equal(spotify.tierLabel, "Preview"); @@ -80,10 +77,7 @@ test("a connector alone: you can bring your data, and the company has not signed test("a signed organisation with a connector shows the signing date", () => { const spotify = find(directory([supporter("Spotify", "Company", "2026-09-09")]), "Spotify"); assert.equal(spotify.status, "Spotify signed the PDPP Principles on 9 Sep 2026. It has not registered with PDPP."); - assert.equal( - spotify.headline, - "You can bring your Spotify data into PDPP today, with an open-source connector." - ); + assert.equal(spotify.headline, "You can bring your Spotify data into PDPP today, with an open-source connector."); }); test("a signed and registered organisation says both", () => { From 5c55a9e5465adcfb74484048c7a88b0f89d6cccf Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 4 Nov 2026 15:00:57 -0600 Subject: [PATCH 04/16] site: name the company only when a register names it An app such as Apple Health is a product, not the company that signs, so the status line says 'The company' unless a register names it. A signed company with no connector leads with its signature. The count says connectors. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../src/components/site/app-directory.tsx | 4 +-- apps/site/src/lib/app-directory.test.ts | 17 +++++++------ apps/site/src/lib/app-directory.ts | 25 +++++++++++++------ 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/apps/site/src/components/site/app-directory.tsx b/apps/site/src/components/site/app-directory.tsx index 130ad1f1d..0bee9a000 100644 --- a/apps/site/src/components/site/app-directory.tsx +++ b/apps/site/src/components/site/app-directory.tsx @@ -26,7 +26,7 @@ function countLine(count: number, searching: boolean): string { if (searching) { return `${count} ${count === 1 ? "result" : "results"}`; } - return `${count} ${count === 1 ? "app" : "apps"}`; + return `${count} ${count === 1 ? "connector" : "connectors"}`; } function AppRow({ entry }: { entry: DirectoryEntry }) { @@ -65,7 +65,7 @@ export function PdppAppDirectory({ entries }: { entries: readonly DirectoryEntry return (
    -
    +
    diff --git a/apps/site/src/lib/app-directory.test.ts b/apps/site/src/lib/app-directory.test.ts index aac9ae498..21102bd84 100644 --- a/apps/site/src/lib/app-directory.test.ts +++ b/apps/site/src/lib/app-directory.test.ts @@ -70,7 +70,7 @@ test("a connector alone: you can bring your data, and the company has not signed const spotify = find(directory(), "Spotify"); assert.equal(spotify.headline, "You can bring your Spotify data into PDPP today, with an open-source connector."); assert.equal(spotify.labels, "Your playlists, Your saved tracks, Your top artists, Your top tracks + 2 more"); - assert.equal(spotify.status, "Spotify has not signed the Principles or registered with PDPP."); + assert.equal(spotify.status, "The company has not signed the Principles or registered with PDPP."); assert.equal(spotify.tierLabel, "Preview"); }); @@ -100,27 +100,27 @@ test("a Registered status alone does not make a Partner", () => { test("an ended registration is ignored", () => { const spotify = find(directory([], [registration("Spotify", "Verified Source", "Lapsed")]), "Spotify"); - assert.equal(spotify.status, "Spotify has not signed the Principles or registered with PDPP."); + assert.equal(spotify.status, "The company has not signed the Principles or registered with PDPP."); }); test("a signed organisation without a connector is its own row, with no connector claim", () => { const kalo = find(directory([supporter("Kalo", "Company", "2026-09-05")]), "Kalo"); assert.equal(kalo.kind, "company"); - assert.equal(kalo.headline, "There is no PDPP connector for Kalo yet."); - assert.equal(kalo.status, "Kalo signed the PDPP Principles on 5 Sep 2026. It has not registered with PDPP."); + assert.equal(kalo.headline, "Kalo signed the PDPP Principles on 5 Sep 2026. It has not registered with PDPP."); + assert.equal(kalo.status, "There is no PDPP connector for Kalo yet."); assert.equal(kalo.tier, null); }); test("individuals never appear", () => { const entries = directory([supporter("Tim N.", "Individual"), supporter("Spotify", "Individual")]); assert.equal(entries.length, SNAPSHOT.connectors.length); - assert.equal(find(entries, "Spotify").status, "Spotify has not signed the Principles or registered with PDPP."); + assert.equal(find(entries, "Spotify").status, "The company has not signed the Principles or registered with PDPP."); assert.deepEqual(searchDirectory(entries, "tim"), []); }); test("a company is matched by whole name, not by substring", () => { const entries = directory([supporter("Spot", "Company")]); - assert.equal(find(entries, "Spotify").status, "Spotify has not signed the Principles or registered with PDPP."); + assert.equal(find(entries, "Spotify").status, "The company has not signed the Principles or registered with PDPP."); assert.equal(find(entries, "Spot").kind, "company"); }); @@ -135,7 +135,10 @@ test("a bracketed company name links the connector to its company", () => { supporters: [supporter("Anthropic", "Company", "2026-10-01")], }); assert.equal(entries.length, 1); - assert.equal(entries[0]?.status, "Claude signed the PDPP Principles on 1 Oct 2026. It has not registered with PDPP."); + assert.equal( + entries[0]?.status, + "Anthropic signed the PDPP Principles on 1 Oct 2026. It has not registered with PDPP." + ); }); test("separate connectors for the same app stay separate rows", () => { diff --git a/apps/site/src/lib/app-directory.ts b/apps/site/src/lib/app-directory.ts index c6abb2315..980be5595 100644 --- a/apps/site/src/lib/app-directory.ts +++ b/apps/site/src/lib/app-directory.ts @@ -117,19 +117,24 @@ function holdsVerified(status: CompanyStatus): boolean { } /** The muted status line. Pure wording, tested for every combination. */ -export function statusLine(name: string, status: CompanyStatus): string { +/** + * The company's own standing, from the registers. `subject` is the company's + * name when a register names it, or "The company" when only a connector exists: + * an app name such as "Apple Health" is a product, not the company that signs. + */ +export function statusLine(subject: string, status: CompanyStatus): string { const registered = status.registrations.map((registration) => `a ${registration.status}`); const partner = holdsVerified(status) ? " It is a PDPP Partner." : ""; if (status.signedOn && registered.length > 0) { - return `${name} signed the PDPP Principles on ${formatDate(status.signedOn)} and is ${joinList(registered)}.${partner}`; + return `${subject} signed the PDPP Principles on ${formatDate(status.signedOn)} and is ${joinList(registered)}.${partner}`; } if (status.signedOn) { - return `${name} signed the PDPP Principles on ${formatDate(status.signedOn)}. It has not registered with PDPP.`; + return `${subject} signed the PDPP Principles on ${formatDate(status.signedOn)}. It has not registered with PDPP.`; } if (registered.length > 0) { - return `${name} has not signed the Principles. It is ${joinList(registered)}.${partner}`; + return `${subject} has not signed the Principles. It is ${joinList(registered)}.${partner}`; } - return `${name} has not signed the Principles or registered with PDPP.`; + return `${subject} has not signed the Principles or registered with PDPP.`; } function joinList(items: readonly string[]): string { @@ -147,6 +152,9 @@ export function labelsLine(labels: readonly string[]): string { return `${labels.slice(0, MAX_LABELS).join(", ")} + ${labels.length - MAX_LABELS} more`; } +const UNKNOWN_COMPANY = "The company"; +const NO_STATUS: CompanyStatus = { registrations: [], signedOn: null }; + function connectorHeadline(name: string, tier: ConnectorTier): string { // A connector in development may not run yet, so it is not promised as // something a person can do today. @@ -222,7 +230,7 @@ export function buildDirectory({ labels: labelsLine(connector.labels), matchNames: [connector.display_name, company?.name].filter((n): n is string => Boolean(n)).map(lower), name: connector.display_name, - status: statusLine(name, company ?? { registrations: [], signedOn: null }), + status: company ? statusLine(company.name, company) : statusLine(UNKNOWN_COMPANY, NO_STATUS), tier: connector.tier, tierLabel: TIER_LABELS[connector.tier], }); @@ -232,13 +240,14 @@ export function buildDirectory({ for (const [key, company] of companies) { if (!linked.has(key)) { companyEntries.push({ - headline: `There is no PDPP connector for ${company.name} yet.`, + // Lead with what is true and positive: the company's own standing. + headline: statusLine(company.name, company), id: `company:${key}`, kind: "company", labels: "", matchNames: [lower(company.name)], name: company.name, - status: statusLine(company.name, company), + status: `There is no PDPP connector for ${company.name} yet.`, tier: null, tierLabel: null, }); From 98a92b0f9b3f66668fd34e39e78d97c59215bdb3 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 4 Nov 2026 15:08:18 -0600 Subject: [PATCH 05/16] site: show each connector by its data, state company status once Every row repeated the same sentence. Rows now show the app and the data it brings; the page says once that companies have not signed unless a row says so. Supported, the normal tier, carries no label. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../src/components/site/app-directory.tsx | 36 +++++++++----- apps/site/src/lib/app-directory.test.ts | 48 ++++++++++++------- apps/site/src/lib/app-directory.ts | 29 ++++------- 3 files changed, 64 insertions(+), 49 deletions(-) diff --git a/apps/site/src/components/site/app-directory.tsx b/apps/site/src/components/site/app-directory.tsx index 0bee9a000..b9ef9ac86 100644 --- a/apps/site/src/components/site/app-directory.tsx +++ b/apps/site/src/components/site/app-directory.tsx @@ -42,17 +42,21 @@ function AppRow({ entry }: { entry: DirectoryEntry }) { )}
    - - {entry.headline} - + {entry.headline && ( + + {entry.headline} + + )} {entry.labels && ( - + {entry.labels} )} - - {entry.status} - + {entry.status && ( + + {entry.status} + + )} ); } @@ -95,11 +99,19 @@ export function PdppAppDirectory({ entries }: { entries: readonly DirectoryEntry
    ) : ( -
      - {shown.map((entry) => ( - - ))} -
    +
    + {shown.some((entry) => entry.kind === "connector") && ( + + Each app below has an open-source connector that brings your data from it into PDPP. Its company has not + signed the Principles or registered with PDPP unless the entry says so. + + )} +
      + {shown.map((entry) => ( + + ))} +
    +
    )} {showsDevelopment && ( diff --git a/apps/site/src/lib/app-directory.test.ts b/apps/site/src/lib/app-directory.test.ts index 21102bd84..52efb9d99 100644 --- a/apps/site/src/lib/app-directory.test.ts +++ b/apps/site/src/lib/app-directory.test.ts @@ -13,14 +13,12 @@ import { labelsLine, type RegisterEntry, searchDirectory, + TIER_LABELS, } from "./app-directory.ts"; import { readConnectorSnapshot, readRegisterEntries } from "./app-directory-sources.ts"; import type { PublicSupporter } from "./public-supporters.ts"; -const OURA_DATA = /your Oura data/; const COMPANY_CLAIM = /\b(is on|is part of|supports|takes part|partner)\b/i; -const OPEN_SOURCE_CONNECTOR = /open-source connector/; -const COMPANY_OR_OFFICIAL = /company|official/i; const SNAPSHOT: ConnectorSnapshot = { connectors: [ @@ -66,18 +64,22 @@ function find(entries: readonly DirectoryEntry[], name: string): DirectoryEntry return entry; } -test("a connector alone: you can bring your data, and the company has not signed or registered", () => { +test("a connector alone shows its data and no company line", () => { const spotify = find(directory(), "Spotify"); - assert.equal(spotify.headline, "You can bring your Spotify data into PDPP today, with an open-source connector."); + assert.equal(spotify.headline, ""); assert.equal(spotify.labels, "Your playlists, Your saved tracks, Your top artists, Your top tracks + 2 more"); - assert.equal(spotify.status, "The company has not signed the Principles or registered with PDPP."); + assert.equal(spotify.status, ""); assert.equal(spotify.tierLabel, "Preview"); }); +test("a supported connector carries no tier label", () => { + assert.equal(TIER_LABELS.supported, null); +}); + test("a signed organisation with a connector shows the signing date", () => { const spotify = find(directory([supporter("Spotify", "Company", "2026-09-09")]), "Spotify"); assert.equal(spotify.status, "Spotify signed the PDPP Principles on 9 Sep 2026. It has not registered with PDPP."); - assert.equal(spotify.headline, "You can bring your Spotify data into PDPP today, with an open-source connector."); + assert.equal(spotify.headline, ""); }); test("a signed and registered organisation says both", () => { @@ -100,7 +102,7 @@ test("a Registered status alone does not make a Partner", () => { test("an ended registration is ignored", () => { const spotify = find(directory([], [registration("Spotify", "Verified Source", "Lapsed")]), "Spotify"); - assert.equal(spotify.status, "The company has not signed the Principles or registered with PDPP."); + assert.equal(spotify.status, ""); }); test("a signed organisation without a connector is its own row, with no connector claim", () => { @@ -114,13 +116,13 @@ test("a signed organisation without a connector is its own row, with no connecto test("individuals never appear", () => { const entries = directory([supporter("Tim N.", "Individual"), supporter("Spotify", "Individual")]); assert.equal(entries.length, SNAPSHOT.connectors.length); - assert.equal(find(entries, "Spotify").status, "The company has not signed the Principles or registered with PDPP."); + assert.equal(find(entries, "Spotify").status, ""); assert.deepEqual(searchDirectory(entries, "tim"), []); }); test("a company is matched by whole name, not by substring", () => { const entries = directory([supporter("Spot", "Company")]); - assert.equal(find(entries, "Spotify").status, "The company has not signed the Principles or registered with PDPP."); + assert.equal(find(entries, "Spotify").status, ""); assert.equal(find(entries, "Spot").kind, "company"); }); @@ -147,13 +149,23 @@ test("separate connectors for the same app stay separate rows", () => { oura.map((entry) => entry.name), ["Oura", "Oura (Browser Sign-In)"] ); - assert.match(oura[1]?.headline ?? "", OURA_DATA); + assert.equal(oura[1]?.labels, "Your sleep"); }); -test("a connector in development is not promised as available today", () => { - const oura = find(directory(), "Oura"); - assert.equal(oura.tierLabel, "In development"); - assert.equal(oura.headline, "An open-source connector for Oura is in development."); +test("a connector in development is labelled so", () => { + assert.equal(find(directory(), "Oura").tierLabel, "In development"); +}); + +test("a connector with no data labels still says what it brings", () => { + const { entries } = buildDirectory({ + register: [], + snapshot: { + ...SNAPSHOT, + connectors: [{ display_name: "GitHub (browser session)", key: "g", labels: [], tier: "preview" }], + }, + supporters: [], + }); + assert.equal(entries[0]?.labels, "Your GitHub data"); }); test("search is case-insensitive and matches a substring of the app name", () => { @@ -201,9 +213,9 @@ test("the first line never says a company is on, supports or takes part in PDPP" for (const entry of entries) { assert.doesNotMatch(entry.headline, COMPANY_CLAIM, entry.headline); } - for (const entry of entries.filter((candidate) => candidate.kind === "connector")) { - assert.match(entry.headline, OPEN_SOURCE_CONNECTOR); - assert.doesNotMatch(entry.headline, COMPANY_OR_OFFICIAL); + // A row says anything about the company only when a register names it. + for (const entry of entries.filter((candidate) => candidate.status === "")) { + assert.doesNotMatch(entry.labels, COMPANY_CLAIM); } }); diff --git a/apps/site/src/lib/app-directory.ts b/apps/site/src/lib/app-directory.ts index 980be5595..75243e7ae 100644 --- a/apps/site/src/lib/app-directory.ts +++ b/apps/site/src/lib/app-directory.ts @@ -36,10 +36,11 @@ export interface RegisterEntry { status: string; } -export const TIER_LABELS: Record = { +// Supported is the normal case and carries no label; only the caveats do. +export const TIER_LABELS: Record = { development: "In development", preview: "Preview", - supported: "Supported", + supported: null, }; const MAX_LABELS = 4; @@ -89,7 +90,7 @@ export interface CompanyStatus { } export interface DirectoryEntry { - /** What a person can do with this app's data. Empty for a company with no connector. */ + /** A company's own standing, for a company with no connector. Empty on connector rows. */ headline: string; id: string; kind: "connector" | "company"; @@ -97,7 +98,7 @@ export interface DirectoryEntry { /** Lower-cased names a search can match: the app and the company. */ matchNames: readonly string[]; name: string; - /** Muted line: the company's own status. */ + /** Muted line. On a connector row, the company's status only when a register names it; else empty. */ status: string; tier: ConnectorTier | null; tierLabel: string | null; @@ -152,18 +153,6 @@ export function labelsLine(labels: readonly string[]): string { return `${labels.slice(0, MAX_LABELS).join(", ")} + ${labels.length - MAX_LABELS} more`; } -const UNKNOWN_COMPANY = "The company"; -const NO_STATUS: CompanyStatus = { registrations: [], signedOn: null }; - -function connectorHeadline(name: string, tier: ConnectorTier): string { - // A connector in development may not run yet, so it is not promised as - // something a person can do today. - if (tier === "development") { - return `An open-source connector for ${name} is in development.`; - } - return `You can bring your ${name} data into PDPP today, with an open-source connector.`; -} - interface Company { name: string; registrations: { since: string; status: string }[]; @@ -224,13 +213,15 @@ export function buildDirectory({ linked.add(match); } entries.push({ - headline: connectorHeadline(name, connector.tier), + headline: "", id: `connector:${connector.key}`, kind: "connector", - labels: labelsLine(connector.labels), + labels: connector.labels.length > 0 ? labelsLine(connector.labels) : `Your ${name} data`, matchNames: [connector.display_name, company?.name].filter((n): n is string => Boolean(n)).map(lower), name: connector.display_name, - status: company ? statusLine(company.name, company) : statusLine(UNKNOWN_COMPANY, NO_STATUS), + // The page says once, above the list, that companies have not signed + // unless a row says so; repeating it on every row is noise. + status: company ? statusLine(company.name, company) : "", tier: connector.tier, tierLabel: TIER_LABELS[connector.tier], }); From e0343d8b32cb4edd01e4a716799e518c22ebaaaa Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 4 Nov 2026 15:20:55 -0600 Subject: [PATCH 06/16] site: link each connector to its source; example a random supported app The link is pinned to the commit the catalogue was built from, so it shows the code the listing describes. The search example is picked after hydration so static HTML and the first render agree. Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/site/scripts/sync-connector-catalog.mjs | 19 +- .../scripts/sync-connector-catalog.test.ts | 25 ++- .../src/components/site/app-directory.tsx | 43 ++++- apps/site/src/data/connector-catalog.json | 180 ++++++++++++------ apps/site/src/lib/app-directory.ts | 16 +- 5 files changed, 204 insertions(+), 79 deletions(-) diff --git a/apps/site/scripts/sync-connector-catalog.mjs b/apps/site/scripts/sync-connector-catalog.mjs index c9ffbfd2d..c8bd897a9 100644 --- a/apps/site/scripts/sync-connector-catalog.mjs +++ b/apps/site/scripts/sync-connector-catalog.mjs @@ -59,9 +59,10 @@ export function labelsFromManifest(manifest) { * network, so a test can feed it a small fixture. * * `manifests` maps connector_key to the parsed connector manifest, or to null - * or undefined when that manifest was missing. + * or undefined when that manifest was missing. `folders` maps connector_key to + * the source folder the manifest was found in; the page links to it. */ -export function buildSnapshot({ catalog, manifests, manifestDigest, layerDigest }) { +export function buildSnapshot({ catalog, manifests, folders = {}, manifestDigest, layerDigest }) { if (!Array.isArray(catalog?.connectors)) { throw new Error("Catalogue has no connectors array"); } @@ -77,6 +78,7 @@ export function buildSnapshot({ catalog, manifests, manifestDigest, layerDigest display_name: connector.display_name, tier: connector.tier, labels: labelsFromManifest(manifests[connector.connector_key]), + source_path: folders[connector.connector_key] ? `connectors/${folders[connector.connector_key]}` : null, }; }); connectors.sort((a, b) => a.display_name.localeCompare(b.display_name, "en") || a.key.localeCompare(b.key, "en")); @@ -159,7 +161,7 @@ async function fetchConnectorManifest(sourceCommit, key) { if (!response.ok) { throw new Error(`${url} returned ${response.status}`); } - return await response.json(); + return { folder, manifest: await response.json() }; } return null; } @@ -167,13 +169,16 @@ async function fetchConnectorManifest(sourceCommit, key) { // One small request per connector, all at once: the catalogue lists a few // dozen, and a missing manifest is a null rather than a failure. async function fetchManifests(catalog) { - const entries = await Promise.all( + const found = await Promise.all( catalog.connectors.map(async (connector) => [ connector.connector_key, await fetchConnectorManifest(catalog.source_commit, connector.connector_key), ]) ); - return Object.fromEntries(entries); + return { + folders: Object.fromEntries(found.map(([key, result]) => [key, result?.folder ?? null])), + manifests: Object.fromEntries(found.map(([key, result]) => [key, result?.manifest ?? null])), + }; } // Measured against the repo formatter: it keeps a JSON array on one line up to this width. @@ -200,8 +205,8 @@ export function formatSnapshot(snapshot) { export async function syncConnectorCatalog(outputPath = DEFAULT_OUTPUT) { const { catalog, manifestDigest, layerDigest } = await fetchCatalog(); - const manifests = await fetchManifests(catalog); - const snapshot = buildSnapshot({ catalog, manifests, manifestDigest, layerDigest }); + const { folders, manifests } = await fetchManifests(catalog); + const snapshot = buildSnapshot({ catalog, folders, manifests, manifestDigest, layerDigest }); await mkdir(path.dirname(outputPath), { recursive: true }); await writeFile(outputPath, formatSnapshot(snapshot)); return snapshot; diff --git a/apps/site/scripts/sync-connector-catalog.test.ts b/apps/site/scripts/sync-connector-catalog.test.ts index 45b070c6b..3cdde3d4b 100644 --- a/apps/site/scripts/sync-connector-catalog.test.ts +++ b/apps/site/scripts/sync-connector-catalog.test.ts @@ -68,9 +68,15 @@ test("buildSnapshot keeps only what the page needs, sorted by display name", () assert.deepEqual(snapshot, { connectors: [ - { display_name: "Alpha", key: "no-manifest", labels: [], tier: "development" }, - { display_name: "Spotify", key: "spotify", labels: ["Your playlists", "Your saved tracks"], tier: "preview" }, - { display_name: "Strava", key: "strava", labels: [], tier: "supported" }, + { display_name: "Alpha", key: "no-manifest", labels: [], source_path: null, tier: "development" }, + { + display_name: "Spotify", + key: "spotify", + labels: ["Your playlists", "Your saved tracks"], + source_path: null, + tier: "preview", + }, + { display_name: "Strava", key: "strava", labels: [], source_path: null, tier: "supported" }, ], generated_at: "2026-11-04T02:08:53.000Z", layer_digest: "sha256:layer", @@ -134,3 +140,16 @@ test("a hyphenated catalogue key also looks in the underscored source folder", ( assert.deepEqual(manifestFolderCandidates("apple-health"), ["apple-health", "apple_health"]); assert.deepEqual(manifestFolderCandidates("spotify"), ["spotify"]); }); + +test("the snapshot records the source folder a manifest was found in", () => { + const snapshot = buildSnapshot({ + catalog: CATALOG, + folders: { [CATALOG.connectors[0].connector_key]: "strava_api" }, + layerDigest: "x", + manifestDigest: "y", + manifests: {}, + }); + const first = snapshot.connectors.find((connector) => connector.key === CATALOG.connectors[0].connector_key); + assert.equal(first?.source_path, "connectors/strava_api"); + assert.ok(snapshot.connectors.filter((connector) => connector !== first).every((c) => c.source_path === null)); +}); diff --git a/apps/site/src/components/site/app-directory.tsx b/apps/site/src/components/site/app-directory.tsx index b9ef9ac86..f76e68961 100644 --- a/apps/site/src/components/site/app-directory.tsx +++ b/apps/site/src/components/site/app-directory.tsx @@ -4,9 +4,9 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Text } from "@/components/typography/text.tsx"; -import { type DirectoryEntry, searchDirectory } from "@/lib/app-directory.ts"; +import { appName, type DirectoryEntry, searchDirectory } from "@/lib/app-directory.ts"; import { cn } from "@/lib/utils.ts"; // The app search on /apps. @@ -36,11 +36,28 @@ function AppRow({ entry }: { entry: DirectoryEntry }) { {entry.name} - {entry.tierLabel && ( - - {entry.tierLabel} - - )} +
    + {entry.tierLabel && ( + + {entry.tierLabel} + + )} + {entry.sourceUrl && ( + + Source ↗ + + )} +
    {entry.headline && ( @@ -63,6 +80,16 @@ function AppRow({ entry }: { entry: DirectoryEntry }) { export function PdppAppDirectory({ entries }: { entries: readonly DirectoryEntry[] }) { const [query, setQuery] = useState(""); + const [example, setExample] = useState(null); + + // A random supported app as the example. Picked after hydration, so the + // static HTML and the first client render agree. + useEffect(() => { + const supported = entries.filter((entry) => entry.tier === "supported").map((entry) => appName(entry.name)); + if (supported.length > 0) { + setExample(supported[Math.floor(Math.random() * supported.length)] ?? null); + } + }, [entries]); const trimmed = query.trim(); const shown = searchDirectory(entries, query); const showsDevelopment = shown.some((entry) => entry.tier === "development"); @@ -78,7 +105,7 @@ export function PdppAppDirectory({ entries }: { entries: readonly DirectoryEntry className={fieldClassName} id="app-search" onChange={(event) => setQuery(event.target.value)} - placeholder="Search for an app, for example Spotify" + placeholder={example ? `Search for an app, for example ${example}` : "Search for an app"} type="search" value={query} /> diff --git a/apps/site/src/data/connector-catalog.json b/apps/site/src/data/connector-catalog.json index 2abd0f70a..987648e65 100644 --- a/apps/site/src/data/connector-catalog.json +++ b/apps/site/src/data/connector-catalog.json @@ -13,13 +13,15 @@ "Your Amazon orders", "Your order line items", "Your Amazon account" - ] + ], + "source_path": "connectors/amazon" }, { "key": "apple-contacts", "display_name": "Apple Contacts", "tier": "preview", - "labels": ["Your address books", "Your contacts", "Your contact groups"] + "labels": ["Your address books", "Your contacts", "Your contact groups"], + "source_path": "connectors/apple_contacts" }, { "key": "apple-health", @@ -41,25 +43,29 @@ "Your other Health data", "Your workouts", "What was imported" - ] + ], + "source_path": "connectors/apple_health" }, { "key": "apple-photos", "display_name": "Apple Photos", "tier": "development", - "labels": ["Your exported photos and videos"] + "labels": ["Your exported photos and videos"], + "source_path": "connectors/apple_photos" }, { "key": "ical", "display_name": "Calendar (iCal)", "tier": "development", - "labels": ["Your calendar events"] + "labels": ["Your calendar events"], + "source_path": "connectors/ical" }, { "key": "character-ai", "display_name": "Character.AI", "tier": "development", - "labels": ["Your chats", "Your messages"] + "labels": ["Your chats", "Your messages"], + "source_path": "connectors/character_ai" }, { "key": "chase", @@ -71,7 +77,8 @@ "Your current Chase activity", "Your Chase statements", "Your Chase balance snapshots" - ] + ], + "source_path": "connectors/chase" }, { "key": "chatgpt", @@ -87,7 +94,8 @@ "Your ChatGPT plan", "Your ChatGPT account profile", "Your ChatGPT usage summary" - ] + ], + "source_path": "connectors/chatgpt" }, { "key": "anthropic", @@ -100,7 +108,8 @@ "Your Claude messages", "Your Claude projects", "Your Claude project documents" - ] + ], + "source_path": "connectors/anthropic" }, { "key": "anthropic-live", @@ -111,7 +120,8 @@ "Your Claude messages", "Your Claude plan", "Your Claude usage limits" - ] + ], + "source_path": "connectors/anthropic_live" }, { "key": "claude-code", @@ -125,7 +135,8 @@ "Your Claude Code memory notes", "Your custom slash commands", "Your Claude Code usage" - ] + ], + "source_path": "connectors/claude_code" }, { "key": "discord-browser", @@ -136,13 +147,15 @@ "Your Discord servers", "Your linked accounts", "Your messages in servers" - ] + ], + "source_path": "connectors/discord_browser" }, { "key": "doordash", "display_name": "DoorDash", "tier": "development", - "labels": ["Your DoorDash orders", "Your DoorDash order items"] + "labels": ["Your DoorDash orders", "Your DoorDash order items"], + "source_path": "connectors/doordash" }, { "key": "facebook", @@ -155,7 +168,8 @@ "Your groups", "Your likes and reactions", "Your comments" - ] + ], + "source_path": "connectors/facebook" }, { "key": "fitbit", @@ -165,7 +179,8 @@ "Your Fitbit exercise", "Your Fitbit daily activity", "Your Fitbit sleep" - ] + ], + "source_path": "connectors/fitbit" }, { "key": "garmin-browser", @@ -177,13 +192,15 @@ "Your Garmin HRV status", "Your Garmin training status", "Your Garmin activities" - ] + ], + "source_path": "connectors/garmin_browser" }, { "key": "github-browser", "display_name": "GitHub (browser session)", "tier": "preview", - "labels": [] + "labels": [], + "source_path": "connectors/github_browser" }, { "key": "gmail", @@ -195,25 +212,29 @@ "Your labels", "The text of your emails", "Your attachments" - ] + ], + "source_path": "connectors/gmail" }, { "key": "google-calendar", "display_name": "Google Calendar", "tier": "development", - "labels": ["Your Google calendars", "Your Google Calendar events"] + "labels": ["Your Google calendars", "Your Google Calendar events"], + "source_path": "connectors/google_calendar" }, { "key": "google-contacts", "display_name": "Google Contacts", "tier": "development", - "labels": ["Your Google contacts", "Your Google contact groups"] + "labels": ["Your Google contacts", "Your Google contact groups"], + "source_path": "connectors/google_contacts" }, { "key": "google-maps-data-portability", "display_name": "Google Maps Data Portability", "tier": "development", - "labels": ["Your Google Maps export jobs"] + "labels": ["Your Google Maps export jobs"], + "source_path": "connectors/google_maps_data_portability" }, { "key": "google-maps", @@ -222,7 +243,8 @@ "labels": [ "Your Google Maps location points", "Your Google Maps visits and activities" - ] + ], + "source_path": "connectors/google_maps" }, { "key": "google-takeout", @@ -233,7 +255,8 @@ "Your YouTube watch history", "Your Google search history", "Your Google photos and videos" - ] + ], + "source_path": "connectors/google_takeout" }, { "key": "groupme", @@ -244,7 +267,8 @@ "Messages from your GroupMe groups", "Your GroupMe direct messages", "Your GroupMe attachments" - ] + ], + "source_path": "connectors/groupme" }, { "key": "heb", @@ -255,7 +279,8 @@ "Your H-E-B order line items", "Your H-E-B profile", "Your H-E-B product nutrition" - ] + ], + "source_path": "connectors/heb" }, { "key": "icloud-notes", @@ -265,7 +290,8 @@ "Your iCloud account", "Your iCloud notes", "Your iCloud notes folders" - ] + ], + "source_path": "connectors/icloud_notes" }, { "key": "imessage", @@ -275,7 +301,8 @@ "Your iMessages", "Your iMessage chat participants", "Your iMessage attachments" - ] + ], + "source_path": "connectors/imessage" }, { "key": "meta", @@ -287,7 +314,8 @@ "Likes on your posts", "Accounts you follow", "Your Instagram ad preferences" - ] + ], + "source_path": "connectors/meta" }, { "key": "instinct", @@ -302,13 +330,15 @@ "What your Instinct vault holds", "Your chats with your agent", "Your conversations with your agent" - ] + ], + "source_path": "connectors/instinct" }, { "key": "jellyfin", "display_name": "Jellyfin", "tier": "preview", - "labels": ["Your Jellyfin libraries", "Your Jellyfin items"] + "labels": ["Your Jellyfin libraries", "Your Jellyfin items"], + "source_path": "connectors/jellyfin" }, { "key": "linkedin", @@ -321,13 +351,15 @@ "Your skills", "Your languages", "Your connections" - ] + ], + "source_path": "connectors/linkedin" }, { "key": "loom", "display_name": "Loom", "tier": "development", - "labels": ["Your Loom videos", "Your Loom transcripts"] + "labels": ["Your Loom videos", "Your Loom transcripts"], + "source_path": "connectors/loom" }, { "key": "muse", @@ -342,19 +374,22 @@ "Your Muse agent's memory", "Your Muse files", "Your Muse connected apps" - ] + ], + "source_path": "connectors/muse" }, { "key": "netflix-export", "display_name": "Netflix Export", "tier": "development", - "labels": ["Your Netflix viewing history"] + "labels": ["Your Netflix viewing history"], + "source_path": "connectors/netflix_export" }, { "key": "notion", "display_name": "Notion", "tier": "preview", - "labels": ["Your Notion pages", "Your Notion databases"] + "labels": ["Your Notion pages", "Your Notion databases"], + "source_path": "connectors/notion" }, { "key": "codex", @@ -367,31 +402,36 @@ "Your Codex approval rules", "Your Codex prompts", "Your Codex skills" - ] + ], + "source_path": "connectors/codex" }, { "key": "oura", "display_name": "Oura", "tier": "development", - "labels": ["Your sleep sessions", "Your readiness", "Your activity"] + "labels": ["Your sleep sessions", "Your readiness", "Your activity"], + "source_path": "connectors/oura" }, { "key": "oura-browser", "display_name": "Oura (Browser Sign-In)", "tier": "preview", - "labels": ["Your sleep sessions", "Your readiness", "Your activity"] + "labels": ["Your sleep sessions", "Your readiness", "Your activity"], + "source_path": "connectors/oura_browser" }, { "key": "perplexity", "display_name": "Perplexity (Browser Sign-In)", "tier": "development", - "labels": ["Your Perplexity threads", "Your Perplexity messages"] + "labels": ["Your Perplexity threads", "Your Perplexity messages"], + "source_path": "connectors/perplexity" }, { "key": "pocket", "display_name": "Pocket", "tier": "development", - "labels": ["Your Pocket saves"] + "labels": ["Your Pocket saves"], + "source_path": "connectors/pocket" }, { "key": "reddit", @@ -409,13 +449,15 @@ "Your downvoted Reddit item stats", "Your hidden Reddit items", "Your hidden Reddit item stats" - ] + ], + "source_path": "connectors/reddit" }, { "key": "shopify", "display_name": "Shopify Shop", "tier": "preview", - "labels": ["Your Shop app orders"] + "labels": ["Your Shop app orders"], + "source_path": "connectors/shopify" }, { "key": "slack-browser", @@ -432,7 +474,8 @@ "Slack user groups", "Your Slack reminders", "Your starred and saved items" - ] + ], + "source_path": "connectors/slack_browser" }, { "key": "spotify", @@ -446,7 +489,8 @@ "Recently played contexts", "Your top artists", "Your top tracks" - ] + ], + "source_path": "connectors/spotify" }, { "key": "steam", @@ -458,19 +502,22 @@ "Your recently played games", "Your Steam friends", "Your Steam level" - ] + ], + "source_path": "connectors/steam" }, { "key": "strava", "display_name": "Strava", "tier": "supported", - "labels": ["Your Strava activities"] + "labels": ["Your Strava activities"], + "source_path": "connectors/strava" }, { "key": "strava-browser", "display_name": "Strava (Browser Sign-In)", "tier": "preview", - "labels": ["Your Strava activities"] + "labels": ["Your Strava activities"], + "source_path": "connectors/strava_browser" }, { "key": "tiktok", @@ -485,19 +532,22 @@ "Videos in your TikTok Favorites collections", "Accounts you follow on TikTok", "Your TikTok followers" - ] + ], + "source_path": "connectors/tiktok" }, { "key": "twitter-archive", "display_name": "Twitter/X Archive", "tier": "development", - "labels": ["Your tweets", "Your DMs"] + "labels": ["Your tweets", "Your DMs"], + "source_path": "connectors/twitter_archive" }, { "key": "uber", "display_name": "Uber", "tier": "supported", - "labels": ["Your Uber trips", "Your Uber receipts"] + "labels": ["Your Uber trips", "Your Uber receipts"], + "source_path": "connectors/uber" }, { "key": "usaa", @@ -511,7 +561,8 @@ "Your USAA inbox", "Your credit card billing details", "Your credit card balances (daily)" - ] + ], + "source_path": "connectors/usaa" }, { "key": "venmo", @@ -521,7 +572,8 @@ "Your Venmo profile", "Your Venmo friends", "Your Venmo transactions" - ] + ], + "source_path": "connectors/venmo" }, { "key": "whatsapp", @@ -531,7 +583,8 @@ "Your WhatsApp chats", "Your WhatsApp messages", "Your WhatsApp media attachments" - ] + ], + "source_path": "connectors/whatsapp" }, { "key": "whatsapp-web", @@ -542,7 +595,8 @@ "Your WhatsApp messages", "Your WhatsApp contacts", "Your WhatsApp groups" - ] + ], + "source_path": "connectors/whatsapp_web" }, { "key": "wholefoods", @@ -553,7 +607,8 @@ "Your Whole Foods orders", "Your Whole Foods items", "Nutrition facts" - ] + ], + "source_path": "connectors/wholefoods" }, { "key": "whoop", @@ -566,7 +621,8 @@ "Your WHOOP recoveries", "Your WHOOP sleeps", "Your WHOOP workouts" - ] + ], + "source_path": "connectors/whoop" }, { "key": "whoop-browser", @@ -577,7 +633,8 @@ "Your WHOOP recoveries", "Your WHOOP sleeps", "Your WHOOP workouts" - ] + ], + "source_path": "connectors/whoop_browser" }, { "key": "x-browser", @@ -588,7 +645,8 @@ "Your X posts", "Posts you liked on X", "Posts you bookmarked on X" - ] + ], + "source_path": "connectors/x_browser" }, { "key": "ynab", @@ -606,7 +664,8 @@ "Your scheduled transactions", "Monthly budget summaries", "Monthly category history" - ] + ], + "source_path": "connectors/ynab" }, { "key": "youtube", @@ -620,7 +679,8 @@ "Your liked videos", "Your watch later list", "Your watch history" - ] + ], + "source_path": "connectors/youtube" } ] } diff --git a/apps/site/src/lib/app-directory.ts b/apps/site/src/lib/app-directory.ts index 75243e7ae..00f3d96b0 100644 --- a/apps/site/src/lib/app-directory.ts +++ b/apps/site/src/lib/app-directory.ts @@ -18,7 +18,14 @@ export type ConnectorTier = "supported" | "preview" | "development"; /** The committed catalogue snapshot (src/data/connector-catalog.json). */ export interface ConnectorSnapshot { - connectors: readonly { display_name: string; key: string; labels: readonly string[]; tier: ConnectorTier }[]; + connectors: readonly { + display_name: string; + key: string; + labels: readonly string[]; + /** Folder in PDP-Connect/data-connectors, e.g. "connectors/spotify"; null when no manifest was found. */ + source_path?: string | null; + tier: ConnectorTier; + }[]; generated_at: string; layer_digest: string; manifest_digest: string; @@ -44,6 +51,7 @@ export const TIER_LABELS: Record = { }; const MAX_LABELS = 4; +const CONNECTORS_REPO = "https://github.com/PDP-Connect/data-connectors"; const ENDED_STATE = /lapsed|withdrawn|revoked|expired/i; const TRAILING_PARENTHETICAL = /\s*\(([^)]*)\)\s*$/; const NON_ALPHANUMERIC = /[^\p{L}\p{N}]+/gu; @@ -98,6 +106,8 @@ export interface DirectoryEntry { /** Lower-cased names a search can match: the app and the company. */ matchNames: readonly string[]; name: string; + /** The connector's source on GitHub, pinned to the catalogue's commit. Null for a company row or an unknown folder. */ + sourceUrl: string | null; /** Muted line. On a connector row, the company's status only when a register names it; else empty. */ status: string; tier: ConnectorTier | null; @@ -219,6 +229,9 @@ export function buildDirectory({ labels: connector.labels.length > 0 ? labelsLine(connector.labels) : `Your ${name} data`, matchNames: [connector.display_name, company?.name].filter((n): n is string => Boolean(n)).map(lower), name: connector.display_name, + sourceUrl: connector.source_path + ? `${CONNECTORS_REPO}/tree/${snapshot.source_commit}/${connector.source_path}` + : null, // The page says once, above the list, that companies have not signed // unless a row says so; repeating it on every row is noise. status: company ? statusLine(company.name, company) : "", @@ -238,6 +251,7 @@ export function buildDirectory({ labels: "", matchNames: [lower(company.name)], name: company.name, + sourceUrl: null, status: `There is no PDPP connector for ${company.name} yet.`, tier: null, tierLabel: null, From 1a91f1415efb3ef09b272464c7db3acf1fa6291a Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 4 Nov 2026 15:21:13 -0600 Subject: [PATCH 07/16] site: type the source-folder snapshot test Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/site/scripts/sync-connector-catalog.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/site/scripts/sync-connector-catalog.test.ts b/apps/site/scripts/sync-connector-catalog.test.ts index 3cdde3d4b..ebdb522cb 100644 --- a/apps/site/scripts/sync-connector-catalog.test.ts +++ b/apps/site/scripts/sync-connector-catalog.test.ts @@ -144,12 +144,17 @@ test("a hyphenated catalogue key also looks in the underscored source folder", ( test("the snapshot records the source folder a manifest was found in", () => { const snapshot = buildSnapshot({ catalog: CATALOG, - folders: { [CATALOG.connectors[0].connector_key]: "strava_api" }, + folders: { spotify: "spotify_web" }, layerDigest: "x", manifestDigest: "y", manifests: {}, }); - const first = snapshot.connectors.find((connector) => connector.key === CATALOG.connectors[0].connector_key); - assert.equal(first?.source_path, "connectors/strava_api"); - assert.ok(snapshot.connectors.filter((connector) => connector !== first).every((c) => c.source_path === null)); + const paths = Object.fromEntries( + snapshot.connectors.map((connector: { key: string; source_path: string | null }) => [ + connector.key, + connector.source_path, + ]) + ); + assert.equal(paths.spotify, "connectors/spotify_web"); + assert.equal(paths.strava, null); }); From e1a5e68831e44f3c1dacd9b34586bb28114588ca Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 4 Nov 2026 15:24:30 -0600 Subject: [PATCH 08/16] site: keep source_path when reading the connector snapshot Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/site/src/lib/app-directory-sources.ts | 1 + apps/site/src/lib/app-directory.test.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/apps/site/src/lib/app-directory-sources.ts b/apps/site/src/lib/app-directory-sources.ts index b575182d0..6aa03db5f 100644 --- a/apps/site/src/lib/app-directory-sources.ts +++ b/apps/site/src/lib/app-directory-sources.ts @@ -19,6 +19,7 @@ const snapshotSchema = z.object({ display_name: z.string(), key: z.string(), labels: z.array(z.string()), + source_path: z.string().nullable(), tier: z.enum(["supported", "preview", "development"]), }) ), diff --git a/apps/site/src/lib/app-directory.test.ts b/apps/site/src/lib/app-directory.test.ts index 52efb9d99..616cc7982 100644 --- a/apps/site/src/lib/app-directory.test.ts +++ b/apps/site/src/lib/app-directory.test.ts @@ -240,4 +240,8 @@ test("the committed snapshot and register parse and build a directory", async () assert.ok(snapshot.connectors.length > 0); assert.equal(entries.filter((entry) => entry.kind === "connector").length, snapshot.connectors.length); assert.ok(entries.every((entry) => !entry.matchNames.includes("tim n."))); + // Every connector row links to its source, pinned to the catalogue's commit. + for (const entry of entries.filter((candidate) => candidate.kind === "connector")) { + assert.ok(entry.sourceUrl?.includes(`/tree/${snapshot.source_commit}/connectors/`), entry.name); + } }); From 576b66d4c4ee0325d933f2212dd4585f473ef937 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 4 Nov 2026 15:31:02 -0600 Subject: [PATCH 09/16] feat(site): lay out /apps as a full-width card grid Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/site/src/app/(concept)/apps/page.tsx | 4 ++ .../src/components/site/app-directory.tsx | 71 ++++++++----------- 2 files changed, 35 insertions(+), 40 deletions(-) diff --git a/apps/site/src/app/(concept)/apps/page.tsx b/apps/site/src/app/(concept)/apps/page.tsx index 549e4e7ef..c5c164700 100644 --- a/apps/site/src/app/(concept)/apps/page.tsx +++ b/apps/site/src/app/(concept)/apps/page.tsx @@ -53,6 +53,10 @@ export default async function Page() { Search for an app to see what you can do with its data through PDPP today, and whether the company has signed or registered. + + Each connector is open source and brings your data from that app into PDPP. A company has not signed the + Principles or registered with PDPP unless its entry says so. +
    diff --git a/apps/site/src/components/site/app-directory.tsx b/apps/site/src/components/site/app-directory.tsx index f76e68961..741550557 100644 --- a/apps/site/src/components/site/app-directory.tsx +++ b/apps/site/src/components/site/app-directory.tsx @@ -29,43 +29,29 @@ function countLine(count: number, searching: boolean): string { return `${count} ${count === 1 ? "connector" : "connectors"}`; } -function AppRow({ entry }: { entry: DirectoryEntry }) { +// Same card as the participate page: a 1px ring, so cards in a gap-px grid share borders. +const CARD = cn("flex flex-col gap-2 bg-background p-6", "shadow-[0_0_0_1px_var(--border)]"); + +function AppCard({ entry }: { entry: DirectoryEntry }) { return ( -
  • -
    +
  • +
    {entry.name} -
    - {entry.tierLabel && ( - - {entry.tierLabel} - - )} - {entry.sourceUrl && ( - - Source ↗ - - )} -
    + {entry.tierLabel && ( + + {entry.tierLabel} + + )}
    {entry.headline && ( - + {entry.headline} )} {entry.labels && ( - + {entry.labels} )} @@ -74,6 +60,19 @@ function AppRow({ entry }: { entry: DirectoryEntry }) { {entry.status} )} + {entry.sourceUrl && ( + + + Source on GitHub ↗ + + + )}
  • ); } @@ -126,19 +125,11 @@ export function PdppAppDirectory({ entries }: { entries: readonly DirectoryEntry
    ) : ( -
    - {shown.some((entry) => entry.kind === "connector") && ( - - Each app below has an open-source connector that brings your data from it into PDPP. Its company has not - signed the Principles or registered with PDPP unless the entry says so. - - )} -
      - {shown.map((entry) => ( - - ))} -
    -
    +
      + {shown.map((entry) => ( + + ))} +
    )} {showsDevelopment && ( From 2046acb2bbae4833a3f304d46400ae15d550f0ff Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 4 Nov 2026 15:49:04 -0600 Subject: [PATCH 10/16] feat(site): link /apps from the participate register and the footer Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/site/src/app/(concept)/participate/page.tsx | 10 +++++++++- apps/site/src/components/layout/footer.tsx | 9 ++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/site/src/app/(concept)/participate/page.tsx b/apps/site/src/app/(concept)/participate/page.tsx index edc5eef3b..89b734cad 100644 --- a/apps/site/src/app/(concept)/participate/page.tsx +++ b/apps/site/src/app/(concept)/participate/page.tsx @@ -9,7 +9,7 @@ import { PdppConceptDoc, PdppConceptPage } from "@/components/layout/concept-pag import { PdppConceptSection } from "@/components/sections/concept-section.tsx"; import { PdppEmailContactCard } from "@/components/site/report-contact-card.tsx"; import { Text } from "@/components/typography/text.tsx"; -import { isPlaceholder, siteConfig, siteFlags } from "@/lib/site-config.ts"; +import { appDirectoryEnabled, isPlaceholder, siteConfig, siteFlags } from "@/lib/site-config.ts"; import { GITHUB_REPO_URL } from "@/lib/site-facts.ts"; import { cn } from "@/lib/utils.ts"; @@ -399,6 +399,14 @@ function RegisterSection({ {" "} · Supporters are listed separately on the Principles page + {appDirectoryEnabled() && ( + + To check one app against this register, the Supporters list and the open-source connectors, use{" "} + + Is your app on PDPP? + + + )} Recognised trust registries diff --git a/apps/site/src/components/layout/footer.tsx b/apps/site/src/components/layout/footer.tsx index d8bf0f802..c7a118a82 100644 --- a/apps/site/src/components/layout/footer.tsx +++ b/apps/site/src/components/layout/footer.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { Suspense } from "react"; import { isContributorSurfaceEnabled } from "@/lib/contributor-surface.ts"; -import { REPORTS_EMAIL, REPORTS_EMAIL_HREF, siteConfig } from "@/lib/site-config.ts"; +import { appDirectoryEnabled, REPORTS_EMAIL, REPORTS_EMAIL_HREF, siteConfig } from "@/lib/site-config.ts"; import { GITHUB_REPO_URL, LFDT_COPYRIGHT_NOTICE, LFPROJECTS_URL, SITE_LICENSES } from "@/lib/site-facts.ts"; import { cn } from "@/lib/utils.ts"; import { ColorSchemeMenu } from "../elements/color-scheme-menu.tsx"; @@ -139,6 +139,13 @@ export function PdppConceptFooter() { PDPP Principles + {appDirectoryEnabled() && ( +
    + + Is your app on PDPP? + +
    + )} From b359c9611d05eba815cf132ca4ce7e7ce22e6105 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 4 Nov 2026 16:16:18 -0600 Subject: [PATCH 11/16] feat(site): add Is your app on PDPP? to a Participate menu Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/site/src/app/(concept)/apps/page.test.ts | 12 ++++++++++++ apps/site/src/components/layout/masthead.tsx | 2 +- apps/site/src/lib/public-site-nav.ts | 16 +++++++++++++--- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/site/src/app/(concept)/apps/page.test.ts b/apps/site/src/app/(concept)/apps/page.test.ts index 01538c5e8..07399e01c 100644 --- a/apps/site/src/app/(concept)/apps/page.test.ts +++ b/apps/site/src/app/(concept)/apps/page.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { publicSiteNav } from "@/lib/public-site-nav.ts"; import { appDirectoryEnabled } from "@/lib/site-config.ts"; import Page from "./page.tsx"; @@ -61,3 +62,14 @@ test("the page is not found when the flag is off", async () => { } } }); + +test("the Participate menu lists /apps only while the page is on", () => { + const participateChildren = (value: string | undefined) => + withFlag(value, () => + publicSiteNav() + .find((item) => item.text === "Participate") + ?.children?.map((child) => child.link) + ); + assert.deepEqual(participateChildren(undefined), ["/participate"]); + assert.deepEqual(participateChildren("true"), ["/participate", "/apps"]); +}); diff --git a/apps/site/src/components/layout/masthead.tsx b/apps/site/src/components/layout/masthead.tsx index 8570d9739..404c9a167 100644 --- a/apps/site/src/components/layout/masthead.tsx +++ b/apps/site/src/components/layout/masthead.tsx @@ -235,7 +235,7 @@ export function PdppConceptMasthead() {
    ); - // Only Specification carries children. The panel opens on hover + // Specification and Participate can carry children. The panel opens on hover // AND on focus-within, so it is reachable by keyboard without a // click handler or any open/closed state to keep in sync — the // links inside are ordinary tab stops. Below the nav's collapse diff --git a/apps/site/src/lib/public-site-nav.ts b/apps/site/src/lib/public-site-nav.ts index a48978e9f..a70c4ddc9 100644 --- a/apps/site/src/lib/public-site-nav.ts +++ b/apps/site/src/lib/public-site-nav.ts @@ -1,7 +1,7 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 -import { siteFlags } from "./site-config.ts"; +import { appDirectoryEnabled, siteFlags } from "./site-config.ts"; export interface PublicSiteNavChild { readonly link: string; @@ -9,7 +9,7 @@ export interface PublicSiteNavChild { } export interface PublicSiteNavLink { - /** Present only on Specification, which opens a dropdown on hover/focus. */ + /** Present on Specification and Participate. With two or more, the item opens a dropdown on hover/focus. */ readonly children?: readonly PublicSiteNavChild[]; readonly link: string; readonly text: string; @@ -24,6 +24,10 @@ export interface PublicSiteNavLink { // document, not a separate destination, and it disappears with reviewOpen // without leaving a gap in the nav. // +// Participate carries "Is your app on PDPP?" (/apps) the same way: the page is a +// view over the registers Participate already shows, so it is a door into that +// section, not a fifth intent. It appears only while /apps is switched on. +// // The review entry is the ONLY place outside /review and the reader's own // version strip that the review period is named. That is deliberate: a // deadline repeated across a site has to be unwound from every one of those @@ -35,10 +39,16 @@ export function publicSiteNav(): readonly PublicSiteNavLink[] { specificationChildren.push({ link: "/review", text: "Review, extended" }); } + const participateChildren: PublicSiteNavChild[] = [{ link: "/participate", text: "How to get involved" }]; + + if (appDirectoryEnabled()) { + participateChildren.push({ link: "/apps", text: "Is your app on PDPP?" }); + } + return [ { link: "/principles", text: "Principles" }, { children: specificationChildren, link: "/specification", text: "Specification" }, { link: "/build", text: "Build" }, - { link: "/participate", text: "Participate" }, + { children: participateChildren, link: "/participate", text: "Participate" }, ]; } From fc4db3c67cff76ecaa3a6f0cb04d0186c9e2fed1 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 4 Nov 2026 16:20:49 -0600 Subject: [PATCH 12/16] fix(site): open nav dropdowns on hover and focus (group, not md:group) Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/site/src/components/layout/masthead.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/site/src/components/layout/masthead.tsx b/apps/site/src/components/layout/masthead.tsx index 404c9a167..4e6a5a5ca 100644 --- a/apps/site/src/components/layout/masthead.tsx +++ b/apps/site/src/components/layout/masthead.tsx @@ -247,7 +247,7 @@ export function PdppConceptMasthead() { } return ( - + {label} Date: Wed, 4 Nov 2026 16:23:43 -0600 Subject: [PATCH 13/16] fix(site): size nav dropdowns to their entries Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/site/src/components/layout/masthead.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/site/src/components/layout/masthead.tsx b/apps/site/src/components/layout/masthead.tsx index 4e6a5a5ca..6c9050820 100644 --- a/apps/site/src/components/layout/masthead.tsx +++ b/apps/site/src/components/layout/masthead.tsx @@ -252,7 +252,7 @@ export function PdppConceptMasthead() { Date: Wed, 4 Nov 2026 17:16:48 -0600 Subject: [PATCH 14/16] feat(site): load the connector catalogue live on /apps, with the repo copy as fallback Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/site/scripts/sync-connector-catalog.mjs | 181 +---------------- .../scripts/sync-connector-catalog.test.ts | 53 ++++- .../src/lib/app-directory-sources.test.ts | 53 +++++ apps/site/src/lib/app-directory-sources.ts | 44 +++- apps/site/src/lib/app-directory.test.ts | 4 +- apps/site/src/lib/connector-catalog.mjs | 190 ++++++++++++++++++ 6 files changed, 344 insertions(+), 181 deletions(-) create mode 100644 apps/site/src/lib/app-directory-sources.test.ts create mode 100644 apps/site/src/lib/connector-catalog.mjs diff --git a/apps/site/scripts/sync-connector-catalog.mjs b/apps/site/scripts/sync-connector-catalog.mjs index c8bd897a9..446502b29 100644 --- a/apps/site/scripts/sync-connector-catalog.mjs +++ b/apps/site/scripts/sync-connector-catalog.mjs @@ -4,182 +4,17 @@ // Refreshes src/data/connector-catalog.json, the snapshot behind /apps. // -// The source is the connector catalogue, an OCI artifact at -// ghcr.io/pdp-connect/connector-catalog:latest that anyone can read without a -// login. This script pulls it, checks the bytes against the layer digest and -// size the registry reports, then reads each connector's manifest at the -// catalogue's source commit for the labels of what a person can bring. -// -// TRUST: the snapshot is pinned to digests, not signed. The manifest digest and -// layer digest are written into the file so anyone can fetch the same artifact -// and compare. This script does NOT verify the catalogue's cosign signature. A -// reader who needs that guarantee must verify the artifact itself. +// The page loads the catalogue live and refreshes it hourly. This file is +// the copy it falls back to when that load fails, so refresh it now and then. +// The loading code, and what it does and does not verify, is in +// src/lib/connector-catalog.mjs. // // Run: node scripts/sync-connector-catalog.mjs [--out ] -import { createHash } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; - -const REGISTRY = "ghcr.io"; -const REPOSITORY = "pdp-connect/connector-catalog"; -const SOURCE_NAME = `${REGISTRY}/${REPOSITORY}`; -const CATALOG_MEDIA_TYPE = "application/vnd.pdpp.connector-catalog.v1+json"; -const MANIFEST_ACCEPT = "application/vnd.oci.image.manifest.v1+json"; -const CONNECTOR_MANIFEST_BASE = "https://raw.githubusercontent.com/PDP-Connect/data-connectors"; -const TIERS = new Set(["supported", "preview", "development"]); - -const DEFAULT_OUTPUT = fileURLToPath(new URL("../src/data/connector-catalog.json", import.meta.url)); - -function sha256Digest(bytes) { - return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; -} - -/** - * Reads the labels a connector declares for what it brings. Returns [] for a - * missing or unreadable manifest: labels are an extra, and a connector with - * none is still listed. - */ -export function labelsFromManifest(manifest) { - const streams = Array.isArray(manifest?.streams) ? manifest.streams : []; - const labels = []; - for (const stream of streams) { - const label = stream?.display?.label; - if (typeof label === "string" && label.trim() && !labels.includes(label.trim())) { - labels.push(label.trim()); - } - } - return labels; -} - -/** - * Pure transform: raw catalogue plus per-connector manifests to the snapshot - * the page renders. Keeps only what the page needs. Nothing here touches the - * network, so a test can feed it a small fixture. - * - * `manifests` maps connector_key to the parsed connector manifest, or to null - * or undefined when that manifest was missing. `folders` maps connector_key to - * the source folder the manifest was found in; the page links to it. - */ -export function buildSnapshot({ catalog, manifests, folders = {}, manifestDigest, layerDigest }) { - if (!Array.isArray(catalog?.connectors)) { - throw new Error("Catalogue has no connectors array"); - } - const connectors = catalog.connectors.map((connector) => { - if (!(connector.connector_key && connector.display_name)) { - throw new Error("Catalogue connector is missing connector_key or display_name"); - } - if (!TIERS.has(connector.tier)) { - throw new Error(`Connector ${connector.connector_key} has unknown tier ${String(connector.tier)}`); - } - return { - key: connector.connector_key, - display_name: connector.display_name, - tier: connector.tier, - labels: labelsFromManifest(manifests[connector.connector_key]), - source_path: folders[connector.connector_key] ? `connectors/${folders[connector.connector_key]}` : null, - }; - }); - connectors.sort((a, b) => a.display_name.localeCompare(b.display_name, "en") || a.key.localeCompare(b.key, "en")); - return { - source: SOURCE_NAME, - manifest_digest: manifestDigest, - layer_digest: layerDigest, - generated_at: catalog.generated_at, - source_commit: catalog.source_commit, - connectors, - }; -} - -async function getJson(url, init) { - const response = await fetch(url, init); - if (!response.ok) { - throw new Error(`${url} returned ${response.status}`); - } - return response; -} - -/** Pulls the catalogue anonymously and checks its digest and size. */ -export async function fetchCatalog() { - const tokenResponse = await getJson(`https://${REGISTRY}/token?scope=repository:${REPOSITORY}:pull`); - const { token } = await tokenResponse.json(); - const headers = { Authorization: `Bearer ${token}` }; - - const manifestResponse = await getJson(`https://${REGISTRY}/v2/${REPOSITORY}/manifests/latest`, { - headers: { ...headers, Accept: MANIFEST_ACCEPT }, - }); - const manifestBytes = Buffer.from(await manifestResponse.arrayBuffer()); - const manifestDigest = sha256Digest(manifestBytes); - const reported = manifestResponse.headers.get("docker-content-digest"); - if (reported && reported !== manifestDigest) { - throw new Error(`Manifest digest mismatch: registry says ${reported}, bytes hash to ${manifestDigest}`); - } - const manifest = JSON.parse(manifestBytes.toString("utf8")); - const layers = (manifest.layers ?? []).filter((candidate) => candidate.mediaType === CATALOG_MEDIA_TYPE); - if (layers.length !== 1) { - throw new Error(`Expected one ${CATALOG_MEDIA_TYPE} layer, found ${layers.length}`); - } - const [layer] = layers; - - const blobResponse = await getJson(`https://${REGISTRY}/v2/${REPOSITORY}/blobs/${layer.digest}`, { - headers, - redirect: "follow", - }); - const blobBytes = Buffer.from(await blobResponse.arrayBuffer()); - verifyBlob(blobBytes, layer); - - return { catalog: JSON.parse(blobBytes.toString("utf8")), manifestDigest, layerDigest: layer.digest }; -} - -/** Throws unless the bytes match the layer's digest and size. */ -export function verifyBlob(bytes, layer) { - const actual = sha256Digest(bytes); - if (actual !== layer.digest) { - throw new Error(`Catalogue digest mismatch: expected ${layer.digest}, got ${actual}`); - } - if (bytes.length !== layer.size) { - throw new Error(`Catalogue size mismatch: expected ${layer.size}, got ${bytes.length}`); - } -} - -// Catalogue keys use hyphens (`apple-health`); most source folders use -// underscores (`connectors/apple_health`). Try the key as written, then that form. -export function manifestFolderCandidates(key) { - const underscored = key.replaceAll("-", "_"); - return underscored === key ? [key] : [key, underscored]; -} - -async function fetchConnectorManifest(sourceCommit, key) { - for (const folder of manifestFolderCandidates(key)) { - const url = `${CONNECTOR_MANIFEST_BASE}/${sourceCommit}/connectors/${folder}/manifest.json`; - // biome-ignore lint/performance/noAwaitInLoops: the second folder is tried only when the first is missing. - const response = await fetch(url); - if (response.status === 404) { - continue; - } - if (!response.ok) { - throw new Error(`${url} returned ${response.status}`); - } - return { folder, manifest: await response.json() }; - } - return null; -} - -// One small request per connector, all at once: the catalogue lists a few -// dozen, and a missing manifest is a null rather than a failure. -async function fetchManifests(catalog) { - const found = await Promise.all( - catalog.connectors.map(async (connector) => [ - connector.connector_key, - await fetchConnectorManifest(catalog.source_commit, connector.connector_key), - ]) - ); - return { - folders: Object.fromEntries(found.map(([key, result]) => [key, result?.folder ?? null])), - manifests: Object.fromEntries(found.map(([key, result]) => [key, result?.manifest ?? null])), - }; -} +import { loadConnectorSnapshot } from "../src/lib/connector-catalog.mjs"; // Measured against the repo formatter: it keeps a JSON array on one line up to this width. const LINE_WIDTH = 80; @@ -203,10 +38,10 @@ export function formatSnapshot(snapshot) { return `${text}\n`; } +const DEFAULT_OUTPUT = fileURLToPath(new URL("../src/data/connector-catalog.json", import.meta.url)); + export async function syncConnectorCatalog(outputPath = DEFAULT_OUTPUT) { - const { catalog, manifestDigest, layerDigest } = await fetchCatalog(); - const { folders, manifests } = await fetchManifests(catalog); - const snapshot = buildSnapshot({ catalog, folders, manifests, manifestDigest, layerDigest }); + const snapshot = await loadConnectorSnapshot(); await mkdir(path.dirname(outputPath), { recursive: true }); await writeFile(outputPath, formatSnapshot(snapshot)); return snapshot; diff --git a/apps/site/scripts/sync-connector-catalog.test.ts b/apps/site/scripts/sync-connector-catalog.test.ts index ebdb522cb..0be96d481 100644 --- a/apps/site/scripts/sync-connector-catalog.test.ts +++ b/apps/site/scripts/sync-connector-catalog.test.ts @@ -6,11 +6,12 @@ import { createHash } from "node:crypto"; import test from "node:test"; import { buildSnapshot, - formatSnapshot, labelsFromManifest, + loadConnectorSnapshot, manifestFolderCandidates, verifyBlob, -} from "./sync-connector-catalog.mjs"; +} from "../src/lib/connector-catalog.mjs"; +import { formatSnapshot } from "./sync-connector-catalog.mjs"; const UNKNOWN_TIER = /unknown tier/; const NO_CONNECTORS = /no connectors array/; @@ -158,3 +159,51 @@ test("the snapshot records the source folder a manifest was found in", () => { assert.equal(paths.spotify, "connectors/spotify_web"); assert.equal(paths.strava, null); }); + +test("loadConnectorSnapshot pulls the catalogue, checks it and reads each manifest", async () => { + const blob = Buffer.from(JSON.stringify(CATALOG)); + const layer = { + digest: `sha256:${createHash("sha256").update(blob).digest("hex")}`, + mediaType: "application/vnd.pdpp.connector-catalog.v1+json", + size: blob.length, + }; + const manifest = Buffer.from(JSON.stringify({ layers: [layer] })); + const requested: string[] = []; + const fakeFetch = (url: string) => { + requested.push(url); + if (url.includes("/token?")) { + return Promise.resolve(Response.json({ token: "t" })); + } + if (url.endsWith("/manifests/latest")) { + return Promise.resolve(new Response(manifest)); + } + if (url.includes("/blobs/")) { + return Promise.resolve(new Response(blob)); + } + if (url.endsWith("/abc123/connectors/spotify/manifest.json")) { + return Promise.resolve(Response.json(MANIFESTS.spotify)); + } + return Promise.resolve(new Response("", { status: 404 })); + }; + + const snapshot = await loadConnectorSnapshot(fakeFetch as typeof fetch); + const spotify = snapshot.connectors.find((connector: { key: string }) => connector.key === "spotify"); + assert.deepEqual(spotify?.labels, ["Your playlists", "Your saved tracks"]); + assert.equal(spotify?.source_path, "connectors/spotify"); + assert.equal(snapshot.layer_digest, layer.digest); + assert.ok(requested.some((url) => url.endsWith("/connectors/no_manifest/manifest.json"))); +}); + +test("loadConnectorSnapshot rejects a catalogue whose bytes do not match the digest", async () => { + const layer = { digest: "sha256:00", mediaType: "application/vnd.pdpp.connector-catalog.v1+json", size: 2 }; + const fakeFetch = (url: string) => { + if (url.includes("/token?")) { + return Promise.resolve(Response.json({ token: "t" })); + } + if (url.endsWith("/manifests/latest")) { + return Promise.resolve(Response.json({ layers: [layer] })); + } + return Promise.resolve(new Response("{}")); + }; + await assert.rejects(loadConnectorSnapshot(fakeFetch as typeof fetch), DIGEST_MISMATCH); +}); diff --git a/apps/site/src/lib/app-directory-sources.test.ts b/apps/site/src/lib/app-directory-sources.test.ts new file mode 100644 index 000000000..1ecfa01ac --- /dev/null +++ b/apps/site/src/lib/app-directory-sources.test.ts @@ -0,0 +1,53 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ConnectorSnapshot } from "./app-directory.ts"; +import { readConnectorSnapshot } from "./app-directory-sources.ts"; + +function snapshot(names: string[]): ConnectorSnapshot { + return { + connectors: names.map((name) => ({ + display_name: name, + key: name.toLowerCase(), + labels: [], + source_path: `connectors/${name.toLowerCase()}`, + tier: "supported" as const, + })), + generated_at: "2026-11-04T02:08:53.000Z", + layer_digest: "sha256:layer", + manifest_digest: "sha256:manifest", + source: "ghcr.io/pdp-connect/connector-catalog", + source_commit: "abc", + }; +} + +const BUNDLED = snapshot(["Bundled"]); +const readBundled = () => Promise.resolve(BUNDLED); + +test("the live catalogue wins when it loads", async () => { + const live = snapshot(["Live"]); + assert.deepEqual(await readConnectorSnapshot({ loadLive: () => Promise.resolve(live), readBundled }), live); +}); + +test("a failed live load falls back to the copy in the repo", async (t) => { + t.mock.method(console, "warn", () => undefined); + const result = await readConnectorSnapshot({ + loadLive: () => Promise.reject(new Error("registry down")), + readBundled, + }); + assert.equal(result, BUNDLED); +}); + +test("an empty or malformed live catalogue falls back too", async (t) => { + t.mock.method(console, "warn", () => undefined); + const results = await Promise.all( + [snapshot([]), { connectors: "nope" }, null].map((bad) => + readConnectorSnapshot({ loadLive: () => Promise.resolve(bad), readBundled }) + ) + ); + for (const result of results) { + assert.equal(result, BUNDLED); + } +}); diff --git a/apps/site/src/lib/app-directory-sources.ts b/apps/site/src/lib/app-directory-sources.ts index 6aa03db5f..6b864d22d 100644 --- a/apps/site/src/lib/app-directory-sources.ts +++ b/apps/site/src/lib/app-directory-sources.ts @@ -1,17 +1,23 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 -// Server-side readers for the two files /apps reads from this repository. -// Both ship in the repo and hold no personal data, so they are read at build -// time. The Supporters register has its own reader in public-supporters.ts. +// Server-side readers for /apps: the connector catalogue (live, with a copy in +// the repo as the fallback) and the conformance register (in the repo). Neither +// holds personal data. The Supporters register has its own reader in +// public-supporters.ts. import { readFile } from "node:fs/promises"; import path from "node:path"; +import { unstable_cache } from "next/cache"; import { z } from "zod"; import type { ConnectorSnapshot, RegisterEntry } from "./app-directory.ts"; +import { loadConnectorSnapshot } from "./connector-catalog.mjs"; const SNAPSHOT_PATH = ["src", "data", "connector-catalog.json"] as const; const REGISTER_PATH = ["public", "register", "index.json"] as const; +// The catalogue changes when a connector is published, a few times a week at +// most. An hour keeps the page close to it at about 60 requests an hour. +const CATALOGUE_REVALIDATE_SECONDS = 3600; const snapshotSchema = z.object({ connectors: z.array( @@ -41,11 +47,41 @@ const registerSchema = z.array( }) ); -export async function readConnectorSnapshot(): Promise { +interface ReadConnectorSnapshotOptions { + loadLive?: () => Promise; + readBundled?: () => Promise; +} + +/** The copy in the repo, refreshed by scripts/sync-connector-catalog.mjs. */ +export async function readBundledConnectorSnapshot(): Promise { const file = path.join(process.cwd(), ...SNAPSHOT_PATH); return snapshotSchema.parse(JSON.parse(await readFile(file, "utf8"))); } +// One cache entry for the whole load. Caching each request on its own would +// key the registry requests by their short-lived token and miss every time. +const loadLiveCached = unstable_cache(() => loadConnectorSnapshot(), ["connector-catalog"], { + revalidate: CATALOGUE_REVALIDATE_SECONDS, +}); + +// Reads the live catalogue, so a newly published connector shows within the +// hour without a deploy. Any failure, or an empty catalogue, falls back to the +// copy in the repo: the page always renders. +export async function readConnectorSnapshot(options: ReadConnectorSnapshotOptions = {}): Promise { + const readBundled = options.readBundled ?? readBundledConnectorSnapshot; + try { + const live = snapshotSchema.parse(await (options.loadLive ?? loadLiveCached)()); + if (live.connectors.length === 0) { + throw new Error("The live connector catalogue is empty"); + } + return live; + } catch { + // Same rule as the Supporters reader: no payloads or messages in the log. + console.warn("Unable to load the live connector catalogue; using the copy in the repo."); + return await readBundled(); + } +} + /** The conformance register. Empty until Registered and Verified open. */ export async function readRegisterEntries(): Promise { const file = path.join(process.cwd(), ...REGISTER_PATH); diff --git a/apps/site/src/lib/app-directory.test.ts b/apps/site/src/lib/app-directory.test.ts index 616cc7982..c2b45da5f 100644 --- a/apps/site/src/lib/app-directory.test.ts +++ b/apps/site/src/lib/app-directory.test.ts @@ -15,7 +15,7 @@ import { searchDirectory, TIER_LABELS, } from "./app-directory.ts"; -import { readConnectorSnapshot, readRegisterEntries } from "./app-directory-sources.ts"; +import { readBundledConnectorSnapshot, readRegisterEntries } from "./app-directory-sources.ts"; import type { PublicSupporter } from "./public-supporters.ts"; const COMPANY_CLAIM = /\b(is on|is part of|supports|takes part|partner)\b/i; @@ -231,7 +231,7 @@ test("formatDate, appName and labelsLine", () => { }); test("the committed snapshot and register parse and build a directory", async () => { - const snapshot = await readConnectorSnapshot(); + const snapshot = await readBundledConnectorSnapshot(); const register = await readRegisterEntries(); const supporters = JSON.parse( await readFile(new URL("../../public/principles/supporters.json", import.meta.url), "utf8") diff --git a/apps/site/src/lib/connector-catalog.mjs b/apps/site/src/lib/connector-catalog.mjs new file mode 100644 index 000000000..6ddd2d579 --- /dev/null +++ b/apps/site/src/lib/connector-catalog.mjs @@ -0,0 +1,190 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +// Reads the connector catalogue behind /apps. +// +// The source is the connector catalogue, an OCI artifact at +// ghcr.io/pdp-connect/connector-catalog:latest that anyone can read without a +// login. This module pulls it, checks the bytes against the layer digest and +// size the registry reports, then reads each connector's manifest at the +// catalogue's source commit for the labels of what a person can bring. +// +// Two callers: the /apps page loads it live (app-directory-sources.ts), and +// scripts/sync-connector-catalog.mjs writes it to src/data/connector-catalog.json, +// the copy the page falls back to when the live load fails. +// +// TRUST: the result is pinned to digests, not signed. The manifest digest and +// layer digest are kept in the result so anyone can fetch the same artifact +// and compare. This module does NOT verify the catalogue's cosign signature. +// A reader who needs that guarantee must verify the artifact itself. +// +// Plain JavaScript with no imports beyond node:crypto, so the sync script can +// run it with plain node and the site can bundle it. + +import { createHash } from "node:crypto"; + +const REGISTRY = "ghcr.io"; +const REPOSITORY = "pdp-connect/connector-catalog"; +const SOURCE_NAME = `${REGISTRY}/${REPOSITORY}`; +const CATALOG_MEDIA_TYPE = "application/vnd.pdpp.connector-catalog.v1+json"; +const MANIFEST_ACCEPT = "application/vnd.oci.image.manifest.v1+json"; +const CONNECTOR_MANIFEST_BASE = "https://raw.githubusercontent.com/PDP-Connect/data-connectors"; +const TIERS = new Set(["supported", "preview", "development"]); +// Per request. A slow registry must not hold up a build or a page regeneration. +const REQUEST_TIMEOUT_MS = 15_000; + +function sha256Digest(bytes) { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +/** + * Reads the labels a connector declares for what it brings. Returns [] for a + * missing or unreadable manifest: labels are an extra, and a connector with + * none is still listed. + */ +export function labelsFromManifest(manifest) { + const streams = Array.isArray(manifest?.streams) ? manifest.streams : []; + const labels = []; + for (const stream of streams) { + const label = stream?.display?.label; + if (typeof label === "string" && label.trim() && !labels.includes(label.trim())) { + labels.push(label.trim()); + } + } + return labels; +} + +/** + * Pure transform: raw catalogue plus per-connector manifests to the snapshot + * the page renders. Keeps only what the page needs. Nothing here touches the + * network, so a test can feed it a small fixture. + * + * `manifests` maps connector_key to the parsed connector manifest, or to null + * or undefined when that manifest was missing. `folders` maps connector_key to + * the source folder the manifest was found in; the page links to it. + */ +export function buildSnapshot({ catalog, manifests, folders = {}, manifestDigest, layerDigest }) { + if (!Array.isArray(catalog?.connectors)) { + throw new Error("Catalogue has no connectors array"); + } + const connectors = catalog.connectors.map((connector) => { + if (!(connector.connector_key && connector.display_name)) { + throw new Error("Catalogue connector is missing connector_key or display_name"); + } + if (!TIERS.has(connector.tier)) { + throw new Error(`Connector ${connector.connector_key} has unknown tier ${String(connector.tier)}`); + } + return { + key: connector.connector_key, + display_name: connector.display_name, + tier: connector.tier, + labels: labelsFromManifest(manifests[connector.connector_key]), + source_path: folders[connector.connector_key] ? `connectors/${folders[connector.connector_key]}` : null, + }; + }); + connectors.sort((a, b) => a.display_name.localeCompare(b.display_name, "en") || a.key.localeCompare(b.key, "en")); + return { + source: SOURCE_NAME, + manifest_digest: manifestDigest, + layer_digest: layerDigest, + generated_at: catalog.generated_at, + source_commit: catalog.source_commit, + connectors, + }; +} + +async function getJson(fetchFn, url, init) { + const response = await fetchFn(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + if (!response.ok) { + throw new Error(`${url} returned ${response.status}`); + } + return response; +} + +/** Pulls the catalogue anonymously and checks its digest and size. */ +export async function fetchCatalog(fetchFn = fetch) { + const tokenResponse = await getJson(fetchFn, `https://${REGISTRY}/token?scope=repository:${REPOSITORY}:pull`); + const { token } = await tokenResponse.json(); + const headers = { Authorization: `Bearer ${token}` }; + + const manifestResponse = await getJson(fetchFn, `https://${REGISTRY}/v2/${REPOSITORY}/manifests/latest`, { + headers: { ...headers, Accept: MANIFEST_ACCEPT }, + }); + const manifestBytes = Buffer.from(await manifestResponse.arrayBuffer()); + const manifestDigest = sha256Digest(manifestBytes); + const reported = manifestResponse.headers.get("docker-content-digest"); + if (reported && reported !== manifestDigest) { + throw new Error(`Manifest digest mismatch: registry says ${reported}, bytes hash to ${manifestDigest}`); + } + const manifest = JSON.parse(manifestBytes.toString("utf8")); + const layers = (manifest.layers ?? []).filter((candidate) => candidate.mediaType === CATALOG_MEDIA_TYPE); + if (layers.length !== 1) { + throw new Error(`Expected one ${CATALOG_MEDIA_TYPE} layer, found ${layers.length}`); + } + const [layer] = layers; + + const blobResponse = await getJson(fetchFn, `https://${REGISTRY}/v2/${REPOSITORY}/blobs/${layer.digest}`, { + headers, + redirect: "follow", + }); + const blobBytes = Buffer.from(await blobResponse.arrayBuffer()); + verifyBlob(blobBytes, layer); + + return { catalog: JSON.parse(blobBytes.toString("utf8")), manifestDigest, layerDigest: layer.digest }; +} + +/** Throws unless the bytes match the layer's digest and size. */ +export function verifyBlob(bytes, layer) { + const actual = sha256Digest(bytes); + if (actual !== layer.digest) { + throw new Error(`Catalogue digest mismatch: expected ${layer.digest}, got ${actual}`); + } + if (bytes.length !== layer.size) { + throw new Error(`Catalogue size mismatch: expected ${layer.size}, got ${bytes.length}`); + } +} + +// Catalogue keys use hyphens (`apple-health`); most source folders use +// underscores (`connectors/apple_health`). Try the key as written, then that form. +export function manifestFolderCandidates(key) { + const underscored = key.replaceAll("-", "_"); + return underscored === key ? [key] : [key, underscored]; +} + +async function fetchConnectorManifest(fetchFn, sourceCommit, key) { + for (const folder of manifestFolderCandidates(key)) { + const url = `${CONNECTOR_MANIFEST_BASE}/${sourceCommit}/connectors/${folder}/manifest.json`; + // biome-ignore lint/performance/noAwaitInLoops: the second folder is tried only when the first is missing. + const response = await fetchFn(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + if (response.status === 404) { + continue; + } + if (!response.ok) { + throw new Error(`${url} returned ${response.status}`); + } + return { folder, manifest: await response.json() }; + } + return null; +} + +// One small request per connector, all at once: the catalogue lists a few +// dozen, and a missing manifest is a null rather than a failure. +export async function fetchManifests(catalog, fetchFn = fetch) { + const found = await Promise.all( + catalog.connectors.map(async (connector) => [ + connector.connector_key, + await fetchConnectorManifest(fetchFn, catalog.source_commit, connector.connector_key), + ]) + ); + return { + folders: Object.fromEntries(found.map(([key, result]) => [key, result?.folder ?? null])), + manifests: Object.fromEntries(found.map(([key, result]) => [key, result?.manifest ?? null])), + }; +} + +/** The whole live load: catalogue, then every manifest, then the snapshot the page renders. */ +export async function loadConnectorSnapshot(fetchFn = fetch) { + const { catalog, manifestDigest, layerDigest } = await fetchCatalog(fetchFn); + const { folders, manifests } = await fetchManifests(catalog, fetchFn); + return buildSnapshot({ catalog, folders, layerDigest, manifestDigest, manifests }); +} From dabf822780d2ca1d75199e6723a8452266c72cf3 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 4 Nov 2026 17:18:28 -0600 Subject: [PATCH 15/16] chore(site): refresh the fallback connector catalogue to 23aee3a Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/site/src/data/connector-catalog.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/site/src/data/connector-catalog.json b/apps/site/src/data/connector-catalog.json index 987648e65..78479cb39 100644 --- a/apps/site/src/data/connector-catalog.json +++ b/apps/site/src/data/connector-catalog.json @@ -1,9 +1,9 @@ { "source": "ghcr.io/pdp-connect/connector-catalog", - "manifest_digest": "sha256:5be68a20e8bcc0892727fb0d1d57821ced6cc8ba02a57b236058862c3e4ddcb4", - "layer_digest": "sha256:c484ef45e0fd733ecaa7cb9037d58b7b84c9a2b8ee061b85cec573a51765ba15", - "generated_at": "2026-11-04T02:08:53.000Z", - "source_commit": "28a179299109dba249c90c41324f9abe0378ddb9", + "manifest_digest": "sha256:600593d19d560555c5db84d38527ad9f01e802e6de2ed0ddf06ed21b90d0b41d", + "layer_digest": "sha256:8d5184178ba8762b7715746ee5f867274b471891cc60cb7d0e7e69e30973fc45", + "generated_at": "2026-11-04T22:45:18.000Z", + "source_commit": "23aee3aaeafad3a804d504e8614763f7845add60", "connectors": [ { "key": "amazon", From c47f9306a73693138a54346717510f0254e63b3b Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 4 Nov 2026 17:18:45 -0600 Subject: [PATCH 16/16] docs(site): describe the live catalogue load in /apps comments Assisted-by: AI Signed-off-by: Tim Nunamaker --- apps/site/scripts/sync-connector-catalog.mjs | 2 +- apps/site/src/app/(concept)/apps/page.tsx | 7 ++++--- apps/site/src/lib/app-directory.test.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/site/scripts/sync-connector-catalog.mjs b/apps/site/scripts/sync-connector-catalog.mjs index 446502b29..a6acdc996 100644 --- a/apps/site/scripts/sync-connector-catalog.mjs +++ b/apps/site/scripts/sync-connector-catalog.mjs @@ -2,7 +2,7 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 -// Refreshes src/data/connector-catalog.json, the snapshot behind /apps. +// Refreshes src/data/connector-catalog.json, the fallback copy of the catalogue for /apps. // // The page loads the catalogue live and refreshes it hourly. This file is // the copy it falls back to when that load fails, so refresh it now and then. diff --git a/apps/site/src/app/(concept)/apps/page.tsx b/apps/site/src/app/(concept)/apps/page.tsx index c5c164700..f0731d5bf 100644 --- a/apps/site/src/app/(concept)/apps/page.tsx +++ b/apps/site/src/app/(concept)/apps/page.tsx @@ -25,9 +25,10 @@ const DATA_CONNECTORS_URL = "https://github.com/PDP-Connect/data-connectors"; // "Is your app on PDPP?" // // A read-only view over lists PDPP already publishes: the signed connector -// catalogue (committed snapshot), the Supporters register and the conformance -// register. No data is collected and nothing is sent anywhere. The search runs -// in the visitor's browser over rows built here, so the page is static. +// catalogue (loaded live and cached for an hour, with a copy in the repo as the +// fallback), the Supporters register and the conformance register. No data is +// collected and nothing is sent anywhere. The search runs in the visitor's +// browser over rows built here, so the page is static between regenerations. // // Behind NEXT_PUBLIC_PDPP_APP_DIRECTORY. Off, the route does not exist. export default async function Page() { diff --git a/apps/site/src/lib/app-directory.test.ts b/apps/site/src/lib/app-directory.test.ts index c2b45da5f..a2cd1ecd7 100644 --- a/apps/site/src/lib/app-directory.test.ts +++ b/apps/site/src/lib/app-directory.test.ts @@ -230,7 +230,7 @@ test("formatDate, appName and labelsLine", () => { assert.equal(labelsLine(["A", "B", "C", "D", "E"]), "A, B, C, D + 1 more"); }); -test("the committed snapshot and register parse and build a directory", async () => { +test("the fallback copy and register parse and build a directory", async () => { const snapshot = await readBundledConnectorSnapshot(); const register = await readRegisterEntries(); const supporters = JSON.parse(