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/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 690d26b219..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: "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: "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: "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: "94058cbb3160c13368ff6b0b04ff961b" }, { 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: "bf9353f0d79aaa9cee55cee905310cae" }, { 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: "a87d8e9cc02db54f4dcbb66e8ca1bcff" }, { 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: "e049e6fd9715f12fbbe06f09c7116fe5" }, { 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: "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/next.config.mjs b/apps/site/next.config.mjs index c256a87a19..9c9749e909 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/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/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..8b2b838540 --- /dev/null +++ b/apps/site/public/register/trust-registries.json @@ -0,0 +1,15 @@ +[ + { + "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/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/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)/page.tsx b/apps/site/src/app/(concept)/page.tsx index 67903b42e0..65dad267b3 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 } 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 = { 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/(concept)/participate/page.tsx b/apps/site/src/app/(concept)/participate/page.tsx index a6298ec7e7..c82f254c6a 100644 --- a/apps/site/src/app/(concept)/participate/page.tsx +++ b/apps/site/src/app/(concept)/participate/page.tsx @@ -1,200 +1,471 @@ // 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 { PdppEmailContactCard } from "@/components/site/report-contact-card.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 { isPlaceholder, 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"; + +/** + * 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; + since: string; + specVersion: string; + state: string; + status: 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. Opening shortly, once we have confirmed where the register will live.", + cta: "Read the Principles", + href: "/principles", }, { - 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, trustRegistries] = await Promise.all([ + readJson("register", "index.json"), + readJson("register", "trust-registries.json"), + ]); + return ( - - +
+ + 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. + +
- {/* 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 - - +
+ {LEVELS.map((level) => ( +
+ + {level.level} + + + {level.title} + + + {level.body} + + + + {level.cta} + + +
+ ))} +
+ + + + + + + ); +} + +function VerificationSection() { + return ( + +
+ {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 → + - - - {/* 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. + ) : ( + + Apply as an Operator, opens 1 Jan 2027 -
- {changeSteps.map((step) => ( -
-
- - {step.title} + )} +
+ + 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) => ( + + ))} + ))} -
-
- - Two maintainers approve before merge - - - Maintainers are the editors of the current draft, listed in{" "} - - MAINTAINERS.md - - . - -
-
+ +
+ + {heading} - {step.body} - - +
+ + {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} + + + + {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 + + +
+ ); +} + +function ChannelsSection() { + return ( + +
+
+ + Chat - - - {/* 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()}. + + #pdp-connect on Discord -
    -
  • - - 2026 · Jul - - - LFDT Labs proposal accepted; repositories public under the PDP-Connect organization. - -
  • -
  • - - 2026 · Apr - - - Core protocol {SPEC_STATUS.version} published as a normative draft alongside the forkable reference - implementation. - -
  • -
-
- - + + 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 → + + +
+ +
+
); } 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..2b93c45d4c --- /dev/null +++ b/apps/site/src/app/(concept)/principles/page.tsx @@ -0,0 +1,105 @@ +// 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 { PdppPrinciplesList } from "@/components/site/principles-list.tsx"; +import { PdppSigningForm } from "@/components/site/signing-form.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 = { + 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..cf1bd68869 --- /dev/null +++ b/apps/site/src/app/(concept)/review/page.tsx @@ -0,0 +1,197 @@ +// 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 { PdppEmailContactCard } from "@/components/site/report-contact-card.tsx"; +import { Text } from "@/components/typography/text.tsx"; +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 → + + +
+ +
+
+ + + + 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/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/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/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/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 e319ac0c52..e95ec2ff95 100644 --- a/apps/site/src/app/specification/[[...slug]]/page.tsx +++ b/apps/site/src/app/specification/[[...slug]]/page.tsx @@ -9,10 +9,12 @@ 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 { MAINTAINER_DOC_SLUGS, specDocExtension } from "@/lib/spec-nav-slugs.ts"; +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/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/footer.tsx b/apps/site/src/components/layout/footer.tsx index cac28cd8b2..7054395b42 100644 --- a/apps/site/src/components/layout/footer.tsx +++ b/apps/site/src/components/layout/footer.tsx @@ -4,13 +4,8 @@ import Link from "next/link"; import { Suspense } from "react"; import { isContributorSurfaceEnabled } from "@/lib/contributor-surface.ts"; -import { - DISCORD_INVITE_URL, - GITHUB_REPO_URL, - LFDT_COPYRIGHT_NOTICE, - LFPROJECTS_URL, - SITE_LICENSES, -} from "@/lib/site-facts.ts"; +import { REPORTS_EMAIL, REPORTS_EMAIL_HREF, siteConfig } from "@/lib/site-config.ts"; +import { GITHUB_REPO_URL, LFDT_COPYRIGHT_NOTICE, LFPROJECTS_URL, SITE_LICENSES } from "@/lib/site-facts.ts"; import { cn } from "@/lib/utils.ts"; import { ColorSchemeMenu } from "../elements/color-scheme-menu.tsx"; import { DiscordIcon, GithubIcon, WordmarkIcon } from "../elements/icons.tsx"; @@ -117,7 +112,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 @@ -138,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" @@ -166,6 +161,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/components/layout/masthead.tsx b/apps/site/src/components/layout/masthead.tsx index 06205b73c8..8570d97392 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..77be7e30a7 --- /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 }) { )}
+ + + + + + + + + + + + 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.