+ 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 — enter it above and check again.
+
+ {#each LEVELS as level (level.id)}
+
+ {/each}
+
+
+
+
+
+ Whose reputation scores you trust
+
+
{reputationEngine}
+
+ 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}
+
+
+ Things nobody gets, whatever their certificate says
+
+
+ {#each domains as domain (domain.id)}
+
+ {/each}
+
+
+ {/if}
+
+
+
+ {#if done}
+ Signed and published to your eVault.
+ {/if}
+ {#if error}
+ {error}
+ {/if}
+
+
+ {#if uri}
+
+
Approve these terms in your wallet.
+
+
+
+
+ {/if}
+
diff --git a/services/pp-auth-demo/src/lib/domains.ts b/services/pp-auth-demo/src/lib/domains.ts
new file mode 100644
index 000000000..be12a3912
--- /dev/null
+++ b/services/pp-auth-demo/src/lib/domains.ts
@@ -0,0 +1,14 @@
+/**
+ * The domains this demonstration deals in.
+ *
+ * These four ids are taken from the published Domain vocabulary
+ * (services/ontology/schemas/domain.json), which is what every schema tags
+ * itself with and what a certificate grants. The full list is twenty; four is
+ * enough to show separation without turning the page into a wall of buttons.
+ */
+export const DOMAINS = [
+ { id: "social", label: "Social" },
+ { id: "communication", label: "Communication" },
+ { id: "finance", label: "Finance" },
+ { id: "health", label: "Health" },
+] as const;
diff --git a/services/pp-auth-demo/src/lib/server/aaas.ts b/services/pp-auth-demo/src/lib/server/aaas.ts
new file mode 100644
index 000000000..3eb443e56
--- /dev/null
+++ b/services/pp-auth-demo/src/lib/server/aaas.ts
@@ -0,0 +1,174 @@
+/**
+ * Everything this app knows about the network comes from Awareness-as-a-Service.
+ *
+ * Accreditations, deployment profiles and platform profiles each have their own
+ * ontology, so they can be asked for directly rather than scanned for.
+ */
+
+import { awarenessApiKey, awarenessUrl } from "./env";
+import {
+ DEPLOYMENT_PROFILE_ONTOLOGY,
+ PLATFORM_ACCREDITATION_ONTOLOGY,
+ USER_ONTOLOGY,
+ type AccreditationRecord,
+ type DeploymentRecord,
+} from "./ontology";
+
+interface Packet {
+ id: string;
+ ontology: string;
+ w3id: string | null;
+ data: Record | null;
+ receivedAt: string;
+}
+
+export function isConfigured(): boolean {
+ return Boolean(awarenessApiKey());
+}
+
+async function packets(params: Record): Promise {
+ if (!isConfigured()) return [];
+ const out: Packet[] = [];
+ let cursor: string | null = null;
+ do {
+ const query = new URLSearchParams({ limit: "500", ...params });
+ if (cursor) query.set("cursor", cursor);
+ const res = await fetch(`${awarenessUrl().replace(/\/$/, "")}/api/packets?${query}`, {
+ headers: { Authorization: `Bearer ${awarenessApiKey()}` },
+ signal: AbortSignal.timeout(30_000),
+ });
+ if (!res.ok) {
+ throw new Error(`AaaS /api/packets returned ${res.status}`);
+ }
+ const body = (await res.json()) as {
+ packets?: Packet[];
+ hasMore?: boolean;
+ nextCursor?: string | null;
+ };
+ out.push(...(body.packets ?? []));
+ cursor = body.hasMore ? (body.nextCursor ?? null) : null;
+ } while (cursor);
+ return out;
+}
+
+/** Short cache: these reads back every page and the data changes rarely. */
+const TTL_MS = 30_000;
+const CACHE = Symbol.for("pp-auth-demo.aaas");
+const store = globalThis as typeof globalThis & {
+ [CACHE]?: Map;
+};
+store[CACHE] ??= new Map();
+
+async function cached(key: string, load: () => Promise): Promise {
+ const entry = store[CACHE]!.get(key);
+ if (entry && Date.now() - entry.at < TTL_MS) return entry.value as T;
+ const value = await load();
+ store[CACHE]!.set(key, { at: Date.now(), value });
+ return value;
+}
+
+export function invalidate(): void {
+ store[CACHE]!.clear();
+}
+
+function str(value: unknown): string {
+ return typeof value === "string" ? value : "";
+}
+
+/**
+ * Every certification decision on the network, newest first.
+ *
+ * A decision covers one platform version, and a version can be refused and
+ * reapply, so several may exist for the same release.
+ */
+export async function accreditations(): Promise {
+ return cached("accreditations", async () => {
+ const found = await packets({ ontology: PLATFORM_ACCREDITATION_ONTOLOGY });
+ return found
+ .map((packet) => packet.data)
+ .filter(
+ (data): data is AccreditationRecord =>
+ Boolean(data) &&
+ typeof data!.platformEName === "string" &&
+ typeof data!.jws === "string",
+ )
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
+ });
+}
+
+/** Every deployment published on the network. */
+export async function deployments(): Promise {
+ return cached("deployments", async () => {
+ const found = await packets({ ontology: DEPLOYMENT_PROFILE_ONTOLOGY });
+ const byEname = new Map();
+ for (const packet of found) {
+ const data = packet.data;
+ if (!data || !str(data.deploymentEname)) continue;
+ byEname.set(str(data.deploymentEname), data as DeploymentRecord);
+ }
+ return [...byEname.values()];
+ });
+}
+
+export interface PlatformProfile {
+ ename: string;
+ platformName: string;
+ displayName: string;
+ description: string;
+ version: string;
+ logoUrl: string | null;
+ url: string;
+ /** Every release proof the platform retains, so an older deployment resolves. */
+ proofs: Array>;
+}
+
+/** One platform's own profile, read from its eVault. */
+export async function platformProfile(ename: string): Promise {
+ return cached(`profile:${ename}`, async () => {
+ const found = await packets({ evault: ename, ontology: USER_ONTOLOGY });
+ const data = found
+ .map((packet) => packet.data)
+ .filter((d): d is Record => Boolean(d) && Boolean(str(d!.platformName)))
+ .at(-1);
+ if (!data) return null;
+ const proofs = [
+ ...(Array.isArray(data.submissionHistory) ? data.submissionHistory : []),
+ data.submissionProof,
+ ].filter((proof) => proof && typeof proof === "object" && proof.statement);
+ return {
+ ename,
+ platformName: str(data.platformName),
+ displayName: str(data.displayName) || str(data.platformName),
+ description: str(data.description),
+ version: str(data.version),
+ logoUrl: str(data.logoUrl) || null,
+ url: str(data.url),
+ proofs,
+ };
+ });
+}
+
+/** A person's profile, for showing who deployed something. */
+export async function personProfile(
+ ename: string,
+): Promise<{ ename: string; displayName: string; avatarUrl: string | null }> {
+ return cached(`person:${ename}`, async () => {
+ const fallback = { ename, displayName: ename, avatarUrl: null };
+ try {
+ const found = await packets({ evault: ename, ontology: USER_ONTOLOGY });
+ const data = found
+ .map((packet) => packet.data)
+ .filter((d): d is Record => Boolean(d) && !str(d!.platformName))
+ .at(-1);
+ if (!data) return fallback;
+ return {
+ ename,
+ displayName:
+ str(data.displayName) || str(data.name) || str(data.username) || ename,
+ avatarUrl: str(data.avatarUrl) || str(data.avatar) || null,
+ };
+ } catch {
+ return fallback;
+ }
+ });
+}
diff --git a/services/pp-auth-demo/src/lib/server/chain.ts b/services/pp-auth-demo/src/lib/server/chain.ts
new file mode 100644
index 000000000..28c28e866
--- /dev/null
+++ b/services/pp-auth-demo/src/lib/server/chain.ts
@@ -0,0 +1,198 @@
+/**
+ * Assembles real evidence for a real deployment, and verifies it.
+ *
+ * Nothing here is manufactured. The deployment profile and the certificate come
+ * from the awareness network, the binding documents from the deployment's own
+ * eVault, and the release proof from the platform's profile. The only thing the
+ * verifier cannot obtain by reading is the deployment's private key, which is
+ * the point of the possession link.
+ */
+
+import {
+ answerChallenge,
+ verifyDeploymentChain,
+ type ChainResult,
+ type DeploymentEvidence,
+ type HandshakeChallenge,
+} from "@metastate-foundation/auth/platform";
+import { randomUUID } from "node:crypto";
+import { verifySignature } from "signature-validator/src/index";
+import { accreditations, deployments, platformProfile } from "./aaas";
+import { bindingDocuments } from "./evault";
+import { registryUrl } from "./env";
+import type { AccreditationRecord, DeploymentRecord } from "./ontology";
+
+/**
+ * Wallet signatures are resolved through the registry, the same way every other
+ * service in the network checks one.
+ */
+async function verifyWalletSignature(
+ signer: string,
+ signature: string,
+ payload: string,
+): Promise {
+ try {
+ const result = await verifySignature({
+ eName: signer,
+ signature,
+ payload,
+ registryBaseUrl: registryUrl(),
+ });
+ return result.valid === true;
+ } catch {
+ return false;
+ }
+}
+
+export interface AssembledEvidence {
+ evidence: DeploymentEvidence | null;
+ /** What could not be found, in words, when evidence is incomplete. */
+ missing: string[];
+ accreditation: AccreditationRecord | null;
+ deployment: DeploymentRecord;
+}
+
+/** The decision in force for one platform release: newest record wins. */
+export function accreditationFor(
+ records: AccreditationRecord[],
+ platformEname: string,
+ version: string,
+): AccreditationRecord | null {
+ return (
+ records.find(
+ (record) =>
+ record.platformEName === platformEname &&
+ record.platformVersion === version,
+ ) ?? null
+ );
+}
+
+export async function assemble(
+ deployment: DeploymentRecord,
+): Promise {
+ const missing: string[] = [];
+ const [records, profile, docs] = await Promise.all([
+ accreditations(),
+ platformProfile(deployment.platformEname),
+ bindingDocuments(deployment.deploymentEname),
+ ]);
+
+ const accreditation = accreditationFor(
+ records,
+ deployment.platformEname,
+ deployment.version,
+ );
+ if (!accreditation) {
+ missing.push(`no certification decision for version ${deployment.version}`);
+ }
+
+ const keyDoc = docs.find((doc) => doc.type === "deployment_key");
+ const versionDoc = docs.find((doc) => doc.type === "software_version");
+ if (!keyDoc) missing.push("the deployment's key document is not readable");
+ if (!versionDoc) missing.push("the deployment's release document is not readable");
+
+ // The platform profile carries its LATEST release proof, but a deployment
+ // may be running an older one, so match on the version actually deployed
+ // rather than taking whatever is current.
+ const proof = profile?.proofs.filter(
+ (entry) => entry?.statement?.version === deployment.version,
+ ).at(-1);
+ if (!proof) {
+ missing.push(`no signed release proof for version ${deployment.version}`);
+ }
+
+ if (!accreditation || !keyDoc || !versionDoc || !proof) {
+ return { evidence: null, missing, accreditation, deployment };
+ }
+
+ return {
+ missing,
+ accreditation,
+ deployment,
+ evidence: {
+ deploymentEname: deployment.deploymentEname,
+ deploymentName: deployment.deploymentName,
+ environment: deployment.environment,
+ deployerEname: deployment.deployerEname,
+ platformEname: deployment.platformEname,
+ versionEname: deployment.versionEname,
+ version: deployment.version,
+ releaseTag: deployment.releaseTag,
+ commitSha: deployment.commitSha,
+ publicKey: deployment.publicKey,
+ deploymentKeyDocument: keyDoc as never,
+ softwareVersionDocument: versionDoc as never,
+ accreditationJws: accreditation.jws,
+ issuerJwksUri: accreditation.issuerJwksUri,
+ submissionProof: proof as never,
+ },
+ };
+}
+
+export function challengeFor(audience: string): HandshakeChallenge {
+ const now = Date.now();
+ return {
+ nonce: randomUUID(),
+ audience,
+ issuedAt: new Date(now).toISOString(),
+ expiresAt: new Date(now + 120_000).toISOString(),
+ };
+}
+
+/**
+ * Verifies a deployment's chain.
+ *
+ * When the operator has supplied that deployment's private key, the challenge
+ * is answered for real and all six links are checked. Without it the signature
+ * is one this app makes with a throwaway key: possession then fails, correctly,
+ * and the remaining five links are still checked against real evidence.
+ */
+export async function verify(
+ evidence: DeploymentEvidence,
+ audience: string,
+ privateKey: string | null,
+): Promise<{ chain: ChainResult; possessionProven: boolean }> {
+ const challenge = challengeFor(audience);
+ const response = privateKey
+ ? await answerChallenge({ evidence, privateKey }, challenge)
+ : { challenge, evidence, signature: "" };
+
+ const chain = await verifyDeploymentChain(response, {
+ audience,
+ registryBaseUrl: registryUrl(),
+ verifyWalletSignature,
+ });
+
+ // With no key there was nothing to check, which is not the same as a check
+ // that failed. Saying "the signature did not verify" would suggest the
+ // deployment presented something wrong rather than that we never asked it.
+ if (!privateKey) {
+ const possession = chain.links.find((link) => link.id === "possession");
+ if (possession) {
+ possession.detail =
+ "Not attempted — this is a reader, not the deployment, so it holds no key to answer with.";
+ }
+ }
+
+ return { chain, possessionProven: Boolean(privateKey) };
+}
+
+/** Deployments grouped under the platform they belong to. */
+export async function network(): Promise<
+ Map
+> {
+ const all = await deployments();
+ const byPlatform = new Map<
+ string,
+ { platform: string; deployments: DeploymentRecord[] }
+ >();
+ for (const deployment of all) {
+ const entry = byPlatform.get(deployment.platformEname) ?? {
+ platform: deployment.platformEname,
+ deployments: [],
+ };
+ entry.deployments.push(deployment);
+ byPlatform.set(deployment.platformEname, entry);
+ }
+ return byPlatform;
+}
diff --git a/services/pp-auth-demo/src/lib/server/data.ts b/services/pp-auth-demo/src/lib/server/data.ts
new file mode 100644
index 000000000..3c392725d
--- /dev/null
+++ b/services/pp-auth-demo/src/lib/server/data.ts
@@ -0,0 +1,183 @@
+/**
+ * The signed-in owner's own records, grouped by the domain each one falls under.
+ *
+ * Every schema declares its domain, so the grouping is the ontology's, not
+ * ours: this is exactly the partition a certificate grants against.
+ */
+
+import { envelopes, store_ } from "./evault";
+import { listDomains, listSchemas } from "./domains";
+
+export interface OwnedRecord {
+ id: string;
+ /** The schema's human title, e.g. "Social Media Post". */
+ kind: string;
+ summary: string;
+}
+
+export interface DomainGroup {
+ id: string;
+ label: string;
+ description: string;
+ records: OwnedRecord[];
+}
+
+/**
+ * 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 text = [
+ "text", "content", "body", "message", "title", "name",
+ "displayName", "description", "summary", "label",
+ ];
+ 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(
+ ([key, value]) =>
+ typeof value === "string" && value.trim().length > 0 && !skip.test(key),
+ );
+ return first
+ ? `${first[0]}: ${String(first[1]).slice(0, 140)}`
+ : "(a record with no readable text)";
+}
+
+/**
+ * Everything the owner holds, by domain.
+ *
+ * Each schema is queried separately because that is the only way an eVault can
+ * be asked for records; they run together so the page does not wait on them in
+ * series. A schema the vault holds nothing of simply contributes nothing.
+ */
+export async function ownedByDomain(ename: string): Promise {
+ const [schemas, domains] = await Promise.all([listSchemas(), listDomains()]);
+ if (schemas.length === 0) return [];
+
+ const byDomain = new Map();
+
+ const results = await Promise.all(
+ schemas.map(async (schema) => {
+ const found = await envelopes(ename, schema.id, 10).catch(() => []);
+ return { schema, found };
+ }),
+ );
+
+ for (const { schema, found } of results) {
+ if (found.length === 0) continue;
+ const list = byDomain.get(schema.domain) ?? [];
+ for (const record of found) {
+ list.push({
+ id: record.id,
+ kind: schema.title,
+ summary: summarise(record.parsed),
+ });
+ }
+ byDomain.set(schema.domain, list);
+ }
+
+ return [...byDomain.entries()]
+ .map(([id, records]) => {
+ const domain = domains.find((d) => d.id === id);
+ return {
+ id,
+ label: domain?.label ?? id,
+ description: domain?.description ?? "",
+ records: records.slice(0, 12),
+ };
+ })
+ .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/lib/server/domains.ts b/services/pp-auth-demo/src/lib/server/domains.ts
new file mode 100644
index 000000000..0a82d8e57
--- /dev/null
+++ b/services/pp-auth-demo/src/lib/server/domains.ts
@@ -0,0 +1,66 @@
+/**
+ * The domain vocabulary, and which domain each ontology belongs to.
+ *
+ * Owned by the ontology service, not by this app: every schema declares the
+ * domain it belongs to, so granting a domain is what decides which record
+ * types a platform may touch.
+ */
+
+import { ontologyUrl } from "./env";
+
+export interface Domain {
+ id: string;
+ label: string;
+ description: string;
+}
+
+export interface Schema {
+ id: string;
+ title: string;
+ domain: string;
+}
+
+const TTL_MS = 30 * 60_000;
+const STORE = Symbol.for("pp-auth-demo.ontology");
+const store = globalThis as typeof globalThis & {
+ [STORE]?: { at: number; domains: Domain[]; schemas: Schema[] };
+};
+
+async function load(): Promise<{ domains: Domain[]; schemas: Schema[] }> {
+ const cached = store[STORE];
+ if (cached && Date.now() - cached.at < TTL_MS) return cached;
+
+ const base = ontologyUrl();
+ const [domains, schemas] = await Promise.all([
+ fetch(new URL("/domains", base), { signal: AbortSignal.timeout(15_000) })
+ .then((r) => (r.ok ? r.json() : { domains: [] }))
+ .then((b) => (b.domains ?? []) as Domain[])
+ .catch(() => [] as Domain[]),
+ fetch(new URL("/schemas", base), { signal: AbortSignal.timeout(15_000) })
+ .then((r) => (r.ok ? r.json() : []))
+ .then((b) =>
+ (Array.isArray(b) ? b : [])
+ .filter((s: any) => s?.id && s?.domain)
+ .map((s: any) => ({ id: s.id, title: s.title ?? s.id, domain: s.domain })),
+ )
+ .catch(() => [] as Schema[]),
+ ]);
+
+ const value = { at: Date.now(), domains, schemas };
+ if (domains.length > 0) store[STORE] = value;
+ return value;
+}
+
+export async function listDomains(): Promise {
+ return (await load()).domains;
+}
+
+export async function listSchemas(): Promise {
+ return (await load()).schemas;
+}
+
+/** Domain of one ontology, or null when the ontology is unknown here. */
+export async function domainOf(ontologyId: string): Promise {
+ const { schemas } = await load();
+ return schemas.find((schema) => schema.id === ontologyId)?.domain ?? null;
+}
diff --git a/services/pp-auth-demo/src/lib/server/env.ts b/services/pp-auth-demo/src/lib/server/env.ts
new file mode 100644
index 000000000..e091fd31c
--- /dev/null
+++ b/services/pp-auth-demo/src/lib/server/env.ts
@@ -0,0 +1,63 @@
+import path from "node:path";
+import { config as loadEnv } from "dotenv";
+import { env } from "$env/dynamic/private";
+
+/**
+ * Configuration, read from the repo-root .env in one place.
+ *
+ * Deliberately avoids `$env/dynamic/public`: several shared variables in this
+ * monorepo carry SvelteKit's PUBLIC_ prefix, and importing that module
+ * serialises the whole public block — every service URL and credential — into
+ * the HTML of every page. Nothing here is needed in the browser.
+ */
+
+// cwd is services/pp-auth-demo under both `vite dev` and `node build/index.js`.
+loadEnv({ path: path.resolve(process.cwd(), "../../.env") });
+
+function raw(name: string): string {
+ return (env[name] ?? process.env[name] ?? "").trim();
+}
+
+/** Public base URL of this app — the w3ds:// callback target. */
+export function publicUrl(): string {
+ return raw("PP_AUTH_DEMO_PUBLIC_URL") || "http://localhost:4310";
+}
+
+export function registryUrl(): string {
+ const url = raw("REGISTRY_URL") || raw("PUBLIC_REGISTRY_URL");
+ if (!url) throw new Error("PUBLIC_REGISTRY_URL is required");
+ return url;
+}
+
+export function awarenessUrl(): string {
+ return raw("AWARENESS_SERVICE_URL") || "https://aaas.w3ds.metastate.foundation";
+}
+
+export function awarenessApiKey(): string {
+ return raw("PP_AUTH_DEMO_AWARENESS_API_KEY") || raw("PPA_AWARENESS_API_KEY") || raw("AWARENESS_API_KEY");
+}
+
+export function ontologyUrl(): string {
+ return raw("PUBLIC_ONTOLOGY_URL") || "https://ontology.w3ds.metastate.foundation";
+}
+
+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";
+}
+
+/** Name this app presents to the registry when minting its read token. */
+export const PLATFORM_NAME = "pp-auth-demo";
diff --git a/services/pp-auth-demo/src/lib/server/evault.ts b/services/pp-auth-demo/src/lib/server/evault.ts
new file mode 100644
index 000000000..e05216198
--- /dev/null
+++ b/services/pp-auth-demo/src/lib/server/evault.ts
@@ -0,0 +1,196 @@
+/**
+ * Reads from real eVaults: the registry resolves an eName to a vault, and a
+ * platform token opens it.
+ *
+ * That token is exactly the bypass PP Auth exists to replace — the registry
+ * mints one for any name that asks, and eVault honours it against any vault.
+ * This app uses it to *read* evidence that is already public, and says so
+ * rather than pretending it has earned the access.
+ */
+
+import { GraphQLClient, gql } from "graphql-request";
+import { PLATFORM_NAME, registryUrl } from "./env";
+
+const BINDING_DOCUMENTS = gql`
+ query BindingDocuments {
+ bindingDocuments(first: 50) {
+ edges {
+ node {
+ id
+ parsed
+ }
+ }
+ }
+ }
+`;
+
+const ENVELOPES = gql`
+ query Envelopes($ontologyId: ID!, $first: Int!) {
+ metaEnvelopes(filter: { ontologyId: $ontologyId }, first: $first) {
+ edges {
+ node {
+ id
+ parsed
+ }
+ }
+ }
+ }
+`;
+
+const CREATE = gql`
+ mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) {
+ createMetaEnvelope(input: $input) {
+ metaEnvelope {
+ id
+ }
+ errors {
+ field
+ message
+ }
+ }
+ }
+`;
+
+const TOKEN = Symbol.for("pp-auth-demo.platformToken");
+const URLS = Symbol.for("pp-auth-demo.evaultUrls");
+const store = globalThis as typeof globalThis & {
+ [TOKEN]?: Promise;
+ [URLS]?: Map;
+};
+store[URLS] ??= new Map();
+
+export function normalizeEName(value: string): string {
+ const trimmed = value.trim();
+ if (!trimmed) return "";
+ return trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
+}
+
+async function platformToken(): Promise {
+ store[TOKEN] ??= (async () => {
+ const res = await fetch(new URL("/platforms/certification", registryUrl()), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ platform: PLATFORM_NAME }),
+ signal: AbortSignal.timeout(15_000),
+ });
+ if (!res.ok) throw new Error(`registry token request returned ${res.status}`);
+ const body = (await res.json()) as { token?: string };
+ if (!body.token) throw new Error("registry returned no token");
+ return body.token;
+ })().catch((error) => {
+ // Do not cache a failure: the next request should try again.
+ store[TOKEN] = undefined;
+ throw error;
+ });
+ return store[TOKEN];
+}
+
+export async function resolveVault(ename: string): Promise {
+ const normalized = normalizeEName(ename);
+ const cached = store[URLS]!.get(normalized);
+ if (cached) return cached;
+ try {
+ const res = await fetch(
+ new URL(`/resolve?w3id=${encodeURIComponent(normalized)}`, registryUrl()),
+ { signal: AbortSignal.timeout(15_000) },
+ );
+ if (!res.ok) return null;
+ const body = (await res.json()) as { evaultUrl?: string; uri?: string };
+ const url = body.evaultUrl || body.uri;
+ if (!url) return null;
+ store[URLS]!.set(normalized, url);
+ return url;
+ } catch {
+ return null;
+ }
+}
+
+async function client(ename: string): Promise {
+ const normalized = normalizeEName(ename);
+ const [url, token] = await Promise.all([
+ resolveVault(normalized),
+ platformToken(),
+ ]);
+ if (!url) return null;
+ return new GraphQLClient(new URL("/graphql", url).toString(), {
+ headers: { Authorization: `Bearer ${token}`, "X-ENAME": normalized },
+ });
+}
+
+export interface RawBindingDocument {
+ id: string;
+ subject: string;
+ type: string;
+ data: Record;
+ signatures: Array>;
+}
+
+/** The binding documents held in one eVault. */
+export async function bindingDocuments(
+ ename: string,
+): Promise {
+ const gqlClient = await client(ename);
+ if (!gqlClient) return [];
+ try {
+ const res = await gqlClient.request<{
+ bindingDocuments: {
+ edges: Array<{ node: { id: string; parsed: Record | null } }>;
+ };
+ }>(BINDING_DOCUMENTS);
+ return res.bindingDocuments.edges
+ .map((edge) => {
+ const parsed = edge.node.parsed;
+ if (!parsed || typeof parsed !== "object") return null;
+ return { id: edge.node.id, ...parsed } as RawBindingDocument;
+ })
+ .filter((doc): doc is RawBindingDocument => doc !== null);
+ } catch (error) {
+ console.warn(`[pp-auth-demo] could not read binding documents for ${ename}:`, error);
+ return [];
+ }
+}
+
+/** MetaEnvelopes of one ontology held in one eVault. */
+export async function envelopes(
+ ename: string,
+ ontologyId: string,
+ first = 25,
+): Promise }>> {
+ const gqlClient = await client(ename);
+ if (!gqlClient) return [];
+ try {
+ const res = await gqlClient.request<{
+ metaEnvelopes: {
+ edges: Array<{ node: { id: string; parsed: Record | null } }>;
+ };
+ }>(ENVELOPES, { ontologyId, first });
+ return res.metaEnvelopes.edges
+ .filter((edge) => edge.node.parsed && typeof edge.node.parsed === "object")
+ .map((edge) => ({ id: edge.node.id, parsed: edge.node.parsed! }));
+ } catch {
+ // A vault holding nothing of this ontology errors on some deployments.
+ return [];
+ }
+}
+
+/** Writes one record into an eVault. Used only for the owner's own terms. */
+export async function store_(
+ ename: string,
+ ontologyId: string,
+ payload: Record,
+ acl: string[],
+): Promise {
+ const gqlClient = await client(ename);
+ if (!gqlClient) throw new Error(`could not resolve an eVault for ${ename}`);
+ const res = await gqlClient.request<{
+ createMetaEnvelope: {
+ metaEnvelope: { id: string } | null;
+ errors: Array<{ message: string }> | null;
+ };
+ }>(CREATE, { input: { ontology: ontologyId, payload, acl } });
+ const errors = res.createMetaEnvelope.errors;
+ if (errors?.length) throw new Error(errors.map((e) => e.message).join("; "));
+ const id = res.createMetaEnvelope.metaEnvelope?.id;
+ if (!id) throw new Error("eVault accepted the write but returned no id");
+ return id;
+}
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/keys.ts b/services/pp-auth-demo/src/lib/server/keys.ts
new file mode 100644
index 000000000..e626542eb
--- /dev/null
+++ b/services/pp-auth-demo/src/lib/server/keys.ts
@@ -0,0 +1,29 @@
+/**
+ * Deployment private keys the operator has supplied, held in memory only.
+ *
+ * A deployment's private key is the whole of the possession proof, so this app
+ * never writes one to disk, never logs it, and forgets all of them on restart.
+ * It accepts one at all because the person running this demonstration is, for
+ * these deployments, the deployer — supplying the key is how they prove the
+ * possession link rather than watch it fail.
+ */
+
+const STORE = Symbol.for("pp-auth-demo.deploymentKeys");
+const store = globalThis as typeof globalThis & { [STORE]?: Map };
+const keys: Map = (store[STORE] ??= new Map());
+
+export function remember(deploymentEname: string, privateKey: string): void {
+ keys.set(deploymentEname, privateKey.trim());
+}
+
+export function forget(deploymentEname: string): void {
+ keys.delete(deploymentEname);
+}
+
+export function keyFor(deploymentEname: string): string | null {
+ return keys.get(deploymentEname) ?? null;
+}
+
+export function held(): string[] {
+ return [...keys.keys()];
+}
diff --git a/services/pp-auth-demo/src/lib/server/ontology.ts b/services/pp-auth-demo/src/lib/server/ontology.ts
new file mode 100644
index 000000000..44180ff91
--- /dev/null
+++ b/services/pp-auth-demo/src/lib/server/ontology.ts
@@ -0,0 +1,36 @@
+/** Ontology ids this app reads, and the vocabulary they belong to. */
+
+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;
+ platformEName: string;
+ platformName: string;
+ platformVersion: string;
+ decision: "granted" | "denied";
+ level: string | null;
+ domains: string[];
+ statement: string;
+ reviewedByEName: string;
+ issuerJwksUri: string;
+ jws: string;
+ createdAt: string;
+}
+
+export interface DeploymentRecord {
+ deploymentEname: string;
+ deploymentName: string;
+ environment: string;
+ deployerEname: string;
+ platformEname: string;
+ versionEname: string;
+ version: string;
+ releaseTag: string;
+ commitSha: string;
+ publicKey: string;
+ createdAt: string;
+}
diff --git a/services/pp-auth-demo/src/lib/server/policy.ts b/services/pp-auth-demo/src/lib/server/policy.ts
new file mode 100644
index 000000000..f3fad7c26
--- /dev/null
+++ b/services/pp-auth-demo/src/lib/server/policy.ts
@@ -0,0 +1,131 @@
+/**
+ * The owner's terms, read from and written to their own eVault.
+ *
+ * The record is a signed statement, so a reader checks the signature rather
+ * than trusting this app to have reported it faithfully. Records are
+ * append-only; the newest valid statement for the owner is the one in force.
+ */
+
+import {
+ accessPolicyPayload,
+ defaultAccessPolicy,
+ parseAccessPolicy,
+ verifyAccessPolicy,
+ type AccessPolicyStatement,
+ type SignedAccessPolicy,
+} from "@metastate-foundation/auth/platform";
+import { verifySignature } from "signature-validator/src/index";
+import { registryUrl } from "./env";
+import { envelopes, store_ } from "./evault";
+import { ACCESS_POLICY_ONTOLOGY } from "./ontology";
+
+export interface LoadedPolicy {
+ statement: AccessPolicyStatement;
+ /** False when nothing has been signed yet and the default applies. */
+ signed: boolean;
+ signature: string | null;
+ issuedAt: string | null;
+}
+
+async function walletVerifier(
+ signer: string,
+ signature: string,
+ payload: string,
+): Promise {
+ try {
+ const result = await verifySignature({
+ eName: signer,
+ signature,
+ payload,
+ registryBaseUrl: registryUrl(),
+ });
+ return result.valid === true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * The terms in force for one owner.
+ *
+ * A record whose signature does not verify is ignored rather than trusted: an
+ * unverifiable policy is somebody's claim about what the owner wanted, and
+ * falling back to the default is the safer reading.
+ */
+export async function currentPolicy(ename: string): Promise {
+ const fallback: LoadedPolicy = {
+ statement: defaultAccessPolicy(ename),
+ signed: false,
+ signature: null,
+ issuedAt: null,
+ };
+
+ let records: Array<{ id: string; parsed: Record }>;
+ try {
+ records = await envelopes(ename, ACCESS_POLICY_ONTOLOGY, 50);
+ } catch (error) {
+ console.warn(`[pp-auth-demo] could not read terms for ${ename}:`, error);
+ return fallback;
+ }
+
+ const candidates = records
+ .map((record) => record.parsed)
+ .filter((parsed) => typeof parsed.issuedAt === "string")
+ .sort((a, b) => String(b.issuedAt).localeCompare(String(a.issuedAt)));
+
+ for (const candidate of candidates) {
+ const statement = parseAccessPolicy(candidate);
+ if (!statement || statement.subject !== ename) continue;
+ const signed: SignedAccessPolicy = {
+ statement,
+ payload: String(candidate.payload ?? ""),
+ signature: String(candidate.signature ?? ""),
+ signer: ename,
+ };
+ if (!(await verifyAccessPolicy(signed, walletVerifier))) continue;
+ return {
+ statement,
+ signed: true,
+ signature: signed.signature,
+ issuedAt: statement.issuedAt,
+ };
+ }
+
+ return fallback;
+}
+
+/** Everything the wallet needs to sign, derived from a draft. */
+export function prepare(
+ statement: AccessPolicyStatement,
+): { statement: AccessPolicyStatement; payload: string } {
+ return { statement, payload: accessPolicyPayload(statement) };
+}
+
+/**
+ * Publishes signed terms into the owner's eVault, world-readable.
+ *
+ * The signature is verified again here before the write. A statement that
+ * cannot be checked must never be stored, or a later reader will drop it and
+ * the owner will believe terms are in force that are not.
+ */
+export async function publish(
+ statement: AccessPolicyStatement,
+ payload: string,
+ signature: string,
+): Promise {
+ const signed: SignedAccessPolicy = {
+ statement,
+ payload,
+ signature,
+ signer: statement.subject,
+ };
+ if (!(await verifyAccessPolicy(signed, walletVerifier))) {
+ throw new Error("The signature over these terms did not verify");
+ }
+ return store_(
+ statement.subject,
+ ACCESS_POLICY_ONTOLOGY,
+ { ...statement, payload, signature },
+ ["*"],
+ );
+}
diff --git a/services/pp-auth-demo/src/lib/server/session.ts b/services/pp-auth-demo/src/lib/server/session.ts
new file mode 100644
index 000000000..6ba2fba00
--- /dev/null
+++ b/services/pp-auth-demo/src/lib/server/session.ts
@@ -0,0 +1,133 @@
+/**
+ * W3DS sign-in, and wallet signing for the owner's terms.
+ *
+ * Both flows are the same shape: we generate a session identifier, the wallet
+ * signs that identifier, and we verify the signature against the registry.
+ *
+ * For the terms the session identifier *is* the canonical policy payload, so
+ * the resulting signature verifies against the statement on its own — anyone
+ * holding the record can check it without trusting this app or its session
+ * store. That is why the terms are worth signing at all.
+ */
+
+import { randomUUID } from "node:crypto";
+// The published dist is CommonJS, which rollup cannot statically analyse for
+// named exports when bundling for SSR. Same workaround as PPA and enotary.
+import { verifySignature } from "signature-validator/src/index";
+import { publicUrl, registryUrl } from "./env";
+
+const TTL_MS = 10 * 60_000;
+
+interface Pending {
+ createdAt: number;
+ kind: "login" | "policy";
+ status: "pending" | "done";
+ ename?: string;
+ signature?: string;
+}
+
+/**
+ * Anchored outside the module graph: the offer, the wallet's callback and the
+ * browser's poll are three separate requests, and Vite's dev SSR can give each
+ * its own copy of a module — which silently splits the map, so a signature
+ * verifies but the page waiting for it never sees it.
+ */
+const STORE = Symbol.for("pp-auth-demo.sessions");
+const store = globalThis as typeof globalThis & { [STORE]?: Map };
+const sessions: Map = (store[STORE] ??= new Map());
+
+function sweep(): void {
+ const now = Date.now();
+ for (const [id, entry] of sessions) {
+ if (now - entry.createdAt > TTL_MS) sessions.delete(id);
+ }
+}
+
+/**
+ * Where the wallet should post back.
+ *
+ * Taken from the request being served, not from configuration. The wallet runs
+ * on a phone, so a callback of `localhost` points it at itself and the login
+ * silently never completes — and a static env var is wrong the moment the app
+ * is reached on a different address than whoever set it had in mind. The
+ * origin the browser used is the one address known to reach this app.
+ */
+function callback(origin: string | undefined, path: string): string {
+ return new URL(path, origin || publicUrl()).toString();
+}
+
+export function createLoginOffer(origin?: string): { uri: string; session: string } {
+ sweep();
+ const session = randomUUID();
+ sessions.set(session, { createdAt: Date.now(), kind: "login", status: "pending" });
+ const redirect = callback(origin, "/api/auth");
+ return {
+ session,
+ uri: `w3ds://auth?redirect=${redirect}&session=${session}&platform=pp-auth-demo`,
+ };
+}
+
+/**
+ * A signing offer whose session id is the payload to be signed. `data` is what
+ * the wallet shows the person before they approve it, so it carries the terms
+ * in readable form.
+ */
+export function createSigningOffer(
+ payload: string,
+ summary: Record,
+ origin?: string,
+): { uri: string; session: string } {
+ sweep();
+ sessions.set(payload, { createdAt: Date.now(), kind: "policy", status: "pending" });
+ const redirect = callback(origin, "/api/sign");
+ const data = Buffer.from(JSON.stringify(summary), "utf8").toString("base64");
+ return {
+ session: payload,
+ uri: `w3ds://sign?session=${encodeURIComponent(payload)}&data=${encodeURIComponent(data)}&redirect_uri=${encodeURIComponent(redirect)}`,
+ };
+}
+
+/** Wallet callback for either flow: verify the signature over the session id. */
+export async function complete(
+ session: string,
+ ename: string,
+ signature: string,
+): Promise<{ ok: boolean; error?: string }> {
+ const pending = sessions.get(session);
+ if (!pending) return { ok: false, error: "unknown or expired session" };
+
+ const result = await verifySignature({
+ eName: ename,
+ signature,
+ payload: session,
+ registryBaseUrl: registryUrl(),
+ });
+ if (!result.valid) {
+ return { ok: false, error: result.error ?? "invalid signature" };
+ }
+
+ pending.ename = ename;
+ pending.signature = signature;
+ pending.status = "done";
+ return { ok: true };
+}
+
+/**
+ * Polled by the page. "unknown" is distinguished from "pending" so an expired
+ * or cross-process session tells the page to start again instead of waiting
+ * forever.
+ */
+export function poll(
+ session: string,
+):
+ | { status: "pending" }
+ | { status: "unknown" }
+ | { status: "done"; ename: string; signature: string } {
+ const pending = sessions.get(session);
+ if (!pending) return { status: "unknown" };
+ if (pending.status === "done" && pending.ename && pending.signature) {
+ sessions.delete(session);
+ return { status: "done", ename: pending.ename, signature: pending.signature };
+ }
+ return { status: "pending" };
+}
diff --git a/services/pp-auth-demo/src/lib/server/token.ts b/services/pp-auth-demo/src/lib/server/token.ts
new file mode 100644
index 000000000..7ea0dba75
--- /dev/null
+++ b/services/pp-auth-demo/src/lib/server/token.ts
@@ -0,0 +1,55 @@
+import { createHmac, timingSafeEqual } from "node:crypto";
+import { jwtSecret } from "./env";
+
+/** Signed session cookie. Nothing sensitive is in it beyond the eName. */
+
+export const COOKIE = "pp_auth_demo_session";
+const MAX_AGE_S = 7 * 24 * 3600;
+
+function sign(value: string): string {
+ return createHmac("sha256", jwtSecret()).update(value).digest("base64url");
+}
+
+export function mint(ename: string): string {
+ const body = Buffer.from(
+ JSON.stringify({ ename, exp: Date.now() + MAX_AGE_S * 1000 }),
+ "utf8",
+ ).toString("base64url");
+ return `${body}.${sign(body)}`;
+}
+
+export function read(token: string | undefined): { ename: string } | null {
+ if (!token) return null;
+ const [body, signature] = token.split(".");
+ if (!body || !signature) return null;
+ const expected = sign(body);
+ if (
+ expected.length !== signature.length ||
+ !timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
+ ) {
+ return null;
+ }
+ try {
+ const claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
+ if (typeof claims.ename !== "string" || Date.now() > claims.exp) return null;
+ return { ename: claims.ename };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * `secure` follows the actual scheme rather than SvelteKit's default, which
+ * sets it for any non-localhost host. Over plain HTTP on a LAN address — how
+ * this is reached from a phone — a Secure cookie is silently dropped and the
+ * login appears to succeed on the server while the browser never advances.
+ */
+export function cookieOptions(url: URL) {
+ return {
+ path: "/",
+ httpOnly: true,
+ sameSite: "lax" as const,
+ secure: url.protocol === "https:",
+ maxAge: MAX_AGE_S,
+ };
+}
diff --git a/services/pp-auth-demo/src/routes/+layout.server.ts b/services/pp-auth-demo/src/routes/+layout.server.ts
new file mode 100644
index 000000000..5c5a9f1e0
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/+layout.server.ts
@@ -0,0 +1,6 @@
+import type { LayoutServerLoad } from "./$types";
+
+export const load: LayoutServerLoad = async ({ locals, url }) => ({
+ user: locals.user,
+ pathname: url.pathname,
+});
diff --git a/services/pp-auth-demo/src/routes/+layout.svelte b/services/pp-auth-demo/src/routes/+layout.svelte
new file mode 100644
index 000000000..20027148a
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/+layout.svelte
@@ -0,0 +1,53 @@
+
+
+
diff --git a/services/pp-auth-demo/src/routes/+page.server.ts b/services/pp-auth-demo/src/routes/+page.server.ts
new file mode 100644
index 000000000..725ee09e0
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/+page.server.ts
@@ -0,0 +1,6 @@
+import { redirect } from "@sveltejs/kit";
+import type { PageServerLoad } from "./$types";
+
+export const load: PageServerLoad = async () => {
+ throw redirect(302, "/platforms");
+};
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..1112445c2
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/acl/+page.server.ts
@@ -0,0 +1,75 @@
+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 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;
+
+ 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: domains.map((entry) => {
+ const domain = entry.id;
+ const grant = grants.find(
+ (held) =>
+ held.granteeEName === record.platformEName &&
+ held.resourceType === domain,
+ );
+ const active = grant && grant.status === "active";
+ return {
+ 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"),
+ 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..9f68d6a09
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/acl/+page.svelte
@@ -0,0 +1,73 @@
+
+
+
+
+
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. Ask for
+ something on this platform's behalf and see what happens — and what comes
+ back out of your eVault when it is allowed.
+
+
+
+ {#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.
+
+ {#each platform.deployments as deployment (deployment.ename)}
+
+ {/each}
+
+
+
+ {:else}
+
+ 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/auth/+server.ts b/services/pp-auth-demo/src/routes/api/auth/+server.ts
new file mode 100644
index 000000000..50ccc30bb
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/auth/+server.ts
@@ -0,0 +1,22 @@
+import { json } from "@sveltejs/kit";
+import { complete } from "$lib/server/session";
+import type { RequestHandler } from "./$types";
+
+/** Wallet callback for w3ds://auth. Field names vary by wallet build. */
+export const POST: RequestHandler = async ({ request }) => {
+ const body = (await request.json().catch(() => ({}))) as Record;
+ const session = String(body.session ?? body.sessionId ?? "");
+ const ename = String(body.ename ?? body.w3id ?? body.eName ?? "");
+ const signature = String(body.signature ?? "");
+
+ if (!session || !ename || !signature) {
+ return json({ error: "session, ename and signature are required" }, { status: 400 });
+ }
+
+ const result = await complete(session, ename, signature);
+ if (!result.ok) {
+ console.warn("[pp-auth-demo/auth] rejected:", result.error);
+ return json({ error: result.error }, { status: 401 });
+ }
+ return json({ ok: true });
+};
diff --git a/services/pp-auth-demo/src/routes/api/auth/logout/+server.ts b/services/pp-auth-demo/src/routes/api/auth/logout/+server.ts
new file mode 100644
index 000000000..c265a3350
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/auth/logout/+server.ts
@@ -0,0 +1,8 @@
+import { redirect } from "@sveltejs/kit";
+import { COOKIE, cookieOptions } from "$lib/server/token";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ cookies, url }) => {
+ cookies.delete(COOKIE, cookieOptions(url));
+ throw redirect(303, "/login");
+};
diff --git a/services/pp-auth-demo/src/routes/api/auth/offer/+server.ts b/services/pp-auth-demo/src/routes/api/auth/offer/+server.ts
new file mode 100644
index 000000000..cc3ed86b1
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/auth/offer/+server.ts
@@ -0,0 +1,6 @@
+import { json } from "@sveltejs/kit";
+import { createLoginOffer } from "$lib/server/session";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ url }) =>
+ json(createLoginOffer(url.origin));
diff --git a/services/pp-auth-demo/src/routes/api/auth/session/[session]/+server.ts b/services/pp-auth-demo/src/routes/api/auth/session/[session]/+server.ts
new file mode 100644
index 000000000..f42046c2f
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/auth/session/[session]/+server.ts
@@ -0,0 +1,16 @@
+import { json } from "@sveltejs/kit";
+import { poll } from "$lib/server/session";
+import { COOKIE, cookieOptions, mint } from "$lib/server/token";
+import type { RequestHandler } from "./$types";
+
+/** Polled by the login page until the wallet has answered. */
+export const GET: RequestHandler = async ({ params, cookies, url }) => {
+ const result = poll(params.session);
+ if (result.status === "unknown") {
+ return json({ status: "unknown" }, { status: 410 });
+ }
+ if (result.status !== "done") return json({ status: "pending" });
+
+ cookies.set(COOKIE, mint(result.ename), cookieOptions(url));
+ return json({ status: "authenticated", ename: result.ename });
+};
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/key/+server.ts b/services/pp-auth-demo/src/routes/api/key/+server.ts
new file mode 100644
index 000000000..4efcfafc0
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/key/+server.ts
@@ -0,0 +1,26 @@
+import { json } from "@sveltejs/kit";
+import { forget, remember } from "$lib/server/keys";
+import type { RequestHandler } from "./$types";
+
+/**
+ * Accepts a deployment's private key so the possession link can be proved.
+ *
+ * Held in memory for this process only — never written to disk, never logged,
+ * gone on restart. It is accepted at all because whoever is running this holds
+ * these deployments, and supplying the key is how they demonstrate the one
+ * link that reading public records cannot establish.
+ */
+export const POST: RequestHandler = async ({ request }) => {
+ const { deploymentEname, privateKey } = (await request.json()) as {
+ deploymentEname?: string;
+ privateKey?: string;
+ };
+ if (!deploymentEname) return json({ error: "deploymentEname is required" }, { status: 400 });
+
+ if (!privateKey?.trim()) {
+ forget(deploymentEname);
+ return json({ keyHeld: false });
+ }
+ remember(deploymentEname, privateKey);
+ return json({ keyHeld: true });
+};
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..52f870f4c
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/request/+server.ts
@@ -0,0 +1,116 @@
+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";
+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;
+ text?: 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,
+ });
+
+ 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 () =>
+ json({ error: "POST a deployment, domain and operation" }, { status: 405 });
diff --git a/services/pp-auth-demo/src/routes/api/sign/+server.ts b/services/pp-auth-demo/src/routes/api/sign/+server.ts
new file mode 100644
index 000000000..7f65deef9
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/sign/+server.ts
@@ -0,0 +1,41 @@
+import { json } from "@sveltejs/kit";
+import { complete } from "$lib/server/session";
+import type { RequestHandler } from "./$types";
+
+/**
+ * Wallet callback for w3ds://sign.
+ *
+ * The wallet signs the session id and posts it back as `message`. For the
+ * owner's terms that session id is the canonical payload of the statement, so
+ * this signature is over the terms themselves — the page polling
+ * /api/terms/status is what turns it into a published record.
+ *
+ * Field names vary between wallet builds, so accept the shapes in use.
+ */
+export const POST: RequestHandler = async ({ request }) => {
+ const body = (await request.json().catch(() => ({}))) as Record;
+ const session = String(body.sessionId ?? body.session ?? "");
+ const ename = String(body.w3id ?? body.ename ?? body.eName ?? "");
+ const signature = String(body.signature ?? "");
+
+ if (!session || !ename || !signature) {
+ return json(
+ { error: "sessionId, w3id and signature are required" },
+ { status: 400 },
+ );
+ }
+
+ // The wallet echoes what it signed; if it disagrees with the session we are
+ // tracking, something has been substituted along the way.
+ const message = body.message === undefined ? session : String(body.message);
+ if (message !== session) {
+ return json({ error: "signed payload does not match the session" }, { status: 400 });
+ }
+
+ const result = await complete(session, ename, signature);
+ if (!result.ok) {
+ console.warn("[pp-auth-demo/sign] rejected:", result.error);
+ return json({ error: result.error }, { status: 401 });
+ }
+ return json({ ok: true });
+};
diff --git a/services/pp-auth-demo/src/routes/api/terms/+server.ts b/services/pp-auth-demo/src/routes/api/terms/+server.ts
new file mode 100644
index 000000000..919e60445
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/terms/+server.ts
@@ -0,0 +1,57 @@
+import { json } from "@sveltejs/kit";
+import {
+ CERTIFICATION_LEVELS,
+ defaultAccessPolicy,
+ 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";
+
+/**
+ * Turns a draft into a statement and asks the wallet to sign it.
+ *
+ * The signing session id is the canonical payload itself, so what the wallet
+ * signs is exactly the digest of these terms — the resulting signature stands
+ * on its own, without anyone having to trust this app's session store.
+ */
+export const POST: RequestHandler = async ({ request, locals, url }) => {
+ const body = (await request.json()) as Record;
+ const ename = locals.user!.ename;
+
+ const level = String(body.minimumLevel ?? "") as CertificationLevel;
+ if (!CERTIFICATION_LEVELS.includes(level)) {
+ return json({ error: "Pick a level" }, { status: 400 });
+ }
+ const strings = (value: unknown): string[] =>
+ Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
+
+ const statement = {
+ ...defaultAccessPolicy(ename),
+ minimumLevel: level,
+ // 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(),
+ nonce: randomUUID(),
+ };
+
+ const prepared = prepare(statement);
+ const offer = createSigningOffer(
+ prepared.payload,
+ {
+ message: "Set the terms platforms must meet to reach your data",
+ minimumLevel: statement.minimumLevel,
+ reputationFrom: statement.reputationEngine,
+ refused: statement.deniedDomains.length ? statement.deniedDomains : "nothing",
+ },
+ url.origin,
+ );
+
+ return json({ statement, payload: prepared.payload, uri: offer.uri });
+};
diff --git a/services/pp-auth-demo/src/routes/api/terms/status/+server.ts b/services/pp-auth-demo/src/routes/api/terms/status/+server.ts
new file mode 100644
index 000000000..885f87e84
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/terms/status/+server.ts
@@ -0,0 +1,42 @@
+import { json } from "@sveltejs/kit";
+import { parseAccessPolicy } from "@metastate-foundation/auth/platform";
+import { publish } from "$lib/server/policy";
+import { poll } from "$lib/server/session";
+import type { RequestHandler } from "./$types";
+
+/**
+ * Polled while the wallet is deciding. Once it signs, the terms are published
+ * into the owner's own eVault — the signature is checked again before the
+ * write, so terms that cannot be verified never become the record.
+ */
+export const POST: RequestHandler = async ({ request, locals }) => {
+ const { payload, statement } = (await request.json()) as {
+ payload?: string;
+ statement?: unknown;
+ };
+ if (!payload) return json({ error: "payload is required" }, { status: 400 });
+
+ const result = poll(payload);
+ if (result.status !== "done") return json({ status: result.status });
+
+ if (result.ename !== locals.user!.ename) {
+ return json(
+ { status: "rejected", error: "Those terms were signed by a different person." },
+ { status: 403 },
+ );
+ }
+
+ const parsed = parseAccessPolicy(statement);
+ if (!parsed) return json({ status: "rejected", error: "Malformed terms" }, { status: 400 });
+
+ try {
+ const id = await publish(parsed, payload, result.signature);
+ return json({ status: "published", id });
+ } catch (error) {
+ console.error("[pp-auth-demo/terms] publish failed:", error);
+ return json(
+ { status: "rejected", error: error instanceof Error ? error.message : "failed" },
+ { status: 500 },
+ );
+ }
+};
diff --git a/services/pp-auth-demo/src/routes/api/verify/+server.ts b/services/pp-auth-demo/src/routes/api/verify/+server.ts
new file mode 100644
index 000000000..33dc2467e
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/api/verify/+server.ts
@@ -0,0 +1,33 @@
+import { json } from "@sveltejs/kit";
+import { assemble, verify } from "$lib/server/chain";
+import { deployments } from "$lib/server/aaas";
+import { keyFor } from "$lib/server/keys";
+import type { RequestHandler } from "./$types";
+
+/**
+ * Verifies one real deployment's chain of trust, now.
+ *
+ * Every link is checked against evidence read from the network at request
+ * time. Possession is only provable when this app has been given that
+ * deployment's private key; otherwise it fails and says why, which is the
+ * correct outcome rather than a gap to paper over.
+ */
+export const POST: RequestHandler = async ({ request, locals }) => {
+ const { deploymentEname } = (await request.json()) as { deploymentEname?: string };
+ const all = await deployments();
+ const deployment = all.find((d) => d.deploymentEname === deploymentEname);
+ if (!deployment) return json({ error: "Unknown deployment" }, { status: 404 });
+
+ const assembled = await assemble(deployment);
+ if (!assembled.evidence) {
+ return json({ chain: null, missing: assembled.missing, possessionProven: false });
+ }
+
+ const { chain, possessionProven } = await verify(
+ assembled.evidence,
+ locals.user!.ename,
+ keyFor(deployment.deploymentEname),
+ );
+
+ return json({ chain, missing: [], possessionProven });
+};
diff --git a/services/pp-auth-demo/src/routes/data/+page.server.ts b/services/pp-auth-demo/src/routes/data/+page.server.ts
new file mode 100644
index 000000000..159e1b88f
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/data/+page.server.ts
@@ -0,0 +1,65 @@
+import { authorize, type PlatformClaim } from "@metastate-foundation/auth/platform";
+import { accreditations, platformProfile } from "$lib/server/aaas";
+import { ownedByDomain } from "$lib/server/data";
+import { currentPolicy } from "$lib/server/policy";
+import type { PageServerLoad } from "./$types";
+
+/**
+ * The owner's own records, and — for each certified platform — what it would
+ * be allowed to reach and what it would be refused.
+ *
+ * The decisions here are the real ones: the real certificate's domains, the
+ * owner's real signed terms, and the same `authorize` an eVault would call.
+ * What is not being claimed is that these platforms have asked; this is what
+ * would happen if they did.
+ */
+export const load: PageServerLoad = async ({ locals }) => {
+ const ename = locals.user!.ename;
+
+ const [groups, policy, records] = await Promise.all([
+ ownedByDomain(ename).catch((error) => {
+ console.error("[pp-auth-demo/data] could not read records:", error);
+ return [];
+ }),
+ currentPolicy(ename),
+ accreditations(),
+ ]);
+
+ // Newest granted decision per platform.
+ 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 domainIds = groups.map((group) => group.id);
+
+ const platforms = await Promise.all(
+ [...granted.values()].map(async (record) => {
+ const profile = await platformProfile(record.platformEName);
+ const claim: PlatformClaim = {
+ platformEname: record.platformEName,
+ platformName: profile?.displayName || record.platformName,
+ deploymentEname: "",
+ version: record.platformVersion,
+ level: (record.level ?? "L0") as PlatformClaim["level"],
+ domains: record.domains ?? [],
+ deployerEname: "",
+ reviewedByEName: record.reviewedByEName,
+ };
+ return {
+ ename: record.platformEName,
+ name: claim.platformName,
+ level: record.level,
+ version: record.platformVersion,
+ certifiedDomains: record.domains ?? [],
+ decisions: domainIds.map((domain) => ({
+ domain,
+ ...authorize(policy.statement, { claim, domain }),
+ })),
+ };
+ }),
+ );
+
+ return { ename, groups, platforms, policy };
+};
diff --git a/services/pp-auth-demo/src/routes/data/+page.svelte b/services/pp-auth-demo/src/routes/data/+page.svelte
new file mode 100644
index 000000000..7dea72672
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/data/+page.svelte
@@ -0,0 +1,111 @@
+
+
+
+
+
Your data
+
What is in your eVault
+
+ Your own records, grouped the way the ontology groups them. That grouping is
+ what a certificate is written against, so it is also what decides which
+ platform can see which of these.
+
+ This reads your own eVault, so it needs to know it is you.
+
+
+
+ {#if uri}
+
+
+
+ {:else if error}
+
{error}
+
+ {:else}
+
Preparing a code…
+ {/if}
+
+
diff --git a/services/pp-auth-demo/src/routes/platforms/+page.server.ts b/services/pp-auth-demo/src/routes/platforms/+page.server.ts
new file mode 100644
index 000000000..3bdb56b8d
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/platforms/+page.server.ts
@@ -0,0 +1,102 @@
+import { accreditations, deployments, isConfigured, platformProfile } from "$lib/server/aaas";
+import { accreditationFor } from "$lib/server/chain";
+import { held } from "$lib/server/keys";
+import type { PageServerLoad } from "./$types";
+
+export interface PlatformView {
+ ename: string;
+ name: string;
+ description: string;
+ currentVersion: string;
+ logoUrl: string | null;
+ deployments: Array<{
+ ename: string;
+ name: string;
+ environment: string;
+ version: string;
+ releaseTag: string;
+ commitSha: string;
+ deployerEname: string;
+ publicKey: string;
+ /** The decision covering this deployment's exact version. */
+ certified: { level: string | null; domains: string[]; decision: string } | null;
+ keyHeld: boolean;
+ }>;
+}
+
+/**
+ * Every platform the network knows about that has at least one deployment or
+ * one certification decision. Nothing is seeded: an empty page means nothing
+ * has been deployed or certified yet, which is a true statement about the
+ * network rather than a failure of this app.
+ */
+export const load: PageServerLoad = async () => {
+ if (!isConfigured()) {
+ return { configured: false, platforms: [] as PlatformView[], error: null };
+ }
+
+ try {
+ const [allDeployments, allAccreditations] = await Promise.all([
+ deployments(),
+ accreditations(),
+ ]);
+
+ const enames = new Set([
+ ...allDeployments.map((d) => d.platformEname),
+ ...allAccreditations.map((a) => a.platformEName),
+ ]);
+
+ const withKeys = new Set(held());
+
+ const platforms = await Promise.all(
+ [...enames].map(async (ename): Promise => {
+ const profile = await platformProfile(ename);
+ const mine = allDeployments
+ .filter((d) => d.platformEname === ename)
+ .sort((a, b) => a.environment.localeCompare(b.environment));
+ return {
+ ename,
+ name: profile?.displayName || profile?.platformName || ename,
+ description: profile?.description ?? "",
+ currentVersion: profile?.version ?? "",
+ logoUrl: profile?.logoUrl ?? null,
+ deployments: mine.map((deployment) => {
+ const decision = accreditationFor(
+ allAccreditations,
+ ename,
+ deployment.version,
+ );
+ return {
+ ename: deployment.deploymentEname,
+ name: deployment.deploymentName,
+ environment: deployment.environment,
+ version: deployment.version,
+ releaseTag: deployment.releaseTag,
+ commitSha: deployment.commitSha,
+ deployerEname: deployment.deployerEname,
+ publicKey: deployment.publicKey,
+ keyHeld: withKeys.has(deployment.deploymentEname),
+ certified: decision
+ ? {
+ level: decision.level,
+ domains: decision.domains ?? [],
+ decision: decision.decision,
+ }
+ : null,
+ };
+ }),
+ };
+ }),
+ );
+
+ platforms.sort((a, b) => b.deployments.length - a.deployments.length);
+ return { configured: true, platforms, error: null };
+ } catch (error) {
+ console.error("[pp-auth-demo/platforms] load failed:", error);
+ return {
+ configured: true,
+ platforms: [] as PlatformView[],
+ error: error instanceof Error ? error.message : "could not read the network",
+ };
+ }
+};
diff --git a/services/pp-auth-demo/src/routes/platforms/+page.svelte b/services/pp-auth-demo/src/routes/platforms/+page.svelte
new file mode 100644
index 000000000..4b72e424d
--- /dev/null
+++ b/services/pp-auth-demo/src/routes/platforms/+page.svelte
@@ -0,0 +1,82 @@
+
+
+
+
+
Platforms
+
+ Every platform running on the network
+
+
+ Read live from the network — the platforms, their releases, the deployments
+ actually running them, and the decisions the association has issued. Check
+ any deployment and it proves what it is, from scratch, against records
+ anyone can read.
+
+
+
+ {#if !data.configured}
+
+
+ This app has no key for the awareness network, so it cannot see what is
+ out there. Set PPA_AWARENESS_API_KEY and reload.
+
+
+ {:else if data.error}
+
+
Could not read the network: {data.error}
+
+ {:else if data.platforms.length === 0}
+
+
+ Nothing has been deployed or certified yet. This page fills in on its own
+ once a platform ships a release and the association decides on it.
+
+ The association says what a platform was found to be. You decide what that
+ is worth. You sign your answers with your wallet and they are kept in your
+ own eVault, so they travel with you and anyone can check them — including a
+ platform working out whether it is worth asking.
+
+ {#if data.policy.signed}
+
+ Signed on {new Date(data.policy.issuedAt ?? "").toLocaleString()}.
+
+ {:else}
+
+ You have not set any terms yet, so the default applies: nothing below
+ {data.policy.statement.minimumLevel}.
+
+ {/if}
+
+
+ {#key data.policy.statement.nonce}
+
+ {/key}
+
+ {#if data.policy.signed}
+
+
+
+ The statement you signed
+
+
diff --git a/services/pp-auth-demo/src/svelte-qrcode.d.ts b/services/pp-auth-demo/src/svelte-qrcode.d.ts
new file mode 100644
index 000000000..cb853dae7
--- /dev/null
+++ b/services/pp-auth-demo/src/svelte-qrcode.d.ts
@@ -0,0 +1,20 @@
+/**
+ * svelte-qrcode ships no type declarations — its package exports only the
+ * `svelte` condition pointing at raw component source. Declare the props we
+ * use so `svelte-check` can see the component.
+ */
+declare module "svelte-qrcode" {
+ import type { Component } from "svelte";
+
+ const QrCode: Component<{
+ value?: string;
+ size?: string | number;
+ color?: string;
+ background?: string;
+ padding?: number;
+ errorCorrection?: "L" | "M" | "Q" | "H";
+ className?: string;
+ }>;
+
+ export default QrCode;
+}
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()],
+});
diff --git a/services/ppa/config/certification-framework.json b/services/ppa/config/certification-framework.json
index c4bc4f521..c6b02a6b8 100644
--- a/services/ppa/config/certification-framework.json
+++ b/services/ppa/config/certification-framework.json
@@ -1,6 +1,6 @@
{
- "$comment": "The PPA application certification matrix, transcribed from 'Post-Platforms Certification Framework — Application Certification Framework Concept v2'. The document calls its quantitative thresholds provisional policy parameters, so this file is versioned and every assessment records the version that judged it. Each dimension lists its distinct requirement texts; `level` is the highest certification level that requirement satisfies, so a repeated requirement collapses to one option. `source: derived` means the app answers the row itself, from the release proof, the actors’ binding documents, attested deployments or signed eReputation references.",
- "frameworkVersion": "2",
+ "$comment": "The PPA application certification matrix, transcribed from 'Post-Platforms Certification Framework — Application Certification Framework Concept v2'. The document calls its quantitative thresholds provisional policy parameters, so this file is versioned and every assessment records the version that judged it. Each dimension lists its distinct requirement texts; `level` is the highest certification level that requirement satisfies, so a repeated requirement collapses to one option. `source: derived` means the app answers the row itself, from the release proof, the actors’ binding documents or signed eReputation references.",
+ "frameworkVersion": "3",
"levels": [
{
"id": "L0",
@@ -351,37 +351,6 @@
}
]
},
- {
- "id": "deployment-assurance",
- "label": "Deployment assurance",
- "source": "derived",
- "options": [
- {
- "level": 0,
- "label": "Nothing ties what is running to this release"
- },
- {
- "level": 1,
- "label": "The team states the deployment matches the release"
- },
- {
- "level": 2,
- "label": "Version records line up with the release"
- },
- {
- "level": 3,
- "label": "A signed deployment attests to this exact release"
- },
- {
- "level": 4,
- "label": "Signed artefacts or hashes match, with deployment logs"
- },
- {
- "level": 5,
- "label": "A reproducible build matches the deployed artefact"
- }
- ]
- },
{
"id": "key-assurance",
"label": "Key / infrastructure assurance",
diff --git a/services/ppa/src/lib/AssessmentMatrix.svelte b/services/ppa/src/lib/AssessmentMatrix.svelte
index 0380e27cc..c605375fe 100644
--- a/services/ppa/src/lib/AssessmentMatrix.svelte
+++ b/services/ppa/src/lib/AssessmentMatrix.svelte
@@ -78,8 +78,9 @@
Assessment
- The level is the weakest of these dimensions — a strong result in one
- does not make up for a weakness in another. Framework v{framework.frameworkVersion}.
+ Every dimension counts. The level is their geometric mean, so a weak row
+ pulls the result down far more than an average would, without one row
+ pinning the rest. Framework v{framework.frameworkVersion}.
@@ -104,13 +105,18 @@
An unanswered dimension counts as no evidence.
+ {:else if result.blocked && limitingLabel}
+
+ {limitingLabel}
+ is unanswered or fails outright, so no level can be awarded.
+
{:else if limitingLabel}
- Held at {result.level ?? "no level"} by
+ Weakest row:
{limitingLabel}.
- Raising that one row is what raises the level.
+ It drags the result hardest, but every row moves it.
- 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..015c3c011 100644
--- a/services/ppa/src/lib/levels.spec.ts
+++ b/services/ppa/src/lib/levels.spec.ts
@@ -79,7 +79,7 @@ describe("computeLevel", () => {
it("still weighs several weak rows heavily", () => {
// Two rows at L0 and three at L1, against a spread up to L5.
- const weak = ["functional-review", "deployment-assurance"];
+ const weak = ["functional-review", "code-review"];
const weaker = ["provenance", "actor-reputation", "key-assurance"];
const answers = [
...allAt(5).filter(
@@ -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..6c0294800 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. */
@@ -96,7 +102,7 @@ export interface ComputedLevel {
* an otherwise strong release to its own value the way a strict minimum did.
*
* The mean is taken over level + 1 and shifted back afterwards. L0 is a real,
- * expected answer on this scale (deployment assurance of "None" is L0), and a
+ * expected answer on this scale (a code review nobody performed is L0), and a
* plain geometric mean multiplies by zero, so a single such row would collapse
* the score to zero no matter how strong the other fifteen were.
*
@@ -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,
diff --git a/services/ppa/src/lib/server/aaas.ts b/services/ppa/src/lib/server/aaas.ts
index 3cff54133..5a8cbb274 100644
--- a/services/ppa/src/lib/server/aaas.ts
+++ b/services/ppa/src/lib/server/aaas.ts
@@ -611,53 +611,3 @@ export async function currentAccreditations(): Promise