From feb6c04c0d1e94e196e4b2aee0660d8b1430217a Mon Sep 17 00:00:00 2001 From: Art A <129354338+artvana@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:01:50 +0200 Subject: [PATCH 1/9] site: config, four-item nav, review ticker, principles content source Foundation for the four-intent site. - src/lib/site-config.ts holds every deployment-specific value behind a visible bracketed placeholder. A wrong-but-plausible default reads as fact and ships silently; "[controller name not set]" cannot. unsetConfigValues() is what the launch checklist reads. The reports mailbox is deliberately NOT configurable: a deployment that could repoint it could redirect a conduct or security report. - Nav becomes Principles, Specification, Build, Participate, with the Specification dropdown carrying "The specification" and, behind reviewOpen, "Review, until 1 Oct". The dropdown opens on hover AND focus-within, so it is keyboard-reachable with no open/closed state to keep in sync. - The review ticker is one link wrapping a CSS marquee: two identical tracks translated by exactly -50%, so the loop seam is invisible and the animation runs off the main thread. prefers-reduced-motion pauses it rather than hiding it, because the message still has to be readable. - sync-spec-docs parses PRINCIPLES.md into PRINCIPLES_PREAMBLE and PRINCIPLES_LIST so /principles and the home page render the six principles without a second copy in site source. It throws unless exactly six parse. - PdppConceptSection's sectionIndex becomes optional: the new site carries no ordinal numbering on section headings. - PRINCIPLES.md gains the sentence about the relying party's uncertainty. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NECjjZbYWNTzq3bu7nuiDP Signed-off-by: Art A <129354338+artvana@users.noreply.github.com> --- PRINCIPLES.md | 2 +- apps/site/.source/dynamic.ts | 2 +- apps/site/scripts/sync-spec-docs.mjs | 51 + apps/site/scripts/sync-spec-docs.mts | 57 + .../src/components/layout/concept-shell.tsx | 2 + apps/site/src/components/layout/masthead.tsx | 46 +- .../src/components/layout/review-banner.tsx | 76 + .../src/components/layout/site-header.tsx | 2 +- .../components/sections/concept-section.tsx | 8 +- apps/site/src/lib/public-site-nav.ts | 61 +- apps/site/src/lib/site-config.ts | 84 ++ .../styles/surfaces/concept/components.css | 15 + .../CLAUDE_CODE_PROMPT.md | 56 + design_handoff_pdpp_site/PDPP Site.dc.html | 900 +++++++++++ design_handoff_pdpp_site/README.md | 39 + .../pdpp-dev-prototype-v3.html | 1343 +++++++++++++++++ 16 files changed, 2709 insertions(+), 35 deletions(-) create mode 100644 apps/site/src/components/layout/review-banner.tsx create mode 100644 apps/site/src/lib/site-config.ts create mode 100644 design_handoff_pdpp_site/CLAUDE_CODE_PROMPT.md create mode 100644 design_handoff_pdpp_site/PDPP Site.dc.html create mode 100644 design_handoff_pdpp_site/README.md create mode 100644 design_handoff_pdpp_site/pdpp-dev-prototype-v3.html diff --git a/PRINCIPLES.md b/PRINCIPLES.md index 3d8a70c89f..252c21d204 100644 --- a/PRINCIPLES.md +++ b/PRINCIPLES.md @@ -6,7 +6,7 @@ --- -When an individual permits a service to use information held about them by another, the permission is commonly granted in general terms and recorded nowhere the individual can examine. The choice is often between all of the information and none of it, and once given, the permission is rarely visible, rarely verifiable, and rarely reversible in practice. +When an individual permits a service to use information held about them by another, the permission is commonly granted in general terms and recorded nowhere the individual can examine. The choice is often between all of the information and none of it, and once given, the permission is rarely visible, rarely verifiable, and rarely reversible in practice. The party relying on the information carries the same uncertainty, since it holds no reliable record of what was consented to, for what purpose, or by whom. As more of daily life is mediated by systems that act on personal information, and as artificial intelligence increases what can be done with it, the absence of a specific, durable and enforceable account of what has been agreed has become a matter of general concern. diff --git a/apps/site/.source/dynamic.ts b/apps/site/.source/dynamic.ts index 72c5c44e2d..c804ce8355 100644 --- a/apps/site/.source/dynamic.ts +++ b/apps/site/.source/dynamic.ts @@ -9,4 +9,4 @@ const create = await dynamic(Config, {"environment":"dynamic","root":"","configPath":"source.config.ts","outDir":".source"}); -export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, }, [{ absolutePath: path.resolve("content/docs/README.md"), info: {"fullPath":"content/docs/README.md","path":"README.md"}, data: {"title":"Web Spec Publishing Notes","description":"Internal notes for keeping canonical root specs and public web spec pages in sync."}, hash: "1ba07ddf8f89f32bb09ef593bff2fd69" }, { absolutePath: path.resolve("content/docs/governance.mdx"), info: {"fullPath":"content/docs/governance.mdx","path":"governance.mdx"}, data: {"title":"Governance, Membership and Conformance","description":"How PDP-Connect is governed, how membership works, and how conformance status is obtained."}, hash: "7201976fdc9c85f1b4fc68f9ff61ce6e" }, { absolutePath: path.resolve("content/docs/index.mdx"), info: {"fullPath":"content/docs/index.mdx","path":"index.mdx"}, data: {"title":"PDPP Docs","description":"Protocol documentation for the Personal Data Portability Protocol."}, hash: "e04b60d907022a185bd9fd9df5192ec3" }, { absolutePath: path.resolve("content/docs/open-questions.md"), info: {"fullPath":"content/docs/open-questions.md","path":"open-questions.md"}, data: {"title":"Open Questions","description":"Open design questions for PDPP, ordered by importance. Consolidated from the deferred-concerns register, design notes, and review threads."}, hash: "3ea665881be8088ad2fdd365fd3bd934" }, { absolutePath: path.resolve("content/docs/principles.mdx"), info: {"fullPath":"content/docs/principles.mdx","path":"principles.mdx"}, data: {"title":"PDPP Principles","description":"The intentions behind the Personal Data Portability Protocol, and the register of Supporters who have signed them."}, hash: "2df04462c584612deebd9bb1dcc8e105" }, { absolutePath: path.resolve("content/docs/reference-implementation-examples.md"), info: {"fullPath":"content/docs/reference-implementation-examples.md","path":"reference-implementation-examples.md"}, data: {"title":"Reference Implementation Examples","description":"Current end-to-end examples from the live PDPP reference implementation. Not normative protocol documentation."}, hash: "91f36ea00fc311e57afdb2a6e62f0a56" }, { absolutePath: path.resolve("content/docs/reference-implementation.md"), info: {"fullPath":"content/docs/reference-implementation.md","path":"reference-implementation.md"}, data: {"title":"Reference Implementation Notes","description":"Current implementation behavior for the forkable PDPP reference stack. Not normative protocol documentation."}, hash: "6041a35a9600acbfff176cef857194b4" }, { absolutePath: path.resolve("content/docs/spec-architecture.mdx"), info: {"fullPath":"content/docs/spec-architecture.mdx","path":"spec-architecture.mdx"}, data: {"title":"Reference Topology","description":"How the current PDPP reference components relate: native provider, polyfill path, runtime, and client flows."}, hash: "b61dca1dec9ea08f712af40b8eac348e" }, { absolutePath: path.resolve("content/docs/spec-auth-design.mdx"), info: {"fullPath":"content/docs/spec-auth-design.mdx","path":"spec-auth-design.mdx"}, data: {"title":"Auth Design","description":"Bearer tokens at both boundaries: wire format and semantics. Identity provider and token issuance are out of scope."}, hash: "21a81024720485a411c29750b9975bdc" }, { absolutePath: path.resolve("content/docs/spec-change-tracking.mdx"), info: {"fullPath":"content/docs/spec-change-tracking.mdx","path":"spec-change-tracking.mdx"}, data: {"title":"Change Tracking","description":"Design rationale for grant-relative incremental sync via changes_since cursors, not canonical changelog streams."}, hash: "68115584c5dcc7217989177a07acbae8" }, { absolutePath: path.resolve("content/docs/spec-collection-profile.mdx"), info: {"fullPath":"content/docs/spec-collection-profile.mdx","path":"spec-collection-profile.mdx"}, data: {"title":"Collection Profile","description":"Companion to the Personal Data Portability Protocol (PDPP) core spec."}, hash: "f5c40c55c760a141e1c79cfe1794981c" }, { absolutePath: path.resolve("content/docs/spec-connector-ecosystem.mdx"), info: {"fullPath":"content/docs/spec-connector-ecosystem.mdx","path":"spec-connector-ecosystem.mdx"}, data: {"title":"Connector Ecosystem","description":"Reference runtime notes for connectors: browser abstraction decisions and third-party source integration."}, hash: "aaa40c2bc89cef2ed3dbba142980fe2d" }, { absolutePath: path.resolve("content/docs/spec-core.mdx"), info: {"fullPath":"content/docs/spec-core.mdx","path":"spec-core.mdx"}, data: {"title":"Protocol Specification","description":"Authorization and disclosure semantics for personal data — record model, selection request, grant, manifest, and resource server interface."}, hash: "cf9ad0d03105703943bf70c371423dfc" }, { absolutePath: path.resolve("content/docs/spec-data-query-api.mdx"), info: {"fullPath":"content/docs/spec-data-query-api.mdx","path":"spec-data-query-api.mdx"}, data: {"title":"Data Query API (Superseded)","description":"Historical companion spec — Core spec Section 8 is now authoritative for the RS query interface."}, hash: "6c1b573cb7747eb75d56c03e628258f0" }, { absolutePath: path.resolve("content/docs/spec-deferred.mdx"), info: {"fullPath":"content/docs/spec-deferred.mdx","path":"spec-deferred.mdx"}, data: {"title":"Deferred Concerns","description":"Issues identified during design and review that are intentionally out of scope for v0.1. Each item is named precisely so it can be referenced from the core spec and tracked for future versions."}, hash: "c84d2ebdd5c42a9e54de853333027c7d" }, { absolutePath: path.resolve("content/docs/spec-discovery-and-trust.mdx"), info: {"fullPath":"content/docs/spec-discovery-and-trust.mdx","path":"spec-discovery-and-trust.mdx"}, data: {"title":"Discovery and Trust","description":"How an authorization server discovers, validates, and accepts PDPP source declarations."}, hash: "adc6123e46e970e36a0bc227f5943e7b" }, { absolutePath: path.resolve("content/docs/spec-ext-aggregation.md"), info: {"fullPath":"content/docs/spec-ext-aggregation.md","path":"spec-ext-aggregation.md"}, data: {"title":"Extension Profile: Aggregation","description":"Optional companion profile to the Personal Data Portability Protocol (PDPP) core spec defining a discoverable, grant-safe, single-stream aggregation surface."}, hash: "06736a6d3b48250fb97edffc8e2a9f43" }, { absolutePath: path.resolve("content/docs/spec-ext-lexical-search.md"), info: {"fullPath":"content/docs/spec-ext-lexical-search.md","path":"spec-ext-lexical-search.md"}, data: {"title":"Extension Profile: Lexical Search","description":"Optional companion profile to the Personal Data Portability Protocol (PDPP) core spec defining a discoverable, grant-safe lexical (full-text) search surface."}, hash: "2cd10de7c2b40e7885eebdac106cbcf9" }, { absolutePath: path.resolve("content/docs/spec-semantic-retrieval-extension.md"), info: {"fullPath":"content/docs/spec-semantic-retrieval-extension.md","path":"spec-semantic-retrieval-extension.md"}, data: {"title":"Semantic Retrieval Extension (Experimental)","description":"Experimental optional PDPP extension defining a semantic retrieval surface at GET /v1/search/semantic. Unstable."}, hash: "63d2e31e5cf9c16494ea6c77a3844636" }]); \ No newline at end of file +export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, }, [{ absolutePath: path.resolve("content/docs/README.md"), info: {"fullPath":"content/docs/README.md","path":"README.md"}, data: {"title":"Web Spec Publishing Notes","description":"Internal notes for keeping canonical root specs and public web spec pages in sync."}, hash: "1ba07ddf8f89f32bb09ef593bff2fd69" }, { absolutePath: path.resolve("content/docs/governance.mdx"), info: {"fullPath":"content/docs/governance.mdx","path":"governance.mdx"}, data: {"title":"Governance, Membership and Conformance","description":"How PDP-Connect is governed, how membership works, and how conformance status is obtained."}, hash: "7201976fdc9c85f1b4fc68f9ff61ce6e" }, { absolutePath: path.resolve("content/docs/index.mdx"), info: {"fullPath":"content/docs/index.mdx","path":"index.mdx"}, data: {"title":"PDPP Docs","description":"Protocol documentation for the Personal Data Portability Protocol."}, hash: "e04b60d907022a185bd9fd9df5192ec3" }, { absolutePath: path.resolve("content/docs/open-questions.md"), info: {"fullPath":"content/docs/open-questions.md","path":"open-questions.md"}, data: {"title":"Open Questions","description":"Open design questions for PDPP, ordered by importance. Consolidated from the deferred-concerns register, design notes, and review threads."}, hash: "3ea665881be8088ad2fdd365fd3bd934" }, { absolutePath: path.resolve("content/docs/principles.mdx"), info: {"fullPath":"content/docs/principles.mdx","path":"principles.mdx"}, data: {"title":"PDPP Principles","description":"The intentions behind the Personal Data Portability Protocol, and the register of Supporters who have signed them."}, hash: "c45c1bc826a46cb2e445a1c0c19ae86c" }, { absolutePath: path.resolve("content/docs/reference-implementation-examples.md"), info: {"fullPath":"content/docs/reference-implementation-examples.md","path":"reference-implementation-examples.md"}, data: {"title":"Reference Implementation Examples","description":"Current end-to-end examples from the live PDPP reference implementation. Not normative protocol documentation."}, hash: "c85fc7b6d48a9cc1d38044f4c12575bc" }, { absolutePath: path.resolve("content/docs/reference-implementation.md"), info: {"fullPath":"content/docs/reference-implementation.md","path":"reference-implementation.md"}, data: {"title":"Reference Implementation Notes","description":"Current implementation behavior for the forkable PDPP reference stack. Not normative protocol documentation."}, hash: "6041a35a9600acbfff176cef857194b4" }, { absolutePath: path.resolve("content/docs/spec-architecture.mdx"), info: {"fullPath":"content/docs/spec-architecture.mdx","path":"spec-architecture.mdx"}, data: {"title":"Reference Topology","description":"How the current PDPP reference components relate: native provider, polyfill path, runtime, and client flows."}, hash: "b61dca1dec9ea08f712af40b8eac348e" }, { absolutePath: path.resolve("content/docs/spec-auth-design.mdx"), info: {"fullPath":"content/docs/spec-auth-design.mdx","path":"spec-auth-design.mdx"}, data: {"title":"Auth Design","description":"Bearer tokens at both boundaries: wire format and semantics. Identity provider and token issuance are out of scope."}, hash: "21a81024720485a411c29750b9975bdc" }, { absolutePath: path.resolve("content/docs/spec-change-tracking.mdx"), info: {"fullPath":"content/docs/spec-change-tracking.mdx","path":"spec-change-tracking.mdx"}, data: {"title":"Change Tracking","description":"Design rationale for grant-relative incremental sync via changes_since cursors, not canonical changelog streams."}, hash: "68115584c5dcc7217989177a07acbae8" }, { absolutePath: path.resolve("content/docs/spec-collection-profile.mdx"), info: {"fullPath":"content/docs/spec-collection-profile.mdx","path":"spec-collection-profile.mdx"}, data: {"title":"Collection Profile","description":"Companion to the Personal Data Portability Protocol (PDPP) core spec."}, hash: "9212a1b3a642ec58e41f671561a85f67" }, { absolutePath: path.resolve("content/docs/spec-connector-ecosystem.mdx"), info: {"fullPath":"content/docs/spec-connector-ecosystem.mdx","path":"spec-connector-ecosystem.mdx"}, data: {"title":"Connector Ecosystem","description":"Reference runtime notes for connectors: browser abstraction decisions and third-party source integration."}, hash: "aaa40c2bc89cef2ed3dbba142980fe2d" }, { absolutePath: path.resolve("content/docs/spec-core.mdx"), info: {"fullPath":"content/docs/spec-core.mdx","path":"spec-core.mdx"}, data: {"title":"Protocol Specification","description":"Authorization and disclosure semantics for personal data — record model, selection request, grant, manifest, and resource server interface."}, hash: "cf9ad0d03105703943bf70c371423dfc" }, { absolutePath: path.resolve("content/docs/spec-data-query-api.mdx"), info: {"fullPath":"content/docs/spec-data-query-api.mdx","path":"spec-data-query-api.mdx"}, data: {"title":"Data Query API (Superseded)","description":"Historical companion spec — Core spec Section 8 is now authoritative for the RS query interface."}, hash: "6c1b573cb7747eb75d56c03e628258f0" }, { absolutePath: path.resolve("content/docs/spec-deferred.mdx"), info: {"fullPath":"content/docs/spec-deferred.mdx","path":"spec-deferred.mdx"}, data: {"title":"Deferred Concerns","description":"Issues identified during design and review that are intentionally out of scope for v0.1. Each item is named precisely so it can be referenced from the core spec and tracked for future versions."}, hash: "c84d2ebdd5c42a9e54de853333027c7d" }, { absolutePath: path.resolve("content/docs/spec-discovery-and-trust.mdx"), info: {"fullPath":"content/docs/spec-discovery-and-trust.mdx","path":"spec-discovery-and-trust.mdx"}, data: {"title":"Discovery and Trust","description":"How an authorization server discovers, validates, and accepts PDPP source declarations."}, hash: "adc6123e46e970e36a0bc227f5943e7b" }, { absolutePath: path.resolve("content/docs/spec-ext-aggregation.md"), info: {"fullPath":"content/docs/spec-ext-aggregation.md","path":"spec-ext-aggregation.md"}, data: {"title":"Extension Profile: Aggregation","description":"Optional companion profile to the Personal Data Portability Protocol (PDPP) core spec defining a discoverable, grant-safe, single-stream aggregation surface."}, hash: "06736a6d3b48250fb97edffc8e2a9f43" }, { absolutePath: path.resolve("content/docs/spec-ext-lexical-search.md"), info: {"fullPath":"content/docs/spec-ext-lexical-search.md","path":"spec-ext-lexical-search.md"}, data: {"title":"Extension Profile: Lexical Search","description":"Optional companion profile to the Personal Data Portability Protocol (PDPP) core spec defining a discoverable, grant-safe lexical (full-text) search surface."}, hash: "2cd10de7c2b40e7885eebdac106cbcf9" }, { absolutePath: path.resolve("content/docs/spec-semantic-retrieval-extension.md"), info: {"fullPath":"content/docs/spec-semantic-retrieval-extension.md","path":"spec-semantic-retrieval-extension.md"}, data: {"title":"Semantic Retrieval Extension (Experimental)","description":"Experimental optional PDPP extension defining a semantic retrieval surface at GET /v1/search/semantic. Unstable."}, hash: "63d2e31e5cf9c16494ea6c77a3844636" }]); \ No newline at end of file diff --git a/apps/site/scripts/sync-spec-docs.mjs b/apps/site/scripts/sync-spec-docs.mjs index f630273ff4..e701e4bb26 100644 --- a/apps/site/scripts/sync-spec-docs.mjs +++ b/apps/site/scripts/sync-spec-docs.mjs @@ -327,6 +327,55 @@ const principlesFrontMatter = { status: requireHeaderMatch(principlesText, "PRINCIPLES.md", "Status"), version: requireHeaderMatch(principlesText, "PRINCIPLES.md", "Version"), }; +// The Principles, parsed into structured content for the site. +// +// /principles and the home page both render the six principles and the +// preamble. Neither may retype them: PRINCIPLES.md is the document people +// sign, and a second copy in site source is a second thing to keep true. The +// numbered list is parsed into { title, body } so the site can render each +// principle as a card with its own heading, which markdown alone cannot +// express, while the words stay the document's. +// +// Parsing is strict and throws: the six principles are the substance of the +// page, and rendering five because a list marker changed would be worse than +// failing the build. +const principlesLines = principlesText.split("\n"); +const principlesBodyStart = principlesLines.findIndex((line) => line.trim() === "---"); +if (principlesBodyStart < 0) { + throw new Error("sync-spec-docs: PRINCIPLES.md has no '---' rule after the status block."); +} +// Preamble: the prose paragraphs between the first rule and the first +// numbered principle. +const preamble = []; +const principles = []; +// `1. **Title.** Body` — the shape every principle in the document uses. +const PRINCIPLE_ROW = /^\d+\.\s+\*\*(.+?)\*\*\s*(.*)$/; +for (const raw of principlesLines.slice(principlesBodyStart + 1)) { + const line = raw.trim(); + if (!line || line === "---") { + continue; + } + // The document continues past the principles into "Add your name" and its + // footnote; those are rendered from their own source, not here. + if (line.startsWith("## ")) { + break; + } + const match = line.match(PRINCIPLE_ROW); + if (match?.[1]) { + principles.push({ body: (match[2] ?? "").trim(), title: match[1].replace(/\.$/, "") }); + continue; + } + if (principles.length === 0) { + preamble.push(line); + } +} +if (principles.length !== 6) { + throw new Error(`sync-spec-docs: expected 6 principles in PRINCIPLES.md, parsed ${principles.length}. ` + + "Update scripts/sync-spec-docs.mts if the list shape changed."); +} +if (preamble.length === 0) { + throw new Error("sync-spec-docs: PRINCIPLES.md has no preamble paragraphs before the numbered list."); +} const generatedDir = path.join(siteDir, "src", "generated"); mkdirSync(generatedDir, { recursive: true }); writeFileSync(path.join(generatedDir, "spec-front-matter.ts"), `// Copyright The PDP-Connect Contributors @@ -338,6 +387,8 @@ writeFileSync(path.join(generatedDir, "spec-front-matter.ts"), `// Copyright The export const SPEC_FRONT_MATTER = ${JSON.stringify({ date, editors, status, version: versionMatch[1] }, null, 2)} as const; export const GOVERNANCE_FRONT_MATTER = ${JSON.stringify(governanceFrontMatter, null, 2)} as const; export const PRINCIPLES_FRONT_MATTER = ${JSON.stringify(principlesFrontMatter, null, 2)} as const; +export const PRINCIPLES_PREAMBLE = ${JSON.stringify(preamble, null, 2)} as const; +export const PRINCIPLES_LIST = ${JSON.stringify(principles, null, 2)} as const; `); console.log(`[sync-spec-docs] generated ${generated} spec page(s) from root spec-*.md`); console.log(`[sync-spec-docs] wrote src/generated/spec-front-matter.ts (${versionMatch[1]}, ${status}, ${date}, ${editors.length} editor(s); ` + diff --git a/apps/site/scripts/sync-spec-docs.mts b/apps/site/scripts/sync-spec-docs.mts index 05dcb824fa..0467a0c0d8 100644 --- a/apps/site/scripts/sync-spec-docs.mts +++ b/apps/site/scripts/sync-spec-docs.mts @@ -381,6 +381,61 @@ const principlesFrontMatter = { version: requireHeaderMatch(principlesText, "PRINCIPLES.md", "Version"), }; +// The Principles, parsed into structured content for the site. +// +// /principles and the home page both render the six principles and the +// preamble. Neither may retype them: PRINCIPLES.md is the document people +// sign, and a second copy in site source is a second thing to keep true. The +// numbered list is parsed into { title, body } so the site can render each +// principle as a card with its own heading, which markdown alone cannot +// express, while the words stay the document's. +// +// Parsing is strict and throws: the six principles are the substance of the +// page, and rendering five because a list marker changed would be worse than +// failing the build. +const principlesLines = principlesText.split("\n"); +const principlesBodyStart = principlesLines.findIndex((line) => line.trim() === "---"); +if (principlesBodyStart < 0) { + throw new Error("sync-spec-docs: PRINCIPLES.md has no '---' rule after the status block."); +} + +// Preamble: the prose paragraphs between the first rule and the first +// numbered principle. +const preamble: string[] = []; +const principles: { body: string; title: string }[] = []; +// `1. **Title.** Body` — the shape every principle in the document uses. +const PRINCIPLE_ROW = /^\d+\.\s+\*\*(.+?)\*\*\s*(.*)$/; + +for (const raw of principlesLines.slice(principlesBodyStart + 1)) { + const line = raw.trim(); + if (!line || line === "---") { + continue; + } + // The document continues past the principles into "Add your name" and its + // footnote; those are rendered from their own source, not here. + if (line.startsWith("## ")) { + break; + } + const match = line.match(PRINCIPLE_ROW); + if (match?.[1]) { + principles.push({ body: (match[2] ?? "").trim(), title: match[1].replace(/\.$/, "") }); + continue; + } + if (principles.length === 0) { + preamble.push(line); + } +} + +if (principles.length !== 6) { + throw new Error( + `sync-spec-docs: expected 6 principles in PRINCIPLES.md, parsed ${principles.length}. ` + + "Update scripts/sync-spec-docs.mts if the list shape changed." + ); +} +if (preamble.length === 0) { + throw new Error("sync-spec-docs: PRINCIPLES.md has no preamble paragraphs before the numbered list."); +} + const generatedDir = path.join(siteDir, "src", "generated"); mkdirSync(generatedDir, { recursive: true }); writeFileSync( @@ -394,6 +449,8 @@ writeFileSync( export const SPEC_FRONT_MATTER = ${JSON.stringify({ date, editors, status, version: versionMatch[1] }, null, 2)} as const; export const GOVERNANCE_FRONT_MATTER = ${JSON.stringify(governanceFrontMatter, null, 2)} as const; export const PRINCIPLES_FRONT_MATTER = ${JSON.stringify(principlesFrontMatter, null, 2)} as const; +export const PRINCIPLES_PREAMBLE = ${JSON.stringify(preamble, null, 2)} as const; +export const PRINCIPLES_LIST = ${JSON.stringify(principles, null, 2)} as const; ` ); diff --git a/apps/site/src/components/layout/concept-shell.tsx b/apps/site/src/components/layout/concept-shell.tsx index ae5414e138..209a980d68 100644 --- a/apps/site/src/components/layout/concept-shell.tsx +++ b/apps/site/src/components/layout/concept-shell.tsx @@ -5,6 +5,7 @@ import type { ComponentPropsWithoutRef } from "react"; import { cn } from "@/lib/utils.ts"; import { PdppConceptFooter } from "./footer.tsx"; import { PdppConceptMasthead } from "./masthead.tsx"; +import { PdppReviewBanner } from "./review-banner.tsx"; /** CSS cascade scope for legacy prose and teal-deep overrides. */ export const CONCEPT_SURFACE = "concept" as const; @@ -26,6 +27,7 @@ export function PdppConceptShell({ children, className, ...props }: ComponentPro {...props} > + {children} diff --git a/apps/site/src/components/layout/masthead.tsx b/apps/site/src/components/layout/masthead.tsx index 06205b73c8..32e101477e 100644 --- a/apps/site/src/components/layout/masthead.tsx +++ b/apps/site/src/components/layout/masthead.tsx @@ -212,9 +212,9 @@ export function PdppConceptMasthead() { data-open={mobileNavOpen} id="pdpp-primary-nav" > - {publicSiteNav.map((item) => { + {publicSiteNav().map((item) => { const active = pathname === item.link || pathname.startsWith(`${item.link}/`); - return ( + const label = ( {item.text} ); + + // Only Specification carries 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 + // width the panel is rendered inline rather than floating, since + // a floating panel inside an already-expanded disclosure has + // nothing to float over. + if (!item.children || item.children.length <= 1) { + return {label}; + } + + return ( + + {label} + + + {item.children.map((child) => ( + + {child.text} + + ))} + + + + ); })}
diff --git a/apps/site/src/components/layout/review-banner.tsx b/apps/site/src/components/layout/review-banner.tsx new file mode 100644 index 0000000000..75c6dcdf3d --- /dev/null +++ b/apps/site/src/components/layout/review-banner.tsx @@ -0,0 +1,76 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import Link from "next/link"; +import { siteFlags } from "@/lib/site-config.ts"; +import { cn } from "@/lib/utils.ts"; + +// The full-width review ticker, under the header, on the accent ground. +// +// One link, not a bar containing a link: the whole strip is the target, so a +// reader who aims anywhere along it lands on /review. Wrapping the marquee in +// the anchor rather than putting an anchor inside it is what makes that true +// without a click handler. +// +// The marquee is CSS-only. A JS ticker re-renders on every frame and fights +// the browser's own compositor; a single transform animation on a duplicated +// track runs off the main thread. The track is rendered TWICE and translated +// by exactly -50%, which is what makes the loop seamless: at the moment the +// first copy leaves, the second is exactly where the first began. +// +// aria-hidden on the second copy: it is the same words again, and a screen +// reader announcing the message twice per loop is noise. The first copy +// carries the accessible text, and the anchor's own label states the +// destination. +// +// prefers-reduced-motion PAUSES rather than hides: the message still has to be +// readable by someone who has asked for less movement, so the animation stops +// with the track at its start rather than the strip disappearing. + +const MESSAGE = "The specification is open for comment until 1 October."; +const CALL_TO_ACTION = "Review it now →"; + +// Wide gaps and a slash between repeats, per the design. The separator is a +// glyph rather than a border so it travels with the text. +const SEPARATOR = "/"; + +function TickerTrack({ ariaHidden }: { ariaHidden?: boolean }) { + return ( + + + {MESSAGE} + {CALL_TO_ACTION} + + + + {MESSAGE} + {CALL_TO_ACTION} + + + + ); +} + +export function PdppReviewBanner() { + if (!siteFlags.reviewOpen) { + return null; + } + + return ( + +
+ + +
+ + ); +} diff --git a/apps/site/src/components/layout/site-header.tsx b/apps/site/src/components/layout/site-header.tsx index 8fb1efd598..f833aaba78 100644 --- a/apps/site/src/components/layout/site-header.tsx +++ b/apps/site/src/components/layout/site-header.tsx @@ -36,7 +36,7 @@ export function SiteHeader({ currentLabel }: { currentLabel: string }) { )}
+ + + + + + + + + + + + From afb408a7e2916fc32c571afb9cc4d5a0c14a3e89 Mon Sep 17 00:00:00 2001 From: Art A <129354338+artvana@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:06:58 +0200 Subject: [PATCH 2/9] site: Principles, Build, Review and Privacy pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of the seven routes, carrying the prototype's copy with the deltas applied. /principles renders the preamble and the six principles from PRINCIPLES.md through the generated module, never from page source. The Supporters table ships an EMPTY state, not the prototype's six example rows and "[ 247 ]" counter: those are labelled layout-only in the design, and a page showing invented signatories has published a false register. /build carries the three role cards and the FAQs. Section citations are named rather than numbered ("Source declaration" rather than "Core §5"): the § glyph and numbered citations do not belong in site copy outside the reader and /review, and a section number that moves leaves every citing page wrong. The links still land on the same anchors. /review applies all three of its copy deltas: the working-sessions channel is gone, the comment-handling sentences are replaced, and the "one document of nine sections" sentence is cut from the lede. It is one of only two places that name the review period, so retiring the period is the flag plus this page. /privacy states controller, purposes, what is stored, what is published, retention, the confirmation and withdrawal mechanism and a contact, all from config so nothing legal is guessed on someone else's behalf. The signing form is present but renders the closed notice until signingLive. When the flag is off the form is ABSENT, not disabled: a disabled form still ships its field names and endpoint to every reader. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NECjjZbYWNTzq3bu7nuiDP Signed-off-by: Art A <129354338+artvana@users.noreply.github.com> --- apps/site/public/principles/supporters.json | 1 + apps/site/src/app/(concept)/build/page.tsx | 229 ++++++++++++++++++ .../src/app/(concept)/principles/page.tsx | 98 ++++++++ apps/site/src/app/(concept)/privacy/page.tsx | 145 +++++++++++ apps/site/src/app/(concept)/review/page.tsx | 208 ++++++++++++++++ apps/site/src/app/principles/layout.tsx | 26 -- apps/site/src/app/principles/page.tsx | 96 -------- .../src/components/site/principles-list.tsx | 52 ++++ .../site/src/components/site/signing-form.tsx | 215 ++++++++++++++++ apps/site/src/components/site/supporters.tsx | 109 +++++++++ apps/site/src/lib/site-config.ts | 1 + 11 files changed, 1058 insertions(+), 122 deletions(-) create mode 100644 apps/site/public/principles/supporters.json create mode 100644 apps/site/src/app/(concept)/build/page.tsx create mode 100644 apps/site/src/app/(concept)/principles/page.tsx create mode 100644 apps/site/src/app/(concept)/privacy/page.tsx create mode 100644 apps/site/src/app/(concept)/review/page.tsx delete mode 100644 apps/site/src/app/principles/layout.tsx delete mode 100644 apps/site/src/app/principles/page.tsx create mode 100644 apps/site/src/components/site/principles-list.tsx create mode 100644 apps/site/src/components/site/signing-form.tsx create mode 100644 apps/site/src/components/site/supporters.tsx diff --git a/apps/site/public/principles/supporters.json b/apps/site/public/principles/supporters.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/apps/site/public/principles/supporters.json @@ -0,0 +1 @@ +[] diff --git a/apps/site/src/app/(concept)/build/page.tsx b/apps/site/src/app/(concept)/build/page.tsx new file mode 100644 index 0000000000..f89ace207c --- /dev/null +++ b/apps/site/src/app/(concept)/build/page.tsx @@ -0,0 +1,229 @@ +// 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 { PdppConceptSection } from "@/components/sections/concept-section.tsx"; +import { Text } from "@/components/typography/text.tsx"; +import { GITHUB_REPO_URL } from "@/lib/site-facts.ts"; +import { cn } from "@/lib/utils.ts"; + +export const metadata: Metadata = { + alternates: { canonical: "/build" }, + description: "The specification, a reference server you can run today, and guidance for the three roles.", + openGraph: { url: "/build" }, + title: "Build - PDPP", +}; + +// The Build page: the "I want to implement this" intent. +// +// SECTION CITATIONS: the prototype's copy cites Core by section number +// throughout ("Core §5 →", "Start with Core §4 and §5"). The build rule is +// that the § glyph and numbered section citations do not appear in site copy +// outside the reader and /review, because a section number that moves leaves +// every page that cites it wrong. The citations are kept as WAYFINDING but +// named rather than numbered: "Source declaration" rather than "Core §5". The +// link still lands on the same anchor, and the reader shows the numeral. + +const CARD = cn( + "flex flex-col gap-3 bg-background p-6", + // Hairline per card so an odd wrap leaves no dangling rule. + "shadow-[0_0_0_1px_var(--border)]" +); + +interface RoleCard { + anchor: string; + cta: string; + eyebrow: string; + start: string; + title: string; + body: string; +} + +const ROLES: readonly RoleCard[] = [ + { + anchor: "/specification#source-declaration", + cta: "Source declaration", + eyebrow: "I hold data", + title: "Make a source", + body: "You have data, on a platform or in a connector, and you want apps to be able to ask for it with consent. You need to write a source declaration that validates against the source declaration section, then serve records through a resource server that conforms to the resource server interface.", + start: "Start with the record model and the source declaration, then the declaration schema and the validator.", + }, + { + anchor: "/specification#selection-request", + cta: "Selection request", + eyebrow: "I want data", + title: "Make an accessor", + body: "You are building an app or agent that needs someone's data. You ask for exactly what you need with a selection request, receive a grant, and query within it. If you have done OAuth, this is OAuth with a stricter scope.", + start: "Start with the selection request and the grant, then the client example in the reference implementation.", + }, + { + anchor: "/self-host", + cta: "Deploy the reference server", + eyebrow: "I run the server", + title: "Make an operator", + body: "You run the authorization server, the resource server, or both. Yours is the code that records the grant and refuses anything outside it. The resource server interface is your contract, and the conformance section is what the test suite will check.", + start: "Start by running the reference implementation, then read the resource server interface against it.", + }, +]; + +interface Faq { + answer: string; + cta: string; + href: string; + question: string; + role: string; +} + +const FAQS: readonly Faq[] = [ + { + role: "Building a source", + question: "How do I get data out of a platform and into a declaration?", + answer: + "The Collection Profile is one worked approach to collection, scheduling and refresh for connector-backed sources. Use it if you do not already have a pipeline. If you do, you conform as long as your declaration validates and your server answers queries.", + cta: "Collection Profile", + href: "/specification/spec-collection-profile", + }, + { + role: "Building an operator", + question: "How does my server find a platform's declaration, and know it is real?", + answer: + "Discovery and Trust walks through locating a provider-native declaration under RFC 9728 and what to check before accepting it. The requirements themselves are in Core, under declaration acceptance, and this document is the how-to.", + cta: "Discovery and Trust", + href: "/specification/spec-discovery-and-trust", + }, + { + role: "Building an accessor", + question: "Which purpose code do I put in my request?", + answer: + "Every grant names a purpose from the registry. Most requests fit an existing code, and ai_training is the one that triggers a protocol-level consent step. If nothing fits you can propose one, and proposals are considered on a published cycle.", + cta: "Purpose registry", + href: "/specification#purpose-registry", + }, + { + role: "Any role", + question: "Core can't express the query I need. Now what?", + answer: + "Extension profiles add lexical search, aggregation and semantic query on top of Core without changing what Core means. Check whether one already covers your case before writing your own; an extension that weakens Core semantics is rejected.", + cta: "Extension profiles", + href: "/specification/spec-ext-lexical-search", + }, +]; + +export default function Page() { + return ( + + +
+ + Build with PDPP + + + Everything you need to build on PDPP is here: the specification, a reference server you can run today, and + guidance for the parts people tend to get stuck on. + + + + Open Core, clone the reference implementation + + +
+ + +
+ {ROLES.map((role) => ( +
+ + {role.eyebrow} + + + {role.title} + + + {role.body} + + + {role.start} + + + + {role.cta} → + + +
+ ))} +
+
+ + +
+
+ + Reference implementation + + + Deploy on Docker, Railway or Fly.io + + + A complete authorization and resource server with scoped access, revocation, and an MCP endpoint so you + can point an assistant at it and watch grants get enforced. Use it to see the protocol behave before you + write your own. + + + + Deploy guide → + + +
+
+ + Try a real source + + + Connect GitHub or ChatGPT to it + + + DataConnect ships signed connectors for both. Install it, grant an app one stream for one week, then + revoke it, and you have seen the whole protocol. + + + + DataConnect releases → + + +
+
+
+ + +
+ {FAQS.map((faq) => ( +
+ + {faq.role} + + + {faq.question} + + + {faq.answer} + + + + {faq.cta} → + + +
+ ))} +
+ + + Built on PDPP? Get it verified → + + +
+
+
+ ); +} diff --git a/apps/site/src/app/(concept)/principles/page.tsx b/apps/site/src/app/(concept)/principles/page.tsx new file mode 100644 index 0000000000..3585cd84b8 --- /dev/null +++ b/apps/site/src/app/(concept)/principles/page.tsx @@ -0,0 +1,98 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Metadata } from "next"; +import { PdppConceptDoc, PdppConceptPage } from "@/components/layout/concept-page.tsx"; +import { PdppConceptSection } from "@/components/sections/concept-section.tsx"; +import { PdppPrinciplesList } from "@/components/site/principles-list.tsx"; +import { PdppSigningForm } from "@/components/site/signing-form.tsx"; +import { PdppSupportersTable, readPublicSupporters } from "@/components/site/supporters.tsx"; +import { Text } from "@/components/typography/text.tsx"; +import { PRINCIPLES_FRONT_MATTER, PRINCIPLES_PREAMBLE } from "@/generated/spec-front-matter.ts"; +import { repoBlobUrl } from "@/lib/site-facts.ts"; + +export const metadata: Metadata = { + alternates: { canonical: "/principles" }, + description: "The intentions behind the Personal Data Portability Protocol, and the public register of Supporters.", + openGraph: { url: "/principles" }, + title: "Principles - PDPP", +}; + +// The Principles page. +// +// Every word of the preamble and the six principles comes from the repo-root +// PRINCIPLES.md through the generated module, never from this file. That +// document is what a Supporter signs; a paraphrase on the page they sign from +// would be a different document. +// +// The version strip states the version and publication date because a +// signature attaches to a version. It is the one fact a signatory needs before +// they act, and PRINCIPLES.md's own Status line is where it comes from. + +export default async function Page() { + const supporters = await readPublicSupporters(); + + return ( + + +
+ + PDPP Principles · v{PRINCIPLES_FRONT_MATTER.version} · {PRINCIPLES_FRONT_MATTER.status} + + + The principles guiding PDPP + +
+ +
+ {PRINCIPLES_PREAMBLE.map((paragraph) => ( + + {paragraph} + + ))} +
+ +
+ +
+ + +
+ + Anyone. Individuals, companies, universities, civil society groups, and public bodies are all welcome. + Individuals appear as first name and last initial; organisations by name. Your email is never shown. + + + Individuals sign for themselves rather than their employer. Signing is not a commitment to build anything + or a sign-off on every line of the specification, and you can withdraw by email at any time. + +
+
+ +
+
+ + +
+ + + {/* The published register is one file, and this names it. A + reader who wants the list without the page can take it. */} + + /principles/supporters.json + + +
+
+ + +
+
+ ); +} diff --git a/apps/site/src/app/(concept)/privacy/page.tsx b/apps/site/src/app/(concept)/privacy/page.tsx new file mode 100644 index 0000000000..3e7346b26c --- /dev/null +++ b/apps/site/src/app/(concept)/privacy/page.tsx @@ -0,0 +1,145 @@ +// 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 { PdppConceptSection } from "@/components/sections/concept-section.tsx"; +import { Text } from "@/components/typography/text.tsx"; +import { siteConfig } from "@/lib/site-config.ts"; + +export const metadata: Metadata = { + alternates: { canonical: "/privacy" }, + description: "What PDP-Connect stores when you sign the Principles, what is published, and how to withdraw.", + openGraph: { url: "/privacy" }, + title: "Privacy - PDPP", +}; + +// The privacy statement for the interim Supporter register. +// +// Every party-specific value comes from config: the controller and the contact +// address are not settled until LF Decentralized Trust hosting is confirmed, +// and a page that guessed either would be making a legal claim on someone +// else's behalf. Until they are set the reader sees a bracketed placeholder, +// which is honest, rather than a plausible-looking name that is wrong. +// +// This page covers ONLY the signing system. The site sets no cookies for +// analytics and runs no third-party tracker, so there is nothing else to +// state; if that ever changes this page grows a section rather than a separate +// document appearing somewhere else. + +const STORED = [ + "The name you entered, and for an organisation the organisation name, the signatory's name and their role.", + "Your email address.", + "Your country and, for an organisation, its type.", + "The four consent choices you made on the form.", + "The version of the Principles you signed and the time your signature was confirmed.", +] as const; + +const PUBLISHED = [ + "A public name: your first name and last initial if you signed as an individual, or the organisation name if you signed for an organisation.", + "Your country.", + "The type, for organisations.", + "The date you signed and the version of the Principles you signed.", +] as const; + +export default function Page() { + return ( + + +
+ + Privacy + + + This page describes what happens to the details you give when you sign the PDPP Principles. It is the only + place on this site where personal data is collected. + +
+ + + + Your details are held by {siteConfig.controllerName} on behalf of PDP-Connect until LF Decentralized Trust + hosting is confirmed, and will be transferred then. This is an interim arrangement, and the transfer is + described in the project's register documentation. + + + + + + To confirm that a signature is genuine, to publish the register of Supporters, and, if you asked for it, to + tell you about new versions of the specification and comment periods. We do not use it for anything else, + and we never sell or share it. + + + + +
    + {STORED.map((item) => ( +
  • + + {item} + +
  • + ))} +
+ + This is held in a private repository that only the project's maintainers can read. + +
+ + +
    + {PUBLISHED.map((item) => ( +
  • + + {item} + +
  • + ))} +
+ + Your email address is never published. Neither is the name or role of the person who signed for an + organisation. + +
+ + +
+ + When you submit the form we send one email to the address you gave, containing a link that confirms your + signature. The link expires after 48 hours and can be used once. If you do not use it, nothing is + published and the details you submitted are discarded. + + + That same email carries a withdrawal link. Using it deletes your record. Only the date of the withdrawal + is kept, with nothing that identifies you, so we can account for the size of the register over time. Your + entry leaves the public list the next time it is published. + + + No other email is ever sent by this system. + +
+
+ + + + Until you withdraw, or until the register is transferred to LF Decentralized Trust, whichever comes first. + On transfer, the whole register moves to the successor controller and the interim copy is deleted. + + + + + + Write to {siteConfig.privacyContact} to ask what is held about you, to correct it, or to have it deleted. + + + + Back to the Principles → + + + +
+
+ ); +} diff --git a/apps/site/src/app/(concept)/review/page.tsx b/apps/site/src/app/(concept)/review/page.tsx new file mode 100644 index 0000000000..44ffd0315e --- /dev/null +++ b/apps/site/src/app/(concept)/review/page.tsx @@ -0,0 +1,208 @@ +// 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 { PdppConceptSection } from "@/components/sections/concept-section.tsx"; +import { Text } from "@/components/typography/text.tsx"; +import { REPORTS_EMAIL_HREF } from "@/lib/site-config.ts"; +import { GITHUB_NEW_ISSUE_URL } from "@/lib/site-facts.ts"; +import { cn } from "@/lib/utils.ts"; + +export const metadata: Metadata = { + alternates: { canonical: "/review" }, + description: "The specification and its governance are open for public comment until 1 October 2026.", + openGraph: { url: "/review" }, + title: "Review the specification - PDPP", +}; + +// The temporary Review page. +// +// This page and the reader's own version strip are the ONLY places on the site +// that name the review period. Everything else that referenced it (the nav +// dropdown entry, the ticker) is behind the reviewOpen flag, so when the +// period closes the flag goes off and this page is the single thing left to +// retire. A deadline repeated across a site has to be unwound from every place +// that repeated it. +// +// Section numerals ARE allowed here, unlike the rest of the site: this page's +// whole job is to point a reader at particular clauses to comment on, and +// "read §7" is the instruction. Step 1-3 are content, not section ordinals. + +const CARD = cn("flex flex-col gap-3 bg-background p-6", "shadow-[0_0_0_1px_var(--border)]"); + +const READING = [ + "§5 Source declaration, including Declaration acceptance", + "§7 Grant", + "§8 Resource server interface", + "§9 Conformance", +] as const; + +const TIMELINE = [ + { + date: "3 September", + text: "The specification and Part A are frozen, the Principles are published, and Supporter signing opens.", + }, + { date: "1 October", text: "The comment period closes, and answers are published after." }, + { date: "15 October", text: "The programme opens." }, +] as const; + +export default function Page() { + return ( + + +
+ + Formal review · open until 1 October 2026 + + + Review the specification + + {/* Copy delta 3: the "one document of nine sections" sentence is + deleted from this lede. */} + + The Personal Data Portability Protocol v0.1.0 and its governance are open for public comment until 1 + October. + +
+ + + Open the specification → + + + + + Open governance → + + +
+
+ + + + Step 1 + +
+
+ + Specification · about 45 minutes + + + Four sections carry most of the weight + + + If you only have twenty minutes, read §7. + +
    + {READING.map((item) => ( +
  • + + {item} + +
  • + ))} +
+ + + Open the specification → + + +
+
+ + Governance · about 20 minutes + + + Two parts, one of them still open + + + Part A is how PDP-Connect runs from 15 October and is frozen during review. Part B is the proposed + long-term structure and is where your comment will change the most. Two open questions: five steering + committee seats or three, and whether one organisation one vote puts off large companies. + + + + Open governance → + + +
+
+
+ + + + Step 2 + + {/* Copy delta 1: the "In person / Working sessions" channel is + deleted. Two channels remain. */} +
+
+ + On GitHub + + + One issue per point + + + Every section of the specification has a comment link that opens an issue with the section pre-filled. + Say what is wrong, what you would change, and why. + + + + Open an issue → + + +
+
+ + By email + + + Same log, no account needed + + + Email your comments and we add them to the same public log, under whatever name you ask for. + + + + pdpp-dev-reports@lfdecentralizedtrust.org → + + +
+
+
+ + + + Step 3 + + {/* Copy delta 2: both sentences replaced. */} + + Every comment is logged in one public log. Substantive comments are answered: accepted, declined with a + reason, or parked, and the answers are published there too. If a comment changes something substantial, the + new text goes out for another 15 days before it is final. During that period we may hold one-to-one + conversations with commenters. + +
    + {TIMELINE.map((entry) => ( +
  • + + {entry.date} + + + {entry.text} + +
  • + ))} +
+
+
+
+ ); +} diff --git a/apps/site/src/app/principles/layout.tsx b/apps/site/src/app/principles/layout.tsx deleted file mode 100644 index a5ef8e63e3..0000000000 --- a/apps/site/src/app/principles/layout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright The PDP-Connect Contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { ReactNode } from "react"; -import { SpecificationShell } from "@/components/specification/shell.tsx"; -import { getPrinciplesFrontMatter } from "@/lib/spec-front-matter.ts"; -import "@/styles/surfaces/concept/index.css"; -import "@/styles/surfaces/specification.css"; - -// Same shell and stylesheets as /governance, for the same reason: the -// Principles are a different KIND of document from the specification, not a -// different visual language. Reusing the shell is also what puts the rail on -// this page, so a reader who arrives at /principles sees the governance -// document and the specification set beside it. -// -// railFrontMatter is tagged "principles" and carries PRINCIPLES.md's own -// Version/Status, not the specification's VERSION/STATUS/DATE/EDITORS block. -// That block describes the SPECIFICATION; showing "Status: Normative draft" -// beside a document that is not protocol text at all would contradict the page. -export default function Layout({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} diff --git a/apps/site/src/app/principles/page.tsx b/apps/site/src/app/principles/page.tsx deleted file mode 100644 index 725257920b..0000000000 --- a/apps/site/src/app/principles/page.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright The PDP-Connect Contributors -// SPDX-License-Identifier: Apache-2.0 - -import { Callout } from "fumadocs-ui/components/callout"; -import { DocsBody, DocsDescription, DocsPage } from "fumadocs-ui/layouts/docs/page"; -import { createRelativeLink } from "fumadocs-ui/mdx"; -import type { Metadata } from "next"; -import { notFound } from "next/navigation"; -import { LLMCopyButton, ViewOptions } from "@/components/ai/page-actions.tsx"; -import { getMDXComponents } from "@/components/mdx/mdx.tsx"; -import { ResponsiveSpecTable } from "@/components/mdx/responsive-table.tsx"; -import { Text } from "@/components/typography/text.tsx"; -import { getPageMarkdownUrl, source } from "@/lib/docs-source.ts"; -import { repoBlobUrl, SUPPORTER_SIGNING_INTERIM_NOTICE, SUPPORTER_SIGNING_OPEN } from "@/lib/site-facts.ts"; -import { PRINCIPLES_SLUG, principlesRoute } from "@/lib/spec-nav-slugs.ts"; - -// The PDPP Principles, served at their own top-level route. -// -// Everything here mirrors /governance, and for the same reasons: the document -// is AUTHORED as a fumadocs page (content/docs/principles.mdx, generated from -// the repo-root PRINCIPLES.md by scripts/sync-spec-docs.mjs) because the TOC, -// the Copy Markdown affordance and search indexing all come from membership in -// that collection — /api/search indexes the fumadocs source and nothing else. -// It is SERVED here rather than at /specification/principles because it is not -// a specification. fumadocs still routes the page under /specification, which -// 308-redirects here (see next.config.mjs) so the document is not served at two -// addresses. - -export default async function Page() { - const page = source.getPage([PRINCIPLES_SLUG]); - - if (!page) { - notFound(); - } - - const { body: MDX, toc } = await page.data.load(); - - return ( - -
-
- - {page.data.title} - - {page.data.description && ( - {page.data.description} - )} -
- - {/* The repo-root PRINCIPLES.md, NOT content/docs/principles.mdx: - the latter is generated and gitignored, so a blob link to it - 404s on GitHub. Root is also where a reader is meant to find - this document. */} - -
-
-
- - - {/* The document's last section is "Add your name", which describes - signing in the present tense. Signing is not open yet, so ONE - notice follows the rendered document saying what it is waiting on. - No form: there is nowhere for a signature to go until the register - has a host. Both the flag and the wording live in site-facts.ts, so - opening signing is a single edit there. */} - {SUPPORTER_SIGNING_OPEN ? null : ( - - {SUPPORTER_SIGNING_INTERIM_NOTICE}. - - )} - -
- ); -} - -export function generateMetadata(): Metadata { - const page = source.getPage([PRINCIPLES_SLUG]); - - if (!page) { - notFound(); - } - - // Canonical is this route, not page.url (which fumadocs resolves to - // /specification/principles, the URL that redirects here). - return { - alternates: { canonical: principlesRoute }, - description: page.data.description, - openGraph: { url: principlesRoute }, - title: page.data.title, - }; -} diff --git a/apps/site/src/components/site/principles-list.tsx b/apps/site/src/components/site/principles-list.tsx new file mode 100644 index 0000000000..fdbdd334fb --- /dev/null +++ b/apps/site/src/components/site/principles-list.tsx @@ -0,0 +1,52 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { PRINCIPLES_LIST } from "@/generated/spec-front-matter.ts"; +import { cn } from "@/lib/utils.ts"; +import { Text } from "../typography/text.tsx"; + +// The six principles, rendered from PRINCIPLES.md via the generated module. +// Nothing here retypes them: that document is what people sign, and a second +// copy in site source is a second thing that has to stay true. +// +// The numerals ARE content here, not section ordinals. The copy rule that +// strips "01"/"02" from section headings does not reach them: the Principles +// are a numbered list in the signed document, and a Supporter who signed +// "principle 4" needs 4 to still be 4. +// +// `body` is optional on the type because a principle whose text moved onto the +// following line would otherwise render an empty paragraph rather than fail; +// the generator already throws unless exactly six parse, so this is belt and +// braces for shape, not for count. + +export function PdppPrinciplesList({ className, compact = false }: { className?: string; compact?: boolean }) { + return ( +
    + {PRINCIPLES_LIST.map((principle, index) => ( +
  1. +
    + + {String(index + 1).padStart(2, "0")} + + + {principle.title}. + +
    + {principle.body && ( + + {principle.body} + + )} +
  2. + ))} +
+ ); +} diff --git a/apps/site/src/components/site/signing-form.tsx b/apps/site/src/components/site/signing-form.tsx new file mode 100644 index 0000000000..2f52a6437d --- /dev/null +++ b/apps/site/src/components/site/signing-form.tsx @@ -0,0 +1,215 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +"use client"; + +import { useState } from "react"; +import { Text } from "@/components/typography/text.tsx"; +import { siteConfig, siteFlags, signingDisclosure } from "@/lib/site-config.ts"; +import { cn } from "@/lib/utils.ts"; + +// The Supporter signing form. +// +// Behind siteFlags.signingLive. When the flag is off the form is not rendered +// at all — not disabled, not hidden with CSS. A disabled form still ships the +// field names and the endpoint to every reader, and a hidden one can be +// re-enabled from devtools and posted to. Off means absent. +// +// The form posts to siteConfig.formEndpoint and does nothing else client-side: +// no validation logic here is load-bearing. Every rule that matters (the +// schema, the rate limit, the organisation-domain check) is enforced in +// /api/sign, because anything checked only in this file is checked only for +// people who use the form. +// +// The country and type lists are the prototype's. They are deliberately short +// and will grow; a free-text country field would make the public register +// unsortable and invite an address. + +const COUNTRIES = [ + "Australia", + "Germany", + "Netherlands", + "Switzerland", + "United Kingdom", + "United States", +] as const; + +const ORGANISATION_TYPES = ["Company", "Platform", "Research institute", "Civil society", "Public body"] as const; + +type SignatoryKind = "individual" | "organisation"; + +const fieldClassName = cn( + "w-full border border-border bg-background px-3 py-2", + "font-sans text-[15px] text-foreground", + "focus-visible:border-primary focus-visible:outline-none" +); + +function Field({ + children, + htmlFor, + label, +}: { + children: React.ReactNode; + htmlFor: string; + label: string; +}) { + return ( +
+ + {label} + + {children} +
+ ); +} + +function Consent({ id, name, children }: { children: React.ReactNode; id: string; name: string }) { + return ( + + ); +} + +export function PdppSigningForm() { + const [kind, setKind] = useState("individual"); + + if (!siteFlags.signingLive) { + return ( +
+ + Signing opens once hosting is confirmed with LF Decentralized Trust. + +
+ ); + } + + const isOrganisation = kind === "organisation"; + + return ( +
+ + + + {/* Two buttons rather than a select: the choice changes which fields and + which consents are shown, so it is a mode, not a value. */} +
+ {(["individual", "organisation"] as const).map((option) => ( + + ))} +
+ +
+ + + + + + + + {isOrganisation && ( + + + + )} + {!isOrganisation && ( + + + + )} + + + + + + {isOrganisation && ( + + + + )} +
+ + + {isOrganisation + ? "We show your organisation name, your country, and the date. We never show your email." + : "We show your first name and last initial, your country, and the date. We never show your email."} + + +
+ + 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. + + )} +
+ +
+ + + We email you to confirm. Organisations sign from an address at their own domain. + + {/* The interim controller disclosure, from config so the transfer to + LFDT is one edit rather than a hunt through page copy. */} + + {signingDisclosure()} + +
+
+ ); +} diff --git a/apps/site/src/components/site/supporters.tsx b/apps/site/src/components/site/supporters.tsx new file mode 100644 index 0000000000..cd3c794cfd --- /dev/null +++ b/apps/site/src/components/site/supporters.tsx @@ -0,0 +1,109 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { Text } from "@/components/typography/text.tsx"; +import { cn } from "@/lib/utils.ts"; + +// The public register of Supporters. +// +// The prototype shows six example rows and a "[ 247 ] signatories" counter, +// labelled in the design as layout-only. Those are NOT rendered: the site +// ships supporters.json as an empty array, and a page that shows invented +// signatories is a page that has published a false register. The empty state +// below is what a reader sees until the first real signature lands. +// +// SHAPE: this file is the only consumer of supporters.json, and it reads ONLY +// the five public fields. The private store holds email, the signatory's name +// and role, and the consent flags; none of them have a route to this +// component, because the publish script never writes them into the file this +// reads. See docs/registers.md. + +export interface PublicSupporter { + /** First name and last initial for individuals, organisation name otherwise. */ + publicName: string; + type: string; + country: string; + signedOn: string; + principlesVersion: string; +} + +// Read at build time from the public JSON. A fetch at request time would put a +// network hop in front of a file that ships in the repo, and would 500 the +// page when it failed; a missing or malformed file here degrades to the empty +// state, which is the honest rendering when the register cannot be read. +export async function readPublicSupporters(): Promise { + try { + const file = path.join(process.cwd(), "public", "principles", "supporters.json"); + const parsed: unknown = JSON.parse(await readFile(file, "utf8")); + return Array.isArray(parsed) ? (parsed as PublicSupporter[]) : []; + } catch { + return []; + } +} + +const CELL = "px-3 py-2.5 text-left align-top"; + +export function PdppSupportersTable({ supporters }: { supporters: readonly PublicSupporter[] }) { + if (supporters.length === 0) { + return ( +
+ + No signatories are listed yet. The register is published here as soon as the first signature is confirmed. + +
+ ); + } + + return ( + // Wide tables scroll inside their own scrollport rather than pushing the + // page sideways. +
+ + + + {["Signatory", "Type", "Country", "Version", "Signed"].map((heading) => ( + + ))} + + + + {supporters.map((supporter) => ( + + + + + + + + ))} + +
+ + {heading} + +
+ + {supporter.publicName} + + + + {supporter.type} + + + + {supporter.country} + + + + {supporter.principlesVersion} + + + + {supporter.signedOn} + +
+
+ ); +} diff --git a/apps/site/src/lib/site-config.ts b/apps/site/src/lib/site-config.ts index a66e90697a..0342b4e355 100644 --- a/apps/site/src/lib/site-config.ts +++ b/apps/site/src/lib/site-config.ts @@ -68,6 +68,7 @@ export const siteConfig = { // address fixed by GOVERNANCE.md's own header, and a deployment that could // point it elsewhere could silently redirect a conduct or security report. export const REPORTS_EMAIL = "pdpp-dev-reports@lfdecentralizedtrust.org"; +export const REPORTS_EMAIL_HREF = `mailto:${REPORTS_EMAIL}`; // Rendered verbatim under the signing form. Sentence supplied by the // programme, with the controller interpolated from config so there is one From a2824e503f1d4636336def2fb1ba9e053a4b78a3 Mon Sep 17 00:00:00 2001 From: Art A <129354338+artvana@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:31:42 +0200 Subject: [PATCH 3/9] site: home page and specification with inline governance Home carries the hero, the OAuth-versus-grant comparison, the six principles, the Supporters register and the build entry point. The hero's right-hand element is the repo's own scrolling data columns rather than the grant card the prototype puts there. The design export and the build prompt both call for the columns, and the grant card already carries the comparison below: rendering it twice would spend the page's one strong artifact on decoration. The comparison is components, not an image. It is the one place the site shows the argument rather than stating it, so it has to reflow, respect the theme, be selectable and read aloud correctly. The two panels deliberately do not share a card shell: they show a consent dialog and a record, and one shell would flatten the difference the section exists to make. /specification now renders GOVERNANCE.md inline at #governance, with the four-stage diagram built from components for the same reasons. /governance 308s there and the standalone route is gone. One page carries the protocol and the programme that stewards it. The contents rail becomes Core's own nine sections as in-page anchors, then the informative documents under "Implementer guidance, informative", then Appendix A. The sections are hand-declared rather than picked from the fumadocs tree because they are anchors on this page, and the tree only knows about documents. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NECjjZbYWNTzq3bu7nuiDP Signed-off-by: Art A <129354338+artvana@users.noreply.github.com> --- apps/site/next.config.mjs | 10 +- apps/site/src/app/(concept)/page.tsx | 106 ++++++++++++-- apps/site/src/app/governance/layout.tsx | 26 ---- apps/site/src/app/governance/page.tsx | 90 ------------ .../app/specification/[[...slug]]/page.tsx | 51 ++++++- .../src/components/site/governance-stages.tsx | 86 +++++++++++ .../site/src/components/site/what-changes.tsx | 138 ++++++++++++++++++ .../src/components/specification/rail.tsx | 2 +- apps/site/src/lib/docs-source.ts | 81 +++++----- apps/site/src/lib/spec-nav-slugs.ts | 10 +- 10 files changed, 425 insertions(+), 175 deletions(-) delete mode 100644 apps/site/src/app/governance/layout.tsx delete mode 100644 apps/site/src/app/governance/page.tsx create mode 100644 apps/site/src/components/site/governance-stages.tsx create mode 100644 apps/site/src/components/site/what-changes.tsx diff --git a/apps/site/next.config.mjs b/apps/site/next.config.mjs index bf6e05b457..1ea2cb247f 100644 --- a/apps/site/next.config.mjs +++ b/apps/site/next.config.mjs @@ -111,8 +111,16 @@ const nextConfig = { // document, not a specification, so it is served at /governance. fumadocs // still routes the page under /specification; that URL redirects to the // canonical one rather than serving the same document at two addresses. + // /governance keeps working and lands on the governance anchor of + // /specification, which now renders GOVERNANCE.md inline. Permanent: + // the standalone route is gone, not moved temporarily. { - destination: "/governance", + destination: "/specification#governance", + permanent: true, + source: "/governance", + }, + { + destination: "/specification#governance", permanent: true, source: "/specification/governance", }, diff --git a/apps/site/src/app/(concept)/page.tsx b/apps/site/src/app/(concept)/page.tsx index 67903b42e0..85be77eb82 100644 --- a/apps/site/src/app/(concept)/page.tsx +++ b/apps/site/src/app/(concept)/page.tsx @@ -2,26 +2,106 @@ // SPDX-License-Identifier: Apache-2.0 import type { Metadata } from "next"; -import { PdppConceptDoc, PdppConceptFrontPage } from "@/components/layout/concept-page.tsx"; -import { PdppFrontDoor } from "@/components/sections/front-door.tsx"; -import { SITE_DESCRIPTION, SITE_TITLE } from "@/lib/site-facts.ts"; +import Link from "next/link"; +import { PdppConceptDoc, PdppConceptPage } from "@/components/layout/concept-page.tsx"; +import { PdppConceptSection } from "@/components/sections/concept-section.tsx"; +import { PdppHeroWaterStill } from "@/components/sections/hero-water-still.tsx"; +import { PdppPrinciplesList } from "@/components/site/principles-list.tsx"; +import { PdppSupportersTable, readPublicSupporters } from "@/components/site/supporters.tsx"; +import { PdppWhatChanges } from "@/components/site/what-changes.tsx"; +import { Text } from "@/components/typography/text.tsx"; +import { cn } from "@/lib/utils.ts"; export const metadata: Metadata = { alternates: { canonical: "/" }, - openGraph: { - description: SITE_DESCRIPTION, - title: SITE_TITLE, - type: "website", - url: "/", - }, + description: + "Lets personal data move freely, with consent. Decide what an app can see, for what, and for how long.", + openGraph: { url: "/" }, + title: "PDPP: Personal Data Portability Protocol", }; -export default function Home() { +// The front door. +// +// The hero's right-hand element is the repo's own scrolling data columns, not +// the grant card the prototype shows there. Two reasons, and they agree: the +// design export puts the columns in the hero, and the grant card already +// appears below in "What changes", where it carries the comparison. Rendering +// it twice would spend the page's one strong artifact on decoration. +// +// The columns are aria-hidden inside their own component: they are texture +// standing in for "records flowing", not content, and announcing eighteen rows +// of sample fields to a screen reader before the page's actual argument would +// bury it. + +const CTA = cn( + "inline-flex w-fit items-center border border-primary bg-primary px-5 py-2.5", + "font-sans text-[15px] text-on-primary-emphasis no-underline", + "hover:border-primary-emphasis hover:bg-primary-emphasis hover:text-on-primary-emphasis!" +); + +export default async function Page() { + const supporters = await readPublicSupporters(); + return ( - + - +
+
+ + The Personal Data Portability Protocol + + + The open standard for personal data. + + + Lets personal data move freely, with consent. Decide what an app can see, for what, and for how long. + + + Become a Supporter + +
+ {/* Fixed height so the mask has something to fade against; the + columns scroll inside it rather than growing the hero. */} +
+ +
+
+ + + + + + +
+ +
+ + Become a Supporter + +
+ + +
+ + + + All Supporters → + + +
+
+ + + + One specification, a reference server you can run today, and three roles to build for. Pick yours. + + + + Start building → + + +
-
+ ); } diff --git a/apps/site/src/app/governance/layout.tsx b/apps/site/src/app/governance/layout.tsx deleted file mode 100644 index e11a9dc3c5..0000000000 --- a/apps/site/src/app/governance/layout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright The PDP-Connect Contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { ReactNode } from "react"; -import { SpecificationShell } from "@/components/specification/shell.tsx"; -import { getGovernanceFrontMatter } from "@/lib/spec-front-matter.ts"; -import "@/styles/surfaces/concept/index.css"; -import "@/styles/surfaces/specification.css"; - -// Same shell, same stylesheets as /specification: the governance document is a -// different KIND of document, not a different visual language. Reusing the -// shell is also what puts the rail on this page, so a reader who arrives at -// /governance can still see the specification set beside it. -// -// railFrontMatter is tagged "governance" and carries GOVERNANCE.md's own -// Status/Circulated/Formal review/Programme live facts, not the -// specification's VERSION/STATUS/DATE/EDITORS block. That block describes the -// SPECIFICATION; showing "Status: Normative draft" beside a document whose own -// callout says it is not normative protocol text would contradict the page. -export default function Layout({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} diff --git a/apps/site/src/app/governance/page.tsx b/apps/site/src/app/governance/page.tsx deleted file mode 100644 index 2855d8ae14..0000000000 --- a/apps/site/src/app/governance/page.tsx +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright The PDP-Connect Contributors -// SPDX-License-Identifier: Apache-2.0 - -import { DocsBody, DocsDescription, DocsPage } from "fumadocs-ui/layouts/docs/page"; -import { createRelativeLink } from "fumadocs-ui/mdx"; -import type { Metadata } from "next"; -import { notFound } from "next/navigation"; -import { LLMCopyButton, ViewOptions } from "@/components/ai/page-actions.tsx"; -import { getMDXComponents } from "@/components/mdx/mdx.tsx"; -import { ResponsiveSpecTable } from "@/components/mdx/responsive-table.tsx"; -import { Text } from "@/components/typography/text.tsx"; -import { getPageMarkdownUrl, source } from "@/lib/docs-source.ts"; -import { repoBlobUrl } from "@/lib/site-facts.ts"; -import { GOVERNANCE_SLUG, governanceRoute } from "@/lib/spec-nav-slugs.ts"; - -// The governance programme document, served at its own top-level route. -// -// It is AUTHORED as a fumadocs page (content/docs/governance.md, generated from -// the repo-root GOVERNANCE.md by scripts/sync-spec-docs.mjs) because everything -// this page needs — the TOC, the Copy Markdown affordance, and search indexing — -// comes from membership in that collection: /api/search indexes the fumadocs -// source and nothing else. It is SERVED here rather than at -// /specification/governance because it is not a specification: the six spec -// documents change through the Community Specification process under CSL-1.0, -// this one by a vote of Partners. fumadocs still routes the page under -// /specification, which 308-redirects here (see next.config.mjs) so the -// document is not served at two addresses. -// -// This mirrors the specification route's renderer rather than sharing it: that -// route is a catch-all whose slug handling, root-slug redirect target and -// per-page noindex rules are all specification concerns that do not apply to a -// single fixed programme page. - -export default async function Page() { - const page = source.getPage([GOVERNANCE_SLUG]); - - if (!page) { - notFound(); - } - - const { body: MDX, toc } = await page.data.load(); - - return ( - -
-
- - {page.data.title} - - {page.data.description && ( - {page.data.description} - )} -
- - {/* The repo-root GOVERNANCE.md, NOT content/docs/governance.md: - the latter is generated and gitignored, so a blob link to it - 404s on GitHub. Root is also where a reader is meant to find - this document. */} - -
-
-
- - - -
- ); -} - -export function generateMetadata(): Metadata { - const page = source.getPage([GOVERNANCE_SLUG]); - - if (!page) { - notFound(); - } - - // Canonical is this route, not page.url (which fumadocs resolves to - // /specification/governance, the URL that redirects here). - return { - alternates: { canonical: governanceRoute }, - description: page.data.description, - openGraph: { url: governanceRoute }, - title: page.data.title, - }; -} diff --git a/apps/site/src/app/specification/[[...slug]]/page.tsx b/apps/site/src/app/specification/[[...slug]]/page.tsx index e319ac0c52..3544bd346e 100644 --- a/apps/site/src/app/specification/[[...slug]]/page.tsx +++ b/apps/site/src/app/specification/[[...slug]]/page.tsx @@ -12,7 +12,9 @@ import { ResponsiveSpecTable } from "@/components/mdx/responsive-table.tsx"; import { Text } from "@/components/typography/text.tsx"; import { getPageMarkdownUrl, source } from "@/lib/docs-source.ts"; import { repoBlobUrl } from "@/lib/site-facts.ts"; -import { MAINTAINER_DOC_SLUGS, specDocExtension } from "@/lib/spec-nav-slugs.ts"; +import { PdppGovernanceStages } from "@/components/site/governance-stages.tsx"; +import { getGovernanceFrontMatter } from "@/lib/spec-front-matter.ts"; +import { GOVERNANCE_SLUG, MAINTAINER_DOC_SLUGS, specDocExtension } from "@/lib/spec-nav-slugs.ts"; interface DocsPageProps { params: Promise<{ @@ -88,11 +90,58 @@ export default async function Page({ params }: DocsPageProps) { table: ResponsiveSpecTable, })} /> + {/* The governance document is rendered INTO this page, at #governance, + rather than at a standalone route: /governance now redirects here. + One page carries the protocol and the programme that stewards it, + which is the relationship the four-intent structure is asserting. + Only the root slug gets it — a reader on an extension profile is + not asking about governance. */} + {isRootSlug && } ); } +// GOVERNANCE.md, rendered from the same generated fumadocs collection the spec +// body comes from, so it keeps the repo-root file as its single source. +async function GovernanceSection() { + const governancePage = source.getPage([GOVERNANCE_SLUG]); + + if (!governancePage) { + return null; + } + + const { body: GovernanceMDX } = await governancePage.data.load(); + + return ( +
+
+ + Governance · {getGovernanceFrontMatter().status} · {getGovernanceFrontMatter().circulated} + + + Who runs this, and who decides + + + Two parts. Part A is how it runs from 15 October. Part B is how we propose it runs once there is an elected + committee. + +
+ + + +
+ +
+
+ ); +} + export function generateStaticParams() { return source.generateParams(); } diff --git a/apps/site/src/components/site/governance-stages.tsx b/apps/site/src/components/site/governance-stages.tsx new file mode 100644 index 0000000000..1a3c7641df --- /dev/null +++ b/apps/site/src/components/site/governance-stages.tsx @@ -0,0 +1,86 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { Text } from "@/components/typography/text.tsx"; +import { cn } from "@/lib/utils.ts"; + +// The four-stage governance diagram, built from components rather than shipped +// as an image. +// +// It is a diagram in the sense that matters — the reader sees four stages in +// order, each with when it starts and who holds authority — but it is text, so +// it reflows on a phone, respects the reader's theme, is selectable and +// searchable, and reads correctly aloud. An exported picture of this would be +// none of those things and would go stale the first time a date moved. +// +// The connector chevrons are aria-hidden: "→" announced between every stage is +// noise, and the ordered list already carries the sequence. + +interface Stage { + authority: string; + body: string; + name: string; + when: string; +} + +const STAGES: readonly Stage[] = [ + { + when: "Pre · now, to 15 Oct", + name: "Public comment", + authority: "The maintainers", + body: "The specification and Part A are frozen while people comment, the Principles are published, and you can sign now. This stage ends on 15 October.", + }, + { + when: "Phase 1 · from 15 Oct", + name: "Launch", + authority: "The maintainers", + body: "The maintainers run things. Supporters can sign, but nobody can be verified until the review committee is named.", + }, + { + when: "Phase 2 · from Nov 2026", + name: "Interim", + authority: "Interim technical committee", + body: "Five people, at least three with no connection to Vana or Open Data Labs, chosen on published criteria and not removable by the maintainers. They review every application for verified status in public and give a decision with reasons, which the maintainers act on. Verification opens at this point.", + }, + { + when: "Phase 3 · from 2027", + name: "Full", + authority: "Elected steering committee", + body: "Partners elect a five-seat steering committee, one organisation one vote, with the Chair elected directly. The steering committee is the only body that can change the specification or grant a status, and it does both by majority vote. It appoints a technical committee, which reviews every application and every proposed change in public and recommends. The Chair records the vote and merges. This starts at 100 Partners or on 15 October 2027, whichever comes first, and not before 15 April 2027. Full detail is in Part B of the governance document.", + }, +]; + +export function PdppGovernanceStages({ className }: { className?: string }) { + return ( +
+
    + {STAGES.map((stage) => ( +
  1. + + {stage.when} + + + {stage.name} + + + {stage.authority} + + + {stage.body} + +
  2. + ))} +
+ +
+ + How decisions are made once the steering committee exists + + + Before the steering committee exists, the interim technical committee recommends and the maintainers merge on + that recommendation with no discretion. The shape is the same; only the body that votes is missing. + +
+
+ ); +} diff --git a/apps/site/src/components/site/what-changes.tsx b/apps/site/src/components/site/what-changes.tsx new file mode 100644 index 0000000000..ec41aef6f8 --- /dev/null +++ b/apps/site/src/components/site/what-changes.tsx @@ -0,0 +1,138 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { Text } from "@/components/typography/text.tsx"; +import { cn } from "@/lib/utils.ts"; + +// The OAuth-versus-grant comparison, built as components rather than images. +// +// It is the one place on the site that shows the protocol's argument instead +// of stating it, so it has to be readable: an image would carry the same +// picture at one fixed size, in one theme, unselectable, unsearchable and +// invisible to a screen reader. Everything below is real text in real boxes. +// +// The two panels deliberately do NOT share a card component. They are showing +// two different things — a consent dialog and a record — and giving them one +// shell would flatten exactly the difference the section exists to make. + +const OAUTH_SCOPES = [ + "Read all your sleep, activity and health data", + "Read your profile, contacts and location history", + "Keep access until you revoke it", +] as const; + +const GRANT_ROWS: readonly (readonly [string, React.ReactNode])[] = [ + ["app", "Habit Coach"], + ["source", "Sleep Tracker"], + [ + "fields", + <> + sleep_score, duration_min +
+ not heart_rate, not location + , + ], + ["range", "1 Jun 2026 to 1 Sep 2026"], + ["purpose", "personalisation"], + ["expires", "1 Dec 2026"], +]; + +function TodayPanel() { + return ( +
+ + Today + +
+
+ + + Sleep Tracker + +
+
+ + Habit Coach wants to access your account + +
    + {OAUTH_SCOPES.map((scope) => ( +
  • + + {scope} + +
  • + ))} +
+
+ + Allow + + Deny +
+
+
+ + One button, everything the platform holds, for as long as the app wants. Nothing records what you agreed to, and + turning it off means finding a settings page on someone else's site. + +
+ ); +} + +// The grant panel's header is the one constant-dark element in both themes, per +// the design: a record is an artifact, and it reads as one against the page +// rather than as another panel of it. +function GrantPanel() { + return ( +
+ + With PDPP + +
+
+ + Grant + + + Active + +
+
+ {GRANT_ROWS.map(([key, value]) => ( +
+
+ + {key} + +
+
+ + {value} + +
+
+ ))} +
+
+ + Two fields, one purpose, three months, until a date you chose. It is recorded where you and the app can both see + it, every request is checked against it, and ending it is one click. Ask for a third field, or a date outside + the range, and the request is refused. + +
+ ); +} + +export function PdppWhatChanges({ className }: { className?: string }) { + return ( +
+ + +
+ ); +} diff --git a/apps/site/src/components/specification/rail.tsx b/apps/site/src/components/specification/rail.tsx index 8451e027f9..881a29e98b 100644 --- a/apps/site/src/components/specification/rail.tsx +++ b/apps/site/src/components/specification/rail.tsx @@ -66,7 +66,7 @@ function RailItem({ item }: { item: PageTree.Item }) { // the two groups change by different routes: the specifications under the // Community Specification process, governance and the Principles by a vote of // Partners. -const RAIL_SECTION_LABELS = new Set(["Specification", "Programme"]); +const RAIL_SECTION_LABELS = new Set(["Core protocol", "Implementer guidance, informative", "Appendices"]); function RailSeparator({ item }: { item: PageTree.Separator }) { const label = typeof item.name === "string" ? item.name : ""; diff --git a/apps/site/src/lib/docs-source.ts b/apps/site/src/lib/docs-source.ts index 7a7d10b802..de9e700a93 100644 --- a/apps/site/src/lib/docs-source.ts +++ b/apps/site/src/lib/docs-source.ts @@ -5,14 +5,7 @@ import type * as PageTree from "fumadocs-core/page-tree"; import { type InferPageType, loader } from "fumadocs-core/source"; import { lucideIconsPlugin } from "fumadocs-core/source/plugins/lucide-icons"; import { docs } from "../../.source/dynamic.ts"; -import { - docsRoute, - GOVERNANCE_SLUG, - governanceRoute, - PRIMARY_SLUGS, - PRINCIPLES_SLUG, - principlesRoute, -} from "./spec-nav-slugs.ts"; +import { docsRoute, GUIDANCE_SLUGS, PRIMARY_SLUGS } from "./spec-nav-slugs.ts"; export const source = loader({ baseUrl: docsRoute, @@ -52,48 +45,54 @@ function pick(byUrl: Map, slugs: readonly string[]): Page .filter((item): item is PageTree.Item => item !== undefined); } -// The programme documents are authored inside the fumadocs collection (so they -// are indexed and carry the same page furniture as a spec page) but are SERVED -// at their own top-level routes. Each tree item therefore keeps the name -// fumadocs resolved for it while pointing at the canonical URL — without this -// the rail would link to /specification/governance and -// /specification/principles, which only 308-redirect there. +// The contents rail: Core's own nine sections, then the informative documents, +// then the appendix. // -// Governance first: it is the document that says what a status means, and the -// Principles are one thing it governs. -const PROGRAMME_DOCS = [ - { route: governanceRoute, slug: GOVERNANCE_SLUG }, - { route: principlesRoute, slug: PRINCIPLES_SLUG }, -] as const; - -function programmeItems(byUrl: Map): PageTree.Item[] { - return PROGRAMME_DOCS.flatMap(({ route, slug }) => pick(byUrl, [slug]).map((item) => ({ ...item, url: route }))); -} - -// The tree the rail renders: the specification set, then the programme -// documents. Every non-normative document (guides, rationale, deferred -// concerns, open questions) keeps its URL but is listed only on /maintainers — -// see MAINTAINER_DOC_SLUGS. +// Core's sections are IN-PAGE anchors, not sibling documents. /specification +// renders spec-core in full, so a reader clicking "5. Source declaration" +// should move down the page they are already on rather than load another one. +// They are therefore hand-declared here rather than picked out of the fumadocs +// tree, which only knows about documents. // // The separator names are load-bearing: components/specification/rail.tsx -// renders "Specification" and "Programme" as the rail's two labels, and any -// separator it does not recognise degrades to a bare rule. -// -// Governance sits under its OWN heading rather than as a seventh document in -// the specification list. The six above are specifications under CSL-1.0 that -// change through the Community Specification process; governance is a -// programme document amended by a vote of Partners. One list would imply one -// amendment route where there are two. +// renders them as the rail's headings, and any separator it does not recognise +// degrades to a bare rule. +const CORE_SECTIONS: readonly (readonly [string, string])[] = [ + ["#introduction", "1. Introduction"], + ["#terminology", "2. Terminology and actors"], + ["#system-architecture", "3. System architecture"], + ["#record-model", "4. Record model"], + ["#source-declaration", "5. Source declaration"], + ["#selection-request", "6. Selection request"], + ["#grant", "7. Grant"], + ["#resource-server-interface", "8. Resource server interface"], + ["#conformance", "9. Conformance"], +]; + export function getSpecNavTree(): PageTree.Root { const full = source.getPageTree(); const byUrl = itemsByUrl(full); + const coreItems: PageTree.Item[] = CORE_SECTIONS.map(([anchor, name]) => ({ + $id: `spec-rail-core-${anchor}`, + name, + type: "page", + url: `${docsRoute}${anchor}`, + })); + return { ...full, children: [ - { $id: "spec-rail-primary", name: "Specification", type: "separator" }, - ...pick(byUrl, PRIMARY_SLUGS), - { $id: "spec-rail-programme", name: "Programme", type: "separator" }, - ...programmeItems(byUrl), + { $id: "spec-rail-primary", name: "Core protocol", type: "separator" }, + ...coreItems, + { $id: "spec-rail-guidance", name: "Implementer guidance, informative", type: "separator" }, + ...pick(byUrl, GUIDANCE_SLUGS), + { $id: "spec-rail-appendices", name: "Appendices", type: "separator" }, + { + $id: "spec-rail-appendix-a", + name: "A. Purpose registry", + type: "page", + url: `${docsRoute}#purpose-registry`, + }, ], }; } diff --git a/apps/site/src/lib/spec-nav-slugs.ts b/apps/site/src/lib/spec-nav-slugs.ts index 45be36ccd3..6bb6ef2952 100644 --- a/apps/site/src/lib/spec-nav-slugs.ts +++ b/apps/site/src/lib/spec-nav-slugs.ts @@ -24,8 +24,14 @@ export const maintainersRoute = "/maintainers"; // the protocol normatively defines, and nothing else. // // Order is the reading order of the specification set, not alphabetical. -export const PRIMARY_SLUGS = [ - "spec-core", +export const PRIMARY_SLUGS = ["spec-core"] as const; + +// The three informative documents, listed under their own rail heading so a +// reader cannot mistake them for part of the normative set. Discovery and +// Trust and the Collection Profile state in their own headers that they define +// no conformance requirements; the extension profiles add surfaces on top of +// Core without changing what Core means. +export const GUIDANCE_SLUGS = [ "spec-discovery-and-trust", "spec-collection-profile", "spec-ext-lexical-search", From b18a7db3b47fdaf1ae9b6c424616b4fe41300f62 Mon Sep 17 00:00:00 2001 From: Art A <129354338+artvana@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:33:44 +0200 Subject: [PATCH 4/9] site: Participate, conformance registers and apply templates /participate carries the three levels, the four verification steps, the status table, both registers and the contact channels. Both registers are PR-driven JSON in this repo and hold no personal data. /register/index.json ships empty; /register/trust-registries.json holds the one recognised entry, the Data Transfer Initiative's Data Trust Registry. Both are read at build time rather than fetched: they ship in the repo, so a network hop in front of them would only add a way for the page to 500. The Operator apply button is inert with its date until operatorApplications is on. The test suite an Operator is assessed against does not exist until then, so a live button would invite an application nobody can act on. Copy delta 1 applied: the Level 1 channel list is "Discord, mailing list, comments", and the section is "Channels and contacts". Issue templates for the three roles list what to attach per GOVERNANCE.md Appendix A, each stating that the admissions check is exhaustive and that every return cites the item it failed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NECjjZbYWNTzq3bu7nuiDP Signed-off-by: Art A <129354338+artvana@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/apply-accessor.md | 36 ++ .github/ISSUE_TEMPLATE/apply-operator.md | 33 ++ .github/ISSUE_TEMPLATE/apply-source.md | 37 ++ apps/site/public/register/index.json | 1 + .../public/register/trust-registries.json | 11 + .../src/app/(concept)/participate/page.tsx | 551 +++++++++++++----- 6 files changed, 515 insertions(+), 154 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/apply-accessor.md create mode 100644 .github/ISSUE_TEMPLATE/apply-operator.md create mode 100644 .github/ISSUE_TEMPLATE/apply-source.md create mode 100644 apps/site/public/register/index.json create mode 100644 apps/site/public/register/trust-registries.json diff --git a/.github/ISSUE_TEMPLATE/apply-accessor.md b/.github/ISSUE_TEMPLATE/apply-accessor.md new file mode 100644 index 0000000000..2d629771d3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/apply-accessor.md @@ -0,0 +1,36 @@ +--- +name: Apply as an Accessor +about: Apply for Conformant Accessor or Verified Accessor status +title: "Apply: Accessor — " +labels: ["register", "apply-accessor"] +--- + +Read GOVERNANCE.md Appendix A before opening this. The admissions check is +exhaustive: a submission cannot be returned for a reason not on that list, and +every return cites the item it failed. + +## Status sought + +- [ ] Conformant Accessor — registered, submission complete. No assessment. +- [ ] Verified Accessor — reviewed by the technical committee, and corporate identity established. + +## Every submission (Appendix A items 1 to 4) + +- [ ] The participation form is complete. +- [ ] Specification version this submission is made against: `vX.Y.Z` +- [ ] Accountable party, with working contact details: +- [ ] This pull request or issue carries DCO sign-off. + +## The application or agent + +- [ ] What it does, and what data it asks for: +- [ ] Purpose codes it requests: + +## Verified Accessor only + +- [ ] Legal attestation attached, naming the entity that carries liability. +- [ ] Corporate identity: an entry on a recognised trust registry (see `/register/trust-registries.json`), or the material for a KYB-style check. + +## Anything else + +Say anything a reviewer would otherwise have to ask for. diff --git a/.github/ISSUE_TEMPLATE/apply-operator.md b/.github/ISSUE_TEMPLATE/apply-operator.md new file mode 100644 index 0000000000..ef72b12f1e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/apply-operator.md @@ -0,0 +1,33 @@ +--- +name: Apply as an Operator +about: Apply for Verified Operator status. Opens 1 January 2027. +title: "Apply: Operator — " +labels: ["register", "apply-operator"] +--- + +**Operator applications open on 1 January 2027**, when the conformance test +suite is published. An application opened before then cannot be assessed, +because the suite it is assessed against does not exist yet. + +Read GOVERNANCE.md Appendix A before opening this. The admissions check is +exhaustive: a submission cannot be returned for a reason not on that list, and +every return cites the item it failed. + +## Every submission (Appendix A items 1 to 4) + +- [ ] The participation form is complete. +- [ ] Specification version this submission is made against: `vX.Y.Z` +- [ ] Accountable party, with working contact details: +- [ ] This pull request or issue carries DCO sign-off. + +## Verified Operator (Appendix A, test suite item) + +- [ ] Test suite results attached. +- [ ] The results name the suite version. +- [ ] The results are reproducible, and this submission says how to reproduce them. +- [ ] Which roles and tiers the claim covers: +- [ ] Corporate identity: an entry on a recognised trust registry (see `/register/trust-registries.json`), or the material for a KYB-style check. + +## Anything else + +Say anything a reviewer would otherwise have to ask for. diff --git a/.github/ISSUE_TEMPLATE/apply-source.md b/.github/ISSUE_TEMPLATE/apply-source.md new file mode 100644 index 0000000000..de2ca548f5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/apply-source.md @@ -0,0 +1,37 @@ +--- +name: Apply as a Source +about: Apply for Conformant Source or Verified Source status +title: "Apply: Source — " +labels: ["register", "apply-source"] +--- + +Read GOVERNANCE.md Appendix A before opening this. The admissions check is +exhaustive: a submission cannot be returned for a reason not on that list, and +every return cites the item it failed. + +## Status sought + +- [ ] Conformant Source — the declaration parses against the specification version named. Automated check, no human judgement. +- [ ] Verified Source — reviewed by the technical committee, and corporate identity established. + +## Every submission (Appendix A items 1 to 4) + +- [ ] The participation form is complete. +- [ ] Specification version this submission is made against: `vX.Y.Z` +- [ ] Accountable party, with working contact details: +- [ ] This pull request or issue carries DCO sign-off. + +## Source statuses (Appendix A items 7 and 8) + +- [ ] Declaration URL: +- [ ] The declaration is well-formed and parses against the specification version named above. +- [ ] Every declared endpoint resolves. + +## Verified Source only + +- [ ] The declaration is published by the platform whose data it describes. +- [ ] Corporate identity: an entry on a recognised trust registry (see `/register/trust-registries.json`), or the material for a KYB-style check. + +## Anything else + +Say anything a reviewer would otherwise have to ask for. diff --git a/apps/site/public/register/index.json b/apps/site/public/register/index.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/apps/site/public/register/index.json @@ -0,0 +1 @@ +[] diff --git a/apps/site/public/register/trust-registries.json b/apps/site/public/register/trust-registries.json new file mode 100644 index 0000000000..6af402a727 --- /dev/null +++ b/apps/site/public/register/trust-registries.json @@ -0,0 +1,11 @@ +[ + { + "id": "dti-data-trust-registry", + "name": "Data Trust Registry", + "operator": "Data Transfer Initiative", + "url": "https://dtinit.org/", + "recognisedFor": ["Verified Source", "Verified Accessor", "Verified Operator"], + "basis": "Corporate identity. A current entry stands in place of a KYB-style check when a Verified status is applied for.", + "recognisedOn": "2026-09-03" + } +] diff --git a/apps/site/src/app/(concept)/participate/page.tsx b/apps/site/src/app/(concept)/participate/page.tsx index a6298ec7e7..d3ff3fcfc5 100644 --- a/apps/site/src/app/(concept)/participate/page.tsx +++ b/apps/site/src/app/(concept)/participate/page.tsx @@ -1,198 +1,441 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 +import { readFile } from "node:fs/promises"; +import path from "node:path"; import type { Metadata } from "next"; import Link from "next/link"; -import { DiscordIcon, GithubIcon } from "@/components/elements/icons.tsx"; import { PdppConceptDoc, PdppConceptPage } from "@/components/layout/concept-page.tsx"; -import { PdppRail } from "@/components/layout/rail.tsx"; -import { PdppConceptDocHeader } from "@/components/sections/concept-doc-header.tsx"; import { PdppConceptSection } from "@/components/sections/concept-section.tsx"; import { Text } from "@/components/typography/text.tsx"; -import { DISCORD_INVITE_URL, GITHUB_MAINTAINERS_URL, GITHUB_NEW_ISSUE_URL, GITHUB_REPO_URL } from "@/lib/site-facts.ts"; -import { SPEC_STATUS } from "@/lib/spec-status.ts"; - -const PARTICIPATE_TOC = [ - { href: "#get-involved", label: "Get involved" }, - { href: "#how-it-changes", label: "How the specification changes" }, - { href: "#status", label: "Where PDPP is today" }, -] as const; +import { REPORTS_EMAIL_HREF, siteConfig, siteFlags } from "@/lib/site-config.ts"; +import { GITHUB_REPO_URL } from "@/lib/site-facts.ts"; +import { cn } from "@/lib/utils.ts"; export const metadata: Metadata = { alternates: { canonical: "/participate" }, - description: "Ask a question about the PDPP draft, or propose a change to the protocol.", + description: "Follow the work, sign the Principles, or build something and get it verified.", openGraph: { url: "/participate" }, title: "Participate - PDPP", }; -// This is a PARTICIPATE page: where someone who wants to get involved finds the -// people, the process, and the places to talk. Not an About page. -// -// Went from 6 sections to 3. What was CUT and why: -// Maintainers table — a roster with a Status column of three identical -// "Active" values is About-page furniture. 4 of 6 -// comparable projects (MCP, Solid, x402, Kubernetes, -// OpenTelemetry, LFDT) name no individuals on the site -// at all; names live in a version-controlled -// MAINTAINERS.md. Replaced by a pointer to that file, -// which is also the canonical list, so it cannot go -// stale. -// License table — the footer now carries all three licenses, labelled -// and linked. Nothing here was unique to this page. -// Implementations — belongs on /reference, the page about running one. -// It was duplicated across both. -// -// The extra horizontal rules between sections are gone (Callum's note: AI has -// added too many lines). -const changeSteps = [ +const CARD = cn("flex flex-col gap-3 bg-background p-6", "shadow-[0_0_0_1px_var(--border)]"); +const CELL = "px-3 py-2.5 text-left align-top"; + +interface RegisterEntry { + organisation: string; + role: string; + status: string; + specVersion: string; + since: string; + state: string; +} + +interface TrustRegistry { + basis: string; + name: string; + operator: string; + recognisedFor: readonly string[]; + recognisedOn: string; + url: string; +} + +// Both registers are PR-driven files in this repo and hold no personal data. +// Read at build time: a fetch at request time would put a network hop in front +// of a file that ships in the repo and would 500 the page when it failed. +async function readJson(...segments: string[]): Promise { + try { + const parsed: unknown = JSON.parse(await readFile(path.join(process.cwd(), "public", ...segments), "utf8")); + return Array.isArray(parsed) ? (parsed as T[]) : []; + } catch { + return []; + } +} + +const LEVELS = [ { - body: "Against the specification files, reviewed in the open. Editorial and non-normative fixes need nothing more than this.", - title: "Every change is a public pull request", + level: "Level 1", + title: "Follow along", + // Copy delta 1: working sessions removed from this list. + body: "Discord, mailing list, comments. Nothing to sign, so regulators can take part off the record.", + cta: "Channels and contacts ↓", + href: "#channels", }, { - body: "What breaks and what the change impacts, written before the code.", - title: "A normative change states its rationale first", + level: "Level 2", + title: "Become a Supporter", + body: "Sign the Principles and go on the public list. Open now.", + cta: "Become a Supporter", + href: "/principles#who-can-sign", }, { - body: "The reference implementation moves in the same release.", - title: "New behavior ships with tests", + level: "Level 3", + title: "Become a Partner", + body: "Get an implementation verified. Partners get drafts early, sit in working groups and vote. Organisations only, from 14 November.", + cta: "How verification works ↓", + href: "#get-verified", }, ] as const; -export default function ParticipatePage() { +const STEPS = [ + { step: "Step 1", title: "Build it", body: "A source, accessor or operator that implements Core." }, + { + step: "Step 2", + title: "Apply", + body: "Open a pull request from the template for your role and attach your evidence. A machine checks it. Pass, and you are Conformant.", + }, + { + step: "Step 3", + title: "Get reviewed", + body: "For Verified, the technical committee reviews in public and votes, and you prove your organisation exists through a recognised trust registry. You get reasons either way, and can resubmit.", + }, + { + step: "Step 4", + title: "You're on the register", + body: "Your status is published with the date and version. Any Verified status makes you a Partner.", + }, +] as const; + +const STATUSES = [ + ["Conformant Source", "Source", "Automated", "Declaration parses against the schema", "By 14 Nov 2026", "Not open"], + [ + "Verified Source", + "Source", + "Reviewed", + "Published by the platform that holds the data. Committee review, identity via a recognised trust registry", + "By 14 Nov 2026", + "Not open", + ], + [ + "Conformant Accessor", + "Accessor", + "Automated", + "Registered, submission complete. No assessment", + "By 14 Nov 2026", + "Not open", + ], + [ + "Verified Accessor", + "Accessor", + "Reviewed", + "Legal attestation, liability on a named entity. Committee review, identity via a recognised trust registry", + "By 14 Nov 2026", + "Not open", + ], + [ + "Verified Operator", + "Operator", + "Reviewed", + "Passes the test suite, then full committee review. Identity via a recognised trust registry", + "By 1 Jan 2027", + "Not open", + ], +] as const; + +export default async function Page() { + const register = await readJson("register", "index.json"); + const trustRegistries = await readJson("register", "trust-registries.json"); + return ( - - - - {/* Actionable links FIRST, directly under the lede. The owner's note: - landing here, you should be able to open an issue or find Discord - immediately. These were previously ruled-list rows — an h3, an - explanatory sentence, and a right-aligned link floating away from - its own label, which is document prose, not an interface. - - Descriptions dropped entirely: "Open an issue" next to a GitHub - mark does not need a sentence explaining that it opens an issue. - - /issues/new, not /issues — the owner said "I want to open an - issue", which is the compose action, not the index. */} - -
- - - Open an issue - - - - - PDP-Connect/pdpp - - - - - #pdp-connect on Discord - - +
+ + How to get involved + + + Follow the work, sign the Principles, or build something and get it verified. All of it is voluntary, and + none of it is needed to use PDPP. + +
+ +
+ {LEVELS.map((level) => ( +
+ + {level.level} + + + {level.title} + + + {level.body} + + + + {level.cta} + + +
+ ))} +
+ + +
+ {STEPS.map((step) => ( +
+ + {step.step} + + + {step.title} + + + {step.body} + +
+ ))}
- - In an issue, name the part of the specification your question applies to and what the text does not let you - decide. A maintainer answers there. + +
+ + + Apply as a Source → + + + + + Apply as an Accessor → + + + {/* Disabled with its date until operatorApplications is on. The + suite an Operator is assessed against does not exist until then, + so a live button would invite an application nobody can act on. */} + {siteFlags.operatorApplications ? ( + + + Apply as an Operator → + + + ) : ( + + Apply as an Operator, opens 1 Jan 2027 + + )} +
+ + Not sure which fits, or not on GitHub? Write to {siteConfig.generalContact} and we will sort it with you. + + + + The statuses + +
+ + + + {["Status", "Role", "Level", "How established", "Opens", "State"].map((heading) => ( + + ))} + + + + {STATUSES.map((row) => ( + + {row.map((cell, index) => ( + + ))} + + ))} + +
+ + {heading} + +
+ + {cell} + +
+
+ + Conformant is a machine check. Verified is a human review, and makes you a Partner. No fee at any stage.
- {/* Grounded against the WHATWG Working Mode and the OpenTelemetry OTEP - README, which document a change as EVIDENCE REQUIREMENTS rather - than a stage pipeline. The previous version invented a four-stage - pipeline (proposal / PR / review / consultation) that no comparable - project runs and this lab cannot honour. */} - - - Normative text lives in the root spec-*.md files. Everything else, including the specification - pages on this site, is a downstream copy. + + + Applications open by 14 November. -
- {changeSteps.map((step) => ( -
-
- - {step.title} - - {step.body} -
-
- ))} -
-
- - Two maintainers approve before merge +
+ + + + {["Organisation", "Role", "Status", "Spec version", "Since", "State"].map((heading) => ( + + ))} + + + + {register.length === 0 ? ( + + + + ) : ( + register.map((entry) => ( + + + {[entry.role, entry.status, entry.specVersion, entry.since, entry.state].map((cell) => ( + + ))} + + )) + )} + +
+ + {heading} + +
+ + Nothing listed yet. + +
+ + {entry.organisation} + + + + {cell} + +
+
+ + Kept on GitHub · machine readable at /register/index.json · Supporters are listed separately on the Principles page + + + + Recognised trust registries + + + A current entry on one of these stands in place of a KYB-style check when an organisation applies for a + Verified status. + +
+ {trustRegistries.map((registry) => ( +
+ + {registry.name} + + + {registry.operator} · recognised {registry.recognisedOn} + + + {registry.basis} - - Maintainers are the editors of the current draft, listed in{" "} - - MAINTAINERS.md + + + {registry.name} → - .
-
+ ))}
- - Interfaces may still change: {SPEC_STATUS.version} has not been through public consultation, which opens - before v1.0 is pinned. Governance and the license split are recorded in{" "} - specification governance. + + + /register/trust-registries.json + - {/* Renamed from "Status of this document". The owner spotted that the - content is PDPP-project-wide status, not the status of this page, - and the heading was the thing that was wrong. Content kept. */} - - - {SPEC_STATUS.version} is a {SPEC_STATUS.label.toLowerCase()}. - -
    -
  • - - 2026 · Jul - - - LFDT Labs proposal accepted; repositories public under the PDP-Connect organization. - -
  • -
  • - - 2026 · Apr + +
    +
    + + Chat + + + #pdp-connect on Discord + + + Where things are announced first. + + + + Join → + + +
    +
    + + Email + + + Mailing list + + + New versions and comment periods. Low volume. + + + + Subscribe → + + +
    +
    + + Governments and regulators + + + Take part without signing + + + Observe anywhere, sign nothing. Ask for a briefing. + + + + Contact → + + +
    +
    + + Something wrong? + + + Report it + + + A wrong description, a name that should not be listed, anything about a status. Answered within five + working days. - - Core protocol {SPEC_STATUS.version} published as a normative draft alongside the forkable reference - implementation. + + + pdpp-dev-reports@lfdecentralizedtrust.org → + -
  • -
+
+
From a045576f195518a1bdb89a5ad09de6e1dce5a9ea Mon Sep 17 00:00:00 2001 From: Art A <129354338+artvana@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:45:08 +0200 Subject: [PATCH 5/9] site: interim Supporter signing, registers documentation, sitemap The signing system, all behind signingLive and none of it exercised: the KV store, the mail provider and the deploy key do not exist yet. Every seam fails closed with SigningUnavailableError rather than degrading, because a seam that degraded would accept a signature, tell the signatory it worked, and drop it. POST /api/sign rate-limits, validates against a strict schema, refuses an organisation submission that is not from an address at the organisation's own domain, writes a pending record to a TTL store and sends ONE email. Nothing is published here. A signature becomes real only when the confirmation link is used, which is what stops anyone signing in someone else's name. Single use is enforced by GETDEL on the pending record, not by the signature: an HMAC is stateless, so checking, then reading, then deleting would leave a window where two clicks both write a signatory file. The withdraw link deliberately does not expire. A signatory keeps the email and may act on it a year later, and a withdrawal that needed a person to serve it is the friction the Principles say ending should not have. The flags gate the ROUTES, not just the UI: a form hidden in the page while the endpoint still accepts posts is not switched off. docs/registers.md documents the three stores, the one-way data flow, who has access, and the handover, rotation and deletion procedure. Also here: the sitemap drops /governance (a redirect in a sitemap is a redirect a crawler must follow to learn it was one) and /review (temporary, and a page due for retirement should not be advertised), and picks up the new routes. Five sandbox files are reformatted by the repo's own formatter. They are unrelated to this change and were already failing `pnpm check` on main; fixing them is what makes this branch green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NECjjZbYWNTzq3bu7nuiDP Signed-off-by: Art A <129354338+artvana@users.noreply.github.com> --- .../public/register/trust-registries.json | 6 +- apps/site/src/app/(concept)/page.tsx | 3 +- .../src/app/(concept)/participate/page.tsx | 15 +- .../src/app/(concept)/principles/page.tsx | 7 +- apps/site/src/app/api/sign/confirm/route.ts | 63 ++++ apps/site/src/app/api/sign/route.ts | 93 ++++++ apps/site/src/app/api/sign/withdraw/route.ts | 54 ++++ .../app/sandbox/_demo/operations-fixtures.ts | 2 +- apps/site/src/app/sandbox/v1/search/route.ts | 5 +- .../[stream]/records/[recordId]/route.ts | 5 +- .../v1/streams/[stream]/records/route.ts | 5 +- .../app/sandbox/v1/streams/[stream]/route.ts | 5 +- .../app/specification/[[...slug]]/page.tsx | 2 +- apps/site/src/components/layout/masthead.tsx | 2 +- .../src/components/layout/review-banner.tsx | 2 +- .../src/components/site/governance-stages.tsx | 5 +- .../site/src/components/site/signing-form.tsx | 25 +- apps/site/src/components/site/supporters.tsx | 11 +- apps/site/src/lib/docs-source.ts | 2 +- apps/site/src/lib/signing/index.ts | 286 ++++++++++++++++++ apps/site/src/lib/signing/providers.ts | 257 ++++++++++++++++ apps/site/src/lib/sitemap-entries.ts | 11 +- apps/site/src/styles/site.css | 4 +- docs/registers.md | 138 +++++++++ 24 files changed, 949 insertions(+), 59 deletions(-) create mode 100644 apps/site/src/app/api/sign/confirm/route.ts create mode 100644 apps/site/src/app/api/sign/route.ts create mode 100644 apps/site/src/app/api/sign/withdraw/route.ts create mode 100644 apps/site/src/lib/signing/index.ts create mode 100644 apps/site/src/lib/signing/providers.ts create mode 100644 docs/registers.md diff --git a/apps/site/public/register/trust-registries.json b/apps/site/public/register/trust-registries.json index 6af402a727..8b2b838540 100644 --- a/apps/site/public/register/trust-registries.json +++ b/apps/site/public/register/trust-registries.json @@ -4,7 +4,11 @@ "name": "Data Trust Registry", "operator": "Data Transfer Initiative", "url": "https://dtinit.org/", - "recognisedFor": ["Verified Source", "Verified Accessor", "Verified Operator"], + "recognisedFor": [ + "Verified Source", + "Verified Accessor", + "Verified Operator" + ], "basis": "Corporate identity. A current entry stands in place of a KYB-style check when a Verified status is applied for.", "recognisedOn": "2026-09-03" } diff --git a/apps/site/src/app/(concept)/page.tsx b/apps/site/src/app/(concept)/page.tsx index 85be77eb82..9d06c2fc23 100644 --- a/apps/site/src/app/(concept)/page.tsx +++ b/apps/site/src/app/(concept)/page.tsx @@ -14,8 +14,7 @@ import { cn } from "@/lib/utils.ts"; export const metadata: Metadata = { alternates: { canonical: "/" }, - description: - "Lets personal data move freely, with consent. Decide what an app can see, for what, and for how long.", + description: "Lets personal data move freely, with consent. Decide what an app can see, for what, and for how long.", openGraph: { url: "/" }, title: "PDPP: Personal Data Portability Protocol", }; diff --git a/apps/site/src/app/(concept)/participate/page.tsx b/apps/site/src/app/(concept)/participate/page.tsx index d3ff3fcfc5..88b163f80a 100644 --- a/apps/site/src/app/(concept)/participate/page.tsx +++ b/apps/site/src/app/(concept)/participate/page.tsx @@ -25,10 +25,10 @@ const CELL = "px-3 py-2.5 text-left align-top"; interface RegisterEntry { organisation: string; role: string; - status: string; - specVersion: string; since: string; + specVersion: string; state: string; + status: string; } interface TrustRegistry { @@ -296,7 +296,10 @@ export default async function Page() { ) : ( register.map((entry) => ( - + {entry.organisation} @@ -316,7 +319,11 @@ export default async function Page() {
- Kept on GitHub · machine readable at /register/index.json · Supporters are listed separately on the Principles page + Kept on GitHub · machine readable at{" "} + + /register/index.json + {" "} + · Supporters are listed separately on the Principles page diff --git a/apps/site/src/app/(concept)/principles/page.tsx b/apps/site/src/app/(concept)/principles/page.tsx index 3585cd84b8..87b00316de 100644 --- a/apps/site/src/app/(concept)/principles/page.tsx +++ b/apps/site/src/app/(concept)/principles/page.tsx @@ -87,7 +87,12 @@ export default async function Page() {
- + Read the signed document diff --git a/apps/site/src/app/api/sign/confirm/route.ts b/apps/site/src/app/api/sign/confirm/route.ts new file mode 100644 index 0000000000..93c869c5a5 --- /dev/null +++ b/apps/site/src/app/api/sign/confirm/route.ts @@ -0,0 +1,63 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { type NextRequest, NextResponse } from "next/server"; +import { buildRecord, recordPath, SigningUnavailableError, verifyToken } from "@/lib/signing/index.ts"; +import { takePending, writeSignatory } from "@/lib/signing/providers.ts"; +import { siteFlags } from "@/lib/site-config.ts"; + +// GET /api/sign/confirm?token=... — the single-use confirmation link. +// +// SINGLE USE is enforced by takePending, which reads and deletes in one store +// operation. The signature check cannot enforce it: an HMAC is stateless and a +// valid token stays valid until it expires. Checking the signature, then +// reading, then deleting would leave a window where two clicks both see the +// pending record and both write a file. +// +// This is the ONLY place a signatory file is written, and it happens after the +// person who owns the address has acted. Everything before this point is +// reversible by doing nothing: an unconfirmed submission expires on its own. + +export const runtime = "nodejs"; + +function outcome(request: NextRequest, state: string): NextResponse { + return NextResponse.redirect(new URL(`/principles?signed=${state}`, request.url), 303); +} + +export async function GET(request: NextRequest): Promise { + if (!siteFlags.signingLive) { + return NextResponse.json({ error: "Signing is not open." }, { status: 404 }); + } + + const token = new URL(request.url).searchParams.get("token"); + const id = token ? verifyToken(token, "confirm") : null; + // One outcome for a bad signature, an expired link and an already-used link. + // Distinguishing them would tell a holder of a stolen link which kind of + // wrong it is, and the person who owns the address cannot act on the + // difference anyway. + if (!id) { + return outcome(request, "invalid"); + } + + try { + const submission = await takePending(id); + if (!submission) { + return outcome(request, "invalid"); + } + + const record = buildRecord(id, submission); + await writeSignatory(record, recordPath(record)); + + // The public register does not change here. It changes when the publish + // script next runs, which is what keeps the public site free of any code + // path that reads the private store. + return outcome(request, "confirmed"); + } catch (error) { + if (error instanceof SigningUnavailableError) { + console.error("[sign/confirm] unavailable:", error.message); + } else { + console.error("[sign/confirm] unexpected:", error); + } + return outcome(request, "error"); + } +} diff --git a/apps/site/src/app/api/sign/route.ts b/apps/site/src/app/api/sign/route.ts new file mode 100644 index 0000000000..0f3aabc784 --- /dev/null +++ b/apps/site/src/app/api/sign/route.ts @@ -0,0 +1,93 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { type NextRequest, NextResponse } from "next/server"; +import { + createToken, + newSubmissionId, + organisationDomainMatches, + SigningRejectedError, + SigningUnavailableError, + submissionSchema, +} from "@/lib/signing/index.ts"; +import { putPending, sendConfirmationEmail, withinRateLimit } from "@/lib/signing/providers.ts"; +import { siteFlags } from "@/lib/site-config.ts"; + +// POST /api/sign — accept a Supporter submission and send one confirmation. +// +// Nothing is published by this route. It writes a pending record that expires +// on its own and sends one email. A signature only becomes real when the +// confirmation link is used, which is what stops anyone signing in someone +// else's name: the person who owns the address is the one who completes it. +// +// ORDER MATTERS. Rate limit, then parse, then the domain rule, then store, +// then send. The rate limit runs first because it is the cheapest and it is +// what protects the mail provider; the send runs last because it is the only +// step with an effect outside this system. + +export const runtime = "nodejs"; + +function clientIp(request: NextRequest): string { + // Vercel sets x-forwarded-for; the leftmost entry is the client. Anything + // further right is a proxy the client cannot control. Falling back to a + // constant means an unknown-IP caller shares one bucket, which throttles + // rather than exempts them. + const forwarded = request.headers.get("x-forwarded-for"); + return forwarded?.split(",")[0]?.trim() || "unknown"; +} + +function seeOther(path: string, request: NextRequest): NextResponse { + return NextResponse.redirect(new URL(path, request.url), 303); +} + +export async function POST(request: NextRequest): Promise { + // The flag gates the ROUTE, not just the form. A form hidden in the UI while + // the endpoint still accepts posts is not switched off. + if (!siteFlags.signingLive) { + return NextResponse.json({ error: "Signing is not open." }, { status: 404 }); + } + + try { + if (!(await withinRateLimit(clientIp(request)))) { + return NextResponse.json({ error: "Too many submissions. Try again later." }, { status: 429 }); + } + + const form = await request.formData(); + const parsed = submissionSchema.safeParse(Object.fromEntries(form)); + if (!parsed.success) { + // The individual field errors are deliberately not returned: they + // describe a schema an attacker would otherwise get to probe, and the + // form already states what it needs. + throw new SigningRejectedError("That submission was not complete. Check the required fields and try again."); + } + + const submission = parsed.data; + if (!organisationDomainMatches(submission)) { + throw new SigningRejectedError("An organisation must sign from an address at its own domain."); + } + + const id = newSubmissionId(); + await putPending(id, submission); + + const { origin } = new URL(request.url); + await sendConfirmationEmail({ + confirmUrl: `${origin}/api/sign/confirm?token=${createToken(id, "confirm")}`, + to: submission.email, + withdrawUrl: `${origin}/api/sign/withdraw?token=${createToken(id, "withdraw")}`, + }); + + return seeOther("/principles?signed=pending", request); + } catch (error) { + if (error instanceof SigningRejectedError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + // An unprovisioned seam is a 503, with no detail: which environment + // variable is missing is not a fact the public needs. + if (error instanceof SigningUnavailableError) { + console.error("[sign] unavailable:", error.message); + return NextResponse.json({ error: "Signing is temporarily unavailable." }, { status: 503 }); + } + console.error("[sign] unexpected:", error); + return NextResponse.json({ error: "Signing is temporarily unavailable." }, { status: 503 }); + } +} diff --git a/apps/site/src/app/api/sign/withdraw/route.ts b/apps/site/src/app/api/sign/withdraw/route.ts new file mode 100644 index 0000000000..19040c353c --- /dev/null +++ b/apps/site/src/app/api/sign/withdraw/route.ts @@ -0,0 +1,54 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { type NextRequest, NextResponse } from "next/server"; +import { SigningUnavailableError, verifyToken } from "@/lib/signing/index.ts"; +import { withdrawSignatory } from "@/lib/signing/providers.ts"; +import { siteFlags } from "@/lib/site-config.ts"; + +// GET /api/sign/withdraw?token=... — the withdrawal link from the one email. +// +// The withdraw token is deliberately NOT single-use and NOT short-lived in +// practice: a signatory keeps the confirmation email and may act on it a year +// later. Its 48-hour expiry applies to the confirm token; a withdrawal link +// that expired would leave someone with no way out except asking a person, +// which is exactly the friction the Principles say ending should not have. +// +// Withdrawing twice is harmless: the second call finds no file and reports the +// same outcome, so a signatory who clicks again sees what they expect. + +export const runtime = "nodejs"; + +function outcome(request: NextRequest, state: string): NextResponse { + return NextResponse.redirect(new URL(`/principles?withdraw=${state}`, request.url), 303); +} + +export async function GET(request: NextRequest): Promise { + if (!siteFlags.signingLive) { + return NextResponse.json({ error: "Signing is not open." }, { status: 404 }); + } + + const token = new URL(request.url).searchParams.get("token"); + // Verified against a far-future expiry so a link kept for years still works; + // the signature is what authorises it, not its age. + const id = token ? verifyToken(token, "withdraw", 0) : null; + if (!id) { + return outcome(request, "invalid"); + } + + try { + await withdrawSignatory(id); + // Reports done whether or not a file was found, for the same reason the + // confirm route has one failure state: a link holder learning whether an + // id is on the register is a disclosure, and the signatory's outcome is + // identical either way. + return outcome(request, "done"); + } catch (error) { + if (error instanceof SigningUnavailableError) { + console.error("[sign/withdraw] unavailable:", error.message); + } else { + console.error("[sign/withdraw] unexpected:", error); + } + return outcome(request, "error"); + } +} diff --git a/apps/site/src/app/sandbox/_demo/operations-fixtures.ts b/apps/site/src/app/sandbox/_demo/operations-fixtures.ts index 8665547b89..c55c89616b 100644 --- a/apps/site/src/app/sandbox/_demo/operations-fixtures.ts +++ b/apps/site/src/app/sandbox/_demo/operations-fixtures.ts @@ -22,6 +22,7 @@ import { type AsAuthorizationServerMetadataDependencies, executeAsAuthorizationServerMetadata, } from "@pdpp/reference-operations-sandbox/as-authorization-server-metadata"; +import { createPdppCliCommand, getPdppCliPackageInfo } from "@pdpp/reference-operations-sandbox/package-info"; import type { RefDatasetSummaryDependencies } from "@pdpp/reference-operations-sandbox/ref-dataset-summary"; import type { RefSpineCorrelationFilters, @@ -80,7 +81,6 @@ import type { StreamsListDependencies, StreamsListSourceDescriptor, } from "@pdpp/reference-operations-sandbox/rs-streams-list"; -import { createPdppCliCommand, getPdppCliPackageInfo } from "@pdpp/reference-operations-sandbox/package-info"; import { buildLiveStreamMetadata } from "./builders.ts"; import { DEMO_CONNECTORS, DEMO_GRANTS, DEMO_RECORDS, DEMO_RUNS, DEMO_STREAMS, DEMO_TRACES } from "./dataset.ts"; import type { DemoGrantDef, DemoRecord, DemoRunDef, DemoTimelineEvent, DemoTraceDef } from "./types.ts"; diff --git a/apps/site/src/app/sandbox/v1/search/route.ts b/apps/site/src/app/sandbox/v1/search/route.ts index fb65243092..b7cb092c8f 100644 --- a/apps/site/src/app/sandbox/v1/search/route.ts +++ b/apps/site/src/app/sandbox/v1/search/route.ts @@ -1,10 +1,7 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 -import { - executeSearchLexical, - SearchLexicalRequestError, -} from "@pdpp/reference-operations-sandbox/rs-search-lexical"; +import { executeSearchLexical, SearchLexicalRequestError } from "@pdpp/reference-operations-sandbox/rs-search-lexical"; import { createSandboxSearchLexicalDependencies } from "../../_demo/operations-fixtures.ts"; import { jsonResponse } from "../_helpers.ts"; diff --git a/apps/site/src/app/sandbox/v1/streams/[stream]/records/[recordId]/route.ts b/apps/site/src/app/sandbox/v1/streams/[stream]/records/[recordId]/route.ts index eda0d7348a..3d6f8bd436 100644 --- a/apps/site/src/app/sandbox/v1/streams/[stream]/records/[recordId]/route.ts +++ b/apps/site/src/app/sandbox/v1/streams/[stream]/records/[recordId]/route.ts @@ -1,10 +1,7 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 -import { - executeRecordDetail, - RecordDetailVisibilityError, -} from "@pdpp/reference-operations-sandbox/rs-records-detail"; +import { executeRecordDetail, RecordDetailVisibilityError } from "@pdpp/reference-operations-sandbox/rs-records-detail"; import { createSandboxRecordDetailDependencies } from "../../../../../_demo/operations-fixtures.ts"; import { jsonResponse, notFound } from "../../../../_helpers.ts"; diff --git a/apps/site/src/app/sandbox/v1/streams/[stream]/records/route.ts b/apps/site/src/app/sandbox/v1/streams/[stream]/records/route.ts index 4830b59237..896481ffcf 100644 --- a/apps/site/src/app/sandbox/v1/streams/[stream]/records/route.ts +++ b/apps/site/src/app/sandbox/v1/streams/[stream]/records/route.ts @@ -1,10 +1,7 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 -import { - executeRecordsList, - RecordsListVisibilityError, -} from "@pdpp/reference-operations-sandbox/rs-records-list"; +import { executeRecordsList, RecordsListVisibilityError } from "@pdpp/reference-operations-sandbox/rs-records-list"; import { createSandboxRecordsListDependencies } from "../../../../_demo/operations-fixtures.ts"; import { jsonResponse, notFound, readListParams } from "../../../_helpers.ts"; diff --git a/apps/site/src/app/sandbox/v1/streams/[stream]/route.ts b/apps/site/src/app/sandbox/v1/streams/[stream]/route.ts index a73c5c7215..3e586829e3 100644 --- a/apps/site/src/app/sandbox/v1/streams/[stream]/route.ts +++ b/apps/site/src/app/sandbox/v1/streams/[stream]/route.ts @@ -1,10 +1,7 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 -import { - executeStreamDetail, - StreamDetailVisibilityError, -} from "@pdpp/reference-operations-sandbox/rs-streams-detail"; +import { executeStreamDetail, StreamDetailVisibilityError } from "@pdpp/reference-operations-sandbox/rs-streams-detail"; import { createSandboxStreamDetailDependencies } from "../../../_demo/operations-fixtures.ts"; import { jsonResponse, notFound } from "../../_helpers.ts"; diff --git a/apps/site/src/app/specification/[[...slug]]/page.tsx b/apps/site/src/app/specification/[[...slug]]/page.tsx index 3544bd346e..e95ec2ff95 100644 --- a/apps/site/src/app/specification/[[...slug]]/page.tsx +++ b/apps/site/src/app/specification/[[...slug]]/page.tsx @@ -9,10 +9,10 @@ import { notFound } from "next/navigation"; import { LLMCopyButton, ViewOptions } from "@/components/ai/page-actions.tsx"; import { getMDXComponents } from "@/components/mdx/mdx.tsx"; import { ResponsiveSpecTable } from "@/components/mdx/responsive-table.tsx"; +import { PdppGovernanceStages } from "@/components/site/governance-stages.tsx"; import { Text } from "@/components/typography/text.tsx"; import { getPageMarkdownUrl, source } from "@/lib/docs-source.ts"; import { repoBlobUrl } from "@/lib/site-facts.ts"; -import { PdppGovernanceStages } from "@/components/site/governance-stages.tsx"; import { getGovernanceFrontMatter } from "@/lib/spec-front-matter.ts"; import { GOVERNANCE_SLUG, MAINTAINER_DOC_SLUGS, specDocExtension } from "@/lib/spec-nav-slugs.ts"; diff --git a/apps/site/src/components/layout/masthead.tsx b/apps/site/src/components/layout/masthead.tsx index 32e101477e..8570d97392 100644 --- a/apps/site/src/components/layout/masthead.tsx +++ b/apps/site/src/components/layout/masthead.tsx @@ -247,7 +247,7 @@ export function PdppConceptMasthead() { } return ( - + {label} -
+
diff --git a/apps/site/src/components/site/governance-stages.tsx b/apps/site/src/components/site/governance-stages.tsx index 1a3c7641df..f9a76ddfd9 100644 --- a/apps/site/src/components/site/governance-stages.tsx +++ b/apps/site/src/components/site/governance-stages.tsx @@ -55,7 +55,10 @@ export function PdppGovernanceStages({ className }: { className?: string }) {
    {STAGES.map((stage) => ( -
  1. +
  2. {stage.when} diff --git a/apps/site/src/components/site/signing-form.tsx b/apps/site/src/components/site/signing-form.tsx index 2f52a6437d..d42d6ec42d 100644 --- a/apps/site/src/components/site/signing-form.tsx +++ b/apps/site/src/components/site/signing-form.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { Text } from "@/components/typography/text.tsx"; -import { siteConfig, siteFlags, signingDisclosure } from "@/lib/site-config.ts"; +import { signingDisclosure, siteConfig, siteFlags } from "@/lib/site-config.ts"; import { cn } from "@/lib/utils.ts"; // The Supporter signing form. @@ -25,14 +25,7 @@ import { cn } from "@/lib/utils.ts"; // and will grow; a free-text country field would make the public register // unsortable and invite an address. -const COUNTRIES = [ - "Australia", - "Germany", - "Netherlands", - "Switzerland", - "United Kingdom", - "United States", -] as const; +const COUNTRIES = ["Australia", "Germany", "Netherlands", "Switzerland", "United Kingdom", "United States"] as const; const ORGANISATION_TYPES = ["Company", "Platform", "Research institute", "Civil society", "Public body"] as const; @@ -44,15 +37,7 @@ const fieldClassName = cn( "focus-visible:border-primary focus-visible:outline-none" ); -function Field({ - children, - htmlFor, - label, -}: { - children: React.ReactNode; - htmlFor: string; - label: string; -}) { +function Field({ children, htmlFor, label }: { children: React.ReactNode; htmlFor: string; label: string }) { return (
    @@ -96,7 +81,7 @@ export function PdppSigningForm() { {/* Two buttons rather than a select: the choice changes which fields and which consents are shown, so it is a mode, not a value. */} -
    +
    {(["individual", "organisation"] as const).map((option) => (
    +
    diff --git a/apps/site/src/components/site/supporters.tsx b/apps/site/src/components/site/supporters.tsx index cd3c794cfd..e1ce8fb2c2 100644 --- a/apps/site/src/components/site/supporters.tsx +++ b/apps/site/src/components/site/supporters.tsx @@ -21,12 +21,12 @@ import { cn } from "@/lib/utils.ts"; // reads. See docs/registers.md. export interface PublicSupporter { + country: string; + principlesVersion: string; /** First name and last initial for individuals, organisation name otherwise. */ publicName: string; - type: string; - country: string; signedOn: string; - principlesVersion: string; + type: string; } // Read at build time from the public JSON. A fetch at request time would put a @@ -74,7 +74,10 @@ export function PdppSupportersTable({ supporters }: { supporters: readonly Publi {supporters.map((supporter) => ( - + {supporter.publicName} diff --git a/apps/site/src/lib/docs-source.ts b/apps/site/src/lib/docs-source.ts index de9e700a93..d7ffc6a681 100644 --- a/apps/site/src/lib/docs-source.ts +++ b/apps/site/src/lib/docs-source.ts @@ -5,7 +5,7 @@ import type * as PageTree from "fumadocs-core/page-tree"; import { type InferPageType, loader } from "fumadocs-core/source"; import { lucideIconsPlugin } from "fumadocs-core/source/plugins/lucide-icons"; import { docs } from "../../.source/dynamic.ts"; -import { docsRoute, GUIDANCE_SLUGS, PRIMARY_SLUGS } from "./spec-nav-slugs.ts"; +import { docsRoute, GUIDANCE_SLUGS } from "./spec-nav-slugs.ts"; export const source = loader({ baseUrl: docsRoute, diff --git a/apps/site/src/lib/signing/index.ts b/apps/site/src/lib/signing/index.ts new file mode 100644 index 0000000000..fea8aedad4 --- /dev/null +++ b/apps/site/src/lib/signing/index.ts @@ -0,0 +1,286 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; +import "server-only"; +import { z } from "zod"; + +// The interim Supporter signing system, server side. +// +// THREAT MODEL, because every decision below follows from it. The public form +// is unauthenticated and reachable by anyone. The things that must not happen +// are: someone signing in another person's name; someone signing on behalf of +// an organisation they have no connection to; the register filling with junk; +// and personal data reaching the public repository. Everything here exists for +// one of those four. +// +// Nothing in this file is exercised by a test or a live run yet: the KV store, +// the mail provider and the deploy key do not exist until they are provisioned. +// It is written to FAIL CLOSED at every seam rather than to degrade, so the +// unprovisioned state is a refusal with a clear reason and never a silent +// half-write. + +// ---------------------------------------------------------------- environment + +// Server-only. None of these are NEXT_PUBLIC_, so none reach the browser +// bundle. Read lazily rather than at module load: a missing variable should +// fail the request that needs it with a nameable error, not the whole build. +function requireEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new SigningUnavailableError(`${name} is not set`); + } + return value; +} + +/** Thrown when the system is not provisioned. Never leaks to the client body. */ +export class SigningUnavailableError extends Error {} + +/** Thrown when the submission itself is bad. Its message IS shown. */ +export class SigningRejectedError extends Error {} + +// ---------------------------------------------------------------- validation + +// Free-text fields are length-capped because they end up in a filename, a +// commit message and a rendered page. `.trim()` before every check so a field +// of spaces cannot pass a min-length. +const personName = z.string().trim().min(1).max(120); + +// Deliberately not a full RFC 5322 parser: the confirmation email is the real +// check, and an over-clever pattern rejects valid addresses. This only rules +// out shapes that cannot be an address at all. +const email = z.string().trim().toLowerCase().min(3).max(254).email(); + +const COUNTRIES = ["Australia", "Germany", "Netherlands", "Switzerland", "United Kingdom", "United States"] as const; + +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"); + +const individualSubmission = z.object({ + signatory_kind: z.literal("individual"), + name: personName, + email, + affiliation: z.string().trim().max(160).optional(), + country: z.enum(COUNTRIES), + principles_version: z.string().trim().max(16), + // Both are REQUIRED for an individual: one is the signature itself, the + // other is consent to publish. A submission missing either is not a + // signature we may act on. + consent_principles: consented, + consent_register: consented, + consent_age: consented, + consent_updates: optionalConsent, +}); + +const organisationSubmission = z.object({ + signatory_kind: z.literal("organisation"), + name: personName, + email, + organisation: personName, + organisation_type: z.enum(ORGANISATION_TYPES), + country: z.enum(COUNTRIES), + principles_version: z.string().trim().max(16), + consent_principles: consented, + consent_register: consented, + consent_authority: consented, + consent_updates: optionalConsent, +}); + +export const submissionSchema = z.discriminatedUnion("signatory_kind", [individualSubmission, organisationSubmission]); + +export type Submission = z.infer; + +// Free mailbox providers an organisation signature may not come from. The rule +// in the brief is that an organisation signs from an address at its own +// domain; this list is what makes "its own domain" mean something, because +// otherwise a free-mailbox address claiming an organisation named after that +// mailbox provider would pass a naive domain comparison. +const PUBLIC_MAILBOX_DOMAINS = new Set([ + "aol.com", + "gmail.com", + "googlemail.com", + "hotmail.com", + "icloud.com", + "live.com", + "mail.com", + "outlook.com", + "proton.me", + "protonmail.com", + "yahoo.com", + "yandex.com", +]); + +/** Normalises an organisation name to the shape a domain label would take. */ +const NON_ALPHANUMERIC = /[^a-z0-9]/g; +const HYPHENS = /-/g; +const WHITESPACE = /\s+/; + +function organisationSlug(organisation: string): string { + return organisation.toLowerCase().normalize("NFKD").replace(NON_ALPHANUMERIC, ""); +} + +// An organisation submission must come from an address at the organisation's +// own domain. This is intentionally a WEAK check that refuses the obvious +// cases rather than a strong one that would refuse legitimate signatures: a +// company whose domain genuinely differs from its trading name is common, and +// the confirmation email plus the maintainers' review are the real controls. +// What it does catch is the free-mailbox case and the plainly unrelated +// domain, which is the abuse it exists for. +export function organisationDomainMatches(submission: Submission): boolean { + if (submission.signatory_kind !== "organisation") { + return true; + } + const [, domain] = submission.email.split("@"); + if (!domain || PUBLIC_MAILBOX_DOMAINS.has(domain)) { + return false; + } + const slug = organisationSlug(submission.organisation); + const domainLabels = domain.split(".").map((label) => label.replace(HYPHENS, "")); + // The organisation name matches a domain label, or a domain label is + // contained in the organisation name (so "acme.io" matches "Acme + // Technologies") — either direction, because both are ordinary. + return domainLabels.some((label) => label.length > 2 && (slug.includes(label) || label.includes(slug))); +} + +// ---------------------------------------------------------------- public name + +/** + * The name that appears on the public register. + * + * Individuals are published as first name and last initial, organisations by + * organisation name. This is computed ONCE, here, and stored alongside the + * record, rather than derived when the register is published: if the rule ever + * changes, everyone who already signed keeps the name they were shown when + * they consented to be listed. + */ +export function computePublicName(submission: Submission): string { + if (submission.signatory_kind === "organisation") { + return submission.organisation; + } + const parts = submission.name.split(WHITESPACE).filter(Boolean); + const first = parts[0] ?? submission.name; + const last = parts.length > 1 ? parts.at(-1) : undefined; + return last ? `${first} ${last.charAt(0).toUpperCase()}.` : first; +} + +// ---------------------------------------------------------------- signed links + +const TOKEN_TTL_MS = 48 * 60 * 60 * 1000; + +export type TokenPurpose = "confirm" | "withdraw"; + +interface TokenPayload { + expiresAt: number; + id: string; + purpose: TokenPurpose; +} + +function tokenSecret(): string { + return requireEnv("PDPP_SIGNING_TOKEN_SECRET"); +} + +function sign(value: string): string { + return createHmac("sha256", tokenSecret()).update(value).digest("base64url"); +} + +/** A single-use link payload: opaque id, purpose, and an absolute expiry. */ +export function createToken(id: string, purpose: TokenPurpose, now = Date.now()): string { + const payload: TokenPayload = { id, purpose, expiresAt: now + TOKEN_TTL_MS }; + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${body}.${sign(body)}`; +} + +/** + * Verifies a link. Returns null for anything wrong, without saying which + * thing: a caller that could distinguish "bad signature" from "expired" from + * "wrong purpose" is an oracle. + * + * Single use is NOT enforced here. It cannot be: a signature check is + * stateless. The store is what burns the token, and the routes do that before + * they act on it. + */ +export function verifyToken(token: string, purpose: TokenPurpose, now = Date.now()): string | null { + const [body, signature] = token.split("."); + if (!(body && signature)) { + return null; + } + const expected = sign(body); + const given = Buffer.from(signature); + const want = Buffer.from(expected); + if (given.length !== want.length || !timingSafeEqual(given, want)) { + return null; + } + try { + const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as TokenPayload; + if (payload.purpose !== purpose || payload.expiresAt < now) { + return null; + } + return payload.id; + } catch { + return null; + } +} + +export function newSubmissionId(): string { + return randomUUID(); +} + +// ---------------------------------------------------------------- the record + +/** + * What is written to the PRIVATE repository, one file per signatory. + * + * 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. + */ +export interface SignatoryRecord { + confirmedAt: string; + consent: { + principles: boolean; + register: boolean; + updates: boolean; + ageOrAuthority: boolean; + }; + country: string; + displayName: string; + email: string; + id: string; + organisation: string | null; + principlesVersion: string; + publicName: string; + signatoryName: string | null; + signatoryRole: string | null; + type: string; +} + +export function buildRecord(id: string, submission: Submission, confirmedAt = new Date()): SignatoryRecord { + const isOrganisation = submission.signatory_kind === "organisation"; + return { + id, + displayName: submission.name, + publicName: computePublicName(submission), + organisation: isOrganisation ? submission.organisation : (submission.affiliation ?? null), + signatoryName: isOrganisation ? submission.name : null, + signatoryRole: null, + email: submission.email, + country: submission.country, + type: isOrganisation ? submission.organisation_type : "Individual", + consent: { + principles: submission.consent_principles, + register: submission.consent_register, + updates: submission.consent_updates, + ageOrAuthority: isOrganisation ? submission.consent_authority : submission.consent_age, + }, + principlesVersion: submission.principles_version, + confirmedAt: confirmedAt.toISOString(), + }; +} + +/** `signatories//.json`, per the interim architecture. */ +export function recordPath(record: SignatoryRecord): string { + return `signatories/${record.confirmedAt.slice(0, 4)}/${record.id}.json`; +} diff --git a/apps/site/src/lib/signing/providers.ts b/apps/site/src/lib/signing/providers.ts new file mode 100644 index 0000000000..7b48631de4 --- /dev/null +++ b/apps/site/src/lib/signing/providers.ts @@ -0,0 +1,257 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +const TRAILING_SLASH = /\/$/; + +import "server-only"; +import { type SignatoryRecord, SigningUnavailableError, type Submission } from "./index.ts"; + +// The three external seams: the short-lived store, the mail provider, and the +// private repository. Each is behind a named function so the route code reads +// as the flow it is, and so swapping a provider is one file. +// +// Every one of them FAILS CLOSED when unprovisioned: it throws +// SigningUnavailableError, which the routes turn into a 503 with no detail. +// The alternative — degrading to a no-op — would accept a signature, tell the +// signatory it worked, and drop it, which is the worst outcome available. +// +// NONE OF THIS HAS RUN. The KV store, the mail provider and the deploy key do +// not exist yet. Treat the request shapes below as the intended contract, to +// be checked against each provider's own documentation at the point they are +// provisioned, not as verified integrations. + +function env(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new SigningUnavailableError(`${name} is not set`); + } + return value; +} + +// ---------------------------------------------------------------- pending store + +// A pending submission lives here between the form post and the confirmation +// click, and nowhere else. It expires on its own, so an unconfirmed signature +// leaves no residue: that is the whole reason it is a TTL store and not the +// private repo. +const PENDING_TTL_SECONDS = 48 * 60 * 60; + +interface KvConfig { + token: string; + url: string; +} + +function kvConfig(): KvConfig { + return { token: env("PDPP_KV_REST_API_TOKEN"), url: env("PDPP_KV_REST_API_URL").replace(TRAILING_SLASH, "") }; +} + +async function kvCommand(command: readonly (string | number)[]): Promise { + const { token, url } = kvConfig(); + const response = await fetch(`${url}/${command.map((part) => encodeURIComponent(String(part))).join("/")}`, { + cache: "no-store", + headers: { authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + throw new SigningUnavailableError(`pending store responded ${response.status}`); + } + const body = (await response.json()) as { result?: unknown }; + return body.result; +} + +export async function putPending(id: string, submission: Submission): Promise { + await kvCommand(["set", `pending:${id}`, JSON.stringify(submission), "ex", PENDING_TTL_SECONDS]); +} + +/** + * Reads a pending submission AND deletes it in the same step. + * + * This is what makes a confirmation link single-use. Reading and then deleting + * would leave a window in which two clicks both see the record and both write + * a signatory file; GETDEL closes it in the store rather than in this process, + * which is the only place it can be closed when the function runs concurrently + * in more than one region. + */ +export async function takePending(id: string): Promise { + const raw = await kvCommand(["getdel", `pending:${id}`]); + if (typeof raw !== "string") { + return null; + } + try { + return JSON.parse(raw) as Submission; + } catch { + return null; + } +} + +// ---------------------------------------------------------------- rate limit + +const RATE_LIMIT_WINDOW_SECONDS = 60 * 60; +const RATE_LIMIT_MAX = 5; + +/** + * Per-IP fixed window. Returns false when the caller is over the limit. + * + * A fixed window lets a caller burst across a boundary, which for a form that + * sends one email per submission is an acceptable trade for a counter that is + * one INCR and cannot drift. The real cost being defended is outbound email, + * not compute. + */ +export async function withinRateLimit(ip: string): Promise { + const key = `rate:${ip}`; + const count = await kvCommand(["incr", key]); + if (count === 1) { + await kvCommand(["expire", key, RATE_LIMIT_WINDOW_SECONDS]); + } + return typeof count === "number" && count <= RATE_LIMIT_MAX; +} + +// ---------------------------------------------------------------- mail + +/** + * Sends THE confirmation email. The only email this system ever sends. + * + * Both links are in this one message: confirming and withdrawing. That is why + * no second email is needed to withdraw, and why the withdrawal path needs no + * account, no password and no support request. + */ +export async function sendConfirmationEmail(options: { + confirmUrl: string; + to: string; + withdrawUrl: string; +}): Promise { + const apiKey = env("PDPP_MAIL_API_KEY"); + const from = env("PDPP_MAIL_FROM"); + + const text = [ + "You asked to sign the PDPP Principles.", + "", + "Confirm your signature (this link expires in 48 hours and can be used once):", + options.confirmUrl, + "", + "If you did not do this, ignore this email and nothing will be published.", + "", + "Keep this message. To withdraw at any time, use this link:", + options.withdrawUrl, + "", + "This is the only email this system sends.", + ].join("\n"); + + const response = await fetch("https://api.resend.com/emails", { + body: JSON.stringify({ + from, + subject: "Confirm your PDPP Principles signature", + text, + to: [options.to], + }), + headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" }, + method: "POST", + }); + + if (!response.ok) { + throw new SigningUnavailableError(`mail provider responded ${response.status}`); + } +} + +// ---------------------------------------------------------------- private repo + +// The private repository is reached with a token that has write access to that +// repository and nothing else. It is a Vercel secret and is never present in +// the public site repo. +// +// Commits are made by a bot identity with a message that says what happened +// and names no person: the commit LOG is metadata that a future maintainer +// reads, and a signatory's name in a commit subject is personal data in a +// place nobody thinks to redact. + +function repoConfig() { + return { + owner: env("PDPP_PRIVATE_REPO_OWNER"), + repo: env("PDPP_PRIVATE_REPO_NAME"), + token: env("PDPP_PRIVATE_REPO_TOKEN"), + }; +} + +async function repoRequest(pathname: string, init: RequestInit): Promise { + const { owner, repo, token } = repoConfig(); + return await fetch(`https://api.github.com/repos/${owner}/${repo}/${pathname}`, { + ...init, + cache: "no-store", + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + ...init.headers, + }, + }); +} + +const BOT = { email: "bot@pdpp.dev", name: "pdpp-supporters-bot" } as const; + +export async function writeSignatory(record: SignatoryRecord, filePath: string): Promise { + const response = await repoRequest(`contents/${filePath}`, { + body: JSON.stringify({ + committer: BOT, + content: Buffer.from(JSON.stringify(record, null, 2)).toString("base64"), + message: `Add signatory ${record.id}`, + }), + method: "PUT", + }); + if (!response.ok) { + throw new SigningUnavailableError(`private repo responded ${response.status}`); + } +} + +/** Deletes a signatory file and appends the date, and only the date, to the log. */ +export async function withdrawSignatory(id: string): Promise { + const year = new Date().getUTCFullYear(); + // The file is under the year it was confirmed in, which is not known here. + // The candidate years are probed CONCURRENTLY rather than in sequence: they + // are independent lookups, and walking them one at a time makes a withdrawal + // as slow as the number of years the register has existed. + const candidates = Array.from({ length: 6 }, (_, offset) => `signatories/${year - offset}/${id}.json`); + const found = await Promise.all( + candidates.map(async (filePath) => { + const existing = await repoRequest(`contents/${filePath}`, { method: "GET" }); + if (!existing.ok) { + return null; + } + const file = (await existing.json()) as { sha?: string }; + return file.sha ? { filePath, sha: file.sha } : null; + }) + ); + + const target = found.find((entry) => entry !== null); + if (!target) { + return false; + } + + const deleted = await repoRequest(`contents/${target.filePath}`, { + body: JSON.stringify({ committer: BOT, message: `Withdraw signatory ${id}`, sha: target.sha }), + method: "DELETE", + }); + if (!deleted.ok) { + throw new SigningUnavailableError(`private repo responded ${deleted.status}`); + } + await appendWithdrawal(); + return true; +} + +// The log records the DATE and nothing else. It exists so the size of the +// register over time can be accounted for; a reason, an id or an address in it +// would defeat the deletion it is recording. +async function appendWithdrawal(): Promise { + const existing = await repoRequest("contents/withdrawn.log", { method: "GET" }); + const previous = existing.ok ? ((await existing.json()) as { content?: string; sha?: string }) : null; + const body = previous?.content ? Buffer.from(previous.content, "base64").toString("utf8") : ""; + const next = `${body}${new Date().toISOString().slice(0, 10)}\n`; + + await repoRequest("contents/withdrawn.log", { + body: JSON.stringify({ + committer: BOT, + content: Buffer.from(next).toString("base64"), + message: "Record a withdrawal", + ...(previous?.sha ? { sha: previous.sha } : {}), + }), + method: "PUT", + }); +} diff --git a/apps/site/src/lib/sitemap-entries.ts b/apps/site/src/lib/sitemap-entries.ts index 08c89dc800..5e431d9a41 100644 --- a/apps/site/src/lib/sitemap-entries.ts +++ b/apps/site/src/lib/sitemap-entries.ts @@ -4,7 +4,6 @@ import type { MetadataRoute } from "next"; import { GOVERNANCE_SLUG, - governanceRoute, MAINTAINER_DOC_SLUGS, PRINCIPLES_SLUG, principlesRoute, @@ -59,12 +58,18 @@ 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. + // /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 + // page due to be retired should not be advertised for indexing. const staticEntries: MetadataRoute.Sitemap = [ { url: siteOrigin }, - { url: `${siteOrigin}${governanceRoute}` }, { url: `${siteOrigin}${principlesRoute}` }, - { url: `${siteOrigin}/self-host` }, + { url: `${siteOrigin}/build` }, { url: `${siteOrigin}/participate` }, + { url: `${siteOrigin}/self-host` }, + { url: `${siteOrigin}/privacy` }, ]; // Spec/docs pages get `lastmod: specLastModified` — the `Date:` header diff --git a/apps/site/src/styles/site.css b/apps/site/src/styles/site.css index 9a1342510a..929d1752d2 100644 --- a/apps/site/src/styles/site.css +++ b/apps/site/src/styles/site.css @@ -44,10 +44,10 @@ See scripts/site-surface-ownership.test.ts for the pinned two-build architecture this preserves. */ @source not "../app/(concept)"; -@source not "../app/governance"; -@source not "../app/principles"; @source not "../app/not-found.tsx"; @source not "../app/specification"; +@source not "../components/layout/review-banner.tsx"; +@source not "../components/site"; @source not "../components/layout/concept-page.tsx"; @source not "../components/layout/concept-shell.tsx"; @source not "../components/layout/footer.tsx"; diff --git a/docs/registers.md b/docs/registers.md new file mode 100644 index 0000000000..72cf4f0a8b --- /dev/null +++ b/docs/registers.md @@ -0,0 +1,138 @@ +# Registers and the data behind them + +PDP-Connect keeps three stores. Two are public and hold no personal data. One +is private and holds all of it. This document says what is in each, how data +moves between them, who can reach them, and how to hand the private one over or +delete it. + +It is written for whoever inherits this, which may not be anyone who set it up. + +## The three stores + +### 1. The private signatory repository + +`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. + +Also `withdrawn.log`: dates only, one line per withdrawal, with no identifier +and no reason. + +Reachable by the maintainers listed in [`MAINTAINERS.md`](../MAINTAINERS.md) +and by one deploy key held as a Vercel secret. Nothing else. + +This store exists only because signing opened before LF Decentralized Trust +hosting was confirmed. It is meant to stop existing. + +### 2. The public supporters register + +`apps/site/public/principles/supporters.json` in this repository. Public. +**Holds no email addresses and no signatory names.** + +Five fields per listed signatory: public name, type, country, date signed, and +Principles version. The public name is first name and last initial for an +individual, or the organisation name for an organisation. It is computed when +the signature is confirmed and stored, so a later change to the naming rule +never renames someone who already consented to appear a particular way. + +Rendered on `/principles`. + +### 3. The external mailing list + +An LF Decentralized Trust list, outside both repositories. + +Nobody is subscribed automatically. The "email me about new versions" checkbox +stores a flag in the private record and does nothing else. +`scripts/export-list-optins.mjs` in the private repository prints the opted-in +addresses for a maintainer to subscribe once, by hand. This repository sends no +email and holds no list. + +## How data moves + +``` + the form on /principles + | + | POST /api/sign + v + pending store (KV, 48h TTL) ---- expires, leaving nothing ----> gone + | + | the signatory uses the confirmation link + v + PRIVATE repo: signatories//.json + | + | scheduled publish, five fields only + v + PUBLIC repo: apps/site/public/principles/supporters.json + | + v + /principles +``` + +Three properties hold this together, and each is worth keeping: + +- **An unconfirmed submission leaves no residue.** It lives in a store with a + TTL and is never written anywhere durable until the person who owns the + address acts. +- **One direction only.** The site writes to the private repository. The + private repository writes to the public one. Nothing reads back the other + way, and the public site has no credential that can read the private store. +- **The publish script is an allowlist.** It names the five public fields one + at a time rather than deleting the private ones, so a record that grows a + field does not leak it. + +## Who has access + +| Store | Read | Write | +| --- | --- | --- | +| Private signatory repo | Maintainers in `MAINTAINERS.md` | Those maintainers, plus the site's deploy key | +| Public supporters JSON | Anyone | The publish bot, and maintainers via PR | +| Mailing list | LFDT list administrators | LFDT list administrators | + +Access to the private repository is reviewed whenever `MAINTAINERS.md` changes. +Someone who leaves that file loses access at the same time. + +## Handing the private store to a successor controller + +Run this when hosting is confirmed with LF Decentralized Trust, or whenever the +interim controller changes. + +1. **Tell the signatories.** Write to every address in `signatories/` saying who + the controller is becoming and when. This is the one message the programme + sends that the signing system does not, and it is sent by a person. +2. **Export.** `git clone --mirror` the private repository, or use + `gh repo archive`. Verify the archive opens and the file count matches. +3. **Hand over** on a channel the successor has named, and get their written + confirmation that they hold it. +4. **Rotate the deploy key.** Delete the existing key from the private + repository, create the replacement against the successor's store, and update + the Vercel secret `PDPP_PRIVATE_REPO_TOKEN`. +5. **Check the site still works**: submit a test signature, confirm it, and + watch it appear in the successor's store and then on `/principles`. Withdraw + the test signature afterwards. +6. **Delete the private repository.** An interim store kept "just in case" is + no longer interim. + +If the programme ends without a successor, do step 1, then delete the private +repository and empty `supporters.json` in the same week. + +## Deleting a single signatory + +The withdrawal link in the confirmation email does this without anyone's help: +it deletes the file and appends the date to `withdrawn.log`. The entry leaves +the public register at the next publish. + +A request that arrives by email instead is served by deleting the same file by +hand and adding the same one line. Do not record who asked. + +## If personal data reaches this public repository + +Treat it as an incident, not a tidy-up. + +1. Remove the content and force-push the branch that carried it. +2. Ask GitHub Support to purge the cached objects: force-pushed commits stay + reachable by SHA for a period. +3. Rotate the deploy key. +4. Tell the people whose data was exposed. From aead571f4e682f677b59e91d705277774150cdc7 Mon Sep 17 00:00:00 2001 From: Art A <129354338+artvana@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:04:31 +0200 Subject: [PATCH 6/9] site: set the interim controller and general contact The Vana Foundation holds the Supporter register until LF Decentralized Trust hosting is confirmed, and support@vanafoundation.org is the project's general address. Both were placeholders; they are decided, so they render as themselves. They are defaults rather than hard-coded values: a deployment can still override either through the environment, and the transfer to LFDT is one edit here. The privacy contact defaults to the general address for now. A privacy page that cannot name an address to write to is worse than one naming the same address as everything else; split them by setting the variable when a dedicated mailbox exists. The footer gains its Contact block. Reports and General are never the same address: reports is the LF Decentralized Trust mailbox fixed by GOVERNANCE.md's header, general is the project's own, and routing either to the other is the failure the separation prevents. The footer's governance link now points at /specification#governance rather than through the /governance redirect. Verified on the served build: no bracketed placeholder renders on /, /principles, /privacy or /participate, and no em-dash appears in site copy on any of the seven routes. Discord and the mailing list URL are the two values still unset. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NECjjZbYWNTzq3bu7nuiDP Signed-off-by: Art A <129354338+artvana@users.noreply.github.com> --- apps/site/src/components/layout/footer.tsx | 27 ++++++++++++++++++- apps/site/src/lib/site-config.ts | 30 ++++++++++++++++++---- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/apps/site/src/components/layout/footer.tsx b/apps/site/src/components/layout/footer.tsx index cac28cd8b2..27a26f2e68 100644 --- a/apps/site/src/components/layout/footer.tsx +++ b/apps/site/src/components/layout/footer.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { Suspense } from "react"; import { isContributorSurfaceEnabled } from "@/lib/contributor-surface.ts"; +import { REPORTS_EMAIL, REPORTS_EMAIL_HREF, siteConfig } from "@/lib/site-config.ts"; import { DISCORD_INVITE_URL, GITHUB_REPO_URL, @@ -117,7 +118,7 @@ export function PdppConceptFooter() { {/* Internal routes, so next/link rather than the bare the LFDT attribution above uses (that one leaves the site). */} - + Governance, membership and conformance @@ -166,6 +167,30 @@ export function PdppConceptFooter() { {githubDisplayText}
    + {/* Two addresses, and they are never the same one. Reports is an + LF Decentralized Trust mailbox fixed by GOVERNANCE.md's own + header and is where a conduct or conformance report goes; + General is the project's own address for everything else. + Routing a report to the general mailbox, or general mail to + the reports one, is the failure this separation exists to + prevent. */} +
diff --git a/apps/site/src/lib/site-config.ts b/apps/site/src/lib/site-config.ts index 0342b4e355..fa05758835 100644 --- a/apps/site/src/lib/site-config.ts +++ b/apps/site/src/lib/site-config.ts @@ -24,6 +24,16 @@ function configured(value: string | undefined, placeholder: string): string { return trimmed ? trimmed : `[${placeholder}]`; } +/** + * A value that IS settled, with an env override for a deployment that needs a + * different one. Distinct from `configured` on purpose: a real default here is + * a fact somebody decided, and it should render as itself rather than as a + * placeholder nobody has to fill in. + */ +function settled(value: string | undefined, agreed: string): string { + return value?.trim() || agreed; +} + /** True when a value is still an unfilled placeholder. */ export function isPlaceholder(value: string): boolean { return PLACEHOLDER_PATTERN.test(value); @@ -48,14 +58,24 @@ export const siteConfig = { /** Where the Supporter form posts. Same-origin by default. */ formEndpoint: configured(process.env.NEXT_PUBLIC_PDPP_FORM_ENDPOINT, "form endpoint not set"), - /** Named on /privacy and under the form. The interim data controller. */ - controllerName: configured(process.env.NEXT_PUBLIC_PDPP_CONTROLLER_NAME, "controller name not set"), + /** + * The interim data controller, named on /privacy and under the signing form. + * The Vana Foundation holds the register until LF Decentralized Trust + * hosting is confirmed, at which point it transfers and this changes once. + */ + controllerName: settled(process.env.NEXT_PUBLIC_PDPP_CONTROLLER_NAME, "the Vana Foundation"), /** The footer's General contact. Deliberately NOT the reports mailbox. */ - generalContact: configured(process.env.NEXT_PUBLIC_PDPP_GENERAL_CONTACT, "general contact address not set"), + generalContact: settled(process.env.NEXT_PUBLIC_PDPP_GENERAL_CONTACT, "support@vanafoundation.org"), - /** Named on /privacy as the address for a data question. */ - privacyContact: configured(process.env.NEXT_PUBLIC_PDPP_PRIVACY_CONTACT, "privacy contact address not set"), + /** + * Named on /privacy as the address for a data question. Defaults to the + * general contact rather than to a placeholder: a privacy page that cannot + * name an address to write to is worse than one that names the same address + * as everything else. Split them by setting this when a dedicated mailbox + * exists. + */ + privacyContact: settled(process.env.NEXT_PUBLIC_PDPP_PRIVACY_CONTACT, "support@vanafoundation.org"), /** The transactional provider that sends the one confirmation email. */ emailProvider: configured(process.env.NEXT_PUBLIC_PDPP_EMAIL_PROVIDER, "email provider not set"), From 4d2c3269481351c07d5ba78bcf5b9af8e36baa55 Mon Sep 17 00:00:00 2001 From: Art A <129354338+artvana@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:21:13 +0200 Subject: [PATCH 7/9] site: hold every personal-data surface in a waiting state The gate is not "is it a list", it is "would this put personal data in the private repository". Nothing that would collect a person's details is reachable until the arrangement for holding them is confirmed. A form that takes an address into a store nobody has agreed the shape of is the one thing on this site that a later edit cannot undo. Three surfaces claimed signing was open and no longer do: - The signing section keeps its place on /principles and now carries a waiting panel. It says we are not taking signatures yet, says why, and sends the reader to the specification instead, because someone who arrived wanting to support the work can still do the useful thing this week and comment on it. - /participate's Level 2 said "Open now". It now says signing opens once the register has a confirmed home, and its link goes to the Principles document rather than to a form that is not there. - The Pre stage in the governance diagram said "you can sign now". The Discord and mailing list cards no longer render an unset config value as a live link. An href of "[mailing list URL not set]" looks live and goes nowhere, which is worst at exactly the moment a reader expects to hand over an address; they show "Link to follow" until the destination is real. The mailing list opt-in needed no separate change: it lives inside the form, which is absent while signingLive is off. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NECjjZbYWNTzq3bu7nuiDP Signed-off-by: Art A <129354338+artvana@users.noreply.github.com> --- .../src/app/(concept)/participate/page.tsx | 61 +++++++++++-------- .../src/components/site/governance-stages.tsx | 2 +- .../site/src/components/site/signing-form.tsx | 32 +++++++++- 3 files changed, 66 insertions(+), 29 deletions(-) diff --git a/apps/site/src/app/(concept)/participate/page.tsx b/apps/site/src/app/(concept)/participate/page.tsx index 88b163f80a..afb05e1ca3 100644 --- a/apps/site/src/app/(concept)/participate/page.tsx +++ b/apps/site/src/app/(concept)/participate/page.tsx @@ -8,7 +8,7 @@ import Link from "next/link"; import { PdppConceptDoc, PdppConceptPage } from "@/components/layout/concept-page.tsx"; import { PdppConceptSection } from "@/components/sections/concept-section.tsx"; import { Text } from "@/components/typography/text.tsx"; -import { REPORTS_EMAIL_HREF, siteConfig, siteFlags } from "@/lib/site-config.ts"; +import { isPlaceholder, REPORTS_EMAIL_HREF, siteConfig, siteFlags } from "@/lib/site-config.ts"; import { GITHUB_REPO_URL } from "@/lib/site-facts.ts"; import { cn } from "@/lib/utils.ts"; @@ -22,6 +22,31 @@ export const metadata: Metadata = { const CARD = cn("flex flex-col gap-3 bg-background p-6", "shadow-[0_0_0_1px_var(--border)]"); const CELL = "px-3 py-2.5 text-left align-top"; +/** + * A channel link that refuses to be a link until its destination is real. + * + * An unset config value renders as a bracketed placeholder, which as an `href` + * would be a link that looks live and goes nowhere. Worse for the mailing + * list than for Discord: a reader who clicks "Subscribe" expects to hand over + * an address, and a dead link at that moment reads as the site losing it. + */ +function ChannelLink({ href, label }: { href: string; label: string }) { + if (isPlaceholder(href)) { + return ( + + Link to follow + + ); + } + return ( + + + {label} + + + ); +} + interface RegisterEntry { organisation: string; role: string; @@ -64,9 +89,9 @@ const LEVELS = [ { level: "Level 2", title: "Become a Supporter", - body: "Sign the Principles and go on the public list. Open now.", - cta: "Become a Supporter", - href: "/principles#who-can-sign", + body: "Sign the Principles and go on the public list. Opening shortly, once we have confirmed where the register will live.", + cta: "Read the Principles", + href: "/principles", }, { level: "Level 3", @@ -377,16 +402,9 @@ export default async function Page() { Where things are announced first. - - - Join → - - +
+ +
@@ -396,18 +414,11 @@ export default async function Page() { Mailing list - New versions and comment periods. Low volume. - - - - Subscribe → - + New versions and comment periods. Low volume. Opening alongside the register. +
+ +
diff --git a/apps/site/src/components/site/governance-stages.tsx b/apps/site/src/components/site/governance-stages.tsx index f9a76ddfd9..356f1898b0 100644 --- a/apps/site/src/components/site/governance-stages.tsx +++ b/apps/site/src/components/site/governance-stages.tsx @@ -28,7 +28,7 @@ const STAGES: readonly Stage[] = [ when: "Pre · now, to 15 Oct", name: "Public comment", authority: "The maintainers", - body: "The specification and Part A are frozen while people comment, the Principles are published, and you can sign now. This stage ends on 15 October.", + body: "The specification and Part A are frozen while people comment, and the Principles are published. Supporter signing opens once the register has a confirmed home. This stage ends on 15 October.", }, { when: "Phase 1 · from 15 Oct", diff --git a/apps/site/src/components/site/signing-form.tsx b/apps/site/src/components/site/signing-form.tsx index d42d6ec42d..70a73b6ecd 100644 --- a/apps/site/src/components/site/signing-form.tsx +++ b/apps/site/src/components/site/signing-form.tsx @@ -3,6 +3,7 @@ "use client"; +import Link from "next/link"; import { useState } from "react"; import { Text } from "@/components/typography/text.tsx"; import { signingDisclosure, siteConfig, siteFlags } from "@/lib/site-config.ts"; @@ -15,6 +16,16 @@ import { cn } from "@/lib/utils.ts"; // field names and the endpoint to every reader, and a hidden one can be // re-enabled from devtools and posted to. Off means absent. // +// The section still exists when it is off, carrying the waiting panel below. +// The rule it enforces: anything that would put personal data in the private +// repository stays shut until the arrangement for holding it is confirmed. A +// form that collects an address into a store nobody has agreed the shape of is +// the one thing here that cannot be undone by a later edit. +// +// The panel sends the reader to the specification rather than apologising and +// stopping. Someone who arrived wanting to support the work can still do the +// thing that is actually useful this week, which is read it and comment. +// // The form posts to siteConfig.formEndpoint and does nothing else client-side: // no validation logic here is load-bearing. Every rule that matters (the // schema, the rate limit, the organisation-domain check) is enforced in @@ -64,9 +75,24 @@ export function PdppSigningForm() { if (!siteFlags.signingLive) { return ( -
- - Signing opens once hosting is confirmed with LF Decentralized Trust. +
+ + Signatures open shortly + + + We are not taking signatures yet. A register of Supporters means holding people's details, and we are settling + where it will live with LF Decentralized Trust before we ask anyone for theirs. + + + The most useful thing you can do meanwhile is read the specification and tell us where it is wrong. + + + + {siteFlags.reviewOpen ? "Review the specification →" : "Read the specification →"} +
); From b1881a19660c495091bdcb01185cf370eb63ce76 Mon Sep 17 00:00:00 2001 From: Art A <129354338+artvana@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:27:08 +0200 Subject: [PATCH 8/9] site: set the Discord invite, and make it one value discord.gg/JmKcFYE4 for #pdp-connect. It also collapses a duplicate. The footer read DISCORD_INVITE_URL from site-facts while /participate read discordUrl from config, so the site held two Discord constants at once. That is how a site ends up publishing two different invites and only noticing when one of them expires. Config owns the value now and the footer reads the same one; the site-facts constant is retired with a note saying where it went. The previous invite (FV4bkZBdmA) is replaced, not kept as a fallback. Two live invites to the same channel is the state this change exists to remove. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NECjjZbYWNTzq3bu7nuiDP Signed-off-by: Art A <129354338+artvana@users.noreply.github.com> --- apps/site/src/components/layout/footer.tsx | 10 ++-------- apps/site/src/lib/site-config.ts | 8 +++++++- apps/site/src/lib/site-facts.ts | 9 +++++---- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/apps/site/src/components/layout/footer.tsx b/apps/site/src/components/layout/footer.tsx index 27a26f2e68..7054395b42 100644 --- a/apps/site/src/components/layout/footer.tsx +++ b/apps/site/src/components/layout/footer.tsx @@ -5,13 +5,7 @@ import Link from "next/link"; import { Suspense } from "react"; import { isContributorSurfaceEnabled } from "@/lib/contributor-surface.ts"; import { REPORTS_EMAIL, REPORTS_EMAIL_HREF, siteConfig } from "@/lib/site-config.ts"; -import { - DISCORD_INVITE_URL, - GITHUB_REPO_URL, - LFDT_COPYRIGHT_NOTICE, - LFPROJECTS_URL, - SITE_LICENSES, -} from "@/lib/site-facts.ts"; +import { GITHUB_REPO_URL, LFDT_COPYRIGHT_NOTICE, LFPROJECTS_URL, SITE_LICENSES } from "@/lib/site-facts.ts"; import { cn } from "@/lib/utils.ts"; import { ColorSchemeMenu } from "../elements/color-scheme-menu.tsx"; import { DiscordIcon, GithubIcon, WordmarkIcon } from "../elements/icons.tsx"; @@ -139,7 +133,7 @@ export function PdppConceptFooter() { as="a" className={footerLinkClassName} color="onAccent" - href={DISCORD_INVITE_URL} + href={siteConfig.discordUrl} rel="noopener noreferrer" size="inherit" target="_blank" diff --git a/apps/site/src/lib/site-config.ts b/apps/site/src/lib/site-config.ts index fa05758835..22409bbec0 100644 --- a/apps/site/src/lib/site-config.ts +++ b/apps/site/src/lib/site-config.ts @@ -80,7 +80,13 @@ export const siteConfig = { /** The transactional provider that sends the one confirmation email. */ emailProvider: configured(process.env.NEXT_PUBLIC_PDPP_EMAIL_PROVIDER, "email provider not set"), - discordUrl: configured(process.env.NEXT_PUBLIC_PDPP_DISCORD_URL, "Discord URL not set"), + /** + * The #pdp-connect invite. One value for the whole site: the footer and + * /participate previously held separate constants, which is how a site ends + * up offering two different doors to the same room and only noticing when + * one of them expires. + */ + discordUrl: settled(process.env.NEXT_PUBLIC_PDPP_DISCORD_URL, "https://discord.gg/JmKcFYE4"), mailingListUrl: configured(process.env.NEXT_PUBLIC_PDPP_MAILING_LIST_URL, "mailing list URL not set"), } as const; diff --git a/apps/site/src/lib/site-facts.ts b/apps/site/src/lib/site-facts.ts index cdc6bbd325..003b69b98b 100644 --- a/apps/site/src/lib/site-facts.ts +++ b/apps/site/src/lib/site-facts.ts @@ -24,10 +24,11 @@ export function repoBlobUrl(repoRelativePath: string): string { return `${GITHUB_REPO_URL}/blob/main/${repoRelativePath}`; } -// The LFDT lab channel invite. discord.gg/hyperledger reaches the whole LFDT -// server; this one drops the visitor directly into #pdp-connect, which is the -// invite the owner said to prefer. -export const DISCORD_INVITE_URL = "https://discord.gg/FV4bkZBdmA"; +// The Discord invite moved to site-config.ts, where it is one value the whole +// site reads. It sat here as a second constant while /participate read its own +// from config, which is how a site ends up publishing two different invites. +// discord.gg/hyperledger reaches the whole LFDT server; the configured invite +// drops the visitor directly into #pdp-connect. // Specification text FIRST — explicit owner instruction. Each row states which // artifact the license covers and links the license text, because the footer From 4708eb8894a23fb37a3e4aa674d2989a8e3f7212 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 3 Sep 2026 08:52:53 -0500 Subject: [PATCH 9/9] fix: repair site check regressions Signed-off-by: Tim Nunamaker Assisted-by: AI --- apps/site/.source/dynamic.ts | 2 +- apps/site/src/app/(concept)/page.tsx | 3 +- .../src/app/(concept)/participate/page.tsx | 496 +++++++++--------- .../src/app/(concept)/principles/page.tsx | 8 +- apps/site/src/app/(concept)/review/page.tsx | 23 +- .../components/site/report-contact-card.tsx | 30 ++ apps/site/src/components/site/supporters.tsx | 35 +- apps/site/src/lib/public-supporters.ts | 28 + apps/site/src/lib/signing/index.ts | 13 +- apps/site/src/lib/site-config.ts | 10 +- apps/site/src/lib/site-facts.ts | 9 + apps/site/src/lib/spec-nav-slugs.ts | 3 - 12 files changed, 357 insertions(+), 303 deletions(-) create mode 100644 apps/site/src/components/site/report-contact-card.tsx create mode 100644 apps/site/src/lib/public-supporters.ts diff --git a/apps/site/.source/dynamic.ts b/apps/site/.source/dynamic.ts index a7d1011bcb..b54efd2a55 100644 --- a/apps/site/.source/dynamic.ts +++ b/apps/site/.source/dynamic.ts @@ -9,4 +9,4 @@ const create = await dynamic(Config, {"environment":"dynamic","root":"","configPath":"source.config.ts","outDir":".source"}); -export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, }, [{ absolutePath: path.resolve("content/docs/README.md"), info: {"fullPath":"content/docs/README.md","path":"README.md"}, data: {"title":"Web Spec Publishing Notes","description":"Internal notes for keeping canonical root specs and public web spec pages in sync."}, hash: "1ba07ddf8f89f32bb09ef593bff2fd69" }, { absolutePath: path.resolve("content/docs/governance.mdx"), info: {"fullPath":"content/docs/governance.mdx","path":"governance.mdx"}, data: {"title":"Governance, Membership and Conformance","description":"How PDP-Connect is governed, how membership works, and how conformance status is obtained."}, hash: "7201976fdc9c85f1b4fc68f9ff61ce6e" }, { absolutePath: path.resolve("content/docs/index.mdx"), info: {"fullPath":"content/docs/index.mdx","path":"index.mdx"}, data: {"title":"PDPP Docs","description":"Protocol documentation for the Personal Data Portability Protocol."}, hash: "e04b60d907022a185bd9fd9df5192ec3" }, { absolutePath: path.resolve("content/docs/open-questions.md"), info: {"fullPath":"content/docs/open-questions.md","path":"open-questions.md"}, data: {"title":"Open Questions","description":"Open design questions for PDPP, ordered by importance. Consolidated from the deferred-concerns register, design notes, and review threads."}, hash: "3ea665881be8088ad2fdd365fd3bd934" }, { absolutePath: path.resolve("content/docs/principles.mdx"), info: {"fullPath":"content/docs/principles.mdx","path":"principles.mdx"}, data: {"title":"PDPP Principles","description":"The intentions behind the Personal Data Portability Protocol, and the register of Supporters who have signed them."}, hash: "c45c1bc826a46cb2e445a1c0c19ae86c" }, { absolutePath: path.resolve("content/docs/reference-implementation-examples.md"), info: {"fullPath":"content/docs/reference-implementation-examples.md","path":"reference-implementation-examples.md"}, data: {"title":"Reference Implementation Examples","description":"Current end-to-end examples from the live PDPP reference implementation. Not normative protocol documentation."}, hash: "c85fc7b6d48a9cc1d38044f4c12575bc" }, { absolutePath: path.resolve("content/docs/reference-implementation.md"), info: {"fullPath":"content/docs/reference-implementation.md","path":"reference-implementation.md"}, data: {"title":"Reference Implementation Notes","description":"Current implementation behavior for the forkable PDPP reference stack. Not normative protocol documentation."}, hash: "6041a35a9600acbfff176cef857194b4" }, { absolutePath: path.resolve("content/docs/spec-architecture.mdx"), info: {"fullPath":"content/docs/spec-architecture.mdx","path":"spec-architecture.mdx"}, data: {"title":"Reference Topology","description":"How the current PDPP reference components relate: native provider, polyfill path, runtime, and client flows."}, hash: "b61dca1dec9ea08f712af40b8eac348e" }, { absolutePath: path.resolve("content/docs/spec-auth-design.mdx"), info: {"fullPath":"content/docs/spec-auth-design.mdx","path":"spec-auth-design.mdx"}, data: {"title":"Auth Design","description":"Bearer tokens at both boundaries: wire format and semantics. Identity provider and token issuance are out of scope."}, hash: "21a81024720485a411c29750b9975bdc" }, { absolutePath: path.resolve("content/docs/spec-change-tracking.mdx"), info: {"fullPath":"content/docs/spec-change-tracking.mdx","path":"spec-change-tracking.mdx"}, data: {"title":"Change Tracking","description":"Design rationale for grant-relative incremental sync via changes_since cursors, not canonical changelog streams."}, hash: "68115584c5dcc7217989177a07acbae8" }, { absolutePath: path.resolve("content/docs/spec-collection-profile.mdx"), info: {"fullPath":"content/docs/spec-collection-profile.mdx","path":"spec-collection-profile.mdx"}, data: {"title":"Collection Profile","description":"Companion to the Personal Data Portability Protocol (PDPP) core spec."}, hash: "9212a1b3a642ec58e41f671561a85f67" }, { absolutePath: path.resolve("content/docs/spec-connector-ecosystem.mdx"), info: {"fullPath":"content/docs/spec-connector-ecosystem.mdx","path":"spec-connector-ecosystem.mdx"}, data: {"title":"Connector Ecosystem","description":"Reference runtime notes for connectors: browser abstraction decisions and third-party source integration."}, hash: "aaa40c2bc89cef2ed3dbba142980fe2d" }, { absolutePath: path.resolve("content/docs/spec-core.mdx"), info: {"fullPath":"content/docs/spec-core.mdx","path":"spec-core.mdx"}, data: {"title":"Protocol Specification","description":"Authorization and disclosure semantics for personal data — record model, selection request, grant, manifest, and resource server interface."}, hash: "cf9ad0d03105703943bf70c371423dfc" }, { absolutePath: path.resolve("content/docs/spec-data-query-api.mdx"), info: {"fullPath":"content/docs/spec-data-query-api.mdx","path":"spec-data-query-api.mdx"}, data: {"title":"Data Query API (Superseded)","description":"Historical companion spec — Core spec Section 8 is now authoritative for the RS query interface."}, hash: "6c1b573cb7747eb75d56c03e628258f0" }, { absolutePath: path.resolve("content/docs/spec-deferred.mdx"), info: {"fullPath":"content/docs/spec-deferred.mdx","path":"spec-deferred.mdx"}, data: {"title":"Deferred Concerns","description":"Issues identified during design and review that are intentionally out of scope for v0.1. Each item is named precisely so it can be referenced from the core spec and tracked for future versions."}, hash: "c84d2ebdd5c42a9e54de853333027c7d" }, { absolutePath: path.resolve("content/docs/spec-discovery-and-trust.mdx"), info: {"fullPath":"content/docs/spec-discovery-and-trust.mdx","path":"spec-discovery-and-trust.mdx"}, data: {"title":"Discovery and Trust","description":"How an authorization server discovers, validates, and accepts PDPP source declarations."}, hash: "adc6123e46e970e36a0bc227f5943e7b" }, { absolutePath: path.resolve("content/docs/spec-ext-aggregation.md"), info: {"fullPath":"content/docs/spec-ext-aggregation.md","path":"spec-ext-aggregation.md"}, data: {"title":"Extension Profile: Aggregation","description":"Optional companion profile to the Personal Data Portability Protocol (PDPP) core spec defining a discoverable, grant-safe, single-stream aggregation surface."}, hash: "06736a6d3b48250fb97edffc8e2a9f43" }, { absolutePath: path.resolve("content/docs/spec-ext-lexical-search.md"), info: {"fullPath":"content/docs/spec-ext-lexical-search.md","path":"spec-ext-lexical-search.md"}, data: {"title":"Extension Profile: Lexical Search","description":"Optional companion profile to the Personal Data Portability Protocol (PDPP) core spec defining a discoverable, grant-safe lexical (full-text) search surface."}, hash: "2cd10de7c2b40e7885eebdac106cbcf9" }, { absolutePath: path.resolve("content/docs/spec-semantic-retrieval-extension.md"), info: {"fullPath":"content/docs/spec-semantic-retrieval-extension.md","path":"spec-semantic-retrieval-extension.md"}, data: {"title":"Semantic Retrieval Extension (Experimental)","description":"Experimental optional PDPP extension defining a semantic retrieval surface at GET /v1/search/semantic. Unstable."}, hash: "63d2e31e5cf9c16494ea6c77a3844636" }]); +export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, }, [{ absolutePath: path.resolve("content/docs/README.md"), info: {"fullPath":"content/docs/README.md","path":"README.md"}, data: {"title":"Web Spec Publishing Notes","description":"Internal notes for keeping canonical root specs and public web spec pages in sync."}, hash: "1ba07ddf8f89f32bb09ef593bff2fd69" }, { absolutePath: path.resolve("content/docs/governance.mdx"), info: {"fullPath":"content/docs/governance.mdx","path":"governance.mdx"}, data: {"title":"Governance, Membership and Conformance","description":"How PDP-Connect is governed, how membership works, and how conformance status is obtained."}, hash: "92336a5e83dd05ca54b40c259811527a" }, { absolutePath: path.resolve("content/docs/index.mdx"), info: {"fullPath":"content/docs/index.mdx","path":"index.mdx"}, data: {"title":"PDPP Docs","description":"Protocol documentation for the Personal Data Portability Protocol."}, hash: "0c2a01a3e0cf24327bbe2382f3ec8c0b" }, { absolutePath: path.resolve("content/docs/open-questions.md"), info: {"fullPath":"content/docs/open-questions.md","path":"open-questions.md"}, data: {"title":"Open Questions","description":"Open design questions for PDPP, ordered by importance. Consolidated from the deferred-concerns register, design notes, and review threads."}, hash: "3ea665881be8088ad2fdd365fd3bd934" }, { absolutePath: path.resolve("content/docs/principles.mdx"), info: {"fullPath":"content/docs/principles.mdx","path":"principles.mdx"}, data: {"title":"PDPP Principles","description":"The intentions behind the Personal Data Portability Protocol, and the register of Supporters who have signed them."}, hash: "c45c1bc826a46cb2e445a1c0c19ae86c" }, { absolutePath: path.resolve("content/docs/reference-implementation-examples.md"), info: {"fullPath":"content/docs/reference-implementation-examples.md","path":"reference-implementation-examples.md"}, data: {"title":"Reference Implementation Examples","description":"Current end-to-end examples from the live PDPP reference implementation. Not normative protocol documentation."}, hash: "c85fc7b6d48a9cc1d38044f4c12575bc" }, { absolutePath: path.resolve("content/docs/reference-implementation.md"), info: {"fullPath":"content/docs/reference-implementation.md","path":"reference-implementation.md"}, data: {"title":"Reference Implementation Notes","description":"Current implementation behavior for the forkable PDPP reference stack. Not normative protocol documentation."}, hash: "6041a35a9600acbfff176cef857194b4" }, { absolutePath: path.resolve("content/docs/spec-architecture.mdx"), info: {"fullPath":"content/docs/spec-architecture.mdx","path":"spec-architecture.mdx"}, data: {"title":"Reference Topology","description":"How the current PDPP reference components relate: native provider, polyfill path, runtime, and client flows."}, hash: "b61dca1dec9ea08f712af40b8eac348e" }, { absolutePath: path.resolve("content/docs/spec-auth-design.mdx"), info: {"fullPath":"content/docs/spec-auth-design.mdx","path":"spec-auth-design.mdx"}, data: {"title":"Auth Design","description":"Bearer tokens at both boundaries: wire format and semantics. Identity provider and token issuance are out of scope."}, hash: "21a81024720485a411c29750b9975bdc" }, { absolutePath: path.resolve("content/docs/spec-change-tracking.mdx"), info: {"fullPath":"content/docs/spec-change-tracking.mdx","path":"spec-change-tracking.mdx"}, data: {"title":"Change Tracking","description":"Design rationale for grant-relative incremental sync via changes_since cursors, not canonical changelog streams."}, hash: "68115584c5dcc7217989177a07acbae8" }, { absolutePath: path.resolve("content/docs/spec-collection-profile.mdx"), info: {"fullPath":"content/docs/spec-collection-profile.mdx","path":"spec-collection-profile.mdx"}, data: {"title":"Collection Profile","description":"Informative builder guidance for connector collection. Not a conformance requirement."}, hash: "76ed4a4d1d87ce6e35a641ee99c44d2a" }, { absolutePath: path.resolve("content/docs/spec-connector-ecosystem.mdx"), info: {"fullPath":"content/docs/spec-connector-ecosystem.mdx","path":"spec-connector-ecosystem.mdx"}, data: {"title":"Connector Ecosystem","description":"Reference runtime notes for connectors: browser abstraction decisions and third-party source integration."}, hash: "aaa40c2bc89cef2ed3dbba142980fe2d" }, { absolutePath: path.resolve("content/docs/spec-core.mdx"), info: {"fullPath":"content/docs/spec-core.mdx","path":"spec-core.mdx"}, data: {"title":"Protocol Specification","description":"Authorization and disclosure semantics for personal data — record model, selection request, grant, manifest, and resource server interface."}, hash: "782d62db206c6062dda06a365d90f180" }, { absolutePath: path.resolve("content/docs/spec-data-query-api.mdx"), info: {"fullPath":"content/docs/spec-data-query-api.mdx","path":"spec-data-query-api.mdx"}, data: {"title":"Data Query API (Superseded)","description":"Historical companion spec — Core spec Section 8 is now authoritative for the RS query interface."}, hash: "6c1b573cb7747eb75d56c03e628258f0" }, { absolutePath: path.resolve("content/docs/spec-deferred.mdx"), info: {"fullPath":"content/docs/spec-deferred.mdx","path":"spec-deferred.mdx"}, data: {"title":"Deferred Concerns","description":"Issues identified during design and review that are intentionally out of scope for v0.1. Each item is named precisely so it can be referenced from the core spec and tracked for future versions."}, hash: "94af4b79a6c3b957c2b58713fc5dbfd6" }, { absolutePath: path.resolve("content/docs/spec-discovery-and-trust.mdx"), info: {"fullPath":"content/docs/spec-discovery-and-trust.mdx","path":"spec-discovery-and-trust.mdx"}, data: {"title":"Discovery and Trust","description":"Informative implementation guidance for discovering and retrieving a source declaration. The normative requirements live in Core Section 5."}, hash: "38526e08da5a6fffaf29dca5ebaf47c4" }, { absolutePath: path.resolve("content/docs/spec-ext-aggregation.md"), info: {"fullPath":"content/docs/spec-ext-aggregation.md","path":"spec-ext-aggregation.md"}, data: {"title":"Extension Profile: Aggregation","description":"Optional companion profile to the Personal Data Portability Protocol (PDPP) core spec defining a discoverable, grant-safe, single-stream aggregation surface."}, hash: "06736a6d3b48250fb97edffc8e2a9f43" }, { absolutePath: path.resolve("content/docs/spec-ext-lexical-search.md"), info: {"fullPath":"content/docs/spec-ext-lexical-search.md","path":"spec-ext-lexical-search.md"}, data: {"title":"Extension Profile: Lexical Search","description":"Optional companion profile to the Personal Data Portability Protocol (PDPP) core spec defining a discoverable, grant-safe lexical (full-text) search surface."}, hash: "2cd10de7c2b40e7885eebdac106cbcf9" }, { absolutePath: path.resolve("content/docs/spec-semantic-retrieval-extension.md"), info: {"fullPath":"content/docs/spec-semantic-retrieval-extension.md","path":"spec-semantic-retrieval-extension.md"}, data: {"title":"Semantic Retrieval Extension (Experimental)","description":"Experimental optional PDPP extension defining a semantic retrieval surface at GET /v1/search/semantic. Unstable."}, hash: "63d2e31e5cf9c16494ea6c77a3844636" }]); \ No newline at end of file diff --git a/apps/site/src/app/(concept)/page.tsx b/apps/site/src/app/(concept)/page.tsx index 9d06c2fc23..65dad267b3 100644 --- a/apps/site/src/app/(concept)/page.tsx +++ b/apps/site/src/app/(concept)/page.tsx @@ -7,9 +7,10 @@ import { PdppConceptDoc, PdppConceptPage } from "@/components/layout/concept-pag import { PdppConceptSection } from "@/components/sections/concept-section.tsx"; import { PdppHeroWaterStill } from "@/components/sections/hero-water-still.tsx"; import { PdppPrinciplesList } from "@/components/site/principles-list.tsx"; -import { PdppSupportersTable, readPublicSupporters } from "@/components/site/supporters.tsx"; +import { PdppSupportersTable } from "@/components/site/supporters.tsx"; import { PdppWhatChanges } from "@/components/site/what-changes.tsx"; import { Text } from "@/components/typography/text.tsx"; +import { readPublicSupporters } from "@/lib/public-supporters.ts"; import { cn } from "@/lib/utils.ts"; export const metadata: Metadata = { diff --git a/apps/site/src/app/(concept)/participate/page.tsx b/apps/site/src/app/(concept)/participate/page.tsx index afb05e1ca3..c82f254c6a 100644 --- a/apps/site/src/app/(concept)/participate/page.tsx +++ b/apps/site/src/app/(concept)/participate/page.tsx @@ -7,8 +7,9 @@ import type { Metadata } from "next"; import Link from "next/link"; import { PdppConceptDoc, PdppConceptPage } from "@/components/layout/concept-page.tsx"; import { PdppConceptSection } from "@/components/sections/concept-section.tsx"; +import { PdppEmailContactCard } from "@/components/site/report-contact-card.tsx"; import { Text } from "@/components/typography/text.tsx"; -import { isPlaceholder, REPORTS_EMAIL_HREF, siteConfig, siteFlags } from "@/lib/site-config.ts"; +import { isPlaceholder, siteConfig, siteFlags } from "@/lib/site-config.ts"; import { GITHUB_REPO_URL } from "@/lib/site-facts.ts"; import { cn } from "@/lib/utils.ts"; @@ -158,8 +159,10 @@ const STATUSES = [ ] as const; export default async function Page() { - const register = await readJson("register", "index.json"); - const trustRegistries = await readJson("register", "trust-registries.json"); + const [register, trustRegistries] = await Promise.all([ + readJson("register", "index.json"), + readJson("register", "trust-registries.json"), + ]); return ( @@ -195,267 +198,274 @@ export default async function Page() { ))}
- -
- {STEPS.map((step) => ( -
- - {step.step} - - - {step.title} - - - {step.body} - -
- ))} + + + + + + ); +} + +function VerificationSection() { + return ( + +
+ {STEPS.map((step) => ( +
+ + {step.step} + + + {step.title} + + + {step.body} +
+ ))} +
-
- - - Apply as a Source → - +
+ + + Apply as a Source → + + + + + Apply as an Accessor → + + + {/* Disabled with its date until operatorApplications is on. The + suite an Operator is assessed against does not exist until then, + so a live button would invite an application nobody can act on. */} + {siteFlags.operatorApplications ? ( + + + Apply as an Operator → + + + ) : ( + + Apply as an Operator, opens 1 Jan 2027 + + )} +
+ + Not sure which fits, or not on GitHub? Write to {siteConfig.generalContact} and we will sort it with you. + + + + The statuses + +
+ + + + {["Status", "Role", "Level", "How established", "Opens", "State"].map((heading) => ( + + ))} + + + + {STATUSES.map((row) => ( + + {row.map((cell, index) => ( + + ))} + + ))} + +
+ + {heading} + +
+ + {cell} + +
+
+ + Conformant is a machine check. Verified is a human review, and makes you a Partner. No fee at any stage. + + + ); +} + +function RegisterSection({ + register, + trustRegistries, +}: { + register: readonly RegisterEntry[]; + trustRegistries: readonly TrustRegistry[]; +}) { + return ( + + + Applications open by 14 November. + +
+ + + + {["Organisation", "Role", "Status", "Spec version", "Since", "State"].map((heading) => ( + + ))} + + + + {register.length === 0 ? ( + + + + ) : ( + register.map((entry) => ( + + + {[entry.role, entry.status, entry.specVersion, entry.since, entry.state].map((cell) => ( + + ))} + + )) + )} + +
+ + {heading} + +
+ + Nothing listed yet. + +
+ + {entry.organisation} + + + + {cell} + +
+
+ + Kept on GitHub · machine readable at{" "} + + /register/index.json + {" "} + · Supporters are listed separately on the Principles page + + + + Recognised trust registries + + + A current entry on one of these stands in place of a KYB-style check when an organisation applies for a Verified + status. + +
+ {trustRegistries.map((registry) => ( +
+ + {registry.name} + + + {registry.operator} · recognised {registry.recognisedOn} - + + {registry.basis} + + - Apply as an Accessor → + {registry.name} → - {/* Disabled with its date until operatorApplications is on. The - suite an Operator is assessed against does not exist until then, - so a live button would invite an application nobody can act on. */} - {siteFlags.operatorApplications ? ( - - - Apply as an Operator → - - - ) : ( - - Apply as an Operator, opens 1 Jan 2027 - - )}
- - Not sure which fits, or not on GitHub? Write to {siteConfig.generalContact} and we will sort it with you. - + ))} +
+ + + /register/trust-registries.json + + +
+ ); +} - - The statuses +function ChannelsSection() { + return ( + +
+
+ + Chat -
- - - - {["Status", "Role", "Level", "How established", "Opens", "State"].map((heading) => ( - - ))} - - - - {STATUSES.map((row) => ( - - {row.map((cell, index) => ( - - ))} - - ))} - -
- - {heading} - -
- - {cell} - -
-
- - Conformant is a machine check. Verified is a human review, and makes you a Partner. No fee at any stage. + + #pdp-connect on Discord - - - - - Applications open by 14 November. + + Where things are announced first. -
- - - - {["Organisation", "Role", "Status", "Spec version", "Since", "State"].map((heading) => ( - - ))} - - - - {register.length === 0 ? ( - - - - ) : ( - register.map((entry) => ( - - - {[entry.role, entry.status, entry.specVersion, entry.since, entry.state].map((cell) => ( - - ))} - - )) - )} - -
- - {heading} - -
- - Nothing listed yet. - -
- - {entry.organisation} - - - - {cell} - -
+
+
- - Kept on GitHub · machine readable at{" "} - - /register/index.json - {" "} - · Supporters are listed separately on the Principles page +
+
+ + Email - - - Recognised trust registries + + Mailing list - - A current entry on one of these stands in place of a KYB-style check when an organisation applies for a - Verified status. + + New versions and comment periods. Low volume. Opening alongside the register. -
- {trustRegistries.map((registry) => ( -
- - {registry.name} - - - {registry.operator} · recognised {registry.recognisedOn} - - - {registry.basis} - - - - {registry.name} → - - -
- ))} +
+
- - - /register/trust-registries.json +
+
+ + Governments and regulators + + + Take part without signing + + + Observe anywhere, sign nothing. Ask for a briefing. + + + + Contact → - - - -
-
- - Chat - - - #pdp-connect on Discord - - - Where things are announced first. - -
- -
-
-
- - Email - - - Mailing list - - - New versions and comment periods. Low volume. Opening alongside the register. - -
- -
-
-
- - Governments and regulators - - - Take part without signing - - - Observe anywhere, sign nothing. Ask for a briefing. - - - - Contact → - - -
-
- - Something wrong? - - - Report it - - - A wrong description, a name that should not be listed, anything about a status. Answered within five - working days. - - - - pdpp-dev-reports@lfdecentralizedtrust.org → - - -
-
-
- - +
+ +
+
); } diff --git a/apps/site/src/app/(concept)/principles/page.tsx b/apps/site/src/app/(concept)/principles/page.tsx index 87b00316de..2b93c45d4c 100644 --- a/apps/site/src/app/(concept)/principles/page.tsx +++ b/apps/site/src/app/(concept)/principles/page.tsx @@ -2,13 +2,15 @@ // 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 { PdppConceptSection } from "@/components/sections/concept-section.tsx"; import { PdppPrinciplesList } from "@/components/site/principles-list.tsx"; import { PdppSigningForm } from "@/components/site/signing-form.tsx"; -import { PdppSupportersTable, readPublicSupporters } from "@/components/site/supporters.tsx"; +import { PdppSupportersTable } from "@/components/site/supporters.tsx"; import { Text } from "@/components/typography/text.tsx"; import { PRINCIPLES_FRONT_MATTER, PRINCIPLES_PREAMBLE } from "@/generated/spec-front-matter.ts"; +import { readPublicSupporters } from "@/lib/public-supporters.ts"; import { repoBlobUrl } from "@/lib/site-facts.ts"; export const metadata: Metadata = { @@ -78,9 +80,9 @@ export default async function Page() { {/* The published register is one file, and this names it. A reader who wants the list without the page can take it. */} - + /principles/supporters.json - +
diff --git a/apps/site/src/app/(concept)/review/page.tsx b/apps/site/src/app/(concept)/review/page.tsx index 44ffd0315e..cf1bd68869 100644 --- a/apps/site/src/app/(concept)/review/page.tsx +++ b/apps/site/src/app/(concept)/review/page.tsx @@ -5,8 +5,8 @@ import type { Metadata } from "next"; import Link from "next/link"; import { PdppConceptDoc, PdppConceptPage } from "@/components/layout/concept-page.tsx"; import { PdppConceptSection } from "@/components/sections/concept-section.tsx"; +import { PdppEmailContactCard } from "@/components/site/report-contact-card.tsx"; import { Text } from "@/components/typography/text.tsx"; -import { REPORTS_EMAIL_HREF } from "@/lib/site-config.ts"; import { GITHUB_NEW_ISSUE_URL } from "@/lib/site-facts.ts"; import { cn } from "@/lib/utils.ts"; @@ -159,22 +159,11 @@ export default function Page() {
-
- - By email - - - Same log, no account needed - - - Email your comments and we add them to the same public log, under whatever name you ask for. - - - - pdpp-dev-reports@lfdecentralizedtrust.org → - - -
+
diff --git a/apps/site/src/components/site/report-contact-card.tsx b/apps/site/src/components/site/report-contact-card.tsx new file mode 100644 index 0000000000..d00b461f0c --- /dev/null +++ b/apps/site/src/components/site/report-contact-card.tsx @@ -0,0 +1,30 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { Text } from "@/components/typography/text.tsx"; +import { REPORTS_EMAIL_HREF } from "@/lib/site-config.ts"; +import { cn } from "@/lib/utils.ts"; + +const CARD = cn("flex flex-col gap-3 bg-background p-6", "shadow-[0_0_0_1px_var(--border)]"); + +/** The shared public-email card used when a reader needs a human response. */ +export function PdppEmailContactCard({ body, heading, title }: { body: string; heading: string; title: string }) { + return ( +
+ + {heading} + + + {title} + + + {body} + + + + pdpp-dev-reports@lfdecentralizedtrust.org → + + +
+ ); +} diff --git a/apps/site/src/components/site/supporters.tsx b/apps/site/src/components/site/supporters.tsx index e1ce8fb2c2..03a6568260 100644 --- a/apps/site/src/components/site/supporters.tsx +++ b/apps/site/src/components/site/supporters.tsx @@ -1,9 +1,8 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 -import { readFile } from "node:fs/promises"; -import path from "node:path"; import { Text } from "@/components/typography/text.tsx"; +import type { PublicSupporter } from "@/lib/public-supporters.ts"; import { cn } from "@/lib/utils.ts"; // The public register of Supporters. @@ -14,34 +13,10 @@ import { cn } from "@/lib/utils.ts"; // signatories is a page that has published a false register. The empty state // below is what a reader sees until the first real signature lands. // -// SHAPE: this file is the only consumer of supporters.json, and it reads ONLY -// the five public fields. The private store holds email, the signatory's name -// and role, and the consent flags; none of them have a route to this -// component, because the publish script never writes them into the file this -// reads. See docs/registers.md. - -export interface PublicSupporter { - country: string; - principlesVersion: string; - /** First name and last initial for individuals, organisation name otherwise. */ - publicName: string; - signedOn: string; - type: string; -} - -// Read at build time from the public JSON. A fetch at request time would put a -// network hop in front of a file that ships in the repo, and would 500 the -// page when it failed; a missing or malformed file here degrades to the empty -// state, which is the honest rendering when the register cannot be read. -export async function readPublicSupporters(): Promise { - try { - const file = path.join(process.cwd(), "public", "principles", "supporters.json"); - const parsed: unknown = JSON.parse(await readFile(file, "utf8")); - return Array.isArray(parsed) ? (parsed as PublicSupporter[]) : []; - } catch { - return []; - } -} +// 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 +// them have a route to this component, because the publish script never writes +// them into the public register. See docs/registers.md. const CELL = "px-3 py-2.5 text-left align-top"; diff --git a/apps/site/src/lib/public-supporters.ts b/apps/site/src/lib/public-supporters.ts new file mode 100644 index 0000000000..4cc4bb908c --- /dev/null +++ b/apps/site/src/lib/public-supporters.ts @@ -0,0 +1,28 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +export interface PublicSupporter { + country: string; + principlesVersion: string; + /** First name and last initial for individuals, organisation name otherwise. */ + publicName: string; + signedOn: string; + type: string; +} + +// Read at build time from the public JSON. A fetch at request time would put a +// network hop in front of a file that ships in the repo, and would 500 the +// page when it failed; a missing or malformed file here degrades to the empty +// state, which is the honest rendering when the register cannot be read. +export async function readPublicSupporters(): Promise { + try { + const file = path.join(process.cwd(), "public", "principles", "supporters.json"); + const parsed: unknown = JSON.parse(await readFile(file, "utf8")); + return Array.isArray(parsed) ? (parsed as PublicSupporter[]) : []; + } catch { + return []; + } +} diff --git a/apps/site/src/lib/signing/index.ts b/apps/site/src/lib/signing/index.ts index fea8aedad4..4932fd74d5 100644 --- a/apps/site/src/lib/signing/index.ts +++ b/apps/site/src/lib/signing/index.ts @@ -122,6 +122,10 @@ function organisationSlug(organisation: string): string { return organisation.toLowerCase().normalize("NFKD").replace(NON_ALPHANUMERIC, ""); } +function oneContainsOther(left: string, right: string): boolean { + return left.indexOf(right) !== -1 || right.indexOf(left) !== -1; +} + // An organisation submission must come from an address at the organisation's // own domain. This is intentionally a WEAK check that refuses the obvious // cases rather than a strong one that would refuse legitimate signatures: a @@ -138,11 +142,16 @@ export function organisationDomainMatches(submission: Submission): boolean { return false; } const slug = organisationSlug(submission.organisation); - const domainLabels = domain.split(".").map((label) => label.replace(HYPHENS, "")); + const domainLabels = new Set(domain.split(".").map((label) => label.replace(HYPHENS, ""))); // The organisation name matches a domain label, or a domain label is // contained in the organisation name (so "acme.io" matches "Acme // Technologies") — either direction, because both are ordinary. - return domainLabels.some((label) => label.length > 2 && (slug.includes(label) || label.includes(slug))); + for (const label of domainLabels) { + if (label.length > 2 && oneContainsOther(slug, label)) { + return true; + } + } + return false; } // ---------------------------------------------------------------- public name diff --git a/apps/site/src/lib/site-config.ts b/apps/site/src/lib/site-config.ts index 22409bbec0..ee9aaea0b0 100644 --- a/apps/site/src/lib/site-config.ts +++ b/apps/site/src/lib/site-config.ts @@ -105,7 +105,11 @@ export function signingDisclosure(): string { /** Config values still on a placeholder, for the launch checklist. */ export function unsetConfigValues(): readonly string[] { - return Object.entries(siteConfig) - .filter(([, value]) => isPlaceholder(value)) - .map(([key]) => key); + const placeholders: string[] = []; + for (const [key, value] of Object.entries(siteConfig)) { + if (isPlaceholder(value)) { + placeholders.push(key); + } + } + return placeholders; } diff --git a/apps/site/src/lib/site-facts.ts b/apps/site/src/lib/site-facts.ts index 003b69b98b..ade433c7bd 100644 --- a/apps/site/src/lib/site-facts.ts +++ b/apps/site/src/lib/site-facts.ts @@ -24,6 +24,15 @@ export function repoBlobUrl(repoRelativePath: string): string { return `${GITHUB_REPO_URL}/blob/main/${repoRelativePath}`; } +// The reference implementation moved to PDP-Connect/data-connect. Coverage +// evidence remains there, so its links must not resolve against this repo. +export const DATA_CONNECT_REPO_URL = "https://github.com/PDP-Connect/data-connect"; + +/** A `blob/main/` link into the reference implementation's canonical repository. */ +export function dataConnectBlobUrl(repoRelativePath: string): string { + return `${DATA_CONNECT_REPO_URL}/blob/main/${repoRelativePath}`; +} + // The Discord invite moved to site-config.ts, where it is one value the whole // site reads. It sat here as a second constant while /participate read its own // from config, which is how a site ends up publishing two different invites. diff --git a/apps/site/src/lib/spec-nav-slugs.ts b/apps/site/src/lib/spec-nav-slugs.ts index 2046212dd1..8d37a43da6 100644 --- a/apps/site/src/lib/spec-nav-slugs.ts +++ b/apps/site/src/lib/spec-nav-slugs.ts @@ -39,9 +39,6 @@ export const GUIDANCE_SLUGS = [ "spec-semantic-retrieval-extension", ] as const; -// Informative implementer guidance, rendered under its own rail heading. These define no conformance requirements: each states so in its own status header, and the site copies carry an informative callout (see their header sidecars in apps/site/spec-headers/). -export const GUIDANCE_SLUGS = ["spec-discovery-and-trust", "spec-collection-profile"] as const; - // Slugs sync-spec-docs.mjs generates from a root spec-*.md (that script's own // SPECS list). It writes these as .mdx — not .md — so each page's // status banner parses as JSX instead of being dropped as a raw HTML block by