diff --git a/apps/site/scripts/count-requested-apps.mjs b/apps/site/scripts/count-requested-apps.mjs new file mode 100644 index 0000000000..d4eef90ec3 --- /dev/null +++ b/apps/site/scripts/count-requested-apps.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +// Counts how many signatories named each app they would like to see join PDPP. +// Reads a local checkout of the private register and prints, to stdout: +// {"apps": [{app, count}], "unreviewedAnswers": N} +// `apps` holds only canonical names: the reviewed names below plus the targets +// of the alias map. Anything else a signer typed is free +// text and can be personal data (an email, a name, a phone number), so it is +// counted but never printed. The stdout output is safe to share with a company. +// +// `--review` additionally prints the unreviewed raw values and their counts to +// STDERR, so a maintainer can extend the alias map. That output is private. + +import { readSignatories } from "./publish-supporters.mjs"; + +const WHITESPACE = /\s+/g; + +// App names a maintainer has reviewed and may print. The form is free text; +// extend this list (and ALIASES) from what `--review` shows. +const REVIEWED_APP_NAMES = [ + "Amazon", + "Apple Health", + "ChatGPT", + "Clue", + "Fitbit", + "Flo", + "Garmin", + "Google", + "Instagram", + "LinkedIn", + "Netflix", + "Oura", + "Reddit", + "Spotify", + "Strava", + "TikTok", + "Uber", + "WhatsApp", + "X", + "YouTube", +]; + +// Lower-cased, space-collapsed spelling -> the name to count it under. +const ALIASES = new Map([ + ["insta", "Instagram"], + ["ig", "Instagram"], + ["chat gpt", "ChatGPT"], + ["chatgpt", "ChatGPT"], + ["openai", "ChatGPT"], + ["open ai", "ChatGPT"], + ["apple health kit", "Apple Health"], + ["healthkit", "Apple Health"], + ["twitter", "X"], + ["yt", "YouTube"], + ["you tube", "YouTube"], + ["whats app", "WhatsApp"], + ["tik tok", "TikTok"], +]); + +// Folded spelling -> canonical name, for every name that may be printed. +const CANONICAL = new Map([...REVIEWED_APP_NAMES, ...ALIASES.values()].map((name) => [foldName(name), name])); + +function foldName(name) { + return String(name).trim().replace(WHITESPACE, " ").toLowerCase(); +} + +/** The distinct folded spellings one record asks for, with their trimmed raw form. */ +function namesInRecord(record) { + const names = new Map(); + for (const raw of Array.isArray(record?.requestedApps) ? record.requestedApps : []) { + const key = typeof raw === "string" ? foldName(raw) : ""; + if (key && !names.has(key)) { + names.set(key, raw.trim().replace(WHITESPACE, " ")); + } + } + return names; +} + +function mostCommonSpelling(spellings) { + // A tie goes to the first by code point, which puts a capitalised spelling + // ahead of a lower-case one. + return [...spellings.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))[0][0]; +} + +/** + * Counts requested apps across records. One signatory counts once per app, + * however they spelled it. + * + * Returns: + * - apps: [{app, count}] for canonical names only, count descending. Safe to share. + * - unreviewedAnswers: how many answers matched no canonical name. A count only. Safe to share. + * - unreviewed: [{value, count}] the raw unmatched values. PRIVATE: may be personal data. + */ +export function countRequestedApps(records) { + const canonicalCounts = new Map(); + const unreviewedGroups = new Map(); + for (const record of records) { + const seenCanonical = new Set(); + for (const [key, spelling] of namesInRecord(record)) { + const canonical = ALIASES.get(key) ?? CANONICAL.get(key) ?? null; + if (canonical) { + seenCanonical.add(canonical); + continue; + } + const group = unreviewedGroups.get(key) ?? { count: 0, spellings: new Map() }; + group.count += 1; + group.spellings.set(spelling, (group.spellings.get(spelling) ?? 0) + 1); + unreviewedGroups.set(key, group); + } + for (const canonical of seenCanonical) { + canonicalCounts.set(canonical, (canonicalCounts.get(canonical) ?? 0) + 1); + } + } + const apps = [...canonicalCounts.entries()] + .map(([app, count]) => ({ app, count })) + .sort((a, b) => b.count - a.count || a.app.localeCompare(b.app)); + const unreviewed = [...unreviewedGroups.values()] + .map((group) => ({ value: mostCommonSpelling(group.spellings), count: group.count })) + .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value)); + return { + apps, + unreviewed, + unreviewedAnswers: unreviewed.reduce((total, entry) => total + entry.count, 0), + }; +} + +async function main() { + const args = process.argv.slice(2); + const review = args.includes("--review"); + const [sourceDirectory] = args.filter((arg) => arg !== "--review"); + if (!sourceDirectory) { + console.error("Usage: count-requested-apps.mjs [--review] "); + process.exitCode = 1; + return; + } + const { apps, unreviewed, unreviewedAnswers } = countRequestedApps(await readSignatories(sourceDirectory)); + console.log(JSON.stringify({ apps, unreviewedAnswers }, null, 2)); + if (review) { + console.error( + "PRIVATE: unreviewed values typed by signatories. They may be personal data. Do not share, paste or commit." + ); + console.error(JSON.stringify(unreviewed, null, 2)); + } +} + +if (import.meta.main) { + main().catch((error) => { + // A JSON SyntaxError message quotes a snippet of the file, which is + // personal data, so it is replaced rather than passed through. + console.error( + error instanceof SyntaxError ? "A signatory file is not valid JSON." : (error?.message ?? "count failed") + ); + process.exitCode = 1; + }); +} diff --git a/apps/site/scripts/count-requested-apps.test.ts b/apps/site/scripts/count-requested-apps.test.ts new file mode 100644 index 0000000000..d35fd45692 --- /dev/null +++ b/apps/site/scripts/count-requested-apps.test.ts @@ -0,0 +1,129 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, test } from "node:test"; +import { promisify } from "node:util"; +import { countRequestedApps } from "./count-requested-apps.mjs"; + +const USAGE = /Usage:/; +const PRIVATE_LABEL = /PRIVATE.*Do not share/; +const run = promisify(execFile); +const SCRIPT = path.join(import.meta.dirname, "count-requested-apps.mjs"); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map(async (directory) => await rm(directory, { force: true, recursive: true })) + ); +}); + +const EMAIL_VALUE = "alice@example.test"; +const NAME_VALUE = "Alice Fakename"; +const PHONE_VALUE = "+1 555 010 0199"; +const PERSONAL_VALUES = [EMAIL_VALUE, NAME_VALUE, PHONE_VALUE]; + +test("count folds case, spacing and aliases, and sorts by count", () => { + const { apps } = countRequestedApps([ + { requestedApps: ["Spotify", "insta"] }, + { requestedApps: [" spotify ", "Chat GPT"] }, + { requestedApps: ["OpenAI", "Instagram", "Strava"] }, + { requestedApps: [] }, + { id: "legacy-record-without-the-field" }, + ]); + assert.deepEqual(apps, [ + { app: "ChatGPT", count: 2 }, + { app: "Instagram", count: 2 }, + { app: "Spotify", count: 2 }, + { app: "Strava", count: 1 }, + ]); +}); + +test("one signatory counts once per app even when they spell it two ways", () => { + assert.deepEqual(countRequestedApps([{ requestedApps: ["Insta", "Instagram", "instagram"] }]).apps, [ + { app: "Instagram", count: 1 }, + ]); +}); + +test("an app outside the reviewed names and alias map is unreviewed, never in apps", () => { + const result = countRequestedApps([ + { requestedApps: ["oura", "Obscure App"] }, + { requestedApps: ["Oura", "obscure app"] }, + { requestedApps: ["Obscure App"] }, + ]); + assert.deepEqual(result.apps, [{ app: "Oura", count: 2 }]); + assert.equal(result.unreviewedAnswers, 3); + assert.deepEqual(result.unreviewed, [{ value: "Obscure App", count: 3 }]); +}); + +async function writeRegister(records: Array<{ id: string; requestedApps: string[] }>): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "pdpp-app-counts-")); + temporaryDirectories.push(directory); + const year = path.join(directory, "signatories", "2026"); + await mkdir(year, { recursive: true }); + await Promise.all( + records.map( + async (record) => + await writeFile( + path.join(year, `${record.id}.json`), + JSON.stringify({ + displayName: `Fake Person ${record.id}`, + email: `${record.id}@example.test`, + ...record, + }) + ) + ) + ); + return directory; +} + +test("default output holds canonical names and an unreviewed total, never free text", async () => { + const directory = await writeRegister([ + { id: "first-fake-id", requestedApps: ["Spotify", "Strava", EMAIL_VALUE] }, + { id: "second-fake-id", requestedApps: ["spotify", NAME_VALUE] }, + { id: "third-fake-id", requestedApps: [PHONE_VALUE] }, + ]); + + const { stdout, stderr } = await run(process.execPath, [SCRIPT, directory]); + assert.deepEqual(JSON.parse(stdout), { + apps: [ + { app: "Spotify", count: 2 }, + { app: "Strava", count: 1 }, + ], + unreviewedAnswers: 3, + }); + assert.equal(stderr, ""); + for (const private_ of [...PERSONAL_VALUES, "Fake Person", "example.test", "fake-id", "555"]) { + assert.equal(stdout.includes(private_), false, `${private_} must not appear in the output`); + } +}); + +test("--review shows unreviewed values on stderr only, labelled private", async () => { + const directory = await writeRegister([ + { id: "first-fake-id", requestedApps: ["Spotify", ...PERSONAL_VALUES] }, + { id: "second-fake-id", requestedApps: [EMAIL_VALUE] }, + ]); + + const { stdout, stderr } = await run(process.execPath, [SCRIPT, "--review", directory]); + assert.deepEqual(JSON.parse(stdout), { apps: [{ app: "Spotify", count: 1 }], unreviewedAnswers: 4 }); + for (const private_ of PERSONAL_VALUES) { + assert.equal(stdout.includes(private_), false, `${private_} must not appear on stdout`); + assert.equal(stderr.includes(private_), true, `${private_} should be listed for review on stderr`); + } + assert.match(stderr, PRIVATE_LABEL); + for (const leaked of ["Fake Person", "fake-id"]) { + assert.equal(stderr.includes(leaked), false, `${leaked} is not a requested app and must not appear`); + } +}); + +test("the command exits non-zero with a usage line when given no directory", async () => { + await assert.rejects(run(process.execPath, [SCRIPT]), (error: { code?: number; stderr?: string }) => { + assert.equal(error.code, 1); + assert.match(error.stderr ?? "", USAGE); + return true; + }); +}); diff --git a/apps/site/scripts/publish-supporters.test.ts b/apps/site/scripts/publish-supporters.test.ts index 83dd59e98a..f201aaef18 100644 --- a/apps/site/scripts/publish-supporters.test.ts +++ b/apps/site/scripts/publish-supporters.test.ts @@ -38,6 +38,7 @@ test("publish supporters writes only consented public fields", async () => { privateMetadata: { identityProviderToken: "nested-private-token" }, principlesVersion: "v1.0", publicName: "Public P.", + requestedApps: ["Fake Requested App"], signatoryName: "Private Signatory", signatoryRole: "Private Role", type: "Individual", @@ -76,6 +77,8 @@ test("publish supporters writes only consented public fields", async () => { "Private Signatory", "Private Role", "unlisted@example.test", + "Fake Requested App", + "requestedApps", ]) { assert.equal(output.includes(privateValue), false, `${privateValue} must not reach the public register`); } diff --git a/apps/site/scripts/seo-metadata.test.ts b/apps/site/scripts/seo-metadata.test.ts index 7a6345eae0..4bf7f0b405 100644 --- a/apps/site/scripts/seo-metadata.test.ts +++ b/apps/site/scripts/seo-metadata.test.ts @@ -88,6 +88,7 @@ test("sitemap includes the front door, nav destinations, and every real doc page assert.ok(urls.includes(SITE_ORIGIN), "front door must be in the sitemap"); assert.ok(urls.includes(`${SITE_ORIGIN}/self-host`)); assert.ok(urls.includes(`${SITE_ORIGIN}/participate`)); + assert.ok(urls.includes(`${SITE_ORIGIN}/apps`)); assert.ok(urls.includes(`${SITE_ORIGIN}/specification`), "the docs index itself must be listed exactly once"); assert.ok(urls.includes(`${SITE_ORIGIN}/specification/spec-core`)); // A normative companion specification: unlisting the maintainer documents diff --git a/apps/site/scripts/sync-connector-catalog.mjs b/apps/site/scripts/sync-connector-catalog.mjs new file mode 100644 index 0000000000..a6acdc996e --- /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 0000000000..0be96d4818 --- /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 0000000000..9e4d9f3796 --- /dev/null +++ b/apps/site/src/app/(concept)/apps/page.test.ts @@ -0,0 +1,14 @@ +// 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"; + +test("the Participate menu lists /apps after the Participate page", () => { + const participate = publicSiteNav().find((item) => item.text === "Participate"); + assert.deepEqual( + participate?.children?.map((child) => child.link), + ["/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 0000000000..3f29fc8052 --- /dev/null +++ b/apps/site/src/app/(concept)/apps/page.tsx @@ -0,0 +1,82 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Metadata } from "next"; +import Link from "next/link"; +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 { 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. +export default async function Page() { + 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 edc5eef3bb..b41beabfbc 100644 --- a/apps/site/src/app/(concept)/participate/page.tsx +++ b/apps/site/src/app/(concept)/participate/page.tsx @@ -399,6 +399,12 @@ function RegisterSection({ {" "} · Supporters are listed separately on the Principles page + + 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/app/(concept)/principles/card/[format]/route.test.ts b/apps/site/src/app/(concept)/principles/card/[format]/route.test.ts new file mode 100644 index 0000000000..73765fac02 --- /dev/null +++ b/apps/site/src/app/(concept)/principles/card/[format]/route.test.ts @@ -0,0 +1,72 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { after, before, mock, test } from "node:test"; +import { SHARE_CARD_FORMATS } from "@/lib/share-card/copy.ts"; +import { GET, generateStaticParams } from "./route.tsx"; + +const PNG_SIGNATURE = "89504e470d0a1a0a"; + +// The card downloads its fonts from Google Fonts. The tests stay offline: every +// stylesheet request gets a stub pointing at one file, and that file is the +// TrueType font next/og ships with. +const TEST_FONT = readFileSync( + createRequire(import.meta.url).resolve("next/dist/compiled/@vercel/og/Geist-Regular.ttf") +); +const TEST_FONT_URL = "https://fonts.gstatic.com/test/Geist-Regular.ttf"; + +before(() => { + const realFetch = globalThis.fetch; + // Only Google Fonts is stubbed; next/og loads its own WebAssembly through fetch too. + mock.method(globalThis, "fetch", (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === TEST_FONT_URL) { + return Promise.resolve(new Response(TEST_FONT)); + } + if (url.startsWith("https://fonts.googleapis.com/")) { + return Promise.resolve(new Response(`src: url(${TEST_FONT_URL}) format('truetype');`)); + } + return realFetch(input, init); + }); +}); + +after(() => { + mock.restoreAll(); +}); + +function request(format: string): Promise { + return GET(new Request(`https://pdpp.dev/principles/card/${format}`), { params: Promise.resolve({ format }) }); +} + +// A PNG starts with an 8-byte signature, then the IHDR chunk: length, "IHDR", +// then width and height as big-endian 32-bit integers at bytes 16 and 20. +function pngSize(png: Buffer): { height: number; signature: string; width: number } { + return { + height: png.readUInt32BE(20), + signature: png.subarray(0, 8).toString("hex"), + width: png.readUInt32BE(16), + }; +} + +test("the card routes are built for exactly the feed and story formats", () => { + assert.deepEqual(generateStaticParams(), [{ format: "feed" }, { format: "story" }]); +}); + +for (const format of ["feed", "story"] as const) { + test(`${format} returns a PNG of ${SHARE_CARD_FORMATS[format].width}x${SHARE_CARD_FORMATS[format].height}`, async () => { + const response = await request(format); + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "image/png"); + assert.deepEqual(pngSize(Buffer.from(await response.arrayBuffer())), { + ...SHARE_CARD_FORMATS[format], + signature: PNG_SIGNATURE, + }); + }); +} + +test("an unknown format is a 404", async () => { + assert.equal((await request("banner")).status, 404); +}); diff --git a/apps/site/src/app/(concept)/principles/card/[format]/route.tsx b/apps/site/src/app/(concept)/principles/card/[format]/route.tsx new file mode 100644 index 0000000000..fca68ff493 --- /dev/null +++ b/apps/site/src/app/(concept)/principles/card/[format]/route.tsx @@ -0,0 +1,21 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { isShareCardFormat, SHARE_CARD_FORMAT_NAMES } from "@/lib/share-card/copy.ts"; +import { renderShareCard } from "@/lib/share-card/render.tsx"; + +// The supporter card is the same image for everyone, so each size is built once +// at build time. Only the two known formats exist; anything else is a 404. +export const dynamicParams = false; + +export function generateStaticParams(): { format: string }[] { + return SHARE_CARD_FORMAT_NAMES.map((format) => ({ format })); +} + +export async function GET(_request: Request, context: { params: Promise<{ format: string }> }): Promise { + const { format } = await context.params; + if (!isShareCardFormat(format)) { + return new Response("Not found", { status: 404 }); + } + return await renderShareCard(format); +} diff --git a/apps/site/src/app/(concept)/principles/page.tsx b/apps/site/src/app/(concept)/principles/page.tsx index dde6056370..c0bab07888 100644 --- a/apps/site/src/app/(concept)/principles/page.tsx +++ b/apps/site/src/app/(concept)/principles/page.tsx @@ -85,7 +85,11 @@ export default async function Page({
- + {/* After a submission or a confirmation the person is done: showing an + empty form again reads as "sign again". Error states keep it for a retry. */} + {params.signed === "pending" || params.signed === "confirmed" ? null : ( + + )}
diff --git a/apps/site/src/app/api/sign/route.ts b/apps/site/src/app/api/sign/route.ts index 0f6cddca05..c4c18dfb46 100644 --- a/apps/site/src/app/api/sign/route.ts +++ b/apps/site/src/app/api/sign/route.ts @@ -59,7 +59,11 @@ export async function POST(request: NextRequest): Promise { ); } - const parsed = submissionSchema.safeParse(Object.fromEntries(form)); + const parsed = submissionSchema.safeParse({ + ...Object.fromEntries(form), + // Several inputs share this name; fromEntries keeps only the last. + requested_apps: form.getAll("requested_apps"), + }); if (!parsed.success) { // The individual field errors are deliberately not returned: they // describe a schema an attacker would otherwise get to probe, and the diff --git a/apps/site/src/components/elements/icons.tsx b/apps/site/src/components/elements/icons.tsx index 4d25b214c1..f1a69c6ad4 100644 --- a/apps/site/src/components/elements/icons.tsx +++ b/apps/site/src/components/elements/icons.tsx @@ -38,7 +38,7 @@ export function DiscordIcon({ className = "pdpp-icon-discord" }: { className?: s // the design concept (index.html .wordmark__mark). currentColor so it picks // up --pdpp-editorial-ink in the masthead and --pdpp-editorial-paper in the // footer, same as the concept's own wordmark. -const WORDMARK_PATH = +export const WORDMARK_PATH = "M60 30C65.3043 30 70.3919 32.1067 74.1426 35.8574C77.8933 39.6081 80 44.6957 80 50V110C80 115.304 77.8933 120.392 74.1426 124.143C70.3919 127.893 65.3043 130 60 130H20V160H0V30H60ZM270 130H210V160H190V30H270V130ZM345 30C350.304 30 355.392 32.1067 359.143 35.8574C362.893 39.6081 365 44.6957 365 50V110C365 115.304 362.893 120.392 359.143 124.143C355.392 127.893 350.304 130 345 130H305V160H285V30H345ZM175 130H115C109.696 130 104.608 127.893 100.857 124.143C97.1067 120.392 95 115.304 95 110V50C95 44.6957 97.1067 39.6081 100.857 35.8574C104.608 32.1067 109.696 30 115 30H155V0H175V130ZM20 50V110H60V50H20ZM115 110H155V50H115V110ZM210 110H250V50H210V110ZM305 110H345V50H305V110Z"; export function WordmarkIcon({ className = "block h-[23px] w-auto" }: { className?: string }) { diff --git a/apps/site/src/components/layout/footer.tsx b/apps/site/src/components/layout/footer.tsx index d8bf0f802e..54d935a313 100644 --- a/apps/site/src/components/layout/footer.tsx +++ b/apps/site/src/components/layout/footer.tsx @@ -139,6 +139,11 @@ export function PdppConceptFooter() { PDPP Principles +
+ + Is your app on PDPP? + +
diff --git a/apps/site/src/components/layout/masthead.tsx b/apps/site/src/components/layout/masthead.tsx index 8570d97392..6c90508208 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/components/site/requested-apps-field.tsx b/apps/site/src/components/site/requested-apps-field.tsx new file mode 100644 index 0000000000..d0e57810fd --- /dev/null +++ b/apps/site/src/components/site/requested-apps-field.tsx @@ -0,0 +1,127 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +"use client"; + +import { Plus, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Text } from "@/components/typography/text.tsx"; +import { MAX_REQUESTED_APP_LENGTH, MAX_REQUESTED_APPS } from "@/lib/signing/requested-app-limits.ts"; +import { cn } from "@/lib/utils.ts"; + +const HINT_ID = "sign-apps-hint"; + +const textActionClassName = cn( + "inline-flex w-fit cursor-pointer items-center gap-1 font-medium font-sans text-[14px] text-primary", + "hover:text-primary-emphasis hover:underline hover:underline-offset-[3px]" +); +const removeClassName = cn( + "flex size-9 shrink-0 cursor-pointer items-center justify-center text-muted-foreground", + "hover:text-foreground focus-visible:text-foreground" +); + +interface Row { + id: number; + initial?: string; +} + +function initialRows(restored: string[] | undefined): Row[] { + const values = (restored ?? []).slice(0, MAX_REQUESTED_APPS); + return values.length > 0 ? values.map((initial, id) => ({ id, initial })) : [{ id: 0 }]; +} + +/** + * One box to start, and "Add another app" for more, up to MAX_REQUESTED_APPS. + * Every box posts as `requested_apps`; the server drops blanks and repeats. + */ +export function RequestedAppsField({ + className, + fieldClassName, + restored, +}: { + className?: string; + fieldClassName: string; + restored?: string[]; +}) { + const [rows, setRows] = useState(() => initialRows(restored)); + const nextId = useRef(rows.length); + const focusId = useRef(null); + const addButton = useRef(null); + const inputs = useRef(new Map()); + + useEffect(() => { + if (focusId.current !== null) { + inputs.current.get(focusId.current)?.focus(); + focusId.current = null; + } + }); + + function addRow() { + const id = nextId.current; + nextId.current += 1; + focusId.current = id; + setRows((current) => [...current, { id }]); + } + + function removeRow(id: number) { + setRows((current) => current.filter((row) => row.id !== id)); + addButton.current?.focus(); + } + + const canAdd = rows.length < MAX_REQUESTED_APPS; + + return ( +
+ + Which apps would you like to see join PDPP? + + + Optional. We count how many supporters name each app and may tell a company its number. We never give it your + name or email. + +
    + {rows.map((row, index) => ( +
  • + { + if (element) { + inputs.current.set(row.id, element); + } else { + inputs.current.delete(row.id); + } + }} + type="text" + /> + {rows.length > 1 ? ( + + ) : null} +
  • + ))} +
+ {canAdd ? ( + + ) : ( + + You can name up to {MAX_REQUESTED_APPS} apps. + + )} +
+ ); +} diff --git a/apps/site/src/components/site/share-card-panel.test.ts b/apps/site/src/components/site/share-card-panel.test.ts new file mode 100644 index 0000000000..1cbc4434cf --- /dev/null +++ b/apps/site/src/components/site/share-card-panel.test.ts @@ -0,0 +1,80 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { linkedInShareUrl, SHARE_CARD_ALT, SHARE_CARD_CAPTION, xIntentUrl } from "@/lib/share-card/copy.ts"; +import { ShareCardPanel } from "./share-card-panel.tsx"; +import { PdppSigningStatus } from "./signing-status.tsx"; + +const PANEL_SLOT = /data-slot="pdpp-share-card-panel"/; +const OLD_SHARE_BUTTON = />Share { + assert.match(statusHtml("confirmed"), PANEL_SLOT); +}); + +test("the panel is hidden on every outcome except confirmed", () => { + for (const signed of ["pending", "incomplete", "ratelimited", "unavailable", "closed", "error", "invalid", "other"]) { + assert.doesNotMatch(statusHtml(signed), PANEL_SLOT, signed); + } +}); + +test("the panel shows the feed image, the caption and the buttons in order", () => { + const html = renderToStaticMarkup(createElement(ShareCardPanel)); + assert.ok(html.includes(`alt="${SHARE_CARD_ALT}"`)); + assert.ok(html.includes('src="/principles/card/feed"')); + assert.ok(html.includes('href="/principles/card/feed"')); + assert.ok(html.includes('href="/principles/card/story"')); + assert.ok(html.includes('download="pdpp-supporter-card.png"')); + assert.ok(html.includes('download="pdpp-supporter-card-story.png"')); + const labels = ["Download image", "Download for Stories", "Copy image", "Post on X", "Post on LinkedIn"]; + const positions = labels.map((label) => html.indexOf(`>${label}<`)); + assert.ok( + positions.every((position) => position > -1), + JSON.stringify(positions) + ); + assert.deepEqual( + positions, + [...positions].sort((a, b) => a - b) + ); + assert.ok(html.includes('aria-label="Copy caption"')); +}); + +test("the caption is shown verbatim", () => { + const expected = + "I just signed the pledge for global rules on where my data goes and who trains AI on it. Sign it too: https://pdpp.dev/principles #PDPP"; + assert.equal(SHARE_CARD_CAPTION, expected); + assert.ok(renderToStaticMarkup(createElement(ShareCardPanel)).includes(`>${expected}<`)); +}); + +test("the X link carries the caption without the link, and the link in its own field", () => { + const url = new URL(xIntentUrl()); + assert.equal(`${url.origin}${url.pathname}`, "https://x.com/intent/post"); + assert.equal( + url.searchParams.get("text"), + "#PDPP I just signed the pledge for global rules on where my data goes and who trains AI on it. Sign it too:" + ); + assert.equal(url.searchParams.get("url"), "https://pdpp.dev/principles"); +}); + +test("the LinkedIn link carries only the encoded page link", () => { + assert.equal( + linkedInShareUrl(), + "https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fpdpp.dev%2Fprinciples" + ); +}); + +test("Share… is not rendered without a browser, and the old link-only Share is gone", () => { + const html = renderToStaticMarkup(createElement(ShareCardPanel)); + assert.ok(!html.includes("Share…")); + assert.doesNotMatch(html, OLD_SHARE_BUTTON); + assert.doesNotMatch(html, OLD_FEED_DOWNLOAD); +}); diff --git a/apps/site/src/components/site/share-card-panel.tsx b/apps/site/src/components/site/share-card-panel.tsx new file mode 100644 index 0000000000..bc80f19752 --- /dev/null +++ b/apps/site/src/components/site/share-card-panel.tsx @@ -0,0 +1,206 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +"use client"; + +import { Check, Copy } from "lucide-react"; +import { useState, useSyncExternalStore } from "react"; +import { Text } from "@/components/typography/text.tsx"; +import { + linkedInShareUrl, + SHARE_CARD_ALT, + SHARE_CARD_CAPTION, + SHARE_CARD_FORMATS, + type ShareCardFormat, + shareCardPath, + xIntentUrl, +} from "@/lib/share-card/copy.ts"; +import { useCopyToClipboard } from "@/lib/use-copy-to-clipboard.ts"; +import { cn } from "@/lib/utils.ts"; + +const buttonClassName = cn( + "w-fit cursor-pointer border border-primary px-5 py-2.5 font-sans text-[15px]", + "bg-primary text-on-primary-emphasis hover:border-primary-emphasis hover:bg-primary-emphasis" +); +const quietButtonClassName = cn( + "w-fit cursor-pointer border border-border px-5 py-2.5 font-sans text-[15px]", + "text-foreground hover:border-primary hover:text-primary" +); + +// A text control that sits at the end of the caption, so it reads as part of the line and not as a second button row. +const inlineCopyClassName = cn( + // Plain inline (not flex), so "Copy" shares the caption's baseline; only the icon is nudged down. + "ml-2 inline cursor-pointer whitespace-nowrap font-medium font-sans text-[13px]", + "text-primary hover:text-primary-emphasis hover:underline hover:underline-offset-[3px]" +); + +const CAPTION_COPY_LABELS = { copied: "Copied", failed: "Copy failed", idle: "Copy" } as const; +const CAPTION_COPY_NAMES = { + copied: "Caption copied", + failed: "Copy failed. Select the caption and copy it by hand.", + idle: "Copy caption", +} as const; + +const IMAGE_FILE_NAME = "pdpp-supporter-card.png"; +const COPY_IMAGE_FAILED = "Your browser cannot copy images. Download it instead."; + +function downloadName(format: ShareCardFormat): string { + return format === "feed" ? IMAGE_FILE_NAME : "pdpp-supporter-card-story.png"; +} + +function isAbort(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +/** Saves the card through a temporary link, so a post button can download and open a tab in one click. */ +function downloadCard(format: ShareCardFormat): void { + const anchor = document.createElement("a"); + anchor.href = shareCardPath(format); + anchor.download = downloadName(format); + anchor.click(); +} + +function openInNewTab(url: string): void { + window.open(url, "_blank", "noopener"); +} + +async function fetchCardBlob(format: ShareCardFormat): Promise { + const response = await fetch(shareCardPath(format)); + if (!response.ok) { + throw new Error(`Card request failed: ${response.status}`); + } + return await response.blob(); +} + +async function copyCaption(): Promise { + try { + await navigator.clipboard.writeText(SHARE_CARD_CAPTION); + return true; + } catch { + return false; + } +} + +/** A file with the card's name and type is enough to ask the browser whether it can share images. */ +function browserCanShareImages(): boolean { + if (typeof navigator.canShare !== "function") { + return false; + } + const probe = new File([], IMAGE_FILE_NAME, { type: "image/png" }); + return navigator.canShare({ files: [probe] }); +} + +// What the browser can share never changes while the page is open. +function subscribeToNothing(): () => void { + return () => undefined; +} + +function serverCannotShare(): boolean { + return false; +} + +/** + * Shown after a confirmed signing. The card is one image for every supporter + * and carries no personal data, so nothing here is recorded or sent anywhere. + */ +export function ShareCardPanel() { + const [status, setStatus] = useState(""); + // The server cannot know, so it renders no Share button; the browser adds it while hydrating. + const canShareImages = useSyncExternalStore(subscribeToNothing, browserCanShareImages, serverCannotShare); + const { copy: copyCaptionText, status: captionCopyStatus } = useCopyToClipboard(); + + async function onCopyImage() { + try { + // Hand the browser a pending blob so the copy keeps the click's permission (Safari needs this). + await navigator.clipboard.write([new ClipboardItem({ "image/png": fetchCardBlob("feed") })]); + setStatus("Image copied."); + } catch { + setStatus(COPY_IMAGE_FAILED); + } + } + + function onPostOnX() { + // Open the tab first: a popup blocker only allows it inside the click itself. + openInNewTab(xIntentUrl()); + downloadCard("feed"); + setStatus("Image downloaded. Attach it to your post on X."); + } + + async function onPostOnLinkedIn() { + openInNewTab(linkedInShareUrl()); + downloadCard("feed"); + const copied = await copyCaption(); + setStatus( + copied + ? "Image downloaded and caption copied. Paste the caption and attach the image on LinkedIn." + : "Image downloaded. Copy the caption above and attach the image on LinkedIn." + ); + } + + async function onShare() { + try { + const file = new File([await fetchCardBlob("feed")], IMAGE_FILE_NAME, { type: "image/png" }); + await navigator.share({ files: [file], text: SHARE_CARD_CAPTION }); + } catch (error) { + if (!isAbort(error)) { + setStatus("We could not share the image. Download it instead."); + } + } + } + + return ( +
+ + Share your support + + {SHARE_CARD_ALT} + + {SHARE_CARD_CAPTION} + + +
+ + Download image + + + Download for Stories + + + + + {canShareImages ? ( + + ) : null} +
+ + {status} + +
+ ); +} diff --git a/apps/site/src/components/site/signing-form.tsx b/apps/site/src/components/site/signing-form.tsx index 5ea23f8f90..6b0ce5c9f6 100644 --- a/apps/site/src/components/site/signing-form.tsx +++ b/apps/site/src/components/site/signing-form.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { type ComponentProps, useState } from "react"; +import { RequestedAppsField } from "@/components/site/requested-apps-field.tsx"; import { Text } from "@/components/typography/text.tsx"; import { COUNTRIES } from "@/lib/countries.ts"; import type { RestoredSigningForm } from "@/lib/signing/form-restoration.ts"; @@ -47,11 +48,32 @@ const fieldClassName = cn( const choiceTextClassName = "font-sans text-[14px] leading-[1.6]"; -function Field({ children, htmlFor, label }: { children: React.ReactNode; htmlFor: string; label: string }) { +// The visible asterisk is for sighted readers. Screen readers already announce +// "required" from the input's own attribute, so the mark is hidden from them. +function RequiredMark() { + return ( + + {" *"} + + ); +} + +function Field({ + children, + htmlFor, + label, + required = false, +}: { + children: React.ReactNode; + htmlFor: string; + label: string; + required?: boolean; +}) { return (
{label} + {required && } {children}
@@ -82,12 +104,14 @@ function Consent({ checked, id, name, + required = false, children, }: { checked?: boolean; children: React.ReactNode; id: string; name: string; + required?: boolean; }) { return ( ); @@ -165,8 +191,12 @@ export function PdppSigningForm({ restoredForm }: { restoredForm?: RestoredSigni ))} + + Required fields are marked with an asterisk (*). + +
- + - + {isOrganisation && ( - + )} - + )} + +
@@ -252,21 +288,21 @@ export function PdppSigningForm({ restoredForm }: { restoredForm?: RestoredSigni
- + I support the PDPP Principles v1.0. - + List me on the public register. Email me about new versions and comment periods. {isOrganisation ? ( - + I am authorised to sign on behalf of this organisation. ) : ( - + I am 18 or over. )} diff --git a/apps/site/src/components/site/signing-status.tsx b/apps/site/src/components/site/signing-status.tsx index 9cfc09a08d..2e9399bf88 100644 --- a/apps/site/src/components/site/signing-status.tsx +++ b/apps/site/src/components/site/signing-status.tsx @@ -1,6 +1,7 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 +import { ShareCardPanel } from "@/components/site/share-card-panel.tsx"; import { Text } from "@/components/typography/text.tsx"; import { signedStatusMessage } from "@/lib/signing/status-messages.ts"; @@ -17,10 +18,14 @@ export function PdppSigningStatus({ signed, withdraw }: { signed?: string; withd return null; } return ( -
- - {message} - -
+ <> +
+ + {message} + +
+ {/* Only a confirmed signature earns the card. */} + {signed === "confirmed" ? : null} + ); } diff --git a/apps/site/src/components/site/supporters.tsx b/apps/site/src/components/site/supporters.tsx index d6ee7d836a..92ac4de551 100644 --- a/apps/site/src/components/site/supporters.tsx +++ b/apps/site/src/components/site/supporters.tsx @@ -14,7 +14,7 @@ import { cn } from "@/lib/utils.ts"; // below is what a reader sees until the first real signature lands. // // SHAPE: the table receives only the five public fields. The private store -// holds email, the signatory's name and role, and the consent flags; none of +// holds email, the signatory's name, any affiliation, and the consent flags; none of // them have a route to this component, because the publish script never writes // them into the public register. See docs/registers.md. diff --git a/apps/site/src/data/connector-catalog.json b/apps/site/src/data/connector-catalog.json new file mode 100644 index 0000000000..78479cb396 --- /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 0000000000..1ecfa01acf --- /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 0000000000..2c4107b3c5 --- /dev/null +++ b/apps/site/src/lib/app-directory-sources.ts @@ -0,0 +1,91 @@ +// 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. */ +// `siteRoot` defaults to the working directory, which is apps/site when Next +// builds or serves the site. Tests pass it because they may run from elsewhere. +export async function readBundledConnectorSnapshot(siteRoot = process.cwd()): Promise { + const file = path.join(siteRoot, ...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(siteRoot = process.cwd()): Promise { + const file = path.join(siteRoot, ...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 0000000000..84395998b4 --- /dev/null +++ b/apps/site/src/lib/app-directory.test.ts @@ -0,0 +1,249 @@ +// 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 { fileURLToPath } from "node:url"; +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 siteRoot = fileURLToPath(new URL("../..", import.meta.url)); + const snapshot = await readBundledConnectorSnapshot(siteRoot); + const register = await readRegisterEntries(siteRoot); + 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 0000000000..00f3d96b03 --- /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 0000000000..043cf03101 --- /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 = new Set(); + for (const stream of streams) { + const label = stream?.display?.label; + if (typeof label === "string" && label.trim()) { + labels.add(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 a48978e9ff..29d48cb197 100644 --- a/apps/site/src/lib/public-site-nav.ts +++ b/apps/site/src/lib/public-site-nav.ts @@ -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. +// // 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,15 @@ export function publicSiteNav(): readonly PublicSiteNavLink[] { specificationChildren.push({ link: "/review", text: "Review, extended" }); } + const participateChildren: PublicSiteNavChild[] = [ + { link: "/participate", text: "How to get involved" }, + { 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/share-card/copy.ts b/apps/site/src/lib/share-card/copy.ts new file mode 100644 index 0000000000..b9c56a3142 --- /dev/null +++ b/apps/site/src/lib/share-card/copy.ts @@ -0,0 +1,58 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +// The supporter card's words and sizes, declared once. The copy is supplied +// verbatim by the brand owner: change it only on their word. The card carries +// no personal data, so the same image goes to every supporter. + +import { SITE_ORIGIN } from "../site-facts.ts"; + +export const SHARE_CARD_COPY = { + button: "Sign the pledge → pdpp.dev", + hashtag: "#PDPP", + statement: "My data, my rules.", + subtext: "I just signed the pledge for global rules on where my data goes and who trains AI on it.", +} as const; + +export const SHARE_CARD_FORMATS = { + feed: { height: 1350, width: 1080 }, + story: { height: 1920, width: 1080 }, +} as const; + +export type ShareCardFormat = keyof typeof SHARE_CARD_FORMATS; + +export const SHARE_CARD_FORMAT_NAMES = Object.keys(SHARE_CARD_FORMATS) as ShareCardFormat[]; + +export function isShareCardFormat(value: string): value is ShareCardFormat { + return Object.hasOwn(SHARE_CARD_FORMATS, value); +} + +export function shareCardPath(format: ShareCardFormat): string { + return `/principles/card/${format}`; +} + +/** The page every post links to. */ +export const SHARE_CARD_LINK = `${SITE_ORIGIN}/principles`; + +/** The alt text for the card image: the card's words, in reading order. */ +export const SHARE_CARD_ALT = `${SHARE_CARD_COPY.statement} ${SHARE_CARD_COPY.subtext} ${SHARE_CARD_COPY.button} ${SHARE_CARD_COPY.hashtag}`; + +const SHARE_CARD_CAPTION_LEAD = + "I just signed the pledge for global rules on where my data goes and who trains AI on it. Sign it too:"; + +/** The post text a supporter can copy, link included. */ +export const SHARE_CARD_CAPTION = `${SHARE_CARD_CAPTION_LEAD} ${SHARE_CARD_LINK} ${SHARE_CARD_COPY.hashtag}`; + +/** X adds the link after the text, so the text ends on "Sign it too:" and the hashtag leads. */ +export function xIntentUrl(): string { + const params = new URLSearchParams({ + text: `${SHARE_CARD_COPY.hashtag} ${SHARE_CARD_CAPTION_LEAD}`, + url: SHARE_CARD_LINK, + }); + return `https://x.com/intent/post?${params}`; +} + +/** LinkedIn's share URL takes a link and no text. */ +export function linkedInShareUrl(): string { + return `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(SHARE_CARD_LINK)}`; +} diff --git a/apps/site/src/lib/share-card/fonts.test.ts b/apps/site/src/lib/share-card/fonts.test.ts new file mode 100644 index 0000000000..ad3da3cd35 --- /dev/null +++ b/apps/site/src/lib/share-card/fonts.test.ts @@ -0,0 +1,73 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { fontCssUrl, loadCardFonts } from "./fonts.ts"; + +const NO_TRUETYPE = /no TrueType file for Inter normal 400/; +const SERVER_ERROR = /returned 500/; + +const CSS = (file: string) => `@font-face { src: url(https://fonts.gstatic.com/${file}) format('truetype'); }`; + +function fakeFetch(handler: (url: string) => Response | Promise): { calls: string[]; fetch: typeof fetch } { + const calls: string[] = []; + const fetchFn = ((input: RequestInfo | URL) => { + const url = String(input); + calls.push(url); + return Promise.resolve(handler(url)); + }) as typeof fetch; + return { calls, fetch: fetchFn }; +} + +test("the stylesheet URL asks for the one weight and style the card uses", () => { + assert.equal( + fontCssUrl({ name: "Inter", style: "normal", weight: 700 }), + "https://fonts.googleapis.com/css2?family=Inter:wght@700" + ); + assert.equal( + fontCssUrl({ name: "Source Serif 4", style: "italic", weight: 400 }), + "https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,wght@1,400" + ); +}); + +test("each font is read from the TrueType file its stylesheet names", async () => { + const { fetch } = fakeFetch((url) => + url.startsWith("https://fonts.googleapis.com/") + ? new Response(CSS(encodeURIComponent(url))) + : new Response(new TextEncoder().encode(url)) + ); + const fonts = await loadCardFonts(fetch); + assert.deepEqual( + fonts.map(({ name, style, weight }) => `${name} ${style} ${weight}`), + ["Inter normal 400", "Inter normal 700", "Source Serif 4 normal 400", "Source Serif 4 italic 400"] + ); + for (const font of fonts) { + const fileUrl = new TextDecoder().decode(font.data); + assert.ok(fileUrl.includes(encodeURIComponent(fontCssUrl(font))), `${font.name} got another font's file`); + } +}); + +test("a failed request is retried before the build gives up", async () => { + let failures = 2; + const { calls, fetch } = fakeFetch((url) => { + if (url.includes("family=Inter:wght@400") && failures > 0) { + failures -= 1; + return new Response("busy", { status: 503 }); + } + return url.startsWith("https://fonts.googleapis.com/") ? new Response(CSS("f.ttf")) : new Response("ttf"); + }); + await loadCardFonts(fetch); + assert.equal(calls.filter((url) => url.includes("family=Inter:wght@400")).length, 3); +}); + +test("a stylesheet without a TrueType file fails loudly", async () => { + const { fetch } = fakeFetch(() => new Response("src: url(https://fonts.gstatic.com/f.woff2) format('woff2');")); + await assert.rejects(loadCardFonts(fetch), NO_TRUETYPE); +}); + +test("Google Fonts being down fails the load after three tries", async () => { + const { calls, fetch } = fakeFetch(() => new Response("down", { status: 500 })); + await assert.rejects(loadCardFonts(fetch), SERVER_ERROR); + assert.equal(calls.filter((url) => url.includes("family=Inter:wght@400")).length, 3); +}); diff --git a/apps/site/src/lib/share-card/fonts.ts b/apps/site/src/lib/share-card/fonts.ts new file mode 100644 index 0000000000..eba61a8e9e --- /dev/null +++ b/apps/site/src/lib/share-card/fonts.ts @@ -0,0 +1,65 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +// The card is set in the site's own typefaces, Inter and Source Serif 4. The +// site gets them from Google Fonts through next/font, which serves only woff2; +// next/og needs TrueType, so the card asks the same Google Fonts API for +// TrueType files when it is built. The site build already depends on Google +// Fonts being reachable, so this adds no new way for a build to fail. + +const CSS_API = "https://fonts.googleapis.com/css2"; +const TRUETYPE_URL = /src: url\((https:\/\/[^)]+)\) format\('truetype'\)/; +const ATTEMPTS = 3; + +export interface CardFont { + data: ArrayBuffer; + name: string; + style: "italic" | "normal"; + weight: 400 | 700; +} + +type FontSpec = Omit; + +const CARD_FONTS: readonly FontSpec[] = [ + { name: "Inter", style: "normal", weight: 400 }, + { name: "Inter", style: "normal", weight: 700 }, + { name: "Source Serif 4", style: "normal", weight: 400 }, + { name: "Source Serif 4", style: "italic", weight: 400 }, +]; + +export function fontCssUrl({ name, style, weight }: FontSpec): string { + const axes = style === "italic" ? `ital,wght@1,${weight}` : `wght@${weight}`; + return `${CSS_API}?family=${name.replaceAll(" ", "+")}:${axes}`; +} + +async function fetchOk(fetchFn: typeof fetch, url: string): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) { + try { + // biome-ignore lint/performance/noAwaitInLoops: a retry has to wait for the attempt before it. + const response = await fetchFn(url, { cache: "force-cache" }); + if (response.ok) { + return response; + } + lastError = new Error(`${url} returned ${response.status}`); + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +async function loadFont(fetchFn: typeof fetch, spec: FontSpec): Promise { + const css = await (await fetchOk(fetchFn, fontCssUrl(spec))).text(); + const fileUrl = TRUETYPE_URL.exec(css)?.[1]; + if (!fileUrl) { + throw new Error(`Google Fonts returned no TrueType file for ${spec.name} ${spec.style} ${spec.weight}`); + } + const data = await (await fetchOk(fetchFn, fileUrl)).arrayBuffer(); + return { ...spec, data }; +} + +/** Downloads the four card fonts. Rejects if any of them cannot be loaded. */ +export function loadCardFonts(fetchFn: typeof fetch = fetch): Promise { + return Promise.all(CARD_FONTS.map((spec) => loadFont(fetchFn, spec))); +} diff --git a/apps/site/src/lib/share-card/render.test.ts b/apps/site/src/lib/share-card/render.test.ts new file mode 100644 index 0000000000..696ebc10c7 --- /dev/null +++ b/apps/site/src/lib/share-card/render.test.ts @@ -0,0 +1,35 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { WORDMARK_PATH } from "../../components/elements/icons.tsx"; +import { SHARE_CARD_ALT, SHARE_CARD_COPY, SHARE_CARD_LINK } from "./copy.ts"; +import { wordmarkSvg } from "./render.tsx"; + +const OKLCH = /oklch\(/; + +test("the card copy is the brand owner's, verbatim", () => { + assert.equal(SHARE_CARD_COPY.statement, "My data, my rules."); + assert.equal( + SHARE_CARD_COPY.subtext, + "I just signed the pledge for global rules on where my data goes and who trains AI on it." + ); + assert.equal(SHARE_CARD_COPY.button, "Sign the pledge → pdpp.dev"); + assert.equal(SHARE_CARD_COPY.hashtag, "#PDPP"); + assert.equal(SHARE_CARD_LINK, "https://pdpp.dev/principles"); +}); + +test("the card alt text carries every word on the card", () => { + for (const text of Object.values(SHARE_CARD_COPY)) { + assert.ok(SHARE_CARD_ALT.includes(text), text); + } +}); + +test("the wordmark is the header's path and viewBox, unmodified, in the primary colour", () => { + const svg = wordmarkSvg("#295171"); + assert.ok(svg.includes(` d="${WORDMARK_PATH}"`)); + assert.ok(svg.includes('viewBox="0 0 365 160"')); + assert.ok(svg.includes('fill="#295171"')); + assert.doesNotMatch(svg, OKLCH); +}); diff --git a/apps/site/src/lib/share-card/render.tsx b/apps/site/src/lib/share-card/render.tsx new file mode 100644 index 0000000000..d06157808c --- /dev/null +++ b/apps/site/src/lib/share-card/render.tsx @@ -0,0 +1,158 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { ImageResponse } from "next/og"; +import type { ReactElement } from "react"; +import { WORDMARK_PATH } from "../../components/elements/icons.tsx"; +import { SHARE_CARD_COPY, SHARE_CARD_FORMATS, type ShareCardFormat } from "./copy.ts"; +import { type CardFont, loadCardFonts } from "./fonts.ts"; + +// Colours of the concept surface (the live site), as hex. Satori cannot parse oklch(), +// so each is the sRGB value of a light-scheme token in +// src/styles/surfaces/concept/tokens/primitive.css. Re-derive them if a token moves. +const PAPER = "#f4f5f6"; // --background = --pdpp-editorial-paper, oklch(0.97 0.001 245) +const INK = "#181b1d"; // --foreground = --pdpp-editorial-ink, oklch(0.22 0.006 245) +const MUTED = "#4e5052"; // --muted-foreground = --pdpp-editorial-ink-soft +const PRIMARY = "#295171"; // --primary = --pdpp-editorial-teal, oklch(0.42 0.07 245); also the header wordmark colour + +/** + * The header wordmark as the site draws it: the same path and viewBox as + * WordmarkIcon, filled with the site's primary colour (the header sets `text-primary`). + */ +export function wordmarkSvg(fill: string): string { + return ``; +} + +// Downloaded once per process, on first use. A failed download is not kept, +// so the next render tries again. +let fonts: Promise | undefined; + +function cardFonts(): Promise { + fonts ??= loadCardFonts().catch((error: unknown) => { + fonts = undefined; + throw error; + }); + return fonts; +} +const wordmarkDataUri = `data:image/svg+xml;base64,${Buffer.from(wordmarkSvg(PRIMARY)).toString("base64")}`; + +/** Per-format measurements. The design is the same; only the room around it changes. */ +interface Layout { + /** Space above and below the content; Stories keeps clear of the app's own header and reply bar. */ + padBottom: number; + padTop: number; + statementSize: number; +} + +const LAYOUTS: Record = { + feed: { padBottom: 80, padTop: 80, statementSize: 168 }, + story: { padBottom: 280, padTop: 250, statementSize: 184 }, +}; + +interface DesignProps { + layout: Layout; + wordmark: string; +} + +// The wordmark's box is 365 x 160 (see WordmarkIcon). +const WORDMARK_HEIGHT = 56; +const WORDMARK_WIDTH = Math.round((WORDMARK_HEIGHT * 365) / 160); + +/** + * The card's designs. Adding another is a new entry here and a new value of + * `ShareCardVariant`; the route, sizes, fonts and copy do not change. + * + * It follows the concept surface: flat paper, ink type, a serif statement and + * sans controls and square corners. + */ +function EditorialDesign({ layout, wordmark }: DesignProps): ReactElement { + const side = 88; + return ( +
+
+ +
+ +
+
+
My data,
+
my rules.
+
+
+ {SHARE_CARD_COPY.subtext} +
+
+ +
+
+ {SHARE_CARD_COPY.button} +
+
+ {SHARE_CARD_COPY.hashtag} +
+
+
+ ); +} + +const DESIGNS = { editorial: EditorialDesign } as const; + +export type ShareCardVariant = keyof typeof DESIGNS; +export const DEFAULT_SHARE_CARD_VARIANT: ShareCardVariant = "editorial"; + +export async function renderShareCard( + format: ShareCardFormat, + variant: ShareCardVariant = DEFAULT_SHARE_CARD_VARIANT +): Promise { + const loaded = await cardFonts(); + const Design = DESIGNS[variant]; + return new ImageResponse(, { + ...SHARE_CARD_FORMATS[format], + fonts: loaded, + }); +} diff --git a/apps/site/src/lib/signing/confirmation.test.ts b/apps/site/src/lib/signing/confirmation.test.ts index 4fdcb256ee..e548d59e60 100644 --- a/apps/site/src/lib/signing/confirmation.test.ts +++ b/apps/site/src/lib/signing/confirmation.test.ts @@ -19,7 +19,9 @@ const ESCAPED_TOKEN = "valid-token&"<>'"; const RAW_ATTRIBUTE_BREAKOUT = /value="valid-token&"/; const UNSAFE_TOKEN = "valid-token&\"<>'"; -async function runScenario(mode: "escaped" | "get" | "success" | "concurrent" | "failure" | "mismatch" | "invalid") { +async function runScenario( + mode: "escaped" | "get" | "success" | "concurrent" | "failure" | "mismatch" | "legacy" | "invalid" +) { const program = ` const mode = ${JSON.stringify(mode)}; const { NextRequest } = await import("next/server"); @@ -32,10 +34,17 @@ async function runScenario(mode: "escaped" | "get" | "success" | "concurrent" | const submission = { affiliation: "PDP-Connect", consent_age: true, consent_principles: true, consent_register: true, consent_updates: false, country: "United States", email: "private@example.test", - name: "Private Display Name", principles_version: "v1.0", signatory_kind: "individual", + name: "Private Display Name", principles_version: "v1.0", requested_apps: ["Fake App"], signatory_kind: "individual", }; const state = { deleteCalls: 0, pending: submission, readCalls: 0, records: [], writeCalls: 0, writeFailure: mode === "failure" }; if (mode === "mismatch") state.records.push({ ...buildRecord(id, submission), email: "other@example.test" }); + if (mode === "legacy") { + // Persisted by the previous deployment: no requestedApps field. The pending entry survived. + const legacySubmission = { ...submission, requested_apps: undefined }; + const { requestedApps: _omitted, ...legacy } = buildRecord(id, legacySubmission); + state.pending = legacySubmission; + state.records.push(legacy); + } const handlers = createConfirmationHandlers({ buildRecord, deletePending: async () => { state.deleteCalls += 1; state.pending = null; }, @@ -68,7 +77,7 @@ async function runScenario(mode: "escaped" | "get" | "success" | "concurrent" | pendingAfterFailure = state.pending !== null; state.writeFailure = false; responses = [failed, await handlers.POST(post())]; - } else if (mode === "mismatch") responses = [await handlers.POST(post())]; + } else if (mode === "mismatch" || mode === "legacy") responses = [await handlers.POST(post())]; else responses = [await handlers.POST(post("invalid-token"))]; process.stdout.write(JSON.stringify({ deleteCalls: state.deleteCalls, @@ -165,6 +174,15 @@ test("a conflicting existing record is not accepted as a successful confirmation assert.equal(result.deleteCalls, 0); }); +test("a retry after the requestedApps deploy accepts the record the previous deploy persisted", async () => { + const result = await runScenario("legacy"); + + assert.match(result.responses[0]?.location ?? "", CONFIRMED_LOCATION); + assert.equal(result.records, 1); + assert.equal(result.pending, false); + assert.equal(result.deleteCalls, 1); +}); + test("an invalid confirmation POST redirects to signed=invalid without effects", async () => { const result = await runScenario("invalid"); diff --git a/apps/site/src/lib/signing/form-restoration.ts b/apps/site/src/lib/signing/form-restoration.ts index 2b584f0515..b75f6a878f 100644 --- a/apps/site/src/lib/signing/form-restoration.ts +++ b/apps/site/src/lib/signing/form-restoration.ts @@ -1,6 +1,8 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 +import { MAX_REQUESTED_APPS } from "./requested-app-limits.ts"; + export interface RestoredSigningForm { affiliation?: string; consent_age: boolean; @@ -12,6 +14,7 @@ export interface RestoredSigningForm { name?: string; organisation?: string; organisation_type?: string; + requested_apps: string[]; signatory_kind: "individual" | "organisation"; } @@ -27,6 +30,14 @@ function text(form: FormData, name: string): string | undefined { return value.slice(0, MAX_FIELD_LENGTH); } +function textList(form: FormData, name: string): string[] { + return form + .getAll(name) + .filter((value): value is string => typeof value === "string") + .slice(0, MAX_REQUESTED_APPS) + .map((value) => value.slice(0, MAX_FIELD_LENGTH)); +} + /** Returns form values that may be shown back to the person who submitted them. Email is never included. */ export function formValuesForRetry(form: FormData): RestoredSigningForm { return { @@ -40,6 +51,7 @@ export function formValuesForRetry(form: FormData): RestoredSigningForm { name: text(form, "name"), organisation: text(form, "organisation"), organisation_type: text(form, "organisation_type"), + requested_apps: textList(form, "requested_apps"), signatory_kind: form.get("signatory_kind") === "organisation" ? "organisation" : "individual", }; } @@ -76,6 +88,9 @@ export function readRestoredForm(value: string | undefined): RestoredSigningForm name: typeof form.name === "string" ? form.name : undefined, organisation: typeof form.organisation === "string" ? form.organisation : undefined, organisation_type: typeof form.organisation_type === "string" ? form.organisation_type : undefined, + requested_apps: Array.isArray(form.requested_apps) + ? form.requested_apps.filter((app): app is string => typeof app === "string").slice(0, MAX_REQUESTED_APPS) + : [], signatory_kind: form.signatory_kind, }; } catch { diff --git a/apps/site/src/lib/signing/index.ts b/apps/site/src/lib/signing/index.ts index 7625fcc0ba..d56294c9ac 100644 --- a/apps/site/src/lib/signing/index.ts +++ b/apps/site/src/lib/signing/index.ts @@ -5,6 +5,7 @@ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; import "server-only"; import { z } from "zod"; import { COUNTRIES } from "@/lib/countries.ts"; +import { MAX_REQUESTED_APP_LENGTH, MAX_REQUESTED_APPS } from "./requested-app-limits.ts"; // The interim Supporter signing system, server side. // @@ -54,11 +55,44 @@ const personName = z.string().trim().min(1).max(120); // out shapes that cannot be an address at all. const email = z.string().trim().toLowerCase().min(3).max(254).email(); +// Apps a signer would like to see join PDPP. Optional, at most ten, and only +// ever counted: nothing here reaches the public register. Names are cleaned +// before the limits are checked, so a blank box or a repeat does not use up a +// slot. Too many, or one that is too long, fails the whole submission like any +// other bad field. +const INNER_WHITESPACE = /\s+/g; + +function cleanRequestedApps(values: string[]): string[] { + const seen = new Set(); + const cleaned: string[] = []; + for (const value of values) { + const app = value.trim().replace(INNER_WHITESPACE, " "); + const key = app.toLowerCase(); + if (app && !seen.has(key)) { + seen.add(key); + cleaned.push(app); + } + } + return cleaned; +} + +const requestedApps = z + .array(z.string()) + .default([]) + .transform(cleanRequestedApps) + .pipe(z.array(z.string().max(MAX_REQUESTED_APP_LENGTH)).max(MAX_REQUESTED_APPS)); + const ORGANISATION_TYPES = ["Company", "Platform", "Research institute", "Civil society", "Public body"] as const; // A checkbox that was ticked arrives as "on"; an unticked one is absent. const consented = z.literal("on").transform(() => true); -const optionalConsent = z.union([z.literal("on"), z.undefined()]).transform((value) => value === "on"); +// `.optional()` is what lets the key be missing: in zod 4 a union that merely +// includes z.undefined() still requires the key, so an unticked box failed the +// whole submission. +const optionalConsent = z + .literal("on") + .optional() + .transform((value) => value === "on"); const individualSubmission = z.object({ signatory_kind: z.literal("individual"), @@ -74,6 +108,7 @@ const individualSubmission = z.object({ consent_register: consented, consent_age: consented, consent_updates: optionalConsent, + requested_apps: requestedApps, }); const organisationSubmission = z.object({ @@ -88,6 +123,7 @@ const organisationSubmission = z.object({ consent_register: consented, consent_authority: consented, consent_updates: optionalConsent, + requested_apps: requestedApps, }); export const submissionSchema = z.discriminatedUnion("signatory_kind", [individualSubmission, organisationSubmission]); @@ -246,6 +282,8 @@ export function newSubmissionId(): string { * Everything a signatory gave, plus what was computed at the moment they * consented. The public register is derived from this by the publish script, * which copies only five of these fields; nothing else has a route out. + * `requestedApps` is counted by `scripts/count-requested-apps.mjs`, which prints + * totals per app and never a name, email or id. */ export interface SignatoryRecord { confirmedAt: string; @@ -262,6 +300,7 @@ export interface SignatoryRecord { organisation: string | null; principlesVersion: string; publicName: string; + requestedApps: string[]; signatoryName: string | null; signatoryRole: string | null; type: string; @@ -273,6 +312,8 @@ export function buildRecord(id: string, submission: Submission, confirmedAt = ne id, displayName: submission.name, publicName: computePublicName(submission), + // A pending record written before this field existed has no value. + requestedApps: submission.requested_apps ?? [], organisation: isOrganisation ? submission.organisation : (submission.affiliation ?? null), signatoryName: isOrganisation ? submission.name : null, signatoryRole: null, @@ -298,9 +339,19 @@ export function recordPath(record: SignatoryRecord): string { /** * Conflicting writes may have different confirmation timestamps. All other * fields are the immutable statement the signatory confirmed. + * + * A record persisted before `requestedApps` existed has no such field, while a + * retry built after that deploy has `requestedApps: []`. Both mean "no apps", + * so a missing field is read as [] on both sides and compared in one key order. */ -export function hasSameImmutableFields(expected: SignatoryRecord, actual: SignatoryRecord): boolean { - const { confirmedAt: _expectedConfirmedAt, ...expectedFields } = expected; - const { confirmedAt: _actualConfirmedAt, ...actualFields } = actual; - return JSON.stringify(expectedFields) === JSON.stringify(actualFields); +export function hasSameImmutableFields( + expected: Omit & { requestedApps?: string[] }, + actual: Omit & { requestedApps?: string[] } +): boolean { + return JSON.stringify(comparableFields(expected)) === JSON.stringify(comparableFields(actual)); +} + +function comparableFields(record: Omit & { requestedApps?: string[] }) { + const { confirmedAt: _confirmedAt, requestedApps: apps, ...fields } = record; + return { ...fields, requestedApps: apps ?? [] }; } diff --git a/apps/site/src/lib/signing/optional-consent.test.ts b/apps/site/src/lib/signing/optional-consent.test.ts new file mode 100644 index 0000000000..370f138a28 --- /dev/null +++ b/apps/site/src/lib/signing/optional-consent.test.ts @@ -0,0 +1,71 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { execFile as execFileCallback } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +const execFile = promisify(execFileCallback); +const SITE_DIRECTORY = fileURLToPath(new URL("../../..", import.meta.url)); +const SIGNING_URL = pathToFileURL(fileURLToPath(new URL("./index.ts", import.meta.url))).href; + +const KINDS = { + individual: { consent_age: "on", signatory_kind: "individual" }, + organisation: { + consent_authority: "on", + organisation: "Example Org", + organisation_type: "Company", + signatory_kind: "organisation", + }, +} as const; + +// `index.ts` imports "server-only", which only loads under the react-server +// condition, so the schema runs in a child process like the other signing tests. +async function storedUpdatesConsent(kind: keyof typeof KINDS, updates: "on" | undefined) { + const input = { + ...KINDS[kind], + consent_principles: "on", + consent_register: "on", + consent_updates: updates, + country: "United States", + email: "person@example.test", + name: "Fake Person", + principles_version: "1.0", + }; + const program = ` + const { submissionSchema, buildRecord } = await import(${JSON.stringify(SIGNING_URL)}); + const parsed = submissionSchema.safeParse(${JSON.stringify(input)}); + process.stdout.write(JSON.stringify(parsed.success + ? { ok: true, updates: buildRecord("fake-id", parsed.data).consent.updates } + : { ok: false })); + `; + const { stdout } = await execFile( + process.execPath, + ["--conditions=react-server", "--import", "tsx", "--input-type=module", "--eval", program], + { cwd: SITE_DIRECTORY } + ); + return JSON.parse(stdout) as { ok: boolean; updates?: boolean }; +} + +test("a signature with the updates box unticked is accepted and stored as no consent", async () => { + // An unticked checkbox is left out of the form data, so the key is missing. + const results = await Promise.all([ + storedUpdatesConsent("individual", undefined), + storedUpdatesConsent("organisation", undefined), + ]); + for (const result of results) { + assert.deepEqual(result, { ok: true, updates: false }); + } +}); + +test("a signature with the updates box ticked is stored as consent", async () => { + const results = await Promise.all([ + storedUpdatesConsent("individual", "on"), + storedUpdatesConsent("organisation", "on"), + ]); + for (const result of results) { + assert.deepEqual(result, { ok: true, updates: true }); + } +}); diff --git a/apps/site/src/lib/signing/providers.test.ts b/apps/site/src/lib/signing/providers.test.ts index efb6c3a59a..caf11bb22f 100644 --- a/apps/site/src/lib/signing/providers.test.ts +++ b/apps/site/src/lib/signing/providers.test.ts @@ -125,7 +125,7 @@ async function runPendingRetry(): Promise { const submission = { affiliation: "PDP-Connect", consent_age: true, consent_principles: true, consent_register: true, consent_updates: false, country: "United States", email: "private@example.test", - name: "Private Display Name", principles_version: "v1.0", signatory_kind: "individual", + name: "Private Display Name", principles_version: "v1.0", requested_apps: ["Fake App"], signatory_kind: "individual", }; const store = new Map([["pending:signatory-id", JSON.stringify(submission)]]); let githubCalls = 0; diff --git a/apps/site/src/lib/signing/requested-app-limits.ts b/apps/site/src/lib/signing/requested-app-limits.ts new file mode 100644 index 0000000000..a088187fe5 --- /dev/null +++ b/apps/site/src/lib/signing/requested-app-limits.ts @@ -0,0 +1,8 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +// Limits for "Which apps would you like to see join PDPP?", shared by the form, +// the server schema and the retry cookie so they cannot disagree. Ten is a +// bound on abuse, not a target: most people name one or two. +export const MAX_REQUESTED_APPS = 10; +export const MAX_REQUESTED_APP_LENGTH = 60; diff --git a/apps/site/src/lib/signing/requested-apps.test.ts b/apps/site/src/lib/signing/requested-apps.test.ts new file mode 100644 index 0000000000..1160898bc1 --- /dev/null +++ b/apps/site/src/lib/signing/requested-apps.test.ts @@ -0,0 +1,94 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { execFile as execFileCallback } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { formValuesForRetry, readRestoredForm, restoredFormCookie } from "./form-restoration.ts"; + +const execFile = promisify(execFileCallback); +const SITE_DIRECTORY = fileURLToPath(new URL("../../..", import.meta.url)); +const SIGNING_URL = pathToFileURL(fileURLToPath(new URL("./index.ts", import.meta.url))).href; + +// `index.ts` imports "server-only", which only loads under the react-server +// condition, so the schema runs in a child process like the other signing tests. +async function parseWith(requestedApps: unknown, kind: "individual" | "organisation" = "individual") { + const program = ` + const { submissionSchema, buildRecord } = await import(${JSON.stringify(SIGNING_URL)}); + const base = ${JSON.stringify( + kind === "individual" + ? { affiliation: "PDP-Connect", consent_age: "on", signatory_kind: "individual" } + : { + consent_authority: "on", + organisation: "Example Org", + organisation_type: "Company", + signatory_kind: "organisation", + } + )}; + const input = { + ...base, + consent_principles: "on", + consent_register: "on", + consent_updates: "on", + country: "United States", + email: "person@example.test", + name: "Fake Person", + principles_version: "1.0", + }; + const requested = ${JSON.stringify(requestedApps)}; + if (requested !== undefined) input.requested_apps = requested; + const parsed = submissionSchema.safeParse(input); + process.stdout.write(JSON.stringify(parsed.success + ? { ok: true, apps: parsed.data.requested_apps, stored: buildRecord("fake-id", parsed.data).requestedApps } + : { ok: false })); + `; + const { stdout } = await execFile( + process.execPath, + ["--conditions=react-server", "--import", "tsx", "--input-type=module", "--eval", program], + { cwd: SITE_DIRECTORY } + ); + return JSON.parse(stdout) as { apps?: string[]; ok: boolean; stored?: string[] }; +} + +test("requested apps are optional and stored as an empty list", async () => { + const cases = (["individual", "organisation"] as const).flatMap((kind) => + [undefined, [], ["", " "]].map((value) => ({ kind, value })) + ); + const results = await Promise.all(cases.map(({ kind, value }) => parseWith(value, kind))); + for (const result of results) { + assert.deepEqual(result, { apps: [], ok: true, stored: [] }); + } +}); + +test("requested apps are trimmed, space-collapsed, de-duplicated and stored", async () => { + const results = await Promise.all( + (["individual", "organisation"] as const).map((kind) => + parseWith([" Spotify ", "strava run", "SPOTIFY", ""], kind) + ) + ); + for (const result of results) { + assert.deepEqual(result, { apps: ["Spotify", "strava run"], ok: true, stored: ["Spotify", "strava run"] }); + } +}); + +test("requested apps reject more than ten and any over 60 characters", async () => { + const ten = Array.from({ length: 10 }, (_, index) => `App ${index}`); + assert.equal((await parseWith(ten)).ok, true); + assert.equal((await parseWith([...ten, "App 10"])).ok, false); + assert.equal((await parseWith(["a".repeat(60)])).ok, true); + assert.equal((await parseWith(["a".repeat(61)])).ok, false); +}); + +test("a retry cookie keeps the requested apps", () => { + const form = new FormData(); + const ten = Array.from({ length: 10 }, (_, index) => `App ${index}`); + for (const app of [...ten, "Extra"]) { + form.append("requested_apps", app); + } + const restored = formValuesForRetry(form); + assert.deepEqual(restored.requested_apps, ten); + assert.deepEqual(readRestoredForm(restoredFormCookie(restored).value)?.requested_apps, ten); + assert.deepEqual(readRestoredForm(restoredFormCookie(formValuesForRetry(new FormData())).value)?.requested_apps, []); +}); diff --git a/apps/site/src/lib/site-config.ts b/apps/site/src/lib/site-config.ts index e8f3781387..3c96be969a 100644 --- a/apps/site/src/lib/site-config.ts +++ b/apps/site/src/lib/site-config.ts @@ -40,7 +40,7 @@ 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` diff --git a/apps/site/src/lib/sitemap-entries.ts b/apps/site/src/lib/sitemap-entries.ts index 5e431d9a41..9a9aca6290 100644 --- a/apps/site/src/lib/sitemap-entries.ts +++ b/apps/site/src/lib/sitemap-entries.ts @@ -58,7 +58,8 @@ const NON_CANONICAL_DOC_PATHS = new Set([ export function buildSitemap(siteOrigin: string, docPages: readonly DocPageRef[], specLastModified: string) { // /sandbox is excluded: it is noindex (see its layout and robots.ts) and // must not appear here. - // The four-intent routes, plus the two the footer and the form link to. + // The four-intent routes, /apps (linked from the Participate menu), and the + // two the footer and the form link to. // /governance is NOT here: it 308s to /specification#governance, and a // redirect in a sitemap is a redirect a crawler has to follow to find out it // was one. /review is absent for a different reason — it is temporary, and a @@ -68,6 +69,7 @@ export function buildSitemap(siteOrigin: string, docPages: readonly DocPageRef[] { url: `${siteOrigin}${principlesRoute}` }, { url: `${siteOrigin}/build` }, { url: `${siteOrigin}/participate` }, + { url: `${siteOrigin}/apps` }, { url: `${siteOrigin}/self-host` }, { url: `${siteOrigin}/privacy` }, ]; diff --git a/docs/registers.md b/docs/registers.md index f3dddbc84a..2bcf4fb326 100644 --- a/docs/registers.md +++ b/docs/registers.md @@ -14,9 +14,14 @@ It is written for whoever inherits this, which may not be anyone who set it up. `PDP-Connect/supporters-private`. Private. **Holds personal data.** One file per confirmed signatory at `signatories//.json`, holding the -name as entered, the computed public name, the organisation, the signatory's -name and role, the email address, the country, the type, the four consent -flags, the Principles version, and the confirmation timestamp. +name as entered, the computed public name, the organisation (for an individual, +the affiliation, if given), the signatory's name for an organisation (the +`signatoryRole` field is always empty), the email address, the country, the +type, the four consent flags, the Principles version, the confirmation +timestamp, and `requestedApps` +(up to ten app names the signatory would like to see join PDPP; an empty +list when none). `requestedApps` is never published. Only counts leave the +private store; see [Counting requested apps](#counting-requested-apps). Also `withdrawn.log`: dates only, one line per withdrawal, with no identifier and no reason. @@ -211,6 +216,45 @@ interim controller changes. If the programme ends without a successor, do step 1, then delete the private repository and empty `supporters.json` in the same week. +## Counting requested apps + +To tell a company how many supporters asked for it, run the count script against +a local checkout of the private repository's `signatures` branch: + +```sh +node apps/site/scripts/count-requested-apps.mjs +``` + +It reads `signatories/**/*.json`, folds case and spacing, applies a small alias +map (`insta` to Instagram, `chat gpt` and `openai` to ChatGPT), and prints JSON +to stdout: + +```json +{"apps": [{"app": "Spotify", "count": 12}], "unreviewedAnswers": 4} +``` + +`apps` lists only canonical names, highest count first. A canonical name is one +on the script's reviewed list (`REVIEWED_APP_NAMES`) or a target of the alias +map. The form question has no suggestions, so a signatory can type anything, +including personal data. Any answer that is not a canonical name is never +printed. It is counted once in `unreviewedAnswers`. + +**Only this default output may be shared**, for example with a company. It holds +no name, email, id or free text. A withdrawn signatory is not counted, because +withdrawal deletes the file. + +To see which spellings fall in `unreviewedAnswers`, so you can add them to the +alias map, add `--review`: + +```sh +node apps/site/scripts/count-requested-apps.mjs --review +``` + +It prints the same default output to stdout and the unreviewed raw values with +counts to stderr, under a line marked `PRIVATE`. **Review output stays with +maintainers.** Do not share, paste or commit it. Redirect stdout only if you +need to save the shareable part (`> counts.json`). + ## Deleting a single signatory The withdrawal link in the confirmation email does this without anyone's help: diff --git a/scripts/signing-journey-oracle.mjs b/scripts/signing-journey-oracle.mjs index 909ca506e6..1113b08afe 100644 --- a/scripts/signing-journey-oracle.mjs +++ b/scripts/signing-journey-oracle.mjs @@ -239,6 +239,21 @@ export function normalizeEmail(value) { .toLowerCase(); } +/** + * Encodes a submission as the form body a browser would send. An array value + * becomes one repeated key per item, as the form's app inputs do; + * `URLSearchParams(object)` would join the array into a single comma string. + */ +export function encodeSubmission(submission) { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(submission)) { + for (const item of Array.isArray(value) ? value : [value]) { + body.append(key, item); + } + } + return body; +} + /** * Describes a signing link for the receipt without putting the token on disk. * @@ -661,7 +676,12 @@ async function main() { // against the live preview: identical submissions differing only in this // key returned 400 without it and 303 with it. consent_updates: "on", + // Messy on purpose: the route must trim, collapse spaces, drop the blank + // and the case-insensitive repeat, leaving the two names in + // REQUESTED_APPS_STORED. Fake names, so a run adds nothing to real counts. + requested_apps: [" Oracle Test App One ", "", "oracle test app one", "Oracle Test App Two"], }; + const REQUESTED_APPS_STORED = ["Oracle Test App One", "Oracle Test App Two"]; try { // Checked inside the try so a misconfigured run leaves a receipt like every @@ -694,7 +714,7 @@ async function main() { // ---- step 1: submit log("1/6 submitting…"); const submitResponse = await fetchNoRedirect(`${baseUrl}/api/sign`, { - body: new URLSearchParams(submission), + body: encodeSubmission(submission), headers: { "content-type": "application/x-www-form-urlencoded" }, method: "POST", }); @@ -883,6 +903,11 @@ async function main() { fail(`signatory file ${field}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(stored[field])}`); } } + if (JSON.stringify(stored.requestedApps) !== JSON.stringify(REQUESTED_APPS_STORED)) { + fail( + `signatory file requestedApps: expected ${JSON.stringify(REQUESTED_APPS_STORED)}, got ${JSON.stringify(stored.requestedApps)}` + ); + } if (stored.consent?.principles !== true || stored.consent?.register !== true) { fail(`signatory file consent not recorded: ${JSON.stringify(stored.consent)}`); } diff --git a/scripts/signing-journey-oracle.test.mjs b/scripts/signing-journey-oracle.test.mjs index 41f2485e7d..36b21cda92 100644 --- a/scripts/signing-journey-oracle.test.mjs +++ b/scripts/signing-journey-oracle.test.mjs @@ -9,6 +9,7 @@ import { buildCommand, createReceipt, describeCommandError, + encodeSubmission, extractLinks, looksLikeVercelPreview, normalizeEmail, @@ -431,3 +432,9 @@ test("looksLikeVercelPreview reads the host, and a malformed URL is not a previe assert.equal(looksLikeVercelPreview("https://pdpp.dev"), false); assert.equal(looksLikeVercelPreview("not a url"), false); }); + +test("encodeSubmission sends an array as repeated keys, as the form's app inputs do", () => { + const body = encodeSubmission({ name: "Fake Person", requested_apps: ["One", "", "Two"] }); + assert.deepEqual(body.getAll("requested_apps"), ["One", "", "Two"]); + assert.equal(body.get("name"), "Fake Person"); +});