diff --git a/apps/site/scripts/sync-connector-catalog.mjs b/apps/site/scripts/sync-connector-catalog.mjs new file mode 100644 index 000000000..a6acdc996 --- /dev/null +++ b/apps/site/scripts/sync-connector-catalog.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +// 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. +// 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 { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +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; +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`; +} + +const DEFAULT_OUTPUT = fileURLToPath(new URL("../src/data/connector-catalog.json", import.meta.url)); + +export async function syncConnectorCatalog(outputPath = DEFAULT_OUTPUT) { + const snapshot = await loadConnectorSnapshot(); + 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..0be96d481 --- /dev/null +++ b/apps/site/scripts/sync-connector-catalog.test.ts @@ -0,0 +1,209 @@ +// 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, + labelsFromManifest, + loadConnectorSnapshot, + manifestFolderCandidates, + verifyBlob, +} from "../src/lib/connector-catalog.mjs"; +import { formatSnapshot } 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: [], 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", + 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); +}); + +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: { spotify: "spotify_web" }, + layerDigest: "x", + manifestDigest: "y", + manifests: {}, + }); + 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); +}); + +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/app/(concept)/apps/page.test.ts b/apps/site/src/app/(concept)/apps/page.test.ts new file mode 100644 index 000000000..07399e01c --- /dev/null +++ b/apps/site/src/app/(concept)/apps/page.test.ts @@ -0,0 +1,75 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +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"; + +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; + } + } +}); + +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/app/(concept)/apps/page.tsx b/apps/site/src/app/(concept)/apps/page.tsx new file mode 100644 index 000000000..f0731d5bf --- /dev/null +++ b/apps/site/src/app/(concept)/apps/page.tsx @@ -0,0 +1,90 @@ +// 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 (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() { + 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. + + + 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. + +
+ +
+ +
+ + + 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/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? + +
+ )} diff --git a/apps/site/src/components/layout/masthead.tsx b/apps/site/src/components/layout/masthead.tsx index 8570d9739..6c9050820 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 @@ -247,12 +247,12 @@ export function PdppConceptMasthead() { } return ( - + {label} +
+ + {entry.name} + + {entry.tierLabel && ( + + {entry.tierLabel} + + )} +
+ {entry.headline && ( + + {entry.headline} + + )} + {entry.labels && ( + + {entry.labels} + + )} + {entry.status && ( + + {entry.status} + + )} + {entry.sourceUrl && ( + + + Source on GitHub ↗ + + + )} + + ); +} + +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"); + + return ( +
+
+ + setQuery(event.target.value)} + placeholder={example ? `Search for an app, for example ${example}` : "Search for an app"} + 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..78479cb39 --- /dev/null +++ b/apps/site/src/data/connector-catalog.json @@ -0,0 +1,686 @@ +{ + "source": "ghcr.io/pdp-connect/connector-catalog", + "manifest_digest": "sha256:600593d19d560555c5db84d38527ad9f01e802e6de2ed0ddf06ed21b90d0b41d", + "layer_digest": "sha256:8d5184178ba8762b7715746ee5f867274b471891cc60cb7d0e7e69e30973fc45", + "generated_at": "2026-11-04T22:45:18.000Z", + "source_commit": "23aee3aaeafad3a804d504e8614763f7845add60", + "connectors": [ + { + "key": "amazon", + "display_name": "Amazon", + "tier": "supported", + "labels": [ + "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"], + "source_path": "connectors/apple_contacts" + }, + { + "key": "apple-health", + "display_name": "Apple Health", + "tier": "development", + "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" + ], + "source_path": "connectors/apple_health" + }, + { + "key": "apple-photos", + "display_name": "Apple Photos", + "tier": "development", + "labels": ["Your exported photos and videos"], + "source_path": "connectors/apple_photos" + }, + { + "key": "ical", + "display_name": "Calendar (iCal)", + "tier": "development", + "labels": ["Your calendar events"], + "source_path": "connectors/ical" + }, + { + "key": "character-ai", + "display_name": "Character.AI", + "tier": "development", + "labels": ["Your chats", "Your messages"], + "source_path": "connectors/character_ai" + }, + { + "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" + ], + "source_path": "connectors/chase" + }, + { + "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" + ], + "source_path": "connectors/chatgpt" + }, + { + "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" + ], + "source_path": "connectors/anthropic" + }, + { + "key": "anthropic-live", + "display_name": "Claude (Live Session)", + "tier": "preview", + "labels": [ + "Your Claude conversations", + "Your Claude messages", + "Your Claude plan", + "Your Claude usage limits" + ], + "source_path": "connectors/anthropic_live" + }, + { + "key": "claude-code", + "display_name": "Claude Code", + "tier": "supported", + "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" + ], + "source_path": "connectors/claude_code" + }, + { + "key": "discord-browser", + "display_name": "Discord (Browser Sign-In)", + "tier": "development", + "labels": [ + "Your Discord profile", + "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"], + "source_path": "connectors/doordash" + }, + { + "key": "facebook", + "display_name": "Facebook", + "tier": "development", + "labels": [ + "Your Facebook profile", + "Your posts", + "Your friends", + "Your groups", + "Your likes and reactions", + "Your comments" + ], + "source_path": "connectors/facebook" + }, + { + "key": "fitbit", + "display_name": "Fitbit", + "tier": "development", + "labels": [ + "Your Fitbit exercise", + "Your Fitbit daily activity", + "Your Fitbit sleep" + ], + "source_path": "connectors/fitbit" + }, + { + "key": "garmin-browser", + "display_name": "Garmin (Browser Sign-In)", + "tier": "development", + "labels": [ + "Your Garmin daily summaries", + "Your Garmin sleep", + "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": [], + "source_path": "connectors/github_browser" + }, + { + "key": "gmail", + "display_name": "Gmail", + "tier": "preview", + "labels": [ + "Your messages", + "Your conversations", + "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"], + "source_path": "connectors/google_calendar" + }, + { + "key": "google-contacts", + "display_name": "Google Contacts", + "tier": "development", + "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"], + "source_path": "connectors/google_maps_data_portability" + }, + { + "key": "google-maps", + "display_name": "Google Maps Timeline Import", + "tier": "supported", + "labels": [ + "Your Google Maps location points", + "Your Google Maps visits and activities" + ], + "source_path": "connectors/google_maps" + }, + { + "key": "google-takeout", + "display_name": "Google Takeout", + "tier": "development", + "labels": [ + "Your Google location history", + "Your YouTube watch history", + "Your Google search history", + "Your Google photos and videos" + ], + "source_path": "connectors/google_takeout" + }, + { + "key": "groupme", + "display_name": "GroupMe", + "tier": "development", + "labels": [ + "Your GroupMe groups", + "Messages from your GroupMe groups", + "Your GroupMe direct messages", + "Your GroupMe attachments" + ], + "source_path": "connectors/groupme" + }, + { + "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" + ], + "source_path": "connectors/heb" + }, + { + "key": "icloud-notes", + "display_name": "iCloud Notes", + "tier": "preview", + "labels": [ + "Your iCloud account", + "Your iCloud notes", + "Your iCloud notes folders" + ], + "source_path": "connectors/icloud_notes" + }, + { + "key": "imessage", + "display_name": "iMessage (macOS)", + "tier": "development", + "labels": [ + "Your iMessages", + "Your iMessage chat participants", + "Your iMessage attachments" + ], + "source_path": "connectors/imessage" + }, + { + "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" + ], + "source_path": "connectors/meta" + }, + { + "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" + ], + "source_path": "connectors/instinct" + }, + { + "key": "jellyfin", + "display_name": "Jellyfin", + "tier": "preview", + "labels": ["Your Jellyfin libraries", "Your Jellyfin items"], + "source_path": "connectors/jellyfin" + }, + { + "key": "linkedin", + "display_name": "LinkedIn", + "tier": "preview", + "labels": [ + "Your LinkedIn profile", + "Your work experience", + "Your education", + "Your skills", + "Your languages", + "Your connections" + ], + "source_path": "connectors/linkedin" + }, + { + "key": "loom", + "display_name": "Loom", + "tier": "development", + "labels": ["Your Loom videos", "Your Loom transcripts"], + "source_path": "connectors/loom" + }, + { + "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" + ], + "source_path": "connectors/muse" + }, + { + "key": "netflix-export", + "display_name": "Netflix Export", + "tier": "development", + "labels": ["Your Netflix viewing history"], + "source_path": "connectors/netflix_export" + }, + { + "key": "notion", + "display_name": "Notion", + "tier": "preview", + "labels": ["Your Notion pages", "Your Notion databases"], + "source_path": "connectors/notion" + }, + { + "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" + ], + "source_path": "connectors/codex" + }, + { + "key": "oura", + "display_name": "Oura", + "tier": "development", + "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"], + "source_path": "connectors/oura_browser" + }, + { + "key": "perplexity", + "display_name": "Perplexity (Browser Sign-In)", + "tier": "development", + "labels": ["Your Perplexity threads", "Your Perplexity messages"], + "source_path": "connectors/perplexity" + }, + { + "key": "pocket", + "display_name": "Pocket", + "tier": "development", + "labels": ["Your Pocket saves"], + "source_path": "connectors/pocket" + }, + { + "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" + ], + "source_path": "connectors/reddit" + }, + { + "key": "shopify", + "display_name": "Shopify Shop", + "tier": "preview", + "labels": ["Your Shop app orders"], + "source_path": "connectors/shopify" + }, + { + "key": "slack-browser", + "display_name": "Slack (Browser Sign-In)", + "tier": "development", + "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" + ], + "source_path": "connectors/slack_browser" + }, + { + "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" + ], + "source_path": "connectors/spotify" + }, + { + "key": "steam", + "display_name": "Steam", + "tier": "preview", + "labels": [ + "Your Steam profile", + "Your owned games", + "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"], + "source_path": "connectors/strava" + }, + { + "key": "strava-browser", + "display_name": "Strava (Browser Sign-In)", + "tier": "preview", + "labels": ["Your Strava activities"], + "source_path": "connectors/strava_browser" + }, + { + "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" + ], + "source_path": "connectors/tiktok" + }, + { + "key": "twitter-archive", + "display_name": "Twitter/X Archive", + "tier": "development", + "labels": ["Your tweets", "Your DMs"], + "source_path": "connectors/twitter_archive" + }, + { + "key": "uber", + "display_name": "Uber", + "tier": "supported", + "labels": ["Your Uber trips", "Your Uber receipts"], + "source_path": "connectors/uber" + }, + { + "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)" + ], + "source_path": "connectors/usaa" + }, + { + "key": "venmo", + "display_name": "Venmo", + "tier": "preview", + "labels": [ + "Your Venmo profile", + "Your Venmo friends", + "Your Venmo transactions" + ], + "source_path": "connectors/venmo" + }, + { + "key": "whatsapp", + "display_name": "WhatsApp (chat export)", + "tier": "supported", + "labels": [ + "Your WhatsApp chats", + "Your WhatsApp messages", + "Your WhatsApp media attachments" + ], + "source_path": "connectors/whatsapp" + }, + { + "key": "whatsapp-web", + "display_name": "WhatsApp (web)", + "tier": "development", + "labels": [ + "Your WhatsApp chats", + "Your WhatsApp messages", + "Your WhatsApp contacts", + "Your WhatsApp groups" + ], + "source_path": "connectors/whatsapp_web" + }, + { + "key": "wholefoods", + "display_name": "Whole Foods", + "tier": "development", + "labels": [ + "Your Amazon account", + "Your Whole Foods orders", + "Your Whole Foods items", + "Nutrition facts" + ], + "source_path": "connectors/wholefoods" + }, + { + "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" + ], + "source_path": "connectors/whoop" + }, + { + "key": "whoop-browser", + "display_name": "WHOOP (Browser Sign-In)", + "tier": "development", + "labels": [ + "Your WHOOP cycles", + "Your WHOOP recoveries", + "Your WHOOP sleeps", + "Your WHOOP workouts" + ], + "source_path": "connectors/whoop_browser" + }, + { + "key": "x-browser", + "display_name": "X (Browser Sign-In)", + "tier": "development", + "labels": [ + "Your X profile", + "Your X posts", + "Posts you liked on X", + "Posts you bookmarked on X" + ], + "source_path": "connectors/x_browser" + }, + { + "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" + ], + "source_path": "connectors/ynab" + }, + { + "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" + ], + "source_path": "connectors/youtube" + } + ] +} 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 new file mode 100644 index 000000000..6b864d22d --- /dev/null +++ b/apps/site/src/lib/app-directory-sources.ts @@ -0,0 +1,89 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +// 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( + 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"]), + }) + ), + 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(), + }) +); + +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); + 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..a2cd1ecd7 --- /dev/null +++ b/apps/site/src/lib/app-directory.test.ts @@ -0,0 +1,247 @@ +// 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, + TIER_LABELS, +} from "./app-directory.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; + +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 shows its data and no company line", () => { + const spotify = find(directory(), "Spotify"); + 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, ""); + 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, ""); +}); + +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, ""); +}); + +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, "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, ""); + 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, ""); + 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, + "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", () => { + const oura = directory().filter((entry) => entry.name.startsWith("Oura")); + assert.deepEqual( + oura.map((entry) => entry.name), + ["Oura", "Oura (Browser Sign-In)"] + ); + assert.equal(oura[1]?.labels, "Your sleep"); +}); + +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", () => { + 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); + } + // 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); + } +}); + +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 fallback copy and register parse and build a directory", async () => { + const snapshot = await readBundledConnectorSnapshot(); + 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."))); + // 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); + } +}); diff --git a/apps/site/src/lib/app-directory.ts b/apps/site/src/lib/app-directory.ts new file mode 100644 index 000000000..00f3d96b0 --- /dev/null +++ b/apps/site/src/lib/app-directory.ts @@ -0,0 +1,281 @@ +// 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 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. + +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[]; + /** 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; + 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; +} + +// Supported is the normal case and carries no label; only the caveats do. +export const TIER_LABELS: Record = { + development: "In development", + preview: "Preview", + supported: null, +}; + +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; +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 { + /** A company's own standing, for a company with no connector. Empty on connector rows. */ + 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; + /** 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; + 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. */ +/** + * 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 `${subject} signed the PDPP Principles on ${formatDate(status.signedOn)} and is ${joinList(registered)}.${partner}`; + } + if (status.signedOn) { + return `${subject} signed the PDPP Principles on ${formatDate(status.signedOn)}. It has not registered with PDPP.`; + } + if (registered.length > 0) { + return `${subject} has not signed the Principles. It is ${joinList(registered)}.${partner}`; + } + return `${subject} 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`; +} + +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: "", + id: `connector:${connector.key}`, + kind: "connector", + 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) : "", + tier: connector.tier, + tierLabel: TIER_LABELS[connector.tier], + }); + } + + const companyEntries: DirectoryEntry[] = []; + for (const [key, company] of companies) { + if (!linked.has(key)) { + companyEntries.push({ + // 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, + sourceUrl: null, + status: `There is no PDPP connector for ${company.name} yet.`, + 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/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 }); +} 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" }, ]; } 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"),