+ The association says what a platform was found to be. You decide what
+ that is worth. Your answers are signed, so they travel with you and
+ anyone can check them — including the platform, before it bothers asking.
+
+
+
+
+
+ The least you will accept
+
+
+ {#each LEVELS as level (level.id)}
+
+ {/each}
+
+ Two platforms want your data. Only one of them should get each thing.
+
+
+ Chatterbox is a social platform. Ledgerly handles money. Both are
+ running, both are certified, and both will now try to reach everything
+ in your vault — your posts, your messages, your accounts, your health
+ records. Watch what each one is actually allowed to touch, and why.
+
+
+ Nothing here is enforced by a list of platform names. Each one proves,
+ from scratch on every attempt, which release it is running and what
+ that release was certified for. A social platform has no way to say
+ the word “finance”: it is not in its certificate, and nothing it can
+ present will put it there.
+
+
+
+
+
+ {#each data.deployments as deployment, index (deployment.id)}
+
+ {/each}
+
+ The signatures on this page are real and are checked by the same code
+ that checks a live deployment. What is simulated is who holds the
+ keys: the wallet, the registry and the association are stood in for by
+ keys generated in this process, so the demonstration runs on its own.
+ A chain that verifies here proves the mechanism works — not that any
+ particular platform is trustworthy.
+
+
+
diff --git a/services/pp-auth-demo/src/routes/api/access/+server.ts b/services/pp-auth-demo/src/routes/api/access/+server.ts
new file mode 100644
index 000000000..850e5575c
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/access/+server.ts
@@ -0,0 +1,30 @@
+import { json } from "@sveltejs/kit";
+import { attemptAccess } from "$lib/server/access";
+import { world } from "$lib/server/world";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ request }) => {
+ const body = (await request.json()) as {
+ deploymentId?: string;
+ domain?: string;
+ kind?: string;
+ text?: string;
+ };
+ const current = await world();
+ const text = (body.text ?? "").trim();
+
+ try {
+ const outcome = await attemptAccess(
+ current,
+ String(body.deploymentId),
+ String(body.domain),
+ text ? { kind: body.kind || "Note", body: text } : undefined,
+ );
+ return json(outcome);
+ } catch (error) {
+ return json(
+ { error: error instanceof Error ? error.message : "failed" },
+ { status: 400 },
+ );
+ }
+};
diff --git a/services/pp-auth-demo/src/routes/api/handshake/+server.ts b/services/pp-auth-demo/src/routes/api/handshake/+server.ts
new file mode 100644
index 000000000..a91e0ba1a
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/handshake/+server.ts
@@ -0,0 +1,28 @@
+import { json } from "@sveltejs/kit";
+import {
+ answerChallenge,
+ verifyHandshake,
+} from "@metastate-foundation/auth/platform";
+import { OWNER_ENAME, world } from "$lib/server/world";
+import type { RequestHandler } from "./$types";
+
+/** Runs the handshake alone, so the chain can be inspected without touching data. */
+export const POST: RequestHandler = async ({ request }) => {
+ const { deploymentId } = (await request.json()) as { deploymentId?: string };
+ const current = await world();
+ const deployment = current.deployments.get(String(deploymentId));
+ if (!deployment) return json({ error: "Unknown deployment" }, { status: 404 });
+
+ const challenge = current.challenges.issue(OWNER_ENAME);
+ const response = await answerChallenge(deployment.identity, challenge);
+ const chain = await verifyHandshake(response, {
+ audience: OWNER_ENAME,
+ registryBaseUrl: "demo://registry",
+ registryJwksUri: "demo://registry/.well-known/jwks.json",
+ verifyWalletSignature: current.roots.verifyWalletSignature,
+ resolveJwks: current.resolveJwks,
+ store: current.challenges,
+ });
+
+ return json({ chain, challenge });
+};
diff --git a/services/pp-auth-demo/src/routes/api/policy/+server.ts b/services/pp-auth-demo/src/routes/api/policy/+server.ts
new file mode 100644
index 000000000..4104061d0
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/policy/+server.ts
@@ -0,0 +1,58 @@
+import { json } from "@sveltejs/kit";
+import {
+ CERTIFICATION_LEVELS,
+ defaultAccessPolicy,
+ verifyAccessPolicy,
+ type CertificationLevel,
+} from "@metastate-foundation/auth/platform";
+import { OWNER_ENAME, signPolicy, verifyOwnerSignature, world } from "$lib/server/world";
+import type { RequestHandler } from "./$types";
+
+/**
+ * Records the owner's terms as a signed statement.
+ *
+ * The signature is made here with the owner's stand-in key; in a running
+ * system this is where the eID wallet signs. It is verified immediately after
+ * signing, so a statement that could not be checked never becomes the policy.
+ */
+export const POST: RequestHandler = async ({ request }) => {
+ const body = (await request.json()) as Record;
+ const current = await world();
+
+ const level = String(body.minimumLevel ?? "") as CertificationLevel;
+ if (!CERTIFICATION_LEVELS.includes(level)) {
+ return json({ error: "Unknown level" }, { status: 400 });
+ }
+ const strings = (value: unknown): string[] =>
+ Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
+ const minimumReputation =
+ body.minimumReputation === null || body.minimumReputation === ""
+ ? null
+ : Number(body.minimumReputation);
+ if (minimumReputation !== null && !Number.isFinite(minimumReputation)) {
+ return json({ error: "Reputation threshold must be a number" }, { status: 400 });
+ }
+
+ const statement = {
+ ...defaultAccessPolicy(OWNER_ENAME),
+ minimumLevel: level,
+ reputationEngine:
+ typeof body.reputationEngine === "string" ? body.reputationEngine.trim() : "",
+ minimumReputation,
+ allowedDomains: body.allowedDomains === null ? null : strings(body.allowedDomains),
+ deniedDomains: strings(body.deniedDomains),
+ issuedAt: new Date().toISOString(),
+ nonce: crypto.randomUUID(),
+ };
+
+ const signed = await signPolicy(statement, current.ownerKey);
+ const valid = await verifyAccessPolicy(signed, (_signer, signature, payload) =>
+ verifyOwnerSignature(current, signature, payload),
+ );
+ if (!valid) {
+ return json({ error: "The signed terms did not verify" }, { status: 500 });
+ }
+
+ current.policy = signed;
+ return json({ policy: signed.statement, payload: signed.payload, signature: signed.signature });
+};
diff --git a/services/pp-auth-demo/src/routes/api/reset/+server.ts b/services/pp-auth-demo/src/routes/api/reset/+server.ts
new file mode 100644
index 000000000..a91debee3
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/reset/+server.ts
@@ -0,0 +1,8 @@
+import { json } from "@sveltejs/kit";
+import { resetWorld } from "$lib/server/world";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async () => {
+ await resetWorld();
+ return json({ ok: true });
+};
diff --git a/services/pp-auth-demo/src/routes/api/tamper/+server.ts b/services/pp-auth-demo/src/routes/api/tamper/+server.ts
new file mode 100644
index 000000000..d437b9a44
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/tamper/+server.ts
@@ -0,0 +1,90 @@
+import { json } from "@sveltejs/kit";
+import { resetWorld, world } from "$lib/server/world";
+import type { RequestHandler } from "./$types";
+
+/**
+ * Breaks one link on purpose.
+ *
+ * A chain that only ever passes demonstrates nothing. Each of these edits is
+ * something an attacker would plausibly try — presenting a key you do not
+ * hold, widening your own authorisation, borrowing a better-certified
+ * release's paperwork — and each should be caught by exactly one link.
+ */
+const EDITS: Record<
+ string,
+ { label: string; expect: string; apply: (deployment: any, other: any, value: string) => void }
+> = {
+ publicKey: {
+ label: "Present a different public key",
+ expect: "possession",
+ apply: (deployment, _other, value) => {
+ deployment.identity.evidence.publicKey = value;
+ deployment.identity.evidence.deploymentKeyDocument.data.publicKey = value;
+ },
+ },
+ environment: {
+ label: "Promote itself from staging to production",
+ expect: "deployment-authorised",
+ apply: (deployment, _other, value) => {
+ deployment.identity.evidence.deploymentKeyDocument.data.environment =
+ value || "production-plus";
+ },
+ },
+ versionDocument: {
+ label: "Borrow the other platform's version document",
+ expect: "bundle-integrity",
+ apply: (deployment, other) => {
+ deployment.identity.evidence.softwareVersionDocument =
+ structuredClone(other.identity.evidence.softwareVersionDocument);
+ },
+ },
+ versionEname: {
+ label: "Point at a different release",
+ expect: "version-identity",
+ apply: (deployment, _other, value) => {
+ deployment.identity.evidence.versionEname =
+ value || "@99999999-9999-4999-8999-999999999999";
+ },
+ },
+ certificate: {
+ label: "Borrow the other platform's certificate",
+ expect: "accreditation",
+ apply: (deployment, other) => {
+ deployment.identity.evidence.accreditationJws =
+ other.identity.evidence.accreditationJws;
+ },
+ },
+};
+
+export const POST: RequestHandler = async ({ request }) => {
+ const { deploymentId, edit, value } = (await request.json()) as {
+ deploymentId?: string;
+ edit?: string;
+ value?: string;
+ };
+ const current = await world();
+ const deployment = current.deployments.get(String(deploymentId));
+ if (!deployment) return json({ error: "Unknown deployment" }, { status: 404 });
+
+ if (edit === "restore") {
+ deployment.identity = structuredClone(deployment.pristine);
+ deployment.tampered = null;
+ return json({ tampered: null });
+ }
+
+ const change = EDITS[String(edit)];
+ if (!change) return json({ error: "Unknown edit" }, { status: 400 });
+
+ const other = [...current.deployments.values()].find(
+ (entry) => entry.id !== deployment.id,
+ );
+ change.apply(deployment, other, String(value ?? ""));
+ deployment.tampered = change.label;
+
+ return json({ tampered: change.label, expect: change.expect });
+};
+
+export const DELETE: RequestHandler = async () => {
+ await resetWorld();
+ return json({ ok: true });
+};
diff --git a/services/pp-auth-demo/svelte.config.js b/services/pp-auth-demo/svelte.config.js
new file mode 100644
index 000000000..4ca2087b8
--- /dev/null
+++ b/services/pp-auth-demo/svelte.config.js
@@ -0,0 +1,14 @@
+import adapter from "@sveltejs/adapter-node";
+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
+
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ adapter: adapter(),
+ env: {
+ dir: "../../",
+ },
+ },
+};
+
+export default config;
diff --git a/services/pp-auth-demo/tsconfig.json b/services/pp-auth-demo/tsconfig.json
new file mode 100644
index 000000000..104691d2d
--- /dev/null
+++ b/services/pp-auth-demo/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+}
diff --git a/services/pp-auth-demo/vite.config.ts b/services/pp-auth-demo/vite.config.ts
new file mode 100644
index 000000000..deb417265
--- /dev/null
+++ b/services/pp-auth-demo/vite.config.ts
@@ -0,0 +1,7 @@
+import tailwindcss from "@tailwindcss/vite";
+import { sveltekit } from "@sveltejs/kit/vite";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()],
+});
From 8f84cfc4ceeb2deaabc8c788f610016d0e595de3 Mon Sep 17 00:00:00 2001
From: coodos
Date: Sun, 30 Aug 2026 17:44:46 +0800
Subject: [PATCH 04/18] docs: document platform authentication and owner terms
Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ
---
.../pp-auth-demonstrator.md | 46 +++++++
docs/docs/Post Platform Guide/pp-auth.md | 116 ++++++++++++++++++
docs/docs/W3DS Basics/Access-Policy.md | 52 ++++++++
docs/docs/W3DS Basics/Links.md | 2 +-
.../W3DS Protocol/Platform-Authentication.md | 85 +++++++++++++
5 files changed, 300 insertions(+), 1 deletion(-)
create mode 100644 docs/docs/Post Platform Guide/pp-auth-demonstrator.md
create mode 100644 docs/docs/Post Platform Guide/pp-auth.md
create mode 100644 docs/docs/W3DS Basics/Access-Policy.md
create mode 100644 docs/docs/W3DS Protocol/Platform-Authentication.md
diff --git a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md
new file mode 100644
index 000000000..f3d007484
--- /dev/null
+++ b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md
@@ -0,0 +1,46 @@
+---
+sidebar_position: 9
+---
+
+# PP Auth demonstrator
+
+A running demonstration of platform authentication and domain separation. Two platforms, one vault, and every attempt to reach data shown with the reason it succeeded or failed.
+
+```bash
+pnpm --filter pp-auth-demo dev
+```
+
+Then open **http://localhost:4310**. Nothing else needs to be running — no database, no registry, no eVault.
+
+## What it shows
+
+**Chatterbox** is a social platform, certified L3 for `social` and `communication`. **Ledgerly** handles money, certified L4 for `finance`. Both are live, both will try to reach everything in the vault.
+
+Point either one at a domain it was not certified for and it is refused — with a sentence saying so, not a status code. The refusal does not come from a list of platform names: it comes from the certificate the deployment presented, which does not name that domain and cannot be made to.
+
+**Your terms** sets the owner's side: the minimum level, whose reputation scores count and what score they must reach, and any domain refused outright. Signing produces a real signature over a real statement, which is verified before it takes effect. Raise the bar to L4 and Chatterbox stops being allowed anything; require a reputation of 50 and Ledgerly does, on the scores the demo's engine reports.
+
+**Try to cheat** is where the mechanism is visible. Each edit breaks exactly one link:
+
+| Edit | Fails at |
+|---|---|
+| Present a different public key — paste your own | Possession |
+| Widen its own authorisation | Deployment authorised |
+| Borrow the other platform's version document | Bundle integrity |
+| Point at a different release | Version identity |
+| Borrow the other platform's certificate | Accreditation |
+
+The chain trace re-runs on every attempt, so you can watch a link go red and read why.
+
+## What is real and what is not
+
+The signatures are real — P-256 and ES256, verified by exactly the same code that verifies a live deployment. The tampering really does fail, for the reason shown.
+
+What is simulated is who holds the keys. The deployer's wallet, the registry and the association are stood in for by keys generated in the demo process, so it runs on its own. A chain that verifies here proves the mechanism works. It proves nothing about any particular platform, which is what the real roots are for.
+
+The minting facility lives at `@metastate-foundation/auth/platform/scenario`, deliberately behind a separate entry point so it cannot be reached by accident from code that verifies real deployments.
+
+## See also
+
+- [Platform Authentication](/docs/W3DS%20Protocol/Platform-Authentication)
+- [Access Policy](/docs/W3DS%20Basics/Access-Policy)
diff --git a/docs/docs/Post Platform Guide/pp-auth.md b/docs/docs/Post Platform Guide/pp-auth.md
new file mode 100644
index 000000000..7d6ba68a1
--- /dev/null
+++ b/docs/docs/Post Platform Guide/pp-auth.md
@@ -0,0 +1,116 @@
+---
+sidebar_position: 10
+---
+
+# Authenticating your platform
+
+Your deployment proves which release it is running, and the eVault decides what that release may touch. This page is the integration.
+
+For the mechanism itself see [Platform Authentication](/docs/W3DS%20Protocol/Platform-Authentication).
+
+## Install
+
+```bash
+pnpm add @metastate-foundation/auth
+```
+
+Both halves ship in one package. Deployments import the signer, verifiers import the verifier; nothing stops you doing both, which is what the demonstrator does.
+
+## What your deployment needs
+
+GitW3 produces all of it when you deploy a release. None of it is secret except the private key, which never leaves your process.
+
+```ts
+import type { DeploymentIdentity } from "@metastate-foundation/auth/platform";
+
+const identity: DeploymentIdentity = {
+ privateKey: process.env.DEPLOYMENT_PRIVATE_KEY!, // PKCS#8, base64
+ evidence: {
+ deploymentEname, deploymentName, environment,
+ deployerEname, platformEname, versionEname,
+ version, releaseTag, commitSha, publicKey,
+ deploymentKeyDocument, // binding document, bundle-signed
+ softwareVersionDocument, // binding document, same signature
+ accreditationJws, // the association's certificate
+ issuerJwksUri,
+ submissionProof, // the release proof the association reviewed
+ },
+};
+```
+
+Store the private key the way you store any other deployment secret. If it leaks, the holder can authenticate as your deployment until the deployer revokes the key — it is the whole of the possession proof.
+
+## Authenticating
+
+```ts
+import { authenticate } from "@metastate-foundation/auth/platform";
+
+const result = await authenticate(identity, "https://vault.example");
+```
+
+That fetches a challenge, signs it, and posts the answer. If you want the two steps yourself — to add retries, or to talk to something other than HTTP — use `answerChallenge(identity, challenge)` and send the response however you like.
+
+## Verifying, if you are the eVault
+
+```ts
+import {
+ createChallengeStore,
+ verifyHandshake,
+ authorize,
+} from "@metastate-foundation/auth/platform";
+
+const challenges = createChallengeStore(); // module scope, not per request
+
+// POST /pp-auth/challenge
+const challenge = challenges.issue(ownerEname);
+
+// POST /pp-auth/verify
+const chain = await verifyHandshake(response, {
+ audience: ownerEname,
+ registryBaseUrl: process.env.PUBLIC_REGISTRY_URL!,
+ store: challenges,
+});
+
+if (!chain.ok) {
+ // chain.links carries all six with a plain-English detail on each.
+ return refuse(chain.links.find((link) => !link.ok));
+}
+```
+
+`chain.claim` is what you learned: platform, deployment, version, level, and the domains it may use.
+
+Then the owner's terms, for each record touched:
+
+```ts
+const decision = authorize(policy, {
+ claim: chain.claim,
+ domain: schema.domain, // the domain the record's ontology declares
+ reputation: score ? { engine, score } : null,
+});
+
+if (!decision.allowed) return refuse(decision.reason);
+```
+
+`decision.reason` is written to be shown to a person. `decision.code` is for your logs.
+
+Hold the challenge store at module scope. Issuing from one instance and redeeming in another rejects every legitimate handshake, and under Vite's dev server a module evaluated twice will do exactly that.
+
+## Injection points
+
+Three things are injectable, all defaulting to the ordinary behaviour:
+
+- `verifyWalletSignature` — how a wallet signature is checked. Defaults to `signature-validator` against your registry.
+- `resolveJwks` — how a JWKS URI becomes keys. Defaults to a cached remote fetch. Supply your own to pin a key set or to run offline.
+- `now` — the clock, for testing time-dependent behaviour.
+
+## Testing your integration
+
+`@metastate-foundation/auth/platform/scenario` mints a complete, self-consistent chain from keys it generates, so you can exercise your verifier without a wallet, a registry or a live association:
+
+```ts
+import { createTrustRoots, mintDeployment } from "@metastate-foundation/auth/platform/scenario";
+```
+
+Everything it produces is genuinely signed and genuinely verified. What differs is the root: the keys standing in for the deployer, the registry and the association are local. **Never configure a production verifier with roots from this module** — a chain that verifies against them proves your code works, not that a platform is trustworthy.
+
+The [demonstrator](/docs/Post%20Platform%20Guide/pp-auth-demonstrator) is built on it and is the fastest way to see the whole thing move.
diff --git a/docs/docs/W3DS Basics/Access-Policy.md b/docs/docs/W3DS Basics/Access-Policy.md
new file mode 100644
index 000000000..62cddef5d
--- /dev/null
+++ b/docs/docs/W3DS Basics/Access-Policy.md
@@ -0,0 +1,52 @@
+---
+sidebar_position: 6
+---
+
+# Access Policy
+
+Certification tells you what a platform was found to be. It does not tell you whether you want to deal with it. That is the eVault owner's decision, and an **access policy** is where they write it down.
+
+It is a signed statement rather than a stored setting, so it travels with the owner and anyone can check it — the eVault enforcing it, a platform working out whether it is even worth asking, or the owner auditing what they agreed to months later.
+
+## What an owner sets
+
+| Term | Meaning |
+|---|---|
+| `minimumLevel` | The weakest certification level they will deal with. A platform certified below it is refused whatever its certificate grants. |
+| `reputationEngine` | Whose reputation scores they accept, as an eName or URL. Blank means reputation is not consulted at all. |
+| `minimumReputation` | The score that engine must report for the platform. Null means no threshold. |
+| `allowedDomains` | Null means "whatever the certificate grants" — the ordinary case. A list narrows it further. |
+| `deniedDomains` | Refused outright, overriding both the certificate and the allow list. |
+
+Naming the engine matters. A score is only meaningful relative to how it was calculated, so the owner elects which calculation they accept rather than inheriting whichever engine a platform happens to cite. A score from an engine the owner did not name counts as no score at all.
+
+## A policy can only narrow
+
+An owner permitting `finance` does not let a social platform reach finance data. The certificate gate runs first and independently: if `finance` is not in what the association granted the release, nothing in the owner's policy can put it there.
+
+This ordering is the point. The owner's terms are a second lock, not a master key.
+
+## The statement
+
+```json
+{
+ "subject": "@849c0221-6f3f-55f9-95f0-f3b0d2b3092f",
+ "minimumLevel": "L3",
+ "reputationEngine": "@ereputation.w3ds",
+ "minimumReputation": 40,
+ "allowedDomains": null,
+ "deniedDomains": ["health"],
+ "issuedAt": "2026-08-30T16:04:11.230Z",
+ "nonce": "0f1c…"
+}
+```
+
+Signed by the owner's wallet over `w3ds:access-policy:v1:` + base64url(sha256(canonical statement)). The signer must be the subject: a policy signed by anyone else is somebody setting terms on a vault that is not theirs, and is rejected.
+
+The newest statement for a subject is the one in force. An owner who has never set one is treated as requiring **L2** — the lowest level the framework issues to a release whose responsible people are identified at all.
+
+Published as the `Access Policy` ontology (`c7a41f6d-95b8-4e2a-9c33-8f0d1b6e4a72`), domain `governance`.
+
+## See also
+
+- [Platform Authentication](/docs/W3DS%20Protocol/Platform-Authentication) — how a platform proves which release it is running
diff --git a/docs/docs/W3DS Basics/Links.md b/docs/docs/W3DS Basics/Links.md
index c1f69d0f8..2d389ec09 100644
--- a/docs/docs/W3DS Basics/Links.md
+++ b/docs/docs/W3DS Basics/Links.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 6
+sidebar_position: 7
---
# Links
diff --git a/docs/docs/W3DS Protocol/Platform-Authentication.md b/docs/docs/W3DS Protocol/Platform-Authentication.md
new file mode 100644
index 000000000..856a7324a
--- /dev/null
+++ b/docs/docs/W3DS Protocol/Platform-Authentication.md
@@ -0,0 +1,85 @@
+---
+sidebar_position: 6
+---
+
+# Platform Authentication (PP Auth)
+
+An eVault has never been able to tell one platform from another. `POST /platforms/certification` mints a year-long token for any name a caller types in, and any registry-signed token bypasses access control outright. So "which platform is this?" has, until now, been answered by whoever asked.
+
+PP Auth replaces that with a chain of trust the caller has to actually hold the keys for. A deployment proves, from scratch on every handshake, which release it is running and what the Post Platforms Association certified that release to do.
+
+## What a deployment proves
+
+Six links, each failing closed. A verifier checks all six and reports all six — an operator debugging a rejected handshake needs the whole trace, not the first problem.
+
+| Link | What it establishes |
+|---|---|
+| **Possession** | The caller signed a fresh challenge with the deployment key. Without this the rest is public paperwork anyone could replay. |
+| **Deployment authorised** | A named person's wallet signed that key for this platform and environment. Authority traces to a human, not a config file. |
+| **Bundle integrity** | Both binding documents hash to the values that signature covered, so neither can be swapped independently of the other. |
+| **Version identity** | The version eName is derivable from the platform eName and version by UUIDv5. Arithmetic, not a lookup — nothing to spoof and no network call. |
+| **Release authorship** | The release's submission proof re-verifies against its registry key-binding certificate. The same proof the association reviewed, checked again rather than taken on trust. |
+| **Accreditation** | The association's ES256 certificate verifies against its JWKS, names this platform as `sub` and this exact version, and grants a level and a set of domains. |
+
+If every link holds, the verifier returns a **claim**: the platform, the deployment, the version, the certification level, and the domains — intersected with what the release actually asked for, so a certificate naming more than the submission requested cannot widen it.
+
+## The handshake
+
+```
+deployment verifier
+ | POST /pp-auth/challenge |
+ |------------------------------------------>|
+ | { nonce, audience, issuedAt, expiresAt } |
+ |<------------------------------------------|
+ | sign the canonical challenge payload |
+ | POST /pp-auth/verify |
+ | { challenge, evidence, signature } |
+ |------------------------------------------>|
+ | verify six links |
+ | { ok, links[], claim } |
+ |<------------------------------------------|
+```
+
+A challenge is single-use and short-lived. It is spent the moment it is answered — whether or not the chain holds — so a captured response cannot be replayed even inside its window.
+
+The deployment **presents** its evidence rather than being looked up. That matters: a verifier needs only public endpoints to check it, and never needs read access to the platform's eVault, which is the access the deployment is trying to obtain in the first place.
+
+## Canonical payloads
+
+Three codebases produce these signatures — the eID wallet, GitW3 in Go, and the registry — so the byte-for-byte forms are fixed.
+
+| Signed thing | Payload |
+|---|---|
+| Handshake challenge | `w3ds:pp-auth:v1:` + base64url(sha256(canonical challenge)) |
+| Deployment attestation bundle | `gitw3:deployment:v1:` + base64url(sha256(`signedPayload`)) |
+| Release submission | `gitw3:ppa:v1:` + base64url(sha256(`JSON.stringify(statement)`)) |
+| Owner access policy | `w3ds:access-policy:v1:` + base64url(sha256(canonical statement)) |
+
+"Canonical" means keys sorted at every depth, matching `getCanonicalBindingDocumentString` in evault-core and the Go implementation in GitW3. The bundle is the exception: its digest is over the `signedPayload` string exactly as stored, not over a re-serialisation of it.
+
+Signatures are accepted as base64url, base58 multibase (`z…`), raw `r‖s`, or DER-wrapped. Public keys are accepted as multibase, `0x`-hex or bare base64. Being strict about the bytes and liberal about how they were written is deliberate: a verifier that insists on one encoding rejects legitimate evidence.
+
+## What certification is not
+
+The association's certificate is a **trust statement, not a permission**. It says what a release was found to be. The eVault stays sovereign and decides for itself what that is worth — see [Access Policy](/docs/W3DS%20Basics/Access-Policy).
+
+Two independent gates, both of which must open:
+
+1. **The certificate.** Is this domain in what the association granted, and in what the release asked for? A social platform certified for `social` and `communication` has no path to `finance` data. Not because the eVault recognises it as a social platform, but because `finance` is not in its certificate and nothing it can present puts it there.
+2. **The owner's policy.** Is the level high enough, is the reputation acceptable, is this domain one the owner permits at all?
+
+An owner's policy can only narrow a certificate, never widen it.
+
+## Backwards compatibility
+
+Existing registry-minted platform tokens keep working. The registry stops minting new ones; deployments issued through GitW3 come with the evidence PP Auth needs. The two coexist while platforms migrate.
+
+## Where the code is
+
+`@metastate-foundation/auth/platform` — both halves in one package.
+
+- `verifyDeploymentChain`, `verifyHandshake`, `createChallengeStore` — the verifier
+- `answerChallenge`, `authenticate` — the deployment side
+- `authorize`, `permittedDomains` — the two gates
+- `accessPolicyPayload`, `verifyAccessPolicy` — the owner's terms
+- `@metastate-foundation/auth/platform/scenario` — mints a self-consistent chain from local keys, for tests and demonstrations. Never configure a production verifier with roots from it.
From 96c8175ffb499d8be6b75b1316ce2ce00595886f Mon Sep 17 00:00:00 2001
From: coodos
Date: Sun, 30 Aug 2026 17:58:15 +0800
Subject: [PATCH 05/18] fix: canonicalise release statements in the order the
wallet signed
Verified against live congo-basin proofs: the digest is over GitW3's struct
field order, not the order a statement arrives in. Every genuine release was
being rejected.
Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ
---
packages/auth/src/platform/bytes.spec.ts | 76 ++++++++++++++++++++++++
packages/auth/src/platform/bytes.ts | 41 +++++++++++++
packages/auth/src/platform/chain.ts | 8 ++-
packages/auth/src/platform/index.ts | 1 +
packages/auth/src/platform/scenario.ts | 12 +++-
5 files changed, 135 insertions(+), 3 deletions(-)
create mode 100644 packages/auth/src/platform/bytes.spec.ts
diff --git a/packages/auth/src/platform/bytes.spec.ts b/packages/auth/src/platform/bytes.spec.ts
new file mode 100644
index 000000000..8d41c51c7
--- /dev/null
+++ b/packages/auth/src/platform/bytes.spec.ts
@@ -0,0 +1,76 @@
+import { createHash } from "node:crypto";
+import { describe, expect, it } from "vitest";
+import { canonicalSubmissionStatement, stableStringify } from "./bytes.js";
+
+/**
+ * A real release proof, read from congo-basin's live platform profile on
+ * 2026-08-30. The payload is what the author's wallet actually signed, so this
+ * is a known-good vector rather than a value this codebase produced: if the
+ * canonical form drifts, this test fails and every genuine release would
+ * otherwise have been silently rejected.
+ */
+const REAL_STATEMENT = {
+ type: "w3ds.ppa.release-submission",
+ nonce: "7GaaGUA1pBOGkPj57hmdzQ",
+ domains: ["social", "finance", "media"],
+ version: "1.1.0",
+ issuedAt: "2026-08-29T17:58:43Z",
+ releaseTag: "v1.1.0",
+ repository: "849c0221-6f3f-55f9-95f0-f3b0d2b3092f/congo-basin",
+ signerEName: "@849c0221-6f3f-55f9-95f0-f3b0d2b3092f",
+ platformName: "congo-basin",
+ repositoryId: 2,
+ platformEName: "@00c41b0b-4a35-574f-b502-d90377f00f44",
+ schemaVersion: 1,
+ manifestCommitId: "39aa01cbf5ee511eb3ea74f005a2246deb522688",
+};
+const REAL_PAYLOAD =
+ "gitw3:ppa:v1:vst5thDbtMeYQ5fWGM4recrBMvFWEYVRPqQ2J4C4qiM";
+
+function payloadFor(statement: Record): string {
+ return (
+ "gitw3:ppa:v1:" +
+ createHash("sha256")
+ .update(canonicalSubmissionStatement(statement))
+ .digest("base64url")
+ );
+}
+
+describe("canonicalSubmissionStatement", () => {
+ it("reproduces the payload a real wallet signed", () => {
+ expect(payloadFor(REAL_STATEMENT)).toBe(REAL_PAYLOAD);
+ });
+
+ it("is unaffected by the key order a statement arrives in", () => {
+ // A statement that has been through an eVault and the awareness fanout
+ // comes back with its keys reordered. That must not change the digest.
+ const shuffled = Object.fromEntries(
+ Object.entries(REAL_STATEMENT).sort(([a], [b]) => a.localeCompare(b)),
+ );
+
+ expect(payloadFor(shuffled)).toBe(REAL_PAYLOAD);
+ });
+
+ it("does not accept the wire order or sorted order as canonical", () => {
+ // Both of these were tried against live proofs and neither matches, so
+ // they are pinned as wrong rather than left as plausible alternatives.
+ const wire = "gitw3:ppa:v1:" + createHash("sha256")
+ .update(JSON.stringify(REAL_STATEMENT))
+ .digest("base64url");
+ const sorted = "gitw3:ppa:v1:" + createHash("sha256")
+ .update(stableStringify(REAL_STATEMENT))
+ .digest("base64url");
+
+ expect(wire).not.toBe(REAL_PAYLOAD);
+ expect(sorted).not.toBe(REAL_PAYLOAD);
+ });
+
+ it("changes when any signed field changes", () => {
+ expect(payloadFor({ ...REAL_STATEMENT, version: "1.1.1" })).not.toBe(
+ REAL_PAYLOAD,
+ );
+ expect(
+ payloadFor({ ...REAL_STATEMENT, domains: ["social", "finance"] }),
+ ).not.toBe(REAL_PAYLOAD);
+ });
+});
diff --git a/packages/auth/src/platform/bytes.ts b/packages/auth/src/platform/bytes.ts
index ca60b87b8..ffc08d1c4 100644
--- a/packages/auth/src/platform/bytes.ts
+++ b/packages/auth/src/platform/bytes.ts
@@ -188,3 +188,44 @@ export function signatureCandidates(value: string): Uint8Array[] {
export function toArrayBuffer(value: Uint8Array): ArrayBuffer {
return Uint8Array.from(value).buffer;
}
+
+/**
+ * Rebuilds a release submission statement in the field order GitW3's Go struct
+ * serialises, which is what the author's wallet actually signed.
+ *
+ * This is not cosmetic and not sortable. The digest is taken over
+ * `JSON.stringify` of the statement, so the order of the keys *is* the
+ * signature. A statement that has been through a JSON parse, an eVault, and the
+ * awareness fanout comes back with its keys in whatever order those hops chose,
+ * and hashing that order produces a digest matching nothing. Verified against
+ * live congo-basin proofs: struct order matches, wire order and sorted order
+ * both fail.
+ */
+export function canonicalSubmissionStatement(
+ raw: Record,
+): string {
+ const statement: Record = {
+ type: raw.type,
+ schemaVersion: raw.schemaVersion,
+ repositoryId: raw.repositoryId,
+ repository: raw.repository,
+ platformEName: raw.platformEName,
+ platformName: raw.platformName,
+ releaseTag: raw.releaseTag,
+ version: raw.version,
+ manifestCommitId: raw.manifestCommitId,
+ domains: raw.domains,
+ signerEName: raw.signerEName,
+ issuedAt: raw.issuedAt,
+ nonce: raw.nonce,
+ };
+ // Optional trailing fields, present only on a resubmission after refusal.
+ if (raw.previousDecision) {
+ statement.previousDecision = raw.previousDecision;
+ statement.previousDecisionAt = raw.previousDecisionAt;
+ }
+ if (raw.responseToDecision) {
+ statement.responseToDecision = raw.responseToDecision;
+ }
+ return JSON.stringify(statement);
+}
diff --git a/packages/auth/src/platform/chain.ts b/packages/auth/src/platform/chain.ts
index 4bdd0ad92..f80ba8049 100644
--- a/packages/auth/src/platform/chain.ts
+++ b/packages/auth/src/platform/chain.ts
@@ -22,6 +22,7 @@ import { createHash } from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";
import {
bindingDocumentHash,
+ canonicalSubmissionStatement,
sha256Base64Url,
stableStringify,
} from "./bytes.js";
@@ -340,7 +341,12 @@ export async function verifyDeploymentChain(
let authorshipDetail = "The release carried no submission proof.";
if (proof?.statement) {
const canonical =
- SUBMISSION_PREFIX + sha256Base64Url(JSON.stringify(proof.statement));
+ SUBMISSION_PREFIX +
+ sha256Base64Url(
+ canonicalSubmissionStatement(
+ proof.statement as unknown as Record,
+ ),
+ );
if (proof.payload !== canonical) {
authorshipDetail = "The signed payload does not match the statement.";
} else if (proof.statement.platformEName !== evidence.platformEname) {
diff --git a/packages/auth/src/platform/index.ts b/packages/auth/src/platform/index.ts
index 99ac13b1f..a243f27cf 100644
--- a/packages/auth/src/platform/index.ts
+++ b/packages/auth/src/platform/index.ts
@@ -1,5 +1,6 @@
export {
bindingDocumentHash,
+ canonicalSubmissionStatement,
decodeBase58,
decodePublicKey,
derSignatureToRaw,
diff --git a/packages/auth/src/platform/scenario.ts b/packages/auth/src/platform/scenario.ts
index e82327f8a..c163b7be8 100644
--- a/packages/auth/src/platform/scenario.ts
+++ b/packages/auth/src/platform/scenario.ts
@@ -17,7 +17,11 @@
import { randomUUID } from "node:crypto";
import { SignJWT, exportJWK, generateKeyPair as generateJwkPair } from "jose";
import type { JWK, KeyLike } from "jose";
-import { bindingDocumentHash, sha256Base64Url } from "./bytes.js";
+import {
+ bindingDocumentHash,
+ canonicalSubmissionStatement,
+ sha256Base64Url,
+} from "./bytes.js";
import { softwareVersionEName } from "./chain.js";
import { generateKeyPair, signP256, verifyP256 } from "./p256.js";
import type {
@@ -190,7 +194,11 @@ export async function mintDeployment(
issuedAt: timestamp,
nonce: randomUUID(),
};
- const payload = SUBMISSION_PREFIX + sha256Base64Url(JSON.stringify(statement));
+ const payload =
+ SUBMISSION_PREFIX +
+ sha256Base64Url(
+ canonicalSubmissionStatement(statement as unknown as Record),
+ );
const keyBindingCertificate = await new SignJWT({
ename: roots.wallet.ename,
publicKey: roots.wallet.publicKey,
From e1a892e5b0c1f309a624492d5151ab7f2fdabca0 Mon Sep 17 00:00:00 2001
From: coodos
Date: Sun, 30 Aug 2026 18:08:28 +0800
Subject: [PATCH 06/18] fix: show what the evidence supported when the identity
floor caps it
A geometric mean of 3.13 beside an award of L2 reads as an arithmetic
mistake. It is the IAL4 requirement for L3 and above, so say that.
Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ
---
services/ppa/src/lib/AssessmentResult.svelte | 15 +++++++++++++--
services/ppa/src/lib/levels.spec.ts | 16 ++++++++++++++++
services/ppa/src/lib/levels.ts | 17 ++++++++++++++++-
3 files changed, 45 insertions(+), 3 deletions(-)
diff --git a/services/ppa/src/lib/AssessmentResult.svelte b/services/ppa/src/lib/AssessmentResult.svelte
index cad5848f4..6acf99e6c 100644
--- a/services/ppa/src/lib/AssessmentResult.svelte
+++ b/services/ppa/src/lib/AssessmentResult.svelte
@@ -53,6 +53,15 @@
→
+ {#if identityCapped && result.scoredLevel}
+
+
Evidence supports
+
+ {result.scoredLevel}
+
+
+
→
+ {/if}
Computed level
@@ -68,8 +77,10 @@
{:else if identityCapped}
- Capped at {result.level} by the identity floor — the weakest
- accountable actor is {minimumIal}.
+ The assessment supports {result.scoredLevel}, but
+ {result.scoredLevel} needs every responsible person verified
+ to {framework.identityFloor[result.scoredLevel ?? "L0"]}. The
+ weakest is {minimumIal}, so this is held at {result.level}.
{:else if limitingLabel}
diff --git a/services/ppa/src/lib/levels.spec.ts b/services/ppa/src/lib/levels.spec.ts
index 79119ac16..ec6bb4429 100644
--- a/services/ppa/src/lib/levels.spec.ts
+++ b/services/ppa/src/lib/levels.spec.ts
@@ -103,6 +103,22 @@ describe("computeLevel", () => {
expect(result.limiting).toBe("identity");
});
+ it("still reports what the evidence alone supported when capped", () => {
+ // Without this the reviewer sees a mean of 5 next to an award of L2 and
+ // reasonably reads it as a bug rather than as the identity floor.
+ const result = computeLevel(framework, allAt(5), "IAL3");
+
+ expect(result.scoredLevel).toBe("L5");
+ expect(result.level).toBe("L2");
+ });
+
+ it("reports the same level twice when nothing capped it", () => {
+ const result = computeLevel(framework, allAt(3), "IAL4");
+
+ expect(result.scoredLevel).toBe("L3");
+ expect(result.level).toBe("L3");
+ });
+
it("refuses any level for an anonymous responsible party", () => {
expect(computeLevel(framework, allAt(5), "IAL1").level).toBeNull();
});
diff --git a/services/ppa/src/lib/levels.ts b/services/ppa/src/lib/levels.ts
index 8f6a48718..8ab64c1cd 100644
--- a/services/ppa/src/lib/levels.ts
+++ b/services/ppa/src/lib/levels.ts
@@ -78,6 +78,12 @@ export interface ComputedLevel {
level: AccessLevel | null;
/** The geometric mean itself, before flooring — shown in the calculation. */
score: number;
+ /**
+ * The level the evidence alone supports, before the identity floor is
+ * applied. Shown alongside `level` so a cap reads as a cap rather than as
+ * an arithmetic mistake.
+ */
+ scoredLevel: AccessLevel | null;
/** Weakest dimension, or "identity" when the IAL floor is what capped it. */
limiting: string | null;
/** True when a dimension fails outright, so no level can be awarded. */
@@ -134,7 +140,14 @@ export function computeLevel(
}
if (blocked || perDimension.length === 0) {
- return { level: null, score: 0, limiting, blocked: true, perDimension };
+ return {
+ level: null,
+ score: 0,
+ scoredLevel: null,
+ limiting,
+ blocked: true,
+ perDimension,
+ };
}
// Geometric mean over level + 1, shifted back, so a legitimate L0 row
@@ -148,6 +161,7 @@ export function computeLevel(
// exp(mean(ln 6)) - 1 lands a hair under 5, so floor alone would award L4
// for a flawless assessment. Nudge past the float error before flooring.
let index = Math.floor(score + 1e-9);
+ const scoredLevel = levelFromIndex(index);
// The identity floor: the highest level whose required IAL is met.
let identityCap = -1;
@@ -166,6 +180,7 @@ export function computeLevel(
return {
level: levelFromIndex(index),
score,
+ scoredLevel,
limiting,
blocked: false,
perDimension,
From ff07f2f9c3985079135ea1d9cff5a42a6449661d Mon Sep 17 00:00:00 2001
From: coodos
Date: Sun, 30 Aug 2026 18:13:13 +0800
Subject: [PATCH 07/18] feat: run the demonstrator on real platforms and your
own eVault
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the minted Chatterbox/Ledgerly world with what is actually on the
network: platforms and certificates from awareness, deployments and their
binding documents from the eVaults holding them, and the signed-in owner's
own records grouped by the domain each schema declares.
Split into Platforms, Your data and Your terms. Terms are signed by the real
wallet — the signing session id is the statement's canonical payload, so the
signature verifies standalone — and published to the owner's eVault.
Possession reports 'not attempted' without a key rather than claiming a check
failed that was never made.
Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ
---
.../pp-auth-demonstrator.md | 45 ++--
services/pp-auth-demo/package.json | 6 +-
services/pp-auth-demo/src/app.d.ts | 2 +-
services/pp-auth-demo/src/hooks.server.ts | 46 ++++
.../src/lib/DeploymentPanel.svelte | 218 ----------------
.../pp-auth-demo/src/lib/DeploymentRow.svelte | 133 ++++++++++
.../{OwnerTerms.svelte => TermsForm.svelte} | 161 +++++++-----
services/pp-auth-demo/src/lib/server/aaas.ts | 174 +++++++++++++
.../pp-auth-demo/src/lib/server/access.ts | 108 --------
services/pp-auth-demo/src/lib/server/chain.ts | 198 ++++++++++++++
services/pp-auth-demo/src/lib/server/data.ts | 87 +++++++
.../pp-auth-demo/src/lib/server/domains.ts | 66 +++++
services/pp-auth-demo/src/lib/server/env.ts | 53 ++++
.../pp-auth-demo/src/lib/server/evault.ts | 196 ++++++++++++++
services/pp-auth-demo/src/lib/server/keys.ts | 29 +++
.../pp-auth-demo/src/lib/server/ontology.ts | 35 +++
.../pp-auth-demo/src/lib/server/policy.ts | 131 ++++++++++
.../pp-auth-demo/src/lib/server/session.ts | 119 +++++++++
services/pp-auth-demo/src/lib/server/token.ts | 55 ++++
services/pp-auth-demo/src/lib/server/world.ts | 245 ------------------
.../pp-auth-demo/src/routes/+layout.server.ts | 6 +
.../pp-auth-demo/src/routes/+layout.svelte | 48 +++-
.../pp-auth-demo/src/routes/+page.server.ts | 42 +--
services/pp-auth-demo/src/routes/+page.svelte | 127 ---------
.../src/routes/api/access/+server.ts | 30 ---
.../src/routes/api/auth/+server.ts | 22 ++
.../src/routes/api/auth/logout/+server.ts | 8 +
.../src/routes/api/auth/offer/+server.ts | 5 +
.../api/auth/session/[session]/+server.ts | 16 ++
.../src/routes/api/handshake/+server.ts | 28 --
.../src/routes/api/key/+server.ts | 26 ++
.../src/routes/api/policy/+server.ts | 58 -----
.../src/routes/api/reset/+server.ts | 8 -
.../src/routes/api/tamper/+server.ts | 90 -------
.../src/routes/api/terms/+server.ts | 59 +++++
.../src/routes/api/terms/status/+server.ts | 42 +++
.../src/routes/api/verify/+server.ts | 33 +++
.../src/routes/data/+page.server.ts | 65 +++++
.../pp-auth-demo/src/routes/data/+page.svelte | 111 ++++++++
.../src/routes/login/+page.svelte | 63 +++++
.../src/routes/platforms/+page.server.ts | 102 ++++++++
.../src/routes/platforms/+page.svelte | 82 ++++++
.../src/routes/terms/+page.server.ts | 12 +
.../src/routes/terms/+page.svelte | 49 ++++
services/pp-auth-demo/src/svelte-qrcode.d.ts | 20 ++
45 files changed, 2203 insertions(+), 1056 deletions(-)
create mode 100644 services/pp-auth-demo/src/hooks.server.ts
delete mode 100644 services/pp-auth-demo/src/lib/DeploymentPanel.svelte
create mode 100644 services/pp-auth-demo/src/lib/DeploymentRow.svelte
rename services/pp-auth-demo/src/lib/{OwnerTerms.svelte => TermsForm.svelte} (50%)
create mode 100644 services/pp-auth-demo/src/lib/server/aaas.ts
delete mode 100644 services/pp-auth-demo/src/lib/server/access.ts
create mode 100644 services/pp-auth-demo/src/lib/server/chain.ts
create mode 100644 services/pp-auth-demo/src/lib/server/data.ts
create mode 100644 services/pp-auth-demo/src/lib/server/domains.ts
create mode 100644 services/pp-auth-demo/src/lib/server/env.ts
create mode 100644 services/pp-auth-demo/src/lib/server/evault.ts
create mode 100644 services/pp-auth-demo/src/lib/server/keys.ts
create mode 100644 services/pp-auth-demo/src/lib/server/ontology.ts
create mode 100644 services/pp-auth-demo/src/lib/server/policy.ts
create mode 100644 services/pp-auth-demo/src/lib/server/session.ts
create mode 100644 services/pp-auth-demo/src/lib/server/token.ts
delete mode 100644 services/pp-auth-demo/src/lib/server/world.ts
create mode 100644 services/pp-auth-demo/src/routes/+layout.server.ts
delete mode 100644 services/pp-auth-demo/src/routes/+page.svelte
delete mode 100644 services/pp-auth-demo/src/routes/api/access/+server.ts
create mode 100644 services/pp-auth-demo/src/routes/api/auth/+server.ts
create mode 100644 services/pp-auth-demo/src/routes/api/auth/logout/+server.ts
create mode 100644 services/pp-auth-demo/src/routes/api/auth/offer/+server.ts
create mode 100644 services/pp-auth-demo/src/routes/api/auth/session/[session]/+server.ts
delete mode 100644 services/pp-auth-demo/src/routes/api/handshake/+server.ts
create mode 100644 services/pp-auth-demo/src/routes/api/key/+server.ts
delete mode 100644 services/pp-auth-demo/src/routes/api/policy/+server.ts
delete mode 100644 services/pp-auth-demo/src/routes/api/reset/+server.ts
delete mode 100644 services/pp-auth-demo/src/routes/api/tamper/+server.ts
create mode 100644 services/pp-auth-demo/src/routes/api/terms/+server.ts
create mode 100644 services/pp-auth-demo/src/routes/api/terms/status/+server.ts
create mode 100644 services/pp-auth-demo/src/routes/api/verify/+server.ts
create mode 100644 services/pp-auth-demo/src/routes/data/+page.server.ts
create mode 100644 services/pp-auth-demo/src/routes/data/+page.svelte
create mode 100644 services/pp-auth-demo/src/routes/login/+page.svelte
create mode 100644 services/pp-auth-demo/src/routes/platforms/+page.server.ts
create mode 100644 services/pp-auth-demo/src/routes/platforms/+page.svelte
create mode 100644 services/pp-auth-demo/src/routes/terms/+page.server.ts
create mode 100644 services/pp-auth-demo/src/routes/terms/+page.svelte
create mode 100644 services/pp-auth-demo/src/svelte-qrcode.d.ts
diff --git a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md
index f3d007484..c641039cf 100644
--- a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md
+++ b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md
@@ -4,41 +4,48 @@ sidebar_position: 9
# PP Auth demonstrator
-A running demonstration of platform authentication and domain separation. Two platforms, one vault, and every attempt to reach data shown with the reason it succeeded or failed.
+Shows platform authentication and domain separation against the live network: real platforms, real certificates from the association, real deployments, and your own eVault.
```bash
pnpm --filter pp-auth-demo dev
```
-Then open **http://localhost:4310**. Nothing else needs to be running — no database, no registry, no eVault.
+Then open **http://localhost:4310** and sign in with your wallet. It needs `PPA_AWARENESS_API_KEY` (or `AWARENESS_API_KEY`) to see the network, and `PUBLIC_REGISTRY_URL` to resolve eVaults.
-## What it shows
+Nothing is seeded. If the platforms page is empty, nothing has been deployed or certified yet — which is a true statement about the network rather than a failure of the app.
-**Chatterbox** is a social platform, certified L3 for `social` and `communication`. **Ledgerly** handles money, certified L4 for `finance`. Both are live, both will try to reach everything in the vault.
+## Platforms
-Point either one at a domain it was not certified for and it is refused — with a sentence saying so, not a status code. The refusal does not come from a list of platform names: it comes from the certificate the deployment presented, which does not name that domain and cannot be made to.
+Every platform with a deployment or a certification decision, read live. Under each are the deployments actually running it, with the release and commit they were built from.
-**Your terms** sets the owner's side: the minimum level, whose reputation scores count and what score they must reach, and any domain refused outright. Signing produces a real signature over a real statement, which is verified before it takes effect. Raise the bar to L4 and Chatterbox stops being allowed anything; require a reputation of 50 and Ledgerly does, on the scores the demo's engine reports.
+**Check it** verifies that deployment's chain of trust, from scratch, against records anyone can read:
-**Try to cheat** is where the mechanism is visible. Each edit breaks exactly one link:
-
-| Edit | Fails at |
+| Link | Where the evidence comes from |
|---|---|
-| Present a different public key — paste your own | Possession |
-| Widen its own authorisation | Deployment authorised |
-| Borrow the other platform's version document | Bundle integrity |
-| Point at a different release | Version identity |
-| Borrow the other platform's certificate | Accreditation |
+| Possession | the deployment itself — see below |
+| Deployment authorised | the wallet signature on the deployment's key document, resolved through the registry |
+| Bundle integrity | the hashes covered by that same signature |
+| Version identity | UUIDv5 arithmetic over the platform eName and version |
+| Release authorship | the release proof in the platform's own profile, and its registry key-binding certificate |
+| Accreditation | the association's ES256 certificate for that exact version |
+
+Five of the six are checked by reading. **Possession is not** — the deployment's private key never leaves the deployment, so a reader cannot answer a challenge on its behalf. That link reports "not attempted" rather than pretending it failed a check that was never made.
+
+If you hold the key — because you are the person who made that deployment — paste it and the challenge is signed for real. It is kept in memory for that process only: never written to disk, never logged, gone on restart. A wrong key produces a genuine signature that genuinely fails.
+
+## Your data
+
+Your own eVault records, grouped by the domain each schema declares. That grouping is what a certificate is written against, so it is also what decides who sees what.
-The chain trace re-runs on every attempt, so you can watch a link go red and read why.
+The table shows every certified platform against every kind of data you hold, decided by the real certificate's domains and your real signed terms, using the same `authorize` an eVault would call. A platform certified for `social`, `finance` and `media` is allowed those and refused everything else — with the reason spelled out. It cannot reach your messages or your files, and nothing it presents will change that.
-## What is real and what is not
+## Your terms
-The signatures are real — P-256 and ES256, verified by exactly the same code that verifies a live deployment. The tampering really does fail, for the reason shown.
+The association says what a platform was found to be; you decide what that is worth. Set the minimum level, whose reputation scores you accept and the score they must reach, and any domain refused outright.
-What is simulated is who holds the keys. The deployer's wallet, the registry and the association are stood in for by keys generated in the demo process, so it runs on its own. A chain that verifies here proves the mechanism works. It proves nothing about any particular platform, which is what the real roots are for.
+Signing goes to your wallet. The signing session id **is** the canonical payload of the statement, so what the wallet signs is exactly the digest of your terms — the signature then verifies against the statement on its own, without anyone trusting this app. The terms are published into your own eVault as an `Access Policy` record, world-readable, and the signature is checked again before the write.
-The minting facility lives at `@metastate-foundation/auth/platform/scenario`, deliberately behind a separate entry point so it cannot be reached by accident from code that verifies real deployments.
+Your terms can only narrow a certificate, never widen it. Permitting `finance` does not let a platform reach finance data it was not certified for.
## See also
diff --git a/services/pp-auth-demo/package.json b/services/pp-auth-demo/package.json
index 5459bc5b6..aaed2a76c 100644
--- a/services/pp-auth-demo/package.json
+++ b/services/pp-auth-demo/package.json
@@ -26,6 +26,10 @@
},
"dependencies": {
"@metastate-foundation/auth": "workspace:*",
- "jose": "^5.2.2"
+ "jose": "^5.2.2",
+ "svelte-qrcode": "^1.0.1",
+ "dotenv": "^16.4.5",
+ "graphql-request": "^7.3.1",
+ "signature-validator": "workspace:*"
}
}
diff --git a/services/pp-auth-demo/src/app.d.ts b/services/pp-auth-demo/src/app.d.ts
index 4b0fb361a..219a939a2 100644
--- a/services/pp-auth-demo/src/app.d.ts
+++ b/services/pp-auth-demo/src/app.d.ts
@@ -1,7 +1,7 @@
declare global {
namespace App {
interface Locals {
- /** The signed-in PPA admin, or null when unauthenticated. */
+ /** The signed-in eVault owner, or null when unauthenticated. */
user: { ename: string } | null;
}
}
diff --git a/services/pp-auth-demo/src/hooks.server.ts b/services/pp-auth-demo/src/hooks.server.ts
new file mode 100644
index 000000000..464bfd46b
--- /dev/null
+++ b/services/pp-auth-demo/src/hooks.server.ts
@@ -0,0 +1,46 @@
+import { redirect, type Handle } from "@sveltejs/kit";
+import { COOKIE, read } from "$lib/server/token";
+
+const PUBLIC_PATHS = new Set(["/login"]);
+
+function isPublic(pathname: string): boolean {
+ if (PUBLIC_PATHS.has(pathname)) return true;
+ if (pathname.startsWith("/api/auth")) return true;
+ if (pathname.startsWith("/api/sign")) return true;
+ // The verifier endpoints are for deployments, which have no session.
+ if (pathname.startsWith("/pp-auth/")) return true;
+ return false;
+}
+
+/**
+ * The wallet posts its callback from a phone, cross-origin, so the callback
+ * routes need CORS — including the private-network preflight Chrome sends when
+ * a public page calls a LAN address.
+ */
+function cors(response: Response): Response {
+ response.headers.set("Access-Control-Allow-Origin", "*");
+ response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
+ response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
+ response.headers.set("Access-Control-Allow-Private-Network", "true");
+ response.headers.set("Access-Control-Max-Age", "86400");
+ return response;
+}
+
+export const handle: Handle = async ({ event, resolve }) => {
+ event.locals.user = read(event.cookies.get(COOKIE));
+
+ if (event.request.method === "OPTIONS") {
+ return cors(new Response(null, { status: 204 }));
+ }
+
+ const { pathname } = event.url;
+ if (!event.locals.user && !isPublic(pathname)) {
+ if (pathname.startsWith("/api/")) {
+ return cors(new Response("Unauthorized", { status: 401 }));
+ }
+ throw redirect(302, "/login");
+ }
+ if (event.locals.user && pathname === "/login") throw redirect(302, "/platforms");
+
+ return cors(await resolve(event));
+};
diff --git a/services/pp-auth-demo/src/lib/DeploymentPanel.svelte b/services/pp-auth-demo/src/lib/DeploymentPanel.svelte
deleted file mode 100644
index 246b15ef0..000000000
--- a/services/pp-auth-demo/src/lib/DeploymentPanel.svelte
+++ /dev/null
@@ -1,218 +0,0 @@
-
-
-
-
-
+ Proved: {chain.claim.platformName} {chain.claim.version}, certified
+ {chain.claim.level} for {chain.claim.domains.join(", ") || "no domains"}.
+
+ {:else if chain.failedAt === "possession" && !deployment.keyHeld}
+
+
+ Everything that can be checked by reading has been checked. The one
+ thing left is whether whoever is calling actually holds this
+ deployment's key — and only the deployment can show that.
+
+ {#if showKey}
+
+
+
+
+
+
+ Kept in memory for this process only. Never written down, never logged.
+
+ {:else}
+
+ {/if}
+
+ {/if}
+ {/if}
+
diff --git a/services/pp-auth-demo/src/lib/OwnerTerms.svelte b/services/pp-auth-demo/src/lib/TermsForm.svelte
similarity index 50%
rename from services/pp-auth-demo/src/lib/OwnerTerms.svelte
rename to services/pp-auth-demo/src/lib/TermsForm.svelte
index 28c480d4b..bac134e75 100644
--- a/services/pp-auth-demo/src/lib/OwnerTerms.svelte
+++ b/services/pp-auth-demo/src/lib/TermsForm.svelte
@@ -1,18 +1,14 @@
-
-
Your terms
-
What you will deal with
-
- The association says what a platform was found to be. You decide what
- that is worth. Your answers are signed, so they travel with you and
- anyone can check them — including the platform, before it bothers asking.
-
-
-
The least you will accept
@@ -100,7 +121,7 @@
class="mt-1"
value={level.id}
bind:group={minimumLevel}
- onchange={() => (saved = false)}
+ onchange={() => (done = false)}
/>
{level.id}
@@ -116,16 +137,8 @@
- (saved = false)}
- placeholder={reputationEngine}
- />
-
- Leave blank to ignore reputation entirely.
-
+ (done = false)} />
+
Leave blank to ignore reputation entirely.
-
-
- Things nobody gets, whatever their certificate says
-
+ Refused before any permission was consulted — it could not prove what it is.
+ {chain.links.find((link) => !link.ok)?.detail}
+
+ {/if}
+
+ {#if chain}
+
+
+ What it proved
+
+
+
+
+
+ {/if}
+
diff --git a/services/pp-auth-demo/src/lib/server/grants.ts b/services/pp-auth-demo/src/lib/server/grants.ts
new file mode 100644
index 000000000..6d5c7fa9f
--- /dev/null
+++ b/services/pp-auth-demo/src/lib/server/grants.ts
@@ -0,0 +1,129 @@
+/**
+ * Access grants, kept in the owner's own eVault as `AccessGrant` records.
+ *
+ * Records are append-only, which is what the ontology's `revision` field is
+ * for: changing what a platform may do writes a new record rather than editing
+ * the old one, so the history of who was given what, and when it was taken
+ * away, survives. The newest revision for a (grantee, resource) pair is the one
+ * in force.
+ */
+
+import type { AccessGrant, Operation } from "@metastate-foundation/auth/platform";
+import { permissionFor } from "@metastate-foundation/auth/platform";
+import { randomUUID } from "node:crypto";
+import { envelopes, store_ } from "./evault";
+import { ACCESS_GRANT_ONTOLOGY } from "./ontology";
+
+export interface StoredGrant extends AccessGrant {
+ grantId: string;
+ grantorEName: string;
+ revision: number;
+ createdAt: string;
+ updatedAt: string;
+ revokedAt: string | null;
+}
+
+function key(granteeEName: string | null, resourceType: string): string {
+ return `${granteeEName ?? "*"}::${resourceType}`;
+}
+
+/**
+ * The grants in force for one owner: newest revision per grantee and resource.
+ *
+ * Revoked records are kept rather than filtered out, so `evaluateGrants` can
+ * tell "withdrawn" apart from "never held" — which are different things to
+ * show someone.
+ */
+export async function currentGrants(ename: string): Promise {
+ let records: Array<{ id: string; parsed: Record }>;
+ try {
+ records = await envelopes(ename, ACCESS_GRANT_ONTOLOGY, 200);
+ } catch (error) {
+ console.warn(`[pp-auth-demo] could not read grants for ${ename}:`, error);
+ return [];
+ }
+
+ const newest = new Map();
+ for (const record of records) {
+ const raw = record.parsed;
+ if (raw.isReference === true) continue;
+ if (raw.grantorEName !== ename) continue;
+ const resourceType = typeof raw.resourceType === "string" ? raw.resourceType : "";
+ const granteeEName =
+ typeof raw.granteeEName === "string" ? raw.granteeEName : null;
+ if (!resourceType) continue;
+
+ const grant: StoredGrant = {
+ grantId: String(raw.grantId ?? ""),
+ grantorEName: ename,
+ granteeType: raw.granteeType === "public" ? "public" : "ename",
+ granteeEName,
+ resourceType,
+ permissions: Array.isArray(raw.permissions)
+ ? raw.permissions.filter((p): p is string => typeof p === "string")
+ : [],
+ status: raw.status === "revoked" ? "revoked" : "active",
+ validFrom: typeof raw.validFrom === "string" ? raw.validFrom : undefined,
+ validUntil: typeof raw.validUntil === "string" ? raw.validUntil : null,
+ revision: Number(raw.revision) || 1,
+ createdAt: String(raw.createdAt ?? ""),
+ updatedAt: String(raw.updatedAt ?? raw.createdAt ?? ""),
+ revokedAt: typeof raw.revokedAt === "string" ? raw.revokedAt : null,
+ };
+
+ const existing = newest.get(key(granteeEName, resourceType));
+ if (!existing || grant.revision > existing.revision) {
+ newest.set(key(granteeEName, resourceType), grant);
+ }
+ }
+
+ return [...newest.values()];
+}
+
+/**
+ * Records what one platform may do with one kind of data.
+ *
+ * An empty operation list revokes rather than deleting: the record stays and is
+ * marked withdrawn, so a later reader can see that access was taken away rather
+ * than finding a silent absence.
+ */
+export async function setGrant(
+ ename: string,
+ granteeEName: string,
+ resourceType: string,
+ operations: Operation[],
+ existing: StoredGrant[],
+): Promise {
+ const previous = existing.find(
+ (grant) =>
+ grant.granteeEName === granteeEName && grant.resourceType === resourceType,
+ );
+ const now = new Date().toISOString();
+ const revoking = operations.length === 0;
+
+ const payload = {
+ isReference: false,
+ grantId: previous?.grantId || randomUUID(),
+ grantorEName: ename,
+ granteeType: "ename" as const,
+ granteeEName,
+ resourceType,
+ // A revoked grant keeps the permissions it used to carry, so the record
+ // says what was withdrawn rather than merely that something was.
+ permissions: revoking
+ ? previous?.permissions?.length
+ ? previous.permissions
+ : [permissionFor(resourceType, "read")]
+ : operations.map((operation) => permissionFor(resourceType, operation)),
+ status: revoking ? ("revoked" as const) : ("active" as const),
+ validFrom: previous?.validFrom ?? now,
+ validUntil: null,
+ createdAt: previous?.createdAt || now,
+ updatedAt: now,
+ revision: (previous?.revision ?? 0) + 1,
+ revokedAt: revoking ? now : null,
+ delegationAllowed: false,
+ };
+
+ await store_(ename, ACCESS_GRANT_ONTOLOGY, payload, [ename, granteeEName]);
+}
diff --git a/services/pp-auth-demo/src/lib/server/ontology.ts b/services/pp-auth-demo/src/lib/server/ontology.ts
index 9f0a5ff78..44180ff91 100644
--- a/services/pp-auth-demo/src/lib/server/ontology.ts
+++ b/services/pp-auth-demo/src/lib/server/ontology.ts
@@ -4,6 +4,7 @@ export const USER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000";
export const PLATFORM_ACCREDITATION_ONTOLOGY = "e1749947-5a10-4973-b9fa-230d8714c36a";
export const DEPLOYMENT_PROFILE_ONTOLOGY = "d38e0c5b-9d63-4a21-8e8b-1d6b63af64d2";
export const ACCESS_POLICY_ONTOLOGY = "c7a41f6d-95b8-4e2a-9c33-8f0d1b6e4a72";
+export const ACCESS_GRANT_ONTOLOGY = "15d24c04-a4f3-4e45-a00e-0123926fbc87";
export interface AccreditationRecord {
accreditationId: string;
diff --git a/services/pp-auth-demo/src/routes/+layout.svelte b/services/pp-auth-demo/src/routes/+layout.svelte
index cb77319f9..20027148a 100644
--- a/services/pp-auth-demo/src/routes/+layout.svelte
+++ b/services/pp-auth-demo/src/routes/+layout.svelte
@@ -7,6 +7,7 @@
const TABS = [
{ href: "/platforms", label: "Platforms" },
{ href: "/data", label: "Your data" },
+ { href: "/acl", label: "Permissions" },
{ href: "/terms", label: "Your terms" },
];
diff --git a/services/pp-auth-demo/src/routes/acl/+page.server.ts b/services/pp-auth-demo/src/routes/acl/+page.server.ts
new file mode 100644
index 000000000..e789b6c78
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/acl/+page.server.ts
@@ -0,0 +1,70 @@
+import { accreditations, deployments, platformProfile } from "$lib/server/aaas";
+import { listDomains } from "$lib/server/domains";
+import { currentGrants } from "$lib/server/grants";
+import { held } from "$lib/server/keys";
+import type { PageServerLoad } from "./$types";
+
+/**
+ * Everything needed to decide, and to see the decision: the certified
+ * platforms, the domains they were certified for, and what each has actually
+ * been permitted to do.
+ */
+export const load: PageServerLoad = async ({ locals }) => {
+ const ename = locals.user!.ename;
+
+ const [records, grants, domains, allDeployments] = await Promise.all([
+ accreditations().catch(() => []),
+ currentGrants(ename),
+ listDomains().catch(() => []),
+ deployments().catch(() => []),
+ ]);
+
+ const granted = new Map();
+ for (const record of records) {
+ if (record.decision !== "granted") continue;
+ if (!granted.has(record.platformEName)) granted.set(record.platformEName, record);
+ }
+
+ const withKeys = new Set(held());
+
+ const platforms = await Promise.all(
+ [...granted.values()].map(async (record) => {
+ const profile = await platformProfile(record.platformEName);
+ const mine = allDeployments.filter(
+ (deployment) => deployment.platformEname === record.platformEName,
+ );
+ return {
+ ename: record.platformEName,
+ name: profile?.displayName || record.platformName,
+ level: record.level,
+ version: record.platformVersion,
+ certifiedDomains: record.domains ?? [],
+ deployments: mine.map((deployment) => ({
+ ename: deployment.deploymentEname,
+ name: deployment.deploymentName,
+ environment: deployment.environment,
+ version: deployment.version,
+ keyHeld: withKeys.has(deployment.deploymentEname),
+ })),
+ grants: (record.domains ?? []).map((domain) => {
+ const grant = grants.find(
+ (entry) =>
+ entry.granteeEName === record.platformEName &&
+ entry.resourceType === domain,
+ );
+ const active = grant && grant.status === "active";
+ return {
+ domain,
+ label: domains.find((d) => d.id === domain)?.label ?? domain,
+ read: Boolean(active && grant!.permissions.includes(`${domain}:Read`)),
+ write: Boolean(active && grant!.permissions.includes(`${domain}:Write`)),
+ revoked: Boolean(grant && grant.status === "revoked"),
+ revision: grant?.revision ?? 0,
+ };
+ }),
+ };
+ }),
+ );
+
+ return { ename, platforms };
+};
diff --git a/services/pp-auth-demo/src/routes/acl/+page.svelte b/services/pp-auth-demo/src/routes/acl/+page.svelte
new file mode 100644
index 000000000..3c45d0cc9
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/acl/+page.svelte
@@ -0,0 +1,150 @@
+
+
+
+
+
Permissions
+
What each platform may do
+
+ Being certified for a kind of data is not permission to do anything with
+ it. Reading your posts is not the same as writing to them, and this is
+ where that is decided. Each change is kept in your own eVault as a
+ permission record, so nothing is lost when you take access away.
+
+
+
+ {#if data.platforms.length === 0}
+
+
+ No platform on the network is certified yet, so there is nothing to
+ permit. This fills in on its own once the association grants one.
+
diff --git a/services/pp-auth-demo/src/routes/api/grants/+server.ts b/services/pp-auth-demo/src/routes/api/grants/+server.ts
new file mode 100644
index 000000000..6d6a1e065
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/grants/+server.ts
@@ -0,0 +1,38 @@
+import { json } from "@sveltejs/kit";
+import type { Operation } from "@metastate-foundation/auth/platform";
+import { currentGrants, setGrant } from "$lib/server/grants";
+import type { RequestHandler } from "./$types";
+
+/**
+ * Records what one platform may do with one kind of data.
+ *
+ * Writes an `AccessGrant` into the owner's own eVault. Clearing both operations
+ * withdraws the grant rather than deleting it, so the record shows access was
+ * taken away rather than never given.
+ */
+export const POST: RequestHandler = async ({ request, locals }) => {
+ const { platformEname, domain, operations } = (await request.json()) as {
+ platformEname?: string;
+ domain?: string;
+ operations?: string[];
+ };
+ if (!platformEname || !domain) {
+ return json({ error: "platformEname and domain are required" }, { status: 400 });
+ }
+
+ const wanted = (operations ?? []).filter(
+ (operation): operation is Operation => operation === "read" || operation === "write",
+ );
+
+ const ename = locals.user!.ename;
+ try {
+ await setGrant(ename, platformEname, domain, wanted, await currentGrants(ename));
+ return json({ ok: true });
+ } catch (error) {
+ console.error("[pp-auth-demo/grants] could not write the grant:", error);
+ return json(
+ { error: error instanceof Error ? error.message : "could not save" },
+ { status: 500 },
+ );
+ }
+};
diff --git a/services/pp-auth-demo/src/routes/api/request/+server.ts b/services/pp-auth-demo/src/routes/api/request/+server.ts
new file mode 100644
index 000000000..41559ba6c
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/request/+server.ts
@@ -0,0 +1,78 @@
+import { json } from "@sveltejs/kit";
+import { authorize, type Operation, type PlatformClaim } from "@metastate-foundation/auth/platform";
+import { deployments, platformProfile } from "$lib/server/aaas";
+import { assemble, verify } from "$lib/server/chain";
+import { currentGrants } from "$lib/server/grants";
+import { keyFor } from "$lib/server/keys";
+import { currentPolicy } from "$lib/server/policy";
+import type { RequestHandler } from "./$types";
+
+/**
+ * One request, all the way through.
+ *
+ * A deployment proves what it is, and then the three gates decide what it may
+ * do: the association's certificate, the owner's terms, and the grants. The
+ * response reports each stage separately so it is clear which one refused.
+ */
+export const POST: RequestHandler = async ({ request, locals }) => {
+ const body = (await request.json()) as {
+ deploymentEname?: string;
+ domain?: string;
+ operation?: string;
+ };
+ const operation: Operation = body.operation === "write" ? "write" : "read";
+ const domain = String(body.domain ?? "");
+ const ename = locals.user!.ename;
+
+ const all = await deployments();
+ const deployment = all.find((d) => d.deploymentEname === body.deploymentEname);
+ if (!deployment || !domain) {
+ return json({ error: "Unknown deployment or domain" }, { status: 404 });
+ }
+
+ const assembled = await assemble(deployment);
+ if (!assembled.evidence) {
+ return json({
+ stage: "evidence",
+ missing: assembled.missing,
+ chain: null,
+ decision: null,
+ });
+ }
+
+ const { chain } = await verify(
+ assembled.evidence,
+ ename,
+ keyFor(deployment.deploymentEname),
+ );
+
+ // Without a proven identity there is nothing to authorise. Refusing here is
+ // the whole point: an unproven caller does not get to reach anything,
+ // however generous the grants behind it are.
+ if (!chain.ok || !chain.claim) {
+ return json({ stage: "handshake", chain, decision: null, missing: [] });
+ }
+
+ const [policy, grants, profile] = await Promise.all([
+ currentPolicy(ename),
+ currentGrants(ename),
+ platformProfile(deployment.platformEname),
+ ]);
+
+ const claim: PlatformClaim = {
+ ...chain.claim,
+ platformName: profile?.displayName || chain.claim.platformName,
+ };
+
+ const decision = authorize(policy.statement, {
+ claim,
+ domain,
+ operation,
+ grants,
+ });
+
+ return json({ stage: "authorised", chain, decision, missing: [] });
+};
+
+export const GET: RequestHandler = async () =>
+ json({ error: "POST a deployment, domain and operation" }, { status: 405 });
From 55cb212698c6d1c62ec1964e73d7dc361c1f58de Mon Sep 17 00:00:00 2001
From: coodos
Date: Sun, 30 Aug 2026 18:38:28 +0800
Subject: [PATCH 10/18] feat: return real records from a permitted read
A request that answered allow or deny without touching the eVault proved
nothing. A permitted read now fetches and renders the records; a refused one
fetches nothing and says the vault was never asked; a permitted write really
writes and reads the domain back.
Also summarises balances, amounts and file sizes, which have no prose field
and were rendering as unreadable.
Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ
---
.../pp-auth-demonstrator.md | 6 +-
.../pp-auth-demo/src/lib/RequestTester.svelte | 52 ++++++++-
services/pp-auth-demo/src/lib/server/data.ts | 108 +++++++++++++++++-
.../src/routes/api/request/+server.ts | 40 ++++++-
4 files changed, 197 insertions(+), 9 deletions(-)
diff --git a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md
index 036808d18..ea4827269 100644
--- a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md
+++ b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md
@@ -47,7 +47,11 @@ Each change writes an `AccessGrant` into your own eVault as a new revision. Clea
**Deployment keys go in here, before you try anything.** Possession is the one link a reader cannot establish by looking, so whether the key is present decides what a check can even mean. Enter it and the deployment can answer a challenge for real; leave it out and every request stops at the handshake, which is the correct outcome.
-**Try a request** then runs one all the way through — a named deployment, an operation, a domain — and reports which of the three gates decided. Turn off write and a write is refused while a read still succeeds; withdraw the grant and the refusal changes from "has not been given permission" to "has been withdrawn".
+**Try a request** then runs one all the way through — a named deployment, an operation, a domain — and reports which of the three gates decided.
+
+A permitted read is not a verdict: it goes to the eVault and the records it returns are rendered underneath. A refused one fetches nothing, and says so — the eVault is never asked. A permitted write really writes, with text you supply, into a schema belonging to that domain, and then reads the domain back so you can see it landed.
+
+Turn off write and a write is refused while a read still succeeds; withdraw the grant and the refusal changes from "has not been given permission" to "has been withdrawn".
## Your terms
diff --git a/services/pp-auth-demo/src/lib/RequestTester.svelte b/services/pp-auth-demo/src/lib/RequestTester.svelte
index ec3951f28..2cd687ce8 100644
--- a/services/pp-auth-demo/src/lib/RequestTester.svelte
+++ b/services/pp-auth-demo/src/lib/RequestTester.svelte
@@ -32,6 +32,10 @@
let decision = $state<{ allowed: boolean; reason: string; code: string } | null>(null);
let stage = $state(null);
let missing = $state([]);
+ let records = $state | null>(null);
+ let wrote = $state<{ id: string; kind: string } | null>(null);
+ let note = $state(null);
+ let text = $state("");
async function send() {
busy = true;
@@ -39,13 +43,17 @@
const res = await fetch("/api/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ deploymentEname, domain, operation }),
+ body: JSON.stringify({ deploymentEname, domain, operation, text }),
});
const body = await res.json();
chain = body.chain ?? null;
decision = body.decision ?? null;
stage = body.stage ?? null;
missing = body.missing ?? [];
+ records = body.records ?? null;
+ wrote = body.wrote ?? null;
+ note = body.note ?? null;
+ if (body.wrote) text = "";
} finally {
busy = false;
}
@@ -85,6 +93,13 @@
This deployment has no key here, so it cannot prove who it is and the
@@ -117,6 +132,41 @@
{/if}
+ {#if decision?.allowed}
+
+
+ {wrote ? "Written, and read back from your eVault" : "Pulled from your eVault"}
+
+ {#if wrote}
+
+ Stored a new {wrote.kind} record.
+
+ {/if}
+ {#if note}
+
{note}
+ {/if}
+ {#if records && records.length > 0}
+
+ {#each records as record (record.id)}
+
+
{record.kind}
+
{record.summary}
+
+ {/each}
+
+ {:else if records}
+
+ The read was permitted and went through — your eVault holds nothing
+ of this kind.
+
+ {/if}
+
+ {:else if decision}
+
+ Nothing was fetched. The eVault was never asked.
+
+ {/if}
+
{#if chain}
diff --git a/services/pp-auth-demo/src/lib/server/data.ts b/services/pp-auth-demo/src/lib/server/data.ts
index 39990647a..3c392725d 100644
--- a/services/pp-auth-demo/src/lib/server/data.ts
+++ b/services/pp-auth-demo/src/lib/server/data.ts
@@ -5,7 +5,7 @@
* ours: this is exactly the partition a certificate grants against.
*/
-import { envelopes } from "./evault";
+import { envelopes, store_ } from "./evault";
import { listDomains, listSchemas } from "./domains";
export interface OwnedRecord {
@@ -22,22 +22,61 @@ export interface DomainGroup {
records: OwnedRecord[];
}
-/** A short readable line for a record, without guessing at its shape. */
+/**
+ * A short readable line for a record.
+ *
+ * Most schemas carry an obvious text field. Money does not: an Account is a
+ * balance and a currency, and a Ledger entry is an amount and a description, so
+ * a summariser that only looks for prose renders your finances as "(no
+ * readable fields)" and the demonstration shows nothing.
+ */
function summarise(parsed: Record): string {
- const preferred = [
+ const text = [
"text", "content", "body", "message", "title", "name",
"displayName", "description", "summary", "label",
];
- for (const key of preferred) {
+ for (const key of text) {
const value = parsed[key];
if (typeof value === "string" && value.trim()) {
return value.trim().slice(0, 160);
}
}
+
+ // Numeric records: say what the number is rather than falling through.
+ const amounts: string[] = [];
+ if (typeof parsed.balance === "number" || typeof parsed.balance === "string") {
+ amounts.push(`balance ${parsed.balance}`);
+ }
+ if (typeof parsed.amount === "number" || typeof parsed.amount === "string") {
+ amounts.push(`amount ${parsed.amount}`);
+ }
+ if (typeof parsed.currencyName === "string" && parsed.currencyName) {
+ amounts.push(String(parsed.currencyName));
+ }
+ if (typeof parsed.accountType === "string" && parsed.accountType) {
+ amounts.unshift(String(parsed.accountType));
+ }
+ if (typeof parsed.type === "string" && parsed.type && amounts.length > 0) {
+ amounts.push(String(parsed.type));
+ }
+ if (amounts.length > 0) return amounts.join(" · ").slice(0, 160);
+
+ const size = typeof parsed.size === "number" ? `${parsed.size} bytes` : null;
+ if (size && typeof parsed.mimeType === "string") {
+ return `${parsed.mimeType} · ${size}`;
+ }
+
+ // Last resort. Identifiers and timestamps are skipped: showing
+ // "updatedAt: 2026-04-07T04:49:34.455Z" tells a reader nothing about what
+ // the record is, and a plain admission is more use than filler.
+ const skip = /(^id$|Id$|At$|EName$|Ename$|^type$|Url$|Hash$)/;
const first = Object.entries(parsed).find(
- ([, value]) => typeof value === "string" && value.trim().length > 0,
+ ([key, value]) =>
+ typeof value === "string" && value.trim().length > 0 && !skip.test(key),
);
- return first ? `${first[0]}: ${String(first[1]).slice(0, 140)}` : "(no readable fields)";
+ return first
+ ? `${first[0]}: ${String(first[1]).slice(0, 140)}`
+ : "(a record with no readable text)";
}
/**
@@ -85,3 +124,60 @@ export async function ownedByDomain(ename: string): Promise {
})
.sort((a, b) => b.records.length - a.records.length);
}
+
+/**
+ * The owner's records in one domain, fetched from the eVault at call time.
+ *
+ * This is what a permitted read actually returns. Nothing is cached and
+ * nothing is precomputed: if a request is allowed, these are the records that
+ * come back, and if it is refused they are never fetched at all.
+ */
+export async function recordsInDomain(
+ ename: string,
+ domain: string,
+): Promise {
+ const schemas = (await listSchemas()).filter((schema) => schema.domain === domain);
+ const found = await Promise.all(
+ schemas.map(async (schema) => {
+ const records = await envelopes(ename, schema.id, 10).catch(() => []);
+ return records.map((record) => ({
+ id: record.id,
+ kind: schema.title,
+ summary: summarise(record.parsed),
+ }));
+ }),
+ );
+ return found.flat();
+}
+
+/** Where a written record goes: the first schema published for that domain. */
+export async function writeTargetFor(
+ domain: string,
+): Promise<{ id: string; title: string } | null> {
+ const schema = (await listSchemas()).find((entry) => entry.domain === domain);
+ return schema ? { id: schema.id, title: schema.title } : null;
+}
+
+/**
+ * Performs a permitted write.
+ *
+ * A write that does not write would be exactly the pretence this demonstration
+ * exists to avoid, so this really does store a record in the owner's eVault —
+ * with text they typed, into a schema that belongs to the domain the grant
+ * covered.
+ */
+export async function writeRecord(
+ ename: string,
+ domain: string,
+ text: string,
+): Promise<{ id: string; kind: string } | null> {
+ const target = await writeTargetFor(domain);
+ if (!target) return null;
+ const id = await store_(
+ ename,
+ target.id,
+ { text, name: text, createdAt: new Date().toISOString() },
+ [ename],
+ );
+ return { id, kind: target.title };
+}
diff --git a/services/pp-auth-demo/src/routes/api/request/+server.ts b/services/pp-auth-demo/src/routes/api/request/+server.ts
index 41559ba6c..52f870f4c 100644
--- a/services/pp-auth-demo/src/routes/api/request/+server.ts
+++ b/services/pp-auth-demo/src/routes/api/request/+server.ts
@@ -2,6 +2,7 @@ import { json } from "@sveltejs/kit";
import { authorize, type Operation, type PlatformClaim } from "@metastate-foundation/auth/platform";
import { deployments, platformProfile } from "$lib/server/aaas";
import { assemble, verify } from "$lib/server/chain";
+import { recordsInDomain, writeRecord } from "$lib/server/data";
import { currentGrants } from "$lib/server/grants";
import { keyFor } from "$lib/server/keys";
import { currentPolicy } from "$lib/server/policy";
@@ -19,6 +20,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
deploymentEname?: string;
domain?: string;
operation?: string;
+ text?: string;
};
const operation: Operation = body.operation === "write" ? "write" : "read";
const domain = String(body.domain ?? "");
@@ -71,7 +73,43 @@ export const POST: RequestHandler = async ({ request, locals }) => {
grants,
});
- return json({ stage: "authorised", chain, decision, missing: [] });
+ if (!decision.allowed) {
+ // Nothing is fetched. A refusal that still read the data and then
+ // declined to show it would not be a refusal at all.
+ return json({ stage: "authorised", chain, decision, records: null, wrote: null });
+ }
+
+ if (operation === "write") {
+ const text = String(body.text ?? "").trim();
+ if (!text) {
+ return json({
+ stage: "authorised",
+ chain,
+ decision,
+ records: null,
+ wrote: null,
+ note: "Permitted, but nothing was written — no text was given.",
+ });
+ }
+ const wrote = await writeRecord(ename, domain, text);
+ return json({
+ stage: "authorised",
+ chain,
+ decision,
+ records: await recordsInDomain(ename, domain),
+ wrote,
+ });
+ }
+
+ // The point of the whole exercise: a permitted read really does go to the
+ // eVault and come back with the records.
+ return json({
+ stage: "authorised",
+ chain,
+ decision,
+ records: await recordsInDomain(ename, domain),
+ wrote: null,
+ });
};
export const GET: RequestHandler = async () =>
From e9dedf337fe7b58fcebdebb97c3b183d7b5b65f8 Mon Sep 17 00:00:00 2001
From: coodos
Date: Sun, 30 Aug 2026 18:48:35 +0800
Subject: [PATCH 11/18] feat: offer every published domain, and permit beside
the request
The domains a platform was not certified for are the ones worth asking
for, so the list is the whole vocabulary with the uncertified ones marked.
Read and write move next to the domain being asked about, replacing the
matrix above it.
Reputation has one service and no threshold, so it is stated rather than
typed.
Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ
---
.../pp-auth-demonstrator.md | 8 +-
docs/docs/W3DS Basics/Access-Policy.md | 4 +-
.../pp-auth-demo/src/lib/RequestTester.svelte | 137 ++++++++++++++----
.../pp-auth-demo/src/lib/TermsForm.svelte | 41 ++----
services/pp-auth-demo/src/lib/server/env.ts | 10 ++
.../src/routes/acl/+page.server.ts | 21 ++-
.../pp-auth-demo/src/routes/acl/+page.svelte | 107 ++------------
.../src/routes/api/terms/+server.ts | 18 +--
.../src/routes/terms/+page.server.ts | 3 +-
.../src/routes/terms/+page.svelte | 6 +-
10 files changed, 183 insertions(+), 172 deletions(-)
diff --git a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md
index ea4827269..ebf07ae1f 100644
--- a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md
+++ b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md
@@ -41,9 +41,11 @@ The table shows every certified platform against every kind of data you hold, de
## Permissions
-Being certified for a kind of data is not permission to do anything with it. This tab is where that is settled: for each certified platform, a read and a write toggle per domain it was certified for. Nothing else is listed, because anything else is refused before permissions are consulted.
+Being certified for a kind of data is not permission to do anything with it. This tab is where that is settled.
-Each change writes an `AccessGrant` into your own eVault as a new revision. Clearing both toggles withdraws the grant rather than deleting it, so the record shows access was taken away rather than never given.
+The domain list is the whole published vocabulary, not just what a platform was certified for — the domains it has no business with are listed too, marked as such, because asking for one and watching the certificate refuse it is the case worth seeing.
+
+Read and write are toggled beside the domain you are asking about. Each change writes an `AccessGrant` into your own eVault as a new revision. Clearing both withdraws the grant rather than deleting it, so the record shows access was taken away rather than never given.
**Deployment keys go in here, before you try anything.** Possession is the one link a reader cannot establish by looking, so whether the key is present decides what a check can even mean. Enter it and the deployment can answer a challenge for real; leave it out and every request stops at the handshake, which is the correct outcome.
@@ -55,7 +57,7 @@ Turn off write and a write is refused while a read still succeeds; withdraw the
## Your terms
-The association says what a platform was found to be; you decide what that is worth. Set the minimum level, whose reputation scores you accept and the score they must reach, and any domain refused outright.
+The association says what a platform was found to be; you decide what that is worth. Set the minimum level and any domain refused outright. The reputation service is named in what you sign but is not a choice: there is one on the network today, so asking you to type its address would only be a way to get it wrong.
Signing goes to your wallet. The signing session id **is** the canonical payload of the statement, so what the wallet signs is exactly the digest of your terms — the signature then verifies against the statement on its own, without anyone trusting this app. The terms are published into your own eVault as an `Access Policy` record, world-readable, and the signature is checked again before the write.
diff --git a/docs/docs/W3DS Basics/Access-Policy.md b/docs/docs/W3DS Basics/Access-Policy.md
index 70db38fd1..ebcdaeb08 100644
--- a/docs/docs/W3DS Basics/Access-Policy.md
+++ b/docs/docs/W3DS Basics/Access-Policy.md
@@ -13,8 +13,8 @@ It is a signed statement rather than a stored setting, so it travels with the ow
| Term | Meaning |
|---|---|
| `minimumLevel` | The weakest certification level they will deal with. A platform certified below it is refused whatever its certificate grants. |
-| `reputationEngine` | Whose reputation scores they accept, as an eName or URL. Blank means reputation is not consulted at all. |
-| `minimumReputation` | The score that engine must report for the platform. Null means no threshold. |
+| `reputationEngine` | Whose reputation scores they accept, as an eName or host. Blank means reputation is not consulted at all. Today the network runs one service, so applications may reasonably fix this rather than ask. |
+| `minimumReputation` | The score that engine must report for the platform. Null means no threshold, which is the common case. |
| `allowedDomains` | Null means "whatever the certificate grants" — the ordinary case. A list narrows it further. |
| `deniedDomains` | Refused outright, overriding both the certificate and the allow list. |
diff --git a/services/pp-auth-demo/src/lib/RequestTester.svelte b/services/pp-auth-demo/src/lib/RequestTester.svelte
index 2cd687ce8..b9d9022ef 100644
--- a/services/pp-auth-demo/src/lib/RequestTester.svelte
+++ b/services/pp-auth-demo/src/lib/RequestTester.svelte
@@ -2,40 +2,93 @@
import ChainTrace from "./ChainTrace.svelte";
import type { ChainResult } from "@metastate-foundation/auth/platform";
+ interface DomainGrant {
+ domain: string;
+ label: string;
+ /** Whether the association certified this platform for this domain. */
+ certified: boolean;
+ read: boolean;
+ write: boolean;
+ revoked: boolean;
+ }
+
let {
+ platformEname,
deployments,
- domains,
+ grants,
+ onchange,
}: {
+ platformEname: string;
deployments: Array<{ ename: string; name: string; environment: string; keyHeld: boolean }>;
- domains: Array<{ domain: string; label: string }>;
+ grants: DomainGrant[];
+ onchange: () => Promise;
} = $props();
let deploymentEname = $state("");
let domain = $state("");
let operation = $state<"read" | "write">("read");
+ let text = $state("");
+
+ let busy = $state(false);
+ let saving = $state(false);
+ let chain = $state(null);
+ let decision = $state<{ allowed: boolean; reason: string; code: string } | null>(null);
+ let stage = $state(null);
+ let missing = $state([]);
+ let records = $state | null>(null);
+ let wrote = $state<{ id: string; kind: string } | null>(null);
+ let note = $state(null);
- // Keep the selection valid as the lists change underneath it. Holding a
- // stale eName would send the request against something no longer listed.
+ // Keep selections valid as the lists change underneath them.
$effect(() => {
if (!deployments.some((entry) => entry.ename === deploymentEname)) {
deploymentEname = deployments[0]?.ename ?? "";
}
});
$effect(() => {
- if (!domains.some((entry) => entry.domain === domain)) {
- domain = domains[0]?.domain ?? "";
+ if (!grants.some((entry) => entry.domain === domain)) {
+ domain = grants.find((entry) => entry.certified)?.domain ?? grants[0]?.domain ?? "";
}
});
- let busy = $state(false);
- let chain = $state(null);
- let decision = $state<{ allowed: boolean; reason: string; code: string } | null>(null);
- let stage = $state(null);
- let missing = $state([]);
- let records = $state | null>(null);
- let wrote = $state<{ id: string; kind: string } | null>(null);
- let note = $state(null);
- let text = $state("");
+ let held = $derived(deployments.find((d) => d.ename === deploymentEname)?.keyHeld ?? false);
+ let selected = $derived(grants.find((entry) => entry.domain === domain) ?? null);
+
+ function clear() {
+ chain = null;
+ decision = null;
+ stage = null;
+ records = null;
+ wrote = null;
+ note = null;
+ }
+
+ async function permit(which: "read" | "write") {
+ if (!selected) return;
+ const next = {
+ read: selected.read,
+ write: selected.write,
+ [which]: !selected[which],
+ };
+ saving = true;
+ try {
+ await fetch("/api/grants", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ platformEname,
+ domain,
+ operations: [
+ ...(next.read ? ["read"] : []),
+ ...(next.write ? ["write"] : []),
+ ],
+ }),
+ });
+ await onchange();
+ } finally {
+ saving = false;
+ }
+ }
async function send() {
busy = true;
@@ -58,8 +111,6 @@
busy = false;
}
}
-
- let held = $derived(deployments.find((d) => d.ename === deploymentEname)?.keyHeld ?? false);
@@ -68,7 +119,7 @@
+ {#if selected}
+
+ {#if selected.certified}
+ You permit it to
+
+
+ {#if !selected.read && !selected.write}
+
+ {selected.revoked ? "withdrawn" : "nothing yet"}
+
+ {/if}
+ {:else}
+
+ Not certified for {selected.label.toLowerCase()} data, so there is
+ nothing to permit — this is refused before permissions are consulted.
+
+ {/if}
+
+ The only reputation service on the network today, so there is nothing to
+ choose. It is named in what you sign, so the record says which service you
+ accepted scores from.
+
{#if domains.length > 0}
diff --git a/services/pp-auth-demo/src/lib/server/env.ts b/services/pp-auth-demo/src/lib/server/env.ts
index 8939ac78f..e091fd31c 100644
--- a/services/pp-auth-demo/src/lib/server/env.ts
+++ b/services/pp-auth-demo/src/lib/server/env.ts
@@ -45,6 +45,16 @@ export function ereputationUrl(): string {
return raw("PPA_EREPUTATION_URL") || "https://ereputation.w3ds.metastate.foundation";
}
+/**
+ * The reputation service whose scores terms are written against.
+ *
+ * There is exactly one, so asking an owner to type its address is asking them
+ * to get it wrong. When a second exists this becomes a choice again.
+ */
+export function reputationEngine(): string {
+ return new URL(ereputationUrl()).host;
+}
+
export function jwtSecret(): string {
return raw("PP_AUTH_DEMO_JWT_SECRET") || raw("PPA_JWT_SECRET") || "pp-auth-demo-dev-secret";
}
diff --git a/services/pp-auth-demo/src/routes/acl/+page.server.ts b/services/pp-auth-demo/src/routes/acl/+page.server.ts
index e789b6c78..1112445c2 100644
--- a/services/pp-auth-demo/src/routes/acl/+page.server.ts
+++ b/services/pp-auth-demo/src/routes/acl/+page.server.ts
@@ -5,9 +5,12 @@ import { held } from "$lib/server/keys";
import type { PageServerLoad } from "./$types";
/**
- * Everything needed to decide, and to see the decision: the certified
- * platforms, the domains they were certified for, and what each has actually
- * been permitted to do.
+ * Everything needed to decide, and to see the decision.
+ *
+ * The domain list is the whole published vocabulary, not just what each
+ * platform was certified for. Offering only the certified ones would hide the
+ * most important case: asking for something a platform has no business with,
+ * and watching the certificate refuse it before permissions are even reached.
*/
export const load: PageServerLoad = async ({ locals }) => {
const ename = locals.user!.ename;
@@ -46,16 +49,18 @@ export const load: PageServerLoad = async ({ locals }) => {
version: deployment.version,
keyHeld: withKeys.has(deployment.deploymentEname),
})),
- grants: (record.domains ?? []).map((domain) => {
+ grants: domains.map((entry) => {
+ const domain = entry.id;
const grant = grants.find(
- (entry) =>
- entry.granteeEName === record.platformEName &&
- entry.resourceType === domain,
+ (held) =>
+ held.granteeEName === record.platformEName &&
+ held.resourceType === domain,
);
const active = grant && grant.status === "active";
return {
domain,
- label: domains.find((d) => d.id === domain)?.label ?? domain,
+ label: entry.label,
+ certified: (record.domains ?? []).includes(domain),
read: Boolean(active && grant!.permissions.includes(`${domain}:Read`)),
write: Boolean(active && grant!.permissions.includes(`${domain}:Write`)),
revoked: Boolean(grant && grant.status === "revoked"),
diff --git a/services/pp-auth-demo/src/routes/acl/+page.svelte b/services/pp-auth-demo/src/routes/acl/+page.svelte
index 3c45d0cc9..24e7e58ee 100644
--- a/services/pp-auth-demo/src/routes/acl/+page.svelte
+++ b/services/pp-auth-demo/src/routes/acl/+page.svelte
@@ -6,37 +6,9 @@
let { data }: { data: PageData } = $props();
- let saving = $state(null);
-
async function refresh() {
await invalidateAll();
}
-
- async function toggle(
- platformEname: string,
- grant: { domain: string; read: boolean; write: boolean },
- which: "read" | "write",
- ) {
- const next = { read: grant.read, write: grant.write, [which]: !grant[which] };
- saving = `${platformEname}:${grant.domain}`;
- try {
- await fetch("/api/grants", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- platformEname,
- domain: grant.domain,
- operations: [
- ...(next.read ? ["read"] : []),
- ...(next.write ? ["write"] : []),
- ],
- }),
- });
- await refresh();
- } finally {
- saving = null;
- }
- }
@@ -45,9 +17,9 @@
What each platform may do
Being certified for a kind of data is not permission to do anything with
- it. Reading your posts is not the same as writing to them, and this is
- where that is decided. Each change is kept in your own eVault as a
- permission record, so nothing is lost when you take access away.
+ it. Reading your posts is not the same as writing to them. Ask for
+ something on this platform's behalf and see what happens — and what comes
+ back out of your eVault when it is allowed.
+ Nothing is deployed from this platform, so there is nothing to ask on
+ its behalf.
+
{/if}
{/each}
diff --git a/services/pp-auth-demo/src/routes/api/terms/+server.ts b/services/pp-auth-demo/src/routes/api/terms/+server.ts
index 47a1bfcc7..919e60445 100644
--- a/services/pp-auth-demo/src/routes/api/terms/+server.ts
+++ b/services/pp-auth-demo/src/routes/api/terms/+server.ts
@@ -5,6 +5,7 @@ import {
type CertificationLevel,
} from "@metastate-foundation/auth/platform";
import { randomUUID } from "node:crypto";
+import { reputationEngine } from "$lib/server/env";
import { prepare } from "$lib/server/policy";
import { createSigningOffer } from "$lib/server/session";
import type { RequestHandler } from "./$types";
@@ -26,20 +27,14 @@ export const POST: RequestHandler = async ({ request, locals, url }) => {
}
const strings = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
- const minimumReputation =
- body.minimumReputation === null || body.minimumReputation === ""
- ? null
- : Number(body.minimumReputation);
- if (minimumReputation !== null && !Number.isFinite(minimumReputation)) {
- return json({ error: "The score must be a number" }, { status: 400 });
- }
const statement = {
...defaultAccessPolicy(ename),
minimumLevel: level,
- reputationEngine:
- typeof body.reputationEngine === "string" ? body.reputationEngine.trim() : "",
- minimumReputation,
+ // Named in the statement so it is on the record which service the owner
+ // accepted scores from, even while there is only one to accept.
+ reputationEngine: reputationEngine(),
+ minimumReputation: null,
allowedDomains: null,
deniedDomains: strings(body.deniedDomains),
issuedAt: new Date().toISOString(),
@@ -52,8 +47,7 @@ export const POST: RequestHandler = async ({ request, locals, url }) => {
{
message: "Set the terms platforms must meet to reach your data",
minimumLevel: statement.minimumLevel,
- reputationEngine: statement.reputationEngine || "not used",
- minimumReputation: statement.minimumReputation ?? "no threshold",
+ reputationFrom: statement.reputationEngine,
refused: statement.deniedDomains.length ? statement.deniedDomains : "nothing",
},
url.origin,
diff --git a/services/pp-auth-demo/src/routes/terms/+page.server.ts b/services/pp-auth-demo/src/routes/terms/+page.server.ts
index c8778b3ce..9b414e17c 100644
--- a/services/pp-auth-demo/src/routes/terms/+page.server.ts
+++ b/services/pp-auth-demo/src/routes/terms/+page.server.ts
@@ -1,4 +1,5 @@
import { listDomains } from "$lib/server/domains";
+import { reputationEngine } from "$lib/server/env";
import { currentPolicy } from "$lib/server/policy";
import type { PageServerLoad } from "./$types";
@@ -8,5 +9,5 @@ export const load: PageServerLoad = async ({ locals }) => {
currentPolicy(ename),
listDomains().catch(() => []),
]);
- return { ename, policy, domains };
+ return { ename, policy, domains, reputationEngine: reputationEngine() };
};
diff --git a/services/pp-auth-demo/src/routes/terms/+page.svelte b/services/pp-auth-demo/src/routes/terms/+page.svelte
index 7c6a9e39e..76516bf79 100644
--- a/services/pp-auth-demo/src/routes/terms/+page.svelte
+++ b/services/pp-auth-demo/src/routes/terms/+page.svelte
@@ -28,7 +28,11 @@
{#key data.policy.statement.nonce}
-
+
{/key}
{#if data.policy.signed}
From 8451e0edbe58b2d0035230e15c90aad78b207bcc Mon Sep 17 00:00:00 2001
From: coodos
Date: Sun, 30 Aug 2026 18:51:20 +0800
Subject: [PATCH 12/18] fix: drop the permission toggles from the request panel
Grants are the platform's to manage through the API; the panel sends
requests and shows what came back.
Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ
---
.../pp-auth-demonstrator.md | 8 +-
.../pp-auth-demo/src/lib/RequestTester.svelte | 73 +------------------
.../pp-auth-demo/src/routes/acl/+page.svelte | 2 -
3 files changed, 8 insertions(+), 75 deletions(-)
diff --git a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md
index ebf07ae1f..a25e721db 100644
--- a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md
+++ b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md
@@ -45,7 +45,13 @@ Being certified for a kind of data is not permission to do anything with it. Thi
The domain list is the whole published vocabulary, not just what a platform was certified for — the domains it has no business with are listed too, marked as such, because asking for one and watching the certificate refuse it is the case worth seeing.
-Read and write are toggled beside the domain you are asking about. Each change writes an `AccessGrant` into your own eVault as a new revision. Clearing both withdraws the grant rather than deleting it, so the record shows access was taken away rather than never given.
+Grants are managed by the platform through `POST /api/grants`, not set by hand here — the page shows what happens under them. Each change writes an `AccessGrant` into the owner's eVault as a new revision; clearing both operations withdraws the grant rather than deleting it, so the record shows access was taken away rather than never given.
+
+```bash
+curl -X POST http://localhost:4310/api/grants \
+ -H 'Content-Type: application/json' \
+ -d '{"platformEname":"@…","domain":"social","operations":["read"]}'
+```
**Deployment keys go in here, before you try anything.** Possession is the one link a reader cannot establish by looking, so whether the key is present decides what a check can even mean. Enter it and the deployment can answer a challenge for real; leave it out and every request stops at the handshake, which is the correct outcome.
diff --git a/services/pp-auth-demo/src/lib/RequestTester.svelte b/services/pp-auth-demo/src/lib/RequestTester.svelte
index b9d9022ef..b2a1004e5 100644
--- a/services/pp-auth-demo/src/lib/RequestTester.svelte
+++ b/services/pp-auth-demo/src/lib/RequestTester.svelte
@@ -7,21 +7,15 @@
label: string;
/** Whether the association certified this platform for this domain. */
certified: boolean;
- read: boolean;
- write: boolean;
- revoked: boolean;
}
let {
- platformEname,
deployments,
grants,
- onchange,
}: {
- platformEname: string;
deployments: Array<{ ename: string; name: string; environment: string; keyHeld: boolean }>;
+ /** Domains to offer, and whether the platform was certified for each. */
grants: DomainGrant[];
- onchange: () => Promise;
} = $props();
let deploymentEname = $state("");
@@ -30,7 +24,6 @@
let text = $state("");
let busy = $state(false);
- let saving = $state(false);
let chain = $state(null);
let decision = $state<{ allowed: boolean; reason: string; code: string } | null>(null);
let stage = $state(null);
@@ -52,7 +45,6 @@
});
let held = $derived(deployments.find((d) => d.ename === deploymentEname)?.keyHeld ?? false);
- let selected = $derived(grants.find((entry) => entry.domain === domain) ?? null);
function clear() {
chain = null;
@@ -63,33 +55,6 @@
note = null;
}
- async function permit(which: "read" | "write") {
- if (!selected) return;
- const next = {
- read: selected.read,
- write: selected.write,
- [which]: !selected[which],
- };
- saving = true;
- try {
- await fetch("/api/grants", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- platformEname,
- domain,
- operations: [
- ...(next.read ? ["read"] : []),
- ...(next.write ? ["write"] : []),
- ],
- }),
- });
- await onchange();
- } finally {
- saving = false;
- }
- }
-
async function send() {
busy = true;
try {
@@ -146,42 +111,6 @@
- {#if selected}
-
- {#if selected.certified}
- You permit it to
-
-
- {#if !selected.read && !selected.write}
-
- {selected.revoked ? "withdrawn" : "nothing yet"}
-
- {/if}
- {:else}
-
- Not certified for {selected.label.toLowerCase()} data, so there is
- nothing to permit — this is refused before permissions are consulted.
-
- {/if}
-